告别繁琐!Go 操作数据库读写 JSON 的优雅小技巧

413 阅读6分钟

在 MySQL 数据库中,将 JSON 数据作为一个字段进行存储与读取,是一种常见且实用的实践方式。今天,我们就通过一个具体的小需求,来深入探讨如何在 Go 语言中使用 GORM 框架优雅地处理这类 JSON 数据。

1. 抛砖引玉:需求背景与问题初现

想象一下,前端系统就像一个忙碌的信使,不断地向后端发起调用请求,并传递若干参数。而后端呢,为了记录前端的访问情况,专门设立了一个数据表,就像一个细心的档案管理员,把每一次的访问信息都记录得清清楚楚。

在这个数据表中,有一个名为 req_params 的字段,它的任务就是原封不动地存储前端传递过来的参数的 JSON 字符串。我们先来看看这个数据表是如何创建的:

 -- 创建用于记录前端访问记录的表
 CREATE TABLE front_end_access_records (
     -- 记录的唯一标识,无符号整数类型,自增主键,从1开始,用于唯一确定每一条访问记录
     id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
     -- 记录前端访问后端的具体时间,默认值为当前时间(使用数据库函数获取)
     access_time DATETIME DEFAULT CURRENT_TIMESTAMP,
     -- 前端请求后端的方法,如GET、POST、PUT、DELETE等,不允许为空
     req_method VARCHAR(10) NOT NULL,
     -- 前端请求的后端接口URL,不允许为空
     req_url VARCHAR(255) NOT NULL,
     -- 原样存储前端传递过来的参数的JSON字符串,可以为空
     req_params TEXT,
     -- 前端请求的客户端IP地址,不允许为空
     client_ip VARCHAR(45) NOT NULL,
     -- 前端请求的User-Agent信息,用于标识客户端的类型和版本等,可以为空
     user_agent VARCHAR(512)
 );

在 Go 语言中,我们通常会定义一个结构体来对应这个数据库表。下面就是对应的结构体代码:

 type FrontEndAccessRecord struct {
     ID          int      `gorm:"primaryKey;autoIncrement;column:id"`
     AccessTime  time.Time `gorm:"default:CURRENT_TIMESTAMP;column:access_time"`
     ReqMethod   string    `gorm:"type:varchar(10);not null;column:req_method"`
     ReqURL      string    `gorm:"type:varchar(255);not null;column:req_url"`
     ReqParams   string    `gorm:"type:text;column:req_params"`
     ClientIP    string    `gorm:"type:varchar(45);not null;column:client_ip"`
     UserAgent   string    `gorm:"type:varchar(512);column:user_agent"`
 }

这里给大家举一个 req_params 的例子,让大家更直观地感受一下:

 ReqParams{
     DateRange: DateRange{
         Start: "2025-01-01",
         End:   "2025-01-12",
     },
     Country: "CN",
     City:    "beijing",
 }

看起来一切都很美好,但是当我们实际操作时,问题就来了。在存储数据之前,我们需要将结构体序列化为 JSON 字符串,这还算简单。然而,当我们读取 FrontEndAccessRecord.ReqParams 内部字段时,每次都要手动进行反序列化,而且在 Go 语言中还需要处理可能出现的错误,这可真是让人头疼不已😫。

别担心,MySQL驱动早就为我们想好了办法,接下来,就让我们看看如何利用 MySQL 驱动的强大功能,轻松解决这个问题。

2. Gorm 中实现 Json 数据读写:化繁为简的魔法

在 GORM 中,我们可以通过实现 ScannerValuer 接口,将结构体转换为 JSON 格式进行存取,就像给数据施了一个魔法,让它可以在结构体和 JSON 字符串之间自由转换。

