go-gorm源码分析(三)
November 13, 2024
GORM支持的关系类型主要包括一对一、一对多、多对多等关联关系的定义和查询。在GORM中,开发者可以在模型中定义这些关联关系,并使用GORM提供的方法进行关联查询和操作。
本文重点介绍其关系的实现原理。
数据结构
作用:关系用来定义DB表(golang struct)之间的关联关系,通过关系字段,可以在CRUD相关API可以间接操作关系表,以保障数据的一致性。
关系定义了Primary Schema和Foreign Schema之间的关联关系:
- Primary schema:DB parent table对应的struct解析后的schema。通常会有一个或多个主键(Primary field)来唯一定位一条记录,用作被外键Reference(引用)
- Foreign schema:DB child table对应的struct解析后的schema。通常会有一列外键(Foreign field)用来存储到Primary Schema中的主键值。
在Schema的结构体定义下存在一个Relationships字段,记录了所有Struct相关的关系。其结构体定义如下:
重点字段解析:
- Name:关系对应的结构体名。
- Type:四类关系
- Field:关系struct在parent struct中对应的Filed定义,参考之前的文章。
- References:包含了主键和外键的关联关系。
- Schema:关系struct的parent struct的schema解析结果。
- FieldSchema:关系struct解析出的schema
- foreignKeys:重写外键Foreign key
- primaryKeys:重写引用References
解析过程
在解析struct Schema的过程中,会对关系类struct field调用 parseRelation 解析关系,并将解析结果存储到 schema.Relationships 中。
parseRelation:根据parent struct解析出的field信息,解析关系类型Type以及详细信息,并加入到对应的关系类型数组。
guessRelation:根据当前field所属的schema定义 与 field解析出的schema定义,猜测解析关系的类型,只能为 BelongsTo 或者 has 之一。大致流程:
- 根据关系类型,确定primarySchema和foreignSchema。因为BelongTo和Has(HasOne、HasMany)的PrimarySchema和ForeignSchema正好相反。
以官网的关系demo为例:
HasOne、HasMany中User struct都属于PrimarySchema,而BelongTo相反。
- 根据猜测的primarySchema、foreignSchema,查找外键field、主键field字段(Field)。
- 如果重写了外键(Override Foreign Key),则直接从ForeignSchema中获取外键 foreignFields。
- 否则试图根据 PrimarySchema 中主键primaryField匹配合适的外键 foreignFields。
- 如果重写了引用(Override-References),则优先使用PrimarySchema中的指定field,否则默认使用 primarySchema.PrimaryFields 作为主键。
- 遍历主键,生成可能匹配的外键名称,包括 primarySchemaName + ID/Id/_id 等。
- 根据可能出现的外键名称,查找相关字段是否存在。如果找到,则将对应主键、外键加入 foreignFields、primaryFields。
- 验证foreignFields、primaryFields关系。如果验证失败,表示猜测错误,需要重新guess递归解析。
关联对象写
以写操作为例,来看关系如何应用在关联对象的处理上。在之前的文章中已经介绍过,关系写入主要是在processor中的两个callback中生效。
- SaveBeforeAssociations: 处理前置关系,只有BelongTo
- SaveAfterAssociations:后置关系处理,主要是对于HasOne、HasMany、Many2Many关系的处理。
SaveBeforeAssociations
BelongTo关系的特点:
- 前后关系:写场景时需要先写入Primary表(即Parent表),然后将返回的主键字段作为外键值,写入Foreign表(Child表)。
- BelongTo关系本质为 M2O ,即允许Foreign表多条记录对应1条Primary表记录。批量插入时需要考虑去重。
来看BelongTo关系的应用:
抽象其执行流程,猜测其大致思路,对每条BelongsTo关系:
- 解析field以及PrimarySchema。这个在上面的解析过程中已经实现了。
- 取出field应该的Value,如果为空则跳过关系的处理。 field.ValueOf 已经具备了该能力。
- 根据PrimarySchema和对应的Value,写入primary表。写入返回的对象也会存储在Value中。即上面代码中的 User.Company 字段中。
- 取出PrimarySchema的主键PrimaryKey值,赋值给ForeignSchema中的外键ForeignKey。即上面的 User.CompanyID = User.Company.ID 。
- 将外键值和ForeignSchema的其他值写入外键表。
BelongTo关系中,当前schema(即field对应的schema,上面的User)为ForeignSchema,关系对象对应的Schema为PrimarySchema。
来看SaveBeforeAssociations中的执行流程:
- 遍历所有BelongsTo关系,跳过已经明确忽略的字段,仅处理需要处理的(关系)字段,包括select明确指定的字段或schema中有写权限的字段。Loading...
- 对PrimarySchema的处理(类型为struct):
- 先判断数据是否空值,如果为空值则跳过
- saveAssociations:写primary表
- 从selectColumns中挑选关系相关的字段,包括select、omit的字段。使用prmary名称匹配字段前缀即可。
- 构建一个新的db会话 db.Session ,配置继承原db配置
- 处理FullSaveAssociations: 如果设置了完全保存关联,则启用时间追踪。
- 新会话设置选择和忽略的列
- 使用新回话调用 tx.Create 写入。Create内部又会调用一遍callbacks,本质上是个递归操作。所以如果是有多层Relation关系也是支持的。
- saveAssociations写入完毕之后,数据对象值会存储在relation所属的field值上。即上面demo代码中的 User.Company 字段上。
- setupReferences:将主键值赋值给外键 ForeignKey。如果当前操作的结果是map[string]interface{},则将关联对象和外键的值添加到这个映射中。
- 如果类型不是struct而是slice、array,与上面的流程类似。不过在调用saveAssociations传入的值是slice。批量写入完毕之后再遍历调用setupReferences完成外键赋值。
- 注意在写入primary数组的时候需要根据其primary key做去重操作。
SaveAfterAssociations
HasOne
HasOne关系的特点:
- 前后关系:写场景时也需要先写入Primary表(即Parent表),然后将返回的主键字段作为外键值写入Foreign表(Child表)。
- HasOne关系为 O2O ,即允许Foreign表1条记录对应1条Primary表记录。
- 写入ForeignSchema时需要考虑冲突的场景,冲突时保障关联关系的有效。
来看一个具体的应用:
HasOne关系处理是在 SaveAfterAssociations 中完成,这表明已经处理完PrimarySchema的写入了,即已经获得了 PrimaryKey 的值。剩下的工作只需要做两件事情:
- 将 pk 值赋值给 Foreign schema中的 Foreign key。
- 完成foreign schema表的写入。
SaveAfterAssociations 中HasOne关系的处理的处理流程大致如下:
- 遍历所有 HasOne 关系,跳过已经明确忽略的字段,仅处理需要处理的(关系)字段,包括select明确指定的字段或schema中有写权限的字段。
- 对ForeignSchema的处理(类型为struct):
- 先判断foreign field数据是否空值,如果为空值则跳过
- 外键赋值:将 primary key赋值给 foreign key。
- 从 db.Statement.ReflectValue 取出主键值 ref.PrimaryKey.ValueOf
- 赋值对象 rel.Field ,及上面的User.Credit对象,赋值字段 ref.ForeignKey,即 Rerfercence 中 ForeignKey所指向的字段。
- saveAssociations:写 foreign 表,流程与BelongTo关系中的处理是一样的。区别在于HasOne关系需要考虑冲突的可能。
- 当写入外键表时,如果写入发生了冲突,比如其他唯一索引存在导致写入失败,为了数据的一致性会加入冲突处理的 clause。比如上面demo代码中的 ON DUPLICATE KEY UPDATE 。
- 如果类型不是struct而是slice、array,与上面的流程类似。不过在调用saveAssociations传入的值是slice。
HasMany
HasMany关系的处理与HasOne流程基本类似,区别在于 HasMany 处理对象时数组。
- 遍历所有 HasMany 关系,跳过已经明确忽略的字段,仅处理需要处理的(关系)字段,包括select明确指定的字段或schema中有写权限的字段。
- 对ForeignSchema的处理(类型为struct数组):
- 先判断foreign field数据是否空值,如果为空值则跳过
- 外键赋值:遍历数组elem,将同一 primary key赋值给 foreign key。
- 从 db.Statement.ReflectValue 取出主键值 ref.PrimaryKey.ValueOf
- 赋值对象 rel.Field ,及上面的User.Credit对象,赋值字段 ref.ForeignKey,即 Rerfercence 中 ForeignKey所指向的字段。
- saveAssociations:写 foreign 表,流程与HasOne关系中的处理是一样的。
- 如果类型不是struct而是slice、array(二维数组?),与上面的流程类似。
- 与HasOne的区别:
- 因为HasMany为一对多,所以在批量写入Foreign表时需要考虑重复的问题。如果对应的foreignSchema已经有pk id了,这部分数据就应该在写入时去重,仅写入一次。
Many2Many
后续分析
关联对象读
Preload API中仅仅将条件保存到会话,实际的关联对象查询是在当前会话执行完毕之后才会启动。
什么时候生效呢?query涉及到的callback有三个:
Query 之前文章介绍过了,其功能是实现查询的主逻辑。而关系对象的读取其实是在 AfterQuery 中处理。
Preload
Preload 负责拉取关系表数据,拉取关系表数据逻辑可以抽象为以下几个步骤:
- 构造查询条件:查询字段以及值列表,对不同的关系类型有不同的取值方法
- 取字段:BelongTo关系,字段名为PrimaryKey field的DB名。而Has关系则为foreignKey field对应DB名。
- 取值:BelongTo关系,字段名为foreign field的值。而Has关系则为primary field对应值。
- 值去重:查询条件的值可能会包含重复值,执行查询前需要去重。
- 执行查询:构建新会话,执行Query逻辑。并将查询结构存储到临时存储。——递归执行 Query processor的execute。
- 赋值:从关系表中查询出的数据,需要赋值到Dest结构的对应的字段中。赋值需要匹配对应的外键关系是否准确。
关系的查询执行也是递归调用,如果关系表数据本身也有对应的关系数据,执行过程中会触发递归的调用。
Preload流程分析:
- 排序:对多个preload进行排序,确保执行顺序一致。
- 如果是嵌套Preload,递归执行Preload
- preload
- 构建关系表查询条件:根据关系对象,解析查询的字段名和值。并去重处理 —— GetIdentityFieldValuesMap
- 执行 查询
- 清理旧记录,避免无数据导致时导致的脏数据
- 赋值,将查询结果的每个对象赋值给对应的(多)列。性能考虑会构建反向索引,避免多次遍历。