vault backup: 2026-04-28 20:23:33
This commit is contained in:
@@ -0,0 +1,263 @@
|
||||
---
|
||||
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` 的标准处理完成。
|
||||
|
||||
## 结合 Use 注册全局解析器
|
||||
|
||||
对于不想在每个 struct 上实现接口的场景(比如第三方类型的扩展),可以用 `RegisterResolve`:
|
||||
|
||||
```go
|
||||
// 为 net.IP 类型注册 GORM 解析逻辑
|
||||
db.Use(clause.OnConflict{}, func(db *gorm.DB) error {
|
||||
// 这个方式已经过时了...
|
||||
return nil
|
||||
})
|
||||
|
||||
// 更现代的方式是用 GORM 的 Resolver
|
||||
type MyType string
|
||||
|
||||
// 注册一个自定义数据类型解析器
|
||||
// 适用于无法给已有类型添加方法的情况
|
||||
```
|
||||
|
||||
> [!info] 注意
|
||||
> GORM 对全局类型注册的 API 在不同版本间有变化。推荐的实践是给类型加上方法实现 Valuer/Scanner——这样代码内聚性更好,依赖也更清晰。
|
||||
|
||||
## AutoMigrate 时的自定义类型
|
||||
|
||||
当你在 AutoMigrate 中使用自定义类型时,GORM 会根据以下步骤决定列类型:
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[字段类型 T] --> B{T 实现<br/>GORMDataType?}
|
||||
B -->|是| C["使用返回值<br/>作为列类型"]
|
||||
B -->|否| D{"T 是已知内置类型?"}
|
||||
D -->|是| E["使用默认映射"]
|
||||
D -->|否| F["尝试 driver.Valuer<br/>推断类型"]
|
||||
F --> G["使用 driver.Value<br/>的反射结果"]
|
||||
|
||||
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-多数据库支持]]
|
||||
Reference in New Issue
Block a user