GORM 入门


五月其一,学习 go 的 ORM 框架 GORM。

GORM 是国人写的,有中文文档《GORM 指南》,写得相当不错,通读大概需要几个小时,读完直接就是 GORM 大师(不是


GORM 历史

简单写一下 GORM 的历史。

GORM 最初来自于一个快速上线的项目,里面需要拼装大量 SQL,作者拼 SQL 拼烦了,自己周末写了 GORM 第一版。

在 2013 年,GORM V1 发布在 github 上。

在 2020 年初疫情期间,作者重写了一遍 GORM,再经过几个月迭代开发出来 GORM V2。按照作者的说法:

GORM V2 是一次相对于 V1 版本的重写,在功能、性能上都得到了增强,并且将部分容易掉坑的 API 进行了调整,来减少项目出错的可能性。

在 2021 年,GORM 推出了 GEN 版本(github 地址),通过自动生成代码的方式使用 GORM,更加安全(避免 SQL 注入),但是使用的人较少,本文并不涉及该版本。

GORM 作者现在在字节跳动的架构-语言部门,字节内的主流 MySQL ORM 框架是 BytedGORM(内部封装版 GORM V2)。


本文内容基于 GORM V2 版本。


你可以在 go 代码中通过 GORM 操作数据库,比如:

1
2
3
4
5
6
7
// 创建
db.Create(&Foo{ID: 1, Name: "pz"})
// 更新
db.Where("id = ?", 1).Update("Name", "pz2")
// 查询
var foo Foo
db.Where("id = ?", 1).Find(&foo)

为了让你快速上手使用 GORM,下面整理了最小示例代码。

最小示例代码

前置准备

首先你需要安装并启动数据库(MySQL)。

如果你在本地还没有配置数据库,我建议你使用 Docker,只需要一句命令就能安装并启动:

1
docker run --name mariadb-test -e MYSQL_ROOT_PASSWORD=123456 -p 3306:3306 -d mariadb

这里使用的是 MariaDB(可简单理解为开源版 MySQL),使用 docker 安装参考文档:《Installing and Using MariaDB via Docker》。

代码

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
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
package main

import (
"fmt"

"gorm.io/driver/mysql"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)

// 这就是真正的 DB 实例,我们使用它执行 SQL 语句
var db *gorm.DB

// 声明模型详见 gorm.io/zh_CN/docs/models.html
type User struct {
gorm.Model
Name string
Age int
}

func init() {
var (
dbUser = "root"
dbPasswd = "123456"
dbHost = "127.0.0.1"
dbPort = "3306"
dbName = "mysql"
err error
)
// 开启数据库连接
dsn := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?charset=utf8mb4&parseTime=True&loc=Local", dbUser, dbPasswd, dbHost, dbPort, dbName)
if db, err = gorm.Open(mysql.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Info)}); err != nil {
panic(err)
}
// 自动创建 users 表
autoDB := db.Set("gorm:table_options", "ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 ROW_FORMAT = Dynamic")
if err = autoDB.AutoMigrate(&User{}); err != nil {
panic(err)
}
}

func main() {
// 创建一个 User 实例
user := User{
Name: "pz",
Age: 18,
}
// 将 user 实例存储到数据库
if err := db.Create(&user).Error; err != nil {
panic(err)
}
// 查询 user 实例
var user2 User
if err := db.First(&user2, user.ID).Error; err != nil {
panic(err)
}
fmt.Printf("%+v\n", user2)
}

(省略 go get 获取代码依赖的部分)

执行结果

在你执行完上面的代码之后,会发生如下事情:

  1. 数据库将自动建表(users),你可以使用 SHOW CREATE TABLE users; 查看建表语句。
  2. 代码将在 users 表中创建一条数据,并查询该条数据。
  3. GORM 执行的每条 SQL 语句,都会打印在控制台上。

GORM 增删改查

CRUD 是最基础的功能,具体使用到的 GORM 方法整理如下:

CRUD 最常用的方法 其他方法
查询 Find First/Last/Take/FindInBatches/FirstOrInit/FirstOrCreate/Count/Scan
创建 Create CreateInBatches/Save
更新 Updates Update/UpdateColumn/UpdateColumns
删除 Delete

