关于 GORM 看这一篇就够了 | 青训营笔记

2,294 阅读14分钟

这是我参与「第五届青训营」笔记创作活动的第9天

优雅地操作数据库 - GORM 框架 ~

🔥前言:ORM 是什么?

在青训营直播课程《Go 语言框架三件套》中介绍了三个常用的 Golang 框架:Go Web、GORM、Go RPC,今天对 GORM 的学习做一个笔记。GORM 是 Go 语言一款性能极好的 ORM 库,类似于 Java 生态中的 MyBatis、Hibernate等。

此在学习 GORM 这个框架之前,有必要来回顾一下 ORM 的概念:

ORM 全称 Object-Relationl Mapping,它的作用是映射数据库和对象之间的关系,方便我们在实现数据库操作的时候不用去写复杂的 SQL 语句,把对数据库的操作上升到对于对象的操作。

image.png

ORM 是一把双刃剑,提升开发效率的同时也会带来一些缺点:牺牲执行性能、牺牲灵活性、弱化 SQL 能力。


初识 GORM

GORM 是一个使用 Go 语言编写的 ORM 框架,文档齐全,对开发者友好,支持主流数据库。

image.png

🎈传送门: GORM 的中文文档和 GitHub 站点是很好的学习参考

  1. GORM - The fantastic ORM library for Golang, aims to be developer friendly.
  2. GitHub - jinzhu/gorm: GORM V1, V2 moved to https://github.com/go-gorm/gorm

迄今为止,GORM 是一个已经迭代了十多年的功能强大的 ORM 框架,在字节跳动内部被广泛使用并且拥有非常丰富的开源扩展。

GORM 目前支持的数据库有 MySQL、SQLServer、PostgreSQL、SQLite。

GORM 约定

GORM 在使用有以下几点约定:

  • GORM 使用名为 ID 的字段作为主键
  • 如果没有 TableName 函数,使用结构体的蛇形复数作为表名
  • 字段名的蛇形作为列表
  • 使用 CreatedAt、UpdateAt 字段作为创建更新时间

上手 GORM 前的准备

假设我们使用的是 mysql 数据库,需要先在项目中下载 gorm 与 mysql 驱动的依赖包:

go get -u gorm.io/gorm
go get -u gorm.io/driver/sqlite 

在代码中导入依赖包:

import (
    "gorm.io/driver/mysql"
    "gorm.io/gorm"
)

在 mysql 中创建数据库 gorm_test 并添加一张 product 表,供后面的学习使用:

字段类型约束
IDint主键、自增、非空
codevarchar非空
priceint非空

对应的 SQL 脚本:

CREATE TABLE `product` (
  `ID` int(11) NOT NULL AUTO_INCREMENT,
  `code` varchar(50) DEFAULT NULL,
  `price` int(11) DEFAULT NULL,
  PRIMARY KEY (`ID`)
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4;
创建模型

在使用 ORM 工具时,通常我们需要在代码中定义模型(Models)与数据库中的数据表进行映射,在 GORM 中模型(Models)通常是正常定义的结构体、基本的 Go 类型或它们的指针。

创建一个 Product 类型的结构体,结构体里的字段和 mysql 的 product 表字段一一对应。

type Product struct {
    ID uint
    Code string
    Price uint
}

如果我们定义的结构体和 mysql 表中的字段不一致(包括大小写),需要加上对应关系,有两种方法:

  1. 如果是主键可以直接写成:
    ID  string `gorm:"primarykey"`
    
  2. 常规写法,附加一个 tag 说明对应表中哪一个字段:
    Code  string `gorm:"column:product_code"`
    

也可以使用 tag 为字段定义一个默认值,比如:

Name  string `gorm:"default:galeone"`

✨小坑: 刚才创建的结构体还有一个问题,结构体名为 Product,按照约定 GORM 默认会认为表名是 products (小写复数),但我们创建的数据库中的表名为 product,因此要创建一个对应关系,方式为给 Product 结构体绑定一个 TableName 方法。

func (p Product) TableName() string {
    return "product"
}
gorm.Model 结构体

为了方便模型定义,GORM 内置了一个 gorm.Model 结构体。gorm.Model 是一个包含了IDCreatedAtUpdatedAtDeletedAt 四个字段的 Golang 结构体。

可以将它嵌入到自己的模型中:

type Product struct {
    gorm.Model
    ID uint
    Code string `gorm:"column:code"`
    Price uint `gorm:"column:price"`
}

当然也可以完全自定义模型。

链接数据库

通过 mysql.Open 函数打开连接,该函数需要传递一个 dsndsn 是连接数据库的一个连接串,相当于 Java 中的 JDBC 连接串。

dsn 的格式为:

{username}:{password}@tcp({host}:{port})/{dbname}?charset=utf8&parseTime=True&loc=Local

创建数据库连接的代码如下:

dsn := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8&parseTime=True&loc=Local", username, password, host, port, dbname)
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{})
if err != nil {
    panic("failed to connect database, error=" + err.Error())
}

