你好,我是轩脉刃。

一个 Web 应用,有很大部分功能是对数据库中数据的获取和加工。比如一个用户管理系统,我们在业务代码中需要频繁增加用户、删除用户、修改用户等,而用户的数据都存放在数据库中。所以对数据库的增删改查,是做 Web 应用必须实现的功能。而我们的 hade 框架如何更好地支持数据库操作呢?这两节课我们就要讨论这个内容。

ORM

提到数据库,就不得不提ORM了,有的同学一接触 Web 开发,就上手使用 ORM 了,这里我们要明确一点:ORM 并不等同于数据库操作。

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

ORM 这个概念出现的时间无从考究了,基本上从面向对象的编程思想出来的时候就有讨论了。但是到现在,是否要使用 ORM 的讨论也一直没有停止。

不支持使用 ORM 的阵营的观点基本上是使用 ORM 会影响性能,且会让使用者不了解底层的具体最终拼接出来的SQL,容易造成用不上索引或者最终拼接错误的情况。而支持使用 ORM 的阵营的观点主要是它能切切实实加速应用开发。

就我个人的观点和经验,我还是支持使用 ORM 的。我认为 ORM 不仅仅是一种映射技术,也是一种建模思想。因为数据库是和业务紧密关联起来的,建立数据库表结构的时候,也是建立了一个业务模型。使用代码中的类定义,比如定义了一个 User 类,基本上就定义了一个 User 表,这样也是一个建立业务模型的过程。

其实不论 ORM 的讨论如何激烈,基本上各个语言都已经有了 ORM 的实现,比如 Java 的 Hibernate、PHP 的 Doctrine、Ruby 的 ActiveRecord。而在 Golang 中,现在最流行的 ORM 库是国人的开源项目Gorm

Gorm 作者 Jinzhu 目前是字节跳动的员工,他在 GitHub 上开源共享了诸如 copier、configer 等开源项目,Gorm 目前 star 数有 26k 之多,使用 MIT 的许可证协议,项目启动于 2013 年,目前是 v2 版本。

这个 v2 版本对应 Gorm GitHub 上 v1.20 以上的 tag,这点我们要额外注意。因为网上的分析文章很多都是基于Gorm 的 v1 版本写的,但Gorm 的 v1 和 v2 版本相差比较大。所以在看 Gorm 文章的时候需要先明确下是什么版本。

我们的框架侧重于整合,站在巨人的肩膀上才更符合现代化框架的要求。基于此, hade 框架并不打算重新开发一套 ORM 框架,而是会直接融合 Gorm 框架成为我们容器中的一个服务 orm service。

版本选择的是 Gorm 截止 2021/10/23 日最新的 v1.21.16 的 tag。毕竟 Gorm 是个有一定体量的项目,而且理解它的重点部分源码的实现原理,对使用者来说非常重要,值得我们先花一章来学习理解。如何融合 Gorm,我们下节课继续学。

Gorm

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

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

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 中关键的几个对象,把这些关键数据结构一一理解透,再跟踪具体的源码能事半功倍。另外也推荐你边看边自己画出这几个关键参数的关系,非常有助于理解和记忆,每个参数讲完之后我也会展示一下我画的分析图供你参考。

来看它的源码定义:

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

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

Dialector

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

所以,这个接口 Dialecotor 中定义的所有方法,都是在后续的查询、更新、数据库迁移等操作中会使用到的。具体每个方法在哪里使用到的,如果你感兴趣可以跟踪下去,如果你不感兴趣也无所谓,只需要记得在后续某个 gorm 接口的具体实现中会用到就行。

// 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 的连接,使用下面这个例子:

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 驱动用的。而这个 Open 函数只有一个字符串参数 DSN,这个参数可能有的同学还不是很了解,我们一起研究下。

DSN

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

DSN 定义了一个数据库的连接方式及信息,包含用户名、密码、数据库 IP、数据库端口、数据库字符集、数据库时区等信息。可以说一个 DSN 就是一个数据源的描述。但是 DSN 并没有明确的官方文档要求其格式,每个语言、每个平台都可以自己定义 DSN 格式,只要定义和解析能对得上就行。

在社区中,大家普遍会按照以下这种格式来进行定义:

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

比如通过 Unix 的 socket 句柄连接本机 MySQL:

mysql://user@unix(/path/to/socket)/dbname

通过 TCP 连接远端 postgres:

pgsql://user:pass@tcp(localhost:5555)/dbname

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

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

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

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 方法:

// 解析 dsn
func ParseDSN(dsn string) (cfg *Config, err error) 

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

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

Option

第一个初始化数据库的参数 dialector 以及之前必要的驱动引入相关参数DSN,就讲解到这里。我们回头继续看 Open 函数:

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

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

// Option 接口
type Option interface {
   Apply(*Config) error
   AfterInitialize(*DB) error
}

这种可变参数如何使用呢?我们看下 Open 的源码:

// 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 指针:

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

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

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

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

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的示例就很好理解了,看注释:

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 的结构指针,定义如下:

type DB struct {
	*Config
	Error        error
	RowsAffected int64
	Statement    *Statement
	// Has unexported fields.
}

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

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

// 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 的所有数据库操作的日志,都输出到这个类中。

// 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 中最精妙的一个地方了:

// 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 的简要实现大致如下:

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 结构就是一个连接池结构,我们可以通过以下四个方法设置连接池的不同属性:

// 设置连接的最大空闲时长
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:

// callbacks gorm callbacks manager
type callbacks struct {
   processors map[string]*processor
}

它里面使用的 map 包含多个 processor。一个 processor 就是一种操作的处理器。processer 的结构定义为:

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 方法来创建一条数据的。再把开头官网的例子拿出来。

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:

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

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

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

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

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

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

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

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 的第四大步中:

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 的两个关键步骤:

理解了这一点,就基本理解了 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 是怎么做到这一点的么?

欢迎在留言区分享你的思考。感谢你的收听,如果你觉得今天的内容对你有所帮助,也欢迎分享给身边的朋友,邀请他一起学习。我们下节课见~