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相关的关系。其结构体定义如下:

Loading...

重点字段解析:


  • Name:关系对应的结构体名。
  • Type:四类关系
Loading...
  • 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 中。

Loading...

 

parseRelation:根据parent struct解析出的field信息,解析关系类型Type以及详细信息,并加入到对应的关系类型数组。

Loading...

 

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关系的应用:

Loading...

抽象其执行流程,猜测其大致思路,对每条BelongsTo关系:

  1. 解析field以及PrimarySchema。这个在上面的解析过程中已经实现了。
  2. 取出field应该的Value,如果为空则跳过关系的处理。 field.ValueOf 已经具备了该能力。
  3. 根据PrimarySchema和对应的Value,写入primary表。写入返回的对象也会存储在Value中。即上面代码中的 User.Company 字段中。
  4. 取出PrimarySchema的主键PrimaryKey值,赋值给ForeignSchema中的外键ForeignKey。即上面的 User.CompanyID = User.Company.ID
  5. 将外键值和ForeignSchema的其他值写入外键表。

 

BelongTo关系中,当前schema(即field对应的schema,上面的User)为ForeignSchema,关系对象对应的Schema为PrimarySchema。

来看SaveBeforeAssociations中的执行流程:

  1. 遍历所有BelongsTo关系,跳过已经明确忽略的字段,仅处理需要处理的(关系)字段,包括select明确指定的字段或schema中有写权限的字段。
    Loading...
  2. 对PrimarySchema的处理(类型为struct):
    1. 先判断数据是否空值,如果为空值则跳过
    2. saveAssociations:写primary表
      1. 从selectColumns中挑选关系相关的字段,包括select、omit的字段。使用prmary名称匹配字段前缀即可。
      2. 构建一个新的db会话 db.Session ,配置继承原db配置
      3. 处理FullSaveAssociations: 如果设置了完全保存关联,则启用时间追踪。
      4. 新会话设置选择和忽略的列
      5. 使用新回话调用 tx.Create 写入。Create内部又会调用一遍callbacks,本质上是个递归操作。所以如果是有多层Relation关系也是支持的。
    3. saveAssociations写入完毕之后,数据对象值会存储在relation所属的field值上。即上面demo代码中的 User.Company 字段上。
    4. setupReferences:将主键值赋值给外键 ForeignKey。如果当前操作的结果是map[string]interface{},则将关联对象和外键的值添加到这个映射中。
  3. 如果类型不是struct而是slice、array,与上面的流程类似。不过在调用saveAssociations传入的值是slice。批量写入完毕之后再遍历调用setupReferences完成外键赋值。
    1. 注意在写入primary数组的时候需要根据其primary key做去重操作。

SaveAfterAssociations

HasOne

HasOne关系的特点:

  • 前后关系:写场景时也需要先写入Primary表(即Parent表),然后将返回的主键字段作为外键值写入Foreign表(Child表)。
  • HasOne关系为 O2O ,即允许Foreign表1条记录对应1条Primary表记录。
  • 写入ForeignSchema时需要考虑冲突的场景,冲突时保障关联关系的有效。

来看一个具体的应用:

Loading...

HasOne关系处理是在 SaveAfterAssociations 中完成,这表明已经处理完PrimarySchema的写入了,即已经获得了 PrimaryKey 的值。剩下的工作只需要做两件事情:

  1. 将 pk 值赋值给 Foreign schema中的 Foreign key。
  2. 完成foreign schema表的写入。

