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

[go]
type Relationships struct {
  // 各种关系分类存储
	HasOne    []*Relationship
	BelongsTo []*Relationship
	HasMany   []*Relationship
	Many2Many []*Relationship
	
	// 所有关系。key为关系名(结构体名)
	Relations map[string]*Relationship

	EmbeddedRelations map[string]*Relationships
}

type Relationship struct {
	Name        string // 字段名. field.Name
	Type        RelationshipType
	Field       *Field // 指向所属的field
	Polymorphic *Polymorphic
	// 关联字段数组,每项记录了 primary 表的 PrimaryKey 字段 和 关联的 ForeignKey 字段
	References  []*Reference
	Schema      *Schema // 指向所属的schema
	FieldSchema *Schema // 嵌套关系field解析出的schema。即关系表的schema
	JoinTable   *Schema

	// Composite-Foreign-Keys时为数组
	foreignKeys []string // 关联的外键字段名. Override Foreign Key
	primaryKeys []string // 被引用的字段名(即对应parent表中的字段名)。 Override References。
}

type Reference struct {
	PrimaryKey    *Field  // primary Schema中的主键
	PrimaryValue  string
	ForeignKey    *Field   // foreign Schema中的外键
	OwnPrimaryKey bool
}

重点字段解析:


  • Name:关系对应的结构体名。
  • Type:四类关系
[go]
const (
	HasOne    RelationshipType = "has_one"      // HasOneRel has one relationship
	HasMany   RelationshipType = "has_many"     // HasManyRel has many relationship
	BelongsTo RelationshipType = "belongs_to"   // BelongsToRel belongs to relationship
	Many2Many RelationshipType = "many_to_many" // Many2ManyRel many to many relationship
	has       RelationshipType = "has"          // 占位符,表明还不确定关系类型
)
  • 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 中。

[go]
// embeddedCacheKey 表示 schema 对应的struct为 embeded、anonymous 结构体,此时不需要解析关系型字段
	if _, embedded := schema.cacheStore.Load(embeddedCacheKey); !embedded {
		for _, field := range schema.Fields {
			if field.DataType == "" && field.GORMDataType == "" && (field.Creatable || field.Updatable || field.Readable) {
				// 解析关系字段
				if schema.parseRelation(field); schema.err != nil {
					return schema, schema.err
				} else {
					// 关系类字段也需要加入 FieldsByName 和 FieldsByBindName 中,用于后续的 LookUpFieldByBindName 等查找
					schema.FieldsByName[field.Name] = field
					schema.FieldsByBindName[field.BindName()] = field
				}
			}
			//...
	}
}

 

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

[go]

func (schema *Schema) parseRelation(field *Field) *Relationship {
	var (
		err        error
		fieldValue = reflect.New(field.IndirectFieldType).Interface()
		relation   = &Relationship{
			Name:        field.Name,
			Field:       field,  // 指向所属的field
			Schema:      schema, // 指向所属的schema
			foreignKeys: toColumns(field.TagSettings["FOREIGNKEY"]),
			primaryKeys: toColumns(field.TagSettings["REFERENCES"]),
		}
	)

	cacheStore := schema.cacheStore

	// 解析关系struct对应的Schema定义
	if relation.FieldSchema, err = getOrParse(fieldValue, cacheStore, schema.namer); err != nil {
		schema.err = err
		return nil
	}

	// 多态关系
	if hasPolymorphicRelation(field.TagSettings) {
		schema.buildPolymorphicRelation(relation, field)
	} else if many2many := field.TagSettings["MANY2MANY"]; many2many != "" {
		// 明确指定为 M2M 关系
		schema.buildMany2ManyRelation(relation, field, many2many)
	} else if belongsTo := field.TagSettings["BELONGSTO"]; belongsTo != "" {
		// 明确指定为 BelongsTo 关系
		schema.guessRelation(relation, field, guessBelongs)
	} else {
		switch field.IndirectFieldType.Kind() {
		case reflect.Struct:
			// 结构体,不确定关系类型,猜测关系类型
			schema.guessRelation(relation, field, guessGuess)
		case reflect.Slice:
			// 切片,可能为 HasMany(O2M)、M2M 关系
			schema.guessRelation(relation, field, guessHas)
		default:
			schema.err = fmt.Errorf("unsupported data type %v for %v on field %s", relation.FieldSchema, schema,
				field.Name)
		}
	}

	// 如果关系类型为has(一个占位符),则根据字段类型(结构体为HasOne,切片为HasMany)更新关系类型。
	if relation.Type == has {
		// don't add relations to embedded schema, which might be shared
		// 如果关系不是嵌入的Schema(即不是同一个Schema),且没有多态性且字段的拥有者Schema为空,将该关系添加到目标Schema的关系中(使用特殊键名避免冲突)。???
		if relation.FieldSchema != relation.Schema && relation.Polymorphic == nil && field.OwnerSchema == nil {
			relation.FieldSchema.Relationships.Relations["_"+relation.Schema.Name+"_"+relation.Name] = relation
		}

		switch field.IndirectFieldType.Kind() {
		case reflect.Struct:
			// 如果是struct,则关系类型为HasOne
			relation.Type = HasOne
		case reflect.Slice:
			// 如果是切片,则关系类型为HasMany
			relation.Type = HasMany
		}
	}

	if schema.err == nil {
		// 设置关系并添加到Schema的关系列表中
		schema.setRelation(relation)

		switch relation.Type {
		case HasOne:
			schema.Relationships.HasOne = append(schema.Relationships.HasOne, relation)
		case HasMany:
			schema.Relationships.HasMany = append(schema.Relationships.HasMany, relation)
		case BelongsTo:
			schema.Relationships.BelongsTo = append(schema.Relationships.BelongsTo, relation)
		case Many2Many:
			schema.Relationships.Many2Many = append(schema.Relationships.Many2Many, relation)
		}
	}

	return relation
}

 

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

