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

263 lines
8.1 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, 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-多数据库支持]]