go-gorm源码分析(一)

October 15, 2024

最近在查找一些golang版本的SQL Builder的选型,刚好了解到了gorm的能力。gorm内嵌的SQL拼接能力其实不太适合作为独立特性来使用,其内部实现与gorm有较深的耦合关系。但是gorm的关系操作能力很强大,猜测跟directus中的实现有些类似。刚好之前阅读directus源码时觉得ts代码有些繁杂晦涩,所以阅读一遍gorm的源码,并记录其分析过程。

源码内容较大,相关分析会分几批来进行。

简介

GORM是golang下的ORM库,主要能力包括:

  • ORM能力:即通过golang中的object(常见为struct)能够与DB表中的记录一一映射,通过操作对象打到快速读写DB的能力。
  • 关联关系:实现了BelongTo(M2O)、HasMany(O2M)、HasOne(O2O)、ManyToMany(M2M)等常见的关系。即通过一次API调用,实现关联表的相关读写操作,避免在业务逻辑中维护关联关系。
  • Create,Save,Update,Delete,Find 中钩子方法:可定义struct的各种操作hook,在DB操作前后对写入、读取的数据进行格式化等处理。
  • 内嵌SQL builder,支持select、join、clause设置等。

更多特性以及用法见官方文档

准备

在准备分析之前,需要准备一些测试代码,用官方的demo就可以,在本地使用docker搭建mysql环境,然后直接运行相关代码。注意点:

  • 开启日志

通过配置开启gorm的日志,可以看到其内部的一些输出。除了错误信息外(默认会开启),目前仅有执行SQL语句的输出,同时也包括其执行耗时、rowsAffected等明细数据。

Loading...

注意:日志输出顺序不等于sql执行顺序。比如在部分关系的处理上,对Relation表的查询需要依赖Primary的查询结果id。但是从日志上看可能会看到Relation表日志先输出。实际在执行调度上是通过callback类似机制触发,不过日志打印先行执行而已。

此外,为了避免对DB的影响,gorm也提供了dryRun模式,仅用于输出SQL。

  • 数据结构查看方法

gorm一些核心数据结果阅读代码可能比较隐晦,尤其各种reflect的代码充斥其中。可以通过调用其非公开API并打印日志,来观察其核心数据结构。比如下面代码演示如何查看核心数据结schema、fields。

Loading...

DB配置以及连接管理

配置方式

gorm的配置及使用方式可以见官方文档

从代码中可以看出,option是采用golang的Functional Options模式来实现,支持指定多个option。

Loading...

常见的配置项

  • SkipDefaultTransaction

是否禁用默认transcation,默认为false。及所有变更操作(create、update、delete)默认会开启事务(因为可能会涉及关系表处理等多次DB交互的场景)。禁用默认事务会带来性能的提升,但会影响数据一致性。

事务生效过程与其他db相关操作一样,都是通过callback的方式注册到写操作的各个 processor 的callback链条中。在 proces.Execute() 中被调度。

注册:default callback注册时,会根据此配置决定是否开启事务callback。

Loading...

callback函数:

Loading...
  • NamingStrategy

用于处理命名映射,比如golang结构体字段名(仅允许大写开头的字母)和db表中列名的映射等。

  • DryRun

DryRun模式开关,用于调试SQL。

  • FullSaveAssociations

更新关联表,gorm更新时默认不更新关系表字段,需要时通过全局配置或者session开启。

  • PrepareStmt

是否启用golang sql的 prepared-statements 模式。即开启后db的执行会通过 Stmt 模式执行,适用于批量或者多goroutine并存的场景。

  • DisableNestedTransaction
  • AllowGlobalUpdate

GORM 默认不允许进行全局 update/delete,该操作会返回 ErrMissingWhereClause 错误。在Query、Delete callback中会校验where条件是否满足(soft delete条件除外)。

Loading...

连接池

默认情况下,gorm会使用gorm下各dialector提供的连接池实现,以mysql为例,使用的:

Loading...

用户也可以实现自己的连接池,只要满足接口要求,实现CRUD相关功能即可。

Loading...

连接池还有另外一种方式,如果开启了 prepared-statements 模式,gorm会在 sql.DB上层进行一次封装( PreparedStmtDB),其目的有二:

  • 屏蔽stmt相关的逻辑,方便CRUD操作。即在执行相关sql前不再需要上层执行prepare stmt后才能执行query。
  • 性能考虑,在stmt连接池中会在内存中自动缓存prepare的结果(连接有效),key为gorm拼接后的SQL。
Loading...

会话以及链式调用

gorm的API分位三类:

  • Chain Methods:对应到源码中的 chainable_api.go ,通常用户SQL执行前的各种条件。比如 Where、GroupBy、Sort等。
  • Finisher Methods:执行SQL,参见 finisher_api.go 。在finisher_api中会使用Chain Methods增加新的条件,比如 First 仅取一条数据。
  • New Session Methods: 用于创建一个安全的可重用的会话。

先看一个官方的例子

Loading...

使用 queryDB 进行了两次查询操作,但是第二次查询时会带入第一次查询通过链式调佣指定的条件,导致sql污染问题的产生,分析原因之前先了解下链式调用的原理。

