Gorm

2021-06-11T14:21:02+08:00 | 12分钟阅读 | 更新于 2021-06-11T14:21:02+08:00

@

学习目标

学完本章,你应该能够:

  1. 用自己的话讲清楚什么是 ORM,以及它在什么场景能帮你省事、什么场景会拖后腿。
  2. 独立完成 GORM 的建连、连接池配置、AutoMigrate、CRUD、关联查询一整条基本链路。
  3. 解释 GORM 的软删除机制,以及它为什么会在查询时自动追加 WHERE deleted_at IS NULL
  4. 说清并规避 GORM 最经典的"零值不更新“坑,知道用指针 / sql.NullXXX / Select 三种解法。
  5. 面试时能讲清楚 PreloadJoinsSaveUpdates、以及 AutoMigrate 的边界——这些是八股高频题。

前置知识

  • Go 基础语法(结构体、tag、指针、map)。
  • 至少用过一种关系型数据库,知道表、主键、外键、索引是什么。
  • 了解 database/sql 的基本用法更佳,但不是必须。

本章你会动手做的事

  1. 用 GORM 连上本地 MySQL,建一张 users 表并读写几条数据。
  2. 故意触发一次"零值不更新"现象,再用指针字段把它修好。
  3. User 加一个 Profile 一对一关系,分别用 PreloadJoins 把数据捞出来对比。

类比:ORM 就像"翻译官”。你只会说 Go(面向对象),数据库只会说 SQL(关系型)。ORM 站在中间,把你写的 user.Name = "Alice" 翻译成数据库能懂的 UPDATE users SET name='Alice'。好处是你不用背 SQL 语法,坏处是翻译有时不够精准,复杂句子还是得自己上手写。

什么是ORM

ORM概念

ORM(Object-Relational Mapping)即对象关系映射,是一种将面向对象编程语言中的对象与关系型数据库中的表进行映射的技术。

核心思想:通过操作对象来操作数据库,而不需要编写原生SQL语句。

白话:ORM 让你"像操作内存里的结构体一样操作数据库"。你改结构体的字段,框架负责把改动同步到表里,你基本不用手写 INSERT/UPDATE

这张图说明了映射方向:

flowchart LR
    A[Go 结构体 User] -->|GORM 翻译成 SQL| B[(MySQL 表 users)]
    C[代码 user.Name = Alice] -->|生成| D[UPDATE users SET name='Alice']
    B -->|查询结果映射回| A

ORM的优点

  • 简化开发:无需编写复杂的SQL语句
  • 类型安全:编译时检查类型错误
  • 数据库无关:切换数据库只需修改配置
  • 提高可维护性:代码更易理解和维护

ORM的缺点

  • 性能开销:相比原生SQL有一定性能损失
  • 学习成本:需要学习框架的API和规则
  • 灵活性受限:复杂查询可能难以实现

如何正确看待ORM

  • 适合场景:常规CRUD操作、快速开发、团队协作
  • 不适合场景:高性能要求、复杂查询优化、数据库特定功能

⚠️ 新手必踩的坑:别把 ORM 当银弹。GORM 在简单 CRUD 上很香,但遇到复杂统计、窗口函数、多表大 join,它生成的 SQL 往往又长又慢。生产里常见的做法是"简单操作用 GORM,复杂报表直接 db.Raw() 写原生 SQL"——GORM 完全支持混用,不要强迫自己只用一种写法。

GORM连接数据库

安装GORM

go get gorm.io/gorm
go get gorm.io/driver/mysql

基本连接

package main

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

func main() {
    dsn := "user:password@tcp(127.0.0.1:3306)/dbname?charset=utf8mb4&parseTime=True&loc=Local"
    db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{})
    if err != nil {
        panic("failed to connect database")
    }
}

DSN格式说明

user:password@tcp(host:port)/dbname?params
参数说明
user数据库用户名
password数据库密码
host数据库地址
port数据库端口
dbname数据库名称
charset字符集,推荐utf8mb4
parseTime是否解析时间
loc时区

连接池配置

// 步骤 1:从 GORM 句柄里取出底层的 *sql.DB(连接池就藏在它里面)
sqlDB, err := db.DB()
if err != nil {
    panic("failed to get DB")
}

