【Go Web学习笔记】第四章 Go访问数据库

前言:大家好,以下所有内容都是我学习韩茹老师的教程时所整理的笔记。部分内容有过删改, 推荐大家去看原作者的文档进行学习本文章仅作为个人的学习笔记,后续还会在此基础上不断修改。学习Go Web时应该已经熟悉Go语言基本语法以及计算机网络的相关内容。

学习链接:https://www.chaindesk.cn/witbook/17/253
参考书籍:《Go Web编程》谢孟军

补充:之所以把教程以笔记的形式做个记录,也是为了加深印象。对我而言,每学完一个知识点后不写点东西真的很容易忘记。

第四章、访问数据库

对许多Web应用程序而言,数据库都是其核心所在。数据库几乎可以用来存储你想查询和修改的任何信息,比如用户信息、产品目录或者新闻列表等。

Go没有内置的驱动支持任何的数据库,但是Go定义了database/sql接口,用户可以基于驱动接口开发相应数据库的驱动。

本章节内容,需要大家已经掌握了一些常规数据库的操作,比如数据库的增删改查等等。。

database/sql接口

Go与PHP不同的地方是Go没有官方提供数据库驱动,而是为开发者开发数据库驱动定义了一些标准接口,开发者可以根据定义的接口来开发相应的数据库驱动,这样做有一个好处,只要按照标准接口开发的代码, 以后需要迁移数据库时,不需要任何修改。那么Go都定义了哪些标准接口呢?让我们来详细的分析一下。

1、 sql.Register

这个存在于database/sql的函数是用来注册数据库驱动的,当第三方开发者开发数据库驱动时,都会实现init函数,在init里面会调用这个 Register(name string, driver driver.Driver) 完成本驱动的注册。

import _ "github.com/go-sql-driver/mysql"前面的”_”作用时不需要把该包都导进来,只执行包的 init() 方法,mysql驱动正是通过这种方式注册到 ”database/sql” 中的:

//github.com/go-sql-driver/mysql/driver.go
func init() {
    sql.Register("mysql", &MySQLDriver{})
}

type MySQLDriver struct{}

func (d MySQLDriver) Open(dsn string) (driver.Conn, error) {
    ...
}

init()通过 Register() 方法将 mysql 驱动添加到 sql.drivers。第三方数据库驱动都是通过调用这个函数来注册自己的数据库驱动名称以及相应的driver实现。在 database/sql 内部通过一个map来存储用户定义的相应驱动。

var drivers = make(map[string]driver.Driver)

假如我们同时用到多种数据库,就可以通过调用sql.Register将不同数据库的实现注册到sql.drivers中去,用的时候再根据注册的 name 将对应的 driver 取出。因此通过 database/sql 的注册函数可以同时注册多个数据库驱动,只要不重复。

//database/sql/sql.go
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
}

关于 init 函数的初始化过程,包在引入的时候会自动调用包的init函数以完成对包的初始化。因此,我们引入上面的数据库驱动包之后要手动去调用 init 函数,然后在 init 函数里面注册这个数据库驱动,这样我们就可以在接下来的代码中直接使用这个数据库驱动了。

2、 driver.Driver

init() 通过Register()方法将 mysql 驱动添加到 sql.drivers (类型:make(map[string]driver.Driver))中, MySQLDriver 实现了 driver.Driver 接口。

Driver是一个数据库驱动的接口,他定义了一个 method: Open(name string),这个方法返回一个数据库的Conn接口。

//database/sql/driver/driver.go
type Driver interface {
    // Open returns a new connection to the database.
    // The name is a string in a driver-specific format.
    //
    // Open may return a cached connection (one previously
    // closed), but doing so is unnecessary; the sql package
    // maintains a pool of idle connections for efficient re-use.
    //
    // The returned connection is only used by one goroutine at a
    // time.
    Open(name string) (Conn, error)
}

返回的Conn只能用来进行一次 goroutine 的操作,也就是说不能把这个Conn应用于Go的多个goroutine里面。如下代码会出现错误

