Skip to content

第07章:模型标签全解(下)—— 特殊类型与嵌入 ​

本章目标 ​

学完本章后,你将能够:

  1. 使用 serializer 标签处理 JSON、时间戳等特殊格式
  2. 使用 embedded / embeddedPrefix 嵌入公共结构体
  3. 正确处理 time.Time、*time.Time、sql.NullTime 三种时间类型
  4. 使用指针类型区分"零值"和"NULL"
  5. 在 GORM 中实现枚举类型
  6. 理解 autoCreateTime / autoUpdateTime 的行为

前置知识 ​

  • 需要先学习:第06章(模型标签上)
  • 需要了解:Go 的指针、接口概念(第01章)

7.1 serializer:序列化器 ​

serializer 是 GORM v2 的新特性,让你可以用极简的方式存储复杂数据类型。

内置序列化器 ​

go
type User struct {
	ID       uint
	Settings Settings `gorm:"serializer:json"`  // 存为 JSON 字符串
	Tags     []string `gorm:"serializer:json"`  // 切片 → JSON 数组
	Meta     MetaInfo `gorm:"serializer:json"`  // 结构体 → JSON 对象
}

type Settings struct {
	Theme   string `json:"theme"`
	Lang    string `json:"lang"`
	Notify  bool   `json:"notify"`
}

type MetaInfo map[string]interface{}

存入数据库时:

go
user := User{
	Settings: Settings{Theme: "dark", Lang: "zh", Notify: true},
	Tags:     []string{"golang", "gorm"},
	Meta:     MetaInfo{"login_count": 42},
}
db.Create(&user)

生成的 SQL(Settings 自动序列化为 JSON 字符串):

sql
INSERT INTO `users` (`settings`, `tags`, `meta`) VALUES
('{"theme":"dark","lang":"zh","notify":true}',
 '["golang","gorm"]',
 '{"login_count":42}')

读取时自动反序列化:

go
var user User
db.First(&user, 1)
fmt.Println(user.Settings.Theme) // dark(自动从 JSON 还原)
fmt.Println(user.Tags[0])        // golang

📌 serializer:json 是最常用的序列化器,特别适合存储配置信息、扩展字段等不需要独立建表的灵活数据。

其他内置序列化器 ​

go
type Event struct {
	ID        uint
	EventTime time.Time `gorm:"serializer:unixtime"`     // 存为 Unix 时间戳(秒)
	Duration  int64     `gorm:"serializer:unixtime"`     // int64 也能序列化为时间
	Playload  []byte    `gorm:"serializer:gob"`          // Gob 序列化
}
序列化器存入格式适用 Go 类型
jsonJSON 字符串struct、map、slice
unixtimeUnix 时间戳(秒)time.Time, int64
unixmilliUnix 时间戳(毫秒)time.Time, int64
unixnanoUnix 时间戳(纳秒)time.Time, int64
gobGob 二进制任意 Go 类型

7.2 embedded / embeddedPrefix:嵌入结构体 ​

当多个模型共享相同的字段时,可以抽取公共结构体:

普通嵌入 ​

go
// 公共审计字段
type AuditInfo struct {
	CreatedBy string    `gorm:"type:varchar(50);comment:创建人"`
	UpdatedBy string    `gorm:"type:varchar(50);comment:更新人"`
	CreatedAt time.Time `gorm:"autoCreateTime"`
	UpdatedAt time.Time `gorm:"autoUpdateTime"`
}

// User 嵌入 AuditInfo
type User struct {
	ID   uint   `gorm:"primaryKey"`
	Name string
	AuditInfo            // 嵌入!字段会平铺到 users 表中
}

// Product 也嵌入 AuditInfo
type Product struct {
	ID    uint   `gorm:"primaryKey"`
	Name  string
	Price float64
	AuditInfo            // 公共字段复用!
}

生成的表结构(字段被平铺):

sql
-- users 表包含:id, name, created_by, updated_by, created_at, updated_at
-- products 表包含:id, name, price, created_by, updated_by, created_at, updated_at

embedded 标签:显式声明嵌入 ​

go
type User struct {
	ID   uint
	Name string
	AuditInfo AuditInfo `gorm:"embedded"`  // 显式声明嵌入
}

embeddedPrefix:给嵌入字段加前缀 ​

go
type User struct {
	ID   uint
	Name string
	AuditInfo AuditInfo `gorm:"embedded;embeddedPrefix:audit_"`  // 列名前缀
}

生成的列名:

audit_created_by, audit_updated_by, audit_created_at, audit_updated_at

