--- tags: [GORM, Go, ORM, 自定义类型, Scanner, Valuer, JSON, GORMDataType] create time: 2026-04-28 00:00 --- # 自定义字段类型 ## 概述 内置类型(int、string、time.Time 等)覆盖了大部分场景,但当需要存储特殊格式的数据时——比如将 Go 的 `map[string]any` 序列化为 JSON、或者使用领域模型中的 Value Object——就需要实现自定义类型与数据库列之间的双向转换。 ```mermaid 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 字段 ```go 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" ``` ### 自定义枚举类型 ```go 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 地址存储 ```go 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「这个类型对应什么数据库列类型」,不需要做复杂的序列化逻辑: ```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"` } ``` > [!note] GORMDataType 的作用范围 > 它只在**建表(AutoMigrate)**时生效,不影响运行时读写行为。也就是说,GORM 会用这个类型创建列,但数据的序列化和反序列化仍由 `database/sql` 的标准处理完成。 ## 全局类型解析器(Resolver) GORM v1.25+ 引入了 Resolver API,可以在不修改类型定义的前提下注册自定义解析: ```go // 创建 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 会根据以下步骤决定列类型: ```mermaid 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 配置的场景: ```go // 通用配置表——用 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 类型 | ## 关联笔记 - [[02-模型定义]] - [[13-多数据库支持]]