JSON, Go 及其它

这是准备用来在公司小组内技术分享 JSON 和 Go 的稿子

想到自己好久没写博文了,顺便在博客贴一下。

JSON 应该是目前我们用的最多的两种序列化格式之一了(另一种是 Thrift)。

它是一种极其简单直观的序列化语言,不过从我在各个地方看来,感觉很多人对 JSON 的了解并不是很透彻。今天主要是从标准、与其它序列化协议的区别和它在 Go 中的使用角度来解释一下我们在使用 JSON 的时候遇到的各种问题,希望能在未来的工作中对大伙儿有所帮助。

JSON 标准

JSON 脱胎于 JavaScript,全称是 JavaScript Object Notation,即爪哇脚本对象标记。

JSON 是 JavaScript 语法上的一个子集(曾经有例外),也就是说任何时候 JSON 都可以直接作为 JavaScript 被解释。

JSON 最新的标准由 RFC 8259ECMA 404 定义。

关于例外可以参考下面几个链接,这里不详细介绍:

  1. proposal-json-superset https://github.com/tc39/proposal-json-superset
  2. JSON: 不要误会,我真的不是JavaScript的子集 https://zhuanlan.zhihu.com/p/29958439
  3. 终于,JSON成了ECMAScript的子集 https://zhuanlan.zhihu.com/p/56003162

一个 JSON 串可以是任意的 JSON 值,JSON 的值可以是下面几种类型:

  • 对象 object
  • 数组 array
  • 数字类型 number
  • 字符串类型 string
  • 字面量 null
  • 字面量 truefalse

Object

对象,就是被花括号围住的零个或多个键值对,一个简单例子是:

{"name":"value"}

这里键必须是 string 类型,值可以是任何 JSON 值;

键可以重复,但这不被标准建议。如果一个 JSON 对象中出现了重复键,标准不保证该对象在各种实现中被处理结果是一致的。基本所有的实现都会只保留重复键中的一个(比如 go 的标准库),但是有些实现可能会报错,有些实现可能会保留所有的重复键,有些实现可能允许程序员配置需要的行为。

键是无序的。即键值对顺序不一致的两个 object,本质上可以是相同的。大部分实现中,object 中键的排列不影响对应对象的值。比如在 go 中,object 对应的类型 map[string]interface{} 本身的实现就不是有序的。

Array

数组,就是被方括号围住的零个或多个值,一个例子是:

[0,1,1,2,3,5,8,13,21,34,55,89,144]

Array 中不同位置的值可以是不同类型,比如第一个是一个 number,第二个是个 array,第三个是一个 object 之类(这里和 Thrift 不同,Thrift 要求 list 中所有元素都拥有相同的类型,后面会介绍)。

Number

Number 就是我们所熟知的数字类型。在 JSON 中,Number 有十进制和科学计数法两种表示形式,区别主要看有没有 e 或者 E。

不允许出现常见的 0b 前缀二进制、0o 前缀或 0 前缀八进制、0x 前缀十六进制,不允许前缀 0。

标准允许实现限制 Number 类型的精度和大小,并且没有要求必须遵守 IEEE 754 的双精度类型。不过由于 JSON 的 Number 对应 JavaScript 的 Number 类型,而 JavaScript 标准要求 Number 类型满足 IEEE 754 的双精度浮点类型定义,因此我所知的所有实现中 Number 都是 IEEE 754 定义的双精度浮点类型。

Number 是 JSON / JavaScript 的一个硬伤。众所周知,IEEE 754 定义的双精度提供了 52 位的尾数,因此能被安全表示的最大的整数只能是:{2^{53}-1=\left(\displaystyle1+\frac12+\frac14+\cdots+\frac1{2^{52}}\right)\times2^{52}} 负数同理。

安全整数的定义是这个数 \pm1 的值都可以无损地被储存和处理。 因此虽然 2^{53} 也可以被精确表示,但是由于无法表示其相邻的值即 2^{53}+1,因此不算作“安全整数”,其它孤零零的整数,如 \left(1+\displaystyle\frac12\right)\times2^{53}\left(1+\displaystyle\sum_{k=1}^{52}\frac1{2^k}\right)\times2^{1023}等同理不算“安全整数”。

关于双精度浮点数 IEEE 754 可以参考:

  1. https://en.wikipedia.org/wiki/Double-precision_floating-point_format
  2. 推荐一下我自己的博文 https://xr1s.me/2018/03/06/ieee-754-2008-floating-point-format-explain-in-detail/

