GORM 是 Go 言语中最受欢迎的 ORM 库之一,它供给了强壮的功用和简练的 API,让数据库操作变得更加简略和易保护。本文将具体介绍 GORM 的常见用法,包括数据库衔接、模型界说、CRUD、业务办理等方面,协助咱们快速上手运用 GORM 进行 Web 后端开发。

装置

经过如下指令装置 GORM:

$ go get -u gorm.io/gorm

你也许见过运用 go get -u github.com/jinzhu/gorm 指令来装置 GORM,这个是老版本 v1,现已过时,不主张运用。新版本 v2 已经搬迁至 github.com/go-gorm/gorm 库房下。

快速开端

如下示例代码带你快速上手 GORM 的运用:

package main
import (
	"gorm.io/driver/sqlite"
	"gorm.io/gorm"
)
// Product 界说结构体用来映射数据库表
type Product struct {
	gorm.Model
	Code  string
	Price uint
}
func main() {
	// 树立数据库衔接
	db, err := gorm.Open(sqlite.Open("test.db"), &gorm.Config{})
	if err != nil {
		panic("failed to connect database")
	}
	// 搬迁表结构
	db.AutoMigrate(&Product{})
	// 添加数据
	db.Create(&Product{Code: "D42", Price: 100})
	// 查找数据
	var product Product
	db.First(&product, 1)                 // find product with integer primary key
	db.First(&product, "code = ?", "D42") // find product with code D42
	// 更新数据 - update product's price to 200
	db.Model(&product).Update("Price", 200)
	// 更新数据 - update multiple fields
	db.Model(&product).Updates(Product{Price: 200, Code: "F42"}) // non-zero fields
	db.Model(&product).Updates(map[string]interface{}{"Price": 200, "Code": "F42"})
	// 删去数据 - delete product
	db.Delete(&product, 1)
}

提示:这儿运用了 SQLite 数据库驱动,需求经过 go get -u gorm.io/driver/sqlite 指令装置。

将以上代码保存在 main.go 中并履行。

$ go run main.go

履行完结后,咱们将在当前目录下得到 test.db SQLite 数据库文件。

Go 语言流行 ORM 框架 GORM 使用介绍

① 进入 SQLite 指令行。

② 检查已存在的数据库表。

③ 设置稍后查询表数据时的输出形式为按列左对齐。

④ 查询表中存在的数据。

有过运用 ORM 结构经验的同学,以上代码即便我不进行解说也能看懂个大概。

这段示例代码根本能够概括 GORM 结构运用套路:

  1. 界说结构体映射表结构:Product 结构体在 GORM 中称作「模型」,一个模型对应一张数据库表,一个结构体实例目标对应一条数据库表记载。

  2. 衔接数据库:GORM 运用 gorm.Open 办法与数据库树立衔接,衔接树立好后,才能对数据库进行 CRUD 操作。

  3. 主动搬迁表结构:调用 db.AutoMigrate 办法能够主动完结在数据库中创立 Product 结构体所映射的数据库表,而且,当 Product 结构体字段有改动,再次履行搬迁代码,GORM 会主动对表结构进行调整,非常便利。不过,我不推荐在出产环境项目中运用此功用。因为数据库表操作都是高风险操作,一定要经过多人 Review 并审阅经过,才能履行操作。GORM 主动搬迁功用尽管理论上不会出现问题,但线上操作慎重为妙,个人认为只有在小项目或数据不那么重要的项目中运用比较适宜。

  4. CRUD 操作:搬迁好数据库后,就有了数据库表,能够进行 CRUD 操作了。

有些同学或许有个疑问,以上示例代码中并没有相似 defer db.Close() 主动关闭衔接的操作,那么何时关闭数据库衔接?

其实 GORM 保护了一个数据库衔接池,初始化 db 后一切的衔接都由底层库来办理,无需程序员手动干预,GORM 会在适宜的机遇主动关闭衔接。GORM 结构作者 jinzhu 也有在源码库房 Issue 中回复过网友的提问,感兴趣的同学能够点击进入检查。

接下来我将对 GORM 的运用进行具体解说。

声明模型

GORM 运用模型(Model)来映射一张数据库表,模型是标准的 Go struct,由 Go 的根本数据类型、完结了 ScannerValuer 接口的自界说类型及其指针或别号组成。

例如:

type User struct {
	ID           uint
	Name         string
	Email        *string
	Age          uint8
	Birthday     *time.Time
	MemberNumber sql.NullString
	ActivatedAt  sql.NullTime
	CreatedAt    time.Time
	UpdatedAt    time.Time
}

咱们能够运用 gorm 字段标签来操控数据库表字段的类型、列大小、默许值等特点,比方运用 column 字段标签来映射数据库中字段名称。

type User struct {
	gorm.Model
	Name         string         `gorm:"column:name"`
	Email        *string        `gorm:"column:email"`
	Age          uint8          `gorm:"column:age"`
	Birthday     *time.Time     `gorm:"column:birthday"`
	MemberNumber sql.NullString `gorm:"column:member_number"`
	ActivatedAt  sql.NullTime   `gorm:"column:activated_at"`
}
func (u *User) TableName() string {
	return "user"
}

在不指定 column 字段标签情况下,GORM 默许运用字段名的 snake_case 作为列名。

