25 | GORM(上):数据库的使用必不可少

思考并回答以下问题:

ORM

ORM是一种将数据库中的数据映射到代码中对象的技术,这个技术的需求出发点就是,代码中有类,数据库中有数据表,我们可以将类和数据表进行映射,从而使得在代码中操作类就等同于操作数据库中的数据表了

因为数据库是和业务紧密关联起来的,建立数据库表结构的时候,也是建立了一个业务模型。使用代码中的类定义,比如定义了一个User类,基本上就定义了一个User表,这样也是一个建立业务模型的过程。

版本选择的是Gorm截止2021/10/23日最新的v1.21.16的tag。

Gorm

一个ORM库,最核心要了解两个部分。一个部分是数据库连接,它是怎么和数据库建立连接的,第二部分是数据库操作,即它是怎么操作数据库的。

我们看一个最精简的Gorm的使用例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
package main

import (
"gorm.io/gorm"
"gorm.io/driver/mysql"
)

// 定义一个 gorm 类
type User struct {
ID uint
Name string
}

func main() {
// 创建 mysql 连接
dsn := "xxxxxxx"
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{})
...

// 插入一条数据
db.Create(&User{Name: "jianfengye"})

...
}

main函数,先创建一个MySQL连接,再插入一条数据,这个User数据是通过事先定义好的User结构来进行设置的。其中的gorm.Open就是一个快速连接数据库的接口,而后续的Create是如何操作数据库的接口。

数据结构

先把重点放在理解这个Open函数上,因为这个函数包含了Gorm中关键的几个对象,把这些关键数据结构一一理解透,再跟踪具体的源码能事半功倍。

来看它的源码定义:

1
2
// 初始化数据库连接
func Open(dialector Dialector, opts ...Option) (db *DB, err error)

这个初始化数据库链接的Open函数有两个参数dialector、opts,和两个返回值gorm.DB、error。我们先理解下这几个参数的意义。

Dialector

第一个参数是Dialector,这是什么呢?它代表数据库连接器。这里也是一个面向接口编程的思想,连接器结构Dialector是一个接口,代表如果你要使用Gorm来连接你的数据库,那么,只需要实现这个接口定义的所有方法,就可以使用Gorm来操作你的数据库了。

所以,这个接口Dialecotor中定义的所有方法,都是在后续的查询、更新、数据库迁移等操作中会使用到的。

1
2
3
4
5
6
7
8
9
10
11
// Dialector GORM database dialector
type Dialector interface {
Name() string // 连接器名称
Initialize(*DB) error // 连接器初始化连接方法
Migrator(db *DB) Migrator // 数据库迁移方法
DataTypeOf(*schema.Field) string // 类中每个字段的类型对应到 sql 语句
DefaultValueOf(*schema.Field) clause.Expression // 每个字段的默认值对应到 sql 语句
BindVarTo(writer clause.Writer, stmt *Statement, v interface{}) // 使用预编译模式的时候使用
QuoteTo(clause.Writer, string) // 将类中的注释对应到 sql 语句中
Explain(sql string, vars ...interface{}) string // 将有占位符的 sql 解析为无占位符 sql,常用于日志打印等
}

不同的数据库有不同的Dialector实现,我们称之为“驱动”。每个数据库的驱动,都有一个git地址进行存放。目前gorm官方支持五种数据库驱动:

如果要创建对应数据库的连接,要先引入对应的驱动。而在对应的驱动库中都有一个约定的Open方法,来创建一个新的数据库驱动。比如要创建MySQL的连接,使用下面这个例子:

1
2
3
4
5
6
7
8
import (
"gorm.io/driver/mysql"
"gorm.io/gorm"
)

// 创建连接
dsn := "gorm:gorm@tcp(localhost:9910)/gorm?charset=utf8&parseTime=True&loc=Local"
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{})

我们看到这里有个mysql.Open,就是创建MySQL的Gorm驱动用的,只有一个字符串参数DSN。

DSN

DSN全称叫Data Source Name,数据库的源名称。