但是服务端的 ID 之类的数值通常都会使用超过 2^{53} 的 i64 类型,因此直接使用 number 类型前端 JavaScript 是处理不了的,或者说至少是不安全的、可能在不同的实现中有不同的表现。这也导致我们经常使用 string 来传递 ID。

不过 Unix 时间戳是安全的,现在的 Unix 时间戳都还没超过 32 位有符号浮点数,到 32 位有符号被用完都还有 17 年时间,32 位无符号被用完要到下个世纪了,所以暂时不需要担心。 关于时间戳可以参考:

  1. 2038 年问题 https://en.wikipedia.org/wiki/Year_2038_problem

IEEE 754 和 2038 年问题、千年虫问题等等都可以展开讲讲。如果还有机会,我可以再做一个 IEEE 754 的分享。

String

String 就是我们熟知的字符串类型。 除了几个特殊的字符必须被转义之外,任何出现在 Unicode 字符表中的字符都可以直接出现在 JSON 字符串中。 必须被转义的特殊的字符包括:

  • 反斜杠 \
  • 双引号 "
  • 控制字符,包括退格 \b 换页 \f 回车 \r 换行 \n 制表符 \t 等,其它控制字符只能通过 \uXXXX 的形式被转义

另外,任何字符都可以以 \uXXXX 的形式被转义,其中 XXXX 是字符的 UTF-16 编码。如 A 的转义为 \u0041 的转义是 \u4e00,😀的转义是 \ud83d\ude00XXXX 部分是不区分大小写的。

除了上文说的 \ " b f r n t 还有一个 u 之外(这几个是区分大小写的),还有一个是可选的可以被转义的字符,是 /。(曾经有人提了一个 errata,想要把 / 从可转义字符中去掉,被驳回了,理由是没必要)

再更多就没有了,除了刚才列出来的那些字符之外,\ 后不能直接跟任何字符,包括在 C 中常见的一种转义字符 \xHH。 关于 Unicode 和 UTF-8 UTF-16 编码,可以参考:

  1. Unicode https://en.wikipedia.org/wiki/Unicode
  2. UTF-16 https://en.wikipedia.org/wiki/UTF-16

Unicode 和 UTF-16 也可以展开讲一讲,我自己对字符编码的研究不深,有研究的小伙伴也可以来个分享。

null,true 和 false

几个表示特殊值的字面量,除了这些之外 JSON 再无特殊字面量。

JavaScript 中还有几个特殊的值 undefined NaN Infinity,不是 JSON 的字面量,不能直接作为 JSON 值被使用。

当然 NaNInfinity 本来也不是 JavaScript 的保留字,和 null true false undefined 不是一个重量级的。但是 JSON 中不允许使用 NaNInfinity 其实也算是一个缺陷。

JSON RFC 的演化

最新的 JSON RFC 是 8259,而此前定义 JSON 的 RFC 有 4627、7158、7159 三份,7159 只是稍微改动了 7158 的年份数字,因此历史版本实际上只能算两份,这里介绍一下他们的区别。

RFC 4627 定义一个完整的 JSON 串必须是 object 或 array,而 RFC 7158 允许任意合法 JSON 值作为 JSON 串,这是最大的变化。

就是说之前 "abc" 不是一个合法的 JSON 串,而 RFC 7158 之后就是了。

除了这个之外,RFC 7158 还补充了一些行为:

  • RFC 7158 提出了 object 键重复问题和排序问题的处理方案:实现定义;
  • RFC 7158 提出 array 中的元素可以是不同类型的 JSON 值;
  • RFC 7158 建议 number 遵守 IEEE 754 标准,遵守安全整数 -2^{53}+1\to2^{53}-1 区间内的整数能被准确表示的要求。此前 4627 没有明确定义应当满足的精度和大小要求;
  • RFC 7158 不允许实现在 UTF-16 编码的 JSON 串开头新增零宽不换行字符(用于区分大小端序),并允许实现忽略该字符
  • RFC 7158 提到了 string 中的 UTF-16 转义编码的问题,比如当存在不合法的 UTF-16 转义的字符串——一个单独的 \uDEAD,它没有对应的 Unicode 字符,该码位本身就是 Unicode 为 UTF-16 编码保留的(具体可以参考 UTF-16 编码文档)——的时候,允许实现执行任何操作,如保留该错误字符、移除该错误字符、抛出错误等;
  • RFC 7158 提到字符串比较的时候应当把转义字符转回 Unicode 码位以进行比较,避免出现 "0""\u0030" 比较结果不相同的情况;
  • RFC 7158 移除了存在风险的 JavaScript eval 实现的例子

