协议编码

序列化

程序通常(至少)使用两种形式的数据:

  • 在内存中,数据保存在对象,结构体,列表,数组,哈希表,树等中这些数据结构针对 CPU 的高效访问和操作进行了优化(通常使用指针)。

  • 如果要将数据写入文件,或通过网络发送,则必须将其编码(encode)为某种自包含的字节序列(例如,JSON 文档)由于每个进程都有自己独立的地址空间,一个进程中的指针对任何其他进程都没有意义,所以这个字节序列表示会与通常在内存中使用的数据结构完全不同。

序列化(serialization、marshalling)的过程是指将数据结构或者对象的状态转换成可以存储(比如文件、内存)或者传输的格式(比如网络)。反向操作就是反序列化(deserialization、unmarshalling)的过程。从内存中表示到字节序列的转换称为 编码(Encoding)(也称为序列化(serialization)或编组(marshalling)),反过来称为解码(Decoding)(解析(Parsing),反序列化(deserialization),反编组( unmarshalling))。

  • 编程语言特定的编码仅限于单一编程语言,并且往往无法提供前向和后向兼容性。

  • JSON,XML 和 CSV 等文本格式非常普遍,其兼容性取决于您如何使用它们。他们有可选的模式语言,这有时是有用的,有时是一个障碍。这些格式对于数据类型有些模糊,所以你必须小心数字和二进制字符串。

  • 像 Thrift,Protocol Buffers 和 Avro 这样的二进制模式驱动格式允许使用清晰定义的前向和后向兼容性语义进行紧凑,高效的编码。这些模式可以用于静态类型语言的文档和代码生成。但是,他们有一个缺点,就是在数据可读之前需要对数据进行解码。

语言特定的格式

许多编程语言都内建了将内存对象编码为字节序列的支持。例如,Java 有 java.io.Serializable,Ruby 有 Marshal,Python 有 pickle 等等。许多第三方库也存在,例如 Kryo for Java。

这些编码库非常方便,可以用很少的额外代码实现内存对象的保存与恢复。但是它们也有一些深层次的问题:

  • 这类编码通常与特定的编程语言深度绑定,其他语言很难读取这种数据。如果以这类编码存储或传输数据,那你就和这门语言绑死在一起了。并且很难将系统与其他组织的系统(可能用的是不同的语言)进行集成。

  • 为了恢复相同对象类型的数据,解码过程需要实例化任意类的能力,这通常是安全问题的一个来源:如果攻击者可以让应用程序解码任意的字节序列,他们就能实例化任意的类,这会允许他们做可怕的事情,如远程执行任意代码。

  • 在这些库中,数据版本控制通常是事后才考虑的。因为它们旨在快速简便地对数据进行编码,所以往往忽略了前向后向兼容性带来的麻烦问题。

  • 效率(编码或解码所花费的 CPU 时间,以及编码结构的大小)往往也是事后才考虑的例如,Java 的内置序列化由于其糟糕的性能和臃肿的编码而臭名昭着。

因此,除非临时使用,采用语言内置编码通常是一个坏主意。

JSON,XML 和二进制变体

谈到可以被许多编程语言编写和读取的标准化编码,JSON 和 XML 是显眼的竞争者。它们广为人知,广受支持,也“广受憎恶”XML 经常被批评为过于冗长和不必要的复杂 JSON 倍受欢迎,主要由于它在 Web 浏览器中的内置支持(通过成为 JavaScript 的一个子集)以及相对于 XML 的简单性 CSV 是另一种流行的与语言无关的格式,尽管功能较弱。1987 年曾经的 Sun Microsystems 发布了 XDR。二十世纪九十年代后期,XML 开始流行,它是一种人类易读的基于文本的编码方式,易于阅读和理解,但是失去了紧凑的基于字节流的编码的优势。JSON 是一种更轻量级的基于文本的编码方式,经常用在 client/server 端的通讯中。YAML 类似 JSON,新的特性更强大,更适合人类阅读,也更紧凑。还有苹果系统的 property list。