DSN定义了一个数据库的连接方式及信息,包含用户名、密码、数据库IP、数据库端口、数据库字符集、数据库时区等信息。可以说一个DSN就是一个数据源的描述。

普遍按照以下这种格式来进行定义:

1
scheme://username:password@host:port/dbname?param1=value1&param2=value2&...

比如通过Unix的socket句柄连接本机MySQL:
1
mysql://user@unix(/path/to/socket)/dbname

通过TCP连接远端postgres:
1
pgsql://user:pass@tcp(localhost:5555)/dbname

DSN在gorm中的使用如下图所示,我们使用这个dsn结合具体的驱动来生成Open函数的第一个参数,数据库连接器。

在具体使用中,我们当然可以直接执行字符串拼接,来拼接出一个DSN,但是我们更希望能通过定义一个Golang的数据结构自动拼接出一个DSN,或者是从一个DSN字符串反序列化生成这个数据结构。

在Golang中有一个第三方库就提供了这样的功能。这个库用来对Go中的SQL提供MySQL驱动,其中定义了一个Config结构,能映射到DSN字符串。Config结构中一些比较重要的字段说明,我写在注释中了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
type Config struct {
User string // 用户名
Passwd string // 密码 (requires User)
Net string // 网络类型
Addr string // 地址 (requires Net)
DBName string // 数据库名
Params map[string]string // 其他连接参数
Collation string // 字符集
Loc *time.Location // 时区
MaxAllowedPacket int // 最大包大小
ServerPubKey string // 连接公钥名称
pubKey *rsa.PublicKey // 连接公钥 key
TLSConfig string // TLS 的配置名称
tls *tls.Config // TLS 的配置项
Timeout time.Duration // 连接超时
ReadTimeout time.Duration // 读超时
WriteTimeout time.Duration // 写超时

...
CheckConnLiveness bool // 在使用连接前确认连接可用
...
ParseTime bool // 是否解析时间格式
...
}

从DSN到这个Config结构,我们使用github.com/go-sql-driver/mysql的ParseDSN,而从Config结构到DSN我们使用FormatDSN方法:
1
2
3
4
5
// 解析 dsn
func ParseDSN(dsn string) (cfg *Config, err error)

// 生成 dsn
func (cfg *Config) FormatDSN() string

这两个方法都先记下,下节课会用到。

Option

Open函数第二个参数opts是Option的可变参数,而这个Option是一个实现了Apply和AfterInitialize的接口:

1
2
3
4
5
// Option 接口
type Option interface {
Apply(*Config) error
AfterInitialize(*DB) error
}

这种可变参数如何使用呢?我们看下Open的源码:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// Open 初始化 DB 的 Session
func Open(dialector Dialector, opts ...Option) (db *DB, err error) {
config := &Config{}

...

for _, opt := range opts {
if opt != nil {
// 先调用 Apply 初始化 Config
if err := opt.Apply(config); err != nil {
return nil, err
}
// Open 最后结束后调用 AfterInitialize
defer func(opt Option) {
if errr := opt.AfterInitialize(db); errr != nil {
err = errr
}
}(opt)
}
}

可以看到,对每一个option,我们直接调用它的Apply方法来对数据库的配置config进行修改。Option的这种编程方式常用在初始化一个比较复杂的结构里面。

比如这里在Gorm中,要初始化一个Gorm的构造配置gorm.Config,而这个Config结构有非常多的配置项,我们希望在创建初始化的时候,能对这个配置进行调整。所以就可以在Option方法中再定义一个Apply方法,它的参数是gorm.Config指针:

1
func (c *Config) Apply(config *Config) error

这样,可以遍历所有的Option,挨个调用它们的Apply方法对Config进行设置,最终我们获取的就是经过所有Option处理后的Config。

这种Option的编程方法在Golang中十分常用,要好好掌握,下一节课,我们也会用这种方式为hade的ORM服务来注册参数。

Gorm这里还有一个比较巧妙的设计,Config结构本身也实现了Option接口。按照这个设计实现之后,你会发现,Config本身也可以作为一个Option在Open的第二个参数中出现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
func (c *Config) Apply(config *Config) error {
if config != c {
*config = *c
}
return nil
}

func (c *Config) AfterInitialize(db *DB) error {
if db != nil {
for _, plugin := range c.Plugins {
if err := plugin.Initialize(db); err != nil {
return err
}
}
}
return nil
}

Gorm实现的时候使用的是gorm.Config,之所以它可以匹配Open函数定义的Option的参数的原因是Config结构本身也实现了Option接口。

通过上图我们就理解了Open函数在使用的时候,第一个参数和第二个参数是如何对应函数定义两个参数的了。

所以Gorm官方连接MySQL的示例就很好理解了,看注释:

1
2
3
4
5
6
7
8
9
10
11
import (
"gorm.io/driver/mysql"
"gorm.io/gorm"
)
func main() {
// 参考 https://github.com/go-sql-driver/mysql#dsn-data-source-name 获取详情
dsn := "user:pass@tcp(127.0.0.1:3306)/dbname?charset=utf8mb4&parseTime=True&loc=Local"
// 第一个参数是 dialector
// 第二个参数是 option,但是由于 gorm.Config 实现了 option,所以可以这么使用
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{})
}