还有一些文字上的改变,具体可以参考 7158 底部列出的附录 A。

  • RFC 8259 将 JSON 串编码限制到了 UTF-8 中(此前还允许 UTF-16 和 UTF-32)

还有一些文字上的改变,具体可以参考 8259 底部列出的附录 A。

JSON 和其它序列化格式

序列化,就是将一个应用程序中的对象按照约定的格式转化为一个字节串的过程,反序列化是将字节串重新组织回内存中的对象的过程。

序列化一般被用于数据固化和资料交换,在实践中,经常被用于如下场景:

  • 远程过程调用 (XML, JSON, Thrift, Protocol Buffers 等)
  • 配置文件编写 (INI, XML, JSON, YAML, TOML 等)
  • 对象持久化 (CSV, XML, JSON, Python Pickle, 数据库自带的序列化储存格式等)

JSON 是一种被广泛使用的序列化方法,它简单易读,但是在实践中却有很多缺点:

  • 不支持 64 位整数,这无论在哪儿都是硬伤
  • 作为配置文件语言时不人性化
    • 不允许注释,配置文件对注释的需求比通常想象的更大,但 JSON 不支持;
    • 字符串内换行不友好,你只能 \n,然后获得一坨超长难读的字符串;
    • 字符串内套字符串,甚至再多套几层,想想就头大好不好;
    • 对二进制数据的支持很差;
    • 不支持特殊浮点数如无穷大或 NaN,可能有一些特殊需求会依赖这个东西;
    • 不允许尾后逗号,这在增减一个 array 或者 object 的元素的时候还挺恼人的;
  • 作为资料交换语言时存在编码解码效率低下问题;
  • 作为资料交换和对象持久化语言时存在体积过大问题;

总之就是相对常见的资料交换和对象持久化语言,它足够人性化和简单,但是不如二进制语言们时空高效;

另一方面作为机器和人交互的接口——配置文件语言,设计配置文件语言时一般着重考虑人类友好、可读性问题,效率一般不在考虑范围内(因为一般只会在程序启动、重启时解析一次),这时候它又不够友好——JSON 根本不是为此设计的,因此也不建议用 JSON 作为配置文件(话是这么说,大家都在用又有什么办法)。

所以聪明的程序员们又发明了很多语言来解决这些问题,这里挑一些介绍:

Thrift Binary Protocol

当我们提到 Thrift 的时候,我们首先指的是 Apache Thrift 的这一套 RPC 生态。它其实定义了一套网络传输协议和序列化反序列化通讯协议,Apache 还提供了代码生成工具和许多语言的官方库。

Thrift 预定义了多种序列化协议,默认情况下,我们使用的都是 Binary Protocol。官方语言库中还实现了 Compact Protocol,它和 Binary 很像,会在后文介绍。

Thrift 需要我们先定义一份 IDL 即 thrift 文件用于描述需要在 RPC 中进行交换的数据格式。

struct Foo { 
  1: i64 I64, 
  2: string S, 
  3: binary B, 
  4: double D, 
  5: list<list<string>> LLS, 
  6: map<string, list<i64>> MS_LI64, 
}

在 Thrift 中和 JSON 的一点很大不同是:

JSON 的 object 在 Thrift 中被拆分成了结构固定的 struct 和 KV 映射的 map 类型。

对于 struct 来说,它不需要传输一个对象的键(字段)的全称,而是使用在 IDL 中定义的标识符来传输字段,这对于降低复杂度是一个很大的优化。

对于 map 来说,和 object 基本是一致的,但也有一些区别,比如 Thrift map 允许 string 外的类型作为键类型,同时不允许值类型可变。

Thrift Binary Protocol 编码

最后,作为一个为 RPC 而生的语言,Thrift 天生支持 exception 类型(错误类型),它本质也是一个 struct,但是被用于传递错误信息。不过字节跳动的 Kite 框架不支持,而 KiteX 框架虽然支持但是没人用——字节的基础组件都默认使用一个特殊的字段来传递错误码了,用 exception 反而会造成不必要的麻烦。

Thrift Compact Protocol

和 Binary 十分类似,但是当编码数字的时候,使用了特殊的序列化方法:zigzag + varint 编码。

我们在 IDL 里给结构体定义了一个 i32 类型的数字时,Thrift 就会给我们预留 4 个字节的空间用来传输这个数字。

