一、设计背景

大家都知道易用性和易维护性一直是goframe一直努力建设的,也是goframe有别其他框架和组件比较大的一点差异。goframe没有采用其他ORM常见的BelongsTo, HasOne, HasMany, ManyToMany这样的模型关联设计,这样的关联关系维护较繁琐,例如外键约束、额外的标签备注等,对开发者有一定的心智负担。因此框架不倾向于通过向模型结构体中注入过多复杂的标签内容、关联属性或方法,并一如既往地尝试着简化设计,目标是使得模型关联查询尽可能得易于理解、使用便捷。因此在之前推出了ScanList方案,建议大家在继续了解With特性之前先了解一下 模型关联-ScanList

经过一系列的项目实践,我们发现ScanList虽然从运行时业务逻辑的角度来维护了模型关联关系,但是这种关联关系维护也不如期望的简便。因此,我们继续改进推出了可以通过模型简单维护关联关系的With模型关联特性,当然,这种特性仍然致力于提升整体框架的易用性和维护性,可以把With特性看做ScanList与模型关联关系维护的一种结合和改进。

本特性需要感谢 aries 提供的宝贵建议。

With特性从goframe v1.15.7版本开始提供,目前属于实验性特性。

二、举个例子

我们先来一个简单的示例,便于大家更好理解With特性,该示例来自于之前的ScanList章节的相同示例,改进版。

1、数据结构

  1. # 用户表
  2. CREATE TABLE `user` (
  3. id int(10) unsigned NOT NULL AUTO_INCREMENT,
  4. name varchar(45) NOT NULL,
  5. PRIMARY KEY (id)
  6. ) ENGINE=InnoDB DEFAULT CHARSET=utf8;
  7. # 用户详情
  8. CREATE TABLE `user_detail` (
  9. uid int(10) unsigned NOT NULL AUTO_INCREMENT,
  10. address varchar(45) NOT NULL,
  11. PRIMARY KEY (uid)
  12. ) ENGINE=InnoDB DEFAULT CHARSET=utf8;
  13. # 用户学分
  14. CREATE TABLE `user_scores` (
  15. id int(10) unsigned NOT NULL AUTO_INCREMENT,
  16. uid int(10) unsigned NOT NULL,
  17. score int(10) unsigned NOT NULL,
  18. PRIMARY KEY (id)
  19. ) ENGINE=InnoDB DEFAULT CHARSET=utf8;

2、数据结构

根据表定义,我们可以得知:

  1. 用户表与用户详情是1:1关系。
  2. 用户表与用户学分是1:N关系。
  3. 这里并没有演示N:N的关系,因为相比较于1:N的查询只是多了一次关联、或者一次查询,最终处理方式和1:N类似。

那么Golang的模型可定义如下:

  1. // 用户详情
  2. type UserDetail struct {
  3. gmeta.Meta `orm:"table:user_detail"`
  4. Uid int `json:"uid"`
  5. Address string `json:"address"`
  6. }
  7. // 用户学分
  8. type UserScores struct {
  9. gmeta.Meta `orm:"table:user_scores"`
  10. Id int `json:"id"`
  11. Uid int `json:"uid"`
  12. Score int `json:"score"`
  13. }
  14. // 用户信息
  15. type User struct {
  16. gmeta.Meta `orm:"table:user"`
  17. Id int `json:"id"`
  18. Name string `json:"name"`
  19. UserDetail *UserDetail `orm:"with:uid=id"`
  20. UserScores []*UserScores `orm:"with:uid=id"`
  21. }

3、数据写入