这对于有多个嵌入结构体的场景非常有用,避免列名冲突。


7.3 时间类型处理 ​

GORM 支持三种时间字段类型,各有不同的适用场景:

对比表 ​

类型零值存 NULL 时适用场景
time.Time0001-01-01 00:00:00不会为 NULL一定有值的时间字段(如 CreatedAt)
*time.Timenil存 NULL可选的时间字段(如 Birthday、DeletedAt)
sql.NullTime{Time:..., Valid: false}存 NULL需要明确知道"是否设置"

time.Time:最常用 ​

go
type User struct {
	CreatedAt time.Time  // 零值:0001-01-01,创建时自动设为当前时间
	UpdatedAt time.Time  // 更新时自动设为当前时间
}

*time.Time:支持 NULL ​

go
type User struct {
	Birthday    *time.Time  // nil → 数据库存 NULL
	LastLoginAt *time.Time  // nil → 表示从未登录
}

// 设置值
now := time.Now()
user := User{
	Birthday:    &now,   // 有值
	LastLoginAt: nil,     // 没设置 → NULL
}

📌 推荐:除了 CreatedAt、UpdatedAt 用 time.Time,其他非必填的时间字段都用 *time.Time。这样能正确区分"没填"和"填了零值"。

sql.NullTime:需要明确判断 ​

go
import "database/sql"

type User struct {
	Birthday sql.NullTime
}

// 使用
user := User{
	Birthday: sql.NullTime{Time: time.Now(), Valid: true},  // 有值
}
user2 := User{
	Birthday: sql.NullTime{Valid: false},  // 无值 → NULL
}

// 判断
if user2.Birthday.Valid {
	fmt.Println(user2.Birthday.Time)
} else {
	fmt.Println("未设置生日")
}

autoCreateTime / autoUpdateTime:自动时间戳 ​

go
type User struct {
	CreatedAt time.Time `gorm:"autoCreateTime"`  // 创建时自动设当前时间
	UpdatedAt time.Time `gorm:"autoUpdateTime"`  // 更新时自动设当前时间
}

📌 如果字段名是 CreatedAt 且类型是 time.Time,GORM 会自动添加 autoCreateTime 行为。UpdatedAt 同理。所以通常不需要手动加这两个标签。


7.4 零值问题与解决方案 ​

问题回顾 ​

第06章提到,GORM 的 Updates() 和 Where() 遇到零值会跳过。这是 GORM 初学者最头疼的问题。

go
type User struct {
	Name string
	Age  int  // 零值 = 0
}

// 场景1:更新时 Age=0 不生效
db.Model(&user).Updates(User{Name: "张三", Age: 0})
// SQL: UPDATE users SET name='张三' WHERE id=1
// ⚠️ Age 被跳过了!因为 0 是零值

// 场景2:条件查询时 Age=0 不生效
db.Where(&User{Age: 0}).Find(&users)
// SQL: SELECT * FROM users
// ⚠️ WHERE age = 0 被跳过了!

解决方案一:使用指针类型 ​

go
type User struct {
	Name string
	Age  *int  // 改成指针!零值 = nil(表示"没设置")
}

age := 0
user := User{Name: "张三", Age: &age}
db.Model(&user).Updates(user)
// SQL: UPDATE users SET name='张三', age=0 WHERE id=1  ✅

解决方案二:使用 Map ​

go
// Map 中的零值会正常处理
db.Model(&user).Updates(map[string]interface{}{
	"name": "张三",
	"age":  0,    // ✅ Map 方式零值不会被跳过
})

解决方案三:使用 Select 强制更新 ​

go
db.Model(&user).Select("Name", "Age").Updates(User{Name: "张三", Age: 0})
// ✅ Select 指定了 Age,零值也会更新

方案选择建议 ​

场景推荐方案
需要精确表示 NULL指针类型 *int
int 类型的零值有意义(如数量为 0)指针类型
string 类型的空字符串有意义不需要特殊处理(空字符串不是零值...吗?)
批量更新混合了零值和非零值Select() + 结构体 或 Map

⚠️ 注意:Go 中 string 的零值是 "",但 GORM 对 string 有特殊处理——"" 不会被当作零值跳过(在 Updates 中会正常更新)。这是最常见的误解之一。


7.5 枚举类型的实现 ​

Go 没有原生的枚举类型,但我们可以用自定义类型实现:

go
// 定义状态类型
type UserStatus string

const (
	StatusActive   UserStatus = "active"
	StatusInactive UserStatus = "inactive"
	StatusBanned   UserStatus = "banned"
)