有关增删该查的操作,GORM 文档写得非常清晰(《GORM CRUD 接口》),不重复抄轮子。


GORM 方法

GORM 使用链式操作,比如:

1
db.Where("name = ?", "pz").Where("age = ?", 18).First(&user)

GORM 的方法分为三种:

  • 链式方法(Chain Method)
  • Finisher 方法(Finisher Method)
  • 新建会话方法(New Session Method)

链式方法是中间操作,往 db 实例中追加条件,Finisher 方法是终止操作,生成 SQL 并执行。

GORM链式

链式方法会往同一个 db 实例中追加条件,如果同一个 db 实例使用了两个 Finisher 方法,第二个语句会受到影响。

1
2
3
4
5
6
7
8
9
tx := db.Where("name = ?", "pz")

// 使用 tx 查询
tx.Where("age = ?", 18).Find(&users)
// SELECT * FROM users WHERE name = 'pz' AND age = 18

// 还是使用 tx 查询(此时会有问题)
tx.Where("age = ?", 28).Find(&users)
// SELECT * FROM users WHERE name = 'pz' AND age = 18 AND age = 28;

如果遇到 SQL 条件污染,返回不明错误,优先检查是否是链式方法造成的。


新建会话方法用来避免链式方法造成的不安全问题,有三种方法:SessionWithContextDebug。(详见《GORM 链式方法》)

1
2
3
4
5
6
7
8
9
10
// 通过 Session 方法,使 tx 变成了共享安全模式
tx := db.Where("name = ?", "pz").Session(&gorm.Session{})

// 使用 tx 查询
tx.Where("age = ?", 18).Find(&users)
// SELECT * FROM users WHERE name = 'pz' AND age = 18

// 还是使用 tx 查询
tx.Where("age = ?", 28).Find(&users)
// SELECT * FROM users WHERE name = 'pz' AND age = 28;

链式方法Finisher 方法平常使用比较多,整理如下:

链式方法

GORM 方法 作用 代码示例
Model 指定查询 Model(go 定义的数据模型,对应 Table) db.Model(&User{}).Update(“name”, “hello”)
Clauses 增加 Clause(一般用不上,特例特用) db.Clauses(clause.OnConflict{DoNothing: true}).Create(&user)
Table 指定查询数据库 Table db.Table(“users”).Update(“name”, “hello”)
Distinct SQL distinct 操作:去重 db.Model(&User{}).Distinct(“name”).Find(&users)
Select SQL select 操作:选择字段 db.Model(&User{}).Select(“name”).Find(&users)
Omit 忽略字段(反向 Select 操作) db.Model(&User{}).Omit(“name”).Find(&user)
Where SQL where 操作:查询条件 db.Model(&User{}).Where(“name = ?”, “pz”).Find(&user)
Not SQL not 操作:非 db.Not(User{Name: “pz”, Age: 18}).First(&users)
Or SQL or 操作:或 db.Where(“name = ?”, “pz”).Or(“name = ?”, “gy”).Find(&users)
Joins SQL left join 操作:左连接(用法很多,详见官方文档《GORM - 查询》) db.Model(&User{}).Select(“users.name, emails.email”).Joins(“left join emails on emails.user_id = users.id”).Scan(&result{})
Group SQL group by 操作:分组 db.Model(&User{}).Select(“name, sum(age) as total”).Group(“name”).Find(&users)
Having SQL having 操作:筛选分组 db.Model(&User{}).Select(“name, sum(age) as total”).Group(“name”).Having(“name = ?”, “pz”).Find(&users)
Order SQL order 操作:排序 db.Order(“age desc, name”).Find(&users)
Limit SQL limit 操作:限制数量 db.Limit(3).Find(&users)
Offset SQL offset 操作:跳过数量 db.Offset(3).Find(&users)
Scopes 用于复用相同的 db 语句 详见《GORM - 高级查询
Preload 预加载相关表 详见《GORM - 预加载
Attrs 用于 FirstOrInit/FirstOrCreate 方法,当查不到数据时,采用 Attrs 赋值字段 db.Attrs(&User{Age: 18}).FirstOrInit(&user, “name = ?”, “pz”)
Assign 用于 FirstOrInit/FirstOrCreate 方法,无论是否查到结果,都采用 Assign 赋值字段 db.Assign(&User{Age: 1}).FirstOrInit(&user, “name = ?”, “pz”)
Unscoped 找到软删除的记录 db.Unscoped().Where(“age = 20”).Find(&users)
Raw 原生 SQL db.Raw(“SELECT name, age FROM users WHERE name = ?”, “Antonio”).Scan(&result)