GORM 默许运用结构体名的 snake_cases 作为表名,为结构体完结 TableName 办法能够自界说表名。

我更喜爱「显式胜于隐式」的做法,所以数据库名和表名都会显现写出来。

因为咱们不运用主动搬迁的功用,所以其他字段标签都用不到,就不在此逐个介绍了,感兴趣的同学能够检查官方文档进行学习。

User 结构体中有一个嵌套的结构体 gorm.Model,它是 GORM 默许供给的一个模型 struct,用来简化用户模型界说。

GORM 倾向于约定优于配置,默许情况下,运用 ID 作为主键,运用 CreatedAtUpdatedAtDeletedAt 字段追寻记载的创立、更新、删去时刻。而这几个字段就界说在 gorm.Model 中:

type Model struct {
	ID        uint `gorm:"primarykey"`
	CreatedAt time.Time
	UpdatedAt time.Time
	DeletedAt DeletedAt `gorm:"index"`
}

由于咱们不运用主动搬迁功用,所以需求手动编写 SQL 句子来创立 user 数据库表结构:

CREATE TABLE `user` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `name` varchar(50) DEFAULT '' COMMENT '用户名',
  `email` varchar(255) NOT NULL DEFAULT '' COMMENT '邮箱',
  `age` tinyint(4) NOT NULL DEFAULT '0' COMMENT '年龄',
  `birthday` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '生日',
  `member_number` varchar(50) COMMENT '成员编号',
  `activated_at` datetime COMMENT '激活时刻',
  `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
  `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  `deleted_at` datetime,
  PRIMARY KEY (`id`),
  UNIQUE KEY `u_email` (`email`),
  INDEX `idx_deleted_at`(`deleted_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='用户表';

数据库中字段类型要跟 Go 中模型的字段类型相对应,不兼容的类型或许导致过错。

衔接数据库

GORM 官方支撑的数据库类型有:MySQL、PostgreSQL、SQLite、SQL Server 和 TiDB。

这儿运用最常见的 MySQL 作为示例,来解说 GORM 怎么衔接到数据库。

在前文快速开端的示例代码中,咱们运用 SQLite 数据库时,装置了 sqlite 驱动程序。要衔接 MySQL 则需求运用 mysql 驱动。

在 GORM 中界说了 gorm.Dialector 接口来标准数据库衔接操作,完结了此接口的程序咱们将其称为「驱动」。针对每种数据库,都有对应的驱动,驱动是独立于 GORM 库的,需求单独引入。

衔接 MySQL 数据库的代码如下:

package main
import (
	"fmt"
	"gorm.io/driver/mysql"
	"gorm.io/gorm"
)
func ConnectMySQL(host, port, user, pass, dbname string) (*gorm.DB, error) {
	dsn := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?charset=utf8mb4&parseTime=True&loc=Local",
		user, pass, host, port, dbname)
	return gorm.Open(mysql.Open(dsn), &gorm.Config{})
}

能够发现,这段代码与衔接 SQLite 数据库的代码千篇一律,这便是面向接口编程的优点。

首先,mysql.Open 接纳一个字符dsn,DSN 全称 Data Source Name,翻译过来叫数据库源名称。DSN 界说了一个数据库的衔接信息,包括用户名、密码、数据库 IP、数据库端口、数据库字符集、数据库时区等信息。DSN 遵循特定格局:

username:password@protocol(address)/dbname?param=value

经过 DSN 所包括的信息,mysql 驱动就能够知道以什么办法衔接到 MySQL 数据库了。

mysql.Open 回来的正是一个 gorm.Dialector 目标,将其传递给 gorm.Open 办法后,咱们将得到 *gorm.DB 目标,这个目标能够用来操作数据库。

GORM 运用 database/sql 来保护数据库衔接池,关于衔接池咱们能够设置如下几个参数:

func SetConnect(db *gorm.DB) error {
	sqlDB, err := db.DB()
	if err != nil {
		return err
	}
	sqlDB.SetMaxOpenConns(100)                 // 设置数据库的最大翻开衔接数
	sqlDB.SetMaxIdleConns(100)                 // 设置最大空闲衔接数
	sqlDB.SetConnMaxLifetime(10 * time.Second) // 设置空闲衔接最大存活时刻
	return nil
}

现在,数据库衔接已经树立,咱们能够对数据库进行操作了。

创立

能够运用 Create 办法创立一条数据库记载:

now := time.Now()
email := "u1@jianghushinian.com"
user := User{Name: "user1", Email: &email, Age: 18, Birthday: &now}
// INSERT INTO `user` (`created_at`,`updated_at`,`deleted_at`,`name`,`email`,`age`,`birthday`,`member_number`,`activated_at`) VALUES ('2023-05-22 22:14:47.814','2023-05-22 22:14:47.814',NULL,'user1','u1@jianghushinian.com',18,'2023-05-22 22:14:47.812',NULL,NULL)
result := db.Create(&user) // 经过数据的指针来创立
fmt.Printf("user: %+v\n", user) // user.ID 主动填充
fmt.Printf("affected rows: %d\n", result.RowsAffected)
fmt.Printf("error: %v\n", result.Error)

要创立记载,咱们需求先实例化 User 目标,然后将其指针传递给 db.Create 办法。

db.Create 办法履行完结后,依然回来一个 *gorm.DB 目标。

user.ID 会被主动填充为创立数据库记载后回来的实在值。

result.RowsAffected 能够拿到此次操作影响行数。

result.Error 能够知道履行 SQL 是否出错。

在这儿,我将 db.Create(&user) 这句 ORM 代码所生成的原生 SQL 句子放在了注释中,便利你比照学习。而且,之后的示例中我也会这样做。

Create 办法不只支撑创立单条记载,它相同支撑批量操作,一次创立多条记载:

now = time.Now()
email2 := "u2@jianghushinian.com"
email3 := "u3@jianghushinian.com"
users := []User{
	{Name: "user2", Email: &email2, Age: 19, Birthday: &now},
	{Name: "user3", Email: &email3, Age: 20, Birthday: &now},
}
// INSERT INTO `user` (`created_at`,`updated_at`,`deleted_at`,`name`,`email`,`age`,`birthday`,`member_number`,`activated_at`) VALUES ('2023-05-22 22:14:47.834','2023-05-22 22:14:47.834',NULL,'user2','u2@jianghushinian.com',19,'2023-05-22 22:14:47.833',NULL,NULL),('2023-05-22 22:14:47.834','2023-05-22 22:14:47.834',NULL,'user3','u3@jianghushinian.com',20,'2023-05-22 22:14:47.833',NULL,NULL)
result = db.Create(&users)

代码首要逻辑不变,只需求将单个的 User 实例换成 User 切片即可。GORM 会运用一条 SQL 句子完结批量创立记载。

查询

查询记载是咱们在日常开发中运用最多的场景了,GORM 供给了多种办法来支撑 SQL 查询操作。

运用 First 办法能够查询第一条记载:

var user User
// SELECT * FROM `user` WHERE `user`.`deleted_at` IS NULL ORDER BY `user`.`id` LIMIT 1
result := db.First(&user)

First 办法接纳一个模型指针,经过模型的 TableName 办法则能够拿到数据库表名,然后运用 SELECT * 句子从数据库中查询记载。

依据生成的 SQL 能够发现 First 办法查询数据默许依据主键 ID 升序排序,而且只会过滤删去时刻为 NULL 的数据,运用 LIMIT 关键字来约束数据条数。

运用 Last 办法能够查询最终一条数据,排序规矩为主键 ID 降序:

var lastUser User
// SELECT * FROM `user` WHERE `user`.`deleted_at` IS NULL ORDER BY `user`.`id` DESC LIMIT 1
result = db.Last(&lastUser)

运用 Where 办法能够添加查询条件:

var users []User
// SELECT * FROM `user` WHERE name != 'unknown' AND `user`.`deleted_at` IS NULL
result = db.Where("name != ?", "unknown").Find(&users)

这儿不再查询单条数据,所以改用 Find 办法来查询一切符合条件的记载。

以上介绍的几种查询办法,都是经过 SELECT * 查询数据库表中的悉数字段,咱们能够运用 Select 办法指定需求查询的字段:

var user2 User
// SELECT `name`,`age` FROM `user` WHERE `user`.`deleted_at` IS NULL ORDER BY `user`.`id` LIMIT 1
result = db.Select("name", "age").First(&user2)

运用 Order 办法能够自界说排序规矩:

var users2 []User
// SELECT * FROM `user` WHERE `user`.`deleted_at` IS NULL ORDER BY id desc
result = db.Order("id desc").Find(&users2)

GORM 也供给了对 Limit & Offset 的支撑:

var users3 []User
// SELECT * FROM `user` WHERE `user`.`deleted_at` IS NULL LIMIT 2 OFFSET 1
result = db.Limit(2).Offset(1).Find(&users3)

运用 -1 能够撤销 Limit & Offset 的约束条件:

var users4 []User
var users5 []User
// SELECT * FROM `user` WHERE `user`.`deleted_at` IS NULL LIMIT 2 OFFSET 1; (users4)
// SELECT * FROM `user` WHERE `user`.`deleted_at` IS NULL; (users5)
result = db.Limit(2).Offset(1).Find(&users4).Limit(-1).Offset(-1).Find(&users5)

这段代码会履行两条查询句子,之所以能够采用这种「链式调用」的办法履行多条 SQL,是因为每个办法回来的都是 *gorm.DB 目标,这也是一种编程技巧。

运用 Count 办法能够统计记载条数:

var count int64
// SELECT count(*) FROM `user` WHERE `user`.`deleted_at` IS NULL
result = db.Model(&User{}).Count(&count)

有时分遇到比较杂乱的业务,咱们或许需求运用 SQL 子查询,子查询能够嵌套在另一个查询中,GORM 允许将 *gorm.DB 目标作为参数时生成子查询:

var avgages []float64
// SELECT AVG(age) as avgage FROM `user` WHERE `user`.`deleted_at` IS NULL GROUP BY `name` HAVING AVG(age) > (SELECT AVG(age) FROM `user` WHERE name LIKE 'user%')
subQuery := db.Select("AVG(age)").Where("name LIKE ?", "user%").Table("user")
result = db.Model(&User{}).Select("AVG(age) as avgage").Group("name").Having("AVG(age) > (?)", subQuery).Find(&avgages)

Having 办法签名如下:

func (db *DB) Having(query interface{}, args ...interface{}) (tx *DB)

第二个参数是一个范型 interface{},所以不只能够接纳字符串,GORM 在判断其类型为 *gorm.DB 时,就会构造一个子查询。

更新

为了解说更新操作,咱们需求先查询一条记载,之后的更新操作都是依据这条被查询出来的 User 目标:

var user User
// SELECT * FROM `user` WHERE `user`.`deleted_at` IS NULL ORDER BY `user`.`id` LIMIT 1
result := db.First(&user)

更新操作只要修正 User 目标的特点,然后调用 db.Save(&user) 办法即可完结:

user.Name = "John"
user.Age = 20
// UPDATE `user` SET `created_at`='2023-05-22 22:14:47.814',`updated_at`='2023-05-22 22:24:34.201',`deleted_at`=NULL,`name`='John',`email`='u1@jianghushinian.com',`age`=20,`birthday`='2023-05-22 22:14:47.813',`member_number`=NULL,`activated_at`=NULL WHERE `user`.`deleted_at` IS NULL AND `id` = 1
result = db.Save(&user)

在更新操作时,User 目标要确保 ID 特点存在值,否则就变成了创立操作。

Save 办法会保存一切的字段,即便字段是对应类型的零值。

除了运用 Save 办法更新一切字段,咱们还能够运用 Update 办法更新指定字段:

// UPDATE `user` SET `name`='Jianghushinian',`updated_at`='2023-05-22 22:24:34.215' WHERE `user`.`deleted_at` IS NULL AND `id` = 1
result = db.Model(&user).Update("name", "Jianghushinian")

Update 只能支撑更新单个字段,要想更新多个字段,能够运用 Updates 办法:

// UPDATE `user` SET `updated_at`='2023-05-22 22:29:35.19',`name`='JiangHu' WHERE `user`.`deleted_at` IS NULL AND `id` = 1
result = db.Model(&user).Updates(User{Name: "JiangHu", Age: 0})

留意,Updates 办法与 Save 办法有一个很大的不同之处,它只会更新非零值字段。Age 字段为零值,所以不会被更新。

假如一定要更新零值字段,除了能够运用上面的 Save 办法,还能够将 User 结构体换成 map[string]interface{} 类型的 map 目标:

// UPDATE `user` SET `age`=0,`name`='JiangHu',`updated_at`='2023-05-22 22:29:35.623' WHERE `user`.`deleted_at` IS NULL AND `id` = 1
result = db.Model(&user).Updates(map[string]interface{}{"name": "JiangHu", "age": 0})

此外,更新数据时,还能够运用 gorm.Expr 来完结 SQL 表达式:

// UPDATE `user` SET `age`=age + 1,`updated_at`='2023-05-22 22:24:34.219' WHERE `user`.`deleted_at` IS NULL AND `id` = 1
result = db.Model(&user).Update("age", gorm.Expr("age + ?", 1))

gorm.Expr("age + ?", 1) 办法调用会被转换成 age=age + 1 SQL 表达式。

删去

能够运用 Delete 办法删去数记载:

var user User
// UPDATE `user` SET `deleted_at`='2023-05-22 22:46:45.086' WHERE name = 'JiangHu' AND `user`.`deleted_at` IS NULL
result := db.Where("name = ?", "JiangHu").Delete(&user)

关于删去操作,GORM 默许运用逻辑删去策略,不会对记载进行物理删去。

所以 Delete 办法在对数据进行删去时,实际上履行的是 SQL UPDATE 操作,而非 DELETE 操作。

deleted_at 字段更新为当前时刻,表示当前数据已删去。这也是为什么前文在解说查询和更新的时分,生成的 SQL 句子都主动附加了 deleted_at IS NULL Where 条件的原因。

这样就完结了逻辑层面的删去,数据在数据库中依然存在,但查询和更新的时分会将其过滤掉。

记载被删去后,咱们无法经过如下代码直接查询到被逻辑删去的记载:

// SELECT * FROM `user` WHERE name = 'JiangHu' AND `user`.`deleted_at` IS NULL ORDER BY `user`.`id` LIMIT 1
result = db.Where("name = ?", "JiangHu").First(&user)
if err := result.Error; err != nil {
	fmt.Println(err) // record not found
}

这将得到一个过错 record not found

不过,GORM 供给了 Unscoped 办法,能够绕过逻辑删去:

// SELECT * FROM `user` WHERE name = 'JiangHu' ORDER BY `user`.`id` LIMIT 1
result = db.Unscoped().Where("name = ?", "JiangHu").First(&user)

以上代码能够查询出被逻辑删去的记载,生成的 SQL 句子中没有包括 deleted_at IS NULL Where 条件。

关于比较重要的数据,主张运用逻辑删去,这样能够在需求的时分康复数据,也便于毛病追寻。

不过,假如清晰想要物理删去一条记载,同理能够运用 Unscoped 办法:

// DELETE FROM `user` WHERE name = 'JiangHu' AND `user`.`id` = 1
result = db.Unscoped().Where("name = ?", "JiangHu").Delete(&user)

相关

日常开发中,大都情况下不只是对单表进行操作,还要对存在相相联络的多表进行操作。

这儿以一个博客体系最常见的三张表「文章表、谈论表、标签表」为例,对 GORM 怎么操作相关表进行解说。

这儿触及最常见的相相联络:一对多和多对多。一篇文章能够有多条谈论,所以文章和谈论是一对多联络;一篇文章能够存在多个标签,每个标签也能够包括多篇文章,所以文章和标签是多对多联络。

模型界说如下:


type Post struct {
	gorm.Model
	Title    string     `gorm:"column:title"`
	Content  string     `gorm:"column:content"`
	Comments []*Comment `gorm:"foreignKey:PostID;constraint:OnUpdate:CASCADE,OnDelete:SET NULL;references:ID"`
	Tags     []*Tag     `gorm:"many2many:post_tags"`
}
func (p *Post) TableName() string {
	return "post"
}
type Comment struct {
	gorm.Model
	Content string `gorm:"column:content"`
	PostID  uint   `gorm:"column:post_id"`
	Post    *Post
}
func (c *Comment) TableName() string {
	return "comment"
}
type Tag struct {
	gorm.Model
	Name string  `gorm:"column:name"`
	Post []*Post `gorm:"many2many:post_tags"`
}
func (t *Tag) TableName() string {
	return "tag"
}

我准备了对应的建表 SQL,能够点击链接进行检查:GitHub 地址。

在模型界说中,Post 文章模型运用 CommentsTags 别离保存相关的谈论和标签,这两个字段不会保存在数据库表中。

Comments 字段标签运用 foreignKey 来指明 Comments 表中的外键,并运用 constraint 指明了约束条件,references 指明 Comments 表外键引证 Post 表的 ID 字段。

其完结在出产环境中都不再推荐运用外键,各个表之间不再有数据库层面的外键约束,在做 CRUD 操作时悉数经过代码层面来进行业务约束。这儿为了演示 GORM 的外键和级联操作功用,所以界说了这些结构体标签。

Tags 字段标签运用 many2many 来指明多对多相关表名。

关于 Comment 模型,PostID 字段便是外键,用来保存 Post.IDPost 字段相同不会保存在数据库中,这种做法在 ORM 结构中非常常见。

接下来,我将相同对相关表的 CRUD 操作进行逐个解说。

创立

创立 Post 时会主动创立与之相关的 CommentsTags

var post Post
post = Post{
	Title:   "post1",
	Content: "content1",
	Comments: []*Comment{
		{Content: "comment1", Post: &post},
		{Content: "comment2", Post: &post},
	},
	Tags: []*Tag{
		{Name: "tag1"},
		{Name: "tag2"},
	},
}
result := db.Create(&post)

这儿界说了一个文章目标 post,而且包括两条谈论和两个标签。

留意 CommentPost 字段引证了 &post,并没有指定 PostID 外键字段,GORM 能够正确处理它。

以上代码将生成并顺次履行如下 SQL 句子:

BEGIN TRANSACTION;
INSERT INTO `tag` (`created_at`,`updated_at`,`deleted_at`,`name`) VALUES ('2023-05-22 22:56:52.923','2023-05-22 22:56:52.923',NULL,'tag1'),('2023-05-22 22:56:52.923','2023-05-22 22:56:52.923',NULL,'tag2') ON DUPLICATE KEY UPDATE `id`=`id`
INSERT INTO `post` (`created_at`,`updated_at`,`deleted_at`,`title`,`content`) VALUES ('2023-05-22 22:56:52.898','2023-05-22 22:56:52.898',NULL,'post1','content1') ON DUPLICATE KEY UPDATE `id`=`id`
INSERT INTO `comment` (`created_at`,`updated_at`,`deleted_at`,`content`,`post_id`) VALUES ('2023-05-22 22:56:52.942','2023-05-22 22:56:52.942',NULL,'comment1',1),('2023-05-22 22:56:52.942','2023-05-22 22:56:52.942',NULL,'comment2',1) ON DUPLICATE KEY UPDATE `post_id`=VALUES(`post_id`)
INSERT INTO `post_tags` (`post_id`,`tag_id`) VALUES (1,1),(1,2) ON DUPLICATE KEY UPDATE `post_id`=`post_id`
COMMIT;

能够发现,与文章构成一对多联络的谈论以及与文章构成多对多联络的标签,都会被创立,而且 GORM 会保护其相相联络,而且这些操作悉数在一个业务下完结。

此外,前文介绍的 Save 办法不只能够更新记载,实际上它还支撑创立记载,当 Post 目标不存在主键 ID 时,Save 办法将会创立一条新的记载:

var post3 Post
post3 = Post{
	Title:   "post3",
	Content: "content3",
	Comments: []*Comment{
		{Content: "comment33", Post: &post3},
	},
	Tags: []*Tag{
		{Name: "tag3"},
	},
}
result = db.Save(&post3)

以上代码生成的 SQL 如下:

BEGIN TRANSACTION;
INSERT INTO `tag` (`created_at`,`updated_at`,`deleted_at`,`name`) VALUES ('2023-05-22 23:17:53.189','2023-05-22 23:17:53.189',NULL,'tag3') ON DUPLICATE KEY UPDATE `id`=`id`
INSERT INTO `post` (`created_at`,`updated_at`,`deleted_at`,`title`,`content`) VALUES ('2023-05-22 23:17:53.189','2023-05-22 23:17:53.189',NULL,'post3','content3') ON DUPLICATE KEY UPDATE `id`=`id`
INSERT INTO `comment` (`created_at`,`updated_at`,`deleted_at`,`content`,`post_id`) VALUES ('2023-05-22 23:17:53.19','2023-05-22 23:17:53.19',NULL,'comment33',0) ON DUPLICATE KEY UPDATE `post_id`=VALUES(`post_id`)
INSERT INTO `post_tags` (`post_id`,`tag_id`) VALUES (0,0) ON DUPLICATE KEY UPDATE `post_id`=`post_id`
COMMIT;

查询

能够运用如下办法,依据 PostID 查询与之相关的 Comments

var (
	post     Post
	comments []*Comment
)
post.ID = 1
// SELECT * FROM `comment` WHERE `comment`.`post_id` = 1 AND `comment`.`deleted_at` IS NULL
err := db.Model(&post).Association("Comments").Find(&comments)

留意⚠️:传递给 Association 办法的参数是 Comments,即在 Post 模型中界说的字段,而非谈论的模型名 Comment。这点一定不要搞错了,否则履行 SQL 时会报错。

Post 是源模型,主键 ID 不能为空。Association 办法指定相关字段名,在 Post 模型中相关的谈论运用 Comments 表示。最终运用 Find 办法来查询相关的谈论。

在查询 Post 时,咱们能够预加载与之相关的 Comments

post2 := Post{}
result := db.Preload("Comments").Preload("Tags").First(&post2)
fmt.Println(post2)
for i, comment := range post2.Comments {
	fmt.Println(i, comment)
}
for i, tag := range post2.Tags {
	fmt.Println(i, tag)
}

咱们能够像往常一样运用 First 办法查询一条 Post 记载,一起搭配运用 Preload 办法来指定预加载的相关字段名,这样在查询 Post 记载时,会将相关字段表的记载悉数查询出来,并赋值给相关字段。

以上代码将履行如下 SQL:

BEGIN TRANSACTION;
SELECT * FROM `post` WHERE `post`.`deleted_at` IS NULL ORDER BY `post`.`id` LIMIT 1
SELECT * FROM `comment` WHERE `comment`.`post_id` = 1 AND `comment`.`deleted_at` IS NULL
SELECT * FROM `post_tags` WHERE `post_tags`.`post_id` = 1
SELECT * FROM `tag` WHERE `tag`.`id` IN (1,2) AND `tag`.`deleted_at` IS NULL
COMMIT;

GORM 经过多条 SQL 句子查询出一切相关记载,而且将相关 CommentsTags 别离赋值给 Post 模型对应字段。

当遇到多表查询时,咱们一般还会运用 JOIN 来衔接多张表:

type PostComment struct {
	Title   string
	Comment string
}
postComment := PostComment{}
post3 := Post{}
post3.ID = 3
// SELECT post.title, comment.Content AS comment FROM `post` LEFT JOIN comment ON comment.post_id = post.id WHERE `post`.`deleted_at` IS NULL AND `post`.`id` = 3
result := db.Model(&post3).Select("post.title, comment.Content AS comment").Joins("LEFT JOIN comment ON comment.post_id = post.id").Scan(&postComment)

运用 Select 办法来指定需求查询的字段,运用 Joins 办法来完结 JOIN 功用,最终运用 Scan 办法能够将查询成果扫描到 postComment 目标中。

针对一对多相相联络,Joins 办法相同支撑预加载:

var comments2 []*Comment
// SELECT `comment`.`id`,`comment`.`created_at`,`comment`.`updated_at`,`comment`.`deleted_at`,`comment`.`content`,`comment`.`post_id`,`Post`.`id` AS `Post__id`,`Post`.`created_at` AS `Post__created_at`,`Post`.`updated_at` AS `Post__updated_at`,`Post`.`deleted_at` AS `Post__deleted_at`,`Post`.`title` AS `Post__title`,`Post`.`content` AS `Post__content` FROM `comment` LEFT JOIN `post` `Post` ON `comment`.`post_id` = `Post`.`id` AND `Post`.`deleted_at` IS NULL WHERE `comment`.`deleted_at` IS NULL
result = db.Joins("Post").Find(&comments2)
for i, comment := range comments2 {
	fmt.Println(i, comment)
	fmt.Println(i, comment.Post)
}

JOIN 功用的预加载无需显式运用 Preload 来指明,只需求在 Joins 办法中指明一对多联络中一这一端模型 Post 即可,运用 Find 查询 Comment 记载。

依据生成的 SQL 能够发现查询主表为 comment,副表为 post。而且副表的字段都被重命名为 模型名__字段名 的格局,如 Post__title(题外话:假如你运用过 Python 的 Django ORM 结构,那么对这个双下划线命名字段的做法应该有种似曾相识的感觉)。

更新

同解说单表更新时一样,咱们需求先查询出一条记载,用来演示更新操作:

var post Post
// SELECT * FROM `post` WHERE `post`.`deleted_at` IS NULL ORDER BY `post`.`id` LIMIT 1
result := db.First(&post)

能够运用如下办法替换 Post 相关的 Comments

comment := Comment{
	Content: "comment3",
}
err := db.Model(&post).Association("Comments").Replace([]*Comment{&comment})

依然运用 Association 办法指定 Post 相关的 CommentsReplace 办法用来完结替换操作。

这儿要留意,Replace 办法回来成果不再是 *gorm.DB 目标,而是直接回来 error

生成 SQL 如下:

BEGIN TRANSACTION;
INSERT INTO `comment` (`created_at`,`updated_at`,`deleted_at`,`content`,`post_id`) VALUES ('2023-05-23 09:07:42.852','2023-05-23 09:07:42.852',NULL,'comment3',1) ON DUPLICATE KEY UPDATE `post_id`=VALUES(`post_id`)
UPDATE `post` SET `updated_at`='2023-05-23 09:07:42.846' WHERE `post`.`deleted_at` IS NULL AND `id` = 1
UPDATE `comment` SET `post_id`=NULL WHERE `comment`.`id` <> 8 AND `comment`.`post_id` = 1 AND `comment`.`deleted_at` IS NULL
COMMIT;

删去

运用 Delete 删去文章表时,不会删去相关表的数据:

var post Post
// UPDATE `post` SET `deleted_at`='2023-05-23 09:09:58.534' WHERE id = 1 AND `post`.`deleted_at` IS NULL
result := db.Where("id = ?", 1).Delete(&post)

关于存在相相联络的记载,删去时默许相同采用 UPDATE 操作,且不影响相关数据。

假如想要在删去谈论时,趁便删去与文章的相相联络,能够运用 Association 办法:

// UPDATE `comment` SET `post_id`=NULL WHERE `comment`.`post_id` = 6 AND `comment`.`id` IN (NULL) AND `comment`.`deleted_at` IS NULL
err := db.Model(&post2).Association("Comments").Delete(post2.Comments)

业务

GORM 供给了对业务的支撑,这在杂乱的业务逻辑中是必要的。

要在业务中履行一系列操作,能够运用 Transaction 办法完结:

func TransactionPost(db *gorm.DB) error {
	return db.Transaction(func(tx *gorm.DB) error {
		post := Post{
			Title: "Hello World",
		}
		if err := tx.Create(&post).Error; err != nil {
			return err
		}
		comment := Comment{
			Content: "Hello World",
			PostID:  post.ID,
		}
		if err := tx.Create(&comment).Error; err != nil {
			return err
		}
		return nil
	})
}

Transaction 办法内部的代码,都将在一个业务中被处理。Transaction 办法接纳一个函数,其参数为 tx *gorm.DB,业务中一切数据库的操作,都应该运用这个 tx 而非 db

在履行业务的函数中,回来任何过错,整个业务都将被回滚,回来 nil 则业务被提交。

除了运用 Transaction 主动办理业务,咱们还能够手动办理业务:

func TransactionPostWithManually(db *gorm.DB) error {
	tx := db.Begin()
	post := Post{
		Title: "Hello World Manually",
	}
	if err := tx.Create(&post).Error; err != nil {
		tx.Rollback()
		return err
	}
	comment := Comment{
		Content: "Hello World Manually",
		PostID:  post.ID,
	}
	if err := tx.Create(&comment).Error; err != nil {
		tx.Rollback()
		return err
	}
	return tx.Commit().Error
}

db.Begin() 用于敞开业务,并回来 tx,稍后的业务操作都应运用这个 tx 目标。假如在处理业务的过程中遇到过错,能够运用 tx.Rollback() 回滚业务,假如没有问题,最终能够运用 tx.Commit() 提交业务。

留意:手动业务,业务一旦开端,你就应该运用 tx 处理数据库操作。

钩子

GORM 还支撑 Hook 功用,Hook 是在创立、查询、更新、删去等操作之前、之后调用的函数,用来办理目标的生命周期。

钩子办法的函数签名为 func(*gorm.DB) error,比方以下钩子函数在创立操作之前触发:

func (u *User) BeforeCreate(tx *gorm.DB) (err error) {
	u.UUID = uuid.New()
	if u.Name == "admin" {
		return errors.New("invalid name")
	}
	return nil
}

比方咱们为 User 模型界说 BeforeCreate 钩子,这样在创立 User 目标前,GORM 会主动调用此函数,完结为 User 目标创立 UUID 以及用户名合法性验证功用。

GORM 支撑的钩子函数以及履行机遇如下:

钩子函数 履行机遇
BeforeSave 调用 Save 前
AfterSave 调用 Save 后
BeforeCreate 刺进记载前
AfterCreate 刺进记载后
BeforeUpdate 更新记载前
AfterUpdate 更新记载后
BeforeDelete 删去记载前
AfterDelete 删去记载后
AfterFind 查询记载后

原生 SQL

尽管咱们运用 ORM 结构往往是为了将原生 SQL 的编写转为面向目标编程,不过对原生 SQL 的支撑是一款 ORM 结构必备的功用。

能够运用 Raw 办法履行原生查询 SQL,并将成果 Scan 到模型中:

var userRes UserResult
db.Raw(`SELECT id, name, age FROM user WHERE id = ?`, 3).Scan(&userRes)
fmt.Printf("affected rows: %d\n", db.RowsAffected)
fmt.Println(db.Error)
fmt.Println(userRes)

原生 SQL 相同支撑运用表达式:

var sumage int
db.Raw(`SELECT SUM(age) as sumage FROM user WHERE member_number ?`, gorm.Expr("IS NULL")).Scan(&sumage)

此外,咱们还能够运用 Exec 履行任意原生 SQL:

db.Exec("UPDATE user SET age = ? WHERE id IN ?", 18, []int64{1, 2})
// 运用表达式
db.Exec(`UPDATE user SET age = ? WHERE name = ?`, gorm.Expr("age * ? + ?", 1, 2), "Jianghu")
// 删去表
db.Exec("DROP TABLE user")

运用 Exec 无法拿到履行成果,能够用来对表进行操作,比方添加、删去表等。

编写 SQL 时支撑运用 @name 语法命名参数:

var post Post
db.Where("title LIKE @name OR content LiKE @name", sql.Named("name", "%Hello%")).Find(&post)
var user User
// SELECT * FROM user WHERE name1 = "Jianghu" OR name2 = "shinian" OR name3 = "Jianghu"
db.Raw("SELECT * FROM user WHERE name1 = @name OR name2 = @name2 OR name3 = @name",
   sql.Named("name", "Jianghu"), sql.Named("name2", "shinian")).Find(&user)

运用 DryRun 形式能够直接拿到由 GORM 生成的原生 SQL,而不履行,便利后续运用:

var user User
stmt := db.Session(&gorm.Session{DryRun: true}).First(&user, 1).Statement
fmt.Println(stmt.SQL.String()) // SQL: SELECT * FROM `user` WHERE `user`.`id` = ? AND `user`.`deleted_at` IS NULL ORDER BY `user`.`id` LIMIT 1
fmt.Println(stmt.Vars)         // 参数: [1]

DryRun 形式能够翻译为空跑,意思是不履行真正的 SQL,这在调试时非常有用。

调试

GORM 常用功用咱们已经根本解说完结了,最终再来介绍下在日常开发中,遇到问题怎么进行调试。

GORM 调试办法我总结了如下 5 点:

  1. 大局敞开日志

还记得在衔接数据库时 gorm.Open 办法的第二个参数吗,咱们当时传递了一个空配置 &gorm.Config{},这个可选的参数能够改动 GORM 的一些默许功用配置,比方咱们能够设置日志等级为 Info,这样就能够在操控台打印一切履行的 SQL 句子:

db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{
	Logger:logger.Default.LogMode(logger.Info),
})
  1. 打印慢查询 SQL

有时分某段 ORM 代码履行很慢,咱们能够经过敞开慢查询日志,来检测 SQL 中的慢查询句子:

func ConnectMySQL(host, port, user, pass, dbname string) (*gorm.DB, error) {
	slowLogger := logger.New(
		log.New(os.Stdout, "\r\n", log.LstdFlags),
		logger.Config{
			// 设定慢查询时刻阈值为 3ms(默许值:200 * time.Millisecond)
			SlowThreshold: 3 * time.Millisecond,
			// 设置日志等级
			LogLevel: logger.Warn,
		},
	)
	dsn := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?charset=utf8mb4&parseTime=True&loc=Local",
		user, pass, host, port, dbname)
	return gorm.Open(mysql.Open(dsn), &gorm.Config{
		Logger: slowLogger,
	})
}
  1. 打印指定 SQL

运用 Debug 能够打印当前 ORM 句子履行的 SQL:

db.Debug().First(&User{})
  1. 大局敞开 DryRun 模型

在衔接数据库时,咱们能够大局敞开「空跑」形式:

db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{
	DryRun: true,
})

敞开 DryRun 模型后,任何 SQL 句子都不会真正履行,便利测验。

  1. 局部敞开 DryRun 模型

在当前 Session 中局部敞开「空跑」模型,能够在不履行操作的情况下生成 SQL 及其参数,用于准备或测验生成的 SQL:

var user User
stmt := db.Session(&gorm.Session{DryRun: true}).First(&user, 1).Statement
fmt.Println(stmt.SQL.String()) // => SELECT * FROM `users` WHERE `id` = $1 ORDER BY `id`
fmt.Println(stmt.Vars)         // => []interface{}{1}

总结

本文对 Go 言语中最盛行的 ORM 结构 GORM 进行了解说,介绍了怎么编写模型,怎么衔接数据库,以及最常运用的 CRUD 操作。而且还对相关表中的一对多、多对多两种相相联络操作进行了解说。咱们还介绍了必不可少的功用「业务」,GORM 还供给了钩子函数便利咱们在 CRUD 操作前后刺进一些自界说逻辑。最终对怎么运用原生 SQL 以及怎么调试也进行了介绍。

只要你原生 SQL 根底扎实,ORM 结构学习起来并不会太费力,而且咱们还有各种调试办法来打印 GORM 所生成的 SQL,便利排查问题。

由于文章篇幅所限,这儿只介绍了 GORM 常用功用,不过也根本能够掩盖日常开发中大都场景。更多高档功用如自界说 Logger、读写别离、从数据库表反向生成模型等操作,能够参阅官方文档进行学习。

本文完整代码示例我放在了 GitHub 上,欢迎点击检查。

希望此文能对你有所协助。

联络我

  • 微信:jianghushinian
  • 邮箱:jianghushinian007@outlook.com
  • 博客地址:jianghushinian.cn/

参阅

  • GORM 源码:github.com/go-gorm/gor…
  • GORM 文档:gorm.io/zh_CN/
  • 本文示例代码:github.com/jianghushin…