Database/SQL 与 GORM 实践 | 青训营笔记

461 阅读7分钟

这是我参与「第三届青训营 -后端场」笔记创作活动的的第4篇笔记

如果有错误和其他意见,麻烦留言指正💖

本节课在GORM方面涉及了很多原理性的知识,需要一段时间消化,打算在自己理解后补充相关笔记。

database/sql

使用例子

非常简单的例子,但是学问不少。

 package main
 
 import (
    "database/sql"
    "fmt"
    _ "github.com/go-sql-driver/mysql"
 )
 
 type User struct {
    id   int
    name string
 }
 
 func main() {
    db, err := sql.Open("mysql", "root:root@tcp(127.0.0.1:3306)/test")
    rows, err := db.Query("select id, name from users")
    if err != nil {
       fmt.Println(err)
    }
    defer rows.Close()
 
    var users []User
    for rows.Next() {
       var user User
       err := rows.Scan(&user.id, &user.name)
 
       if err != nil {
          fmt.Println(err)
       }
       users = append(users, user)
    }
    fmt.Println(users)
 
    if rows.Err() != nil {
       fmt.Println(rows.Err())
    }
 }

这段代码主要做了下面四件事情。

  1. 首先,要 import 第三方数据库驱动,这里用的式mysql的驱动包 github.com/go-sql-driver/mysql 。因为Go 只提供操作 SQL/SQL-Like 数据库的通用接口,并没有提供具体数据库的实现。

  2. main函数的第一行调用了Open函数

     func Open(driverName, dataSourceName string) (*DB, error)
    
    • driverName:指定的数据库

    • dataSourceName(DSN):指定数据源。通用格式为[username[:password]@][protocol[(address)]]/dbname[?param1=value1&...&paramN=valueN]

    • 返回一个指向 DB 的指针和错误 errorDB 是一个数据库(操作)句柄,代表一个具有零到多个底层连接的连接池。

      注意:它并不代表一个到数据库的具体连接,而是一个能操作的数据库对象,具体的连接在内部通过连接池来管理,对外不暴露。程序中只需要有一个 DB 实例即可。

  3. 接下来第二行,调用 DBQuery方法,作用是执行一次查询,返回多行结果

    database/sql 包中定义了三种返回结果:

    • Row:一行结果,DB.QueryRow 的返回类型

    • Rows:多行结果,DB.Query 的返回类型

    • Result:对已执行的SQL命令的总结,DB.Exec 的返回类型。Result 包含两个方法LastInsertId()RowsAffected()

      LastInsertId()返回一个数据库生成的回应命令的整数;

      RowsAffected() 返回被update、insert或delete命令影响的行数。

  4. 在 for 循环中遍历 rows 中的结果,通过 Scan 方法将数据映射到结构体上。Next用于准备Scan方法的下一行结果。如果成功会返回真,如果没有下一行或者出现错误会返回假。

然后我们再来看一下程序中的其他细节:

为什么要 rows 要 Close?

官方文档对 func (*Rows) Next 方法的描述:

Close关闭Rows,阻止对其更多的列举。 如果Next方法返回假,Rows会自动关闭。

既然如此,我们写的 defer rows.Close() 是不是多此一举。其实不然。我写了一个例子。

 func main() {
    db, err := sql.Open("mysql", "root:root@tcp(127.0.0.1:3306)/test")
    rows, err := db.Query("select id, name from users")
    if err != nil {
       fmt.Println(err)
    }
     
    // 程序结束后执行
    defer func() {
       var user User
       rows.Scan(&user.id, &user.name)
       fmt.Println(user)
    }()
    // defer rows.Close()  //注释掉 Close 方法
 ​
    var users []User
    for rows.Next() {
       // 故意制造异常
       panic("error")
 ​
       var user User
       err := rows.Scan(&user.id, &user.name)
 ​
       if err != nil {
          fmt.Println(err)
       }
       users = append(users, user)
    }
    fmt.Println(users)
 ​
    if rows.Err() != nil {
       fmt.Println(rows.Err())
    }
 }

注释掉 defer rows.Close() 。在遍历rows的时候,让程序发生异常,程序异常终止后执行defer中的函数,会成功打印出遍历结果。

然后我们去掉 defer rows.Close()前面的注释,重新运行程序,会发现只打印了一个初始化空的结构体变量。

因此,得出结论:如果在遍历的过程中,程序发生异常,是不会自动释放 rows 的资源的,为了防止资源泄露,还是要手动 Close 一下。

为什么 db 不 Close?

实际中,defer db.Close() 可以不调用

官方文档对func (*DB) Close 的说明:

Close关闭数据库,释放任何打开的资源。一般不会关闭DB,因为DB句柄通常被多个go程共享,并长期活跃。

为什么 Open 的 error 不处理?

sql.DB 并不是实际的数据库连接,因此,sql.Open 函数并没有进行数据库连接,只有在驱动未注册时才会返回 err != nil