gorm.DB

两个传入参数讲完了,我们继续看Open的返回结构,除了常规的error外,还有一个gorm.DB的结构指针,定义如下:

1
2
3
4
5
6
7
type DB struct {
*Config
Error error
RowsAffected int64
Statement *Statement
// Has unexported fields.
}

它具有丰富的操作数据库的方法,比如增加数据的Create方法、更新数据的Update方法。

我们研究下gorm.DB的结构,它嵌套了一层gorm.Config结构,里面有几个关键字段:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// Config GORM config
type Config struct {
...
// gorm 的日志输出
Logger logger.Interface
...

// db 的具体连接
ConnPool ConnPool
// db 驱动器
Dialector
...

callbacks *callbacks // 回调方法
...
}

其中的Logger、ConnPool和Callback字段,值得详细研究一下。

Logger

我们从Open看到了,一个gorm.DB结构就代表一个数据库连接,而这个数据库连接的所有日志操作输出在哪里呢?就是通过这个Logger字段配置的。

Logger字段是一个接口,表示如果有一个实现了logger.Interface接口的日志输出类,我就能让这个DB的所有数据库操作的日志,都输出到这个类中。

1
2
3
4
5
6
7
8
// Interface logger interface
type Interface interface {
LogMode(LogLevel) Interface
Info(context.Context, string, ...interface{})
Warn(context.Context, string, ...interface{})
Error(context.Context, string, ...interface{})
Trace(ctx context.Context, begin time.Time, fc func() (sql string, rowsAffected int64), err error)
}

Gorm使用Logger接口的方法,和我们hade框架定义Logger服务的方法如出一辙,它不定义具体的实现类,而是定义了具体的接口。所以下一节课,我们将Gorm融合进入hade框架的时候,要做的事情就是封装一个实现了Gorm的logger.Interface接口的实现类,而这个实现类的具体实现方法,使用hade框架的日志服务类来实现。

ConnPool

ConnPool也定义了一个接口,它代表数据库的真实连接所在的连接池。这个接口的定义,我认为是Gorm中最精妙的一个地方了:

1
2
3
4
5
6
7
// ConnPool db conns pool interface
type ConnPool interface {
PrepareContext(ctx context.Context, query string) (*sql.Stmt, error)
ExecContext(ctx context.Context, query string, args ...interface{}) (sql.Result, error)
QueryContext(ctx context.Context, query string, args ...interface{}) (*sql.Rows, error)
QueryRowContext(ctx context.Context, query string, args ...interface{}) *sql.Row
}

这个接口定义了四个方法,但它们并不是随便定义的,而是根据Golang标准库的database/sql的Conn结构来定义的,这是什么意思呢?