为简化示例,我们这里创建5条用户数据,采用事务操作方式写入:

  • 用户信息,id1-5namename_1name_5
  • 同时创建5条用户详情数据,address数据为address_1address_5
  • 每个用户创建5条学分信息,学分为1-5
  1. db.Transaction(func(tx *gdb.TX) error {
  2. for i := 1; i <= 5; i++ {
  3. // User.
  4. user := User{
  5. Name: fmt.Sprintf(`name_%d`, i),
  6. }
  7. lastInsertId, err := db.Model(user).Data(user).OmitEmpty().InsertAndGetId()
  8. if err != nil {
  9. return err
  10. }
  11. // Detail.
  12. userDetail := UserDetail{
  13. Uid: int(lastInsertId),
  14. Address: fmt.Sprintf(`address_%d`, lastInsertId),
  15. }
  16. _, err = db.Model(userDetail).Data(userDetail).OmitEmpty().Insert()
  17. if err != nil {
  18. return err
  19. }
  20. // Scores.
  21. for j := 1; j <= 5; j++ {
  22. userScore := UserScores{
  23. Uid: int(lastInsertId),
  24. Score: j,
  25. }
  26. _, err = db.Model(userScore).Data(userScore).OmitEmpty().Insert()
  27. if err != nil {
  28. return err
  29. }
  30. }
  31. }
  32. return nil
  33. })

执行成功后,数据库数据如下:

  1. mysql> show tables;
  2. +----------------+
  3. | Tables_in_test |
  4. +----------------+
  5. | user |
  6. | user_detail |
  7. | user_score |
  8. +----------------+
  9. 3 rows in set (0.01 sec)
  10. mysql> select * from `user`;
  11. +----+--------+
  12. | id | name |
  13. +----+--------+
  14. | 1 | name_1 |
  15. | 2 | name_2 |
  16. | 3 | name_3 |
  17. | 4 | name_4 |
  18. | 5 | name_5 |
  19. +----+--------+
  20. 5 rows in set (0.01 sec)
  21. mysql> select * from `user_detail`;
  22. +-----+-----------+
  23. | uid | address |
  24. +-----+-----------+
  25. | 1 | address_1 |
  26. | 2 | address_2 |
  27. | 3 | address_3 |
  28. | 4 | address_4 |
  29. | 5 | address_5 |
  30. +-----+-----------+
  31. 5 rows in set (0.00 sec)
  32. mysql> select * from `user_score`;
  33. +----+-----+-------+
  34. | id | uid | score |
  35. +----+-----+-------+
  36. | 1 | 1 | 1 |
  37. | 2 | 1 | 2 |
  38. | 3 | 1 | 3 |
  39. | 4 | 1 | 4 |
  40. | 5 | 1 | 5 |
  41. | 6 | 2 | 1 |
  42. | 7 | 2 | 2 |
  43. | 8 | 2 | 3 |
  44. | 9 | 2 | 4 |
  45. | 10 | 2 | 5 |
  46. | 11 | 3 | 1 |
  47. | 12 | 3 | 2 |
  48. | 13 | 3 | 3 |
  49. | 14 | 3 | 4 |
  50. | 15 | 3 | 5 |
  51. | 16 | 4 | 1 |
  52. | 17 | 4 | 2 |
  53. | 18 | 4 | 3 |
  54. | 19 | 4 | 4 |
  55. | 20 | 4 | 5 |
  56. | 21 | 5 | 1 |
  57. | 22 | 5 | 2 |
  58. | 23 | 5 | 3 |
  59. | 24 | 5 | 4 |
  60. | 25 | 5 | 5 |
  61. +----+-----+-------+
  62. 25 rows in set (0.00 sec)

4、数据查询

新的With特性下,数据查询相当简便,例如,我们查询一条数据:

  1. var user *User
  2. db.Model(tableUser).WithAll().Where("id", 3).Scan(&user)

以上语句您将会查询到用户ID为3的用户信息、用户详情以及用户学分信息,以上语句将会在数据库中自动执行以下SQL语句:

  1. 2021-05-02 22:29:52.634 [DEBU] [ 2 ms] [default] SHOW FULL COLUMNS FROM `user`
  2. 2021-05-02 22:29:52.635 [DEBU] [ 1 ms] [default] SELECT * FROM `user` WHERE `id`=3 LIMIT 1
  3. 2021-05-02 22:29:52.636 [DEBU] [ 1 ms] [default] SHOW FULL COLUMNS FROM `user_detail`
  4. 2021-05-02 22:29:52.637 [DEBU] [ 1 ms] [default] SELECT `uid`,`address` FROM `user_detail` WHERE `uid`=3 LIMIT 1
  5. 2021-05-02 22:29:52.643 [DEBU] [ 6 ms] [default] SHOW FULL COLUMNS FROM `user_score`
  6. 2021-05-02 22:29:52.644 [DEBU] [ 0 ms] [default] SELECT `id`,`uid`,`score` FROM `user_score` WHERE `uid`=3