...

go goroutineA (Conn) //执行查询操作

go goroutineB (Conn) //执行插入操作

...

上面这样的代码可能会使Go不知道某个操作究竟是由哪个goroutine发起的,从而导致数据混乱,比如可能会把 goroutineA 里面执行的查询操作的结果返回给 goroutineB 从而使B错误地把此结果当成自己执行的插入数据。

第三方驱动都会定义这个函数,它会解析name参数来获取相关数据库的连接信息,解析完成后,它将使用此信息来初始化一个Conn并返回它。

3、 driver.Conn

Conn是一个数据库连接的接口定义,他定义了一系列方法,这个Conn只能应用在一个goroutine里面,不能使用在多个goroutine里面,详情请参考上面的说明。

type Conn interface {

    Prepare(query string) (Stmt, error)

    Close() error

    Begin() (Tx, error)

}
  • Prepare函数返回与当前连接相关的执行Sql语句的准备状态,可以进行查询、删除等操作。
  • Close函数关闭当前的连接,执行释放连接拥有的资源等清理工作。因为驱动实现了database/sql里面建议的conn pool,所以你不用再去实现缓存conn之类的,这样会容易引起问题。
  • Begin函数返回一个代表事务处理的Tx,通过它你可以进行查询,更新等操作,或者对事务进行回滚、递交。

4、 driver.Stmt

Stmt是一种准备好的状态,和Conn相关联,而且只能应用于一个goroutine中,不能应用于多个goroutine。

type Stmt interface {

    Close() error

    NumInput() int

    Exec(args []Value) (Result, error)

    Query(args []Value) (Rows, error)

}
  • Close函数关闭当前的链接状态,但是如果当前正在执行query,query还是有效返回rows数据。
  • NumInput函数返回当前预留参数的个数,当返回 >= 0 时数据库驱动就会智能检查调用者的参数。当数据库驱动包不知道预留参数的时候,返回 -1。
  • Exec函数执行Prepare准备好的sql,传入参数执行 update/insert 等操作,返回 Result 数据。
  • Query函数执行Prepare准备好的sql,传入需要的参数执行select操作,返回 Rows 结果集。

5、 driver.Tx

事务处理一般就两个过程,递交或者回滚。数据库驱动里面也只需要实现这两个函数就可以。

type Tx interface {

    Commit() error

    Rollback() error

}

这两个函数一个用来递交一个事务,一个用来回滚事务。

6、 driver.Execer

这是一个Conn可选择实现的接口。

type Execer interface {

    Exec(query string, args []Value) (Result, error)

}

如果这个接口没有定义,那么在调用 DB.Exec , 就会首先调用Prepare返回 Stmt,然后执行 Stmt 的Exec,然后关闭 Stmt。

7、 driver.Result

这个是执行Update/Insert等操作返回的结果接口定义。

type Result interface {

    LastInsertId() (int64, error)

    RowsAffected() (int64, error)

}
  • LastInsertId函数返回由数据库执行插入操作得到的自增ID号。
  • RowsAffected函数返回query操作影响的数据条目数。

8、 driver.Rows

Rows是执行查询返回的结果集接口定义

type Rows interface {

    Columns() []string

    Close() error

    Next(dest []Value) error

}
  • Columns函数返回查询数据库表的字段信息,这个返回的 slice 和 sql 查询的字段一一对应,而不是返回整个表的所有字段。
  • Close函数用来关闭Rows迭代器。
  • Next函数用来返回下一条数据,把数据赋值给dest。dest里面的元素必须是 driver.Value 的值除了string,返回的数据里面所有的string都必须要转换成 []byte。如果最后没数据了,Next函数最后返回io.EOF。

9、 driver.RowsAffected

RowsAffested其实就是一个 int64 的别名,但是他实现了Result接口,用来底层实现Result的表示方式

type RowsAffected int64

func (RowsAffected) LastInsertId() (int64, error)

