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

8.1 KiB
Raw Blame History

tags, create time
tags create time
GORM
Go
ORM
自定义类型
Scanner
Valuer
JSON
GORMDataType
2026-04-28 00:00

自定义字段类型

概述

内置类型(int、string、time.Time 等)覆盖了大部分场景,但当需要存储特殊格式的数据时——比如将 Go 的 map[string]any 序列化为 JSON、或者使用领域模型中的 Value Object——就需要实现自定义类型与数据库列之间的双向转换。

flowchart LR
    GoVal["Go 结构体值"] -->|序列化→| DBVal["数据库列值"]
    DBVal -->|反序列化←| GoVal
    
    style GoVal fill:#3B82F6,color:#fff
    style DBVal fill:#EAB308,color:#fff

GORM 提供三组接口,分别解决不同层次的定制需求:

接口 方向 作用 适用场景
driver.Valuer + sql.Scanner 读写双相 完全控制序列化/反序列化 JSON 字段、加密字符串
GORMDataType() 建表时 覆盖 GORM 的类型推导 自定义枚举、位图
Scan / Value 驱动级 最底层的 database/sql 适配 需要兼容所有库的场景

[!tip] 核心原则 这些接口的本质是桥接——让普通的 Go struct 能够被 database/sql 驱动「理解」。GORM 在读取和写入数据时会检测类型是否实现了相应接口,如果实现了就调用它们。

ValueScanner 模式(推荐)

这是最常用也最灵活的方式——同时实现 driver.Valuer(Go → DB)和 sql.Scanner(DB → Go)两个接口:

JSON 字段

type JSONMap map[string]interface{}

// Value: Go → DB(序列化)
func (j JSONMap) Value() (driver.Value, error) {
    if j == nil {
        return nil, nil
    }
    bytes, err := json.Marshal(j)
    if err != nil {
        return nil, err
    }
    return string(bytes), nil
}

// Scan: DB → Go(反序列化)
func (j *JSONMap) Scan(value interface{}) error {
    if value == nil {
        *j = nil
        return nil
    }
    str, ok := value.([]byte)
    if !ok {
        str = []byte(fmt.Sprintf("%v", value))
    }
    return json.Unmarshal(str, j)
}

// 使用示例
type Setting struct {
    ID    uint      `gorm:"primaryKey"`
    Key   string    `gorm:"size:64;uniqueIndex"`
    Value JSONMap   `gorm:"type:json"` // MySQL 5.7+ / PostgreSQL JSONB
}

// 写入
db.Create(&Setting{Key: "theme", Value: JSONMap{"color": "dark", "fontSize": 14}})

// 读取
var setting Setting
db.Where("key = ?", "theme").First(&setting)
fmt.Println(setting.Value["color"]) // "dark"

自定义枚举类型

type Priority int8

func (p Priority) String() string {
    names := map[Priority]string{
        1: "urgent",
        2: "high", 
        3: "normal",
        4: "low",
    }
    return names[p]
}

func (p Priority) Value() (driver.Value, error) {
    return int8(p), nil
}

func (p *Priority) Scan(value interface{}) error {
    if value == nil {
        *p = 3 // default normal
        return nil
    }
    *p = Priority(value.(int64))
    return nil
}

type Task struct {
    ID       uint     `gorm:"primaryKey"`
    Title    string   `gorm:"size:128"`
    Priority Priority `gorm:"not null;default:3"`
}

// 赋值时使用类型安全的枚举
db.Create(&Task{Title: "Fix login bug", Priority: Priority(1)}) // urgent

// 查询时直接得到强类型值
var task Task
db.First(&task, 1)
fmt.Println(task.Priority.String()) // "urgent"

IP 地址存储

type IPPool net.IP

func (ip IPPool) Value() (driver.Value, error) {
    return ip.To4(), nil
}

func (ip *IPPool) Scan(value interface{}) error {
    bytes, ok := value.([]byte)
    if !ok {
        return fmt.Errorf("invalid IP type: %T", value)
    }
    *ip = IPPool(net.ParseIP(string(bytes)))
    return nil
}

