This repository has been archived on 2026-05-24. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
all-in-kingsoft/hhs/GORM/02-模型定义/自定义类型作为字段.md
T
2026-05-06 13:42:02 +08:00

17 KiB
Raw Blame History

tags, create time
tags create time
GORM
Go
ORM
自定义类型
Scanner
Valuer
GORMDataType
JSON
序列化
Resolver
2026-05-06 14:30

自定义类型作为字段

概述

内置类型(int、string、time.Time 等)覆盖了大部分基础场景,但当你的领域模型包含值对象(Value Object)或需要存储特殊格式的数据时——比如将 map[string]any 序列化为 JSON、将 IPv4 地址压缩到 4 字节、或用枚举替代魔法数字——就需要实现自定义类型与数据库列之间的双向转换。

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 字段:

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 更符合直觉。

自定义枚举——告别魔法数字

用强类型的枚举替换散落在代码中的魔法数字,让意图一目了然:

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 字节:

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() 函数,可在查询时转回人类可读格式:

SELECT name, INET6_NTOA(ip) AS readable_ip FROM hosts;
-- 输出: Name | readable_ip
-- Alice  | 192.168.1.1

敏感信息加密存储

在数据库中存储加密后的密码或 API Key,每次读取时自动解密:

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 类型的自然形式读写:

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 的组合用法

两者可以同时实现——各司其职、互不干扰:

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,你无法给它们添加方法
跨模块复用 多个模块引用同一个自定义类型,避免在每个模块中重复实现接口
集中管理 所有类型映射关系集中在一个初始化点,便于维护和审计
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 按照以下决策树确定每个字段的列类型:

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 和复合索引构建一个健壮的通用配置存储:

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 辅助查询

关联笔记