func (v RowsAffected) RowsAffected() (int64, error)

10、 driver.Value

Value其实就是一个空接口,他可以容纳任何的数据。

type Value interface{}

drive的Value是驱动必须能够操作的Value,Value要么是nil,要么是下面的任意一种

int64

float64

bool

[]byte

string [*]除了Rows.Next返回的不能是string.

time.Time

11、 driver.ValueConverter

ValueConverter 接口定义了如何把一个普通的值转化成 driver.Value 的接口

type ValueConverter interface {

    ConvertValue(v interface{}) (Value, error)

}

在开发的数据库驱动包里面实现这个接口的函数在很多地方会使用到,这个ValueConverter有很多好处:

  1. 转化driver.value到数据库表相应的字段,例如int64的数据如何转化成数据库表uint16字段
  2. 把数据库查询结果转化成 driver.Value 值
  3. 在scan函数里面如何把 dirve.Value 值转化成用户定义的值

12、 driver.Valuer

Valuer接口定义了返回一个 driver.Value 的方式

type Valuer interface {

    Value() (Value, error)

}

很多类型都实现了这个Value方法,用来自身与 driver.Value 的转化。

通过上面的讲解,你应该对于驱动的开发有了一个基本的了解,一个驱动只要实现了这些接口就能完成增删查改等基本操作了,剩下的就是与相应的数据库进行数据交互等细节问题了,在此不再赘述。

13、 database/sql

database/sql 在 database/sql/driver 提供的接口基础上定义了一些更高阶的方法,用以简化数据库操作,同时内部还建议性地实现一个conn pool。

type DB struct {
    driver driver.Driver  //数据库实现驱动
    dsn    string  //数据库连接、配置参数信息,比如username、host、password等
    numClosed uint64

    mu           sync.Mutex          //锁,操作DB各成员时用到
    freeConn     []*driverConn       //空闲连接
    connRequests []chan connRequest  //阻塞请求队列,等连接数达到最大限制时,后续请求将插入此队列等待可用连接
    numOpen      int                 //已建立连接或等待建立连接数
    openerCh    chan struct{}        //用于connectionOpener
    closed      bool
    dep         map[finalCloser]depSet
    lastPut     map[*driverConn]string // stacktrace of last conn's put; debug only
    maxIdle     int                    //最大空闲连接数
    maxOpen     int                    //数据库最大连接数
    maxLifetime time.Duration          //连接最长存活期,超过这个时间连接将不再被复用
    cleanerCh   chan struct{}
}

maxIdle(默认值2)、maxOpen(默认值0,无限制)、maxLifetime(默认值0,永不过期)可以分别通过SetMaxIdleConnsSetMaxOpenConnsSetConnMaxLifetime设定。

我们可以看到 Open函数返回的是DB对象,里面有一个 freeConn,它就是那个简易的连接池。它的实现相当简单或者说简陋,就是当执行 Db.prepare 的时候会 defer db.putConn(ci, err),也就是把这个连接放入连接池,每次调用conn的时候会先判断 freeConn 的长度是否大于0,大于0说明有可以复用的conn,直接拿出来用就是了,如果不大于0,则创建一个conn,然后再返回之。

这时mysql还没有建立连接,只是初始化了一个sql.DB结构,这是非常重要的一个结构,所有相关的数据都保存在此结构中;Open同时启动了一个connectionOpener协程。

14、获取连接

上面说了Open时是没有建立数据库连接的,只有等用的时候才会实际建立连接,获取可用连接的操作有两种策略:cachedOrNewConn(有可用空闲连接则优先使用,没有则创建)、alwaysNewConn(不管有没有空闲连接都重新创建),下面以一个query的例子看下具体的操作:

rows, err := db.Query("select * from userinfo")

以下是Query的源代码:

//database/sql/sql.go:
func (db *DB) Query(query string, args ...interface{}) (*Rows, error) {
    var rows *Rows
    var err error
    //maxBadConnRetries = 2
    for i := 0; i < maxBadConnRetries; i++ {
        rows, err = db.query(query, args, cachedOrNewConn)
        if err != driver.ErrBadConn {
            break
        }
    }
    if err == driver.ErrBadConn {
        return db.query(query, args, alwaysNewConn)
    }
    return rows, err
}

func (db *DB) query(query string, args []interface{}, strategy connReuseStrategy) (*Rows, error) {
    ci, err := db.conn(strategy)
    if err != nil {
        return nil, err
    }

    //到这已经获取到了可用连接,下面进行具体的数据库操作
    return db.queryConn(ci, ci.releaseConn, query, args)
}

数据库连接由db.query()获取:

func (db *DB) conn(strategy connReuseStrategy) (*driverConn, error) {
    db.mu.Lock()
    if db.closed {
        db.mu.Unlock()
        return nil, errDBClosed
    }
    lifetime := db.maxLifetime

    //从freeConn取一个空闲连接
    numFree := len(db.freeConn)
    if strategy == cachedOrNewConn && numFree > 0 {
        conn := db.freeConn[0]
        copy(db.freeConn, db.freeConn[1:])
        db.freeConn = db.freeConn[:numFree-1]
        conn.inUse = true
        db.mu.Unlock()
        if conn.expired(lifetime) {
            conn.Close()
            return nil, driver.ErrBadConn
        }
        return conn, nil
    }

    //如果没有空闲连接,而且当前建立的连接数已经达到最大限制则将请求加入connRequests队列,
    //并阻塞在这里,直到其它协程将占用的连接释放或connectionOpenner创建
    if db.maxOpen > 0 && db.numOpen >= db.maxOpen {
        // Make the connRequest channel. It's buffered so that the
        // connectionOpener doesn't block while waiting for the req to be read.
        req := make(chan connRequest, 1)
        db.connRequests = append(db.connRequests, req)
        db.mu.Unlock()
        ret, ok := <-req  //阻塞
        if !ok {
            return nil, errDBClosed
        }
        if ret.err == nil && ret.conn.expired(lifetime) { //连接过期了
            ret.conn.Close()
            return nil, driver.ErrBadConn
        }
        return ret.conn, ret.err
    }

    db.numOpen++ //上面说了numOpen是已经建立或即将建立连接数,这里还没有建立连接,只是乐观的认为后面会成功,失败的时候再将此值减1
    db.mu.Unlock()
    ci, err := db.driver.Open(db.dsn) //调用driver的Open方法建立连接
    if err != nil { //创建连接失败
        db.mu.Lock()
        db.numOpen-- // correct for earlier optimism
        db.maybeOpenNewConnections()  //通知connectionOpener协程尝试重新建立连接,否则在db.connRequests中等待的请求将一直阻塞,知道下次有连接建立
        db.mu.Unlock()
        return nil, err
    }
    db.mu.Lock()
    dc := &driverConn{
        db:        db,
        createdAt: nowFunc(),
        ci:        ci,
    }
    db.addDepLocked(dc, dc)
    dc.inUse = true
    db.mu.Unlock()
    return dc, nil
}

总结一下上面获取连接的过程:

  • step1:首先检查下freeConn里是否有空闲连接,如果有且未超时则直接复用,返回连接,如果没有或连接已经过期则进入下一步;
  • step2:检查当前已经建立及准备建立的连接数是否已经达到最大值,如果达到最大值也就意味着无法再创建新的连接了,当前请求需要在这等着连接释放,这时当前协程将创建一个channel:chan connRequest,并将其插入db.connRequests队列,然后阻塞在接收chan connRequest上,等到有连接可用时这里将拿到释放的连接,检查可用后返回;如果还未达到最大值则进入下一步;
  • step3:创建一个连接,首先将numOpen加1,然后再创建连接,如果等到创建完连接再把numOpen加1会导致多个协程同时创建连接时一部分会浪费,所以提前将numOpen占住,创建失败再将其减掉;如果创建连接成功则返回连接,失败则进入下一步
  • step4:创建连接失败时有一个善后操作,当然并不仅仅是将最初占用的numOpen数减掉,更重要的一个操作是通知connectionOpener协程根据db.connRequests等待的长度创建连接,这个操作的原因是:numOpen在连接成功创建前就加了1,这时候如果numOpen已经达到最大值再有获取conn的请求将阻塞在step2,这些请求会等着先前进来的请求释放连接,假设先前进来的这些请求创建连接全部失败,那么如果它们直接返回了那些等待的请求将一直阻塞在那,因为不可能有连接释放(极限值,如果部分创建成功则会有部分释放),直到新请求进来重新成功创建连接,显然这样是有问题的,所以maybeOpenNewConnections将通知connectionOpener根据db.connRequests长度及可创建的最大连接数重新创建连接,然后将新创建的连接发给阻塞的请求。

