第07章:模型标签全解(下)—— 特殊类型与嵌入
本章目标
学完本章后,你将能够:
- 使用
serializer标签处理 JSON、时间戳等特殊格式 - 使用
embedded/embeddedPrefix嵌入公共结构体 - 正确处理
time.Time、*time.Time、sql.NullTime三种时间类型 - 使用指针类型区分"零值"和"NULL"
- 在 GORM 中实现枚举类型
- 理解
autoCreateTime/autoUpdateTime的行为
前置知识
- 需要先学习:第06章(模型标签上)
- 需要了解:Go 的指针、接口概念(第01章)
7.1 serializer:序列化器
serializer 是 GORM v2 的新特性,让你可以用极简的方式存储复杂数据类型。
内置序列化器
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{}存入数据库时:
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 字符串):
INSERT INTO `users` (`settings`, `tags`, `meta`) VALUES
('{"theme":"dark","lang":"zh","notify":true}',
'["golang","gorm"]',
'{"login_count":42}')读取时自动反序列化:
var user User
db.First(&user, 1)
fmt.Println(user.Settings.Theme) // dark(自动从 JSON 还原)
fmt.Println(user.Tags[0]) // golang📌 serializer:json 是最常用的序列化器,特别适合存储配置信息、扩展字段等不需要独立建表的灵活数据。
其他内置序列化器
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 类型 |
|---|---|---|
json | JSON 字符串 | struct、map、slice |
unixtime | Unix 时间戳(秒) | time.Time, int64 |
unixmilli | Unix 时间戳(毫秒) | time.Time, int64 |
unixnano | Unix 时间戳(纳秒) | time.Time, int64 |
gob | Gob 二进制 | 任意 Go 类型 |
7.2 embedded / embeddedPrefix:嵌入结构体
当多个模型共享相同的字段时,可以抽取公共结构体:
普通嵌入
// 公共审计字段
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 // 公共字段复用!
}生成的表结构(字段被平铺):
-- users 表包含:id, name, created_by, updated_by, created_at, updated_at
-- products 表包含:id, name, price, created_by, updated_by, created_at, updated_atembedded 标签:显式声明嵌入
type User struct {
ID uint
Name string
AuditInfo AuditInfo `gorm:"embedded"` // 显式声明嵌入
}embeddedPrefix:给嵌入字段加前缀
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.Time | 0001-01-01 00:00:00 | 不会为 NULL | 一定有值的时间字段(如 CreatedAt) |
*time.Time | nil | 存 NULL | 可选的时间字段(如 Birthday、DeletedAt) |
sql.NullTime | {Time:..., Valid: false} | 存 NULL | 需要明确知道"是否设置" |
time.Time:最常用
type User struct {
CreatedAt time.Time // 零值:0001-01-01,创建时自动设为当前时间
UpdatedAt time.Time // 更新时自动设为当前时间
}*time.Time:支持 NULL
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:需要明确判断
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:自动时间戳
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 初学者最头疼的问题。
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 被跳过了!解决方案一:使用指针类型
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
// Map 中的零值会正常处理
db.Model(&user).Updates(map[string]interface{}{
"name": "张三",
"age": 0, // ✅ Map 方式零值不会被跳过
})解决方案三:使用 Select 强制更新
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 没有原生的枚举类型,但我们可以用自定义类型实现:
// 定义状态类型
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:用户状态"`
}使用:
user := User{
Name: "张三",
Status: StatusActive, // 类型安全,不会拼错字符串
}
// ❌ 编译错误!类型不匹配
// user.Status = "actvie" // 拼错了,编译就报错
// ✅ 编译通过
user.Status = StatusActive更高级的做法——实现 Scanner/Valuer 接口(第29章详解),可以把枚举值存为整数而非字符串。
7.6 自定义 Scanner / Valuer 接口(入门)
如果你需要把一个 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)
}使用:
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
// ❌ 如果用户没填生日,存的是 0001-01-01
Birthday time.Time
// ✅ 用指针,没填就是 NULL
Birthday *time.Time错误2:serializer 与 type 标签冲突
// ❌ serializer:json 已经决定了存储格式
Field string `gorm:"serializer:json;type:varchar(100)"`
// type 和 serializer 同时存在可能导致意外行为
// ✅ 只用 serializer
Field string `gorm:"serializer:json"`错误3:嵌入结构体字段名冲突
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接口让你能自定义存储格式
练习题
- 定义一个
User模型,Settings字段(map[string]interface{}类型)使用serializer:json存入数据库。写代码验证存入和读取。 - 定义一个公共的
Timestamp嵌入结构体(包含CreatedAt和UpdatedAt),然后分别嵌入到User和Product中。 - 零值陷阱的三种解决方案分别是什么?哪种最推荐?为什么?
- 写一个
UserStatus枚举类型(active/inactive/banned),并在 User 模型中使用。 - 为什么建议"非必填的时间字段"用
*time.Time? - (思考题)
serializer:json和自定义Scanner/Valuer都能存取 JSON 数据,它们的区别是什么?什么时候该用哪个?