当 data format 或 schema 发生变化时,经常需要对应用程序代码进行相应的调整(例如,向记录中添加新字段,然后应用程序代码开始读取和写入该字段)。然而,对于一个大型应用系统,代码更迭往往并非易事。对于服务端程序,通常使用滚动更新的方式进行发布。这意味若新旧版本的代码,以及新旧数据格式,可能会同时在系统内共存。为了使系统继续顺利运行,需要保持双向的兼容性:
-
向后兼容:较新的代码可以读取由旧代码编写的数据。
-
向前兼容:较旧的代码可以读取由新代码编写的数据。
向后兼容通常不难实现:作为新代码的作者,清楚旧代码所编写的数据格式,因此可以比较明确地处理这些旧数据(如果需要,只需保留旧的代码来读取旧的数据)。向前兼容可能会比较棘手,它需要旧代码忽略新版本的代码所做的添加。
程序通常使用(至少)两种不同的数据表示形式:
- 在内存中,数据保存在对象、结构体、列表 、数组 、哈希表和树等结构中。这些数据结构针对 CPU 的高效访问和操作进行了优化(通常使用指针)。
- 将数据写入文件或通过网络发送时,必须将其编码为某种自包含的字节序列(例如 JSON 文档)。由于指针对其他进程没有意义,所以这个字节序列表示看起来与内存中使用的数据结构大不一样。
许多编程语言都内置支持将内存中的对象编码为字节序列。例如,Java 有 java.io.Serializable,Ruby 有 Marshal,Python 有 pickle 等。此外,还有许多第 三方库,例如用于 Java 的 Kryo。
这些编码库使用起来非常方便,它们只需要很少的额外代码即可保存或恢复内存中的对象。然而,这里也有一些深层次的问题:
- 编码通常==与特定的编程语言绑定在一起==,而用另一种语言访问数据就非常困难。
- 为了在相同的对象类型中恢复数据,解码过程需要能够实例化任意的类。这经常导致一些==安全问题==:如果攻击者可以让应用程序解码任意的字节序列,那么它 们可以实例化任意的类,这通常意味着,它们可以做些可怕的事情,比如远程执行任意代码。
- 在这些库中,==多版本数据通常是次要的==,主要目标是快速且简单地编码数据,所以它们经常忽略向前和向后兼容性等问题。
- ==效率==(编码或解码花费的 CPU 时间,以及编码结构的大小)通常也是次要的。例如,Java 的内置序列化由于其糟糕的性能和臃肿的编码而广为诟病。
由于这些原因,使用语言内控的编码方案通常不是个好主意,除非只是为了临时尝试。
JSON 、XML 和 CSV 都是文本格式,因此具有不错的可读性(尽管语法容易引发争论)。除了表面的语法问题之外,它们也有一些微妙的问题:
- 数字编码有很多模糊之处。在 XML 和 CSV 中,无法区分数字和碰巧由数字组成的字符串(除了引用外部模式)。JSON 区分字符串和数字,但不区分整数和浮点数,并且不指定精度。
- JSON 和 XML 对 Unicode 字符串(即入类可读文本)有很好的支持,但是它们不支持二进制字符串(没有字符编码的字节序列)。二进制字符串是一个有用的功能,所以人们通过使用 Base64 将二进制数据编码为文本来解决这个限制。
- XML 和 JSON 都有可选的 schema 支持。这些 schema language 相当强大,因此学习和实现起来也比较复杂。XML schema 的使用相当广泛,但许多基于JSON 的工具并不局限于使用 schema。由于数据(例如数字和二进制字符串)的正确解释取决于 schema 中的信息,因此不使用 XML/JSON 架构的应用程序可能不得不硬编码适当的编码/解码逻辑。
- CSV 没有任何 schema,因此应用程序需要定义每行和每列的含义。如果应用程序更改添加新的行或列,则必须手动处理该更改。
JSON 不像 XML 那么冗长,但与二进制格式相比,两者仍然占用大量空间。这种现象导致开发了大量的二进制编码,用以支持 JSON(如 MessagePack、BSON、 BJSON、UBJSON、BISON 和 Smile)和 XML(如 WBXML 和 Fast Infoset)。
由于它们没有规定 schema,所以需要在编码数据时包含所有的对象字段名称。也就是说,在示例 4-1 中的 JSON 文档的二进制编码中,它们将需要在某处包含字符串 userName、favoriteNumber 和 interest。
示例 4-1一条样本记录,本章后续的多种二进制格式编码都以该记录未示例说明
{
"userName": "Martin",
"favoriteNumber": 1337,
"interests": ["daydreaming", "hacking"]
}来看一个 MessagePack 的例子,它是一种 JSON 的二进制编码。图 4-1 展示了采用 MessagePack 对示例 4-1 的 JSON 文档进行编码所得到的字节序列
二进制编码的长度为 66 字节,仅略小于文本 JSON 编码(去掉空格)占用的 81 字节。JSON 的所有二进制编码在这方面是相似的。目前对于如此小的空间缩减(也许解析速度可以加快)是否值得失去可读性仍存有疑问。
在下面的章节中,我们将看到如何做得更好,只用 32 字节即可完成对同样的记录进行编码。
Apache Thrift 和 Protocol Buffers (protobuf) 是基于相同原理的两种二进制编码库。
Thrift 和 Protocol Buffers 都需要 schema 来编码任意的数据。 为了用 Thrift 对示例 4-1 中的数据进行编码,可以使用 Thrift 接口定义语言 (IDL) 来描述 schema,如下所示:
struct Person {
1: required string userName,
2: optional i64 favoriteNumber,
3: optional list<string> interests
}
Thrift 有两种不同的二进制编码格式,分别称为 BinaryProtocol 和 CompactProtocol。先来看看 BinaryProtocol,以这种格式编码示例 4-1 需要 59 字节,如图4-2 所示。
与图 4-1 相比,最大的区别是没有字段名(userName 、favoriteNumber 和 interest)。相反,编码数据包含数字类型的字段标签(1 、2 和 3)。这些是 schema 定义中出现的数字。字段标签就像字段的别名,用来指示当前的字段,但更为紧凑,可以省去引用字段全名。
Thrift CompactProtocol 略。
最后,Protocol Buffers 对相同的数据进行编码,如图 4-4所示。它的位打包方式略有不同,但与 Thrift 的 CompactProtocol 非常相似。Protocol Buffers 只用 33 字节可以表示相同的记录。
之前说过,schema 不可避免地需要随着时间而不断变化,称之为 schema 演化(schema evolution)。那么 Thrift 和 Protocol Buffers 如何在保持向后和向前兼容性的同时应对 schema 更改呢?
可以添加新的字段到 schema,只要给每个字段一个新的标记号码。如果旧的代码(不知道添加的新标记号码)试图读取新代码写入的数据,包括一个它不能识别的标记号码中新的字段,则它可以简单地忽略该字段。实现时,通过数据类型的注释来通知解析器跳过特定的字节数。这样可以实现向前兼容性,即旧代码可以读取由新代码编写的记录。
向后兼容性呢?只要每个字段都有一个唯一的标记号码,新的代码总是可以读取旧的数据,因为标记号码仍然具有相同的含义。唯一的细节是,==如果添加一个新的字段,则无法使其成为必需字段==。如果要添加字段并将其设置为 required,当新代码读取旧代码写入的数据,则该检查将失败。因此,为了保持向后兼容性,在schema 的初始部署之后添加的每个字段都必须是可选的或具有默认值。
删除字段就像添加字段一样,不过向后和向前兼容性问题相反。这意味着只能删除可选的字段(必填字段永远不能被删除),而且==不能再次使用相同的标签号码==(因为可能仍然有写入的数据包含旧的标签号码,而该字段必须被新代码忽略)。
另外一个问题,如果改变字段的数据类型呢?这是有可能的,但存在值会丢失精度或被截断的风险。例如,假设将一个 32 位的整数变成一个 64 位的整数。新代码可以容易地读取旧代码写入的数据,因为解析器可以用零填充任何缺失的位。但是,如果旧代码读取新代码写入的数据,旧代码仍然使用 32 位变量来保存该值。 如果解码的 64 位值不适合 32 位,则它将被截断。
略
尽管 JSON 、 XML 和 CSV 等文本数据格式非常普遍,但基于 schema 的二进制编码也是一个可行的选择。它们有许多不错的属性:
- 它们可以比各种 “二进制 JSON” 变体更紧凑,可以省略编码数据中的字段名称。
- schema 是一种有价值的文档形式,因为 schema 是解码所必需的,所以可以确定它是最新的(而手动维护的文档可能很容易偏离现实)。
- schema 数据库允许在部署任何内容之前检查模式更改的向前和向后兼容性。
- 对于静态类型编程语言的用户来说,从 schema 生成代码的能力是有用的,它能够在编译时进行类型检查。
本章开始时,我们提到,每当将一些数据发送到非共享内存的另一个进程时,例如,当通过网络发送数据或者把它写入文件时,都需要将数据编码为字节序列。
这是一个相当抽象的想法,数据可以通过多种方式从一个进程流向另一个进程。谁编码数据?谁解码数据?在本章的其余部分,将探讨一些最常见的进程间数据流动的方式:
- 通过数据库
- 通过服务调用
- 通过异步消息传递
在数据库中,写入数据库的进程对数据进行编码,而读取数据库的进程对数据进行解码。可能只有一个进程访问数据库,在这种情况下,reader 只是同一进程的较新版本,此时,可以认为向数据库中存储内容,就是给未来的自已发送消息。
这种情况下,向后兼容性显然是必要的;否则未来的自已将无法解码以前写的东西。
一般而言,几个不同的进程同时访问数据库是很常见的。在应用程序正在改变的环境中,访问数据库的某些进程可能运行较新的代码,而某些进程可能运行较旧的代码。例如,因为当前正在滚动升级中部署新版本,所以某些实例已经更新,而其他实例尚未更新。
这意味着数据库中的值可以由较新版本的代码写入,然后由仍在运行的旧版本代码读取。因此,数据库通常也需要向前兼容。
然而,还有一个额外的障碍。假设在记录 schema 中添加了一个字段,并且较新的代码将该新字段的值写入数据库。随后,旧版本的代码(尚不知道该新字段)将读取 、更新记录并将其写回。在这种情况下,理想的行为通常是旧代码保持新字段不变,即使它无法解释。
略
RPC 模型试图使向远程网络服务发出请求看起来与在同一进程中调用编程语言中的函数或方法相同(这种抽象称为位置透明)。虽然 RPC 起初看起来很方便,但这种方法在根本上是有缺陷的。网络请求与本地函数调用非常不同:
- 本地函数调用是可预测的,并且成功或失败仅取决于控制的参数。网络请求是不可预测的,请求或响应可能由于网络问题而丢失,或者远程计算机可能速度慢 或不可用,这些问题完全不在控制范围之内。网络问题很常见,因此必须有所准备,例如重试失败的请求。
- 本地函数调用要么返回一个结果,要么抛出一个异常,或者永远不会返回(因为进入无限循环或者进程崩溃)。网络请求有另一个可能的结果:由于超时,它返回时可能没有结果。在这种情况下,根本不知道发生了什么:如果没有收到来自远程服务的响应,无法知道请求是否成功。
- 如果重试失败的网络请求,可能会发生请求实际上已经完成,只是响应丢失的情况。在这种情况下,重试将导致该操作被执行多次,除非在协议中建立重复数据消除(幕等性)机制。本地函数调用则没有这样问题。
- 每次调用本地函数时,通常需要大致相同的时间来执行。网络请求比函数调用要慢得多,而且其延迟也有很大的变化。
- 调用本地函数时,可以高效地将引用(指针)传递给本地内存中的对象。当发出网络请求时,所有这些参数都需要被编码成可以通过网络发送的字节序列。如果参数是像数字或字符串这样的基本类型,这没关系,但是对于较大的对象很快就会出现问题。
- 客户端和服务可以用不同的编程语言来实现,所以 RPC 框架必须将数据类型从一种语言转换成另一种语言。因为不是所有的语言都具有相同的类型,所以最终 可能会丑陋。
新一代的 RPC 框架更加明确了远程请求与本地函数调用不同的事实。例如,Finagle 和 Rest.Ii 使用 Futures (Promises) 来封装可能失败的异步操作。Futures 还简化了需要并行请求多项服务的情况,并将其结果合并。gRPC 支持流,其中调用不仅包括一个请求和一个响应,还包括一段时间内一系列的请求和响应。
略
在最后一节中,将简要介绍一下 RPC 和数据库之间的异步消息传递系统。它们与 RPC 的相似之处在于,客户端的请求(通常称为消息)以低延迟传递到另一个进程。它们与数据库的相似之处在于,不是通过直接的网络连接发送消息,而是通过称为消息代理(也称为消息队列,或面向消息的中间件)的中介发送的,该中介会暂存消息。
与直接 RPC 相比,使用消息代理有以下几个优点:
- 如果接收方不可用或过载,它可以充当缓冲区,从而提高系统的可靠性。
- 它可以自动将消息重新发送到崩溃的进程,从而防止消息丢失。
- 它避免了发送方需要知道接收方的 IP 地址和端口号(这在虚拟机经常容易起起停停的云部署中特别有用)。
- 它支持将一条消息发送给多个接收方。
- 它在逻辑上将发送方与接收方分离(发送方只是发布消息,并不关心谁使用它们)。
然而,与 RPC 的差异在于,==消息传递通信通常是单向的==:发送方通常不期望收到对其消息的回复。进程可能发送一个响应,但这通常是在一个独立的通道上完成的。这种通信模式是异步的:发送者不会等待消息被传递,而只是发送然后忘记它。
略
略