执行后,通过g.Dump(user)打印的用户信息如下:

  1. {
  2. "id": 3,
  3. "name": "name_3",
  4. "UserDetail": {
  5. "uid": 3,
  6. "address": "address_3"
  7. },
  8. "UserScores": [
  9. {
  10. "id": 11,
  11. "uid": 3,
  12. "score": 1
  13. },
  14. {
  15. "id": 12,
  16. "uid": 3,
  17. "score": 2
  18. },
  19. {
  20. "id": 13,
  21. "uid": 3,
  22. "score": 3
  23. },
  24. {
  25. "id": 14,
  26. "uid": 3,
  27. "score": 4
  28. },
  29. {
  30. "id": 15,
  31. "uid": 3,
  32. "score": 5
  33. }
  34. ]
  35. }

5、列表查询

我们来一个通过With特性查询列表的示例:

  1. var users []*User
  2. db.Model(users).With(UserDetail{}).Where("id>?", 3).Scan(&users)

执行后,通过g.Dump(users)打印用户数据如下:

  1. [
  2. {
  3. "id": 4,
  4. "name": "name_4",
  5. "UserDetail": {
  6. "uid": 4,
  7. "address": "address_4"
  8. },
  9. "UserScores": null
  10. },
  11. {
  12. "id": 5,
  13. "name": "name_5",
  14. "UserDetail": {
  15. "uid": 5,
  16. "address": "address_5"
  17. },
  18. "UserScores": null
  19. }
  20. ]

6、条件与排序

通过With特性关联时可以指定关联的额外条件,以及在多数据结果下指定排序规则。例如:

  1. type User struct {
  2. gmeta.Meta `orm:"table:user"`
  3. Id int `json:"id"`
  4. Name string `json:"name"`
  5. UserDetail *UserDetail `orm:"with:uid=id, where:uid > 3"`
  6. UserScores []*UserScores `orm:"with:uid=id, where:score>1 and score<5, order:score desc"`
  7. }

通过orm标签中的where子标签以及order子标签指定额外关联条件体积排序规则。

三、详细说明

想必您一定对上面的某些使用比较好奇,比如gmeta包、比如WithAll方法、比如orm标签中的with语句、比如Model方法给定struct参数识别数据表名等等,那这就对啦,接下来,我们详细聊聊吧。

1、gmeta

我们可以看到在上面的结构体数据结构中都使用embed方式嵌入了一个gmeta.Meta结构体,例如:

  1. type UserDetail struct {
  2. gmeta.Meta `orm:"table:user_detail"`
  3. Uid int `json:"uid"`
  4. Address string `json:"address"`
  5. }

其实在GoFrame框架中有很多这种小组件包用以实现特定的便捷功能。gmeta包的作用主要用于嵌入到用户自定义的结构体中,并且通过标签的形式给gmeta包的结构体(例如这里的gmeta.Meta)打上自定义的标签内容(列如这里的`orm:"table:user_detail"` ),并在运行时可以特定方法动态获取这些自定义的标签内容。详情请参考章节:元数据-gmeta

因此,这里嵌入gmeta.Meta的目的是为了标记该结构体关联的数据表名称。

2、模型关联指定

在如下结构体中:

  1. type User struct {
  2. gmeta.Meta `orm:"table:user"`
  3. Id int `json:"id"`
  4. Name string `json:"name"`
  5. UserDetail *UserDetail `orm:"with:uid=id"`
  6. UserScores []*UserScore `orm:"with:uid=id"`
  7. }

我们通过给指定的结构体属性绑定orm标签,并在orm标签中通过with语句指定当前结构体(数据表)与目标结构体(数据表)的关联关系,with语句的语法如下:

  1. with:当前属性对应表关联字段=当前结构体对应数据表关联字段

并且字段名称忽略大小写以及特殊字符匹配,例如以下形式的关联关系都是能够自动识别的:

  1. with:UID=ID
  2. with:Uid=Id
  3. with:U_ID=id

如果两个表的关联字段都是同一个名称,那么也可以直接写一个即可,例如:

  1. with:uid