type User struct {
	ID     uint
	Name   string
	Status UserStatus `gorm:"type:varchar(20);default:active;comment:用户状态"`
}

使用:

go
user := User{
	Name:   "张三",
	Status: StatusActive,  // 类型安全,不会拼错字符串
}

// ❌ 编译错误!类型不匹配
// user.Status = "actvie"  // 拼错了,编译就报错

// ✅ 编译通过
user.Status = StatusActive

更高级的做法——实现 Scanner/Valuer 接口(第29章详解),可以把枚举值存为整数而非字符串。


7.6 自定义 Scanner / Valuer 接口(入门) ​

如果你需要把一个 Go 类型以特殊的格式存入数据库,可以实现这两个接口:

go
import "database/sql/driver"

// 自定义一个 JSON Map 类型
type JSONMap map[string]interface{}

// Scan:从数据库读取时调用(数据库值 → Go 值)
func (j *JSONMap) Scan(value interface{}) error {
	if value == nil {
		*j = make(JSONMap)
		return nil
	}
	bytes, ok := value.([]byte)
	if !ok {
		return fmt.Errorf("无法将 %T 转换为 JSONMap", value)
	}
	return json.Unmarshal(bytes, j)
}

// Value:写入数据库时调用(Go 值 → 数据库值)
func (j JSONMap) Value() (driver.Value, error) {
	if j == nil {
		return nil, nil
	}
	return json.Marshal(j)
}

使用:

go
type User struct {
	ID       uint
	Settings JSONMap `gorm:"type:json"`  // 使用自定义类型
}

user := User{
	Settings: JSONMap{"theme": "dark", "lang": "zh"},
}
db.Create(&user)
// Settings 自动序列化为 JSON 存入数据库

📌 虽然上面演示了手动实现 Scanner/Valuer,但实际上用 serializer:json 标签就能达到同样的效果,更方便。手动实现适用于更复杂的场景(如加密、哈希等),第29章会深入讲解。


常见错误 ​

错误1:时间字段用 time.Time 导致无法存 NULL ​

go
// ❌ 如果用户没填生日,存的是 0001-01-01
Birthday time.Time

// ✅ 用指针,没填就是 NULL
Birthday *time.Time

错误2:serializer 与 type 标签冲突 ​

go
// ❌ serializer:json 已经决定了存储格式
Field string `gorm:"serializer:json;type:varchar(100)"`
// type 和 serializer 同时存在可能导致意外行为

// ✅ 只用 serializer
Field string `gorm:"serializer:json"`

错误3:嵌入结构体字段名冲突 ​

go
type AuditInfo struct {
	CreatedAt time.Time
}

type SoftDelete struct {
	CreatedAt time.Time  // 和 AuditInfo 同名!
}

type User struct {
	AuditInfo
	SoftDelete
	// ❌ CreatedAt 冲突!GORM 不知道该用哪个
}

// ✅ 使用 embeddedPrefix 区分
type User struct {
	AuditInfo  AuditInfo  `gorm:"embedded;embeddedPrefix:audit_"`
	SoftDelete SoftDelete `gorm:"embedded;embeddedPrefix:del_"`
	// 列名:audit_created_at, del_created_at,互不冲突
}

本章小结 ​

  • serializer:json 让复杂类型(struct/map/slice)存入数据库变得轻松
  • embedded 复用公共字段,embeddedPrefix 避免冲突
  • *time.Time 比 time.Time 更适合"可选"的时间字段(支持 NULL)
  • 指针类型是解决零值陷阱的最佳方式
  • Go 枚举可以用自定义类型 + 常量实现,类型安全
  • Scanner/Valuer 接口让你能自定义存储格式

练习题 ​

  1. 定义一个 User 模型,Settings 字段(map[string]interface{} 类型)使用 serializer:json 存入数据库。写代码验证存入和读取。
  2. 定义一个公共的 Timestamp 嵌入结构体(包含 CreatedAt 和 UpdatedAt),然后分别嵌入到 User 和 Product 中。
  3. 零值陷阱的三种解决方案分别是什么?哪种最推荐?为什么?
  4. 写一个 UserStatus 枚举类型(active/inactive/banned),并在 User 模型中使用。
  5. 为什么建议"非必填的时间字段"用 *time.Time?
  6. (思考题)serializer:json 和自定义 Scanner/Valuer 都能存取 JSON 数据,它们的区别是什么?什么时候该用哪个?

把零散知识,整理成可以反复走的路。