也就是说db, err := sql.Open("mysql", "root:root@tcp(127.0.0.1:3306)/test") 这段代码,我们的 dsn 写错了,也不会返回错误,返回错误只有可能是因为驱动写错了。

我觉得这个错误处理没啥必要,只要程序正常工作了不可能出现这个错误。

设计原理

image.png database / sql 采用了极简接口设计的原则。

  • 它对上层应用程序提供了标准操作接口。
  • 对下层的驱动暴露标准的连接和操作接口。
  • 在内部实现连接池的管理。

这样的好处在于不同的数据库只需要实现相同的接口,上层应用不用修改代码就能实现不同数据库的支持。

Driver 注册

database/sql/driver 包定义了Driver 接口

 type Driver interface {
     // Open返回一个新的与数据库的连接,参数name的格式是驱动特定的。
     //
     // Open可能返回一个缓存的连接(之前关闭的连接),但这么做是不必要的;
     // sql包会维护闲置连接池以便有效的重用连接。
     //
     // 返回的连接同一时间只会被一个go程使用。
     Open(name string) (Conn, error)
 }

Go 使用 database/sql 包下的 Register 函数向全局注册 driver。如果 Register 注册同一名称两次,或者driver参数为nil,会导致panic。

 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
 }

连接池配置方法

  • func (db *DB) SetMaxOpenConns(n int):设置与数据库建立连接的最大数目。
  • func (db *DB) SetMaxIdleConns(n int):设置连接池中的最大闲置连接数。
  • func (db *DB) SetConnMaxLifetime(d time.Duration):设置可以重用连接的最长时间。
  • func (db *DB) SetConnMaxIdleTime(d time.Duration):设置连接可能处于空闲状态的最长时间

实现连接的过程

 for i := 0; i < maxBadConnRetries; i++ {
     // 从连接池获取连接或通过 driver 新建连接
     dc, err := db.conn(ctx, strategy)
         //1.有空闲连接, 复用连接
         //2.没有空闲连接, 新建连接
     
     // 将连接放回连接池
     defer dc.db.putConn(dc, err, true)
         // 校验过程, 丢弃不满足条件的连接
     
     if err == nil {
         err = dc.ci.Query(sql, args...)
     }
     isBadConn = errors.Is(err, driver.ErrBadConn)
     // 连接成功就跳出循环,否则再次尝试重连
     if !isBadConn {
         break
     }
 }

在使用 Go 原生的 database/sql 包进行数据库操作时依旧很繁琐,包括但不限于以下问题:

  • 数据库驱动包没有编译期的检查,很容易忘记导包
  • 数据库驱动命名冲突
  • 不支持结构体定义数据库
  • 需要手写大量SQL语句

因此,为了解决这些问题,有请 GORM 出场。

GORM 使用简介

具体各种使用方式参考GORM 中文文档。这里就不重复罗列了

把上面的代码用 gorm 重新实现。

 type User struct {
     Id   int
     Name string
 }
 
 func main() {
     db, err := gorm.Open(mysql.Open("root:root@tcp(127.0.0.1:3306)/test"))
     if err != nil {
         fmt.Println(err)
     }
     var users []User
     err = db.Select("id", "name").Find(&users, 1).Error
     fmt.Println(users)
 }

发现代码简化了不少,尤其是在查询部分。

注意:还有一个小细节的问题,使用GORM时,结构体中要查询或者作为主键的变量一定要以大写开头,否则拼接出的sql语句会出现意料之外的错误。

GORM 设计原理 (不全)

SQL 生成插件扩展ConnPoolDialector四个方面讲解GORM如何实现这些功能及为何实现这些功能。

目前参考文档和课程对 SQL 生成有一个大概的理解。其他几个方面还得花些时间去学习。

SQL 生成

下面这张图概括了SQL生成全过程 image.png

GORM 内部使用 SQL builder 生成 SQL。对于每个操作,GORM 都会创建一个 *gorm.Statement 对象,所有的 GORM API 都是在为 statement 添加 / 修改 Clause(子句),最后,GORM 会根据这些 Clause 生成 SQL。

注意:不同的数据库,Clause 可能会生成不同的 SQL。

GORM 中有三种类型的方法: 链式方法(Chain Method)Finisher 方法新建会话方法

  • 链式方法是将 Clauses 添加至 GORM Statement。完整链式方法可以查阅 gorm/chainable_api.go 下的代码。
  • Finishers方法 是会立即执行注册回调的方法,然后生成并执行 SQL。完整方法可以查阅 gorm/finisher_api.go 下的代码。
  • 新建会话方法会创建一个新的 Statement 实例而不是使用当前的。有SessionWithContextDebug

GORM 最佳实践

这里只是简单罗列一下GORM在工程上有哪些方面的使用。

  • 数据序列化与SQL表达式
  • 批量数据操作
  • 代码复用、分库分表、Sharding
  • 混沌工程 / 压测
  • Logger / Trace
  • Migrator
  • Gen 代码生成 / Raw SQL
  • 安全

参考和推荐阅读

《Go 语言标准库》database/sql

《GORM 中文文档 》