这是我参与「第三届青训营 -后端场」笔记创作活动的的第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())
}
}
这段代码主要做了下面四件事情。
-
首先,要
import第三方数据库驱动,这里用的式mysql的驱动包github.com/go-sql-driver/mysql。因为Go 只提供操作 SQL/SQL-Like 数据库的通用接口,并没有提供具体数据库的实现。 -
在
main函数的第一行调用了Open函数func Open(driverName, dataSourceName string) (*DB, error)-
driverName:指定的数据库
-
dataSourceName(DSN):指定数据源。通用格式为
[username[:password]@][protocol[(address)]]/dbname[?param1=value1&...¶mN=valueN] -
返回一个指向
DB的指针和错误error,DB是一个数据库(操作)句柄,代表一个具有零到多个底层连接的连接池。注意:它并不代表一个到数据库的具体连接,而是一个能操作的数据库对象,具体的连接在内部通过连接池来管理,对外不暴露。程序中只需要有一个DB实例即可。
-
-
接下来第二行,调用
DB的Query方法,作用是执行一次查询,返回多行结果database/sql包中定义了三种返回结果:-
Row:一行结果,
DB.QueryRow的返回类型 -
Rows:多行结果,
DB.Query的返回类型 -
Result:对已执行的SQL命令的总结,
DB.Exec的返回类型。Result 包含两个方法LastInsertId()和RowsAffected()。LastInsertId()返回一个数据库生成的回应命令的整数;RowsAffected()返回被update、insert或delete命令影响的行数。
-
-
在 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 写错了,也不会返回错误,返回错误只有可能是因为驱动写错了。
我觉得这个错误处理没啥必要,只要程序正常工作了不可能出现这个错误。
设计原理
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 生成, 插件扩展,ConnPool,Dialector四个方面讲解GORM如何实现这些功能及为何实现这些功能。
目前参考文档和课程对 SQL 生成有一个大概的理解。其他几个方面还得花些时间去学习。
SQL 生成
下面这张图概括了SQL生成全过程
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实例而不是使用当前的。有Session、WithContext、Debug
GORM 最佳实践
这里只是简单罗列一下GORM在工程上有哪些方面的使用。
- 数据序列化与SQL表达式
- 批量数据操作
- 代码复用、分库分表、Sharding
- 混沌工程 / 压测
- Logger / Trace
- Migrator
- Gen 代码生成 / Raw SQL
- 安全
参考和推荐阅读