Finisher 方法

GORM 方法 作用 代码示例
Create 创建 db.Create(&User{Name: “pz”})
CreateInBatches 分批创建 db.Create(&[]User{ {Name: “pz”} }, 100)
Save 保存(会保存零值字段) db.Save(&User{Name: “pz”})
First 查询首条符合条件的数据(没查到报错) db.First(&user)
Take 随便查询一条符合条件的数据(没查到报错) db.Take(&user)
Last 查询最后一条符合条件的数据(没查到报错) db.Last(&user)
Find 查询所有符合条件的数据(没查到报错) db.Limit(1).Find(&user)
FindInBatches 批量查询并处理记录 详见《GORM - 高级查询
FirstOrInit 查询首条符合条件的数据,或者根据给定的条件初始化一个实例 db.FirstOrInit(&user, User{Name: “non_existing”})
FirstOrCreate 查询首条符合条件的数据,或者根据给定的条件创建一条新纪录 db.FirstOrCreate(&user, User{Name: “non_existing”})
Update 更新单字段 db.Model(&User{}).Where(“name = ?”, “pz”).Update(User{Age: 18})
Updates 更新多字段(不会更新零值字段,除非指定) db.Model(&User{}).Where(“name = ?”, “pz”).Updates(User{Age: 18})
UpdateColumn 更新单字段 db.Model(&Foo{ID: 1}).UpdateColumn(“name”, “pz”)
UpdateColumns 更新多字段 db.Model(&Foo{ID: 1}).UpdateColumns(Foo{})
Delete 删除 db.Delete(&email)
Count 计数 db.Model(&User{}).Where(“name = ?”, “pz”).Count(&count)
Row 查询,返回值是 *sql.Row db.Table(“users”).Where(“name = ?”, “pz”).Select(“name”, “age”).Row().Scan(&name, &age)
Rows 查询,返回值是 *sql.Rows 详见《GORM - 高级查询
Scan 扫描,可以当 Find 用,也可以配合 *sql.Row*sql.Rows 使用 db.Model(&User{}).Where(“name = ?”, “pz”).Scan(&users)
Pluck 查询单字段 db.Model(&users).Pluck(“name”, &[]string])
ScanRows 扫描 *sql.Rows 到结构体里 db.ScanRows(rows, &user)
Connection 连接数据库并执行方法(没用过,查不到资料)
Transaction 开启事务,并在事务中执行 SQL db.Transaction(func(tx *gorm.DB) error {
  // 先创建 user
  user := &User{Name: “pz”, Age: 18}
  res := tx.Create(user)
  if res.Error != nil {
    return res.Error
  }
  // 再更新 user
  res = tx.Model(user).Update(“age”, 20)
  return res.Error
})
Begin 开启事务 db.Begin()
Commit 提交事务 tx.Commit()
Rollback 回滚事务 tx.Rollback()
SavePoint 创建保存点 tx.SavePoint(“sp1”)
RollbackTo 回滚到保存点 tx.RollbackTo(“sp1”)
Exec 直接执行 SQL db.Exec(“DROP TABLE users”)

GORM 预加载

GORM 有一个很好用的功能是预加载,下面举一个具体的例子。

有两张表:部门表(departments)和员工表(employees),员工表记录所在部门 ID(换句话说,部门和员工是 1:N 的关系)。

1
2
3
4
5
6
7
8
9
10
11
12
13
type Department struct {
gorm.Model
DepartmentID int `gorm:"primary_key"`
Name string
Employees []Employee `gorm:"association_foreignkey:DepartmentID;foreignkey:GroupID"`
}

type Employee struct {
gorm.Model
UserID int `gorm:"primary_key"`
Name string
GroupID int // 这里为了区分,把 DepartmentID 改名定义为 GroupID
}