但是与 UTF-8 和 Unicode 的关系差不多,我们大部分时候用不到较高的那些位,很多时候我们只需要传一些小数字而已,比如大部分情况下可能只用到 i8 范围内的枚举值,却还是要占用 4 个字节,这不是浪费吗?

所以 varint 编码提出:每个字节最高位保留一个比特,用来说明当前字节是不是该数字的最后一字节,剩下 7 位保存数字本身,如图:

varint

但这里还有一个问题是,当我们遇到补码表示的负数,那肯定是会占满 4 个字节的,因为补码表示的负数最高位必然是 1。对于一些绝对值较小的负数,我们也希望占空间较小,于是——

\mathrm{zigzag}(n)=\begin{cases}2n&\text{if }n\ge0\\-2n-1&\text{if }n<0\end{cases}

即 0 仍是 0,-1 变成 1,1 变成了 2,以此类推。然后再进行 varint 压缩。 对于所有多字节整数类型 i16 i32 i64 都使用了 zigzag + varint 进行了压缩,string binary list set map 类型的头部长度编码,因为肯定非负,只使用了 varint 进行压缩。

Protocol Buffers

Protocol Buffers 和 Thrift 十分类似,同样需要一个 IDL 文件(也就 JSON 特殊一点,所以才低效)。

Protocol Buffers 整数默认使用 varint 编码,也可以指定使用定长类型 fixed 的整数。

Protocol Buffers 区分无符号整数和整数,对于无符号整数直接使用 varint,对于有符号整数使用 zigzag + varint 编码。 关于 Protocol Buffers 的编码,可以直接参考它爹的官方文档:https://developers.google.com/protocol-buffers/docs/encoding

INI

和主要用于资料交换和数据持久化的二进制序列化编码不同,为配置文件而生的编码类型,设计重点是人类友好,而非时间空间性能。

INI 是一种使用历史较为久远的配置文件格式,最早在 MS-DOS 上被用来作为默认的配置文件格式。

它甚至比 JSON 更简单,因为它只有键值对还有分区这两个概念,它没有类型,一切均是字符串。

但是它支持注释。 连 INI 都支持注释,你看 JSON 是多不适合作为配置文件。 它现在还在 Windows 系统和一些软件中被作为配置文件格式。

[section] 
key=val 
; comments

XML

XML 也是个有标准规范的语言,可以作为配置文件,也有被用于数据交换的。

XML 其实是比较复杂的。从它的标准定义自带很多可能导致远程代码执行漏洞的问题,比如 XXE,这就可见其复杂性。

XML 也支持注释。再鄙视一次 JSON。

XML 和 INI 类似,也是一种弱类型(无类型)语言,一切皆字符串。

XML 作为配置主要在 Java 中被使用,macOS 操作系统默认的配置文件格式 property list 其实也是一种 XML。

XML 作为数据交换语言主要在……呃,HTML 中被使用(HTML 是 XML 变种,HTML 不是 XML)。另外一些 RPC 协议也会使用 XML,如 SOAP。

https://www.w3.org/TR/2006/REC-xml11-20060816/

YAML

YAML 和 Thrift、Protocol Buffers 不同,它纯粹是为配置文件而生的语言。

YAML 1.2 开始,YAML 成为了 JSON 的超集,也就是说可以直接在 YAML 里写 JSON 被正常解析。

YAML 也是一种比较复杂的语言,从很多 YAML 实现存在远程代码执行漏洞——虽然这和解析库搞扩展脱不了干系——也可见一斑。

而且我不喜欢 YAML 依赖缩进表示对象层级的要求。 具体内容不介绍,但 YAML 切实解决了 JSON 的几个痛点:

  • 支持注释
  • 支持大整数
  • 支持二进制数据
  • 支持字符串内换行
  • 支持特殊浮点数 inf 和 nan
  • 基本解决了字符串嵌套的问题

YAML 一般被用于一些较新的软件中,比如 Kubernetes、Ansible 就在使用 YAML 作为配置文件。

简单举例,详细可以去官网学习:https://yaml.org/spec/1.2/spec.html

--- 
# 数字类型 
integer: 1234 
 
# 字符串,如果内容不是纯数字可以不加双引号,或者通过 !! 指定数据类型 
string: "1234" 
 
# 可以通过 !! 来指定数据类型,或者用小数点表示是浮点类型 
floating: !!float 1234 
 
# yes YES true TRUE 等都表示真 
# no NO false FALSE 都表示假 
boolean: yes 
 