JSON,XML 和 CSV 是文本格式,因此具有人类可读性(尽管语法是一个热门辩题)。除了表面的语法问题之外,它们也有一些微妙的问题:

  • 数字的编码多有歧义之处。XML 和 CSV 不能区分数字和字符串(除非引用外部模式)JSON 虽然区分字符串和数字,但不区分整数和浮点数,而且不能指定精度。
  • 当处理大量数据时,这个问题更严重了。例如,大于$2^{53}$的整数不能在 IEEE 754 双精度浮点数中精确表示,因此在使用浮点数(例如 JavaScript)的语言进行分析时,这些数字会变得不准确 Twitter 上有一个大于$2^{53}$的数字的例子,它使用一个 64 位的数字来标识每条推文 Twitter API 返回的 JSON 包含了两种推特 ID,一个 JSON 数字,另一个是十进制字符串,以此避免 JavaScript 程序无法正确解析数字的问题。
  • JSON 和 XML 对 Unicode 字符串(即人类可读的文本)有很好的支持,但是它们不支持二进制数据(不带字符编码(character encoding)的字节序列)。二进制串是很实用的功能,所以人们通过使用 Base64 将二进制数据编码为文本来绕开这个限制。模式然后用于表示该值应该被解释为 Base64 编码。这个工作,但它有点 hacky,并增加了 33%的数据大小 XML 和 JSON 都有可选的模式支持。这些模式语言相当强大,所以学习和实现起来相当复杂 XML 模式的使用相当普遍,但许多基于 JSON 的工具嫌麻烦才不会使用模式。由于数据的正确解释(例如数字和二进制字符串)取决于模式中的信息,因此不使用 XML/JSON 模式的应用程序可能需要对相应的编码/解码逻辑进行硬编码。
  • CSV 没有任何模式,因此应用程序需要定义每行和每列的含义。如果应用程序更改添加新的行或列,则必须手动处理该变更 CSV 也是一个相当模糊的格式(如果一个值包含逗号或换行符,会发生什么?)。尽管其转义规则已经被正式指定,但并不是所有的解析器都正确的实现了标准。

尽管存在这些缺陷,但 JSON,XML 和 CSV 已经足够用于很多目的。特别是作为数据交换格式(即将数据从一个组织发送到另一个组织),它们很可能仍然很受欢迎。这种情况下,只要人们对格式是什么意见一致,格式多么美观或者高效就没有关系。让不同的组织达成一致的难度超过了其他大多数问题。

模式编码

Protocol Buffers,Thrift 和 Avro 都使用模式来描述二进制编码格式。他们的模式语言比 XML 模式或者 JSON 模式简单得多,它支持更详细的验证规则(例如,“该字段的字符串值必须与该正则表达式匹配”或“该字段的整数值必须在 0 和 100 之间“)。由于 Protocol Buffers,Thrift 和 Avro 实现起来更简单,使用起来也更简单,所以它们已经发展到支持相当广泛的编程语言。

这些编码所基于的想法绝不是新的。例如,它们与 ASN.1 有很多相似之处,它是 1984 年首次被标准化的模式定义语言。它被用来定义各种网络协议,其二进制编码(DER)仍然被用于编码 SSL 证书(X.509),例如 ASN.1 支持使用标签号码的模式演进,类似于 Protocol Buf-fers 和 Thrift。然而,这也是非常复杂和严重的文件记录,所以 ASN.1 可能不是新应用程序的好选择。

许多数据系统也为其数据实现某种专有的二进制编码。例如,大多数关系数据库都有一个网络协议,您可以通过该协议向数据库发送查询并获取响应。这些协议通常特定于特定的数据库,并且数据库供应商提供将来自数据库的网络协议的响应解码为内存数据结构的驱动程序(例如使用 ODBC 或 JDBC API)。

所以,我们可以看到,尽管 JSON,XML 和 CSV 等文本数据格式非常普遍,但基于模式的二进制编码也是一个可行的选择。他们有一些很好的属性:

  • 它们可以比各种“二进制 JSON”变体更紧凑,因为它们可以省略编码数据中的字段名称。
  • 模式是一种有价值的文档形式,因为模式是解码所必需的,所以可以确定它是最新的(而手动维护的文档可能很容易偏离现实)。
  • 保留模式数据库允许您在部署任何内容之前检查模式更改的向前和向后兼容性。
  • 对于静态类型编程语言的用户来说,从模式生成代码的能力是有用的,因为它可以在编译时进行类型检查。

总而言之,模式进化允许与 JSON 数据库提供的无模式/模式读取相同的灵活性

Protocol Buffers 与 Thrift