// 步骤 2:设置最大空闲连接数(闲着也不释放的连接上限)
sqlDB.SetMaxIdleConns(10)
// 步骤 3:设置最大打开连接数(含正在用的 + 空闲的,超过就排队)
sqlDB.SetMaxOpenConns(100)
// 步骤 4:设置连接最大存活时间,到期回收,避免用到被数据库单方面断开的"死连接"
sqlDB.SetConnMaxLifetime(time.Hour)

⚠️ 新手必踩的坑:不配置连接池。GORM 默认连接数很小(空闲 2、打开 10 左右),高并发下会瞬间打满,请求全部阻塞。线上务必根据压测结果调大 SetMaxOpenConns,并设 SetConnMaxLifetime——否则运行一段时间后偶发 driver: bad connection,多半是 MySQL 主动断了长连接你还在用。

Auto Migrate功能

什么是Auto Migrate

Auto Migrate 可以自动根据 Model 定义创建或更新数据库表结构。

使用示例

type User struct {
    ID   uint
    Name string
    Age  int
}

func main() {
    // 步骤 1:定义好结构体后,调用 AutoMigrate
    db.AutoMigrate(&User{})
}

字段类型映射

Go类型MySQL类型
int, int64bigint
uint, uint64bigint unsigned
float32, float64float
stringlongtext
booltinyint(1)
time.Timedatetime

注意事项

  • 创建表:如果表不存在,自动创建
  • 更新表:只添加新字段,不会删除已有字段
  • 不会修改:字段类型、字段名、约束条件

⚠️ 新手必踩的坑:AutoMigrate 只加不减。它不会删字段、不会改字段类型(比如把 varchar(50)varchar(100) 它无动于衷,把 string 改成 int 它直接报错或不动)。生产环境千万别把 AutoMigrate 当迁移工具用——结构变更请用专门的迁移工具(如 golang-migrate、Flyway)管理,AutoMigrate 只适合本地开发快速建表。

GORM的Model逻辑删除

软删除概念

软删除不是真正删除记录,而是通过标记字段表示记录已删除。

类比:软删除就像把文件丢进"回收站"。文件还在磁盘上,只是打了个删除标记;真要彻底消失得"清空回收站"(硬删除)。好处是误删能恢复,坏处是查询时得记得"只看回收站外的"。

启用软删除

type User struct {
    gorm.Model
    Name string
}

gorm.Model 包含以下字段:

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

查询时自动过滤

var users []User
db.Find(&users)

上述查询会自动添加 WHERE deleted_at IS NULL 条件。

下面这张图说明了为什么"普通查询"看不到被删数据:

flowchart TD
    A["db.Find &users"] --> B[GORM 自动追加过滤条件]
    B --> C[WHERE deleted_at IS NULL]
    C --> D[只返回未删除的记录]
    E["db.Unscoped.Find &users"] --> F[不加 deleted_at 过滤]
    F --> G[返回含已软删除的记录]

查找已删除的记录

var users []User
db.Unscoped().Find(&users)

永久删除

db.Unscoped().Delete(&user)

通过NullString解决不能更新零值问题

问题描述

在 GORM 中,零值(如空字符串、0、false)不会被更新到数据库。

user.Name = ""
db.Save(&user)

上述代码中,Name 字段不会被更新为空字符串。

白话:GORM 分不清你"故意把名字改成空"还是"压根没动这个字段"。它偷懒地把所有零值当成"没设置",于是 Save 时直接跳过。这在"我想清空某个字段"时就会翻车。

解决方案

使用 sql.NullString 或 GORM 的 gorm.NullString

import (
    "gorm.io/gorm"
    "database/sql"
)

type User struct {
    gorm.Model
    Name sql.NullString
    Age  sql.NullInt64
}

user := User{
    Name: sql.NullString{String: "", Valid: true},
}
db.Save(&user)

关键点sql.NullStringValid: true 显式告诉 GORM"这个空值是我认真的",于是它才会把空串写进库。Valid: false 则表示"不更新这个字段"。

使用 GORM 的 Scanner

type User struct {
    gorm.Model
    Name *string
    Age  *int
}

name := ""
user := User{
    Name: &name,
}
db.Save(&user)

⚠️ 新手必踩的坑:零值的三套解法怎么选。① 指针类型(*string)——最常用,零值也能写,但每次赋值要取地址有点啰嗦;② sql.NullXXX——语义清晰但要 Valid 标记;③ db.Model(&u).Select("name").Updates(...)——只更新指定字段,强制把 name 纳入更新列表,连零值一起写。实战推荐:要么模型字段用指针,要么用 Select 精确控制,别混着用以免心智负担。