# 多行字符串,有很多换行、空格、前后缀空白字符控制方式,不一一介绍 
multi-line: | 
    换行 
    会被保留 
    缩进(前四个空格)会被移除 
        后续的空格会被保留 
 
# 二进制数据会被 base64 解码 
binary: !!binary | 
  R0lGODdhDQAIAIAAAAAAANn 
  Z2SwAAAAADQAIAAACF4SDGQ 
  ar3xxbJ9p0qa7R0YxwzaFME 
  1IAADs= 
 
# 需要缩进,一个 - 表示一个元素,元素类型可以不同 
array: 
  - 你 
  - 好 
  - 世 
  - 界 
   
# 对象,其实上面定义的键也是外层大对象中的字段,冒号前的都是键 
object: 
  field: false 
  another_field: 1234.5 
 
# YAML 是 JSON 的超集 
json-style: 
  object: {"key": "val"} 
  array: ["elem"] 
 
# 数组套对象 
array_of_objects: 
  -  
    field-0: 这是第一个 object 的 field-0 字段 
    field-1:  
  - field-0: 这是第二个 object 的 field-0 字段 
    field-1: 
  - {"field-0": "这是第三个 object 的 field-0 字段", "field-1": null}

TOML

XML、YAML 太复杂了,结果人们又开始追求简化。

TOML 是 GitHub 前 CEO Tom 所创,它的基本语法和 INI 很像,但是它是强类型的,而且增加了不少支持 map 的语法。

它继承吸收了 YAML 的优点,同时抛弃了 YAML 中很多用途不多的东西,比如锚点和引用;逻辑比较复杂容易混淆的地方:如字符串前后缀空格的处理。解析器的实现也比较简单。

它也很好地解决了 JSON 的痛点。

个人比较喜欢这个,可惜 TCC 平台不支持,甚至飞书文档高亮都不支持。

TOML 的官网在 https://toml.io/en/v1.0.0

在 GitHub Wiki 上有目前在用 TOML 的一些项目 https://github.com/toml-lang/toml/wiki#projects-using-toml,比较有名的有 Python 包管理器 pip 和 Rust 包管理器 cargo。

# 数字类型 
integers = 1234_5678 
 
# 字符串 
strings = "字符串儿" 
 
# 多行字符串 
multi-line = """ 
    和 python 的多行字符串完全一致 
""" 
 
# 可换行可不换行,允许尾后逗号 
array = ["你", "好", "世", "界"] 
 
# 对象,其实上面定义的键也是外层大对象中的字段,等号前的都是键 
[object] 
field-0 = "字段0" 
field-1 = "字段1" 
field-2 = {"object-inside-object" = "再套一层对象又何妨", "array-inside-object" = ["但是不样换行"]} 
[object."field-3"] 
object-inside-object = "也可以通过这种方法来增加嵌套层数" 
 
 
# 数组套对象 
[[array_of_objects]] 
field-0 = "数组中的顺序" 
field-1 = "就是对象在文档中出现的顺序" 
 
[[array_of_objects]] 
field-0 = "这是第二个对象"

JSON 在 Go 中的使用

因为我们在用 Go,所以特地放一段来介绍 JSON 在 Go 中的使用,主要是介绍一些扩展和特性。

Go 自带 JSON 解析的标准库 encoding/json,主要围绕标准库的一些行为来介绍。

JSON 的几种类型在 go 中都有对应的类型,比如

  • Object 对应 map[string]interface{}
  • Array 对应 []interface{}
  • Number 是 float64
  • String 就是 string
  • truefalse 就是 true false
  • null 对应 nil

这可以通过将这些 JSON 值解析到一个 interface{} 中看出来。

其中变化比较大的是 Object 类型,它可以被解析到一个 struct 上,标准库会用反射将 Object 的键按名字对应上 struct 的字段,不过会无视私有(小写开头)的字段。如果想自定义 JSON 序列化反序列化的字段名,可以用 Struct Tag。

type Object struct { 
    PublicFields        interface{} 
    ignorePrivateFields interface{} 
    StructTagsRenameKey interface{} `json:"struct tag"` 
    IgnoreSpecialTags   interface{} `json:"-"` 
    HyphenAsKeyFields   interface{} `json:"-,"` 
    // 据我所知 JSON Object 的键是 "-," 就不支持了 
    // 带逗号的就不被支持,因为被用作 tag 里的分隔符了 
    // 如果真的有这种数据,应该只能用 map[string]interface{} 
} 
 
