深入了解Go語言中database/sql是如何設(shè)計的
常見的關(guān)系型數(shù)據(jù)庫都支持標(biāo)準(zhǔn)的 SQL 語言,所以無論是 MySQL、PostgreSQL 還是 SQL Server,我們都可以使用相同的 SQL 語句來對其進(jìn)行操作。這種思想同樣體現(xiàn)在 Go 語言的數(shù)據(jù)庫操作中,在 Go 語言中內(nèi)置了 database/sql 包,它只對外暴露了一套統(tǒng)一的編程接口,便可以操作不同數(shù)據(jù)庫。
本文重點講解 database/sql 設(shè)計思想,默認(rèn)讀者已經(jīng)有了 database/sql 使用經(jīng)驗,對于 database/sql 功能則不會詳細(xì)介紹。如果你對 database/sql 不熟悉,可以查看我的另一篇文章《在 Go 中如何使用 database/sql 來操作數(shù)據(jù)庫》。
接口設(shè)計
首先我們來看下 database/sql 目錄結(jié)構(gòu)長什么樣:
go1.20.1/src/database
└── sql
├── driver
│ ├── driver.go
│ └── types.go
├── convert.go
├── ctxutil.go
├── sql.go
...
筆記:這里沒有列出測試文件。
可以發(fā)現(xiàn),database/sql 實際上包含了 sql 包及其子包 driver。
sql 包為我們提供了操作數(shù)據(jù)庫的對象以及方法,driver 包則定義了數(shù)據(jù)庫驅(qū)動的編程接口,這些接口都是第三方驅(qū)動包需要實現(xiàn)的。
現(xiàn)在我們一起來看下 driver 包是如何設(shè)計的。
在 driver 包中定義了一個 Driver 接口:
type Driver interface {
Open(name string) (Conn, error)
}這個接口只有一個 Open 方法,用來建立一個數(shù)據(jù)庫連接并返回。
Open 方法的 name 參數(shù)即為 DSN,返回值中的 Conn 接口則代表了一個數(shù)據(jù)庫連接,定義如下:
type Conn interface {
Prepare(query string) (Stmt, error)
Close() error
Begin() (Tx, error)
}Conn 接口包含三個方法:
Prepare 用來預(yù)處理 SQL,返回一個準(zhǔn)備好的 SQL 語句。
Close 用來關(guān)閉數(shù)據(jù)庫連接。
Begin 顯然是對事務(wù)的支持。
其中 Prepare 返回 Stmt 類型,這也是一個接口,定義如下:
type Stmt interface {
Close() error
NumInput() int
Exec(args []Value) (Result, error)
Query(args []Value) (Rows, error)
}Close 用來關(guān)閉該預(yù)處理語句。
NumInput 返回 SQL 中占位符參數(shù)的數(shù)量。
Exec 和 Query 兩個方法我們再熟悉不過了,分別用來執(zhí)行 SQL 命令以及查詢記錄。這兩個方法都接收參數(shù) []Value,Value 其實是 any 類型,也就是 interface{},定義如下:
type Value any
Exec 方法返回的 Result 接口定義如下:
type Result interface {
LastInsertId() (int64, error)
RowsAffected() (int64, error)
}LastInsertId 返回 INSERT SQL 插入記錄的 ID。
RowsAffected 返回受影響記錄的行數(shù)。
Query 方法返回的 Rows 接口定義如下:
type Rows interface {
Columns() []string
Close() error
Next(dest []Value) error
}當(dāng)我們執(zhí)行 SQL 查詢時,如果不知道列名,可以使用 rows.Columns() 查看所有列名稱列表。
Close 用來關(guān)閉 Rows 的迭代器,關(guān)閉后無法再繼續(xù)調(diào)用 Next 查詢下一條記錄。
調(diào)用 Next 可以將下一行數(shù)據(jù)填充到提供的 dest 切片中。
Value 在上面已經(jīng)介紹過了,是 any 類型。
現(xiàn)在 Conn 接口中定義的 Prepare 方法這條線所涉及到的類型,我們已經(jīng)追查到底了,是時候回過頭來看下 Begin 方法返回的 Tx 類型定義了:
type Tx interface {
Commit() error
Rollback() error
}Tx 不出所料,同樣是一個接口,包含兩個方法:
Commit 用來提交事務(wù)。
Rollback 用來回滾事務(wù)。
至此,Driver 接口的設(shè)計就清晰的擺在眼前了:

除了 Driver 接口,在 database/sql/driver 包中,還有幾個常用接口定義如下:
type Connector interface {
Connect(context.Context) (Conn, error)
Driver() Driver
}
type Pinger interface {
Ping(ctx context.Context) error
}
type Execer interface {
Exec(query string, args []Value) (Result, error)
}
type ExecerContext interface {
ExecContext(ctx context.Context, query string, args []NamedValue) (Result, error)
}
type Queryer interface {
Query(query string, args []Value) (Rows, error)
}
type QueryerContext interface {
QueryContext(ctx context.Context, query string, args []NamedValue) (Rows, error)
}Connector 接口用來連接數(shù)據(jù)庫。
Pinger 接口用來檢查連接是否能被正確建立。
還有 Execer、ExecerContext、Queryer、QueryerContext 這 4 個接口,正好對應(yīng)了我們在利用 database/sql 時操作數(shù)據(jù)庫所使用的方法。
所有這些接口,都是第三方數(shù)據(jù)庫驅(qū)動包要實現(xiàn)的接口(有些接口是可選的)。
看到這里,你可能有個疑惑,為什么這些接口都只定義為只有一個方法的小接口?
這其實是 Go 語言中的慣用法,越小的接口抽象程度越高,易于解耦,也越容易被實現(xiàn),并且非常適用于 Go 語言的組合機制。
好了,關(guān)于 driver 包中接口的定義部分就講解到這里,其他用的比較少接口的我就不在這里介紹了,感興趣的同學(xué)可以自行嘗試閱讀源碼學(xué)習(xí)。
以上介紹的這些接口全部定義在 driver/driver.go 文件中,而 driver/types.go 文件中則用來定義類型,如 Bool、Int32 等方便用來類型轉(zhuǎn)換,由于不是本文重點,這里也就不多介紹了。
代碼實現(xiàn)
看了以上關(guān)于接口定義的講解,你可能會覺得有些云里霧里,有種學(xué)了一身功夫卻又無從下手的感覺。
沒關(guān)系,接下來我將根據(jù)一段示例代碼,帶你深入到 database/sql 的源碼中,加深你對 database/sql 包的理解。
以下示例是我們使用 database/sql 操作 MySQL 最典型的場景:
package main
import (
"database/sql"
"fmt"
_ "github.com/go-sql-driver/mysql"
)
func main() {
db, err := sql.Open("mysql", "user:password@tcp(127.0.0.1:3306)/demo?charset=utf8mb4&parseTime=true&loc=Local")
if err != nil {
panic(err.Error())
}
defer db.Close()
rows, err := db.Query("SELECT id, name FROM user")
if err != nil {
panic(err.Error())
}
defer rows.Close()
for rows.Next() {
var (
id int
name string
)
if err := rows.Scan(id, name); err != nil {
panic(err.Error())
}
fmt.Printf("id: %d, name: %s\n", id, name)
}
}這段代碼最讓初學(xué)者摸不著頭腦的是我們以匿名的方式導(dǎo)入了 MySQL 驅(qū)動包:
import _ "github.com/go-sql-driver/mysql"
但在實際的代碼中并沒有使用它。
其實,這看似有些奇怪的代碼導(dǎo)入的作用,就隱藏在 go-sql-driver/mysql 包的 init 函數(shù)中:
import "database/sql"
func init() {
sql.Register("mysql", &MySQLDriver{})
}go-sql-driver/mysql 包導(dǎo)入并使用 database/sql 包的 sql.Register 函數(shù),將自己實現(xiàn)的驅(qū)動程序注冊到 database/sql 中。
調(diào)用注冊函數(shù)的代碼寫在了 init 函數(shù)中,go-sql-driver/mysql 包正是利用了匿名導(dǎo)入時 Go 語言會自動調(diào)用被導(dǎo)入包的 init 方法所產(chǎn)生的副作用,來實現(xiàn)驅(qū)動注冊。
注冊驅(qū)動函數(shù) sql.Register 實現(xiàn)如下:
var (
driversMu sync.RWMutex
drivers = make(map[string]driver.Driver)
)
func Register(name string, driver driver.Driver) {
driversMu.Lock()
defer driversMu.Unlock()
if driver == nil {
panic("sql: Register driver is nil")
}
if _, dup := drivers[name]; dup {
panic("sql: Register called twice for driver " + name)
}
drivers[name] = driver
}可以發(fā)現(xiàn),Register 內(nèi)部通過全局互斥鎖變量 driversMu 保證了并發(fā)操作的安全性。在加鎖的條件下,將 mysql 驅(qū)動保存在 drivers 這個全局的 map 類型變量中,以 mysql 為 key,驅(qū)動對象為 value。
這就是為什么,我們能夠使用 sql.Open 函數(shù)與數(shù)據(jù)庫建立連接的原因。
func Open(driverName, dataSourceName string) (*DB, error) {
driversMu.RLock()
driveri, ok := drivers[driverName]
driversMu.RUnlock()
if !ok {
return nil, fmt.Errorf("sql: unknown driver %q (forgotten import?)", driverName)
}
if driverCtx, ok := driveri.(driver.DriverContext); ok {
connector, err := driverCtx.OpenConnector(dataSourceName)
if err != nil {
return nil, err
}
return OpenDB(connector), nil
}
return OpenDB(dsnConnector{dsn: dataSourceName, driver: driveri}), nil
}Open 函數(shù)接收兩個參數(shù),驅(qū)動名稱和 DSN。
在 Open 函數(shù)內(nèi)部,首先從全局變量 drivers 中獲取驅(qū)動對象。而我們調(diào)用 sql.Open 函數(shù)時,傳遞的第一個參數(shù)是 mysql,這剛好與 go-sql-driver/mysql 包中注冊的驅(qū)動名稱對應(yīng),所以能夠獲取到 MySQL 驅(qū)動程序。
接著,代碼中通過類型斷言,判斷驅(qū)動對象 driveri 是否為 driver.DriverContext 類型。
是的話就先調(diào)用驅(qū)動對象的 OpenConnector 方法得到 Connector 類型的對象,然后再使用 OpenDB 打開數(shù)據(jù)庫連接。
driver.DriverContext 接口定義如下:
type DriverContext interface {
OpenConnector(name string) (Connector, error)
}只包含了 OpenConnector 方法,這個方法返回 Connector 接口類型。
Connector 接口前文已經(jīng)講過,我們可以再回顧下它的定義:
type Connector interface {
Connect(context.Context) (Conn, error)
Driver() Driver
}這個接口定義了兩個方法分別用來連接數(shù)據(jù)庫和獲取驅(qū)動對象。
而如果 driveri 不是 driver.DriverContext 類型,則需要先構(gòu)造一個 dsnConnector 對象,然后再使用 OpenDB 函數(shù)打開數(shù)據(jù)庫連接。
dsnConnector 是一個結(jié)構(gòu)體,定義非常簡單:
type dsnConnector struct {
dsn string
driver driver.Driver
}只包含了 DSN 和驅(qū)動對象。
并且它同時也實現(xiàn)了 Connector 接口:
func (t dsnConnector) Connect(_ context.Context) (driver.Conn, error) {
return t.driver.Open(t.dsn)
}
func (t dsnConnector) Driver() driver.Driver {
return t.driver
}接下來,我們看看 OpenDB 函數(shù)是如何定義的:
func OpenDB(c driver.Connector) *DB {
ctx, cancel := context.WithCancel(context.Background())
db := &DB{
connector: c,
openerCh: make(chan struct{}, connectionRequestQueueSize),
lastPut: make(map[*driverConn]string),
connRequests: make(map[uint64]chan connRequest),
stop: cancel,
}
go db.connectionOpener(ctx)
return db
}在 OpenDB 函數(shù)內(nèi)部實例化了一個 *sql.DB 結(jié)構(gòu)體指針并返回。這個結(jié)構(gòu)體由 database/sql 包提供,是統(tǒng)一用戶編程接口的關(guān)鍵結(jié)構(gòu)體,我們后續(xù)的查詢操作,就是調(diào)用了這個對象上的方法。
這里實例化 *sql.DB 對象時,并不不會立即建立數(shù)據(jù)庫連接,連接會在需要時被延遲建立。
在 sql.DB 結(jié)構(gòu)體中,我們需要關(guān)注的是 openerCh 屬性,這是一個 Channel 對象,是一個用來保存連接請求的隊列,稍后我們將見到它的關(guān)鍵作用。
db 對象創(chuàng)建后,通過 go db.connectionOpener(ctx) 單獨啟用了一個協(xié)程,用來處理建立連接的請求。
函數(shù)最終返回了這個 *sql.DB 類型的 db 對象,此對象是并發(fā)安全的,支持多個 Goroutine 同時操作,并且維護(hù)了自己的空閑連接池。
OpenDB 函數(shù)只應(yīng)被調(diào)用一次,且很少需要用戶主動關(guān)閉連接。
db.connectionOpener 方法實現(xiàn)如下:
func (db *DB) connectionOpener(ctx context.Context) {
for {
select {
case <-ctx.Done():
return
case <-db.openerCh:
db.openNewConnection(ctx)
}
}
}這里僅包含一個永不退出的無限循環(huán),當(dāng) db.openerCh 這個 Channel 有值時,代碼會進(jìn)入 db.openNewConnection 函數(shù)的調(diào)用。
func (db *DB) openNewConnection(ctx context.Context) {
ci, err := db.connector.Connect(ctx)
...
dc := &driverConn{
db: db,
createdAt: nowFunc(),
returnedAt: nowFunc(),
ci: ci,
}
if db.putConnDBLocked(dc, err) {
db.addDepLocked(dc, dc)
}
...
}在 db.openNewConnection 函數(shù)的第一行代碼中,db.connector 屬性是在之前調(diào)用 OpenDB 時進(jìn)行賦值的一個 driver.Connector 接口類型對象(還記得前文講的 dsnConnector 嗎),調(diào)用它的 Connect 方法就可以與數(shù)據(jù)庫建立連接了。
之后調(diào)用的 db.putConnDBLocked(dc, err) 方法作用是將這個連接放入數(shù)據(jù)庫空閑連接池中(db.freeConn 屬性)。
至此,我們得到了兩條函數(shù)調(diào)用線:

在驅(qū)動包 go-sql-driver/mysql 中,通過 sql.Register 進(jìn)行驅(qū)動程序注冊。
在 database/sql 中,我們調(diào)用 sql.OpenDB 來開啟數(shù)據(jù)庫連接,這不會立刻建立連接,而是通過開啟新的 Goroutine 阻塞在 db.openerCh Channel 上,等待建立連接請求。
那么接下來,何時觸發(fā) *sql.DB.connectionOpener 函數(shù)中 <-db.openerCh 這個 case,就是我們要研究的重點了。
我們可以順著示例代碼繼續(xù)往下看。
在示例中,接下來使用 db.Query("SELECT id, name FROM user") 方法來查詢 user 記錄。
*sql.DB.Query 方法定義如下:
func (db *DB) Query(query string, args ...any) (*Rows, error) {
return db.QueryContext(context.Background(), query, args...)
}它直接調(diào)用了 *sql.DB.QueryContext:
func (db *DB) QueryContext(ctx context.Context, query string, args ...any) (*Rows, error) {
var rows *Rows
var err error
err = db.retry(func(strategy connReuseStrategy) error {
rows, err = db.query(ctx, query, args, strategy)
return err
})
return rows, err
}*sql.DB.QueryContext 方法內(nèi)部又調(diào)用了 db.query 方法:
func (db *DB) query(ctx context.Context, query string, args []any, strategy connReuseStrategy) (*Rows, error) {
dc, err := db.conn(ctx, strategy)
if err != nil {
return nil, err
}
return db.queryDC(ctx, nil, dc, dc.releaseConn, query, args)
}在 db.query 方法內(nèi)部,首先調(diào)用了 db.conn 方法。db.conn 顧名思義,就是用來建立數(shù)據(jù)庫連接的,定義如下:
func (db *DB) conn(ctx context.Context, strategy connReuseStrategy) (*driverConn, error) {
last := len(db.freeConn) - 1
if strategy == cachedOrNewConn && last >= 0 {
conn := db.freeConn[last]
...
return conn, nil
}
...
ci, err := db.connector.Connect(ctx)
if err != nil {
db.mu.Lock()
db.numOpen-- // correct for earlier optimism
db.maybeOpenNewConnections()
db.mu.Unlock()
return nil, err
}
db.mu.Lock()
dc := &driverConn{
db: db,
createdAt: nowFunc(),
returnedAt: nowFunc(),
ci: ci,
inUse: true,
}
db.addDepLocked(dc, dc)
db.mu.Unlock()
return dc, nil
}這里我省略了一些代碼,只列出了比較重要的邏輯。
在函數(shù)內(nèi)部,首先會嘗試從 db.freeConn 空閑連接池中獲取連接。
如果沒有空閑連接,則調(diào)用 db.connector.Connect 來獲取新的數(shù)據(jù)庫連接。
當(dāng)獲取連接失敗,會調(diào)用 db.maybeOpenNewConnections() 方法并返回錯誤。
這個 db.maybeOpenNewConnections() 方法是我們要關(guān)注的重點,定義如下:
func (db *DB) maybeOpenNewConnections() {
numRequests := len(db.connRequests)
if db.maxOpen > 0 {
numCanOpen := db.maxOpen - db.numOpen
if numRequests > numCanOpen {
numRequests = numCanOpen
}
}
for numRequests > 0 {
db.numOpen++ // optimistically
numRequests--
if db.closed {
return
}
db.openerCh <- struct{}{}
}
}可以發(fā)現(xiàn),正是在這個方法內(nèi)部,調(diào)用了 db.openerCh <- struct{}{} 為 Channel 發(fā)送數(shù)據(jù)。
當(dāng) db.openerCh 有值時,會被前文講解的通過子協(xié)程調(diào)用的 *sql.DB.connectionOpener 函數(shù)消費,以此來觸發(fā)異步獲取數(shù)據(jù)庫連接操作。
前文有提到,異步創(chuàng)建的數(shù)據(jù)庫連接會被放入空閑連接池 db.freeConn 中。
此時,我們再次回到 db.query 方法被調(diào)用的地方,來重新審視下 *sql.DB.QueryContext 方法的定義:
func (db *DB) QueryContext(ctx context.Context, query string, args ...any) (*Rows, error) {
var rows *Rows
var err error
err = db.retry(func(strategy connReuseStrategy) error {
rows, err = db.query(ctx, query, args, strategy)
return err
})
return rows, err
}這里并不是簡單的直接調(diào)用 db.query,而是將其放入了 db.retry 方法中調(diào)用。
顧名思義,db.retry 方法是用來進(jìn)行重試操作的,如果 db.query 調(diào)用失敗,則會重試一次。
這就體現(xiàn)了當(dāng)調(diào)用 db.connector.Connect(ctx) 失敗時,調(diào)用 db.maybeOpenNewConnections() 方法異步建立連接的意義。
因為如果第一次創(chuàng)建連接失敗,則 db.retry 會進(jìn)行重試,下次重試的時候,再次進(jìn)入 db.conn 方法,如果異步建立連接已經(jīng)完成,則可以直接從空閑連接池 db.freeConn 中獲取數(shù)據(jù)庫連接。即使異步建立連接來不及完成,那么空閑連接池也會有一個新的連接被創(chuàng)建,下次有另外一個請求進(jìn)來,也能夠從空閑連接池中獲取連接。這個操作能夠提升程序的性能。
至此,database/sql 包中 sql.Open 和 *sql.DB.Query 兩條函數(shù)調(diào)用線,我們就搞清楚了:

這兩條函數(shù)調(diào)用線通信的關(guān)鍵,就是 db.openerCh 所在。
現(xiàn)在,上圖中 *sql.DB.Query 這條函數(shù)調(diào)用線我們唯獨沒有搞清楚的就只剩下 *sql.DB.queryDC 的調(diào)用了。
*sql.DB.queryDC 定義如下:
func (db *DB) queryDC(ctx, txctx context.Context, dc *driverConn, releaseConn func(error), query string, args []any) (*Rows, error) {
queryerCtx, ok := dc.ci.(driver.QueryerContext)
var queryer driver.Queryer
if !ok {
queryer, ok = dc.ci.(driver.Queryer)
}
if ok {
var nvdargs []driver.NamedValue
var rowsi driver.Rows
var err error
withLock(dc, func() {
nvdargs, err = driverArgsConnLocked(dc.ci, nil, args)
if err != nil {
return
}
rowsi, err = ctxDriverQuery(ctx, queryerCtx, queryer, query, nvdargs)
})
...
}
...
}這里斷言了 *driverConn 中攜帶的查詢對象是 driver.QueryerContext 還是 driver.Queryer,并將斷言結(jié)果傳遞給 ctxDriverQuery 函數(shù)。
ctxDriverQuery 定義如下:
func ctxDriverQuery(ctx context.Context, queryerCtx driver.QueryerContext, queryer driver.Queryer, query string, nvdargs []driver.NamedValue) (driver.Rows, error) {
if queryerCtx != nil {
return queryerCtx.QueryContext(ctx, query, nvdargs)
}
dargs, err := namedValueToValue(nvdargs)
if err != nil {
return nil, err
}
select {
default:
case <-ctx.Done():
return nil, ctx.Err()
}
return queryer.Query(query, dargs)
}在 ctxDriverQuery 函數(shù)內(nèi)部,根據(jù)查詢對象類型的不同,調(diào)用了 queryerCtx.QueryContext 或 queryer.Query。
這個操作正是在調(diào)用驅(qū)動程序?qū)?yīng)的 QueryContext 或 Query 方法。
不管是 driver.QueryerContext 還是 driver.Queryer,都是 database/sql/driver 中定義的接口類型,database/sql 內(nèi)部正是通過使用接口類型,來實現(xiàn)跟驅(qū)動程序 go-sql-driver/mysql 的解耦。
這樣,database/sql 不直接跟 go-sql-driver/mysql 中定義的具體類型打交道,二者通過 database/sql/driver 這個中間層來交互,這便是 Go 語言接口用法的精髓所在。
現(xiàn)在,我們的函數(shù)調(diào)用線路圖就已經(jīng)完整了。