表结构定义细节

字段标签

type User struct {
    ID        uint      `gorm:"primaryKey"`
    Name      string    `gorm:"size:255;not null;unique"`
    Age       int       `gorm:"default:0"`
    Email     string    `gorm:"column:email_address"`
    CreatedAt time.Time `gorm:"autoCreateTime"`
    UpdatedAt time.Time `gorm:"autoUpdateTime"`
}

常用标签

标签说明
primaryKey主键
size字段大小
not null非空约束
unique唯一约束
default默认值
column列名
autoCreateTime自动创建时间
autoUpdateTime自动更新时间
index创建索引
uniqueIndex创建唯一索引

嵌入结构体

type Address struct {
    City  string
    State string
}

type User struct {
    gorm.Model
    Name    string
    Address Address `gorm:"embedded"`
}

通过create方法插入记录

基本插入

user := User{Name: "Alice", Age: 25}
result := db.Create(&user)

// 返回插入记录的主键
fmt.Println(user.ID)

// 返回错误
fmt.Println(result.Error)

// 返回插入的记录数
fmt.Println(result.RowsAffected)

批量插入

users := []User{
    {Name: "Alice", Age: 25},
    {Name: "Bob", Age: 30},
}
db.Create(&users)

通过Map插入

db.Create(map[string]interface{}{
    "Name": "Charlie",
    "Age":  35,
})

选择字段插入

db.Select("Name").Create(&User{Name: "Dave", Age: 40})

忽略字段

db.Omit("Age").Create(&User{Name: "Eve", Age: 45})

批量插入和通过map插入记录

批量插入优化

users := make([]User, 100)
for i := range users {
    users[i] = User{Name: fmt.Sprintf("User%d", i)}
}

db.CreateInBatches(users, 100)

使用Upsert

db.Clauses(clause.OnConflict{
    Columns:   []clause.Column{{Name: "name"}},
    DoUpdates: clause.AssignmentColumns([]string{"age"}),
}).Create(&users)

通过Map批量插入

db.Model(&User{}).Create([]map[string]interface{}{
    {"Name": "Alice", "Age": 25},
    {"Name": "Bob", "Age": 30},
})

通过take, first、last获取数据

获取第一条记录

var user User
db.First(&user)

获取最后一条记录

var user User
db.Last(&user)

获取任意一条记录

var user User
db.Take(&user)

根据条件获取

var user User
db.Where("name = ?", "Alice").First(&user)

处理不存在的情况

var user User
result := db.First(&user)

if errors.Is(result.Error, gorm.ErrRecordNotFound) {
    fmt.Println("记录不存在")
}

⚠️ 新手必踩的坑:First 找不到会返回 ErrRecordNotFound,但 Find 不会。很多人用 db.Find(&user) 查单条,结果查不到时 user 是零值,error 还是 nil,代码误以为"查到了个空用户"。要查单条请用 First/Take 并显式判断 gorm.ErrRecordNotFound

GORM的基本查询

查询所有记录

var users []User
db.Find(&users)

带条件查询

var users []User
db.Where("age > ?", 18).Find(&users)

多个条件

db.Where("name = ? AND age > ?", "Alice", 18).Find(&users)

IN查询

db.Where("name IN ?", []string{"Alice", "Bob"}).Find(&users)

LIKE查询

db.Where("name LIKE ?", "%li%").Find(&users)

排序

db.Order("age DESC").Find(&users)

分页

var users []User
db.Limit(10).Offset(0).Find(&users)

计数

var count int64
db.Model(&User{}).Count(&count)

GORM的更新操作

保存所有字段

user := User{ID: 1, Name: "Alice", Age: 26}
db.Save(&user)

⚠️ 新手必踩的坑:Save 是全量更新。它会把结构体里所有字段都写进 UPDATE,包括你没改的零值字段(又回到零值问题)。只改某几个字段请用 Update/Updates + Select,更安全也更省。

更新指定字段

db.Model(&user).Update("name", "Alice Updated")

更新多个字段

db.Model(&user).Updates(map[string]interface{}{
    "name": "Alice Updated",
    "age":  26,
})

使用结构体更新

db.Model(&user).Updates(User{Name: "Alice Updated", Age: 26})

⚠️ 新手必踩的坑:用结构体 Updates 时零值会被忽略。上面 Updates(User{...}) 里如果 Age: 0,GORM 不会把 age 更新成 0。要更新零值,要么用 map[string]interface{},要么 Select 指定字段。

