js/ts 程序员表示很惊讶,go 中居然要额外写代码解决 json 中的 null

最近学习 go 发现发现处理 json 中的 null 时,会这么难受,需要专门写一篇文章来讲解一下【🐶】

以下是正文

json 是一种常用的数据格式,在 go 使用 json 序列化和反序列化时比较方便的,但在使用过程中,会遇到一些问题,比如 null

由于 go 没有联合类型,当 json 中有个属性为 null 时,就无法直接将 null 转换成 nil 后赋值给某个具体的类型

比如下面这个例子:

  • Name 定一个的是 string 类型,但在 jsonname 的值为 null,直接转换会报错
go 复制代码
type Tag struct {
  ID   int    `json:"id"`
  Name string `json:"name"`
}
tag := Tag{
  ID:   1,
  Name: nil,  // 这里会报错
}

这种问题不光出现在 json 解析时,还会出现在数据库读写时

比如在数据库中,某个字段的值为 NULL,在读取时,会被解析成 nil,但是 go 中的类型是不能直接赋值为 nil

所以在这两种场景下该怎么解决呢?

一般有三种方法:

  • 使用指针
  • 自定义类型
  • 使用第三方库

使用指针

go 的指针类型是可以赋值为 nil 的,所以我们使用指针解决这个问题

我们把上面例子中的 Name 定义为 string 的指针类型,如下代码:

go 复制代码
type Tag struct {
  ID   int    `json:"id"`
  Name *string `json:"name"`
}
name := "uccs" // 定义一个 string 类型的变量,因为不能把一个字面量直接赋值给指针类型
tag := Tag{
  ID:   1,
  Name: &name,  // 将 name 的地址赋值给 Name,使用 & 地址符
}

在使用时,需要先判断一下 Name 是为 nil,如果不为 nil,则使用 * 取值符取出值

go 复制代码
// Name 是指针类型,判断是否为 nil 时不需要使用 * 取值符
if tag.Name != nil {
  // Name 是指针类型,取值时需要使用 * 取值符
  if *tag.Name == "uccs" {
    // ...
  }
}

注意事项

  1. ORM 框架会实现一个 NullString 的类型,
    • 当我们在定义 Model 时,如果某个字段可以为 NULL,则 ORM 框架会把它定义为 NullString 类型(下文讲解)
  2. 给指针赋值时,不能直接使用字面量,需要先定义一个变量,然后将变量的地址赋值给指针
  3. 使用指针时需要注意,这里会比较绕
    • 在判断是否为 nil 时,不需要使用 * 取值符
    • 在判断是否为 uccs 时,需要使用 * 取值符
  4. 当遇到 panic: runtime error: invalid memory address or nil pointer dereference 错误时,说明指针为 nil

也就是说使用指针时,我们最需要注意的是:在指针上取值时,一定要注意它是不是为 nil

自定义类型

我们使用结构体定义一个类型:NullString,它有两个属性 StringValid

  • String 用来存储字符串
  • Valid 用来标识 String 是否有值
    • 如果 Validtrue,则 String 有值
    • 如果 Validfalse,则 String 是空值 ""
go 复制代码
type NullString struct {
  String string
  Valid  bool
}

当我们定义好类型后,需要考考虑两个问题:

  • 如何解决 json 解析时 null 的问题
  • 如何向数据库进行读写

go 有个特点,你自定义的类型有某些方法,那么在某些场景下,这些方法会被调用

比如,序列化时,会调用 MarshalJSON 方法,反序列化时,会调用 UnmarshalJSON 方法

你的自定义类型实现了这两个方法,那么在序列化和反序列化时,这两个方法就会被调用

数据库读写是实现 ScanValue 方法

所以下面就从这两块讲起:

序列化和反序列化

我们给 NullString 类型添加两个方法 MarshalJSONUnmarshalJSON

go 复制代码
// 序列化时
func (ns NullString) MarshalJSON() ([]byte, error) {
  // 如果 Valid 为 true,则返回 String 的 json 序列化结果
  if ns.Valid {
    return []byte(`"` + ns.String + `"`), nil
  }
  // 如果 Valid 为 false,则返回 null 序列化的结果
  return []byte("null"), nil
}
// 反序列化
func (ns *NullString) UnmarshalJSON(data []byte) error {
  // 如果 data 为 null,则 Valid 为 false
  // String 为空字符串
  if string(data) == "null" {
    ns.String, ns.Valid = "", false
    return nil
  }
  // 否则,将 data 反序列化到 String 中
  // 并将 Valid 设置为 true
  if err := json.Unmarshal(data, &ns.String); err != nil {
    return err
  }
  ns.Valid = true
  return nil
}

