四.编码与演化

修改应用程序的功能也可能意味着需要更改其存储的数据:可能需要使用新的字段或记录方式,或者以新的方式展示现有的数据

当数据格式(format)或模式(schema)发生改变时,通常需要对应程序代码进行相应的更改,但在大型应用程序中,代码变更通常不会立即完成:

  • 对于服务端应用程序,可能需要执行滚动升级(rolling upgrade) (也称为阶段发布(staged rollout) ),一次将新版本部署到少数几个节点,检查新版本是否正常运行,然后逐渐部完所有的节点
  • 对于客户端应用程序,升级取决于用户,用户可能相当长一段时间不会去升级软件

新版本的代码以及新旧数据格式可能会在系统中同时共处。系统想要顺利运行,就需要保持双向兼容性

  • 向后兼容(backward compatibility):新代码可以读取由旧代码写入的数据
  • 向前兼容(forward compatibility):旧的代码可以读取由新的代码写入的数据

编码格式

  • JSON、XML、Protocol Buffers、Thrift和Avro

Web服务中的数据存储和通信

  • 表属性状态传递(REST)
  • 远程过程调用(RPC)
  • 消息传递系统(Actor和消息队列)

编码数据的格式

程序通常使用两种形式的数据

  1. 在内存中,数据保存在对象、结构体、列表、数组、散列表、树等结构中。这些数据针对CPU的高效访问进行了优化(通常使用指针)
  2. 如果要将数据写入文件,或通过网络发送,则必须将其编码(encode)为某种自包含的字节序列(例如JSON)。由于每个进程都有自己独立的地址空间,一个进程中的指针对任何其他进程都没有意义,所以这个字节序列表示会与通常在内存中使用的数据结构完全不同
  • 从内存中表示到字节序列的转换称为编码(encoding) ,也称为序列化(serialization)或编组(marshalling)
  • 反过来称为解码(decoding) ,也称为解析(paring),反序列化(deserialization),反编组(unmarshalling)

serialization和marshal的区别:marshal不仅传输对象的状态,而且会一起传输对象的方法

语言特定的格式

许多变成语言内建了将内存对象编码为字节序列的支持,这些编码库很方便,但也有一些问题

  • 与特定的编程语言绑定
  • 解码过程需要实例化任意类的能力,这通常是安全问题的一个来源
  • 数据版本控制是事后才考虑的,它们旨在快速简便地对数据进行编码,所以忽略了前向后向兼容性带来的麻烦
  • 效率问题

JSON、XML个二进制变体

一些微妙的问题

  • 数字编码有很多模糊之处。在XML和CSV中,无法区分数字和碰巧有数字组成的字符串(除了引用外部模式)。JSON虽然区分字符串与数字,但并不区分整数和浮点数,并且不能指定精度

    Twitter API 返回的 JSON 包含了两个推特 ID,一个是 JSON 数字,另一个是十进制字符串,以解决 JavaScript 程序中无法正确解析大数字的问题

  • JSON和XML对Unicode字符串有很好的支持但它们不支持二进制数据(即不带字符编码(character encoding) 的字节序列)。人们通常使用Base64将二进制数据编码为文本来绕过限制。其特有的模式标识着这个值当被解释为Base64编码的二进制数据。这种方案缺点是会增加三分之一的数据

  • XML和JSON都有可选的模式支持,这些模式语言相当强大,所以学习和实现起来都相当复杂。不使用XML/JSON模式的应用程序可能需要对相应的编码/解码逻辑进行硬编码

  • CSV没有任何模式,因此每行每列的含义完全由应用程序自行定义。如果应用程序变更添加了新的行或列,那么这种变更必须通过手工处理。CSV也是一个相当模糊的格式

二进制编码

JSON比XML简洁,但与二进制格式想比还是太占空间。这导致了大量二进制版本JSON和XML的出现,但没有一个能像文本版本JSON和XML那样被采用

这些格式中的一些扩展了一组数据类型(例如:区分整数和浮点数或增加对二进制字符串的支持),另一方面,他们没有改变JSOM/XML的数据模型,特别由于它们没有规定模式,所以它们需要在编码数据中包含所有的对象字段名称

image-20230819115319792

Thrift和Protocol Buffers

Thrift和Protocol BUffers都需要一个模式来编码任何数据,要在Thrift中对数据进行编码,可以使用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;
}

Thrift和Protocol Buffers每一个都带有一个代码生成工具,它采用类似于这里所示的模式定义,并且生成了以各种变成语言语言实现模式的类。应用程序可以调用此生成的代码来对模式的记录进行编码或解码。