注意:如果maxOpen=0将不会有请求阻塞等待连接,所有请求只要从freeConn中取不到连接就会新创建。

另外Query、Exec有个重试机制,首先优先使用空闲连接,如果2次取到的连接都无效则尝试新创建连接。

获取到可用连接后将调用具体数据库的driver处理sql。

15、释放连接

数据库连接在被使用完成后需要归还给连接池以供其它请求复用,释放连接的操作是:putConn()

func (db *DB) putConn(dc *driverConn, err error) {
    ...

    //如果连接已经无效,则不再放入连接池
    if err == driver.ErrBadConn {
        db.maybeOpenNewConnections()
        dc.Close() //这里最终将numOpen数减掉
        return
    }
    ...

    //正常归还
    added := db.putConnDBLocked(dc, nil)
    ...
}

func (db *DB) putConnDBLocked(dc *driverConn, err error) bool {
    if db.maxOpen > 0 && db.numOpen > db.maxOpen {
        return false
    }
    //有等待连接的请求则将连接发给它们,否则放入freeConn
    if c := len(db.connRequests); c > 0 {
        req := db.connRequests[0]
        // This copy is O(n) but in practice faster than a linked list.
        // TODO: consider compacting it down less often and
        // moving the base instead?
        copy(db.connRequests, db.connRequests[1:])
        db.connRequests = db.connRequests[:c-1]
        if err == nil {
            dc.inUse = true
        }
        req <- connRequest{
            conn: dc,
            err:  err,
        }
        return true
    } else if err == nil && !db.closed && db.maxIdleConnsLocked() > len(db.freeConn) {
        db.freeConn = append(db.freeConn, dc)
        db.startCleanerLocked()
        return true
    }
    return false
}

释放的过程:

  • step1:首先检查下当前归还的连接在使用过程中是否发现已经无效,如果无效则不再放入连接池,然后检查下等待连接的请求数新建连接,类似获取连接时的异常处理,如果连接有效则进入下一步;
  • step2:检查下当前是否有等待连接阻塞的请求,有的话将当前连接发给最早的那个请求,没有的话则再判断空闲连接数是否达到上限,没有则放入freeConn空闲连接池,达到上限则将连接关闭释放。
  • step3:(只执行一次)启动connectionCleaner协程定时检查feeConn中是否有过期连接,有则剔除。

有个地方需要注意的是,Query、Exec操作用法有些差异:

a.Exec(update、insert、delete等无结果集返回的操作)调用完后会自动释放连接; b.Query(返回sql.Rows)则不会释放连接,调用完后仍然占有连接,它将连接的所属权转移给了sql.Rows,所以需要手动调用close归还连接,即使不用Rows也得调用rows.Close(),否则可能导致后续使用出错,如下的用法是错误的:

//错误
db.SetMaxOpenConns(1)
db.Query("select * from test")

row,err := db.Query("select * from test") //此操作将一直阻塞

//正确
db.SetMaxOpenConns(1)
r,_ := db.Query("select * from test")
r.Close() //将连接的所属权归还,释放连接
row,err := db.Query("select * from test")
//other op
row.Close()
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

ClimberCoding

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值