JSON 与 Protobuf 学习对比
前言
JSON 和 Protobuf 作为数据交互的格式,在项目中被频繁使用。尤其在 gRPC-Gateway 中更是与这两者密不可分,希望通过本次分享内容的沉淀和总结能够帮助团队同学加深对二者的了解。本文档将会简要的介绍它们的原理、编解码方式、及优缺点。
JSON
JSON 是一种轻量级的数据交换格式。它基于 JavaScript Programming Language , Standard ECMA-262 3rd Edition - December 1999的一个子集 。采用完全独立于编程语言的文本格式来存储和表示数据。
简洁和清晰的层次结构使得 JSON 成为理想的数据交换语言。 易于人阅读和编写,同时也易于解析和生成,广泛的支持浏览器与操作系统的兼容性,使得它被广泛使用。
标准库 encoding/json 的编解码
以一个简单的结构体入手
type Req struct {
Id int32 `json:"id"`
Name string `json:"name"`
Cards []string `json:"cards"`
Height float64 `json:"height"`
Loc location `json:"loc"`
}
type location struct {
X int32 `json:"x"`
Y string`json:"y"`
}
JSON Marshal 的流程
- 使⽤递归的解析结构体内所有的字段,⽣成字节序列,
- 尽可能的缓存可复⽤的中间状态结果,以提⾼性能。
利⽤ Golang 反射,递归调⽤,最终会形成树状调⽤结构体,对上述的结构体对应的对象,其最终的递归树状图如下:
- 结构体定义为顶点 v ,类似于广度优先搜索,对复合类型的 filed 继续递归调⽤ typeEncoder ,获取所有的 类型编码器 encoderFunc 。
- 启动类型编码器(上图所示的各种 Encoder 对应的 encodeFunc **)**调⽤,依赖类型编码器函数内部递归,从根节点依次调⽤整棵树的序列化函数。递归遍历结构体树状结构,递归的结束条件是最终递归⾄基本类型,⽣成基本类型编码器,生成 JSON 字符编码。
递归是为了处理复杂类型,如ptr编码器,通过 t.Elem() 递归调用 typeEncoder 获得其子元素的基本类型编码器 encoderFunc
func newPtrEncoder(t reflect.Type) encoderFunc {
enc := ptrEncoder{typeEncoder(t.Elem())}
// 返回基本类型编码
return enc.encode
}
官方使用了多处缓存做优化:
func Marshal(v any) ([]byte, error) {
// 作缓存优化
e := newEncodeState()
// 序列化
err := e.marshal(v, encOpts{escapeHTML: true})
...
// 最后直接获取e.bytes()。
buf := append([]byte(nil), e.Bytes()...)
// 将e放入sync.poll内
encodeStatePool.Put(e)
}
- 序列化中,会被缓存为 encodeState 结构体对象,⾸要作⽤是存储字符编码,其内部包含了 bytes.Buffer ,如果不断的释放⽣成 新的 bytes.Buffer ,会降低性能。在递归过程中不断写入 buffer ,最后获取输出。对缓存起到了一定优化
- encodeState 结构体被放 进 sync.poll 内(var encodeStatePool ),来保存和复⽤临时对象,减少内存分配, 降低 GC 压力。
- 对于已经解析过的结构体,会缓存下来作为优化,解析过程再碰到可以直接使用。
- 结构体内部分成员属性是必然保持稳定,缓存⼀些预处理的结果,在最终递归⽣成 JSON 字符串时候使⽤。
- 某个域,是否需要被序列化。
- 每个域的对应 encoderFunc 。
- 序列化成 JSON 时候的 key 值,如 Req 内 name 域将被预处理为 “name” 。
- 是否是嵌套其他 struct 等特性。
全局缓存中 Type 是唯⼀的,其⽤于序列化的属性是稳定不变的,可以全局通⽤。
func typeEncoder(t reflect.Type) encoderFunc {
if fi, ok := encoderCache.Load(t); ok {
return fi.(encoderFunc)
}
JSON Unmarshal 的流程
反序列化实现方式也和序列化有些不同,不同在于从分析它的数据类型变为按顺序去解析,通过解析对应特殊符号来得到想要的对象
具体步骤:
- 首先会缓存为 decodeState 结构体对象。
- 循环遍历结 decodeState 构体对象,找到其中的 key 值之后再找到结构体中同名字段类型。
- 递归调用 value 方法反射,设置结构体对应的值,直到遍历到 JSON 中结尾 } 结束循环。
JSON 的问题
1.对于 int64, JSON 并不支持,其原因主要为:
- JSON 是基于 JavaScript Programming Language, Standard ECMA-262 3rd Edition - December 1999 的一个子集 。
- Javascript 的数字存储使用了 IEEE 754 中规定的双精度浮点数数据类型,而这一数据类型能够安全存储 -(2^53-1) 到 2^53-1 之间的数值(包含边界值)。JSON 是 Javascript 的一个子集,所以它也遵守这个规则。
- (拓展)“安全”意思是说能够 one-by-one 表示的整数,也就是说在(-2^53, 2^53)范围内,双精度数表示和整数是一对一的,反过来说,在这个范围以内,所有的整数都有唯一的浮点数表示,这叫做安全整数,
2^53是这样存的:
符号位:0 指数:53 尾数:1.000000...000 (小数点后一共52个0)
2^53-1是这样存的:
符号位:0 指数:52 尾数:1.111111....111 (小数点后一共52个1)
2^53+1:
符号位:0 指数:53 尾数:1.000000...000 (小数点后一共52个0)
注意到,2^53+1的存储和2^53一样。这样就不再“一一对应”。故安全整数就是 -(2^53-1) 到 2^53-1之间
所以对于大整数,JSON 只能采用 string 进行存储。
- JSON 不能传递二进制数据。如果想要传输图片等二进制文件的话,是没办法直接传输。
Protobuf
Protobuf 即 Protocol Buffers ,是一种轻便高效的结构化数据存储格式,与语言、平台无关,可扩展可序列化。
Protobuf 性能和效率大幅度优于 JSON、XML 等其他的结构化数据格式。Protobuf 是以二进制方式存储的,占用空间小,相对于 JSON 更省空间。
Protobuf 的一个典型应用场景便是做通信的数据交换格式
Protobuf 编码原理
前面提到 Protobuf 更省空间,那 Protobuf 又是如何做到的呢?
这就不得不提到它采用的编码方式。
Protobuf message 是一系列的 键值对( TLV or TV ) 组成,
数据形式大致如下:
- tag**:length + value :(type2)**
- tag**:value (type0、1、5)**
当 Protobuf message 编码时,每一个键值对转换成一条 record 由 tag (wire type、field num)和数据组成
如下图所它的六种 wire type
( 编码方式:varint 即 Base 128 Varints 、I64 即 64-bit、I32 即 32-bit、LEN 即Length-Delimited )
( 注3、4已弃用 )
再具体讲解之前可能会有这样的疑问,为什么 proto 文件要有序号?且序号是否可以随便使用?
举个例子:
序号1的 tag :
0000 1000
tag 的定义是:
field_num << 3 | wire type
通俗来讲就是:
最后三个 bit 位表示 wire type 也就是上述提到的六种 wire type 类型,000 表示 0 也就是 varint,
然后将我们剩余的0 0001 红色部分拿出来得到我们的 field_number ,蓝色部分暂且按下不表。
因此我们的序号就必须存在,他是我们数据唯一标识的组成部分,所以十分重要。Protobuf 最终的编码结果是抛弃了所有的字段名,仅仅保留了字段的序号、类型和数据的值。
同时序号大小也影响着编码大小,所以为了压缩数据,应尽量使用小的序号。
但是四个 bit 位仅仅能表示到15也就是1111 ,那如果序号超过15,又是如何表示呢?
Varint 编码
变长整型编码的基本思想:根据数值的大小来动态地占用存储空间, 使得值比较小的数字占用较少的字节数, 值相对比较大的数字占用较多的字节数。
采用变长整型编码的数字, 其占用的字节数不是完全一致的, Varint 编码使用每个字节的最高有效位作为标志位, 而剩余的 7 位以二进制补码的形式来存储数字值本身。
- 当最高有效位为 1 时, 代表其后还跟有字节
- 当最高有效位为 0 时, 代表已经是该数字的最后的一个字节
在 Protobuf 中, 使用的是 Base 128 Varints 编码
在 Protobuf 中, Base128 Varints 采用的是小端序, 即数字的低位存放在高地址,
举个例子:
对于数字 1, 假设 int 类型占 8 个字节,
以标准的整型存储, 其二进制表示应为
00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000001
可见, 只有最后一个字节存储了有效数值, 前 3 个字节都是 0, 若采用 Varint 编码, 其二进制形式为
00000001
因为其没有后续字节, 因此其最高有效位为 0, 其余的 7 位以补码形式存放 1
需要注意的是 tag 的编码也是采用 Varint 编码,现在就可以回答前面的问题,蓝色部分0代表已没有后续字节,如果序号超过15 ,则需要增加到两个字节去表示,所以序号的大小会改变编码的长度。
message Person {
required int32 id = 17;
}
举个例子 序号为17 ,类型为int32,赋值666 的编码结果如下,让我们尝试对它 Varint 解码:
10001000 00000001 10011010 00000101
同样,第一个字节的后三位bit 表示类型 0 即 用 Varint 编码的类型
截去符号位 剩余 0001 0000001 将他们交换字节序 10001 转化成 10 进制 就是我们的17
看最高有效位, 高 8 位的最高有效位为 1, 代表其后还跟有有效字节, 低 8 位的最高有效位为 0, 代表其已是最后一个字节, 由于 Protobuf 采用小端字节序存储数据, 因此我们移除两个字节的最高有效位, 并交换字节序便得到
1010011010
转换为十进制, 即是数字 666。
此时又出现了问题,如果我的赋值不是正数,而是负数,那又是如何压缩编码的呢?
10000000 00000000 00000000 00000001
Zigzag 编码
Varint 编码的实质在于去掉数字开头的 0, 因此可缩短数字所占的存储字节数。
但如果数字为负数, 则采用 Varint 编码会恒定占用 10 个字节, 原因在于负数的符号位为 1, 对于负数其从符号位开始的高位均为 1,
Protobuf 里引入了 sint32/sint64 类型,只有声明为这两个类型才会对负数先使用 Zigzag 编码,否则直接使用 Varint 编码。
ZigZag 按绝对值升序排列,将整数 hash 成递增的32位bit流,Zigzag 编码用无符号数来表示有符号数字,正数和负数交错
一句话总结就是正数扩大成2倍,负数取反加1,形如拉链一样,这也是 ZigZag 编码名字的由来
为了统一两种方式,并效仿 Varint 的压缩优势,减少 ZigZag 的字节数。
- sint32 被编码为(n<<1) ^ (n>>31) 对应的 Varint ;
- sint64 被编码为 (n<<1) ^ (n>>63) 对应的 Varint ;
这样,绝对值较小的整数只需要较少的字节就可以表示。
因此,Protobuf 对于正数的编码采用 Varint ,对于负数的编码采用 ZigZag 编码后的 Varint 。但 Protobuf 也无法自动识别正数负数并做出不同的编码方式的选择,所以需要
在 .proto 结构定义文件中,如果是 int32、int64、uint32、uint64 采用 Varint 的方式,如果是sint32、sint64采用 ZigZag 编码后的 Varint 的方式。
其他编码
当数据类型为 fixed64,sfixed64,double 时,变量值采用的是64-bit编码方式;即固定占用8个字节传输。
当数据类型为 fixed32,sfixed32,float 时,变量值采用的是 32-bit 编码方式;即固定占用4个字节传输。
再来回顾这张表,type 0、1、5 已经做了简要的介绍,还剩下 type 2 :the LEN wiretype
它的编码方式也十分简单
- tag+len + value
tag :field_num << 3 | wire type
len :其后数据的字节数
value :数据
对于 string 类型 ,采用 utf-8 编码
对于 bytes 类型,任何的 8bit 序列;
packed repeated 与 repeated 的区别在于编码方式不一样,repeated 将多个属性类型与值分开存储。而 packed repeated 采用 Length-delimited 方式。下面这个是官方文档的例子:
message Test4 {
repeated int32 d = 4 [packed=true];
}
22 // tag (field number 4, wire type 2)
03 // payload size (3 bytes)
03 // first element (varint 3)
8E 02 // second element (varint 270)
//如果没有packed的属性是这样存储的:
20 // tag(field number 4,wire type 0)
03 // first element (varint 3)
20 // tag(field number 4,wire type 0)
8E 02 // second element (varint 270)
proto3 的 repeated 默认就是使用 packed repeated 这种方式来存储
序列化结果对比
数据分别用 JSON 和 Protobuf 来进行表示
下面是其数据格式:
message Req {
int32 id = 1;
string name = 2;
repeated string cards = 3;
double height = 4;
location x = 5;
}
message location{
int32 x = 1;
int32 y = 2;
}
数据内容为
{
"id": 4,
"name": "ming",
"cards": [
"工商银行",
"招商银行",
"建设银行"
],
"height": 1.85,
"x": {
"x": 10,
"y": 11
}
}
序列化后的结果为
- json:
- protobuf
从上图我们可以直观的感受到两者序列化后的结果差异:
- JSON 为了保证格式的正确和自解释的功能,序列化后的结果还包含了很多格式字符,包括{ " , }等,还包括了字段名本身,导致编码结果相比 Protobuf 大很多。
- Protobuf 使用了 Varint 极大地压缩了编码,且抛弃了所有的字段名,仅仅保留了字段的序号、类型和数据的值,使得 Protobuf 编码大小远小于 JSON 。同时也导致可读性差,自解释性差 ,序列化&反序列化 Protobuuf , 需要有 Protobuf 源文件才可以进行 ;
gRPC-Gateway 对于 Protobuf 与 JSON 的转换流程
gRPC 常用于服务端之间的相互调用,如果想把服务暴露给前端,可以选择 gRPC-Gateway 方式来快速将 gRPC 服务以 Http 的方式暴露出来;
gRPC-Gateway 原理如下图:
JSON & Protobuf 转换流程
- gRPC -Gateway 使用 marshal_jsonpb 作为解析器( 默认为 protobuf 的 encoding/protojson ) 将传入的 HTTP JSON 解析成 protobuf message 然后发出一个 gRPC Client请求
- gRPC Client 将 Protobuf message 编码为 Protobuf 二进制格式,并将其发送到 gRPC 服务器,
- gRPC Server 处理请求并以 Protobuf 二进制格式返回响应。
- gRPC Client 将其解析为一个 Protobuf 消息,并将其返回给 gRPC-Gateway,后者将 Protobuf 消息编码为 HTTP JSON 并将其返回给客户端。
encoding/protojson 详解
marshal(Protobuf → JSON)
//marshalValue marshals the givenprotoreflect.Value.
func (e encoder) marshalValue(val pref.Value, fd pref.FieldDescriptor) error {
switch {
case fd.IsList():
return e.marshalList(val.List(), fd)
case fd.IsMap():
return e.marshalMap(val.Map(), fd)
default:
return e.marshalSingular(val, fd)
}
}
// marshalList marshals the given protoreflect.List.
func (e encoder) marshalList(list pref.List, fd pref.FieldDescriptor) error {
// 写入[
e.StartArray()
// defer 写入 ]
defer e.EndArray()
for i := 0; i < list.Len(); i++ {
item := list.Get(i)
if err := e.marshalSingular(item, fd); err != nil {
return err
}
}
return nil
}
- 遍历 protobuf message 的每个 filed ,依次写入 key/value ,利用反射,根据 value 的类型调用不同的 marshal 方法,写入 JSON 字符串。
Unmarshal(JSON → Protobuf )
func (d decoder) unmarshalMessage(m pref.Message, skipTypeURL bool) error {
...
// 拿到json串的第一个字符{
tok, err := d.Read()
if err != nil {
return err
}
// 如果不是{开头,则不是标准json返回错误
if tok.Kind() != json.ObjectOpen {
return d.unexpectedTokenError(tok)
}
...
for {
// Read field name.
tok, err := d.Read()
if err != nil {
return err
}
switch tok.Kind() {
default:
return d.unexpectedTokenError(tok)
case json.ObjectClose:
return nil
case json.Name:
// Continue below.
}
name := tok.Name()
...
}
}
- 按顺序去解析 JSON,通过解析对应特殊符号来作出具体的操作,其中 d.read 会起到类似于切割 JSON 的作用拿到完整的 key/value,对于 不同类型的 value 再分别去处理,最后将结果写入 Protobuf message
总结
JSON 简洁和清晰的层次结构,使得 JSON 成为优秀的数据交换语言。拥有较强的自解释性,
最大的优点是,广泛的支持浏览器与操作系统的兼容性,使得它被广泛使用。除了之前提到的问题还有就是传输效率不高。
Protobuf 传输效率高,序列化后体积小利于传输,且序列化反序列化速度快。不过相比 JSON 通用性还是没那么好。
参考文档
protobuf优缺点及编码原理 - 牛奔 - 博客园 (cnblogs.com)
Protocol Buffers | Google Developers
gRPC-Gateway | gRPC-Gateway Documentation Website (grpc-ecosystem.github.io)