⭐GORM 代码框架:

通过上面导入依赖、创建模型、链接数据库几个步骤,基本的 GORM 代码框架就已经搭建好了,接下来就可以使用 GORM 来进行数据库的 CRUD 操作了。

完整代码如下:

package main

import (
    "fmt"
    "gorm.io/driver/mysql"
    "gorm.io/gorm"
)

type Product struct {
    ID uint
    Code string `gorm:"column:code"`
    Price uint `gorm:"column:price"`
}

func (p Product) TableName() string {
    return "product"
}

func main() {
    dsn := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8&parseTime=True&loc=Local", "root", "123456", "127.0.0.1", 3306, "gorm_test")
    db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{})
    if err != nil {
        panic("failed to connect database, error=" + err.Error())
    }
}

GORM 基本操作 - CRUD

上面在链接数据库的代码中 gorm.Open() 函数返回给我们一个 db 对象,数据库的增删改查都用 db 进行。

创建记录

先初始化一个结构体,调用 Create 函数在对应表中插入数据。

p := Product{Code: "D23", Price: 99}
result := db.Create(&p)
// p.ID:返回插入数据的主键;result.RowsAffected:返回插入记录的条数
log.Println(p.ID, result.Error, result.RowsAffected)

日志输出:

2023/01/25 11:01:35 1 <nil> 1

PS: 这里调用函数的时候传入的是结构体引用,这是因为 ID 字段为自增主键,GORM 会把插入数据的主键保存在结构体中,以便于我们及时查看自增主键的值。

如果想在插入时对错误进行处理,一个比较优雅的写法是:

p := Product{Code: "D42", Price: 100}
if err := db.Create(&p).Error; err != nil {
    fmt.Println("插入失败", err)
}

批量插入:

要插入大量记录,可以将一个切片传递给 Create 方法。GORM 将生产单独一条 SQL 语句来插入所有数据,并回填主键的值。

products := []Product{{Code: "E11", Price: 13}, {Code: "U89", Price: 13}}
if err := db.Create(&products).Error; err != nil {
    fmt.Println("插入失败", err)
}

或者使用 CreateInBatches 函数进行分批创建,指定每批的数量:db.CreateInBatches(products, 10)

如果插入数据的时候,出现了主键冲突,可以使用OnConflict处理冲突:

p := Product{Code: "D42", ID: 1}
if err := db.Clauses(clause.OnConflict{DoNothing: true}).Create(&p).Error; err != nil {
    fmt.Println("插入失败", err)
}

DoNothingtrue 代表如果冲突的话,什么也不做。

查询数据

GORM 提供了 FirstTakeLast 方法,以便从数据库中检索单个对象。当查询数据库时它添加了 LIMIT 1 条件,如果没有找到记录,会返回 ErrRecordNotFound 错误。

🎈官方文档给出的示例:

// 获取第一条记录(主键升序)
db.First(&user) 
// SELECT * FROM users ORDER BY id LIMIT 1;  

// 获取一条记录,没有指定排序字段
db.Take(&user) 
// SELECT * FROM users LIMIT 1;  

// 获取最后一条记录(主键降序) 
db.Last(&user) 
// SELECT * FROM users ORDER BY id DESC LIMIT 1;

PS:创建记录 的部分执行了几次插入操作,目前的 product 表如下:

IDcodeprice
1D2399
2D42100
3E1113
4U8913

First 接收第二个参数,表示按主键进行查询,比如查询主键为 1 的产品:

var p Product
db.First(&p, 1)
fmt.Println(p)
{1 D23 99}

如果要按照指定的条件查询,需要传入条件参数或者使用 Where 函数:

var p1, p2 Product
db.First(&p1, "code = ?", "D23")
db.Where("code = ?", "E11").First(&p2)
fmt.Println(p1, p2)
{1 D23 99} {3 E11 13}

Find 函数:

要查询多条记录,需要使用 Find 函数,这也是官方推荐的,可以避免 ErrRecordNotFound 错误,Find 方法能够接受 struct 和 slice 的数据。

products := make([]Product, 0)
db.Find(&products, "price = ?", 13)
fmt.Println(products)
[{3 E11 13} {4 U89 13}]

条件过滤 - Where 函数:

Where 函数可以在查询前按照指定条件过滤数据,相当于 SQL 中的条件查询。

  • 使用 where 查询:
    1. 使用占位符 ? 作为条件:
      db.Where("code = ?", "E11").Find(&products)
      
    2. 直接使用结构体作为条件:
      db.Where(Product{Code: "E11"}).Find(&products)
      
    使用结构体作为查询条件时,GORM 只会查询非零值字段,可以使用 MapSelect 替换。
  • 使用 in 查询:
    products := make([]Product, 0)
    db.Where("price in ?", []uint{99, 100}).Find(&products)
    fmt.Println(products)
    
    [{1 D23 99} {2 D42 100}]
    
  • 使用 like 查询:
    products := make([]Product, 0)
    db.Where("code like ?", "%D%").Find(&products)
    fmt.Println(products)
    
    [{1 D23 99} {2 D42 100}]
    

此外 GORM 还支持内联条件,需要时直接调用 Not() 或者 Or() 方法即可,和 Where 的使用类似;如果需要多表级联查询,GORM 也提供了 Joins() 函数支持。

更新数据

使用 Update 函数更新数据,传入要更新的字段和对应的值。当使用 Update 更新单列时,需要有一些条件,否则会引起 ErrMissingWhereClause 错误,一般搭配 Model 方法指明要更新哪一张表,用 Where 方法指明要更新哪一条数据。

比如我们要将代码为 "D42" 的产品价格改为 88,可以这样写:

db.Model(&Product{}).Where("code = ?", "D42").Update("price", 88)

搭配 Expr 函数可以实现表达式更新,比如:

db.Model(&p).Update("price", gorm.Expr("price * ? * ?", 3, 2))
// UPDATE product SET price = price * 3 * 2 WHERE ID = p.ID

Model 方法:

当使用 Model 方法,并且值中有主键值时,主键将会被用于构建条件。

  1. Model 函数如果传入一个空的结构体,只是用来确定要更新的表名。比如 db.Model(&User{}) 表示后续的更新操作是针对 users 这张表的。
  2. Model 函数也可以传入一个具体的结构体,既能确定表名,也能用主键确定更新的记录。假设 users 表以 ID 为主键,则 db.Model(&user) 表示后续的更新操作针对的是 users 表中主键为 user.ID 的那条记录。

Updates 方法:

如果要更新多个字段,可以使用 Updates 函数,该函数需要传入一个结构体或 Map,并且使用结构体作为参数,可以省略 Model 方法,因为 GORM 会从参数的结构体中查询相关表名。(结构体作为参数不会更新零值)

比如我们要改写 ID1 的那条记录,可以这样写:

db.Where("ID = ?", 1).Updates(Product{Code: "C61", Price: 66})

搭配 Select 函数可以只更新选定的字段,其他字段会被忽略:

db.Model(&Product{}).Where("ID = ?", 1).Select("price").Updates(
    map[string]interface{}{"code": "C61", "price": 66})

删除数据

GORM 使用 Delete 函数删除数据,删除一条记录时,删除对象需要指定主键,否则会触发 批量 Delete

从产品表中删除 ID3 的记录,代码如下:

p := Product{ID: 3}
db.Delete(&p)

也可以使用带额外条件的删除:

db.Where("code = ?", "E11").Delete(Product{ID: 3})

除了通过主键(数字和字符串),还支持内联条件来删除记录:

db.Delete(&Product{}, 2)  
// DELETE FROM product WHERE ID = 2;  
  
db.Delete(&Product{}, "2")  
// DELETE FROM product WHERE ID = 2;  
  
db.Delete(&Product{}, []int{1,2,3})  
// DELETE FROM product WHERE ID IN (1,2,3);

执行上面的代码,再次查看产品表就只剩下一条记录了:

IDcodeprice
4U8913
批量删除

如果指定的值不包括主键字段,那么 GORM 会执行批量删除,会删除所有匹配的记录。

db.Where("code like ?", "%E%").Delete(&Product{})

GORM 默认会阻止全局删除,所以不用担心手滑把所有数据不小心清空了。在没有任何条件的情况下执行批量删除,GORM 不会执行该操作,并返回 ErrMissingWhereClause 错误。

😁要是非要删库跑路的话,可以使用原生 SQL 或者启用 AllowGlobalUpdate 模式:

db.Exec("DELETE FROM product")
// or:
db.Session(&gorm.Session{AllowGlobalUpdate: true}).Delete(&Product{})  
软删除

如果模型(结构体)包含了一个 gorm.deletedat 字段(gorm.Model 已经包含了该字段),它将自动获得 软删除 的能力。

比如要在 product 中开启软删除,可以这样定义模型:

type Product struct {
    ID uint
    Code string `gorm:"column:code"`
    Price uint `gorm:"column:price"`
    Deleted gorm.DeletedAt
}