[go]
// foreign schema
type User struct {
	gorm.Model
	Name           string `gorm:"size:128;index:user_name"`
	Age            int
	CompanyID      uint // 默认情况下, CompanyID 被隐含地用来在 User 和 Company 之间创建一个外键关系, 因此必须包含在 User 结构体中才能填充 Company 内部结构体。
	Company        Company
}
// primary schema
type Company struct {
	ID   int
	Name string
}
c := Company{
	ID:   1,
	Name: "Taobao Inc",
}
user := User{
	Name:    "alex",
	Age:     age % 60,
	Company: c,
}
db.Create(&user)

// INSERT INTO `t_Company` (`Name`,`ID`) VALUES (?,?) ON DUPLICATE KEY UPDATE `ID`=`ID`
// INSERT INTO `t_User` (`CreatedAt`,`UpdatedAt`,`DeletedAt`,`Name`,`Age`,`CompanyID`) VALUES (?,?,?,?,?,?)

抽象其执行流程,猜测其大致思路,对每条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中有写权限的字段。
    [go]
    // 处理"Belongs To"关联
    for _, rel := range db.Statement.Schema.Relationships.BelongsTo {
    	// 检查是否应该被包括或排除在当前的数据库操作中(基于selectColumns和restricted)。
    	if v, ok := selectColumns[rel.Name]; (ok && !v) || (!ok && restricted) {
    		continue
    	}
    	// ...
    }
    
  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时需要考虑冲突的场景,冲突时保障关联关系的有效。

来看一个具体的应用:

[go]
func HasOneCreate(db *gorm.DB) {
	age := rand.Int()
	c := CreditCard{
		Model: gorm.Model{
			ID: 1,
		},
		Number: "12345678",
	}
	user := User{
		Name:       "alex",
		Age:        age % 60,
		CreditCard: c,
	}
	db.Create(&user)

	// another user with the same one relation
	user = User{
		Name:       "bob",
		Age:        age % 60,
		CreditCard: c,
	}
	db.Create(&user)
}
// INSERT INTO `t_CreditCard` (`CreatedAt`,`UpdatedAt`,`DeletedAt`,`Number`,`UserID`,`ID`) VALUES (?,?,?,?,?,?) ON DUPLICATE KEY UPDATE `UserID`=VALUES(`UserID`)
// INSERT INTO `t_User` (`CreatedAt`,`UpdatedAt`,`DeletedAt`,`Name`,`Age`) VALUES (?,?,?,?,?)
// INSERT INTO `t_CreditCard` (`CreatedAt`,`UpdatedAt`,`DeletedAt`,`Number`,`UserID`,`ID`) VALUES (?,?,?,?,?,?) ON DUPLICATE KEY UPDATE `UserID`=VALUES(`UserID`)
// INSERT INTO `t_User` (`CreatedAt`,`UpdatedAt`,`DeletedAt`,`Name`,`Age`) VALUES (?,?,?,?,?)

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中仅仅将条件保存到会话,实际的关联对象查询是在当前会话执行完毕之后才会启动。

[go]


// Statement statement
type Statement struct {
	// Join 语句
	Joins    []join
	Preloads map[string][]interface{}
	// ...
}

// Preload preload associations with given conditions
//
//	// get all users, and preload all non-cancelled orders
//	db.Preload("Orders", "state NOT IN (?)", "cancelled").Find(&users)
func (db *DB) Preload(query string, args ...interface{}) (tx *DB) {
	tx = db.getInstance()
	if tx.Statement.Preloads == nil {
		tx.Statement.Preloads = map[string][]interface{}{}
	}
	tx.Statement.Preloads[query] = args
	return
}

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

[go]
queryCallback := db.Callback().Query()
queryCallback.Register("gorm:query", Query)
queryCallback.Register("gorm:preload", Preload)
queryCallback.Register("gorm:after_query", AfterQuery)
queryCallback.Clauses = config.QueryClauses

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