在本示例中,UserDetail属性对应的数据表为user_detailUserScores属性对应的数据表为user_score,两者与当前User结构体对应的表user都是使用uid进行关联,并且目标关联的user表的对应字段为id

3、With/WithAll

1)基本介绍

默认情况下,即使我们的结构体属性中的orm标签带有with语句,ORM组件并不会默认启用With特性进行关联查询,而是需要依靠With/WithAll方法启用该查询特性。

  • With:指定启用关联查询的数据表,通过给定的属性对象指定。
  • WithAll:启用操作对象中所有带有with语句的属性结构体关联查询。

这两个方法的定义如下:

  1. // With creates and returns an ORM model based on meta data of given object.
  2. // It also enables model association operations feature on given `object`.
  3. // It can be called multiple times to add one or more objects to model and enable
  4. // their mode association operations feature.
  5. // For example, if given struct definition:
  6. // type User struct {
  7. // gmeta.Meta `orm:"table:user"`
  8. // Id int `json:"id"`
  9. // Name string `json:"name"`
  10. // UserDetail *UserDetail `orm:"with:uid=id"`
  11. // UserScores []*UserScores `orm:"with:uid=id"`
  12. // }
  13. // We can enable model association operations on attribute `UserDetail` and `UserScores` by:
  14. // db.With(User{}.UserDetail).With(User{}.UserDetail).Scan(xxx)
  15. // Or:
  16. // db.With(UserDetail{}).With(UserDetail{}).Scan(xxx)
  17. // Or:
  18. // db.With(UserDetail{}, UserDetail{}).Scan(xxx)
  19. func (m *Model) With(objects ...interface{}) *Model
  20. // WithAll enables model association operations on all objects that have "with" tag in the struct.
  21. func (m *Model) WithAll() *Model

在我们本示例中,使用的是WithAll方法,因此自动启用了User表中的所有属性的模型关联查询,只要属性结构体关联了数据表,并且orm标签中带有with语句,那么都将会自动查询数据并根据模型结构的关联关系进行数据绑定。假如我们只启用某部分关联查询,并不启用全部属性模型的关联查询,那么可以使用With方法来指定。并且With方法可以指定启用多个关联模型的自动查询,在本示例中的WithAll就相当于:

  1. var user *User
  2. db.Model(tableUser).With(UserDetail{}, UserScore{}).Where("id", 3).Scan(&user)

也可以这样:

  1. var user *User
  2. db.Model(tableUser).With(User{}.UserDetail, User{}.UserScore).Where("id", 3).Scan(&user)

2)仅关联用户详情模型

假如我们只需要查询用户详情,并不需要查询用户学分,那么我们可以使用With方法来启用指定对象对应数据表的关联查询,例如:

  1. var user *User
  2. db.Model(tableUser).With(UserDetail{}).Where("id", 3).Scan(&user)

也可以这样:

  1. var user *User
  2. db.Model(tableUser).With(User{}.UserDetail).Where("id", 3).Scan(&user)

执行后,通过g.Dump(user)打印用户数据如下:

  1. {
  2. "id": 3,
  3. "name": "name_3",
  4. "UserDetail": {
  5. "uid": 3,
  6. "address": "address_3"
  7. },
  8. "UserScores": null
  9. }

3)仅关联用户学分模型

我们也可以只关联查询用户学分信息,例如:

  1. var user *User
  2. db.Model(tableUser).With(UserScore{}).Where("id", 3).Scan(&user)

也可以这样:

  1. var user *User
  2. db.Model(tableUser).With(User{}.UserScore).Where("id", 3).Scan(&user)

执行后,通过g.Dump(user)打印用户数据如下:

  1. {
  2. "id": 3,
  3. "name": "name_3",
  4. "UserDetail": null,
  5. "UserScores": [
  6. {
  7. "id": 11,
  8. "uid": 3,
  9. "score": 1
  10. },
  11. {
  12. "id": 12,
  13. "uid": 3,
  14. "score": 2
  15. },
  16. {
  17. "id": 13,
  18. "uid": 3,
  19. "score": 3
  20. },
  21. {
  22. "id": 14,
  23. "uid": 3,
  24. "score": 4
  25. },
  26. {
  27. "id": 15,
  28. "uid": 3,
  29. "score": 5
  30. }
  31. ]
  32. }