首先我们要知道,Golang的标准库database/sql其实定义了一套数据库连接规范。官方的基本思想就是,数据库的种类非常多,我不可能对每一个数据库都实现一套定制化的类库,所以我定义一套基本数据结构和方法,并且提供每个数据库需要实现的驱动接口。使用者只需要实现驱动接口,就能使用这套基本数据结构和方法了。

是不是和前面说的Gorm的驱动逻辑一样?是的。Golang中所有的ORM库,底层都是基于标准库的database/sql来实现数据库的连接和基本操作。只是在具体操作上,会封装一层逻辑,当使用不同驱动接口的时候,实现不一样的接口操作。

这里的ConnPool就是Gorm对database/sql的数据结构的封装。换句话说,开头的Gorm使用例子,在底层database/sql的简要实现大致如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
package main

import (
"database/sql"
)

func main() {

dsn := "xxxx"
...
db, err = sql.Open("mysql", *dsn)
...

result, err := db.ExecContext(ctx, "INSERT INTO user (name) values ('jianfengye')")
...
}

这里sql.Open创建的sql.DB结构,就包含ConnPool中定义的四个接口:PrepareContext、ExecContext、QueryContext、QueryRowContext。也就是说:database/sql的sql.DB结构实现了Gorm库的ConnPool接口。

而实际上,database/sql里面的sql.DB结构就是一个连接池结构,我们可以通过以下四个方法设置连接池的不同属性:

1
2
3
4
5
6
7
8
// 设置连接的最大空闲时长
func (db *DB) SetConnMaxIdleTime(d time.Duration)
// 设置连接的最大生命时长
func (db *DB) SetConnMaxLifetime(d time.Duration)
// 设置最大空闲连接数
func (db *DB) SetMaxIdleConns(n int)
// 设置最大打开连接数
func (db *DB) SetMaxOpenConns(n int)

所以gorm.DB里面的ConnPool实际上存放的就是database/sql的sql.DB结构。

callbacks

最后看gorm.DB里面的callbacks字段,它存放的是所有具体函数的调用方法。callback指针指向的数据结构也是叫做同名的callbacks:

1
2
3
4
// callbacks gorm callbacks manager
type callbacks struct {
processors map[string]*processor
}

它里面使用的map包含多个processor。一个processor就是一种操作的处理器。processer的结构定义为:
1
2
3
4
5
6
type processor struct {
db *DB // 对应的 gorm.DB
Clauses []string // 处理器对应的 sql 片段
fns []func(*DB) // 这个处理器对应的处理函数
callbacks []*callback // 这个处理器对应的回调函数,生成 fns
}

开头的那个例子,我们调用了gorm.DB的Create方法,它会去gorm.DB的callbacks中的processors里,寻找key为“create”的处理器processor。然后逐个调用处理器中设置好的fns。下面分析源码的时候也会看到具体的实现逻辑。

源码

现在理解了Gorm在创建连接过程中涉及的几个关键对象,我们就再从源码开始梳理一下Gorm的核心逻辑,理解下Gorm是怎么使用Open创建数据库连接、怎么使用创建的数据库连接的Create方法来创建一条数据的。再把开头官网的例子拿出来。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28

package main

import (
"gorm.io/gorm"
"gorm.io/driver/mysql"
)

type Product struct {
gorm.Model
Code string
Price uint
}

func main() {
dsn := "xxxxxxx"
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{})
if err != nil {
panic("failed to connect database")
}

...

// Create
db.Create(&Product{Code: "D42", Price: 100})

...
}

用第一节课教的思维导图的方式来分析这个Gorm的主流程,主要就是gorm.Open和db.Create两个方法。

gorm.Open

首先是gorm.Open:

1
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{})

我们将函数源码分为四个大步:

第一大步,初始化gorm.Config结构。通过使用参数中的Option可变参数的Apply接口,对最终的配置结构gorm.Config进行相应的修改,其中包括修改输出的Logger结构。

第二步,初始化gorm.DB结构:

1
db = &DB{Config: config, clone: 1}

第三步,初始化gorm.DB的callbacks。

