Files
cs-note/hhs/GORM/02-模型定义.md
T
2026-05-24 11:42:38 +08:00

360 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
tags: [GORM, Go, ORM, 模型, struct tag, 字段映射, 主键, 表名]
create time: 2026-04-28 00:00
---
# 模型定义
## 概述
模型(Model)是 GORM 操作的核心入口——一个普通的 Go struct 被 GORM「看见」后,就可以直接进行数据库操作。本章讲解如何通过 struct tag、命名约定和接口来自定义 GORM 对模型的解读。
## 什么是 Model?
> [!definition] Model
> Model 是一个包含以下任一条件的 struct:
> 1. 有主键字段
> 2. 定义了 `TableName()` 方法
> 3. 被 `AutoMigrate`、`Create`、`Where` 等方法引用
```go
type User struct {
ID uint `gorm:"primaryKey"`
Name string `gorm:"size:64;not null"`
}
// 这就是一个最简单的 GORM Model
```
> [!tip] 核心原则
> GORM 的本质是一个「约定优于配置」的 ORM。struct 只是普通的 Go 结构体,`gorm:"..."` tag 告诉 GORM 如何将它映射到数据库——不修改运行时行为,只在构建 SQL 和建表时生效。这意味着你可以将同一个 struct 同时用于 HTTP 请求体和数据库模型(通过暴露合适的字段)。
## 表名规则
GORM 通过三层优先级推导表名:`db.Table()` → `TableName()` 方法 → struct 名蛇形复数。自定义表名时还涉及 `NamingStrategy` 全局配置、value/pointer receiver 选择、分表策略等细节——详见子文档:
> [!abstract] 子文档
> - [[02-模型定义/表名规则]] — 默认策略、TableName()、db.Table()、NamingStrategy、分表实战、决策指南
## 字段 Tag 详解
### 完整 Tag 语法
```
gorm:"column:name;type:bigint;not null;default:0;uniqueIndex;index;comment:用户ID"
```
| Tag 关键字 | 作用 | 示例 |
|------------|------|------|
| `column` | 映射列名 | `column:user_id` |
| `type` | 列类型覆盖 | `type:varchar(128)` |
| `not null` | NOT NULL 约束 | `not null` |
| `default` | 默认值 | `default:'unknown'` |
| `primaryKey` | 主键 | `primaryKey` |
| `autoIncrement` | 自增 | `autoIncrement` |
| `uniqueIndex` | 唯一索引 | `uniqueIndex:idx_name` |
| `index` | 普通索引 | `index:idx_status` |
| `comment` | 注释(MySQL) | `comment:用户名` |
| `<-` | 字段写入控制 | `<-:true` / `<-:false` / `<-:create` / `<-:update` |
| `->` | 读取控制(只读 = 只从 DB 读出) | `->:true` / `->:false`(不读取回 struct) |
| `<-` + `->` | 组合控制读写 | `<-:false;->:true`(只读,不可写入) |
| `-` | 忽略此字段 | `-` |
### 写入权限控制
```go
type Product struct {
ID uint `gorm:"primaryKey;autoIncrement"`
Name string `gorm:"not null;<-:create"` // 仅创建时可写入
Price float64 `gorm:"not null;<-:true;->:true"` // 读写均可(默认)
CreatedAt time.Time `gorm:"<-:create;->:true;autoTime"` // 仅创建时自动填充
UpdatedAt time.Time `gorm:"<-:update;->:true;autoTime"` // 仅更新时自动填充
ViewCount int `gorm:"->:false;<-:false"` // 完全忽略(非 DB 字段)
}
```
> [!example] 方向记忆法
> `<-` 表示数据**流向数据库**(写入),`->` 表示数据**从数据库流出**(读取)。箭头方向就是数据的方向。
### 嵌入 Struct(组合优于继承)
Go 不支持类的继承,但可以通过结构体嵌套实现代码复用:
```go
// 方式一:显式嵌入公共字段
type AuditRecord struct {
CreatedBy string `gorm:"size:64"` // 创建人
UpdatedBy string `gorm:"size:64"` // 最后修改人
}
type User struct {
ID uint `gorm:"primaryKey"`
Name string `gorm:"size:64;not null"`
AuditRecord // 匿名嵌入——Go 将 AuditRecord 的字段提升为 User 的直接字段
}
// 方式二:使用 gorm.Model(推荐 🌟)
type Article struct {
gorm.Model // 自动包含 ID、CreatedAt、UpdatedAt、DeletedAt
Title string `gorm:"size:128;not null"`
Content string `gorm:"type:text"`
}
```
> [!note] 为什么推荐 `gorm.Model`?
> `gorm.Model` 是 GORM 预定义的 struct,包含四个最常见字段:
> ```go
> type Model struct {
> ID any `gorm:"primaryKey"` // 主键,类型为 any (v2.27+)
> CreatedAt time.Time // 创建时间
> UpdatedAt time.Time // 更新时间
> DeletedAt gorm.DeletedAt // 软删除时间戳
> }
> ```
> 嵌入后即可拥有完整的审计追踪 + 软删除能力,无需每个 struct 重复声明。
> [!question] 思考
> 如果 `User` 同时嵌入了 `AuditRecord` 和 `gorm.Model`,而两者都有 `CreatedBy` 字段,会发生什么?
> **答**:编译报错——Go 不允许歧义字段访问。此时应只保留一个来源,或改为普通成员变量而非嵌入:`Audit AuditRecord`。
## 字段类型映射
| Go 类型 | 推荐 DB 类型 | 说明 |
|---------|-------------|------|
| `int`, `int64` | BIGINT | GORM 默认以 `int64` 处理 |
| `uint`, `uint64` | BIGINT UNSIGNED | — |
| `string` | VARCHAR(n) | 需手动指定 size |
| `bool` | TINYINT(1) / BOOLEAN | — |
| `float64` | DOUBLE | — |
| `time.Time` | DATETIME / TIMESTAMP | 配合 `autoTime` tag |
| `[]byte` | BLOB / BYTEA | 二进制数据 |
| `json.RawMessage` | JSON | JSON 序列化字段 |
### `autoTime` 自动时间戳
```go
type Article struct {
ID uint `gorm:"primaryKey"`
Title string `gorm:"size:128;not null"`
CreatedAt time.Time `gorm:"autoTime;createTime"` // 首次插入时自动填入当前时间,后续 UPDATE 不会修改
UpdatedAt time.Time `gorm:"autoTime;updateTime"` // 每次 UPDATE 时自动更新为当前时间
}
```
> [!tip] `autoTime` + `createTime` / `updateTime` 的分工
> - 只写 `autoTime`(旧写法):GORM 在 INSERT 和 UPDATE 时都更新时间字段。
> - `autoTime` + `createTime`:INSERT 时设置 `CreatedAt`,UPDATE 不碰它——这才是创建时间的正确语义。
> - `autoTime` + `updateTime`:UPDATE 时设置 `UpdatedAt`,INSERT 时同样会设初始值。
## 主键策略
### 默认策略:ID uint
```go
type User struct {
ID uint // GORM 默认将名为 ID 的字段视为主键
Name string
}
```
### UUID 主键
```go
import "github.com/google/uuid"
type Order struct {
ID uuid.UUID `gorm:"type:char(36);primaryKey"`
Code string `gorm:"uniqueIndex;not null"`
}
// 在 Create 前生成 UUID
order.ID = uuid.New()
db.Create(&order)
```
> [!tip] 为什么选 UUID 做主键?
> 自增 ID 存在 URL 泄露、ID 猜测等安全风险。UUID v4 完全随机,不会暴露数据量级。代价是索引碎片化更严重——InnoDB 的聚簇索引以主键排序,随机插入会导致页分裂,吞吐量下降约 30%。如果性能敏感可考虑 [ULID](https://github.com/ulid/spec)(有序且随机),或雪花算法生成的 `int64`。
> [!abstract] 子文档
> - [[02-模型定义/UUID主键策略]] — UUID 版本选型(v4/v7)、BeforeCreate 自动生成、存储优化(char(36) vs binary(16))、InnoDB 页分裂原理、ULID / Snowflake 替代方案、决策流程
### 复合主键
```go
type OrderItem struct {
OrderID uint `gorm:"primaryKey"` // 多个 primaryKey tag 构成复合主键
ProductID uint `gorm:"primaryKey"`
Quantity int `gorm:"not null"`
}
// 生成表结构:PRIMARY KEY (order_id, product_id)
```
> [!warning] 复合主键的注意事项
> - `First()` 和 `Take()` 无法工作(它们期望单主键),必须用 `Where("order_id=? AND product_id=?", ...)` 精确定位。
> - `AutoMigrate` 对复合主键支持有限,建议手动建表或在迁移后验证 schema。
> - 关联查询时外键也需要对应多列——通常此时改用单独的 `id` 作为主键 + 唯一索引代替复合主键更为方便。
## 软删除
GORM 的软删除通过 `DeletedAt` 字段实现——被"删除"的记录实际上只是将该字段设为当前时间,数据仍然保留在数据库中。
```go
type User struct {
gorm.Model // DeletedAt 已包含在此中
}
// "删除"一条记录(实际是 UPDATE SET deleted_at = NOW())
db.Delete(&user) // sql: UPDATE users SET deleted_at=... WHERE id=?
// 查询时自动排除已删除记录
db.Find(&users) // WHERE deleted_at IS NULL
// 查询全部(包括已删除)
db.Unscoped().Find(&users)
// 真正物理删除
db.Unscoped().Delete(&user)
```
> [!important] 软删除的设计哲学
> 软删除不是银弹。适合用于「可能需要恢复」的业务场景(如组织架构、订单流程)。但对于有唯一性约束的字段,软删除后会导致冲突——因为旧记录的该行仍占着唯一值。此时需自行设计隔离策略,例如添加 `is_deleted INT DEFAULT 0` 替代 GORM 内置方案。
> [!question] 为什么软删除用时间戳而非 BOOLEAN?
> 时间戳记录了确切的删除时刻,便于后续审计和问题排查;而 BOOLEAN 只能告诉你「是否删除」,丢失了时序信息。同时,`NULL` 表示未删除,语义更加清晰。
## 自定义类型作为字段
当内置类型不够用时,GORM 提供三组接口让 struct 自行控制与数据库的交互方式。详见子文档:
> [!abstract] 子文档
> - [[02-模型定义/自定义类型作为字段]] — ValueScanner 深度用法(JSON、枚举、IP 压缩、加密存储)、GORMDataType 类型覆盖、Resolver API、AutoMigrate 推导流程、常见坑点
### ValueScanner 模式(推荐)
实现 `driver.Valuer`(写回 DB)和 `sql.Scanner`(从 DB 读出)两个接口:
```go
type JSONMap map[string]interface{}
// Value:Go → DB(序列化)
func (j JSONMap) Value() (driver.Value, error) {
if j == nil {
return nil, nil
}
return json.Marshal(j) // 存入时转为 JSON 字符串
}
// Scan:DB → Go(反序列化)
func (j *JSONMap) Scan(value interface{}) error {
if value == nil {
*j = nil
return nil
}
return json.Unmarshal(value.([]byte), &j)
}
type Setting struct {
ID uint `gorm:"primaryKey"`
Key string `gorm:"size:64;uniqueIndex;not null"`
Value JSONMap `gorm:"type:json"` // 存入 MySQL JSON 列
}
```
### GORMDataType 覆盖类型推导
如果你只需要告诉 GORM「这个类型对应什么数据库类型」,无需处理序列化逻辑:
```go
type Priority int8 // 优先级:1-紧急 2-高 3-普通 4-低
func (Priority) GORMDataType() string {
return "TINYINT UNSIGNED" // 覆盖 GORM 默认的 INT 推导
}
type Task struct {
ID uint `gorm:"primaryKey"`
Title string `gorm:"size:128;not null"`
Priority Priority `gorm:"not null;default:3"`
}
```
> [!tip] 该用哪个接口?
> - 需要 **序列化成特定格式**(如 JSON、压缩字符串)→ 实现 `driver.Valuer` + `sql.Scanner`
> - 只需 **覆盖列类型**,不做特殊转换 → 实现 `GORMDataType()`
> - 两者可同时实现——`GORMDataType` 决定建表类型,`Value/Scan` 决定读写时的数据转换。
## 数据校验与生命周期钩子
struct tag 适合声明式约束,但更复杂的业务逻辑需要 GORM 提供的生命周期回调:
```go
type User struct {
ID uint `gorm:"primaryKey"`
Email string `gorm:"size:128;uniqueIndex;not null"`
Password string `gorm:"size:128;not null"`
IsActive bool `gorm:"default:true"`
CreatedAt time.Time
}
// BeforeCreate:在 INSERT 之前执行(可用于加密密码、生成唯一码等)
func (u *User) BeforeCreate(tx *gorm.DB) error {
// 基础校验
if !strings.Contains(u.Email, "@") {
return errors.New("invalid email format") // 返回错误中断操作
}
return nil
}
// AfterFind:在 SELECT 之后执行(可用于解密敏感字段)
func (u *User) AfterFind(tx *gorm.DB) error {
// 例如:对查询结果做脱敏处理
return nil
}
```
> [!summary] 常用钩子一览
> | 钩子 | 触发时机 | 典型用途 |
> |------|----------|----------|
> | `BeforeCreate` | `Create` 之前 | 字段默认值、加密、校验 |
> | `AfterCreate` | `Create` 之后 | 发送通知、记录日志 |
> | `BeforeUpdate` | `Update` 之前 | 乐观锁版本检查 |
> | `AfterUpdate` | `Update` 之后 | 同步缓存、审计追踪 |
> | `BeforeDelete` | `Delete` 之前 | 关联级联清理 |
> | `AfterDelete` | `Delete` 之后 | 软删除相关辅助操作 |
> | `AfterFind` | `Find/First` 之后 | 字段解密、格式化输出 |
> [!note] 校验的最佳实践
> struct tag 的 `not null` / `uniqueIndex` 仅在 **建表** 时生效。运行时 GORM 不会自动校验这些数据——如果需要应用层校验,优先使用独立的 validator 库(如 `github.com/go-playground/validator`),而不是依赖生命周期钩子做业务校验。
## 模型设计流程图
```mermaid
graph TD
A[定义 struct] --> B{有无 ID<br/>字段?}
B -->|有| C[ID 自动作主键]
B -->|无| D{实现 TableName?}
D -->|已实现| E[使用返回值<br/>作为表名]
D -->|未实现| F[struct 名 →<br/>蛇形复数形式]
C --> G{需要自定义列名或<br/>类型?}
E --> G
F --> G
G -->|是| H[添加 gorm tag]
G -->|否| I{是否需要软删除?}
H --> I
I -->|是| J[嵌入 gorm.Model<br/>或使用 DeletedAt]
I -->|否| K{内置类型够用?}
J --> K
K -->|是| L[完成 ✓]
K -->|否| M[实现 Value / Scan<br/>接口]
M --> L
style A fill:#4FC08D,color:#fff
style C fill:#3B82F6,color:#fff
style J fill:#F59E0B,color:#fff
style L fill:#A0AEC0,color:#fff
```
## 关联笔记
- [[01-安装与初始化]]
- [[03-CRUD 操作]]
- [[12-自定义字段类型]]