Init
This commit is contained in:
@@ -0,0 +1,320 @@
|
||||
---
|
||||
tags: [GORM, Go, UUID, 主键, 分布式, 性能优化, InnoDB]
|
||||
create time: 2026-05-05 00:00
|
||||
---
|
||||
|
||||
# UUID 主键策略
|
||||
|
||||
## 概述
|
||||
|
||||
自增 ID 简单高效,但在分布式系统和对外 API 中暴露出明显短板——ID 可被猜测、数据规模暴露、多节点冲突难解。UUID 提供了去中心化的主键生成方案,代价则是对 InnoDB 写入性能的冲击。本文从选型到落地,完整讲解在 GORM 中使用 UUID 的方方面面。
|
||||
|
||||
## 自增 ID 的隐性代价
|
||||
|
||||
```go
|
||||
// 这是最直观的写法——但你很快会发现它的问题
|
||||
type User struct {
|
||||
ID uint `gorm:"primaryKey;autoIncrement"`
|
||||
Name string
|
||||
}
|
||||
|
||||
// RESTful API 中,用户的个人资料页 URL 可能是这样:
|
||||
// GET /users/1 → 某个用户
|
||||
// GET /users/2 → 另一个用户
|
||||
// GET /users/100 → 哦,你们只有 100 个用户?
|
||||
```
|
||||
|
||||
> [!warning] 自增 ID 的三大风险
|
||||
> 1. **信息泄露**:通过 ID 可推断用户总量、增长速度、业务规模。
|
||||
> 2. **枚举攻击**:遍历 `/users/1`、`/users/2`、`/users/3`……轻松抓取全量数据。
|
||||
> 3. **分布式冲突**:多个数据库节点各自自增,合并时必然产生重复 ID。
|
||||
|
||||
> [!question] 思考
|
||||
> 如果只是在 API 层面对外暴露一个哈希过的「展示 ID」、内部仍用自增主键,能否解决上述问题?
|
||||
> **部分可以**——它能隐藏数据规模,但增加了额外的映射开销(哈希冲突处理、额外索引等)。如果业务本身就是分布式的,自增 ID 的主键冲突问题依然无解。因此 UUID 在分布式场景下是更根本的解法。
|
||||
|
||||
## UUID 版本选型
|
||||
|
||||
UUID 不是一个单一的算法,它有多个版本,选错版本等于白用。
|
||||
|
||||
| 版本 | 生成依据 | 有序性 | 适用场景 |
|
||||
|------|----------|--------|----------|
|
||||
| v1 | 时间戳 + MAC 地址 | 有序 | 需全局唯一 + 可排序,但有 MAC 隐私风险 |
|
||||
| v4 | 随机数 | 完全无序 | 最通用,隐私性好 |
|
||||
| v7 | 毫秒时间戳 + 随机数 | 时间有序 | **推荐**:兼顾唯一性与 InnoDB 写入性能 |
|
||||
|
||||
```go
|
||||
import "github.com/google/uuid"
|
||||
|
||||
// UUID v4 — 最常用但写入性能差
|
||||
id := uuid.New() // 例如: 550e8400-e29b-41d4-a716-446655440000
|
||||
|
||||
// UUID v7 — Go 1.24+ 才原生支持,现阶段用 google/uuid 的 Must 方法
|
||||
id, _ := uuid.NewV7() // 例如: 018f3a80-8c5d-7a1b-b000-6b3a1c234567
|
||||
// ^^^^^^^^ 时间戳部分使写入更有序
|
||||
```
|
||||
|
||||
> [!tip] 优先使用 UUID v7
|
||||
> UUID v7 的前 48 位是毫秒级时间戳(Unix Epoch),使得生成的 ID 天然按时间递增。在 InnoDB 聚簇索引下,这能大幅减少页分裂——后文会详细说明原因。
|
||||
|
||||
## GORM 中的三种 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 前手动生成
|
||||
order := Order{
|
||||
ID: uuid.New(),
|
||||
Code: "ORD-20240001",
|
||||
}
|
||||
db.Create(&order)
|
||||
```
|
||||
|
||||
> [!warning] 手动赋值的问题
|
||||
> 如果你忘记在某个 `Create` 调用前生成 UUID,GORM 不会报错——它会尝试插入零值 `00000000-0000-0000-0000-000000000000`,导致重复主键错误。下面的方式二可以根治此问题。
|
||||
|
||||
### 方式二:BeforeCreate 钩子(推荐)
|
||||
|
||||
```go
|
||||
type Order struct {
|
||||
ID uuid.UUID `gorm:"type:char(36);primaryKey"`
|
||||
Code string `gorm:"uniqueIndex;size:32;not null"`
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// BeforeCreate:GORM 在每次 INSERT 前自动调用,ID 零值时自动生成
|
||||
func (o *Order) BeforeCreate(tx *gorm.DB) error {
|
||||
if o.ID == uuid.Nil { // uuid.Nil 是零值判断的标准方式
|
||||
o.ID = uuid.New()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// 调用方无需关心 ID 生成
|
||||
db.Create(&Order{Code: "ORD-20240002"}) // ID 自动填充
|
||||
```
|
||||
|
||||
> [!tip] 为什么还要检查 `uuid.Nil`?
|
||||
> 如果调用方已经显式设置了 ID(例如从消息队列消费带有 ID 的数据),`BeforeCreate` 不应覆盖它。这个检查确保了「只在未设置时自动生成」的语义。
|
||||
|
||||
### 方式三:自定义类型封装(适合团队规范)
|
||||
|
||||
```go
|
||||
type PK uuid.UUID // 自定义主键类型
|
||||
|
||||
func (pk *PK) BeforeCreate(tx *gorm.DB) error {
|
||||
if *pk == PK(uuid.Nil) {
|
||||
*pk = PK(uuid.New())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Value:写入数据库时的转换
|
||||
func (pk PK) Value() (driver.Value, error) {
|
||||
return uuid.UUID(pk).String(), nil
|
||||
}
|
||||
|
||||
// Scan:从数据库读取时的转换
|
||||
func (pk *PK) Scan(value interface{}) error {
|
||||
id, err := uuid.Parse(string(value.([]byte)))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
*pk = PK(id)
|
||||
return nil
|
||||
}
|
||||
|
||||
// 使用示例
|
||||
type Product struct {
|
||||
ID PK `gorm:"type:char(36);primaryKey"`
|
||||
Name string `gorm:"size:128;not null"`
|
||||
}
|
||||
```
|
||||
|
||||
> [!note] 封装自定义类型的收益
|
||||
> - 所有使用 `PK` 的模型自动获得 UUID 生成能力,零样板代码。
|
||||
> - 团队统一主键策略——Code Review 时一看 `PK` 就明白这是 UUID 主键模型。
|
||||
> - 将来如果想从 v4 切换到 v7,只需改 `BeforeCreate` 一处。
|
||||
|
||||
## 存储优化:char(36) vs binary(16)
|
||||
|
||||
UUID 的标准字符串形式是 `550e8400-e29b-41d4-a716-446655440000`,占 36 字节。但实际的 UUID 数据只需 16 字节。
|
||||
|
||||
```go
|
||||
// 方案 A:存为字符串(可读性好)
|
||||
type OrderA struct {
|
||||
ID uuid.UUID `gorm:"type:char(36);primaryKey"`
|
||||
// 每条记录主键占用 36 字节 + 索引开销
|
||||
}
|
||||
|
||||
// 方案 B:存为 binary(16)(空间效率高,推荐)
|
||||
type OrderB struct {
|
||||
ID uuid.UUID `gorm:"type:binary(16);primaryKey"`
|
||||
// 每条记录主键仅占 16 字节
|
||||
}
|
||||
```
|
||||
|
||||
| 方案 | 主键大小 | 1000 万行主键索引体积 | 可读性 |
|
||||
|------|----------|----------------------|--------|
|
||||
| `char(36)` | 36 字节 | ~360 MB(不含辅助索引) | 直接 SELECT 可读 |
|
||||
| `binary(16)` | 16 字节 | ~160 MB | 需 `HEX()` 或 `UUID_TO_BIN()` 转换 |
|
||||
|
||||
> [!tip] MySQL 特殊技巧
|
||||
> MySQL 提供了 `UUID_TO_BIN()` 和 `BIN_TO_UUID()` 函数,可在存储时自动做二进制转换。如果使用原生 SQL 或迁移脚本建表,可以配合触发器自动处理。但在 GORM 中,用 `type:binary(16)` + `Value/Scan` 接口手动控制更为灵活。
|
||||
|
||||
> [!question] 思考
|
||||
> 如果你采用 `binary(16)` 存储,在数据库管理工具(如 DataGrip、DBeaver)中查看数据时主键列显示为乱码——你会如何处理?
|
||||
> **两种思路**:① 查询时用 `SELECT BIN_TO_UUID(id), ... FROM orders`;② 保留 `char(36)`,毕竟现代 SSD 下 20 字节的差异对大多数业务影响微乎其微。**对于内部系统或数据量不大(<1000 万)的场景,char(36) 的可读性优势远大于空间节省的收益。**
|
||||
|
||||
## 性能深度解析:UUID 为什么"慢"
|
||||
|
||||
### InnoDB 的聚簇索引机制
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
A["INSERT 自增 ID<br/>id=1001"] --> B["写入最后一页"]
|
||||
C["INSERT UUID v4<br/>随机值"] --> D["定位目标页<br/>(可能已刷出)"] --> E["页已满 →<br/>页分裂"] --> F["分裂后的两页<br/>各写一半"]
|
||||
```
|
||||
|
||||
InnoDB 使用聚簇索引——表数据按主键物理排序存储在 B+ 树中:
|
||||
|
||||
- **自增 ID**:每次插入都在 B+ 树的「最右侧」追加新页,触发的页分裂极少。
|
||||
- **UUID v4**:随机值意味着新行可能插入到 B+ 树的**任意位置**。如果目标页已满,InnoDB 必须执行页分裂——将一页拆成两页,重新平衡树结构。这导致:
|
||||
- 磁盘随机 I/O 增加
|
||||
- 页利用率下降(分裂后的页各约 50% 填充率)
|
||||
- 缓冲池命中率降低
|
||||
|
||||
> [!note] 性能下降的量级
|
||||
> 业界测试表明,UUID v4 的写入吞吐量比自增 ID 低约 **25%-40%**,页分裂导致的数据碎片化还会拖慢范围查询。但请注意——这是在**持续高并发写入**场景下的差异。如果你的写入 QPS 在几百级别,日常几乎感受不到。
|
||||
|
||||
### UUID v7:兼顾唯一性与有序性
|
||||
|
||||
UUID v7 的前 48 位是毫秒级 Unix 时间戳,使得不同毫秒生成的 ID 天然递增:
|
||||
|
||||
```
|
||||
UUID v4: e2d3f1a4-... (随机) → 随机插入,页分裂严重
|
||||
UUID v7: 018f3a80-... (时间) → 大致有序,页分裂大幅减少
|
||||
^^^^^^^^
|
||||
毫秒时间戳,同一毫秒内顺序排列
|
||||
```
|
||||
|
||||
> [!warning] 同一毫秒内的并发写入
|
||||
> UUID v7 在同一毫秒内的多个 ID,随机部分决定了它们的相对顺序。因此同一毫秒内的批量写入仍可能触发少量页分裂——但这远比 v4 的全随机要温和。
|
||||
|
||||
## 替代方案对比
|
||||
|
||||
| 方案 | 长度 | 有序 | 全局唯一 | 可读性 | 性能(vs 自增) |
|
||||
|------|------|------|----------|--------|-----------------|
|
||||
| 自增 ID (`int64`) | 8 字节 | 有序 | ❌ 单点 | 高 | 基准 |
|
||||
| UUID v4 | 16 字节 | 无序 | ✅ | 中(字符串) | -30% |
|
||||
| UUID v7 | 16 字节 | 大致有序 | ✅ | 中(字符串) | -10% |
|
||||
| ULID | 16 字节 / 26 字符 | 有序 | ✅ | 高(Crockford Base32) | -10% |
|
||||
| Snowflake (`int64`) | 8 字节 | 有序 | ✅ | 低(需反解) | -5% |
|
||||
|
||||
### ULID 示例
|
||||
|
||||
[ULID](https://github.com/ulid/spec) 的结构与 UUID v7 类似(时间戳 + 随机数),但使用 Crockford Base32 编码,生成的字符串不含混淆字符(如 `I`、`L`、`O`),对用户更友好:
|
||||
|
||||
```go
|
||||
import "github.com/oklog/ulid/v2"
|
||||
|
||||
type Article struct {
|
||||
ID string `gorm:"type:char(26);primaryKey"` // ULID 固定 26 字符
|
||||
// ...
|
||||
}
|
||||
|
||||
func (a *Article) BeforeCreate(tx *gorm.DB) error {
|
||||
if a.ID == "" {
|
||||
a.ID = ulid.Make().String() // 例如: 01HX3V8M2P4K5Q6R7S8T9V0W1X
|
||||
}
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
### Snowflake(雪花算法)
|
||||
|
||||
适合对 ID 长度敏感、且已有分布式 ID 生成基础设施的场景:
|
||||
|
||||
```go
|
||||
import "github.com/bwmarrin/snowflake"
|
||||
|
||||
var snowflakeNode *snowflake.Node
|
||||
|
||||
func init() {
|
||||
snowflakeNode, _ = snowflake.NewNode(1) // 节点编号 1
|
||||
}
|
||||
|
||||
// 生成 int64 ID:ID 包含时间戳 + 节点 ID + 序列号
|
||||
id := snowflakeNode.Generate().Int64() // 例如: 1541828496137482240
|
||||
```
|
||||
|
||||
> [!tip] Snowflake 的取舍
|
||||
> Snowflake 小巧(8 字节),性能优异,但需要部署节点编号服务(或依赖 Redis/etcd 协调 WorkerID)。而 UUID v7 是纯本地生成,零依赖。**团队规模小(<5 个节点)优先用 UUID v7,大规模微服务架构再考虑 Snowflake。**
|
||||
|
||||
## UUID 作为外键
|
||||
|
||||
当主键使用 UUID 时,关联表的外键也必须使用相同类型:
|
||||
|
||||
```go
|
||||
type Order struct {
|
||||
ID uuid.UUID `gorm:"type:char(36);primaryKey"`
|
||||
OrderCode string `gorm:"uniqueIndex;size:32;not null"`
|
||||
}
|
||||
|
||||
type OrderItem struct {
|
||||
ID uuid.UUID `gorm:"type:char(36);primaryKey"`
|
||||
OrderID uuid.UUID `gorm:"type:char(36);not null;index"` // 外键列
|
||||
ProductID uuid.UUID `gorm:"type:char(36);not null"`
|
||||
Quantity int `gorm:"not null"`
|
||||
Order Order `gorm:"foreignKey:OrderID"` // GORM 关联定义
|
||||
}
|
||||
|
||||
func (oi *OrderItem) BeforeCreate(tx *gorm.DB) error {
|
||||
if oi.ID == uuid.Nil {
|
||||
oi.ID = uuid.New()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// 预加载关联查询
|
||||
var items []OrderItem
|
||||
db.Preload("Order").Where("order_id = ?", orderID).Find(&items)
|
||||
```
|
||||
|
||||
> [!warning] 外键 UUID 的索引考量
|
||||
> 外键列通常需要创建索引(如上例中的 `index` tag)。如果 OrderItem 数据量很大(千万级),`order_id` 的 UUID 索引同样会面临 InnoDB 页分裂问题。此时可以考虑在该索引上使用 `binary(16)` 存储来减少索引体积。
|
||||
|
||||
## 决策流程
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
A["选择主键策略"] --> B{"是否需要<br/>分布式部署?"}
|
||||
B -->|"否,单数据库"| C{"API 是否<br/>对外暴露 ID?"}
|
||||
C -->|"否,纯内部"| D["自增 ID (uint)<br/>简单高效"]
|
||||
C -->|"是"| E["UUID v7<br/>binary(16)存储"]
|
||||
B -->|"是"| F{"写 QPS 是否<br/>超过 5000?"}
|
||||
F -->|"否"| E
|
||||
F -->|"是"| G{"团队是否有<br/>分布式 ID 基础设施?"}
|
||||
G -->|"有"| H["Snowflake (int64)<br/>8字节,高吞吐"]
|
||||
G -->|"无"| I["UUID v7<br/>配合 binary(16)<br/>可接受性能折损"]
|
||||
style D fill:#10B981,color:#fff
|
||||
style E fill:#3B82F6,color:#fff
|
||||
style H fill:#F59E0B,color:#fff
|
||||
style I fill:#EF4444,color:#fff
|
||||
```
|
||||
|
||||
> [!summary] 一句话建议
|
||||
> **2026 年了,新项目默认用 UUID v7 + `binary(16)` 存储。** 除非你在做极致的写入性能优化(如日志采集、时序数据),否则它带来的分布式友好性和隐私保护远超那 10% 的性能差异。
|
||||
|
||||
## 关联笔记
|
||||
|
||||
- [[02-模型定义]] — 父文档,包含主键策略概述与其他字段定义
|
||||
- [[03-CRUD 操作]] — UUID 模型的增删改查操作
|
||||
- [[12-自定义字段类型]] — Value/Scan 接口的更多用法
|
||||
@@ -0,0 +1,501 @@
|
||||
---
|
||||
tags: [GORM, Go, ORM, 自定义类型, Scanner, Valuer, GORMDataType, JSON, 序列化, Resolver]
|
||||
create time: 2026-05-06 14:30
|
||||
---
|
||||
|
||||
# 自定义类型作为字段
|
||||
|
||||
## 概述
|
||||
|
||||
内置类型(`int`、`string`、`time.Time` 等)覆盖了大部分基础场景,但当你的领域模型包含**值对象**(Value Object)或需要存储特殊格式的数据时——比如将 `map[string]any` 序列化为 JSON、将 IPv4 地址压缩到 4 字节、或用枚举替代魔法数字——就需要实现自定义类型与数据库列之间的双向转换。
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
GoVal["Go 结构体值"] -->|"driver.Valuer.Value() → 序列化 →"| DBVal["数据库列值"]
|
||||
DBVal -->|"sql.Scanner.Scan() ← 反序列化 ←"| GoVal
|
||||
|
||||
style GoVal fill:#3B82F6,color:#fff
|
||||
style DBVal fill:#EAB308,color:#fff
|
||||
```
|
||||
|
||||
GORM 提供了三组接口来解决不同层次的定制需求:
|
||||
|
||||
| 接口 | 作用方向 | 生效时机 | 适用场景 |
|
||||
|------|----------|----------|---------|
|
||||
| `driver.Valuer` + `sql.Scanner` | 读写双向 | 运行时 INSERT / SELECT | JSON 字段、加密字符串、领域模型转换 |
|
||||
| `GORMDataType()` | 仅建表推导 | AutoMigrate 阶段 | 覆盖列类型定义,不做数据转换 |
|
||||
| `Resolver` (v1.25+) | 读写双向 | 配置期注册 | 第三方包类型无法修改时 |
|
||||
|
||||
> [!tip] 核心原则
|
||||
> 这些接口的本质是**桥接器**——让普通的 Go struct 能够被 `database/sql` 驱动「理解」。GORM 在读取和写入数据时会检测类型是否实现了相应接口,如果实现了就调用它们来桥接 Go 值与 SQL 驱动之间的鸿沟。
|
||||
|
||||
## ValueScanner 模式(最常用)
|
||||
|
||||
同时实现 `driver.Valuer`(Go → DB,写入时的序列化)和 `sql.Scanner`(DB → Go,读取时的反序列化)两个接口,这是最灵活也最常用的方式。
|
||||
|
||||
### JSON 字段——动态结构的万能容器
|
||||
|
||||
MySQL 5.7+ 和 PostgreSQL 都原生支持 JSON 列类型。配合 `Value/Scan` 接口,可以像操作原生 `map[string]any` 一样操作数据库中的 JSON 字段:
|
||||
|
||||
```go
|
||||
import (
|
||||
"database/sql/driver"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
type JSONMap map[string]any
|
||||
|
||||
// Value: Go → DB(序列化)
|
||||
func (j JSONMap) Value() (driver.Value, error) {
|
||||
if j == nil {
|
||||
return nil, nil // NULL 而非 "null"——这很关键
|
||||
}
|
||||
bytes, err := json.Marshal(j)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return string(bytes), nil // MySQL JSON 列接受字符串
|
||||
}
|
||||
|
||||
// Scan: DB → Go(反序列化)
|
||||
func (j *JSONMap) Scan(value interface{}) error {
|
||||
if value == nil {
|
||||
*j = nil
|
||||
return nil
|
||||
}
|
||||
// MySQL driver 返回 []byte,某些 wrapper 可能返回 string
|
||||
b, ok := value.([]byte)
|
||||
if !ok {
|
||||
b = []byte(fmt.Sprintf("%v", value))
|
||||
}
|
||||
return json.Unmarshal(b, j)
|
||||
}
|
||||
|
||||
// 使用示例
|
||||
type AppSetting struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
Key string `gorm:"size:64;uniqueIndex;not null"`
|
||||
Value JSONMap `gorm:"type:json"`
|
||||
}
|
||||
|
||||
// 写入任意嵌套的 JSON 结构
|
||||
db.Create(&AppSetting{
|
||||
Key: "user_preferences",
|
||||
Value: JSONMap{
|
||||
"theme": "dark",
|
||||
"language": "zh-CN",
|
||||
"notifications": JSONMap{
|
||||
"email": true,
|
||||
"push": false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// 直接访问 JSON 内部字段,无需额外解析
|
||||
var setting AppSetting
|
||||
db.Where("key = ?", "user_preferences").First(&setting)
|
||||
fmt.Println(setting.Value["notifications"].(JSONMap)["email"]) // true
|
||||
```
|
||||
|
||||
> [!note] `nil` vs `json.Null`
|
||||
> 当 `JSONMap` 为 `nil` 时,`Value()` 返回 `nil`(即 SQL 的 NULL),而不是 `"null"`(JSON 的空值)。这两者在数据库中是不同的概念——NULL 表示「没有值」,而 `null` 是一个实际的 JSON 值。对于大多数业务场景,返回 nil/NULL 更符合直觉。
|
||||
|
||||
### 自定义枚举——告别魔法数字
|
||||
|
||||
用强类型的枚举替换散落在代码中的魔法数字,让意图一目了然:
|
||||
|
||||
```go
|
||||
type Priority int8
|
||||
|
||||
const (
|
||||
Urgent Priority = 1 // 紧急
|
||||
High Priority = 2 // 高
|
||||
Normal Priority = 3 // 普通
|
||||
Low Priority = 4 // 低
|
||||
)
|
||||
|
||||
func (p Priority) String() string {
|
||||
names := map[Priority]string{
|
||||
Urgent: "urgent",
|
||||
High: "high",
|
||||
Normal: "normal",
|
||||
Low: "low",
|
||||
}
|
||||
s := names[p]
|
||||
if s == "" {
|
||||
return fmt.Sprintf("unknown(%d)", p)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// 写入:Go 值 → DB 整数
|
||||
func (p Priority) Value() (driver.Value, error) {
|
||||
return int8(p), nil
|
||||
}
|
||||
|
||||
// 读取:DB 整数 → Go 值,非法值降级为默认值
|
||||
func (p *Priority) Scan(value interface{}) error {
|
||||
if value == nil {
|
||||
*p = Normal // 默认优先级
|
||||
return nil
|
||||
}
|
||||
val := int8(value.(int64))
|
||||
// 防御性编码:不信任旧数据中的非法枚举值
|
||||
switch Priority(val) {
|
||||
case Urgent, High, Normal, Low:
|
||||
*p = Priority(val)
|
||||
default:
|
||||
*p = Normal // 未知值回退到 normal
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// 使用
|
||||
type Task struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
Title string `gorm:"size:128;not null"`
|
||||
Priority Priority `gorm:"not null;default:3"`
|
||||
}
|
||||
|
||||
// 赋值时使用具名常量,语义清晰
|
||||
db.Create(&Task{Title: "Fix payment bug", Priority: Urgent})
|
||||
// 查询结果自动转换为强类型枚举
|
||||
var task Task
|
||||
db.First(&task, 1)
|
||||
fmt.Printf("优先级: %s (%d)\n", task.Priority.String(), task.Priority)
|
||||
// 输出: 优先级: urgent (1)
|
||||
```
|
||||
|
||||
> [!question] 思考题
|
||||
> 为什么 `Value()` 用值接收者 `(p Priority)`,而 `Scan()` 必须用指针接收者 `(p *Priority)`?
|
||||
>
|
||||
> > **答案**:`Value()` 只需要**读取**当前值来转换成数据库格式,不需要修改原对象;而 `Scan()` 的职责是将数据库读出的值**写回**目标变量——如果不使用指针,修改只在方法内部的副本上生效,外部永远看不到变化。这是 Go 指针语义的基本功。
|
||||
|
||||
### IP 地址压缩存储——从 45 字节到 4 字节
|
||||
|
||||
IPv4 地址标准形式 `"192.168.1.1"` 占 15 个字符,IPv6 最长可达 45 个字符。但如果只存原始二进制,IPv4 只需 4 字节,IPv6 只需 16 字节:
|
||||
|
||||
```go
|
||||
package ipaddr
|
||||
|
||||
import (
|
||||
"database/sql/driver"
|
||||
"fmt"
|
||||
"net"
|
||||
)
|
||||
|
||||
// IPAddr 封装 net.IP,提供 Value/Scan 能力
|
||||
type IPAddr net.IP
|
||||
|
||||
func (ip IPAddr) Value() (driver.Value, error) {
|
||||
v := net.IP(ip)
|
||||
// 优先返回压缩后的 IPv4(4 字节),若本身是 IPv6 则返回完整 16 字节
|
||||
if v4 := v.To4(); v4 != nil {
|
||||
return v4, nil
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
func (ip *IPAddr) Scan(value interface{}) error {
|
||||
if value == nil {
|
||||
*ip = nil
|
||||
return nil
|
||||
}
|
||||
// MySQL/MariaDB 的 Inet_Aton 返回的是 []byte
|
||||
b, ok := value.([]byte)
|
||||
if !ok {
|
||||
// 兜底:如果是字符串形式的 IP
|
||||
str, ok := value.(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("invalid IP type: %T", value)
|
||||
}
|
||||
parsed := net.ParseIP(str)
|
||||
if parsed == nil {
|
||||
return fmt.Errorf("invalid IP address: %s", str)
|
||||
}
|
||||
*ip = IPAddr(parsed)
|
||||
return nil
|
||||
}
|
||||
parsed := net.IP(b)
|
||||
// 如果是 4 字节的 IPv4,转为标准的 16 字节 IPv4-mapped 格式
|
||||
if len(b) == 4 {
|
||||
if mapped := parsed.To4(); mapped != nil {
|
||||
parsed = mapped
|
||||
}
|
||||
}
|
||||
*ip = IPAddr(parsed)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Host 模型——每个 IP 记录只用 4 字节(IPv4)或 16 字节(IPv6)
|
||||
type Host struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
Name string `gorm:"size:128;not null"`
|
||||
IP IPAddr `gorm:"type:varbinary(16)"` // 同时兼容 IPv4 和 IPv6
|
||||
}
|
||||
```
|
||||
|
||||
> [!important] varbinary(16) 为什么是最佳选择?
|
||||
> - `char(45)`:IPv4 浪费了空间,且索引体积大 10 倍以上。
|
||||
> - `char(15)`:只能存 IPv4,IPv6 会截断。
|
||||
> - `varbinary(16)`:IPv4 实际占用 4 字节,IPv6 占用 16 字节——按实际大小存储,兼顾两种协议。
|
||||
>
|
||||
> MySQL 还提供了 `INET_NTOA()` / `INET6_NTOA()` 函数,可在查询时转回人类可读格式:
|
||||
> ```sql
|
||||
> SELECT name, INET6_NTOA(ip) AS readable_ip FROM hosts;
|
||||
> -- 输出: Name | readable_ip
|
||||
> -- Alice | 192.168.1.1
|
||||
> ```
|
||||
|
||||
### 敏感信息加密存储
|
||||
|
||||
在数据库中存储加密后的密码或 API Key,每次读取时自动解密:
|
||||
|
||||
```go
|
||||
import "crypto/aes"
|
||||
|
||||
// EncryptedText 在写入时 AES 加密,读取时自动解密
|
||||
type EncryptedText string
|
||||
|
||||
func (e EncryptedText) Value() (driver.Value, error) {
|
||||
if e == "" {
|
||||
return nil, nil
|
||||
}
|
||||
// 实际项目中应使用安全的密钥管理和加盐策略
|
||||
encrypted, err := encryptString(string(e))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return encrypted, nil
|
||||
}
|
||||
|
||||
func (e *EncryptedText) Scan(value interface{}) error {
|
||||
if value == nil {
|
||||
*e = ""
|
||||
return nil
|
||||
}
|
||||
b, _ := value.([]byte)
|
||||
decrypted, err := decryptString(string(b))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
*e = EncryptedText(decrypted)
|
||||
return nil
|
||||
}
|
||||
|
||||
// encryptString / decryptString 由项目统一封装,此处省略实现细节
|
||||
```
|
||||
|
||||
## GORMDataType —— 仅覆盖列类型
|
||||
|
||||
如果你只需要告诉 GORM「这个类型在数据库里应该是什么类型」,但不需要对数据进行额外的序列化/反序列化——数据以 Go 类型的自然形式读写:
|
||||
|
||||
```go
|
||||
type Status byte // 0=pending 1=processing 2=done 3=failed
|
||||
|
||||
func (Status) GORMDataType() string {
|
||||
return "TINYINT UNSIGNED" // 覆盖 GORM 默认的 INT 推导
|
||||
}
|
||||
|
||||
type Ticket struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
Status Status `gorm:"not null;default:0"`
|
||||
// 生成的 DDL: status TINYINT UNSIGNED NOT NULL DEFAULT 0
|
||||
}
|
||||
```
|
||||
|
||||
> [!note] GORMDataType 的作用范围
|
||||
> 它只在**建表(AutoMigrate)**时生效,不影响运行时读写行为。也就是说,GORM 会用返回值作为列类型定义 DDL,但数据的序列化和反序列化仍由 `database/sql` 的标准处理逻辑完成。
|
||||
>
|
||||
> 这意味着:如果你的 Go 类型和数据库列类型之间存在天然兼容关系(如 `byte` ↔ `TINYINT UNSIGNED`),仅实现 `GORMDataType()` 就够了,不需要再写 `Value/Scan`。
|
||||
|
||||
### GORMDataType + Value/Scan 的组合用法
|
||||
|
||||
两者可以同时实现——各司其职、互不干扰:
|
||||
|
||||
```go
|
||||
type Priority int8
|
||||
|
||||
// GORMDataType 决定建表的列类型
|
||||
func (Priority) GORMDataType() string {
|
||||
return "TINYINT UNSIGNED"
|
||||
}
|
||||
|
||||
// Value/Scan 决定运行时的数据转换
|
||||
func (p Priority) Value() (driver.Value, error) {
|
||||
return int8(p), nil
|
||||
}
|
||||
|
||||
func (p *Priority) Scan(value interface{}) error {
|
||||
if value == nil {
|
||||
*p = 3
|
||||
return nil
|
||||
}
|
||||
*p = Priority(value.(int64))
|
||||
return nil
|
||||
}
|
||||
|
||||
// 建表时 → priority TINYINT UNSIGNED NOT NULL DEFAULT 3
|
||||
// 运行时 → int8 ↔ Priority 的自动序列化/反序列化
|
||||
```
|
||||
|
||||
> [!tip] 该用哪个接口?速查表
|
||||
> | 需求 | 最小实现 | 说明 |
|
||||
> |------|----------|------|
|
||||
> | 仅需改列类型,数据自然兼容 | `GORMDataType()` | 最简单,只影响 DDL |
|
||||
> | Go 类型和 DB 类型需转换 | `driver.Valuer` + `sql.Scanner` | 完全控制序列化和反序列化 |
|
||||
> | 第三方类型,不能改源码 | `Resolver` API | 在不修改类型的前提下注册解析逻辑 |
|
||||
> | 三者全要 | 全部实现 | `GORMDataType` 管建表,`Value/Scan` 管读写 |
|
||||
|
||||
## 全局类型解析器(Resolver API)
|
||||
|
||||
GORM v1.25+ 引入了 Resolver 机制,允许**在不修改类型定义**的前提下注册自定义解析逻辑。这在以下场景中特别有用:
|
||||
|
||||
| 场景 | 说明 |
|
||||
|------|------|
|
||||
| 第三方包类型 | 如 `net.IP`、`json.RawMessage`,你无法给它们添加方法 |
|
||||
| 跨模块复用 | 多个模块引用同一个自定义类型,避免在每个模块中重复实现接口 |
|
||||
| 集中管理 | 所有类型映射关系集中在一个初始化点,便于维护和审计 |
|
||||
|
||||
```go
|
||||
import (
|
||||
"reflect"
|
||||
|
||||
"gorm.io/gorm/schema"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
// 为 net.IP 注册解析器
|
||||
type IPResolver struct{}
|
||||
|
||||
func (IPResolver) Value(src any) (driver.Value, error) {
|
||||
ip := src.(net.IP)
|
||||
if v4 := ip.To4(); v4 != nil {
|
||||
return v4, nil
|
||||
}
|
||||
return ip, nil
|
||||
}
|
||||
|
||||
func (IPResolver) Scan(dest any, src any) error {
|
||||
b, _ := src.([]byte)
|
||||
*dest.(*net.IP) = net.IP(b)
|
||||
return nil
|
||||
}
|
||||
|
||||
func initDB() (*gorm.DB, error) {
|
||||
return gorm.Open(mysql.Open(dsn), &gorm.Config{
|
||||
Schema: &schema.Schema{
|
||||
Resolver: resolver.FullSaveResolver{
|
||||
Resolvers: map[reflect.Type]resolver.Resolver{
|
||||
reflect.TypeOf(net.IP{}): IPResolver{},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
> [!warning] 关于 Resolver 的现状
|
||||
> GORM 官方对 Resolver API 的稳定性和文档完善度仍在迭代中。**如果你的类型可以自己添加方法,始终优先选择直接实现 `driver.Valuer` + `sql.Scanner`**——代码内聚性更好,IDE 可以做类型检查和补全,而且不依赖版本兼容性。
|
||||
|
||||
## AutoMigrate 时的类型推导流程
|
||||
|
||||
当你调用 `db.AutoMigrate(&User{})` 时,GORM 按照以下决策树确定每个字段的列类型:
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A["字段类型 T"] --> B{"是否实现<br/>GORMDataType()?"}
|
||||
B -- "是" --> C["使用返回值作为列类型<br/>跳过后续检查"]
|
||||
B -- "否" --> D{"T 是否为 GORM<br/>已知内置类型?"}
|
||||
D -- "是" --> E["使用内置类型映射表<br/>如 string → VARCHAR(n)"]
|
||||
D -- "否" --> F{"是否有 sql.Scanner<br/>实现?"}
|
||||
F -- "有" --> G["尝试推断底层驱动类型"]
|
||||
F -- "无" --> H["使用反射探测<br/>可能产生意外结果"]
|
||||
|
||||
style A fill:#4FC08D,color:#fff
|
||||
style C fill:#3B82F6,color:#fff
|
||||
style E fill:#A0AEC0,color:#fff
|
||||
style H fill:#EF4444,color:#fff
|
||||
```
|
||||
|
||||
> [!warning] 最危险的分支
|
||||
> 流程图最右侧的 H 分支——既没有 `GORMDataType()`,也没有 `sql.Scanner`——GORM 会通过反射去猜测类型。这种猜测不可靠,可能导致列类型不符合预期。**最佳实践:任何自定义类型至少实现 `GORMDataType()`。**
|
||||
|
||||
## 综合实战:配置项存储系统
|
||||
|
||||
实际项目中经常遇到需要存储动态 key-value 配置的场景。下面展示如何结合 JSON 字段、`OnConflict` 和复合索引构建一个健壮的通用配置存储:
|
||||
|
||||
```go
|
||||
type AppConfig struct {
|
||||
gorm.Model
|
||||
Namespace string `gorm:"size:64;not null;index:idx_ns_key"`
|
||||
Key string `gorm:"size:128;not null;index:idx_ns_key"`
|
||||
Data JSONMap `gorm:"type:json;not null"`
|
||||
}
|
||||
|
||||
func (AppConfig) TableName() string {
|
||||
return "app_configs"
|
||||
}
|
||||
|
||||
// Upsert:不存在则插入,存在则更新 Data 字段
|
||||
func SetConfig(db *gorm.DB, ns, key string, data map[string]any) error {
|
||||
config := AppConfig{
|
||||
Namespace: ns,
|
||||
Key: key,
|
||||
Data: JSONMap(data),
|
||||
}
|
||||
return db.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "namespace"}, {Name: "key"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{"data", "updated_at"}),
|
||||
}).Create(&config).Error
|
||||
}
|
||||
|
||||
// Get:按 namespace + key 精确查找
|
||||
func GetConfig(db *gorm.DB, ns, key string) (JSONMap, error) {
|
||||
var config AppConfig
|
||||
if err := db.Select("data").Where("namespace = ? AND key = ?", ns, key).
|
||||
First(&config).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return config.Data, nil
|
||||
}
|
||||
|
||||
// ListByNamespace:列出某个命名空间下的所有键
|
||||
func ListKeys(db *gorm.DB, ns string) ([]string, error) {
|
||||
var configs []AppConfig
|
||||
if err := db.Select("key").Where("namespace = ?", ns).Find(&configs).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
keys := make([]string, len(configs))
|
||||
for i, c := range configs {
|
||||
keys[i] = c.Key
|
||||
}
|
||||
return keys, nil
|
||||
}
|
||||
```
|
||||
|
||||
> [!tip] 为什么 JSON 比拆多列更好?
|
||||
> - **灵活性**:字段增减无需 ALTER TABLE,零停机变更。
|
||||
> - **嵌套能力**:可以存储任意深度的结构化数据。
|
||||
> - **引擎级支持**:MySQL 5.7+ 和 PostgreSQL 都有 JSON 查询函数(`JSON_EXTRACT`、`->>`)。
|
||||
> - **性能**:现代 MySQL 对 JSON 列做了内存优化,小对象查询效率接近原生列。
|
||||
>
|
||||
> **代价**:失去了行级的类型安全校验和部分字段的独立索引能力。如果需要按 JSON 内部字段查询,MySQL 5.7+ 支持生成列(Generated Column)+ 索引的组合方案。
|
||||
|
||||
## 常见坑点速查
|
||||
|
||||
| 问题 | 原因 | 解决方案 |
|
||||
|------|------|---------|
|
||||
| `Scan` 收不到 `nil` 值 | 未处理 `value == nil` 分支,导致 panic | 在 `Scan()` 开头检查 `nil`,提前返回 |
|
||||
| `Value()` 序列化失败导致整个 INSERT 中断 | `json.Marshal` 遇到不可序列化的值(如 channel) | 做好错误处理和防御性编码 |
|
||||
| `GORMDataType()` 没生效 | 方法的 receiver 类型声明错误(如用了 `*Priority`) | 确保方法是**非指针类型**上的公开方法 |
|
||||
| `PostgreSQL JSONB` 与 `MySQL JSON` 行为差异 | 某些驱动对 nil 的处理不一致 | 用 `Select()` 显式指定 column 类型或做驱动检测 |
|
||||
| `AutoMigrate` 改变了已有列类型 | 忘记实现 `GORMDataType()`,GORM 按默认映射推断 | 先跑 `Describe table` 确认实际列类型 |
|
||||
| `net.IP` 存成变长字符串 | MySQL 驱动可能对 `[]byte` 的行为不确定 | 明确限制长度或使用 `INET6_NTOA` 辅助查询 |
|
||||
|
||||
## 关联笔记
|
||||
|
||||
- [[../02-模型定义]] — 父文档,包含字段 tag、主键策略等概述
|
||||
- [[12-自定义字段类型]] — 更广泛的自定义类型生态(包括第三方库集成)
|
||||
- [[04-条件查询]] — JSON 字段的查询技巧(MySQL JSON_EXTRACT、PostgreSQL ->>)
|
||||
@@ -0,0 +1,321 @@
|
||||
---
|
||||
tags: [GORM, Go, ORM, 表名, TableName, NamingStrategy, 分表]
|
||||
create time: 2026-05-05 00:00
|
||||
---
|
||||
|
||||
# 表名规则
|
||||
|
||||
## 概述
|
||||
|
||||
GORM 遵循"约定优于配置"原则,默认将 struct 名自动推导为蛇形复数的表名。当默认规则不满足需求时,可以通过 `TableName()` 方法、`db.Table()` 链式调用或 `NamingStrategy` 全局配置来自定义表名。本文将逐层拆解这三种方式的优先级、适用场景和常见陷阱。
|
||||
|
||||
## GORM 表名推导的三层优先级
|
||||
|
||||
GORM 决定「这个 struct 对应哪张表」时,按照从高到低的优先级依次判断,一旦命中就停止后续推断:
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
A["查询/操作开始"] --> B{"db.Table() 是否被调用?"}
|
||||
B -->|"是"| C["使用 db.Table() 指定的表名<br/>绕过所有其他规则"]
|
||||
B -->|"否"| D{"Model 是否实现<br/>TableName() 方法?"}
|
||||
D -->|"是"| E["使用 TableName() 返回值<br/>绕过 NamingStrategy"]
|
||||
D -->|"否"| F["应用 NamingStrategy<br/>struct名 → 蛇形 → 复数 → 前缀"]
|
||||
C --> G["生成 SQL"]
|
||||
E --> G
|
||||
F --> G
|
||||
style C fill:#EF4444,color:#fff
|
||||
style E fill:#F59E0B,color:#fff
|
||||
style F fill:#3B82F6,color:#fff
|
||||
style G fill:#10B981,color:#fff
|
||||
```
|
||||
|
||||
> [!important] 关键结论
|
||||
> `TableName()` 的返回值会**绕过** `NamingStrategy` 配置。如果你设置了 `TablePrefix: "t_"`,但 `TableName()` 返回 `"sys_user"`,最终表名仍是 `"sys_user"` 而非 `"t_sys_user"`。**要么全用 `TableName()` 自己管理,要么全依赖 `NamingStrategy`**,混用会导致表名前缀不一致。
|
||||
|
||||
### 第一层:默认策略——蛇形复数(零配置)
|
||||
|
||||
这是 GORM 的开箱即用默认行为。struct 名经过两个转换步骤:
|
||||
|
||||
```
|
||||
struct 名 → 蛇形(snake_case) → 复数(plural)
|
||||
```
|
||||
|
||||
| Struct 名 | 推导表名 | 规则说明 |
|
||||
|-----------|---------|---------|
|
||||
| `User` | `users` | 蛇形为 `user`,加复数 `s` |
|
||||
| `UserProfile` | `user_profiles` | 大驼峰分拆为 `user` + `profile`,加复数 |
|
||||
| `APIKey` | `api_keys` | 连续大写视为一个词:`API` → `api` |
|
||||
| `OrderItem` | `order_items` | 两词分别转蛇形后拼接 |
|
||||
| `Category` | `categories` | 以 `y` 结尾,变 `y` 为 `ies` |
|
||||
| `Box` | `boxes` | 以 `x` 结尾,加 `es` |
|
||||
| `AdminUser` | `admin_users` | 标准英语单词,复数规则正确 |
|
||||
|
||||
> [!warning] 复数的坑
|
||||
> GORM 使用的复数引擎(`jinzhu/inflection`)对英语单词有内置规则,但**中文拼音缩写**或**非标准单词**可能产生意料之外的结果:
|
||||
>
|
||||
> ```go
|
||||
> type UserTOTP struct { ... } // → user_totps ❌ 你可能期待 user_totp
|
||||
> type WechatUserInfo struct{} // → wechat_user_infos(info 的复数规则不适用)
|
||||
> type AdminDatum struct { ... } // → admin_data ✅ 但容易忘记 datum → data
|
||||
> ```
|
||||
>
|
||||
> 如果你对表名有洁癖,建议直接实现 `TableName()` 来锁定表名,不要在复数规则上较劲。
|
||||
|
||||
**如何关闭复数?** 在连接数据库时配置:
|
||||
|
||||
```go
|
||||
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{
|
||||
NamingStrategy: schema.NamingStrategy{
|
||||
SingularTable: true, // 禁用表名复数,User → user
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
### 第二层:`TableName()`——模型级别约定
|
||||
|
||||
当 struct 实现了 `TableName() string` 方法,GORM 优先使用其返回值。这是**模型级别的约定**,定义一次,全局生效。
|
||||
|
||||
```go
|
||||
func (User) TableName() string {
|
||||
return "sys_user" // 显式指定表名
|
||||
}
|
||||
```
|
||||
|
||||
**适用场景**:
|
||||
|
||||
| 场景 | 示例 | 说明 |
|
||||
|------|------|------|
|
||||
| 表名与模型名不同 | `User` → `sys_user` | 遗留系统或统一命名规范 |
|
||||
| 多环境前缀 | 测试环境 `test_user`,生产 `user` | 通过环境变量动态切换 |
|
||||
| 分库分表 | `order_2026_05` 按月份分表 | 拼接时间维度 |
|
||||
| 多租户 | `t001_users`、`t002_users` | 拼接租户 ID |
|
||||
|
||||
> [!question] 思考
|
||||
> 如果 `User` 没有主键字段,也没有实现 `TableName()`,GORM 不把它当作 Model。但如果只实现了 `TableName()` 而没有主键,它算 Model 吗?
|
||||
>
|
||||
> **答**:算。Model 的三个条件(有主键 / 实现 `TableName()` / 被 GORM 方法引用)是**或**的关系,满足其一即可。不过没有主键时 `First()`、`Take()` 等依赖主键的方法无法正常工作。
|
||||
|
||||
#### value receiver vs pointer receiver
|
||||
|
||||
```go
|
||||
// ✅ 推荐:value receiver
|
||||
func (User) TableName() string { return "sys_user" }
|
||||
|
||||
// ⚠️ 可用但不够灵活
|
||||
func (*User) TableName() string { return "sys_user" }
|
||||
```
|
||||
|
||||
GORM 内部的查找逻辑是先尝试值接收者的方法集,找不到再尝试指针接收者。用 `(User)` 时:
|
||||
|
||||
- `db.Create(&user)` → 指针可以访问值接收者方法 ✅
|
||||
- `db.Create(user)` → 值也可以访问值接收者方法 ✅
|
||||
|
||||
而用 `(*User)` 时,如果某处代码传递的是**值**而非指针,就无法访问该方法。另外 `TableName()` 只是返回一个字符串常量,不需要修改任何字段,value receiver 是最自然的选择。
|
||||
|
||||
#### 多环境动态表名
|
||||
|
||||
```go
|
||||
func (User) TableName() string {
|
||||
switch os.Getenv("APP_ENV") {
|
||||
case "test":
|
||||
return "test_sys_user"
|
||||
case "staging":
|
||||
return "staging_sys_user"
|
||||
default:
|
||||
return "sys_user"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> [!tip] 环境区分用 `TableName()` vs `NamingStrategy`?
|
||||
> - 少数几个表的表名需要区分环境 → 用 `TableName()` + 环境变量(更灵活)
|
||||
> - 所有表统一加前缀 → 用 `NamingStrategy.TablePrefix`(更简洁)
|
||||
|
||||
### 第三层:`db.Table("xxx")`——链式调用,临时覆盖
|
||||
|
||||
这是**查询级别**的覆盖,不影响模型定义本身,只在当前链式调用中生效:
|
||||
|
||||
```go
|
||||
// 场景一:临时查一张视图
|
||||
db.Table("user_view").Where("active = ?", true).Find(&users)
|
||||
|
||||
// 场景二:按月份分表查询
|
||||
tableName := fmt.Sprintf("orders_%s", time.Now().Format("2006_01"))
|
||||
db.Table(tableName).Create(&order)
|
||||
|
||||
// 本次调用后,User 的默认表名仍然是它原来的样子
|
||||
db.Find(&users) // 走默认表名,不受上面 db.Table() 影响
|
||||
```
|
||||
|
||||
> [!tip] 分表场景推荐用 `db.Table()` 而非 `TableName()`
|
||||
> `TableName()` 在**每次**操作时都会调用。如果你在 `TableName()` 里用 `time.Now()` 动态拼接,可能导致同一个事务中前后不一致——INSERT 和 UPDATE 跨月边界时落到不同表。推荐在业务层显式传入时间或分表键,用 `db.Table()` 明确指定。
|
||||
|
||||
## 全局表名策略:NamingStrategy
|
||||
|
||||
如果你想**统一**管理所有表的命名规则(前缀、后缀、单复数、大小写),不需要每个 struct 写一遍 `TableName()`:
|
||||
|
||||
```go
|
||||
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{
|
||||
NamingStrategy: schema.NamingStrategy{
|
||||
TablePrefix: "t_", // 所有表加 t_ 前缀 → t_users
|
||||
SingularTable: true, // 不加复数 → t_user
|
||||
NameReplacer: strings.NewReplacer("sys_", "system_"), // 自定义替换
|
||||
NoLowerCase: false, // 默认转为小写
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
**完整处理流程**:
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
A["struct 名: UserProfile"] --> B["NameReplacer 替换"]
|
||||
B --> C["转蛇形: user_profile"]
|
||||
C --> D{"SingularTable?"}
|
||||
D -->|"false(默认)"| E["加复数: user_profiles"]
|
||||
D -->|"true"| F["不加: user_profile"]
|
||||
E --> G["加前缀: t_user_profiles"]
|
||||
F --> G
|
||||
G --> H["最终表名"]
|
||||
```
|
||||
|
||||
### TablePrefix 实战案例
|
||||
|
||||
#### 案例一:基础前缀——所有表加 `t_`
|
||||
|
||||
这是最常见的场景,一次配置,全局生效:
|
||||
|
||||
```go
|
||||
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{
|
||||
NamingStrategy: schema.NamingStrategy{
|
||||
TablePrefix: "t_", // User → t_users, OrderItem → t_order_items
|
||||
SingularTable: false, // 默认值,可省略
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
> [!tip] 命名习惯参考
|
||||
> - `t_`:table 的缩写,最通用
|
||||
> - `tb_`:table 的变体缩写,部分团队习惯
|
||||
> - `tbl_`:另一种常见写法
|
||||
> - 保持团队统一即可,无优劣之分
|
||||
|
||||
#### 案例二:按环境切换前缀
|
||||
|
||||
通过环境变量或配置文件,在不同环境下使用不同前缀。**所有 struct 无需任何修改**:
|
||||
|
||||
```go
|
||||
func getTablePrefix() string {
|
||||
switch os.Getenv("APP_ENV") {
|
||||
case "dev":
|
||||
return "dev_"
|
||||
case "test":
|
||||
return "test_"
|
||||
default:
|
||||
return "" // 生产环境无前缀
|
||||
}
|
||||
}
|
||||
|
||||
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{
|
||||
NamingStrategy: schema.NamingStrategy{
|
||||
TablePrefix: getTablePrefix(), // 测试环境 → test_user,生产环境 → user
|
||||
SingularTable: true,
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
> [!tip] 环境区分用 `TableName()` vs `TablePrefix`?
|
||||
> - 少数几个表的表名需要区分环境 → 用 `TableName()` + 环境变量(更灵活)
|
||||
> - 所有表统一加前缀 → 用 `NamingStrategy.TablePrefix`(更简洁)
|
||||
|
||||
#### 案例三:多数据库连接各自前缀
|
||||
|
||||
当项目连接多个数据库时,为每个连接配置不同的前缀。**同名的 struct,在不同 DB 连接下映射到不同物理表**:
|
||||
|
||||
```go
|
||||
// 系统库连接:表前缀 sys_
|
||||
sysDB, _ := gorm.Open(mysql.Open(sysDSN), &gorm.Config{
|
||||
NamingStrategy: schema.NamingStrategy{
|
||||
TablePrefix: "sys_",
|
||||
SingularTable: true,
|
||||
},
|
||||
})
|
||||
|
||||
// 业务库连接:表前缀 biz_
|
||||
bizDB, _ := gorm.Open(mysql.Open(bizDSN), &gorm.Config{
|
||||
NamingStrategy: schema.NamingStrategy{
|
||||
TablePrefix: "biz_",
|
||||
SingularTable: true,
|
||||
},
|
||||
})
|
||||
|
||||
// 同一个 User struct,在不同 DB 连接下映射到不同表
|
||||
sysDB.Find(&users) // → SELECT * FROM `sys_user`
|
||||
bizDB.Find(&users) // → SELECT * FROM `biz_user`
|
||||
```
|
||||
|
||||
复用同一套 struct 定义,只需在建立连接时区分前缀,避免了为每个库重复定义模型。
|
||||
|
||||
#### 案例四:前缀 + NameReplacer 组合
|
||||
|
||||
`TablePrefix` 和 `NameReplacer` 可以组合使用,处理顺序参考上方流程图——**先替换,再转蛇形,最后加前缀**:
|
||||
|
||||
```go
|
||||
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{
|
||||
NamingStrategy: schema.NamingStrategy{
|
||||
TablePrefix: "t_",
|
||||
SingularTable: true,
|
||||
// SysUser → 替换为 SystemUser → 蛇形 system_user → 加前缀 → t_system_user
|
||||
NameReplacer: strings.NewReplacer("sys_", "system_"),
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
> [!question] 思考:`db.Table()` 和 `TablePrefix` 同时存在会怎样?
|
||||
> 如果你配置了 `TablePrefix: "t_"`,但链式调用 `db.Table("orders").Find(...)`,最终查哪张表?
|
||||
>
|
||||
> **答**:`orders`,不会自动加 `t_` 前缀。`db.Table()` 是第一优先级,传入什么表名就查什么表,**完全绕过** `NamingStrategy`。只有不调用 `db.Table()` 的普通 struct 操作才会命中 `TablePrefix`。
|
||||
|
||||
> [!warning] 混用 `TableName()` 和 `TablePrefix` 的坑
|
||||
> 假设你全局配置了 `TablePrefix: "t_"`,但同时给部分 struct 实现了 `TableName()`:
|
||||
>
|
||||
> ```go
|
||||
> // 全局 NamingStrategy 配置了 TablePrefix: "t_"
|
||||
>
|
||||
> type Product struct{} // 没有 TableName() → 自动生成 t_products ✅
|
||||
>
|
||||
> func (User) TableName() string {
|
||||
> return "sys_user" // 显式返回,不会自动加前缀 → sys_user ❌
|
||||
> }
|
||||
> ```
|
||||
>
|
||||
> 结果:`Product` 查 `t_products`,`User` 查 `sys_user`——表名前缀不统一。**要么全用 `TableName()` 手动管理前缀,要么全依赖 `NamingStrategy.TablePrefix`,不要混用。**
|
||||
|
||||
> [!note] NamingStrategy 不作用的地方
|
||||
> `NamingStrategy` 只影响**未实现 `TableName()`** 的 struct。一旦你为某个 struct 实现了 `TableName()`,GORM 会跳过 `NamingStrategy` 的所有规则直接使用返回值。这不是 bug,是有意为之的设计——模型级别的约定应该优先于全局配置。
|
||||
|
||||
## 决策指南
|
||||
|
||||
根据你的实际需求选择对应方案:
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
Q["我需要自定义表名吗?"] -->|"不需要,默认就够"| A["零配置<br/>User → users"]
|
||||
Q -->|"所有表统一加前缀"| B["NamingStrategy.TablePrefix"]
|
||||
Q -->|"个别表名不符合默认规则"| C["实现 TableName()"]
|
||||
Q -->|"临时查视图/分表"| D["db.Table()"]
|
||||
Q -->|"按时间/租户分表"| E["db.Table() 动态拼接"]
|
||||
Q -->|"关闭复数"| F["NamingStrategy.SingularTable"]
|
||||
style A fill:#3B82F6,color:#fff
|
||||
style B fill:#10B981,color:#fff
|
||||
style C fill:#F59E0B,color:#fff
|
||||
style D fill:#EF4444,color:#fff
|
||||
style E fill:#EF4444,color:#fff
|
||||
style F fill:#10B981,color:#fff
|
||||
```
|
||||
|
||||
## 关联笔记
|
||||
|
||||
- [[../02-模型定义]]
|
||||
- [[../../03-CRUD 操作]]
|
||||
- [[../../01-安装与初始化]]
|
||||
Reference in New Issue
Block a user