(或者直接引入 gorm.Model

拥有软删除能力的模型调用 Delete 时,记录不会从数据库中被真正删除。但 GORM 会将 DeletedAt 置为当前时间, 并且不能再通过普通的查询方法找到该记录。

软删除的原理就是 GORM 在查询时会在 SQL 语句后拼接一个 where 条件来检查 deleted_at 字段,如果为空说明没有删除,不为空说明这条数据应该被删除,查询出来的结果就把它过滤了。

可以使用 Unscoped 方法找到被软删除的记录或是永久删除匹配的记录:

db.Unscoped().Where("code = U89").Find(&products)
db.Unscoped().Delete(&p)

GORM 进阶

GORM 事务

GORM 提供了 BeginCommitRollback 三个方法用于事务(transaction)操作。

为了确保数据一致性,GORM 的写入操作默认是在事务里进行的,如果没有这方面的要求,可以在初识化时将其禁用,这将获得大约 30%+ 的性能提升。

// 全局禁用 
db, err := gorm.Open(sqlite.Open("gorm.db"), &gorm.Config{
    SkipDefaultTransaction: true, 
})

要在事务中执行一系列操作,一般流程如下:

tx := db.Begin() // 开启事务,从现在开始应用 tx 执行操作而不是 db
if err := tx.Create(&Product{Code: "E12", Price: 998}).Error; err != nul {
    tx.Rollback() // 出现错误时回滚
    return
}
if err := tx.Create(&Product{Code: "E13", Price: 668}).Error; err != nul {
    tx.Rollback() // 出现错误时回滚
    return
}
tx.Commit() // 提交事务

GORM 还提供了 Transaction 函数执行事务中的一系列操纵,写法更加简单,还能避免用户漏写 CommitRollback

db.Transaction(func(tx *gorm.DB) error {
    if err := tx.Create(&Product{Code: "E12", Price: 998}).Error; err != nil {
       return err // 返回任何错误都会回滚事务
    }
    if err := tx.Create(&Product{Code: "E13", Price: 668}).Error; err != nil {
       return err
    }
    return nil // 返回 nil 提交事务
})

钩子 - Hook

Hook 英文直译是 “钩子” 的意思,在代码执行的时候 Hook 能钩取一些信息拦截下来,然后执行自己定义的代码,从而插入额外功能的黑科技。

image.png

GORM 提供了 Hook 的能力,Hook 是在创建、查询、更新、删除等操作之前、之后调用的函数。如果为模型定义了指定的方法,它会在增删改查时自动被调用。

钩子方法的函数签名应该是:func(*gorm.DB) error

🎈举个栗子 - 比如我们想要在每次向 product 表插入价格大于 100 的商品后,自动把价格更新成九折,可以先创建一个 Hook 函数:

func (p *Product) AfterCreate(tx *gorm.DB) (err error) {
    if p.Price > 100 {
       tx.Model(p).Update("price", gorm.Expr("price * ?", 0.9))
    }
    return
}

执行插入操作后会自动调用 Hook 函数:

if err := db.Create(&Product{Code: "A57", Price: 200}).Error; err != nil {
   fmt.Println("插入数据失败", err)
}

执行上面的代码,查看数据库中的 product 表会发现插入记录的价格从 200 变成了 180:

IDcodeprice
4U8913
5A57180

性能优化

禁用默认事务

上文已经提到过,对于写操作(创建、更新、删除),为了确保数据的完整性,GORM 会将它们封装在一个事务里。但这会降低性能,可以在初始化时禁用这种方式。

db, err := gorm.Open(sqlite.Open("gorm.db"), &gorm.Config{
    SkipDefaultTransaction: true, 
})
缓存预编译语句

执行任何 SQL 时都创建并缓存预编译语句,可以提高后续的调用速度。

db, err := gorm.Open(sqlite.Open("gorm.db"), &gorm.Config{
    PrepareStmt: true,
})

GORM 生态

GORM 拥有丰富的扩展可供使用,提高开发效率。

代码生成工具 - Gen:

🎈GitHub - go-gorm/gen: Gen: Friendly & Safer GORM powered by Code Generation

Gen 是 GORM 的官方工具,由 go-gorm 组织提供。Gen 的定位是通过代码生成,让 GORM 更加友好,也更加安全。

分片分库 - Sharding:

🎈GitHub - go-gorm/sharding: High performance table sharding plugin for Gorm.

Sharding 是一个高性能的 GORM 分表中间件。它基于 Conn 层做 SQL 拦截、AST 解析、分表路由、自增主键填充,带来的额外开销极小。对开发者友好、透明,使用上与普通 SQL、GORM 查询无差别,只需要额外注意一下分表键条件。

乐观锁插件:

🎈GitHub - go-gorm/optimisticlock: optimistic lock plugin for gorm

乐观锁适用于读多写少的应用场景,可以提高吞吐量。


🚀【参考】🚀

  1. 青训营直播课程:Go 框架三件套
  2. GORM入门指南 | 李文周的博客 (liwenzhou.com)
  3. GORM 指南 | GORM - The fantastic ORM library for Golang, aims to be developer friendly.