Apache Thrift 和 Protocol Buffers(protobuf)是基于相同原理的二进制编码库 Protocol Buffers 最初是在 Google 开发的,Thrift 最初是在 Facebook 开发的,并且在 2007~2008 年都是开源的 Thrift 和 Protocol Buffers 都需要一个模式来编码任何数据。要在 Thrift 的例 4-1 中对数据进行编码,可以使用 Thrift 接口定义语言(IDL)来描述模式,如下所示:

struct Person {
    1: required string       userName,
    2: optional i64          favoriteNumber,
    3: optional list<string> interests
}

Protocol Buffers 的等效模式定义看起来非常相似:

message Person {
    required string user_name       = 1;
    optional int64  favorite_number = 2;
    repeated string interests       = 3;
}

总体来说 Protobuf 序列化和反序列的性能都是比较高的,编码后的数据大小也不错。Protobuf 支持很多语言,比如 C++、C#、Dart、Go、Java、Python、Rust 等,同时也是跨平台的,所以得到了广泛的应用。Protobuf 包含序列化格式的定义、各种语言的库以及一个 IDL 编译器。正常情况下你需要定义 proto 文件,然后使用 IDL 编译器编译成你需要的语言。

模式演变

我们之前说过,模式不可避免地需要随着时间而改变。我们称之为模式演变 Thrift 和 Protocol Buffers 如何处理模式更改,同时保持向后兼容性?

字段标签

从示例中可以看出,编码的记录就是其编码字段的拼接。每个字段由其标签号码(样本模式中的数字 1,2,3)标识,并用数据类型(例如字符串或整数)注释。如果没有设置字段值,则简单地从编码记录中省略。从中可以看到,字段标记对编码数据的含义至关重要。您可以更改架构中字段的名称,因为编码的数据永远不会引用字段名称,但不能更改字段的标记,因为这会使所有现有的编码数据无效。

您可以添加新的字段到架构,只要您给每个字段一个新的标签号码。如果旧的代码(不知道你添加的新的标签号码)试图读取新代码写入的数据,包括一个新的字段,其标签号码不能识别,它可以简单地忽略该字段。数据类型注释允许解析器确定需要跳过的字节数。这保持了前向兼容性:旧代码可以读取由新代码编写的记录。

向后兼容性呢?只要每个字段都有一个唯一的标签号码,新的代码总是可以读取旧的数据,因为标签号码仍然具有相同的含义。唯一的细节是,如果你添加一个新的领域,你不能要求。如果您要添加一个字段并将其设置为必需,那么如果新代码读取旧代码写入的数据,则该检查将失败,因为旧代码不会写入您添加的新字段。因此,为了保持向后兼容性,在模式的初始部署之后 添加的每个字段必须是可选的或具有默认值。

删除一个字段就像添加一个字段,倒退和向前兼容性问题相反。这意味着您只能删除一个可选的字段(必填字段永远不能删除),而且您不能再次使用相同的标签号码(因为您可能仍然有数据写在包含旧标签号码的地方,而该字段必须被新代码忽略)。

删除一个字段就像添加一个字段,倒退和向前兼容性问题相反。这意味着您只能删除一个可选的字段(必填字段永远不能删除),而且您不能再次使用相同的标签号码(因为您可能仍然有数据写在包含旧标签号码的地方,而该字段必须被新代码忽略)。

数据类型

如何改变字段的数据类型?这可能是可能的——检查文件的细节——但是有一个风险,值将失去精度或被扼杀。例如,假设你将一个 32 位的整数变成一个 64 位的整数。新代码可以轻松读取旧代码写入的数据,因为解析器可以用零填充任何缺失的位。但是,如果旧代码读取由新代码写入的数据,则旧代码仍使用 32 位变量来保存该值。如果解码的 64 位值不适合 32 位,则它将被截断。

Protobuf 的一个奇怪的细节是,它没有列表或数组数据类型,而是有一个字段的重复标记(这是第三个选项旁边必要和可选)。重复字段的编码正如它所说的那样:同一个字段标记只是简单地出现在记录中。这具有很好的效果,可以将可选(单值)字段更改为重复(多值)字段。读取旧数据的新代码会看到一个包含零个或一个元素的列表(取决于该字段是否存在)。读取新数据的旧代码只能看到列表的最后一个元素。

Thrift 有一个专用的列表数据类型,它使用列表元素的数据类型进行参数化。这不允许 Protocol Buffers 所做的从单值到多值的相同演变,但是它具有支持嵌套列表的优点。