Thrift有两种不同的二进制编码格式

1.BinaryProtocol

image-20230819115332497

  • 每个字段都有一个类型注解,还可以根据需要指定长度,出现在数据中的字符串被编码为ASCII
  • 没有字段名,编码数据包含字段标签,就像字段的别名,采用紧凑的方式而不必拼出字段名称

2.CompactPotocol

image-20230819115345835

  • 将相同信息打包成只有34个字节,它通过将字段类型和标签号打包到单个字节中,并使用可变长度整数来实现。每个字节的最高位用来指示是否还有更多的字节

Protocol Buffers对相同的数据进行编码:

image-20230819115357622

字段设置为required与否对于编码没有任何影响,区别在于设置为required的字段如果没有被找到,则运行时检查将失败

字段标签和模式的演变

Thrift和Protocol Buffers如何处理模式更改,同时保持向后兼容性?

字段标记对编码数据的含义至关重要,你可以更改架构中字段的名称,因为编码的数据永远不会引用字段名称,但不能更改字段的标记,因为这回使所有现有的编码数据无效

  • 向前兼容性:可以添加新的字段到架构,只要给每个字段一个新的标签号码。如果旧代码视图读取新代码写入的数据,包含新的字段,其标签号不能之别,可以简单的忽略该字段,并根据数据类型的注释确定要跳过的字节数,使得旧代码可以读取新代码编写的记录
  • 向后兼容性:只要每个字段都有唯一的标签号码,新的代码总是可以读取旧的数据,唯一的细节是,不能将新添加的字段设置为必须,否则新代码读取旧代码写入的数据时,会因为旧代码不会写入新添加的必须字段而检查失败 为了保持向后兼容性,在模式初始部署之后添加的每个字段必须是可选的或具有默认值

删除一个字段就像添加一个字段,只是这回要考虑的是向前兼容性,只能删除可选的字段,不能删除必须的字段。并且不能再次使用相同的标签号码(因为可能仍然后旧数据写在包含旧标签号码的地方,而该字段必须被新代码忽略)

数据类型和模式演变

改变字段的数据类型是可能的,但是可能导致值失去精度或被截短,例如32位整数和64位整数之间的转换

Protobuf没有列表或数组数据类型,而是用字段重复标记repeated

Thrift有专门的列表数据类型,这不允许Protobuf所做的从单值到多值的转变,但是具有支持嵌套列表的优点

Avro

Avro也使用模式来指定正在编码的数据的结构,有两种模式语言

  1. Avro IDL 用于人工编辑

    record Person {
        string                userName;
        union { null, long }  favoriteNumber = null;
        array<string>         interests;
    }
    
  2. 一种基于JSON更易于机器读取

    {
        "type": "record",
        "name": "Person",
        "fields": [
            {"name": "userName", "type": "string"},
            {"name": "favoriteNumber", "type": ["null", "long"], "default": null},
            {"name": "interests", "type": {"type": "array", "items": "string"}}
        ]
    }
    

Avro二进制编码只有32个字节长

image-20230819115412404

编码只是由连载一起的值组成。一个字符串只是一个长度前缀,后跟UTF-8字节。但在被包含的数据中没有任何内容指出它是一个字符串,也可以是一个整数。

为了解析二进制数据,按照它们出现在模式中的顺序遍历这些字段,并使用模式来告诉每个字段的类型。这意味着读取数据的代码使用与写入数据的代码完全相同的模式才能正确解码二进制数据

Writer模式和Reader模式

Writer模式:编码数据时使用的模式

Reader模式:解码数据时使用的模式

Avro的关键思想是Writer模式和Reader模式不必是相同的,他们只需要兼容。Avro库通过并排查Writer模式和Reader模式并将数据从Writer模式转换到Reader模式来解决差异,具体由Avro规范规定

例如:Writer模式和Reader模式通过字段名匹配字段,如果读取的代码遇到Writer中有Reader中没有的字段则忽略它,如果Reader模式需要某个字段但是Writer没有则使用在Reader中声明的默认值填充

模式演变规则

为了保持兼容性,只能添加或删除具有默认值的字段

  • 如果要添加一个没有默认值的字段,新的Reader将无法读取旧Writer写的数据,破坏向后兼容性
  • 删除没有默认值的字段,旧的Reader将无法读取新Writer写入的数据,破坏向前兼容性

Writer模式到底是什么

