gorm-note

关于 gorm 框架进行 mysql CRUD 的使用心得

还是直接写 sql 好,用代码生成或者泛型整一些单表简单操作,复杂操作用 sqlx 这样的 sql 框架写就好。

关于 ORM 框架,需要注意其利弊兼有的特点,其虽可以帮助我们简化一些简单 sql 操作,做一些辅助检查,但性能低,复杂查询表达不如直接使用 sql 的缺点是存在的。
对于简单查询,使用 ORM 框架是不错的,但综合性能和编写效率,复杂查询还是手写 sql 比较好。

综合一下还是觉得 java 中 MyBatis plus 负责简单查询,MyBatis 手写 sql 复杂查询的模式更好一些。

至于多表操作,其实有以下几个方案:

  1. 冗余字段或其他存储(redis 等):其实这个最快,如果可以通过冗余一两个字段完成操作最好。
  2. 本地多次单表查询:想提前返回错误或者要进行检查。
  3. join:性能其实相比本地多次单表查因为网络IO更快。但要注意较容易慢 sql。
    实际业务进行选择,尽量避免三张表及以上的多表操作。
    关于 join 还有很多优化策略,但这篇重点不是这个。

泛型

为了更好的使用 gorm,省去简单查询的重复编写是不可少的一步,可以使用 gorm-gen 负责生成代码,但有时会对生成的代码并不满意(比如方法实现上想做出调整),可以参照我下面结合泛型给出的例子:
单表查询的泛型,具体步骤如下:
定义类型限制的 interface,类似于下面这样

1
2
3
4
type Model interface {
TableName() string
GetID() int64
}