4)不关联任何模型查询

假如,我们不需要关联查询,那么更简单,例如:

  1. var user *User
  2. db.Model(tableUser).Where("id", 3).Scan(&user)

执行后,通过g.Dump(user)打印用户数据如下:

  1. {
  2. "id": 3,
  3. "name": "name_3",
  4. "UserDetail": null,
  5. "UserScores": null
  6. }

四、使用限制

1、字段查询与过滤

可以看到,在我们上面的示例中,并没有指定查询的字段,但是在打印的SQL日志中可以看到查询语句不是简单的SELECT *而是执行了具体的字段查询。在With特性下,将会自动按照关联模型对象的属性进行查询,属性的名称将会与数据表的字段做自动映射,并且会自动过滤掉无法自动映射的字段查询。

所以,在With特性下,我们无法做到仅查询属性中对应的某几个字段。如果需要实现仅查询并赋值某几个字段,建议您对model数据结构按照业务场景进行裁剪,创建满足特定业务场景的数据结构,而不是使用一个数据结构满足不同的多个场景。

我们来一个示例更好说明。假如我们有一个实体对象数据结构Content,一个常见的CMS系统的内容模型,与数据表字段一一对应:

  1. type Content struct {
  2. Id uint `orm:"id,primary" json:"id"` // 自增ID
  3. Key string `orm:"key" json:"key"` // 唯一键名,用于程序硬编码,一般不常用
  4. Type string `orm:"type" json:"type"` // 内容模型: topic, ask, article等,具体由程序定义
  5. CategoryId uint `orm:"category_id" json:"category_id"` // 栏目ID
  6. UserId uint `orm:"user_id" json:"user_id"` // 用户ID
  7. Title string `orm:"title" json:"title"` // 标题
  8. Content string `orm:"content" json:"content"` // 内容
  9. Sort uint `orm:"sort" json:"sort"` // 排序,数值越低越靠前,默认为添加时的时间戳,可用于置顶
  10. Brief string `orm:"brief" json:"brief"` // 摘要
  11. Thumb string `orm:"thumb" json:"thumb"` // 缩略图
  12. Tags string `orm:"tags" json:"tags"` // 标签名称列表,以JSON存储
  13. Referer string `orm:"referer" json:"referer"` // 内容来源,例如github/gitee
  14. Status uint `orm:"status" json:"status"` // 状态 0: 正常, 1: 禁用
  15. ReplyCount uint `orm:"reply_count" json:"reply_count"` // 回复数量
  16. ViewCount uint `orm:"view_count" json:"view_count"` // 浏览数量
  17. ZanCount uint `orm:"zan_count" json:"zan_count"` // 赞
  18. CaiCount uint `orm:"cai_count" json:"cai_count"` // 踩
  19. CreatedAt *gtime.Time `orm:"created_at" json:"created_at"` // 创建时间
  20. UpdatedAt *gtime.Time `orm:"updated_at" json:"updated_at"` // 修改时间
  21. }

内容的列表页又不需要展示这么详细的内容,特别是其中的Content字段非常大,我们列表页只需要查询几个字段而已。那么我们可以单独定义一个用于列表的返回数据结构,而不是直接使用数据表实体对象数据结构。例如:

  1. type ContentListItem struct {
  2. Id uint `json:"id"` // 自增ID
  3. CategoryId uint `json:"category_id"` // 栏目ID
  4. UserId uint `json:"user_id"` // 用户ID
  5. Title string `json:"title"` // 标题
  6. CreatedAt *gtime.Time `json:"created_at"` // 创建时间
  7. UpdatedAt *gtime.Time `json:"updated_at"` // 修改时间
  8. }

2、必须存在关联字段属性

由于With特性是通过识别数据结构关联关系并自动执行多条SQL查询来实现的,因此关联的字段也必须作为对象的属性便于关联字段值得自动获取。简单地讲,with标签中的字段必须存在于关联对象的属性上。

五、后续改进

  • 目前With特性仅实现了查询操作,还不支持写入更新等操作。