JSON及其相关变体
背景
在 序列化方法综述 中,我们说到了文本格式的序列化方法,其中通用的两种文本格式就是 JSON 和 XML。
xml 的全称为:可扩展标记语言。既然能称为语言,描述一个数据自然也是比较简单的。但是 xml 的格式中使用 <tag></tag>
作为分隔符,而且还可以在 tag 中加入 options,比如 <name charset="ascii">longalong</name>
这样。好处是扩展性比较广,甚至能在此基础上建立起一套应用层协议,比如 SOAP、SAML。当有比较复杂的数据定义时,用 xml 是一个不错的选择,业界也有一些大型项目使用 xml 作为数据格式,比如 jmx (jmeter 脚本文件)、xlsx (excel 文件)。
xml 因扩展性强而受欢迎,但扩展性强的另一面则是复杂性。与之相反的 json 则是因为简单而受到大多数项目的青睐。
JSON 特征及其优劣
直观地看 JSON,格式如下:
1 |
|
从上面的结构可以得出一些基本特征:
- 基本数据类型 ( key 仅能为 string,value 可为 number、string、bool、null )
- 容器类型 (object、array)
- 数据分隔方式 (大括号、中括号、引号、冒号、逗号)
- 不支持引用、不支持注释
- 无 schema,可随意嵌套 (自描述,通过 key 、类型、嵌套格式)
json 的特点也就决定了其优点和不足:
- 优: 简单可读 (文本分隔、格式清晰)
- 劣: 嵌套层数越多,数据密度越低 (引号、括号、逗号 等等分隔比较多)
- 劣: 序列化和反序列化性能均较低 (均是 O(n) 的复杂度,必须读完所有内容)
- 劣: 对数的表示精度不够、没有更多的高级类型 (例如 date、binary、big int 等)
关于 json 的数的精度问题,是由于 json 的数不区分 float64 和 int64,因此,对于一个 int64 的大数,在底层数据的表示上,是把初符号位之外的几乎所有位都用 1 占了。但对于一个 float64 而言,不仅要存数的大小,还要存小数点的信息,常用使用 IEEE754标准将64位分为三部分:
- sign,符号位部分,1个bit 0为正数,1为负数
- exponent,指数部分,11个bit
- fraction,小数部分,52个bit
从理解上,可以认为 exponent 部分存的是小数的位置。
也就意味着,在 json 中,浮点数能表示的数的最大范围仅有 2^53 (加上符号位) (因为 json 是 js 的子集,js 的数表示方法用的 IEEE754 标准)。另外,一个数在 json 中传递时,如果你不知道它是整数还是浮点数,就可能造成把整数当做浮点数解析,也就造成了数值错误。
需要知道的是,这并非意味着 json 本身表达不了 int64 的大数 (作为文本格式,底层上它可以表示任何数),而是在很多 64位的系统中,不同的语言默认解析 json 中数字的标准就是 IEEE754,也就导致了丢失精度,甚至可能结果不一致,比如 js 和 golang 之间,根本原因还是 json 没有可扩展的数的类型。
当然,解决办法也是有的,用数字的十进制字符串就可以了,但这也增加了各端的类型适配工作,不如原生支持来得方便。
json 的二进制优化
由于 json 的文本格式特性以及用了大量文本分隔符,导致存储的数据大小较大,再加上仅支持几个基础数据类型,导致很多场景不太适用。于是就催生了参照 json 的二进制优化方案,其中最常见的就是 bson 和 msgpack 两个。
其实从广义上看,各种其他的二进制序列化方法也可以认为是参照 json 的优化,本质都是对可嵌套的数据结构表示方法的优化。这里单独把这两个方案拎出来,主要还是由于这两个保留了 json 的 schema-less 的特性,其他二进制序列化中,大都抽离了 schema。
bson
bson 的全称是 Binary JSON
,顾名思义,就是把 json 的一部分用二进制去表示了,这也是 bson 设计的基本思想。详细的信息在 spec 中描述得很清楚了,下面用一个形象的例子获得一些感官认识:
从结构上,对比 json,主要有以下优化:
- json 中用「大括号」和 「中括号」 表示的嵌套类型;bson 中使用 「长度」 + 「类型」+「结束符 00」来表示。 主要的优势是反序列化会更快。
- json 中使用 「冒号」表示 key 和 value 的分隔;bson 中使用 「结束符 00」来分隔。好处是可以去除对 json 中必须用 「冒号」把 key 包起来的问题 (因为key本身可能存在冒号)。
- json 中使用 「逗号」表示兄弟节点的分隔;bson 中使用「类型」先做标识,对于长度不确定的类型(字符串), 则再使用「长度」+「结束符 00」来表示。好处是反序列化更快。
- bson 增加了更多的细节类型,主要如下:
- 数的类型细化到 int32、int64、uint64、double、decimal128
- 原始类型扩充了 byte 和 binary
- 增加了 timestamp、datetime、UUID、MD5 等子类型(本身还是用 int 和 binary 表示,但赋予了类型描述)
对比 bson 的不足,可以发现两个方面: ① bson 相对于 json 在可读性上几乎没有了 ② bson 对数的编码没有做压缩
以下是一些可以参考的资料,其中 mongodb 官方的 bson 实现封装得比较好,可以详细看看。
spec: https://bsonspec.org/
mongodb 文档: https://www.mongodb.com/json-and-bson
bson 实现1: https://github.com/mongodb/mongo-go-driver/tree/master/bson
bson 实现2: https://github.com/go-mgo/mgo/tree/v2/bson
格式解析可以参考: https://cloud.tencent.com/developer/article/2058958
一个 update 单个字段的技巧: https://github.com/chidiwilliams/flatbson
msgpack
对于 schema-less 的序列化优化方式,从 json 的劣势就可以推导出,主要方向都是: ① 增加更多类型支持 ② 分隔符用更精简的方式代替 ,msgpack 也不例外。
类型系统中,msgpack 使用一个 byte 标识每一个字段的具体类型,具体类型标识可以查看 msgpack spec ,总共 37 种。 msgpack 的一个有意思的特性是,类型系统的类型上限是 256,其中 0-127 的数据段留给了自定义类型,也就意味着 msgpack 提供了原生的自定义类型扩展能力,或许在一些场景中能比较好地使用,比如 URL、date、email、phone、uuid 等等。
基础数据类型包括: 整数、浮点数、nil、bool、raw (string、bytes), 容器类型包括 array、map,以及自定义类型(本质也是 bytes)。
msgpack 直接在类型中,就区分了不同长度的数据,比如 整数,就直接分成了 uint8、uint16、uint32、uint64、int8、int16、int32、int64。 其序列化之后的格式如下:
1 |
|
摘自官方 spec
从这里的思路可以看出,msgpack 对于基本类型,习惯使用 「类型表达了特定长度」的方式作为分隔依据,从效率上会比「结束符」更高。
对于非基本类型,或者说对于非固定长度的类型,比如 string、array、bytes、map,则是采用 「类型 + 长度 + 数据」的方式,以 string 为例,其序列化后格式如下:
1 |
|
摘自官方 spec
值得注意的是,对于 map 类型,「长度」并非指数据长度,而是指 map 中的 items 的数量。 这也就意味着,反序列化时,这种结构是无法直接获取到 map 整体长度的,只能一个一个 key 顺序遍历到最后。
整体而言,msgpack 实现了更精简的对象序列化,数据密度比 json 更高,类型的扩展性也更好。相对不足之处就是 map 的遍历问题。
可参考文档:
spec: https://msgpack.org/
一个 golang 实现: https://github.com/vmihailenco/msgpack
json 的模板化语言 jsonnet
json 因其简单易读的特性,常被用于各种配置文件,比如很多后端服务都会有如下配置信息:
1 |
|
一些简单的配置,用原生的 json 即可,但往往配置项多了之后,配置项之间很容易产生关联关系,比如上面的例子中,server.mode 的值如果为 “debug”,就需要 log.level 改成 “debug”,并且希望 output 直接为 “/dev/stdout”。
为了实现上面的需求,我们能直接想到的方案可能是 template,各编程语言大都会提供模板语法,比如 golang 中官方的 go template、nodejs 社区提供的 jade/ejs 、python 的 jinja 等等。
用过模板语言的开发者大都会觉得模板语法写起来十分恶心,状态理解不清 + 调试不便 是最大的阻碍,这其实是 code in template 这种模式的通病(ansible 不方便的地方也在于此)。
另一种方案是 template in code,也即在编程语言的代码中去拼接 template,好处是编程语言的可调试性更好,但坏处则是数据量大了之后,就需要对拼接的逻辑进行拆分,拆完之后整个体系也很庞大,理解代码逻辑也比较复杂。
那设想一种可能性,一种编程语言剔除复杂的代码逻辑,专门为了生成 json 模板而存在,仅保留生成 json 所需要的语法,让模板和代码结合得更友好,是不是听起来很棒呢?
这就是 jsonnet 的诞生背景。
用 jsonnet 重新实现上面的配置:
1 |
|
结果如下:
编程语言的能力完整性,我们一般使用「图灵完备」来表示,一个语言被认为是图灵完备的,需要满足以下条件:
命令式(Imperative)和函数式(Functional)能力: 可以执行顺序命令和控制流程,还可以定义函数或者递归调用函数来解决问题。
条件和循环: 语言必须具备条件语句(例如 if-else、switch)和循环结构(例如 for、while),以便进行条件判断和重复执行任务。
内存访问: 具备对内存或数据结构进行读写、修改的能力。
无限存储和无限时间: 能够处理无限的数据和运行时间。尽管实际计算机资源有限,但从理论上来说,图灵完备的语言能够模拟无限的存储和运行时间。
支持条件判断和跳转: 具备能够根据条件进行程序跳转的能力,比如跳转到指定的代码块或执行不同的代码路径。
递归性质: 支持函数的递归调用,可以通过递归实现循环和迭代算法。
jsonnet 实际上是一个图灵完备的语言,所以从能力上,它可以实现我们现在能理解的几乎所有的处理逻辑 (图灵机能处理的计算)。
核心有如下特性需要掌握:
- 变量 (数据类型)
- 数值操作
- if 判断
- for 循环
- function
- 包管理模块 (import)
这些都是一门语言的基石,可以从 language 查看细节。
另外,从使用的便利性上,官方提供了一些标准库方法,比如对 string 的处理、数学计算 等等,掌握了这些方法就能更从容应对各种场景,可以从 stdlib 查看更多细节。
对于大型复杂的基于配置的系统,jsonnet 是一个很不错的方案,至少一定程度上比 template 方案更加清晰,看过 helm charts 的 template 之后,估计大家都更能理解到这个工具的价值了。
可参考文档:
golang 实现: https://github.com/google/go-jsonnet
spec: https://jsonnet.org/
json 的超集 yaml
经常使用 json 作为配置使用时,可能会感受到 json 的一些不方便之处:
- 无法对字段添加注释
- 长字符串无法清晰地表示 (只能用
\n
这种比较别扭的方式) - 层级越多,层级的关系就越看不清楚
此时,另一种能够完全转化成 json 的格式 yaml 就是不错的选择。从理解上,yaml 的所有数据格式都能通过一定方式转换成 json 格式,因此可以认为 yaml 是 json 的一个超级。
以下是一个 yaml 格式的数据:
1 |
|
yaml 的基本特性有这些:
- 使用缩进代表层级关系
- 使用「冒号」作为 kv 分隔符
- 支持 bool、int、float、str、map、array、null 几种基本数据类型 (还有一些比较少用的特殊类型)
- map 兼容 json 的写法
{xx: xxx}
- array 兼容 json 的写法
[item1, item2]
- 除了 bool 和 数字外,其他都会被当做 str 处理
- 使用
#
作为注释的起始符 - 使用
|
、|-
、|+
、>
这些符号作为多行文本的处理 - yaml 使用
& + name
表示引用锚点,使用* + name
表示引用,使用<<: * + name
表示内嵌 - 使用
---
在一个文件中表示多个 yaml 文档分隔
大多数时候,我们使用这些就够了,但其实 yaml 还有一些特殊的语法,比如 tag
,要使用特殊语法时,还需要慎重,毕竟越复杂越容易出错。
可参考文档:
json schema
我们一直在说 json 是一个 schema-less 的结构,这是从 json 本身的特性出发的。但从另外一个角度看,schema 也有着非常大的作用,比如 值校验
、语法提示
,因此,一个可选的 schema 方案就诞生了 —— json schema
json schema 也是使用一个 json 去描述另一个 json 的约束,当前最新版本的格式可以查看 https://www.learnjsonschema.com/2020-12/
对于一个 schema 而言,主要需要实现以下能力:
- 字段描述 (描述、是否可读写、默认值、是否已弃用 等)
- 类型校验
- 值校验
- 格式校验 (pattern、format)
- 大小校验 (mininum、maximun)
- 数量校验 (minContains)
- 内容校验 (oneOf、not、unique……)
- 必填校验 (required)
- schema 引用 (类似与 import)
如果我们用 json schema 描述上面的 server config ,就成了如下内容:
1 |
|
对比看一下:
1 |
|
可参考文档:
golang 的实现: https://github.com/santhosh-tekuri/jsonschema
官网: https://json-schema.org/
基于 openapi 的 http 接口定义
json schema 的应用场景中,最典型的就是对 http api 定义了。
最常规的 http 接口大都使用 json 作为数据传输格式,而接口的接收参数和响应值也都有其特定的格式,若能结合 schema 定义的方式来描述接口,在「数据格式校验」、「文档生成」、「用例编写/测试」以及「代码生成/提示」这几个方面都有重要意义。
这个场景,就是 openapi 诞生的基础。
openapi 是一个建立在 json-schema 之上,用来描述一个 http 请求的格式约定,常用的有 openapi 2.x 和 openapi 3.x,是目前最通用的接口定义方式,几乎所有的接口管理平台都会支持,比如 yapi、postman、apipost、metersphere 等等。
从使用场景上,主要用于 「接口文档生成」和「client 代码生成」两个方面,前者最常用的工具是 swagger UI,提供了文档查看页面、接口 mock、请求调试 等等;后者在不同语言下都有各自的实现,例如 golang 下的开发框架 go-kratos 和 go-zero 都有对应能力,当然,swagger 也有一些统一的实现 Swagger Codegen。实现代码生成的核心能力,基本都是建立在 模板
的基础上,所以大都提供了自定义模板的能力,以便生成符合自己需要的代码。
openapi 能生成代码,同样反过来,我们也经常有从代码生成 openapi 的需求。 这样的好处是,文档本身就是从代码逻辑中提取出来的,因此研发人员不用单独维护接口文档,这个需求在代码变更频繁的时候尤其突出。
解决这个场景的工具有两类,其一,swagger 在各语言下的实现,基于注释完成 openapi 文档的编写; 其二,在一些开发框架中,会直接将接口的 meta 信息放到接口定义的代码中,这是一种比 注释
更精确可控的信息管理方式,在 golang 中如 goframe 就是采用该方案 (类似于 php 的 laravel 框架)。
【缺少一个实践】
可参考文档:
spec v3.1: https://spec.openapis.org/oas/v3.1.0
openapi 官网: https://www.openapis.org/
swagger 官网: https://swagger.io/
Everything that happens as it should, and if you observe carefully, you will find this to be so.
— Marcus Aurelius
本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!