编码与演化
数据编码格式
程序通常使用( 至少)两种不同的数据表示形式:
-
在内存中,数据保存在对象、结构体、列表、数组、哈希表和树等结构中。 这些数据结构针对 CPU 高效访问和操作进行了优化(通常使用指针)。
-
将数据写入文件或通过网络发送时,必须将其编码为某种自包含的字节序列(例如 JSON 文档)。 由于指针对其他进程没有意义,所以这个字节序列表示看起来与内存中使用的数据结构大不一样。
因此,在这两种表示之间需要进行类型的转化。从内存中的表示到字节序列的转化称为编码(或序列化),相反的过程称为解码(或解析,反序列化) 。
语言特定的格式
许多编程语言都内建了将内存对象编码为字节序列的支持。例如,Java 有 java.io.Serializable
,Ruby 有Marshal
,Python 有 pickle
等等。许多第三方库也存在,例如 Java 的 Kryo。
这些编码库非常方便,可以用很少的额外代码实现内存对象的保存与恢复。但是它们也有一些深层次的问题:
- 这类编码通常与特定的编程语言深度绑定,其他语言很难读取这种数据。
- 为了在相同的对象类型中恢复数据,解码过程需要能够实例化任意的类。这经常导致一些安全问题:如果攻击者可以让应用程序解码任意的字节序列,那么它们可以实例化任意的类,这通常意味着,它们可以做些可怕的 情,比如远程执行任意代码。
- 在这些库中,数据版本控制通常是事后才考虑的。 因为它们旨在快速简便地对数据进行编码,所以往往忽略了前向后向兼容性带来的麻烦问题。
- 效率(编码或解码所花费的CPU时间,以及编码结构的大小)往往也是事后才考虑的。 例如,Java 的内置序列化由于其糟糕的性能和臃肿的编码而臭名昭着
因此,除非临时使用,采用语言内置编码通常是一个坏主意。
JSON、XML与二进制变体
当我们谈到可以被多种编程语言读写的标准编码时,JSON 和 XML 是最显眼的角逐者。它们广为人知,广受支持,也广受憎恶。 XML 经常收到批评:过于冗长与且过份复杂。 JSON 的流行则主要源于 Web 浏览器的内置支持,以及相对于 XML 的简单性。 CSV 是另一种流行的与语言无关的格式,尽管其功能相对较弱。
JSON,XML 和 CSV 属于文本格式,因此具有较高的可读性。除了表面的语法问题之外,它们也存在一些微妙的问题:
- 数值编码多很多存在歧义的地方。XML 和 CSV 不能区分数字和字符串(除非引用一个外部模式)。 JSON 虽然区分字符串与数值,但不区分整数和浮点数,而且不能指定精度。
- JSON 和 XML 对 Unicode 字符串有很好的支持,但是它们不支持二进制数据。 二进制字符串是很有用的功能,所以人们通过使用 Base64 将二进制数据编码为文本来绕过此限制。其特有的模式标识着这个值应当被解释为 Base64 编码。这种方案虽然管用,但会增加 33% 的数据大小。
- XML 和 JSON 都有可选的模式支持。 这些模式语言相当强大,所以学习和实现起来都相当复杂。 XML 模式的使用相当普遍,但许多基于 JSON 的工具才不会去折腾模式。由于对数据的正确解读取决于模式中的信息,因此不使用 XML/JSON 模式的应用程序可能需要对相应的编码/解码逻辑进行硬编码。
- CSV 没有任何模式,因此每行和每列的含义完全由应用程序自行定义。 如果应用程序变更添加了新的行或列,那么这种变更必须通过手工处理。 CSV 也是一个相当模糊的格式。尽管其转义规则已经被正式指定,但并不是所有的解析器都能够正确的实现它们。
Thrift与Protocol Buffers
Apache Thrift 和 Protocol Buffers 是基于相同原理的二进制编码库。 Protocol Buffers 最初是在 Google 开发的,Thrift最初是在 Facebook 开发的,并且它们都是在 2007~2008 年开源的。
Thrift 和 Protocol Buffers 都需要一个模式来编码任何数据,因此需要通过接口定义语言 IDL 描述模式。如:
|
|
Avro
Apache Avro 是另一种二进制编码格式,与 Protocol Buffers 和 Thrift 有着有趣的不同。由于 Thrift 不适合 Hadoop 的用例,因此它作为 Hadoop 的一个子项目在 2009 年开始启动。
Avro 也使用模式来指定正在编码的数据的结构。 它有两种模式语言:一种(Avro IDL)用于人工编辑,一种(基于JSON)更易于机器读取。
我们用 Avro IDL 编写的示例模式可能如下所示:
|
|
该模式的等价 JSON 表示如下:
|
|
数据流模式
进程间数据流动的常见方式有以下几种:
- 通过数据库
- 通过服务调用(RPC、REST)
- 通过异步消息传递
基于数据库的数据流
在数据库中,写入数据库的进程对数据进行编码,而读取数据库的进程对数据进行解码。可能只有一个进程访问数据库,在这种情况下, Reader 只是同一个进程的较新版本,此时,可以认为向数据库中存储内容,就是给未来的自己发送消息。
基于服务的数据流
对于需要通过网络进行通信的进程,有许多不同的通信方式。最常见的是有两个角色:客户端和服务器。服务器通过网络公开 PI ,客户端可以连接到服务器以向该 API 发出请求。 服务器公开的 API 称为服务。
Web 以这种方式工作:客户向 Web 服务器发出请求,通过 GET 请求下载 HTML,CSS,JavaScript,图像等,并通过 POST 请求提交数据到服务器。 API 包含一组标准的协议和数据格式(HTTP,URL,SSL/TLS,HTML 等)。
将大型应用程序按照功能区域分解为较小的服务,这样当一个服务需要来自另一个服务的某些功能或数据时,就会向另一个服务发出请求。这种构建应用程序的方式传统上被称为面向服务的体系结构(service-oriented architecture,SOA),最近被改进和更名为微服务架构。面向服务/微服务架构的一个关键设计目标是通过使服务独立部署和演化来使应用程序更易于更改和维护。
Web 服务(REST、SOAP)
当服务使用 HTTP 作为底层通信协议时,可称之为 Web 服务。这可能是一个小错误,因为 Web 服务不仅在 Web上使用,而且在几个不同的环境中使用。例如:
- 运行在用户设备上的客户端应用程序通过 HTTP 向服务发出请求。 这些请求通常通过公共互联网进行。
- 一种服务向同一组织拥有的另一项服务提出请求,这些服务通常位于同一数据中心内,作为面向服务/微型架构的一部分。
- 一种服务通过互联网向不同组织所拥有的服务提出请求。这用于不同组织后端系统之间的数据交换。此类别包括由在线服务提供的公共 API,或用于共享访问用户数据的 OAuth。
有两种流行的 Web 服务方法:REST 和 SOAP:
- REST:REST 不是一种协议,而是一个基于 HTTP 原则的设计理念。它强调简单的数据格式,使用 URL 来标识资源,并使用 HTTP 功能进行缓存控制 、身份验证和内容类型协商。根据 REST 原则所设计的 API 称为 RESTful。
- SOAP:SOAP 是一种基于 XML 的协议,用于发出网络 API 请求。虽然它最常用于 HTTP ,但其目的是独立于HTTP ,并避免使用大多数 HTTP功能。相反,它带有庞大 而复杂的多种相关标准(Web 服务框架, Web Service Framework ,称为WS-*)和新增的各种功能。
RPC
RPC(Remote Procedure Call )即远程过程调用,其试图向远程网络服务发出请求,看起来与在同一进程中调用编程语言中的函数或方法相同(这种抽象称为位置透明)。 RPC 主要侧重于同一组织内多项服务之间的请求,通常发生在同一个数据中心内。
其与本地函数调用存在以下差异:
-
本地函数调用是可预测的,并且成功或失败仅取决于受你控制的参数。而网络请求是不可预知的。由于网络问题,请求或响应可能会丢失,或者远程计算机可能很慢或不可用,这些问题完全不在你的控制范围之内。网络问题是常见的,所以你必须预测他们,例如通过重试失败的请求。
-
本地函数调用要么返回结果,要么抛出异常,或者永远不返回(因为进入无限循环或进程崩溃)。网络请求有另一个可能的结果。由于超时,它可能会返回没有结果。在这种情况下,如果你没有得到来自远程服务的响应,你无法知道请求是否通过。
-
如果重试失败的网络请求,可能会发生请求实际上已经完成,但是响应丢失。在这种情况下,重试将导致该操作被执行多次。 除非你在协议中引入去重机制(幂等)。本地函数调用没有这个问题。
-
每次调用本地函数时,通常需要大致相同的时间来执行。网络请求比函数调用要慢得多,而且其延迟也是非常可变的。好的时候它可能会在不到一毫秒的时间内完成,但是当网络拥塞或者远程服务超载时,可能需要几秒钟的时间完成一样的东西。
-
调用本地函数时,可以高效地将引用(指针)传递给本地内存中的对象。当你发出一个网络请求时,所有这些参数都需要被编码成可以通过网络发送的一系列字节。 如果参数是像数字或字符串这样的基本类型倒是没关系,但是对于较大的对象很快就会变成问题。
-
客户端和服务可以用不同的编程语言实现,所以 RPC 框架必须将数据类型从一种语言翻译成另一种语言。这可能会捅出大篓子,因为不是所有的语言都具有相同的类型 。
基于消息传递的数据流
它与 RPC 的相似之处在于,客户端的请求(通常称为消息)以低延迟传递到另一个进程。它与基于数据库的方式的相似之处在于,不是通过直接的网络连接发送消息,而是通过称为消息队列的中介发送的, 该中介会暂存消息。
使用异步消息传递存在以下优点:
- 如果接收方不可用或过载,可以充当缓冲区,从而提高系统的可靠性。
- 它可以自动将消息重新发送到已经崩溃的进程,从而防止消息丢失。
- 避免发送方需要知道接收方的 IP 地址和端口号(这在虚拟机经常启启停停的云部署中特别有用)。
- 它允许将一条消息发送给多个接收方。
- 将发送方与接收方逻辑分离(发送方只是发布消息,不关心使用者)。
与 RPC 的差异在于:
- 消息传递通信通常是单向的:发送方通常不期望收到对其消息的回复 。进程可能发送一个响应,但这通常是在一个独立的通道上完成的。
- 这种通信模式是异步的 :发送者不会等待消息被传递,而只是发送然后忘记它。
消息队列
详细的交付语义因实现和配置而异,但通常情况下,消息队列的使用方式如下:一个进程将消息发送到指定的队列或主题,代理确保将消息传递给那个队列或主题的一个或多个消费者或订阅者。在同一主题上可以有许多生产者和许多消费者。
一个主题只提供单向数据流。 但是,消费者本身可能会将消息发布到另一个主题上(因此,可以将它们链接在一起,就像我们将在中看到的那样),或者发送给原始消息的发送者使用的回复队列(允许请求/响应数据流,类似于 RPC)。
消息代理通常不会执行任何特定的数据模型 —— 消息只是包含一些元数据的字节序列,因此你可以使用任何编码格式。 如果编码是向后和向前兼容的,你可以灵活地对发布者和消费者的编码进行独立的修改,并以任意顺序进行部署。
分布式Actor框架
Actor 模型是单个进程中并发的编程模型。逻辑被封装在 Actor 中,而不是直接处理线程(以及竞争条件,锁定和死锁的相关问题)。 每个 Actor 通常代表一个客户或实体,它可能有一些本地状态(不与其他任何角色共享),它通过发送和接收异步消息与其他角色通信。**不保证消息传送:在某些错误情况下,消息将丢失。**由于每个角色一次只能处理一条消息,因此不需要担心线程,每个角色可以由框架独立调度。
在分布式 Actor 框架中,此编程模型用于跨多个节点伸缩应用程序。不管发送方和接收方是在同一个节点上还是在不同的节点上,都使用相同的消息传递机制。如果它们在不同的节点上,则该消息被透明地编码成字节序列,通过网络发送,并在另一侧解码。
分布式的 Actor 框架实质上是将消息队列和 Actor 编程模型集成到一个框架中。 但是,如果要执行基于 Actor 的应用程序的滚动升级,则仍然需要担心向前和向后兼容性问题,因为消息可能会从运行新版本的节点发送到运行旧版本的节点,反之亦然。