对于一段特定的编码数据,Reader如何知道其Writer模式。如果将整个模式包括在每个记录中,因为模式可能比编码的数据大得多,从而二进制节省的空间都是徒劳

答案取决于Avro使用的上下文

  • 有很多记录的大文件 很多大文件中,所有的记录都使用相同的模式进行编码,可以在文件的开头只包含一次Writer模式

  • 支持独立写入的记录数据库

    所有记录很难有相同的模式,最简单的方式是每个编码记录的开始处包含一个版本号,数据库中保留一个模式版本列表,Reader从记录中提取版本号从而获取Writer模式,例如Espresso

  • 通过网络连接发送记录

    两个进程通过双向网络连接进行通信时,他们可以在连接设置上协商模式版本,然后再连接的生命周期中使用该模式。例如Avro RPC

动态生成的模式

Avro方法的一个优点是架构不包含任何标签号码

Avro对动态生成的模式更为友善,假如你需要把一个关系数据库的内容转储到一个文件中,你可以很容易的从关系模式生成一个Avro模式,并用该模式进行编码,当关系数据库发生改变时,则可以生成新的Avro模式来导出数据。因为字段是通过名字来标识的,更新的Writer模式仍然可以与旧的Reader模式匹配

如果使用Thrit或Protocol Buffers的话字段标签可能必须手动分配,每次数据库模式更改时管理员都必须手动更新从数据库列名到字段标签的映射

代码生成和动态类型的语言

Thrift和Protobuf依赖于代码生成,在定义了模式之后可以使用你选择的编程语言生成实现此模式的代码。在静态类型语言中,有助于将高效的内存中的数据结构用于解码数据,并且在编写访问数据结构的程序时允许在IDE中进行检查和自动补全

在动态类型编程语言中,生成代码没有太多意义,因为没有编译时的类型检查

Avro为金泰类型编程语言提供了可选的代码生成功能,但是它也可以在不生成任何代码的情况下使用。如果你有一个对象容器文件(它嵌入了 Writer 模式),你可以简单地使用 Avro 库打开它,并以与查看 JSON 文件相同的方式查看数据。该文件是自描述的,因为它包含所有必要的元数据。

模式的优点

Protocol Buffers、Thrift和Avro等比XML模式或者JSON模式简单的多,后者支持更详细的验证规则。由于前者实现起来更简单,使用起来也更简单,所以也发展到了支持相当广泛的编程语言

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

基于模式的二进制编码的优点:

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

模式演化保持了与JSON数据库提供的无模式/读时模式相同的灵活性

数据流的类型

数据可以通过多种方式从一个流程流向另一个流程

数据在流程之间流动的一些常见的方式:

  • 通过数据库
  • 通过服务调用
  • 通过异步消息传递

数据库中的数据流

几个不同的进程同时访问数据库是常见的,几个进程可能是不同应用程序或服务,也可能是几个相同的服务实例。无论那种方式都可能存在某些进程运行较新的代码,某些实例运行较旧的代码。所以显然数据库需要向前兼容和向后兼容

需要意识到一个问题是,加入旧版本代码读取并更新新代码写入的记录时,旧代码没有的新字段应该保持不变。许多编码格式支持未知字段的保存,但有时候需要在应用程序层面保持谨慎,要防止新字段的丢失

在不同的时间写入不同的值

数据库中的值,有一些可能是五秒钟前写的,也有一些可能是五年前写的。

部署新应用程序可以在短时间将旧版本程序替换为新版本。但数据库不是如此,对五年前的数据,除非进行显式地重写,否则它仍然会以原始编码形式存在,这也被称作:数据的生命周期超出代码的生命周期

许多时候进行数据重写代价是昂贵的,所以许多数据库允许进行简单的模式更改来避免重写

模式演变允许整个数据库看起来好像是用单个模式编码的,即使底层存储可能包含用各种历史版本的模式编码的记录

归档存储

备份或转储数据库时可以对数据拷贝进行一致的编码

由于数据转储是一次写入的,而且以后是不可变的,所以 Avro 对象容器文件等格式非常适合。这也是一个很好的机会,可以将数据编码为面向分析的列式格式,例如 Parquet

服务中的数据流:REST与RPC

最常见的通行是客户端和服务器的通信

服务器本身可以是另外一个服务的客户端,例如web应用服务器充当数据库的客户端,这种方法通常用于将大型应用程序按照功能区域分解为较小的服务,当一个服务需要来自另外有一个服务的某些功能或数据时,会向另一个服务发出请求,这种某件应用程序的方式传统上被称为面向服务的体系结构(SOA) ,最近被改进和更名为微服务架构

