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

502 lines
17 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, 自定义类型, 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 ->>)