var object Object 
json.Unmarshal([]byte(`{"PublicFields":0,"struct tag":0,"-":0}`), &object) 
object == Object{ 
    PublicFields:        0, 
    ignorePrivate:       nil, 
    StructTagsRenameKey: 0, 
    IgnoreSpecialTags:   nil, 
    HyphenAsKeyFields:   0, 
}

这些都是基础知识,不详细介绍。

Go 对 JSON 的 64 位整数扩展

Go 支持 int64,所以扩展了 encoding/json 中的 Number 类型,使得它可以序列化出精确的 int64 类型,也可以将 JSON 中 2^{53}2^{63} 之间的 Number 类型无损反序列化到 int64 中。负数和 uint64 同理。

当然,反序列化的时候必须指定接收类型是 int64 或者是内部类型是 int64interface{} 类型。如果还是用无类型的 interface{} 的话,自动生成的类型会是 float64,然后丢精度。

业务中有时也会存在只能用 interface{} 作接收类型的情况,如果这种情况还是希望能精确解析 int64 出来,可以使用 json.Decoder 设置 UseNumber,这样数字类型就会被解析成 json.Number 这个特殊类型,然后手动推导是什么类型:

const binary = `{"number":1}` 
buffer := bytes.NewBufferString(binary) 
decoder := json.NewDecoder(buffer) 
decoder.UseNumber()  // 注意这里 
 
var object map[string]interface{} 
if err := decoder.Decode(&object); err != nil { 
    panic(err) 
} 
fmt.Printf("%#v\n", object) 
number := object["number"].(json.Number) 
if value, err := number.Int64(); err == nil { 
    fmt.Printf("Number can be represented as int64, value=%v\n", value) 
} 
if value, err := number.Float64(); err == nil { 
    fmt.Printf("Number can be represented as float64, value=%v\n", value) 
}

Go struct tags 中的 ,string

在一个整数、浮点数、布尔类型的字段后跟 struct tag,只要包含 ,string,那么在 MarshalUnmarshal 的时候,就会帮忙转换成 string 类型和从 string 类型解析出来。

这在处理 int64 的时候极为好用。

举个例子:

type Object struct { 
    Int   int64   `json:",string"` 
    Float float64 `json:",string"` 
    Bool  bool    `json:"Boolean,string"` 
} 
 
// {"Int":"1","Float":"0.5","Boolean":"true"} 
json.Marshal(&Object{ 
    Int:   1, 
    Float: 0.5, 
    Bool:  true, 
})

但是当 int64 在一个切片中 []int64,或者在 map[string]int64 中,那就不行了,这个只能通过下文的 json.Marshalerjson.Unmarshaler 来实现。

为 Go 的结构体扩展字段

之前有这么一个技术上的需求,我们有一个逻辑很重的通用打包函数 Function,拿到一个打包结果 Result。这个 Result 的结构是固定的,在许多业务中被使用。

但是对于某个新的业务需求,我想在 Result 中新增一个字段,甚至修改一个字段的类型(不兼容修改),明显我不可能去改这个通用打包函数 Function 和 Result。但是又不想重写这个 Function 和 Result,因为那工作量太大了。

想到的解决方案就是通过结构体嵌入 embed struct 来解决这个问题,举个例子:

var result *Result = Function() 
myResult := struct { 
    *Result 
    OverwritedFields int64 
    ExtendedFields   string 
} { 
    *Result:          result, 
    OverwritedFields: 1, 
    ExtendedFields:   "", 
} 
json.Marshal(&myResult)

结构体嵌入除了能为 JSON 扩展、修改字段,也可以做到将两个结构体序列化后的字段合并、将一个 JSON 拆到两个结构体等操作。

json.RawMessage

我们经常会有这种需求,根据 JSON Object 中的一个字段,判断剩下的字段分别是什么名称、是什么类型。比如

  1. 当 Version 字段为 1 的时候,我们可能解析到旧的结构里;当 Version 为 2 的时候,我们解析到新的结构中;如
{ 
    "version": 1, 
    "data": { 
        "extra": "{}" 
    } 
} 
// 和 
{ 
    "version": 2, 
    "data": { 
        "extra": {} 
    } 
}

根据 version 来判断解析方式,不同 version 之间存在不兼容的情况。

  1. 当 Type 字段为 “SomeStruct” 的时候,我们可能会解析到名为 SomeStruct 的结构中;当 Type 值为 “AnotherStruct” 的时候,我们可能会解析到名为 AnotherStruct 的结构体中;如
{ 
    "type": "SomeStruct", 
    "data": { 
        "SomeField": "" 
    } 
} 
// 和 
{ 
    "type": "AnotherStruct", 
    "data": { 
        "AnotherField": {} 
    } 
}