接着针对该 Model,定义相关的单表 Dao 接口和结构,方法实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// Interface of Dao[Model]
type IDao[M Model interface {
Create(ctx context.Context, m M) error
//...
}

// Struct of Dao[Model]
type MDao[T Model struct {
*gorm.DB
}

// Create Method of MDao
func (m *MDao[T]) Create(ctx context.Context, t T) error {
r := m.WithContext(ctx).Table(t.TableName()).Clauses(
clause.OnConflict{
DoNothing: true,
}).
Create(&t)
return r.Error
}

//...

用 MDao 对 IDao 进行实现相关方法完成。
而对于具体使用而言,我们可以定义一个多表接口 Dao

1
2
3
4
5
type Dao struct {  
ProductDao gormer.IDao[*model.Product]
OrderDao gormer.IDao[*model.BusinessOrder]
// 这里可以对 DB 做一些直接操作的接口,比如复杂查询或者事务
}

上面只是个简单的例子,实际业务中还要看情况选择使用。

注意事项

建表规范

建表规范不同,其中要注意的几点是:

  • 主键字段
  • created_at,updated_at,deleted_at 等字段
  • valid 字段
  • CHARSET 是 utf8 还是 utf8mb4
  • 注释

注意零值

注意数据库中不要规定任何值为 0 的数据为有实际意义的值,其作为数值使用的数值型类型,比如类似于 enum 使用的字段,因为如果在 gorm 的插入或修改中定义某值为 0,gorm 会认为该值不需要插入或修改,在某些情况下这样可能酿成大错。
如果想插入或更新零值,可以使用诸如 "0" 的做法。

对象返回

首先,永远不要返回双 nil,因为我们往往只检查 err。
对于对象的一对一关系,父子关系等等:

  • 一对一:返回与主对象有父关系的所有数据,他们为对象提供了基本上下文
  • 一对多:知道子关系数量有限时才返回子关系

列表或搜寻

我们需要实现一个 Filter 结构对结果进行过滤,分页等等

事务

应该确定的是,对逻辑而言,如果可以保证每一个函数调用都是一个独立的事务,世界会变得更美好。当然世界肯定不是完美的。
同时,应该设计事务平铺的机制以支持在 service 层操作被视为单独的事务。

方法命名

我个人使用的规范是:对于 ByA ,A 为 ID 时不加该后缀,对于增删改操作,后缀 Batch 代表批量进行

查找

Select

虽然查找上一般都能直接查找全部数据,但 Select 方法是减少查询时间下必须的。

子查询

1
2
3
4
subQuery := db.Select("AVG(age)").Where("name LIKE ?", "name%").Table("users")  
db.Select("AVG(age) as avgage").Group("name").Having("AVG(age) > (?)", subQuery).Find(&results)
// 或者在 Table 中直接使用,相当于 sql 的 from
db.Table("(?) as u", db.Model(&User{}).Select("name", "age")).Where("age = ?", 18).Find(&User{})

查找或创建

使用 Attrs().FirstOrCreate() 或者使用 Assign().FirstOrCreate() 两者的区别是前者在找到后就不会使用 Attrs 中的属性,而后者即使找到也会使用 Assign 中的属性进行更新。

1
db.Where(User{Name: "non_existing"}).Attrs(User{Age: 20}).FirstOrInit(&user)

索引提示

对查询语句提示其应该使用哪个索引

1
db.Clauses(hints.UseIndex("idx_user_name")).Find(&User{})

迭代处理

使用迭代处理,可以比较轻易的实现树形结构查询等复杂查询情况

1
2
3
4
5
6
7
8
9
rows, err := db.Model(&User{}).Where("name = ?", "jinzhu").Rows()  
defer rows.Close()

for rows.Next() {
var user User
// ScanRows 扫描每一行进结构体
db.ScanRows(rows, &user)
// 对每一个 User 进行操作
}

下面是一个树形结构查询例子

1
2
3
4
5
6
7
8
9
10
11
12
13
trees := []model.CategoryTree{}
rows, err := db.Table(TableNameCategory).Where("root = ?",1)Rows()
defer rows.Close()

treeSearch
for rows.Next() {
var c model.Category
db.ScanRows(rows, &c)
trees = append(trees, model.CategoryTree{
Value: c
})
rows,
}

批量查询

使用 FindInBatches 进行批量查询,批量查询应该使用事务进行处理。

单列查询

使用 Pluck 方法进行单列查询进切片中

1
2
var ages []int64  
db.Model(&User{}).Pluck("age", &ages)

自定义查询函数

如分页函数等对 pageSize,page 等等进行检查,使用 Scopes 完成类似于方法的定义更有利于规范。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
func Paginate(pageSize, page int) func(db *gorm.DB) *gorm.DB {  
return func(db *gorm.DB) *gorm.DB {
if page <= 0 {
page = 1
}
switch {
case pageSize > 100:
pageSize = 100
case pageSize <= 0:
pageSize = 10
}

offset := (page - 1) * pageSize
fmt.Println(offset, pageSize)
return db.Limit(pageSize).Offset(offset)
}
}

分页相关

首先,如果需要使用非唯一性索引分页排序,需要搭配唯一性索引,否则无法确保一致性。同时也需要确定非唯一性索引是否是联合索引。如果是,在中间以最左匹配原则加入联合索引的其他键可以进一步优化效率。
大分页下我们需要进行的查询如果使用非主键索引,需要进行多次回表效率极低,这里可以用延迟关联+覆盖索引实现高效分页查询

1
2
3
4
5
6
7
select * from contacts          -- 想要展示给用户的完整数据。
inner join ( -- "延迟关联"。
select id from contacts -- 使用快速索引进行分页。
order by id
limit 15 offset 150000
) as tmp using(id)
order by id -- 对单个结果页进行排序。

综合以上,分页在 gorm 的实现应该是这样的:

1
2
3
4
5
6
7
r := ds.Table(model.TableNameProduct).Select("product.id", "name").  
Joins("INNER JOIN (?) as tmp ON tmp.id = product.id",
ds.Table(model.TableNameProduct).Select("id").
Where("valid = ? and gem_valid = ?", 1, 1).
Count(&count).Limit(pageSize).Offset(page).
Order("id desc"), model.TableNameProduct).
Order("id desc").Find(&products)

插入

插入可以分为几种类型,最简单的单次插入,以及批量插入,树形结构插入,插入或修改(upsert)等等。

单次插入

如下代码:

1
res := ds.WithContext(ctx).Table(model.TableNameApp).Create(&m)

基本上只需要使用 Create 即可,这里也可以使用 Save ,其会将所有字段(包括零值字段)插入,但一般来说在良好的数据库定义和上文对零值的注意下并不需要,且对于某些比如 created_ad 等需要在数据库中使用 default 进行维护等字段,Save 就有些不实用了。

批量插入

使用 CreatedInBatched 方法。

创建或更新

我们经常需要预计到创建或更新的情况,即唯一性约束被触犯时,我们两种应对方法(返回或者更新原值),这两种都可以用 gormClauses 方法解决,如下:

1
2
3
4
5
ds.WithContext(ctx).Table(model.TableNameGoods).Clauses(  
clause.OnConflict{
Columns: []clause.Column{{Name: "name"}},
DoUpdates: clause.AssignmentColumns([]string{"desc","info"}),
}).Create(&good)

这里的 OnConflict 中有几种像 DoUpdates 这样的解决方案,分别是 DoUpdatesDoNothingUpdateAll ,后两种是布尔值,只需说明是否需要进行此过程即可,Columns 中是被冒犯到的唯一性约束。
实际上,Clauses 还有其他妙用。

树形结构插入

树形结构插入需要注意

  • 子节点往往需要父节点的 ID 作为 parent_id,这就意味着父节点的插入一定要在子节点之前。
  • 对于一棵较大的树而言,我们需要用批量插入的形式来尽量减少 sql 的次数。这就意味着深度优先插入劣于广度优先(广度优先可以一次性返回)
    也就时说,我们可以将树形结构插入分成这样的几个步骤:
  1. 定义插入过程,这里可以用匿名函数。
  2. 定义广度优先算法,更新当前层级和下一需要进行更新的层级。
  3. 使用事务,将上面的插入过程和广度优先更新层级的过程统合。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    // 定义插入过程
    var create func(goods *[]model.Good) error
    create = func(goods *[]model.Auth) error {
    // 插入过程
    }

    // 定义广度优先过程
    nextLevel := make([]model.GoodTree, 0)
    creates := make([]model.Good, 0)

    var getLevel func(goodtree *[]model.GoodTree)
    getLevel = func(goods *[]model.GoodTree) {
    nextLevel = make([]model.GoodTree, 0)
    creates = make([]model.Good, 0)
    for _, v := range *goodtree {
    creates = append(creates, v.Value)
    for _, nv := range v.Children {
    // 要注意的是这里因为插入完成所以 v.Value.ID 在这时候就存在了
    nv.Value.ParentID = v.Value.ID
    nextLevel = append(nextLevel, *nv)
    }
    }
    }

    // 使用事务进行实际更新
    tx := ds.DB.WithContext(ctx).Begin()
    if tx.Error != nil {
    return errors.WithStack(tx.Error)
    }

    getLevel(data)
    for len(creates) > 0 {
    err = create(&creates)
    if err != nil {
    tx.Rollback()
    return errors.WithStack(err)
    }
    getLevel(&nextLevel)
    }
    return tx.Commit().Error

删除

一般都不真删除而是 update 逻辑删除相关的字段。

逻辑删除

逻辑删除基本是实际业务中必备的。
逻辑删除时针对可能影响唯一性约束时,可以有三种解决方案:

  • 将删除标记设置默认值(例如 0 ),将唯一字段与删除标记添加唯一键约束。当某一记录需要删除时,将删除标记置为 NULL。
    由于 NULL 不会和其他字段有组合唯一键的效果,所以当记录被删除时(删除标记被置为 NULL 时),解除了唯一键的约束。此外该方法能很好地解决批量删除的问题(只要置为 NULL 就完事了),消耗的空间也并不多( 1 位 + 联合索引)。 但 NULL 本身就可能出现问题。
  • 将删除标记更新为主键,这样同样保证了唯一约束字段和删除标记组合索引的唯一性。
  • 引入 deleted_at 字段,和唯一字段组成联合唯一索引,进行逻辑删除时同步更新。
    最后一种解决方式基本是可以保证不会出现什么问题的
    引入 gorm 自带的软删除机制通过
    1
    2
    3
    4
    5
    type User struct { 
    ID int Deleted
    gorm.DeletedAt
    Name string
    }
    查找被软删除的记录可以通过 Unscoped 解决
    在上文中提到的最后一种和唯一字段组成联合唯一索引的方法,可以通过如下代码进行支持
    1
    2
    3
    4
    5
    6
    7
    import "gorm.io/plugin/soft_delete" 

    type User struct {
    ID uint
    Name string `gorm:"uniqueIndex:udx_name"`
    DeletedAt soft_delete.DeletedAt `gorm:"uniqueIndex:udx_name"`
    }
    gorm.io/plugin/soft_delete 还提供了上文的删除标记方法:
    1
    2
    3
    4
    5
    6
    7
    import "gorm.io/plugin/soft_delete" 

    type User struct {
    ID uint
    Name string
    IsDel soft_delete.DeletedAt `gorm:"softDelete:flag"`
    }

gorm-note
https://iku50.github.io/2024/06/26/gorm-note/
作者
iku50
发布于
2024年6月26日
许可协议