總結(jié)
本文帶大家一起學(xué)習(xí)了 database/sql 包的設(shè)計思想,database/sql/driver 用來定義定義驅(qū)動需要實現(xiàn)的接口,database/sql 則為用戶提供了操作數(shù)據(jù)庫的方法。
這里涉及了一個使用 init 函數(shù)的技巧,利用 init 函數(shù)的副作用,可以實現(xiàn)不改 database/sql 任何代碼的情況下,只需要 import 驅(qū)動程序,就能注冊驅(qū)動程序的所有功能。
通過一個簡單的示例程序,我們一起閱讀了 database/sql 包的部分源碼,以 *sql.DB.Query 方法作為示例,查看了 database/sql 最終是在何處調(diào)用驅(qū)動程序?qū)?yīng)方法的。拋磚引玉,如果你對其他方法源碼也感興趣,可以順著我講解的思路繼續(xù)深入學(xué)習(xí)。
database/sql 包統(tǒng)一了 Go 語言操作數(shù)據(jù)庫的編程接口,避免了操作不同數(shù)據(jù)庫需要學(xué)習(xí)多套 API 的窘境。
記住,在 Go 語言中使用接口來解耦是慣用方法,你一定要掌握。未來我們講解如何編寫單元測試代碼的時候,還會用到。
注意:本文講解的 database/sql 包源碼版本為 Go 1.20.1,其他版本可能有所不同。
以上就是深入了解Go語言中database/sql是如何設(shè)計的的詳細(xì)內(nèi)容,更多關(guān)于Go語言database/sql的資料請關(guān)注腳本之家其它相關(guān)文章!
相關(guān)文章
go語言之給定英語文章統(tǒng)計單詞數(shù)量(go語言小練習(xí))
這篇文章給大家分享go語言小練習(xí)給定英語文章統(tǒng)計單詞數(shù)量,實現(xiàn)思路大概是利用go語言的map類型,以每個單詞作為關(guān)鍵字存儲數(shù)量信息,本文通過實例代碼給大家介紹的非常詳細(xì),需要的朋友參考下吧2020-01-01
golang?使用sort.slice包實現(xiàn)對象list排序
這篇文章主要介紹了golang?使用sort.slice包實現(xiàn)對象list排序,對比sort跟slice兩種排序的使用方式區(qū)別展開內(nèi)容,需要的小伙伴可以參考一下2022-03-03
一文帶你掌握Go語言I/O操作中的io.Reader和io.Writer
在?Go?語言中,io.Reader?和?io.Writer?是兩個非常重要的接口,它們在許多標(biāo)準(zhǔn)庫中都扮演著關(guān)鍵角色,下面就跟隨小編一起學(xué)習(xí)一下它們的使用吧2025-01-01
golang websocket 服務(wù)端的實現(xiàn)
這篇文章主要介紹了golang websocket 服務(wù)端的實現(xiàn),文中通過示例代碼介紹的非常詳細(xì),對大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價值,需要的朋友們下面隨著小編來一起學(xué)習(xí)學(xué)習(xí)吧2019-09-09