根据 type 来判断解析方式,type 可以有数十种,很难把每个 type 的字段都定义到一个大 struct 中维护。

  1. 我们希望对于一系列值在外层套一个 Object,提供一些公共字段,而序列化和反序列化对用户都是透明的,这在一些中间件中可能会比较常见;如
{ 
    "status": 0, 
    "message": "success", 
    "data": { 
        "": "", 
        "1": 1 
    } 
}

我们希望对 data 的处理是对用户透明的,即用户只能拿到 data 这个 Object 的原值(比如拿到 {"":"","1":1} 这堆 []byte)自行解析。当然我们不希望拿到一个 map,那处理起来比 []byte 要麻烦多了。

我们会发现这三种场景都有一个统一的地方,即不希望立即解析内层对象,或根据外层字段来选择不同的解析方式,或将内层对象数据透传给业务自行处理,json.RawMessage 就是为这场景而被创造的。

json.RawMessage 本质上是 []byte。Go 在解析 JSON 时,当碰到 json.RawMessage 为接收类型的字段,它就会把这个字段对应的所有数据原封不动地填入 json.RawMessage 中,然后继续处理其它字段。

拿案例 3 来举个例子,因为案例 3 我们需要将 data 中的数据原封不动返回给业务。虽然我们还是可以通过将 data 定义成 map[string]interface{}UnmarshalMarshal 的方法来传递,但是这具有一定危险性——万一数据中存在 int64,那不经过一番处理的话信息就容易丢失。

这里用 json.Message 就很方便了:

type ( 
    Endpoint func(ctx context.Context, req []byte) (resp []byte, err error) 
    Middleware func(next Endpoint) Endpoint 
) 
 
type Wrapper struct { 
    Status  int             `json:"status"` 
    Message string          `json:"message"` 
    Data    json.RawMessage `json:"data"` 
} 
 
func myMiddleware(next Endpoint) Endpoint { 
    return func(ctx context.Context, req []byte) ([]byte, error) { 
        var wrapper Wrapper 
        json.Unmarshal(req, &wrapper) 
        resp, err := next(ctx, wrapper.Data) 
        // err 处理 
        data, _ := json.Marshal(&Wrapper{ 
            Status:  0, 
            Message: "success", 
            Data:    resp, 
        }) 
        return data, nil 
    } 
}

不过对于上面的情况 1 和 2,都还有一种解决方法,即先定义一个只有 version 或者 type 的类型,提前把类型信息提取出来,然后对不同的 version 或 type 分别处理:

data := []byte(`{"version":2,"data":{}}`) 
var version struct { 
    Version int32 `json:"version"` 
} 
switch json.Unmarshal(data, &version); version { 
case 1: 
    var wrapper struct { 
        Data *StructV1 `json:"data"` 
    } 
    if err := json.Unmarshal(data, &wrapper); err != nil { 
        panic(err) 
    } 
    // 处理 wrapper.Data 
case 2: 
    var wrapper struct { 
        Data *StructV2 `json:"data"` 
    } 
    if err := json.Unmarshal(data, &wrapper); err != nil { 
        panic(err) 
    } 
    // 处理 wrapper.Data 
}

json.Marshalerjson.Unmarshaler

json.Marshalerjson.Unmarshaler 是标准库定义的两个接口类型:

  • 实现了 json.Marshaler 的类型,就可以自定义它的序列化方法;
  • 实现了 json.Unmarshaler 的类型,就可以自定义它的反序列化方法;

它们的接口非常简单粗暴:

type Marshaler interface{ 
    MarshalJSON() ([]byte, error) 
} 
 
type Unmarshaler interface{ 
    UnmarshalJSON([]byte) error 
}

Marshaler 需要我们将结构序列化为一串 []byte,这串 []byte 会被直接拼接到最终的结果中。

Unmarshaler 会给我们一串 []byte,我们将这串 []byte 的值填充到结构体里就完成了反序列化。

json.RawMessage 本质是一个 []byte,但是它实现了 MarshalerUnmarshaler,它的作用就是在序列化的时候,把自己直接填到结果里;在反序列化的时候,将对应的二进制数据填到自己身子里。

我一般用 json.Marshalerjson.Unmarshaler 来做下面的工作:

  1. 实现将 []int64 类型编码成 JSON 中的 String Array(这用上面提到的 ,string struct tags 是做不到的)