有了这两个方法之后,我们就解决了 json 解析时 null 的问题

是什么时候会触发这两个方法呢?

  • json 内容解析填充 struct 的场景时会触发 UnmarshalJSON 的调用
    • 直接调用 json.Unmarshaljson 数据进行解析时
    • http.Request 读取 json Body
    • 使用 encoding/jsonDecoder 进行解码时
    • 对实现了 Unmarshaler 接口的对象调用 UnmarshalJSON 方法时
  • 反过来,将 struct 内容序列化为 json 时会触发 json.Marshal 的调用
    • 直接调用 json.Marshal 对一个对象进行编码
    • 使用 http.ResponseWriterWrite 方法响应 json 数据时
    • 使用 encoding/jsonEncoder 进行编码时
    • 对实现了 Marshaler 接口的对象调用 MarshalJSON 方法时

序列化和反序列化问题解决了,那如何向数据库进行读写呢?

数据库读写

我们再给 NullString 添加两个方法 ValueScan

  • Value 方法会在写入数据库时被调用
  • Scan 方法会在从数据库读取时被调用
go 复制代码
// Scan 方法在 数据库读取时被调用
func (ns *NullString) Scan(value interface{}) error {
  // 如果 value 为 nil,则 Valid 为 false,String 为空字符串
  if value == nil {
    ns.String, ns.Valid = "", false
    return nil
  }
  // 否则,将 value 断言为 string 类型,断言成功 Valid 为 true,String 为 value
  ns.String, ns.Valid = value.(string)
  return nil
}
// Value 方法 在写入数据库时被调用
func (ns NullString) Value() (driver.Value, error) {
  // 如果 Valid 为 false,则返回 nil
  if !ns.Valid {
    return nil, nil
  }
  // 否则,返回 String
  return ns.String, nil
}

添加这两个方法后,我们就可以向数据库中写入 null

是什么时候会触发这两个方法呢?

  • Scanner 接口的 Scan 方法会在以下情况被调用
    • ORM 框架如 GORMdatabase/sql 等查询时,扫描结果到自定义模型
  • Valuer 接口的 Value 方法会在以下情况被调用
    • ORM 框架如 GORMdatabase/sql 构造写入语句时,获取自定义模型的值

使用

将上面 Tag 的解构体改为:

go 复制代码
type Tag struct {
  ID   int        `json:"id"`
  Name NullString `json:"name"`
}

不过这里要注意的一点是,在给 Name 赋值时,需要使用 NullString 进行赋值,如果下所示:

go 复制代码
tag := Tag{
  ID:   1,
  Name: NullString{String: "hello", Valid: true},
}

最后需要注意的是,go 中其他类型也要实现这样的方法,比如 NullIntNullBool 等,可以参照这个 guregu/null 这个库

使用第三方库

  1. 第三方库 guregu/null 已经实现了上面的方法,我们可以直接使用
  2. ORM 一般都实现了这些功能
    • 需要注意的是有些 ORM 只实现了 ScannerValuer 接口,没有实现 MarshalJSONUnmarshalJSON 接口

总结

  1. 使用 string 只能满足必填的情况
  2. ORM 框架一般都实现了 ScannerValuer 接口,但是有些 ORM 没有实现 MarshalJSONUnmarshalJSON 接口,需要自己实现,或者使用第三方库
  3. 使用指针时,如 *string,需要注意指针是否为 nil
相关推荐
审判长烧鸡1 小时前
【AI问答】GO代码循环返值
go
捧 花1 小时前
Eino框架记忆功能实现指南
go·agent·eino
Java陈序员2 小时前
主流数据库通吃!一款开源实用的数据库备份管理工具!
react.js·postgresql·go
云浪3 小时前
搞懂 Go WaitGroup:一篇文章彻底理解并发等待机制
后端·go
吴声子夜歌3 小时前
PlantUML——显示JSON数据
json
祀爱3 小时前
ControllerBase 类将对象转换为 JSON 格式并返回前端的方法
前端·json·asp.net
weelinking17 小时前
【产品】11_实现后端接口——数据在背后如何流动
java·人工智能·python·sql·oracle·json·ai编程
喵个咪18 小时前
选择第三方IAM还是自建权限体系?中小型后台系统权限架构决策指南
后端·架构·go
喵个咪20 小时前
AI重构软件开发范式:框架与脚手架为何仍是生产级开发的刚需?
架构·go·ai编程
夜悊21 小时前
Go并发编程的学习代码示例:生产者消费者模型
go