这里我们只拆解了这个例子的create函数相关的callback。核心的关键函数在Gorm库callback.go的RegisterDefaultCallbacks方法。比如下列的代码,就是创建create相关的执行方法fns:

1
2
3
4
5
6
7
8
9
10
11
12
createCallback := db.Callback().Create()
createCallback.Match(enableTransaction).Register("gorm:begin_transaction", BeginTransaction)
createCallback.Register("gorm:before_create", BeforeCreate)
createCallback.Register("gorm:save_before_associations", SaveBeforeAssociations(true))
createCallback.Register("gorm:create", Create(config))
createCallback.Register("gorm:save_after_associations", SaveAfterAssociations(true))
createCallback.Register("gorm:after_create", AfterCreate)
createCallback.Match(enableTransaction).Register("gorm:commit_or_rollback_transaction", CommitOrRollbackTransaction)
if len(config.CreateClauses) == 0 {
config.CreateClauses = createClauses
}
createCallback.Clauses = config.CreateClauses

我们可以看到,Gorm在一个create方法,定义了7个执行方法fns,分别是:BeginTransaction、BeforeCreate、SaveBeforeAssociations、Create、SaveAfterAssociations、AfterCreate、CommitOrRollbackTransaction。这七个执行方法就是按照顺序,从上到下每个Create函数都会执行的方法。

其中关注一下Create方法,它又分为五个步骤:

我们看到了熟悉的ExecContent函数,这个就对应上了Golang标准库的database/sql中sql.DB的ExecContext方法。原来它藏在这里!

那前面说的database/sql的sql.DB的Open方法,又放在哪里呢?就在gorm.Open的第四大步中:

1
db.ConnPool, err = sql.Open(dialector.DriverName, dialector.DSN)

将database/sql中生成的sql.DB结构,设置在了gorm.DB的ConnPool上。

db.Create

下面再来看gorm.DB的Create方法。它的任务就很简单了:触发启动processor中的fns方法。具体最核心的代码就在Gorm的callback.go中的Execute函数里。

可以看到,在Execute函数中,最核心的是遍历fns,调用fn(db)方法,其中就有我们前面定义的Create方法了,也就是执行了database/sql的db.ExecContext方法。

这里我们就根据思维导图找到了Gorm封装的database/sql的两个关键步骤:

  • sql.Open
  • db.ExecContext

理解了这一点,就基本理解了Gorm最核心的实现原理了。

当然Gorm中还有一个部分,是将我们定义的Model解析成为SQL语句,这里又是Gorm定义的一套非常庞大的数据结构支撑的了,其中包括Statement、Schema、Field、Relationship等和数据表操作相关的数据结构。

这需要用另外一个篇幅来描述了。不过这块Model解析,对我们下一章hade框架融合Gorm的影响并不大。有兴趣的同学可以追着上述Create方法中的stmt.Parse方法进一步分析。

今天我们还没有涉及代码修改,思维导图保存在GitHub上的geekbang/25分支中根目录的mysql.xmind中了。

小结

我们分析了Gorm的具体数据结构和创建连接的核心源码流程。想要检验自己是否理解这节课也很简单,你可以对照开头为user表插入一行的代码,看看能不能清晰分析出它的底层是如何封装标准库的database/sql来实现的。

我们在阅读Gorm源码的同时,也是在学习它的优秀编码方式,比如今天讲到的Option方式、定义驱动、ConnPool定义实现标准库方法的接口。这些都是Gorm设计精妙的地方。

当然Gorm的代码远不是一篇文章能说透的。其中包含的Model解析,以及更多的具体细节实现,都得靠你在后续使用过程中多看官网、多思考、多解析,才能完全吃透这个库。

思考题

GORM有一个功能我非常喜欢,DryRun空跑,这个设置是在gorm.DB结构中的。如果我们设置了gorm.DB的DryRun,能让我在这个DB中的所有SQL操作并不真正执行,这个功能在调试的时候是非常有用的。你能再顺着思维导图,分析出DryRun是怎么做到这一点的么?

0%