面向服务/微服务架构的一个关键设计目标是通过使服务独立部署和演化来使应用程序更易于更改和维护。换句话说就是期望服务器和客户端的旧版本和新版本同时运行,因此服务器和客户端使用的数据编码必须在不同版本的服务API之间兼容

Web服务

当服务使用HTTP作为低层通信协议时,可称之为Web服务。

Web服务不仅在Web上使用,而且在几个不同的环境中使用,例如:

  • 运行在用户设备上的客户端应用程序通过HTTP向服务发出请求
  • 一种服务向同一组织拥有的另一项服务提出请求,这些服务通常位于同一数据中心内,作为面向服务/微服务架构的一部分(支持这种用的软件有时被称为中间件(middleware)
  • 一种服务通过互联网像不同组织所拥有的服务提出请求

两种流行的Web服务方法:REST和SOAP

  • REST不是一个协议,而是一个基于HTTP原则的设计哲学,强调简单的数据类型,使用URL来标识资源,并使用HTTP功能进行缓存控制,身份验证和内容类型协商。根据REST原则设计的API称为RESTful
  • SOAP是用于制作网络API请求的基于XML的协议。
    SOAP Web服务的API使用称为Web服务描述语言(WSDL)的基于XML的语言来描述。WSDL支持代码生成,客户端可以使用本地类和方法调用(编码为XML消息并由框架再次阶码)访问远程服务

由于WSDL的设计不是人类可读的,而且由于SOAP消息通常因为过于复杂而无法手动构建,所以SOAP的用户在很大程度上依赖于工具支持、代码生成和IDE。对于SOPA供应商不支持的编程语言的用户来说,与SOAP服务的集成是困难的

尽管 SOAP 及其各种扩展表面上是标准化的,但是不同厂商的实现之间的互操作性往往会造成问题。由于所有这些原因,尽管许多大型企业仍然使用 SOAP,但在大多数小公司中已经不再受到青睐

REST 风格的 API 倾向于更简单的方法,通常涉及较少的代码生成和自动化工具。定义格式(如 OpenAPI,也称为 Swagger )可用于描述 RESTful API 并生成文档

RPC的问题

网络请求与本地函数调用非常不同

  • 本地函数调用是可预测的,并且成功或失败仅取决于受你控制的参数。网络请求是不可预测的,请求或响应可能由于网络问题会丢失,或者远程计算机可能很慢或不可用
  • 本地函数调用要么返回结果,要么抛出异常,要么永远不返回。网络请求可能由于超时返回时可能没有结果
  • 如果重试失败的网络请求,可能会发生请求实际上已经完成,只是响应丢失的情况。在这种情况下,重试将导致该操作被执行多次,除非你在协议中建立幂等性(idempotence)
  • 每次调用本地函数大致执行相同的时间,网络请求比函数调用要慢得多,而且延迟也非常可变
  • 调用本地函数时,可以高效地将引用传递给本地内存中的对象。当你发出一个请求时,所有这些参数都需要被编码成可以通过网络发送的一系列字节
  • 客户端和服务端可以用不同的编程语言实现,所以RPC框架必须将数据类型从一种语言翻译成另一种语言。这可能会变得很丑陋

RPC的当前方向

  • gPRC是使用Protocol Buffers的RPC实现
  • Finagle也使用Thrift
  • Rest.li使用JSON over HTTP

新一代的RPC框架更加明确远程请求与本地函数调用的不同

  • Fingle和Resi.li使用futures来封装可能失败的异步操作,Futures还可以简化需要并行发出多项服务并将结果合并的情况
  • gRPC支持流,其中一个调用不仅包括一个请求和一个响应,还可以是随时间进行的一些列请求和响应

其中一些框架还提供服务发现,即允许客户端找出在哪个IP地址和端口号上可以找到特定的服务

使用二进制编码格式的自定义RPC协议可以实现比通用的JSON over REST更好的性能。但RESTful API也有一些优点:方便实验和调试、能被所有主流的编程语言和平台所支持,还有大量可用的工具的生态系统

由于这些原因,REST是公共API的主要风格,RPC框架的主要重点在于同一组织拥有的服务之间的请求,通常在同一数据中心内

数据编码与RPC的演化

对于可演化性,重要的是可以独立更改和部署RPC客户端和服务器

假设所有服务器都会先更新,其次是所有的客户端,因此只需要在请求上具有后向兼容,并且对响应具有前向兼容

RPC方案的前后向兼容性属性从它使用的编码方式中继承

  • Thrift、gRPC(Protobuf)和 Avro RPC 可以根据相应编码格式的兼容性规则进行演变。
  • 在 SOAP 中,请求和响应是使用 XML 模式指定的。这些可以演变,但有一些微妙的陷阱。
  • RESTful API 通常使用 JSON(没有正式指定的模式)用于响应,以及用于请求的 JSON 或 URI 编码 / 表单编码的请求参数。添加可选的请求参数并向响应对象添加新的字段通常被认为是保持兼容性的改变。

RPC经常用于跨越组织边界的通信,所以服务的兼容性变得更加困难,需要长期甚至无限期的保持兼容性。如果需要兼容性进行更改,则服务提供商通常会维护多个版本的服务API

消息传递中的数据流

与直接RPC相比,使用消息代理有几个有点

  • 如果收件人不可用或过载,可以充当缓冲区,提高系统的可靠性
  • 可以自动将消息重新发送到已经崩溃的进程,从而防止消息丢失
  • 避免发件人需要知道收件人的IP地址和端口号
  • 允许一条消息发送给多个收件人
  • 收件人与发件人逻辑分离

差异在于消息传递通信通常是单向的,发送者不期望收到消息的恢复,只是发送它然后忘记它

消息代理

一个进程将消息发送到指定的队列或主题,代理确保将消息传递给那个队列或主题的一个或多个消费者或订阅者

一个主题只提供单向数据流,但消费者还可以将消息发布到另一个主题上

消息代理通常不会执行任何特定的数据模型——消息只是包含一些元数据和字节序列,因此你可以使用任何编码格式

如果消费者重新发布消息到另一个主题,可能需要小心保留未知字段

分布式的Actor框架

Actor模型是单个进程中并发的编程模型。逻辑被封装在Actor中,而不是直接处理线程。每个 actor 通常代表一个客户或实体,它可能有一些本地状态(不与其他任何角色共享),它通过发送和接收异步消息与其他角色通信。不保证消息传送:在某些错误情况下,消息将丢失。由于每个角色一次只能处理一条消息,因此不需要担心线程,每个角色可以由框架独立调度。

在分布式 Actor 框架中,此编程模型用于跨多个节点伸缩应用程序。不管发送方和接收方是在同一个节点上还是在不同的节点上,都使用相同的消息传递机制。如果它们在不同的节点上,则该消息被透明地编码成字节序列,通过网络发送,并在另一侧解码。

分布式的 Actor 框架实质上是将消息代理和 actor 编程模型集成到一个框架中。但是,如果要执行基于 actor 的应用程序的滚动升级,则仍然需要担心向前和向后兼容性问题,因为消息可能会从运行新版本的节点发送到运行旧版本的节点,反之亦然。

小结

本章研究了将数据结构转换为网络正宗的字节或磁盘上的字节的几种方法。看到编码的细节不仅影响其效率,更重要的是影响了应用程序的体系结构和部署它们的选项

在服务滚动升级期间,我们必须假设不同的节点正在运行我们的应用程序代码的不同版本,所以数据编码方式需要考虑向前兼容性和向后兼容性

我们讨论了几种数据编码格式及其兼容性属性:

  • 编程语言特定的编码仅限于单一编程语言,并且往往无法提供前向和后向兼容性。
  • JSON、XML 和 CSV 等文本格式非常普遍,其兼容性取决于你如何使用它们。他们有可选的模式语言,这有时是有用的,有时是一个障碍。这些格式对于数据类型有些模糊,所以你必须小心数字和二进制字符串。
  • 像 Thrift、Protocol Buffers 和 Avro 这样的二进制模式驱动格式允许使用清晰定义的前向和后向兼容性语义进行紧凑,高效的编码。这些模式可以用于静态类型语言的文档和代码生成。但是,他们有一个缺点,就是在数据可读之前需要对数据进行解码。

我们还讨论了数据流的几种模式,说明了数据编码重要性的不同场景:

  • 数据库,写入数据库的进程对数据进行编码,并从数据库读取进程对其进行解码
  • RPC 和 REST API,客户端对请求进行编码,服务器对请求进行解码并对响应进行编码,客户端最终对响应进行解码
  • 异步消息传递(使用消息代理或参与者),其中节点之间通过发送消息进行通信,消息由发送者编码并由接收者解码