更新选中字段

db.Model(&user).Select("name").Updates(User{Name: "Alice", Age: 27})

更新忽略字段

db.Model(&user).Omit("age").Updates(User{Name: "Alice", Age: 27})

批量更新

db.Model(&User{}).Where("age > ?", 18).Update("age", 19)

GORM的软删除细节

查询未删除记录

var users []User
db.Find(&users)

查询已删除记录

var users []User
db.Unscoped().Find(&users)

恢复已删除记录

db.Unscoped().Model(&user).Update("deleted_at", nil)

永久删除

db.Unscoped().Delete(&user)

软删除条件

var users []User
db.Where("deleted_at IS NOT NULL").Find(&users)

表的关联插入

一对一关系

type User struct {
    gorm.Model
    Name    string
    Profile Profile
}

type Profile struct {
    gorm.Model
    UserID uint
    Bio    string
}

user := User{
    Name: "Alice",
    Profile: Profile{
        Bio: "Software Engineer",
    },
}

db.Create(&user)

保存关联

user := User{Name: "Alice"}
db.Create(&user)

user.Profile = Profile{Bio: "Engineer"}
db.Model(&user).Association("Profile").Append(&user.Profile)

下面这张图概括了 GORM 四种典型关联在数据库层面的形态:

erDiagram
    USER ||--o| PROFILE : "拥有 一对一"
    USER ||--o{ ORDER : "下 一对多"
    USER }|..|{ ROLE : "拥有 多对多"

白话||--o| 是一夫一妻(一个 User 配一个 Profile);||--o{ 是一夫多妻(一个 User 有多个 Order);}|..|{ 是群舞(User 和 Role 互相多对多,中间靠一张 user_roles 连接表)。}| 表示"可选/必填",这里不必死记,看箭头数量就行。

通过preload和joins查询多表

Preload预加载

var users []User
db.Preload("Profile").Find(&users)

多级预加载

db.Preload("Orders").Preload("Orders.Items").Find(&users)

带条件的预加载

db.Preload("Orders", "status = ?", "paid").Find(&users)

Joins连接查询

db.Joins("JOIN profiles ON profiles.user_id = users.id").
    Where("profiles.bio LIKE ?", "%Engineer%").
    Find(&users)

⚠️ 新手必踩的坑:Preload 是"两条 SQL",Joins 是"一条 SQL",语义不同Preload 先查 User 再查 Profile(默认可能触发 N+1,需用 PreloadJoins 解决);Joins 用 SQL 的 JOIN 一次查出来,但它是"内连接",主表记录若没有关联子记录就会被过滤掉。要"左连接保留主表全部"得写 Joins("LEFT JOIN ...")。另外 Preload 不会按关联字段过滤主表,要过滤主表请用 Joins

Has Many关系

定义关系

type User struct {
    gorm.Model
    Name   string
    Orders []Order
}

type Order struct {
    gorm.Model
    UserID uint
    Amount float64
}

查询关联

var user User
db.Preload("Orders").First(&user)

添加关联

order := Order{Amount: 100}
db.Model(&user).Association("Orders").Append(&order)

删除关联

db.Model(&user).Association("Orders").Delete(&order)

替换关联

orders := []Order{{Amount: 200}, {Amount: 300}}
db.Model(&user).Association("Orders").Replace(&orders)

清空关联

db.Model(&user).Association("Orders").Clear()

GORM处理多对多的关系

定义关系

type User struct {
    gorm.Model
    Name  string
    Roles []Role `gorm:"many2many:user_roles;"`
}

type Role struct {
    gorm.Model
    Name  string
    Users []User `gorm:"many2many:user_roles;"`
}

创建关联表

db.AutoMigrate(&User{}, &Role{})

添加关联

user := User{Name: "Alice"}
role := Role{Name: "Admin"}

db.Create(&user)
db.Create(&role)

db.Model(&user).Association("Roles").Append(&role)

查询关联

var user User
db.Preload("Roles").First(&user)

删除关联

db.Model(&user).Association("Roles").Delete(&role)

替换关联

roles := []Role{{Name: "User"}, {Name: "Editor"}}
db.Model(&user).Association("Roles").Replace(&roles)

清空关联

db.Model(&user).Association("Roles").Clear()

获取关联计数

var count int64
db.Model(&user).Association("Roles").Count(&count)

⚠️ 新手必踩的坑:多对多一定要指定连接表名 many2many:user_roles。两侧都写上同一个表名,GORM 才知道用哪张中间表。漏写的话 GORM 会用默认命名(通常是 user_roles 拼接),一旦两侧不一致或想自定义字段就抓瞎。另外 Association().Append 对切片要传 *Role 的地址或切片,传值容易编译不过。

GORM的表名自定义、自定义beforecreate逻辑

表名自定义

func (u User) TableName() string {
    return "my_users"
}

全局禁用复数

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

白话:GORM 默认把结构体名变复数当表名(Userusers),这是英文习惯。国内很多老表是单数命名,用 SingularTable: true 关掉复数化最省事。更精准的做法是给每个模型实现 TableName(),优先级最高。

BeforeCreate钩子

func (u *User) BeforeCreate(tx *gorm.DB) (err error) {
    u.UUID = uuid.New().String()
    return
}

AfterCreate钩子

func (u *User) AfterCreate(tx *gorm.DB) (err error) {
    tx.Create(&Profile{UserID: u.ID})
    return
}

其他钩子

func (u *User) BeforeUpdate(tx *gorm.DB) (err error) {
    // 更新前逻辑
    return
}

func (u *User) AfterUpdate(tx *gorm.DB) (err error) {
    // 更新后逻辑
    return
}

func (u *User) BeforeDelete(tx *gorm.DB) (err error) {
    // 删除前逻辑
    return
}

func (u *User) AfterDelete(tx *gorm.DB) (err error) {
    // 删除后逻辑
    return
}

事务中的钩子

钩子会在事务中执行,可以访问当前事务对象 tx

下面这张图说明了 Create 操作里钩子与数据库的交互顺序:

sequenceDiagram
    participant App as 业务代码
    participant BC as BeforeCreate 钩子
    participant DB as 数据库
    participant AC as AfterCreate 钩子
    App->>BC: 调用 Create 触发
    BC->>BC: 生成 UUID 等预处理
    BC->>DB: 执行 INSERT
    DB-->>AC: 插入成功
    AC->>DB: 例如再建一条 Profile
    AC-->>App: 返回结果

自测题与动手练习

自测题(合上书能答出来,才算懂)

  1. ORM 的核心思想是什么?哪些场景适合用 ORM、哪些场景不适合?
  2. GORM 的软删除是怎么实现的?为什么普通 Find 查不到被删数据?想查被删数据该怎么写?
  3. 什么是"零值不更新"?列举至少两种让"把 Name 改成空串"真正生效的方法。
  4. SaveUpdates(User{...}) 在更新时有啥区别?为什么用结构体 UpdatesAge: 0 不会写进库?
  5. Preload("Profile")Joins("JOIN profiles ...") 在语义上有哪两个关键不同点?

动手练习(建议真做一遍)

  1. 本地起一个 MySQL,用 GORM 建 users 表并插入 3 条数据;随后把某个用户的 Name 改成空串,分别用 Save 和"指针字段 + Save"两种方式验证结果差异。
  2. UserProfile(一对一)和 Orders(一对多),用 Preload 查出来,再故意制造 N+1(循环里逐个查 Order),用 Preload 优化后对比 SQL 条数。
  3. 写一个复杂统计需求(比如"按年龄分组统计用户数"),先用 GORM 链式写法实现,再用 db.Raw() 写原生 SQL 实现,对比两者生成的语句与可读性。

本章小结

  • ORM 是翻译官:把 Go 对象操作翻译成 SQL,常规 CRUD 省心,复杂查询该写原生就写原生(db.Raw)。
  • 连接池必须配SetMaxOpenConns + SetConnMaxLifetime,否则高并发会阻塞或踩到"死连接"。
  • AutoMigrate 只加不减:仅适合开发建表,生产结构变更请用专门迁移工具。
  • 零值与软删除是两大坑:零值用指针 / Select / NullXXX 解决;软删除靠 DeletedAt 字段,查询自动过滤,想看全量加 Unscoped()
  • 关联查选用对姿势Preload 多条 SQL、Joins 单条 SQL 且是内连接,多对多别忘了指定 many2many 连接表。

下一篇可以接着看 GORM 的事务、原生 SQL 与 Scopes 复用查询条件,把"工程里真正常用的进阶玩法"补齐。

About Me

没什么想介绍的,一个很大众的码农…

喜欢代码,车,马,真的是 🐎

讨厌别人让我给自己的代码写注释 最厌烦别人的程序没有写注释

目标

学AI,加油!加油!