如果现在需要查询,某部门的信息及其部门下所有员工信息,通常是通过 MySQL 中的 JOIN 语句查询。

GORM 提供了另一种查询方式,使用预加载(Preload)查询,代码如下:

1
2
var departments []Department
db.Preload(clause.Associations).Where(...).Find(&departments)

GORM 预加载在真正查询时,并不通过 JOIN 查询,而是 SELECT 查询两次:

  1. 查部门表
  2. 查员工表(WHERE group_id = xxx)
  3. 封装查询结果

使用 GORM 预加载,需要在声明模型时,在 Struct Tag 指定 foreignkeyassociation_foreignkey(数据库并不需要真正建立外键,这里只用于 GORM 内部分析)。


GORM 指针传参

我在使用 GORM API 的时候,经常会遇到一个问题:传参应该传指针,还是应该不传指针?

1
2
3
// 下面两种写法,哪种是合法的,还是都合法?
db.Model(User{ID: 1}).Find(&users)
db.Model(&User{ID: 1}).Find(&users)

报错报得多了之后,我决定把所有方法都试一遍,试完总结成两句话:

  • 链式方法尽量不传指针
  • Finisher 方法尽量传指针

链式方法

GORM 方法 GORM 语句 没有指针是否合法 有指针是否合法
Model db.Model(User{}).Find(&users)
Table db.Table("users").Find(&users)
Where db.Where(“id = ?”, 1).Find(&users)
db.Where(User{Name: "pz"}).Find(&users)
db.Where(“name IN ?”, []string{"pz"}).Find(&users)
Select db.Select("name").Find(&users)
db.Select([]string{"name"}).Find(&users)
Omit db.Omit("name").Find(&users)
Limit db.Limit(1).Find(&users)
Offset db.Offset(1).Limit(1).Find(&users)
Preload db.Preload(clause.Associations).Find(&users)

Finisher 方法

GORM 方法 GORM 语句 没有指针是否合法 有指针是否合法
Create db.Create(Foo{})
db.Create([]Foo{ {} })
db.Create([1]Foo{ {} })
db.Model(&Foo{}).Create(map[string]interface{}{})
db.Model(&Foo{}).Create([]map[string]interface{}{})
CreateInBatches db.CreateInBatches([]Foo{ {} }, 100)
db.CreateInBatches([1]Foo{ {}}, 100)
Save db.Save(Foo{})
db.Save([]Foo{ {} })
db.Save([1]Foo{ {} })
db.Model(&Foo{ID: 1}).Save(map[string]interface{}{})
Update db.Model(&Foo{ID: 1}).Update(“name”, "pz")
Updates db.Model(&Foo{ID: 1}).Updates(Foo{})
db.Model(&Foo{ID: 1}).Updates(map[string]interface{}{})
UpdateColumn db.Model(&Foo{ID: 1}).UpdateColumn(“name”, "pz")
UpdateColumns db.Model(&Foo{ID: 1}).UpdateColumns(Foo{})
db.Model(&Foo{ID: 1}).UpdateColumns(map[string]interface{}{})
Find db.Find(Foo)
db.Find(&Foo, 1)
db.Find(&Foo, []int{1, 2})
db.Find([]Foo)
db.Find(&[]Foo, 1)
db.Find(&[]Foo, []int{1, 2})
First db.First(Foo{})
db.First(&Foo{}, 1)
Last db.Last(Foo{})
db.Last(&Foo{}, 1)
Take db.Take(Foo{})
db.Take(&Foo{}, 1)
Expr db.Model(&Foo{ID: 1}).Update(“id”, gorm.Expr(“id + ?”, num))

一些 Tips

下面整理一些小 tips,可能会用得上。

查询

  • First/Take/Last 查询不到数据时报错 record not found,Find 查询不到数据时不报错

更新

  • Updates 不会更新非零字段,Save 会更新非零字段
  • Update/Updates 会更新 CreatedAt 字段,UpdateColumn/UpdateColumns 不会更新 CreatedAt 字段
  • 即使 Select 没有指定 UpdatedAt 字段,实际上也会更新