// 返回拷贝, 防止被外部修改
func null() []byte {
    return []byte{'n', 'u', 'l', 'l'}
}

type Int64s []int64 
 
func (is Int64s) MarshalJSON() ([]byte, error) {
    if is == nil {
        return null(), nil
    }
    ss := make([]string, len(is))
    for k, i := range is {
        ss[k] = strconv.FormatInt(i, 10)
    }
    return json.Marshal(ss)
}

func (is *Int64s) UnmarshalJSON(data []byte) error {
    if bytes.Equal(data, null()) {
        return nil
    }
    var ss []string
    err := json.Unmarshal(data, &ss)
    if err != nil {
        return err
    }
    rs := make(Int64s, len(ss))
    for k, s := range ss {
        rs[k], err = strconv.ParseInt(s, 10, 64)
        if err != nil {
            return err
        }
    }
    *is = rs
    return nil
}
  1. 将 Unix 时间戳直接解析成 time.Time 和将 time.Time 序列化为 Unix 时间戳:
// Unix 时间戳 
type UnixTs struct{ time.Time }

func (ut UnixTs) MarshalJSON() ([]byte, error) {
    if ut.IsZero() {
        return null(), nil
    }
    return []byte(strconv.FormatInt(ut.Unix(), 10)), nil
}

func (ut *UnixTs) UnmarshalJSON(value []byte) error {
    if bytes.Equal(value, null()) {
        ut.Time = time.Time{}
        return nil
    }
    timestamp, err := strconv.ParseInt(string(value), 10, 64)
    if err != nil {
        return err
    }
    ut.Time = time.Unix(timestamp, 0)
    return nil
}

type EsDate struct{ time.Time }

func (ed EsDate) MarshalJSON() ([]byte, error) {
     if ed.IsZero() {
         return null(), nil
     }
     return []byte(strconv.FormatInt(ed.UnixNano()/int64(time.Millisecond), 10)), nil
 }

func (ed *EsDate) UnmarshalJSON(value []byte) error {
    if bytes.Equal(value, null()) {
        ed.Time = time.Time{}
        return nil
    }
    ms, err := strconv.ParseInt(string(value), 10, 64)
    if err != nil {
        return err
    }
    ed.Time = time.Unix(0, ms*int64(time.Millisecond))
    return nil
}
  1. 解析 SQL 标准定义的时间戳类型:
const SqlTsLayout = "2006-01-02 15:04:05.999999999" 

type SqlTs struct{ time.Time }

func (st SqlTs) MarshalJSON() ([]byte, error) {
    if st.IsZero() {
        return null()
    }
    return []byte(st.Format(SqlTsLayout)), nil 
}

func (st *SqlTs) UnmarshalJSON(data []byte) error {
    if bytes.Equal(data, null()) {
        ed.Time = time.Time{}
        return nil
    }
    time, err := time.Parse(SqlTsLayout, string(data)) 
    if err != nil { 
        return err 
    } 
    st.Time = time 
    return nil 
}

题外话

json.Marshalerjson.Unmarshaler 的接口抽象方式我并不是很喜欢,因为它暴露了太多 JSON 细节给用户,而且(或者说因此)也存在破坏 JSON 结构的危险。

前者换句话说,它的抽象不足,用户还要处理底层二进制数据流。后者是前者的果,比如我序列化的时候处理错误,往结果里塞了一个没闭合的 " [ {,那整个 JSON 的结构就被毁掉了。

就算 json.Marshal 会拦住程序员的错误结果,这也只能在运行时才能发现。而这本身就不该留有这种余地。

一般来说,一个设计良好的 JSON 库都是定义一套 JSON 的数据类型,继承自一个比如说 JsonElement 这样的类型。

库应当提供接口将字符串数据解析成

  1. 用户指定的数据结构
  2. JsonElement

同时也提供接口,能将默认的几种数据类型转换成:

  1. 字符串数据
  2. JsonElement

同时也提供 SerializerDeserializer 这样的 interface,允许用户自定义序列化和反序列化方法,但是参数或返回值不是 []byte 了,而应当是 JsonElement

在序列化,即实现 Serializer 的时候,用户将数据组装成一个 JsonElement 并返回;

在反序列化,即实现 Deserializer 的时候,用户从 JsonElement 中类型断言出对应的 JSON 数据类型,然后解析成需要的结构;

这样才是比较好的抽象。

后记

这段不是演讲内容,总之,劳动节快乐吧。

发表评论