DDIA Chapter 4:编码与演化
应用程序不可避免的存在迭代,所以开发人员应该竟可能构建具有可演化性、能灵活适应变化的系统。当数据格式(format)或模式(schema)发生变化时,通常需要对应用程序代码进行相应的 更改(例如,为记录添加新字段,然后修改程序开始读写该字段)。但在大型应用程序中, 代码变更通常不会立即完成,因为考虑到:
- 对于服务端(server-side)应用程序,可能需要执行滚动升级 (rolling upgrade)(也 称为阶段发布(staged rollout)),一次将新版本部署到少数几个节点,检查新版本是 否运行正常,然后逐渐部完所有的节点。这样无需中断服务即可部署新版本,为频繁发布提供了可行性,从而带来更好的可演化性。
- 对于客户端(client-side)应用程序,用户可能长时间不会去升级软件。
这意味着,新旧版本的代码,以及新旧数据格式可能会在系统中同时共处。系统想要继续顺 利运行,就需要保持双向兼容性(从代码读取数据的角度出发):
- 向后兼容 (backward compatibility) :新代码可以读旧数据。
- 向前兼容 (forward compatibility) :旧代码可以读新数据。
编码数据的格式
程序通常具有两种形式的数据:
在内存中,数据保存在对象,结构体,列表,数组,哈希表,树等中。 这些数据结构针 对CPU的高效访问和操作进行了优化(通常使用指针)。
如果要将数据写入文件,或通过网络发送,则必须将其编码(encode)为某种自包含的 字节序列(例如,JSON文档)。
从内存中表示转换到字节序列的过程称为:编码(encoding)、序列化(serialization)、编组(marshalling);
从字节序列表示转换为内存中表示的过程称为:解码(decoding)、解析(Parsing)、反序列化(deserialization)、反编组(unmarshalling);
语言特定的格式
许多编程语言都内建了将内存对象编码为字节序列的支持,这些编码库非常方便,可以用很少的额外代码实现内存对象的保存与恢复。但是它们也有一 些深层次的问题:
- 这类编码通常与特定的编程语言深度绑定,其他语言很难读取这种数据。
- 为了恢复相同对象类型的数据,解码过程需要实例化任意类的能力,这通常是安全问题的一个来源。
- 在这些库中,数据版本控制通常是事后才考虑的。因为它们旨在快速简便地对数据进行编码,所以往往忽略了前向后向兼容性带来的麻烦问题。
- 效率(编码或解码所花费的CPU时间,以及编码结构的大小)往往也是事后才考虑的。
JSON,XML和二进制变体
XML经常被批评为过于冗长和不必要的复杂。 JSON 倍受欢迎,主要由于它在Web浏览器中的内置支持(通过成为JavaScript的一个子集)以及相对于XML的简单性。尽管存在这些缺陷,但JSON,XML和CSV已经足够用于很多目的,特别是作为数据交换格式 (即将数据从一个组织发送到另一个组织)。
二进制编码:JSON比XML简洁,但与二进制格式一比,还是太占地方。这一事实导致大量二进制编码版本JSON & XML的出现,JSON(MessagePack,BSON,BJSON,UBJSON,BISON和Smile等)。
JSON示例记录:
1
2
3
4
5{
"userName": "Martin",
"favoriteNumber": 1337,
"interests": ["daydreaming", "hacking"]
}
MessagePack
MessagePack是一个JSON的二进制编码,其对上述JSON数据序列化结果如下:
- 第一个字节
0x83
表示接下来是3个字段(低四位=0x03
)的对象 object(高四位=0x80
)。 (如果想知道如果一个对象有15个以上的字段会发生什么情况,字段的数量塞不进4个bit里,那么它会用另一个不同的类型标识符,字段的数量被编码两个或四个字节)。 - 第二个字节
0xa8
表示接下来是8字节长的字符串(最低四位=0x08
)。 - 接下来八个字节是ASCII字符串形式的字段名称
userName
。由于之前已经指明长度,不需要任何标记来标识字符串的结束位置(或者任何转义)。 - 接下来的七个字节对前缀为
0xa6
的六个字母的字符串值Martin
进行编码,依此类推。
Avro
Apache Avro 是另一种二进制编码格式,作为Hadoop的一个子项目在2009年开始,其序列化上图示例如下,为了解析二进制数据,您按照它们出现在架构中的顺序遍历这些字段,并使用架构来告诉您每个字段的数据类型。
数据流的类型
数据可以通过多种方式从一个流程流向另一个流程。谁编码数据,谁解码?在本章的其余部分中,我们将探讨数据如何在流程之间流动的一些最常见的方式:
- 通过数据库
- 通过服务调用
- 通过异步消息传递
数据库中的数据流
在数据库中,写入数据库的过程对数据进行编码,从数据库读取的过程对数据进行解码。向后兼容性很重要(即新代码可以读旧数据),否则未来自己将无法解码以前写的东西。由于滚动升级,数据库中的一个值可能被更新版本的代码写入,然后被运行的旧版本代码读取,因此数据库也需要向前兼容。
服务中的数据流:REST与RPC
通过网络进行通信时最常见的安排是设置客户端和服务器。服务器通过网络公开API,客户端可以连接到服务器向对应的API发出请求,服务器公开的API称为服务。
Web以这种方式工作:客户(Web浏览器)向Web服务器发出请求,使GET请求下载HTML, CSS,JavaScript,图像等,并向POST请求提交数据到服务器。 API包含一组标准的协议和 数据格式(HTTP,URL,SSL/TLS,HTML等)。Web浏览器不是唯一的客户端类型。例如,在移动设备或桌面计算机上运行的本地应用程序也可以向服务器发出网络请求,并且在Web浏览器内运行的客户端JavaScript应用程序可以使用XMLHttpRequest成为HTTP客户端(该技术被称为Ajax )。在这种情况下,服务器的响应通常不是用于显示给人的HTML,而是用于便于客户端应用程序代码(如JSON)进一步处理的编码数据。尽管HTTP可能被用作传输协议,但顶层实现的API是特定于应用程序的,客户端和服务器需要就该API的细节达成一致。
此外,服务器本身可以是另一个服务的客户端(例如,典型的Web应用服务器充当数据库的客户端)。这种方法通常用于将大型应用程序按照功能区域分解为较小的服务,这样当一个服务需要来自另一个服务的某些功能或数据时,就会向另一个服务发出请求。这种构建应用程序的方式传统上被称为 面向服务的体系结构(service-oriented architecture,SOA) ,最近被改进和更名为 微服务架构 。
Web服务
当服务使用HTTP作为底层通信协议时,可称之为Web服务;有两种流行的Web服务方法:REST和SOAP。他们在哲学方面几乎是截然相反的。
- REST不是一个协议,而是一个基于HTTP原则的设计哲学。它强调简单的数据格式,使用URL来标识资源,并使用HTTP功能进行缓存控制,身份验证和内容类型协商。与SOAP相比,REST已经越来越受欢迎,至少在跨组织服务集成的背景下,并经常与微服务相关。根据REST原则设计的API称为RESTful。
远程过程调用(RPC)
RPC模型试图向远程网络服务发出请求,看起来与在同一进程中调用编程语言中的函数或方法相同(这种抽象称为位置透明)。尽管RPC起初看起来很方便,但这种方法根本上是有缺陷的。网络请求与本地函数调用非常不同:
- 本地函数调用是可预测的,并且成功或失败,这仅取决于受您控制的参数。网络请求是不可预知的:由于网络问题,请求或响应可能会丢失,或者远程计算机可能很慢或不可用,这些问题完全不在您的控制范围之内。网络问题是常见的,所以你必须预测他们,例如通过重试失败的请求。
- 本地函数调用要么返回结果,要么抛出异常,或者永远不返回(因为进入无限循环或进程崩溃)。网络请求有另一个可能的结果:由于超时,它可能会返回没有结果。在这种情况下,你根本不知道发生了什么:如果你没有得到来自远程服务的响应,你无法知道请求是否通过。
- 如果您重试失败的网络请求,可能会发生请求实际上正在通过,只有响应丢失。在这种情况下,重试将导致该操作被执行多次,除非您在协议中引入除重( 幂等(idempotent))机制。本地函数调用没有这个问题。
- 每次调用本地功能时,通常需要大致相同的时间来执行。网络请求比函数调用要慢得多,而且其延迟也是非常可变的:在不到一毫秒的时间内它可能会完成,但是当网络拥塞或者远程服务超载时,可能需要几秒钟的时间完全一样的东西。
- 调用本地函数时,可以高效地将引用(指针)传递给本地内存中的对象。当你发出一个网络请求时,所有这些参数都需要被编码成可以通过网络发送的一系列字节。没关系,如果参数是像数字或字符串这样的基本类型,但是对于较大的对象很快就会变成问题。
消息传递中的数据流
我们一直在研究从一个过程到另一个过程的编码数据流的不同方式。到目前为止,我们已经讨论了REST和RPC(其中一个进程通过网络向另一个进程发送请求并期望尽可能快的响应)以及数据库(一个进程写入编码数据,另一个进程在将来再次读取)。
在最后一节中,我们将简要介绍一下RPC和数据库之间的异步消息传递系统。它们与RPC类似,因为客户端的请求(通常称为消息)以低延迟传送到另一个进程。它们与数据库类似,不是通过直接的网络连接发送消息,而是通过称为消息代理(也称为消息队列或面向消息的中间件)的中介来临时存储消息。与直接RPC相比,使用消息代理有几个优点:
- 如果收件人不可用或过载,可以充当缓冲区,从而提高系统的可靠性。
- 它可以自动将消息重新发送到已经崩溃的进程,从而防止消息丢失。
- 避免发件人需要知道收件人的IP地址和端口号(这在虚拟机经常出入的云部署中特别有用)。
- 它允许将一条消息发送给多个收件人。
- 将发件人与收件人逻辑分离(发件人只是发布邮件,不关心使用者)。
然而,与RPC相比,差异在于消息传递通信通常是单向的:发送者通常不期望收到其消息的回复。一个进程可能发送一个响应,但这通常是在一个单独的通道上完成的。这种通信模式是异步的:发送者不会等待消息被传递,而只是发送它,然后忘记它。
小结
在本章中,我们研究了将数据结构转换为网络中的字节或磁盘上的字节的几种方法。我们看到了这些编码的细节不仅影响其效率,更重要的是应用程序的体系结构和部署它们的选项。
特别是,许多服务需要支持滚动升级,其中新版本的服务逐步部署到少数节点,而不是同时部署到所有节点。滚动升级允许在不停机的情况下发布新版本的服务(从而鼓励在罕见的大型版本上频繁发布小型版本),并使部署风险降低(允许在影响大量用户之前检测并回滚有故障的版本)。这些属性对于可演化性,以及对应用程序进行更改的容易性都是非常有利的。
在滚动升级期间,或出于各种其他原因,我们必须假设不同的节点正在运行我们的应用程序代码的不同版本。因此,在系统周围流动的所有数据都是以提供向后兼容性(新代码可以读取旧数据)和向前兼容性(旧代码可以读取新数据)的方式进行编码是重要的。
我们讨论了几种数据编码格式及其兼容性属性:
- 编程语言特定的编码仅限于单一编程语言,并且往往无法提供前向和后向兼容性。
- JSON,XML和CSV等文本格式非常普遍,其兼容性取决于您如何使用它们。他们有可选的模式语言,这有时是有用的,有时是一个障碍。这些格式对于数据类型有些模糊,所以你必须小心数字和二进制字符串。
- 像Thrift,Protocol Buffers和Avro这样的二进制模式驱动格式允许使用清晰定义的前向和后向兼容性语义进行紧凑,高效的编码。这些模式可以用于静态类型语言的文档和代码生成。但是,他们有一个缺点,就是在数据可读之前需要对数据进行解码。
我们还讨论了数据流的几种模式,说明了数据编码是重要的不同场景:
- 数据库,写入数据库的进程对数据进行编码,并从数据库读取进程对其进行解码
- RPC和REST API,客户端对请求进行编码,服务器对请求进行解码并对响应进行编码,客户端最终对响应进行解码
- 异步消息传递(使用消息代理或参与者),其中节点之间通过发送消息进行通信,消息由发送者编码并由接收者解码
我们可以小心地得出这样的结论:前向兼容性和滚动升级在某种程度上是可以实现的。愿您的应用程序的演变迅速、敏捷部署。