[!question] 思考题 为什么 Value 和 Scan 的方法签名分别是 (JSONMap) 和 (j *JSONMap)?一个用值接收者、一个用指针接收者?

答案:Value() 只需要读取当前值来序列化,不需要修改原对象;而 Scan() 需要将数据库读出的值写回目标变量,所以必须用指针才能修改外部结构体的内容。

GORMDataType — 仅覆盖类型

如果你只需要告诉 GORM「这个类型对应什么数据库列类型」,不需要做复杂的序列化逻辑:

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"`
}

[!note] GORMDataType 的作用范围 它只在**建表(AutoMigrate)**时生效,不影响运行时读写行为。也就是说,GORM 会用这个类型创建列,但数据的序列化和反序列化仍由 database/sql 的标准处理完成。

全局类型解析器(Resolver)

GORM v1.25+ 引入了 Resolver API,可以在不修改类型定义的前提下注册自定义解析:

// 创建 DBConfig,为指定类型注册序列化/反序列化逻辑
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{
    Resolver: resolver.FullSaveResolver{
        Resolvers: map[reflect.Type]resolver.Resolver{
            reflect.TypeOf(net.IP{}): &IPResolver{}, // 自定义 net.IP 解析器
        },
    },
})

[!tip] 何时使用 Resolver?

  • 场景一:第三方包提供的类型,无法添加方法实现 Valuer/Scanner
  • 场景二:项目中大量地方用到同一自定义类型,避免每个文件重复实现接口
  • 首选方案:仍然是直接给类型实现 driver.Valuer + sql.Scanner——代码内聚性更好,IDE 也能做类型检查

AutoMigrate 时的自定义类型

当你在 AutoMigrate 中使用自定义类型时,GORM 会根据以下步骤决定列类型:

flowchart TD
    A["字段类型 T"] --> B{"是否实现 GORMDataType?"}
    B -- "是" --> C["使用返回值作为列类型"]
    B -- "否" --> D{"T 是已知内置类型?"}
    D -- "是" --> E["使用默认映射"]
    D -- "否" --> F["尝试从 driver.Valuer 推断"]
    F --> G["根据 driver.Value 反射确定"]
    
    style A fill:#4FC08D,color:#fff
    style C fill:#3B82F6,color:#fff
    style E fill:#A0AEC0,color:#fff

综合实战:配置项存储系统

实际项目中经常遇到需要存储任意 key-value 配置的场景:

// 通用配置表——用 JSON 存动态字段
type AppConfig struct {
    gorm.Model
    Namespace string    `gorm:"size:64;not null;index:idx_namespace_key"`
    Key       string    `gorm:"size:128;not null"`
    Data      JSONMap   `gorm:"type:json;not null"`
}

// 索引保证同一命名空间下 key 不重复
func (AppConfig) TableName() string {
    return "app_configs"
}

// CRUD 示例
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"}),
    }).Create(&config).Error
}

func GetConfig(db *gorm.DB, ns, key string) (JSONMap, error) {
    var config AppConfig
    if err := db.Where("namespace = ? AND key = ?", ns, key).First(&config).Error; err != nil {
        return nil, err
    }
    return config.Data, nil
}

[!tip] 为什么 JSON 比拆多个列更好?

  • 字段动态增减不需要改表结构
  • 可以嵌套复杂数据结构
  • MySQL/PostgreSQL 都提供了 JSON 查询函数(如 JSON_EXTRACT),配合 GORM 也能使用

代价:失去了行级的类型安全和部分字段的独立索引能力。

常见坑点速查

问题 原因 解决方案
Scan 接收不到 nil 值 未处理 value == nil 分支 Scan 开头检查 nil 并返回 nil
Value 序列化失败导致整个操作中断 JSON marshal 出错 做好错误处理和防御性编码
GORMDataType 没生效 忘记嵌入或别名方法声明 确保方法是 receiver 类型上的公开方法
Postgres JSONB vs MySQL JSON 差异 驱动行为不一致 用 Select 显式指定 column 类型

关联笔记