链式调用原理

在DB结构体中,存在一个Statment对象,来保存一次会话所有查询条件。

Loading...

为实现链式调用,在多次调用Chain Methods时,各种条件都会被加入到同一个Statment中,并最终被执行。所以上面的SQL污染问题的原因也在于此。在使用 DB.Where() 获取到DB对象 queryDB 后,后续使用同一个 queryDB 多次调用,Chain Methods指定的条件会被累加到一个Statment中。

但是如果直接使用 gorm.Open 返回的db对象,却没出现污染问题:

Loading...

原因就在于Chain Method的处理逻辑上,对于二者存在区别。以Where为例:

Loading...

在Where内部,不是直接使用db对象的Statment,而是先通过getInstance获取到一个“新的”db对象。这里的新不一定会new一个对象,也有可能会复用。

Loading...

可见对于使用 gorm.Open 获取到的db对象和使用 Where 获取到db对象,根据其内部的clone计数标识,在调用后续的 Chain Method时会产生不同的效果。

  • 前者:gorm.Open 初始化时clone为1,此时首次调用getInstance会命中新建逻辑。会新建一个statment对象(clone=0),供后续的调用复用(clone 标记为0)。
  • 后者:由于之前已经通过 Chain Method 获取到了一份复制过的statment(clone值为0),后续的链式调用也会继续复用同一份。

类似数结构,gorm.Open 创建的DB对象可以被多次复制,并产生祖孙节点。所以为了避免污染,尽量使root节点本身或者使用会话模式。

会话模式

如何安全的复用statment以达到条件重复利用的目的呢?gorm提供了会话模式来作为解决方案。参考官方demo:

Loading...

DB.Session() 会复制一份db对象,并将clone标识为2。如上,在Chain Method中调用getInstance时,会命中Statment的clone逻辑。此时虽然也会新建db、Statment,不同的是会将Statment中所有之前的数据copy一份到新建的db.Statment对象中。后续的每次Query调用也都会产生一次新增、copy操作实现类似条件继承的效果,而非直接复用或者纯新增。避免了污染的可能性。

SQL构建

gorm支持Raw SQL,更常见的用法是使用其SQL Builder能力来构建SQL。

Loading...

Statment

SQL Builder的实现原理与其他实现差别不大,比如 go-sqlbuilder。本质上都是通过API设置好目的SQL的各部分参数,最后Build输出完成的SQL语句,具体可设置内容参见Statment的定义:

Loading...

重点结构:

  • Table:指定查询表名称。通过 db.Table() 设置。 db.Table("users")
  • Model:指定数据模型,通过 db.Model() 设置。

在构建SQL之前,如果指定了Model,则会根据其结构定义,使用 golang reflect 相关能力,解析出DB相关表结构信息。

Loading...

ParseWithSpecialTableName实现Schema核心解析逻辑,大致原理是使用golang的反射机制,解析struct定义,以及根据各字段的tag信息,解析出DB中字段的详细信息(数据类型、PrimaryKey、AutoIncrement、是否允许为空、默认值等),以及其与内存中的结构体字段的映射关系,以供后续的CRUD操作(QueryContext、ExecuteContext)使用。此部分内容后面详细分析。

  • scopes

scopes用于存储一些需要动态设置Statment的callback函数,通常用于设置动态条件。各callback会在sql生成前被执行。

Loading...

Clauses

*gorm.Statement 底层实际使用 Clause 列表来生成SQL,暴露的各种API基本上也是对Clause 的操作。从代码目录上看,clause基本就是对SQL各部分拆解后的封装,包括:Insert/Delete/Select/Update、From、Join、Where、GroupBy、OrderBy等。Clause 的接口要求:

Loading...

gorm中支持的 Clause 类型包括:

Loading...

 

在上层设置好各种Clause之后,何时触发SQL的构建呢,以及如何处理各DB的差异?

以Mysql下的插入场景为例,观察其SQL构建过程。

  1. 初始化:在dialector的初始化过程中( gorm.Open() ),会注册cRUD的各processor所需的callback以及相关配置
Loading...

可见各dialector会决定其自身各操作所需的 Clause 名称,来选择其组建SQL所需的 Clause 条件,然后RegisterDefaultCallbacks 中注册各类CRUD操作的callback逻辑,以插入为例,回调操作(callbacks)包括:

Loading...
  1. gorm中的各个 Finisher Methods 会设置Dest,然后触发callbacks链的执行。包括上面代码中的各个callbacks。
  2. 其中 Create callback负责SQL构建以及数据的写入。
Loading...

BuildClause 即为dialector中所指定构建Mysql插入语句时支持的clause名称,即上面的 "INSERT", "VALUES", "ON CONFLICT" 三部分。

最后的Build就比较简单了,取出Statment.Clause 中的各个Clause,调用其Build方法构建SQL子句,最后合并成完整的SQL。

Loading...

其中,允许dialector自定义clause的Build实现。比如mysql就自定义了 ON CONFLICT 等的实现

 

See all postsSee all posts