首先,我们把 ReqParams 字段定义为结构体类型,这样可以让数据的处理更加直观和方便:

 // FrontEndAccessRecord 定义前端访问记录结构体
 type FrontEndAccessRecord struct {
     ID          int      `gorm:"primaryKey;autoIncrement;column:id"`
     AccessTime  time.Time `gorm:"default:CURRENT_TIMESTAMP;column:access_time"`
     ReqMethod   string    `gorm:"type:varchar(10);not null;column:req_method"`
     ReqURL      string    `gorm:"type:varchar(255);not null;column:req_url"`
     ReqParams   ReqParams `gorm:"type:text;column:req_params"`
     ClientIP    string    `gorm:"type:varchar(45);not null;column:client_ip"`
     UserAgent   string    `gorm:"type:varchar(512);column:user_agent"`
 }
 ​
 // DateRange 定义日期范围结构体
 type DateRange struct {
     Start string `json:"start"`
     End   string `json:"end"`
 }
 ​
 // ReqParams 定义请求参数结构体
 type ReqParams struct {
     DateRange DateRange `json:"date_range"`
     Country   string    `json:"country"`
     City      string    `json:"city"`
 }

2.1. 实现 Valuer 接口:数据存储的魔法咒语

为了实现在数据存储时能够自动将结构体数据转为数据库可以存储的值,我们需要为 ReqParams 实现 Valuer 接口。其实很简单,只需要将结构体进行序列化就可以了:

 // Value 实现 Valuer 接口,用于将结构体转换为数据库可以存储的值
 func (rp *ReqParams) Value() (driver.Value, error) {
     return json.Marshal(rp)
 }

2.2. 实现 Scanner 接口:数据读取的解密钥匙

为了实现读取数据时能够将数据库值转为结构体,我们需要为 ReqParams 实现 Scan 接口。这个接口的作用就是将数据库中的 JSON 字符串反序列化为结构体:

 // Scan 实现 Scanner 接口,用于将数据库中的数据扫描到结构体中
 func (rp ReqParams) Scan(value interface{}) error {
     if value == nil {
         return nil
     }
     var bytes []byte
     switch v := value.(type) {
     case []byte:
         bytes = v
     case string:
         bytes = []byte(v)
     default:
         return fmt.Errorf("unsupported data type: %T", value)
     }
     return json.Unmarshal(bytes, rp)
 }

2.3. 使用示例:轻松享受魔法的成果

实现了上述两个接口后,在使用时就无需手动序列化和反序列化字段了,就像拥有了一个智能助手,帮我们自动处理这些繁琐的事情。下面是一个简单的使用示例:

 func main() {
     // 创建一个新的前端访问记录
     record := FrontEndAccessRecord{
         ReqMethod: "GET",
         ReqURL:    "https://example.com/api",
         ReqParams: ReqParams{
             DateRange: DateRange{
                 Start: "2025-01-01",
                 End:   "2025-01-12",
             },
             Country: "CN",
             City:    "beijing",
         },
         ClientIP:  "127.0.0.1",
         UserAgent: "Mozilla/5.0",
     }
 ​
     // 保存记录到数据库
     result := db.Create(&record)
     if result.Error != nil {
         fmt.Println("Error creating record:", result.Error)
         return
     }
 ​
     // 从数据库中读取记录
     var readRecord FrontEndAccessRecord
     result = db.First(&readRecord, record.ID)
     if result.Error != nil {
         fmt.Println("Error reading record:", result.Error)
         return
     }
 ​
     fmt.Printf("Read record: %+v\n", readRecord)
 }

通过以上的步骤,我们成功地在 GORM 中实现了 JSON 数据的读写,避免了手动序列化和反序列化的麻烦,让代码更加简洁和易于维护。希望这篇文章能帮助你在处理数据库中的 JSON 数据时更加得心应手,快去试试吧!

3. 知其然,知其所以然

3.1. 数据存储时的链路

从我们调用create方法开始,传入create的参数就会被一直向下透传,最终会来到 gorm 使用的MySQL的驱动库中。

落库过程中会有值转换的动作,在这个过程中,会判断我们是否实现了driver.Valuer接口,并进行相关调用。

image.png

3.2. 数据读取时的链路

在数据处理流程的起始阶段,数据会从数据库里进行读取操作。此时,这些从数据库中提取出来的数据,尚未与预定义的 Model 结构体建立映射关系,它们仅仅是原始的、未经过组织和适配的数据集合。紧接着, gorm 或调用自身的 scan 方法。在 scan 方法中进一步调用 Go 语言自身标准库中的相关方法。在 Go 语言的 convert 包内,会触发一个重要的判断机制。该机制会检查目标对象是否实现了 scan 方法。

image.png