大家好,最近在维护一些先人写的代码时,发现很多先人在处理 MySQL 的JSON 字段时,习惯性地用
string类型。好像没啥毛病,反正存进去取出来再 Unmarshal一下也能用。
但实际上,自从 MySQL 5.7 官方不再维护后,MySQL 8.x 版本中,JSON 字段的功能和性能已经不可同日而语了。如果我们还在用老一套的“字符串一把梭”,不仅浪费了数据库的能力,还给代码埋下了隐患。
今天我们就以设计一个“作家(Writer)”表为例,聊聊在 GORM 中处理 JSON 数据的三种姿势。
假设我们要存储作家的信息,其中有两个典型的 JSON 字段:
milestones (创作年表):结构固定(年份+事件)。metadata (元数据):结构不固定(可能包含流派、代理商、社交账号等)。
初始化 SQL 如下:
CREATE TABLE`writers` (
`id`INT AUTO_INCREMENT PRIMARY KEY,
`name`VARCHAR(100) NOTNULL,
`milestones`JSONDEFAULTNULLCOMMENT'创作年表',
`metadata`JSONDEFAULTNULLCOMMENT'元数据',
`created_at` DATETIME DEFAULTCURRENT_TIMESTAMP
);
方案 A:使用 string存储
这是最简单直接的写法,直接把数据库的 JSON 字段映射成 Go 的 string。
Model 定义:
type WriterA struct {
ID int `gorm:"primaryKey"`
Name string `gorm:"not null"`
// 直接用 string,完全把 JSON 当字符串处理
Milestones string `gorm:"column:milestones;type:json"`
Metadata string `gorm:"column:metadata;type:json"`
}
存取示例:
// 写入时:需要手动序列化,并强制转换为 string
milestones := []map[string]interface{}{
{"year": 2006, "work": "三体"},
}
jsonBytes, _ := json.Marshal(milestones)
writer.Milestones = string(jsonBytes) //会发生内存拷贝
// 读取时:需要将 string 转回 []byte 再反序列化
var result []map[string]interface{}
json.Unmarshal([]byte(writer.Milestones), &result)
这个方案能用,但代码里充斥着类型转换,且性能和规范性最差。
方案 B:使用GORM的库 datatypes.JSON
这是 GORM 提供的方案,datatypes.JSON的底层其实是 []byte的别名。
Model 定义:
import "gorm.io/datatypes"
type WriterB struct {
ID int `gorm:"primaryKey"`
Name string `gorm:"not null"`
// 使用官方类型
Milestones datatypes.JSON `gorm:"column:milestones"`
Metadata datatypes.JSON `gorm:"column:metadata"`
}
存取示例:
// 写入时:依然需要序列化,但直接赋值 []byte
milestones := []map[string]interface{}{
{"year": 2006, "work": "三体"},
}
jsonBytes, _ := json.Marshal(milestones)
writer.Milestones = datatypes.JSON(jsonBytes) // 零拷贝
// 读取时:直接反序列化
var result []map[string]interface{}
json.Unmarshal(writer.Milestones, &result)
string和 datatypes.JSON 的区别
很多同学会有疑惑:方案 A 和 方案 B,在业务代码里不都要手动 Marshal和 Unmarshal吗?看起来没区别啊?
其实在实际代码的执行过程中,方案 B 是对方案 A 的降维打击:
- 方案A (String):GORM 把它当普通字符串。如果你想查“所有科幻流派的作家”,你得手写原生 SQL (
WHERE metadata->>'$.tag' = 'sci-fi'),容易写错且难以维护。 - 方案B (Datatypes.JSON):GORM 官方包封装了 JSONQuery。你可以写出非常优雅的链式调用,自动兼容不同数据库语法:
// 查找 metadata 中 tags 包含 "Sci-Fi" 的记录
db.Where(datatypes.JSONQuery("metadata").HasKey("tags", "Sci-Fi")).Find(&writers)
- 方案A (String):
json.Marshal出来是 []byte,存入 string字段时发生了一次内存拷贝(Copy)。高并发下这是无谓的 GC 压力。 - 方案B (Datatypes.JSON):底层就是
[]byte,零拷贝直接落库。
- 方案A (String):进行数据迁移
AutoMigrate时,GORM 不知道你是 JSON,可能会建成 TEXT类型,导致无法享受 MySQL 8.0 的 JSON 索引优化。 - 方案B (Datatypes.JSON):GORM 明确知道这是 JSON,会自动映射为数据库的最佳 JSON 类型。
方案 C:自定义结构体 (Scanner/Valuer)
利用 Go 的接口机制,让 GORM 自动在 Go 结构体和数据库 JSON 之间转换。这是“强类型”的写法。
具体定义:
// 1. 定义具体业务结构
type Milestone struct {
Year int `json:"year"`
Work string`json:"work"`
}
type Milestones []Milestone
// 2. 实现 sql.Scanner (读库:JSON -> Struct)
func (m *Milestones) Scan(value interface{}) error {
return json.Unmarshal(value.([]byte), m)
}
// 3. 实现 driver.Valuer (写库:Struct -> JSON)
func (m Milestones) Value() (driver.Value, error) {
return json.Marshal(m)
}
// 4. Model 使用
type WriterC struct {
ID int `gorm:"primaryKey"`
// 直接使用强类型,无需再做 json 转换
Milestones Milestones `gorm:"column:milestones;type:json"`
// 假设 Metadata 也用结构体(如果结构固定的话)
// Metadata MetadataStruct `gorm:"column:metadata;type:json"`
}
存取示例:
// 写入:直接赋值结构体!
writer.Milestones = Milestones{
{Year: 2006, Work: "《三体》"},
}
db.Create(&writer)
// 读取:直接使用结构体!
var w WriterC
db.First(&w, 1)
fmt.Println(w.Milestones[0].Work) // 直接点出属性
三种方案横向对比
| | | | | | |
|---|
| A: String | | | | | | 别用 |
| B: datatypes | | | | | | 动态结构 |
| C: 自定义结构 | | | | | | 核心业务 |
最佳实践:混合双打
看完对比,结论就很明显了。针对我们开头的 Writer表:
- 对于
milestones(创作年表):结构固定,使用方案 C,确保类型安全。 - 对于
metadata(元数据):结构动态,使用方案 B,保留灵活性,同时支持 JSON 查询。
最终混合方案代码
package model
import (
"database/sql/driver"
"encoding/json"
"gorm.io/datatypes"
"time"
)
// === 1. 固定结构定义 (对应方案 C) ===
type Milestone struct {
Year int `json:"year"`
Work string`json:"work"`
Award string`json:"award,omitempty"`
}
type Milestones []Milestone
// 实现 Scanner 接口
func (m *Milestones) Scan(value interface{}) error {
bytes, ok := value.([]byte)
if !ok {
returnnil
}
return json.Unmarshal(bytes, m)
}
// 实现 Valuer 接口
func (m Milestones) Value() (driver.Value, error) {
return json.Marshal(m)
}
// === 2. 混合 Model 定义 ===
type Writer struct {
ID int `gorm:"primaryKey"`
Name string`gorm:"not null"`
// 强类型:创作年表 (方案 C)
// 优势:代码自动提示,编译期检查,无需手动转换
Milestones Milestones `gorm:"column:milestones;type:json"`
// 灵活类型:元数据 (方案 B)
// 优势:什么都能存,支持 GORM 的 JSON 查询语法
Metadata datatypes.JSON `gorm:"column:metadata;type:json"`
CreatedAt time.Time
}
// === 3. 实际使用体验 ===
func main() {
// 创建
writer := Writer{
Name: "刘慈欣",
// 方案 C 爽点:直接写结构体,不用 Marshal
Milestones: Milestones{
{Year: 2006, Work: "《三体》"},
{Year: 2015, Work: "《三体》", Award: "雨果奖"},
},
}
// 方案 B:处理杂项,还是需要手动 Marshal 一下
meta := map[string]interface{}{
"tags": []string{"硬科幻", "宏大叙事"},
"fans": "磁铁",
}
jsonBytes, _ := json.Marshal(meta)
writer.Metadata = datatypes.JSON(jsonBytes) // 零拷贝赋值
db.Create(&writer)
// --- 查询与使用 ---
var result Writer
db.First(&result, writer.ID)
// 方案 C 爽点:直接读取,自带类型
println(result.Milestones[1].Award) // 输出:雨果奖
// 方案 B 爽点:复杂查询
// 查找 metadata 中 tags 包含 "硬科幻" 的记录
var list []Writer
db.Where(datatypes.JSONQuery("metadata").HasKey("tags", "硬科幻")).Find(&list)
}
总结
核心数据用 Struct 换安全,扩展数据用 datatypes.JSON 更灵活,坚决不用 string 一把梭。个人总结的 Go 语言处理 MySQL JSON 字段的口诀╮(╯▽╰)╭。