SaveAfterAssociations 中HasOne关系的处理的处理流程大致如下:

  1. 遍历所有 HasOne 关系,跳过已经明确忽略的字段,仅处理需要处理的(关系)字段,包括select明确指定的字段或schema中有写权限的字段。
  2. 对ForeignSchema的处理(类型为struct):
    1. 先判断foreign field数据是否空值,如果为空值则跳过
    2. 外键赋值:将 primary key赋值给 foreign key。
      1. 从 db.Statement.ReflectValue 取出主键值 ref.PrimaryKey.ValueOf
      2. 赋值对象 rel.Field ,及上面的User.Credit对象,赋值字段 ref.ForeignKey,即 Rerfercence 中 ForeignKey所指向的字段。
    3. saveAssociations:写 foreign 表,流程与BelongTo关系中的处理是一样的。区别在于HasOne关系需要考虑冲突的可能。
      1. 当写入外键表时,如果写入发生了冲突,比如其他唯一索引存在导致写入失败,为了数据的一致性会加入冲突处理的 clause。比如上面demo代码中的 ON DUPLICATE KEY UPDATE
  3. 如果类型不是struct而是slice、array,与上面的流程类似。不过在调用saveAssociations传入的值是slice。

HasMany

HasMany关系的处理与HasOne流程基本类似,区别在于 HasMany 处理对象时数组。

  1. 遍历所有 HasMany 关系,跳过已经明确忽略的字段,仅处理需要处理的(关系)字段,包括select明确指定的字段或schema中有写权限的字段。
  2. 对ForeignSchema的处理(类型为struct数组):
    1. 先判断foreign field数据是否空值,如果为空值则跳过
    2. 外键赋值:遍历数组elem,将同一 primary key赋值给 foreign key。
      1. 从 db.Statement.ReflectValue 取出主键值 ref.PrimaryKey.ValueOf
      2. 赋值对象 rel.Field ,及上面的User.Credit对象,赋值字段 ref.ForeignKey,即 Rerfercence 中 ForeignKey所指向的字段。
    3. saveAssociations:写 foreign 表,流程与HasOne关系中的处理是一样的。
  3. 如果类型不是struct而是slice、array(二维数组?),与上面的流程类似。
  4. 与HasOne的区别:
    1. 因为HasMany为一对多,所以在批量写入Foreign表时需要考虑重复的问题。如果对应的foreignSchema已经有pk id了,这部分数据就应该在写入时去重,仅写入一次。

Many2Many

后续分析

 

关联对象读

Preload API中仅仅将条件保存到会话,实际的关联对象查询是在当前会话执行完毕之后才会启动。

Loading...

什么时候生效呢?query涉及到的callback有三个:

Loading...

Query 之前文章介绍过了,其功能是实现查询的主逻辑。而关系对象的读取其实是在 AfterQuery 中处理。

Preload

Preload 负责拉取关系表数据,拉取关系表数据逻辑可以抽象为以下几个步骤:

  1. 构造查询条件:查询字段以及值列表,对不同的关系类型有不同的取值方法
    1. 取字段:BelongTo关系,字段名为PrimaryKey field的DB名。而Has关系则为foreignKey field对应DB名。
    2. 取值:BelongTo关系,字段名为foreign field的值。而Has关系则为primary field对应值。
    3. 值去重:查询条件的值可能会包含重复值,执行查询前需要去重。
  2. 执行查询:构建新会话,执行Query逻辑。并将查询结构存储到临时存储。——递归执行 Query processor的execute。
  3. 赋值:从关系表中查询出的数据,需要赋值到Dest结构的对应的字段中。赋值需要匹配对应的外键关系是否准确。

关系的查询执行也是递归调用,如果关系表数据本身也有对应的关系数据,执行过程中会触发递归的调用。

 

Preload流程分析:

  1. 排序:对多个preload进行排序,确保执行顺序一致。
  2. 如果是嵌套Preload,递归执行Preload
  3. preload
    1. 构建关系表查询条件:根据关系对象,解析查询的字段名和值。并去重处理 —— GetIdentityFieldValuesMap
    2. 执行 查询
    3. 清理旧记录,避免无数据导致时导致的脏数据
    4. 赋值,将查询结果的每个对象赋值给对应的(多)列。性能考虑会构建反向索引,避免多次遍历。

 

 

See all postsSee all posts