ch5-编码与演化

第五章 编码与演化

万物流转,无物常驻。
—— 赫拉克利特(以弗所),柏拉图《克拉底鲁篇》引述(公元前 360 年)


早期版本读者须知

早期版本电子书让您能在作者撰写过程中获取最新内容——这是未经编辑的原始素材,让您能在正式出版前提前掌握这些技术。

本章将是最终书籍的第五章。本书的 GitHub 仓库地址为:https://github.com/ept/ddia2-feedback

如果您希望积极参与本草案的审阅和评论,请在 GitHub 上联系我们。


5.1 变化的必然性

应用程序不可避免地会随时间演变。随着新产品上线、用户需求被更好地理解或商业环境变化,功能会被添加或修改。在第二章中,我们介绍了可演化性的概念:我们应该致力于构建易于适应变化的系统(参见”可演化性:让改变变得简单”)。

在大多数情况下,应用程序功能的变更也需要其存储数据的变更:可能需要捕获新的字段或记录类型,或者需要以新的方式呈现现有数据。

我们在第三章讨论的数据模型有不同的方式来应对这种变化。关系型数据库通常假设数据库中的所有数据都符合单一模式:虽然该模式可以更改(通过模式迁移,即 ALTER 语句),但在任何时间点只有一个模式生效。相比之下,读时模式(“无模式”)数据库不强制执行模式,因此数据库可以包含在不同时间写入的、新旧数据格式的混合(参见”文档模型中的模式灵活性”)。

当数据格式或模式发生变化时,通常需要相应更改应用程序代码(例如,向记录添加新字段,应用程序代码开始读写该字段)。然而,在大型应用程序中,代码变更往往无法瞬时完成:

  • 服务端应用:您可能希望执行滚动升级(也称分阶段推出),将新版本逐个部署到部分节点,检查新版本是否平稳运行,然后逐步覆盖所有节点。这允许在不中断服务的情况下部署新版本,从而鼓励更频繁的发布和更好的可演化性。

  • 客户端应用:您受制于用户,他们可能在相当长的时间内不会安装更新。

这意味着旧版本和新版本的代码,以及旧格式和新格式的数据,可能同时在系统中共存。为了让系统继续平稳运行,我们需要在双向保持兼容性:

兼容性类型定义
向后兼容新代码可以读取旧代码写入的数据
向前兼容旧代码可以读取新代码写入的数据

向后兼容通常不难实现:作为新代码的作者,您知道旧代码写入的数据格式,因此可以显式处理(如有必要,只需保留旧代码来读取旧数据)。向前兼容可能更棘手,因为它要求旧代码忽略新版本代码所做的添加。

向前兼容的另一个挑战如图 5-1 所示。假设您向记录模式添加了一个字段,新代码创建包含该新字段的记录并将其存储在数据库中。随后,旧版本的代码(尚不知道该新字段)读取记录、更新并写回。在这种情况下,期望的行为通常是旧代码保持新字段完整,即使它无法解释该字段。但如果记录被解码为不显式保留未知字段的模型对象,数据可能会丢失,如图 5-1 所示。

图5-1:当旧版本应用更新由新版本应用先前写入的数据时,如果不小心可能会导致数据丢失

在本章中,我们将研究几种数据编码格式,包括 JSON、XML、Protocol Buffers 和 Avro。特别是,我们将研究它们如何处理模式变更,以及如何支持旧数据和新数据、旧代码和新代码需要共存的系统。然后我们将讨论这些格式如何用于数据存储和通信:在数据库、Web 服务、REST API、远程过程调用(RPC)、工作流引擎,以及事件驱动的系统(如 Actor 和消息队列)中。


5.2 数据编码格式

程序通常以(至少)两种不同的表示形式处理数据:

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

  2. 传输/存储时:当您想将数据写入文件或通过网络发送时,必须将其编码为某种自包含的字节序列(例如 JSON 文档)。由于指针对其他进程没有意义,这种字节序列表示通常与内存中常用的数据结构看起来截然不同。

因此,我们需要在这两种表示之间进行某种转换。从内存表示转换为字节序列称为编码(也称序列化或编组),反向过程称为解码(解析、反序列化、解编组)。

术语冲突:不幸的是,“序列化”在事务上下文中也有使用(见第八章),含义完全不同。为避免一词多义,本书将使用”编码”,尽管”序列化”可能是更常见的术语。

有些情况下不需要编码/解码——例如,当数据库直接对从磁盘加载的压缩数据进行操作时(参见”查询执行:编译与向量化”)。也有零拷贝数据格式,如 Cap’n Proto 和 FlatBuffers,它们被设计为在运行时和磁盘/网络上使用,无需显式转换步骤。

然而,大多数系统需要在内存对象和平面字节序列之间转换。由于这是如此常见的问题,有无数不同的库和编码格式可供选择。让我们简要概述一下。


5.2.1 语言特定格式

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

这些编码库非常方便,因为它们允许用最少的额外代码保存和恢复内存对象。然而,它们也存在一些深层次的问题:

问题说明
语言绑定编码通常与特定编程语言绑定,用另一种语言读取数据非常困难
安全隐患解码过程需要实例化任意类,这通常是安全问题的根源。攻击者可能让应用解码任意字节序列,实例化任意类,从而可能远程执行任意代码
版本控制数据版本控制通常是事后考虑:由于它们旨在快速简便地编码数据,往往忽视向前和向后兼容这些棘手问题
效率低下编码/解码的 CPU 时间和编码结构的大小通常也是事后考虑。例如,Java 内置序列化以其糟糕的性能和臃肿的编码而臭名昭著

结论:出于这些原因,将语言内置编码用于除非常临时性目的之外的任何用途通常是个坏主意。


5.2.2 JSON、XML 及其二进制变体

转向可由多种编程语言读写标准化编码时,JSON 和 XML 是明显的候选者。它们广为人知、广泛支持,也几乎同样被广泛诟病。XML 常被批评为过于冗长和 unnecessarily 复杂。JSON 的流行主要归功于其在 Web 浏览器中的内置支持和相对于 XML 的简单性。CSV 是另一种流行的语言无关格式,但它仅支持无嵌套的表格数据。

JSON、XML 和 CSV 是文本格式,因此具有一定的可读性(尽管语法是热门辩论话题)。除了表面的语法问题,它们还有一些微妙的问题:

问题详细说明
数字编码歧义XML 和 CSV 中无法区分数字和碰巧由数字组成的字符串(除非引用外部模式)。JSON 区分字符串和数字,但不区分整数和浮点数,也不指定精度
大数精度大于 2⁵³ 的整数在 IEEE 754 双精度浮点数中无法精确表示,因此在 JavaScript 等使用浮点数的语言中解析时会变得不准确
二进制字符串JSON 和 XML 对 Unicode 字符串(人类可读文本)支持良好,但不支持二进制字符串(无字符编码的字节序列)。人们通过 Base64 将二进制数据编码为文本来绕过此限制,但这有点 hacky,且数据大小增加 33%
模式复杂性XML Schema 和 JSON Schema 功能强大,但学习和实现相当复杂
CSV 的模糊性CSV 没有任何模式,由应用定义每行每列的含义。应用变更添加新行列时需要手动处理。CSV 格式也相当模糊(值包含逗号或换行符时怎么办?)

尽管有这些缺陷,JSON、XML 和 CSV 对许多用途来说已经足够好。它们可能会继续流行,特别是作为数据交换格式(即从一个组织向另一个组织发送数据)。在这些情况下,只要人们就格式达成一致,格式的美观或效率往往不如让不同组织就任何事达成一致那么重要。


JSON Schema

JSON Schema 已成为在系统间交换数据或写入存储时建模数据的广泛采用方式。您可以在以下场景中找到 JSON Schema:

  • Web 服务(参见”Web 服务”),作为 OpenAPI Web 服务规范的一部分
  • 模式注册表,如 Confluent 的 Schema Registry 和 Red Hat 的 Apicurio Registry
  • 数据库,如 PostgreSQL 的 pg_jsonschema 验证器扩展和 MongoDB 的$jsonSchema 验证器语法

JSON Schema 规范提供多种功能。模式包括标准原始类型(字符串、数字、整数、对象、数组、布尔值或 null)。JSON Schema 还提供单独的验证规范,允许开发者在字段上叠加约束。例如,端口字段可能具有 1 到 65535 的最小值和最大值。

JSON Schema 可以有开放封闭内容模型:

  • 开放内容模型:允许模式中未定义的任何字段以任何数据类型存在(additionalProperties: true,默认)
  • 封闭内容模型:仅允许显式定义的字段

因此,JSON Schema 通常是定义什么不被允许(即任何定义字段上的无效值),而不是模式中允许什么。

示例 5-1:具有整数键和字符串值的 JSON Schema 示例(整数键表示为仅包含整数的字符串,因为 JSON Schema 要求所有键都是字符串)

{
  "$schema": "http://json-schema.org/draft-07/schema#",
  "type": "object",
  "patternProperties": {
    "^[0-9]+$": {
      "type": "string"
    }
  },
  "additionalProperties": false
}

JSON Schema 还支持条件 if/else 模式逻辑、命名类型、对远程模式的引用等。所有这些构成了一个非常强大的模式语言,但也使得定义变得笨重。解析远程模式、推理条件规则或以向前或向后兼容的方式演化模式都可能具有挑战性。


二进制编码

JSON 比 XML 简洁,但两者与二进制格式相比仍然占用大量空间。这导致了 JSON(MessagePack、CBOR、BSON、BJSON、UBJSON、BISON、Hessian、Smile 等)和 XML(WBXML、Fast Infoset 等)的二进制编码大量涌现。这些格式在特定领域被采用,因为它们更紧凑且有时解析更快,但没有一个像 JSON 和 XML 的文本版本那样被广泛采用。

其中一些格式扩展了数据类型集(例如区分整数和浮点数,或添加对二进制字符串的支持),但除此之外保持 JSON/XML 数据模型不变。特别是,由于它们不规定模式,需要在编码数据中包含所有对象字段名。

示例 5-2:本章将在几种二进制格式中编码的示例记录

{
    "userName": "Martin",
    "favoriteNumber": 1337,
    "interests": ["daydreaming", "hacking"]
}

MessagePack为例(JSON 的二进制编码):

编码后的字节序列解释:

  • 第一字节0x83:表示接下来是一个对象(高四位=0x80),有三个字段(低四位=0x03)
  • 第二字节0xa8:表示接下来是字符串(高四位=0xa0),长度为 8 字节(低四位=0x08)
  • 接下来 8 字节:字段名userName的 ASCII 编码
  • 依此类推…

二进制编码为 66 字节,仅比文本 JSON 编码的 81 字节(去除空白后)略少。所有 JSON 的二进制编码在这方面都类似。如此小的空间减少(以及可能的解析速度提升)是否值得损失可读性尚不明确。

在后续章节中,我们将看到如何做得更好——仅用 32 字节编码相同记录。


5.2.3 Protocol Buffers

Protocol Buffers(protobuf)是 Google 开发的二进制编码库。它与 Facebook 最初开发的 Apache Thrift 类似;本节关于 Protocol Buffers 的大部分内容也适用于 Thrift。

Protocol Buffers 要求对任何编码的数据都有模式。要用 Protocol Buffers 编码示例 5-2 的数据,您需要按如下方式在 Protocol Buffers 接口定义语言(IDL)中描述模式:

syntax = "proto3";

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

Protocol Buffers 附带代码生成工具,获取上述模式定义后,生成在各种编程语言中实现模式的类。您的应用程序代码可以调用此生成代码来编码或解码记录。

使用 Protocol Buffers 编码器编码示例 5-2 需要33 字节(如图 5-3 所示)。

图5-3:使用Protocol Buffers编码的示例记录

与图 5-2 类似,每个字段都有类型注释(指示是字符串、整数等),并在需要时有长度指示(如字符串长度)。数据中出现的字符串(“Martin”、“daydreaming”、“hacking”)也编码为 ASCII(准确说是 UTF-8)。

与图 5-2 的大区别是没有字段名(userName、favoriteNumber、interests)。相反,编码数据包含字段标签(数字 1、2、3)。这些就是模式定义中出现的数字。字段标签就像字段的别名——是一种紧凑的方式来说明我们在谈论哪个字段,而无需拼出字段名。

Protocol Buffers 通过将字段类型和标签号打包到单个字节中来节省更多空间。它使用变长整数:数字 1337 用两个字节编码,每个字节的最高位用于指示是否还有更多字节。这意味着-64 到 63 之间的数字用一个字节编码,-8192 到 8191 之间的数字用两个字节编码,以此类推。更大的数字使用更多字节。

Protocol Buffers 没有显式的列表或数组数据类型。相反,interests字段上的repeated修饰符表示该字段包含值列表,而非单个值。在二进制编码中,列表元素简单地表示为同一记录中同一字段标签的重复出现。


字段标签与模式演化

模式不可避免地需要随时间变化。我们称之为模式演化。Protocol Buffers 如何在保持向后和向前兼容的同时处理模式变更?

从示例可以看出,编码记录只是其编码字段的连接。每个字段由其标签号(示例模式中的数字 1、2、3)标识,并带有数据类型注释(如字符串或整数)。如果未设置字段值,它就从编码记录中省略。由此可见,字段标签对编码数据的含义至关重要。您可以更改模式中的字段名,因为编码数据从不引用字段名;但不能更改字段的标签,因为那会使所有现有编码数据无效。

您可以向模式添加新字段,只要给每个字段一个新的标签号。如果旧代码(不知道您添加的新标签号)尝试读取新代码写入的数据,包括一个它不认识的新标签号的字段,它可以简单地忽略该字段。数据类型注释允许解析器确定需要跳过多少字节,并保留未知字段以避免图 5-1 中的问题。这保持了向前兼容:旧代码可以读取新代码写入的记录。

向后兼容呢?只要每个字段有唯一的标签号,新代码总能读取旧数据,因为标签号仍然有相同的含义。如果新模式中添加了字段,而您读取的旧数据尚不包含该字段,它就用默认值填充(例如,如果字段类型是字符串则为空字符串,如果是数字则为零)。

删除字段就像添加字段一样,只是向后和向前兼容性问题相反。您永远不能重复使用相同的标签号,因为某处可能仍有包含旧标签号的数据,新代码必须忽略该字段。过去使用的标签号可以在模式定义中保留,以确保不会被遗忘。

更改字段的数据类型呢?某些类型之间是可能的——请查看文档了解详情——但存在值被截断的风险。例如,将 32 位整数更改为 64 位整数:新代码可以轻松读取旧代码写入的数据,因为解析器可以用零填充缺失的位。但如果旧代码读取新代码写入的数据,旧代码仍使用 32 位变量保存值。如果解码后的 64 位值不适合 32 位,它将被截断。


5.2.4 Avro

Apache Avro是另一种二进制编码格式,与 Protocol Buffers 有显著不同。它于 2009 年作为 Hadoop 的子项目启动,源于 Protocol Buffers 不适合 Hadoop 用例的结果。

Avro 也使用模式来指定被编码数据的结构。它有两种模式语言:一种(Avro IDL)供人类编辑,一种(基于 JSON)更易于机器读取。与 Protocol Buffers 一样,这种模式语言仅指定字段及其类型,不像 JSON Schema 那样支持复杂的验证规则。

我们的示例模式用 Avro IDL 编写可能如下所示:

record Person {
    string               userName;
    union { null, long } favoriteNumber = null;
    array<string>        interests;
}

该模式的等效 JSON 表示如下:

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

首先,注意模式中没有标签号。如果我们使用此模式编码示例记录(示例 5-2),Avro 二进制编码只有32 字节长——是我们见过的最紧凑的编码。编码字节序列的分解如图 5-4 所示。

图5-4:使用Avro编码的示例记录

如果检查字节序列,您会发现没有任何内容标识字段或其数据类型。编码只是值连接在一起。字符串只是长度前缀后跟 UTF-8 字节,但编码数据中没有内容告诉您这是字符串。它同样可能是整数或其他任何东西。整数使用变长编码。

要解析二进制数据,您按照模式中字段出现的顺序遍历字段,并使用模式告诉您每个字段的数据类型。这意味着二进制数据只有在使用与写入数据代码完全相同的模式读取时才能正确解码。读取器和写入器之间的模式任何不匹配都意味着数据解码错误。

那么,Avro 如何支持模式演化?


写入器模式与读取器模式

当应用程序想要编码一些数据(写入文件或数据库,通过网络发送等)时,它使用它所知道的任何版本的模式进行编码——例如,该模式可能编译到应用程序中。这称为写入器模式

当应用程序想要解码一些数据(从文件或数据库读取,从网络接收等)时,它使用两个模式:与编码时相同的写入器模式,以及可能不同的读取器模式(如图 5-5 所示)。读取器模式定义应用程序代码期望的每个记录的字段及其类型。

图5-5:在Protocol Buffers中,编码和解码可以使用不同版本的模式。在Avro中,解码使用两个模式:写入器模式必须与编码时使用的相同,但读取器模式可以是旧版本或新版本

如果读取器和写入器模式相同,解码很容易。如果不同,Avro 通过并排查看写入器模式和读取器模式,将数据从写入器模式转换为读取器模式来解决差异。Avro 规范精确定义了这种解析的工作原理,如图 5-6 所示。

图5-6:Avro读取器解析写入器模式和读取器模式之间的差异

例如,如果写入器模式和读取器模式的字段顺序不同,这没有问题,因为模式解析通过字段名匹配字段。如果读取数据的代码遇到写入器模式中有但读取器模式中没有的字段,它会被忽略。如果读取数据的代码期望某个字段,但写入器模式不包含该名称的字段,它就用读取器模式中声明的默认值填充。


模式演化规则

在 Avro 中,向前兼容意味着您可以有新版本的模式作为写入器,旧版本的模式作为读取器。相反,向后兼容意味着您可以有新版本的模式作为读取器,旧版本作为写入器。

要保持兼容性,您只能添加或删除具有默认值的字段。(我们 Avro 模式中的favoriteNumber字段默认值为null。)例如,假设您添加一个具有默认值的字段,因此该新字段存在于新模式但不存在于旧模式中。当使用新模式的读取器读取用旧模式写入的记录时,缺失字段用默认值填充。

如果您要添加没有默认值的字段,新读取器将无法读取旧写入器写入的数据,因此您会破坏向后兼容性。如果您要删除没有默认值的字段,旧读取器将无法读取新写入器写入的数据,因此您会破坏向前兼容性。

在某些编程语言中,null是任何变量的可接受默认值,但在 Avro 中并非如此:如果您想允许字段为null,必须使用联合类型。例如,union { null, long, string } field;表示字段可以是数字、字符串或null。只有当null是联合的第一个分支时,才能使用null作为默认值。这比默认所有内容都可空更冗长,但通过明确什么可以为空、什么不能,有助于防止错误。

更改字段的数据类型是可能的,只要 Avro 可以转换该类型。更改字段名是可能的,但有点棘手:读取器模式可以包含字段名的别名,因此它可以将旧写入器模式的字段名与别名匹配。这意味着更改字段名是向后兼容但不是向前兼容的。类似地,向联合类型添加分支是向后兼容但不是向前兼容的。


但是,写入器模式是什么?

有一个重要问题我们迄今忽略了:读取器如何知道用于编码特定数据的写入器模式?我们不能在每个记录中包含整个模式,因为模式可能比编码数据大得多,使二进制编码的所有空间节省变得徒劳。

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

场景解决方案
包含大量记录的大文件文件的写入器只需在文件开头包含一次写入器模式。Avro 指定了文件格式(对象容器文件)来做到这一点
单独写入记录的数据库在每个编码记录的开头包含版本号,并在数据库中保留模式版本列表。读取器可以获取记录,提取版本号,然后从数据库获取该版本号的写入器模式
通过网络连接发送记录两个进程在连接建立时可以协商模式版本,然后在连接生命周期内使用该模式。Avro RPC 协议就是这样工作的

模式版本数据库无论如何都是有用的东西,因为它充当文档,并让您有机会检查模式兼容性。作为版本号,您可以使用简单的递增整数,或模式的哈希值。


动态生成的模式

与 Protocol Buffers 相比,Avro 方法的一个优势是模式不包含任何标签号。但这为什么重要?在模式中保留几个数字有什么问题?

区别在于 Avro 对动态生成的模式更友好。例如,假设您有一个关系数据库,其内容您想转储到文件中,并且您想使用二进制格式来避免上述文本格式(JSON、CSV、XML)的问题。如果使用 Avro,您可以相当容易地从关系模式生成 Avro 模式(使用我们之前看到的 JSON 表示),并使用该模式编码数据库内容,将其全部转储到 Avro 对象容器文件中。您可以为每个数据库表生成记录模式,每列成为该记录中的一个字段。数据库中的列名映射到 Avro 中的字段名。

现在,如果数据库模式发生变化(例如,一个表添加了一列,删除了另一列),您只需从更新的数据库模式生成新的 Avro 模式,并以新的 Avro 模式导出数据。数据导出过程不需要注意模式变化——它只需在每次运行时进行模式转换。任何读取新数据文件的人都会看到记录字段已更改,但由于字段通过名称标识,更新的写入器模式仍可以与旧的读取器模式匹配。

相比之下,如果您为此目的使用 Protocol Buffers,字段标签可能必须手动分配:每次数据库模式更改时,管理员必须手动更新从数据库列名到字段标签的映射。(这可能可以自动化,但模式生成器必须非常小心不要分配以前使用过的字段标签。)这种动态生成的模式根本不是 Protocol Buffers 的设计目标,而 Avro 的设计目标之一。


5.2.5 模式的优点

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

这些编码所基于的想法绝非新颖。例如,它们与ASN.1有很多共同之处,这是一种模式定义语言,于 1984 年首次标准化。它曾被用于定义各种网络协议,其二进制编码(DER)至今仍用于编码 SSL 证书(X.509)。ASN.1 使用标签号支持模式演化,类似于 Protocol Buffers。然而,它也非常复杂且文档糟糕,因此 ASN.1 可能不是新应用的好选择。

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

因此,我们可以看到,虽然 JSON、XML 和 CSV 等文本数据格式很普遍,但基于模式的二进制编码也是可行的选择。它们有许多优点:

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

总之,模式演化提供了与无模式/读时模式 JSON 数据库相同的灵活性(参见”文档模型中的模式灵活性”),同时为您的数据提供更好的保证和更好的工具。


5.3 数据流模式

在本章开头我们说,每当您想向不共享内存的另一个进程发送数据时——例如,每当您想通过网络发送数据或将其写入文件——您需要将其编码为字节序列。然后我们讨论了用于此目的的各种不同编码。

我们讨论了向前和向后兼容性,它们对可演化性很重要(通过允许您独立升级系统的不同部分,而不必同时更改所有内容,使变更变得容易)。兼容性是编码数据的一个过程和解码数据的另一个过程之间的关系。

这是一个相当抽象的概念——数据从一个过程流向另一个过程有多种方式。谁编码数据,谁解码数据?在本章的其余部分,我们将探讨过程间数据流的一些最常见方式:

  • 通过数据库(参见”通过数据库的数据流”)
  • 通过服务调用(参见”通过服务的数据流:REST 与 RPC”)
  • 通过工作流引擎(参见”持久执行与工作流”)
  • 通过异步消息(参见”事件驱动架构”)

5.3.1 通过数据库的数据流

在数据库中,写入数据库的过程编码数据,从数据库读取的过程解码数据。可能只有一个过程访问数据库,在这种情况下,读取器只是同一过程的稍后版本——在这种情况下,您可以将存储内容到数据库视为向未来的自己发送消息。

这里显然需要向后兼容;否则您未来的自己将无法解码您之前写入的内容。

通常,几个不同的过程同时访问数据库是很常见的。这些过程可能是几个不同的应用或服务,也可能只是同一服务的几个实例(并行运行以实现可扩展性或容错)。无论哪种方式,在应用变化的环境中,访问数据库的某些过程可能运行较新的代码,某些运行较旧的代码——例如,因为新版本目前正在滚动升级中部署,所以某些实例已更新而其他尚未更新。

这意味着数据库中的值可能由较新版本的代码写入,随后由仍在运行的较旧版本的代码读取。因此,数据库通常也需要向前兼容

不同时间写入的不同值

数据库通常允许随时更新任何值。这意味着在单个数据库中,您可能有一些值是五毫秒前写入的,有些值是五年前写入的。

部署应用的新版本(至少是服务端应用)时,您可能在几分钟内完全用新版本替换旧版本。数据库内容则不然:五年前的数据仍会在那里,以原始编码形式,除非您从那时起明确重写过它。这一观察有时被总结为数据比代码活得久

将数据重写(迁移)到新模式当然是可能的,但在大数据集上这样做代价高昂,因此大多数数据库尽可能避免。大多数关系数据库允许简单的模式变更,例如添加具有 null 默认值的新列,而无需重写现有数据。读取旧行时,数据库为磁盘编码数据中缺失的任何列填充 null。因此,模式演化允许整个数据库看起来像是用单一模式编码的,即使底层存储可能包含用各种历史版本模式编码的记录。

更复杂的模式变更——例如,将单值属性更改为多值,或将某些数据移动到单独的表——仍然需要重写数据,通常在应用层进行。在此类迁移中保持向前和向后兼容性仍然是一个研究问题。

归档存储

也许您不时对数据库进行快照,例如用于备份目的或加载到数据仓库(参见”数据仓库”)。在这种情况下,数据转储通常使用最新模式进行编码,即使源数据库中的原始编码包含来自不同时代的混合模式版本。既然您无论如何都在复制数据,您不妨一致地编码数据副本。

由于数据转储是一次性写入的,此后不可变,因此 Avro 对象容器文件等格式很合适。这也是将数据编码为对分析友好的列式格式(如 Parquet,参见”列压缩”)的好机会。


5.3.2 通过服务的数据流:REST 与 RPC

当您有需要通过网络通信的过程时,有几种不同的方式来安排这种通信。最常见的安排是有两个角色:客户端和服务器。服务器通过网络公开 API,客户端可以连接到服务器以向该 API 发出请求。服务器公开的 API 称为服务

Web 就是这样工作的:客户端(Web 浏览器)向 Web 服务器发出请求,发出 GET 请求下载 HTML、CSS、JavaScript、图像等,并发出 POST 请求向服务器提交数据。API 由一组标准化的协议和数据格式(HTTP、URL、SSL/TLS、HTML 等)组成。因为 Web 浏览器、Web 服务器和网站作者大多同意这些标准,您可以使用任何 Web 浏览器访问任何网站(至少在理论上!)。

Web 浏览器不是唯一的客户端类型。例如,在移动设备和台式计算机上运行的原生应用通常与服务器通信,在 Web 浏览器内运行的客户端 JavaScript 应用也可以发出 HTTP 请求。在这种情况下,服务器的响应通常不是供人类显示的 HTML,而是便于客户端应用代码进一步处理的编码数据(最常见的是 JSON)。尽管 HTTP 可用作传输协议,但上面实现的 API 是特定于应用的,客户端和服务器需要就该 API 的细节达成一致。

在某些方面,服务类似于数据库:它们通常允许客户端提交和查询数据。然而,虽然数据库允许使用我们在第三章讨论的查询语言进行任意查询,但服务公开的是特定于应用的 API,仅允许业务逻辑(服务应用代码)预定的输入和输出。这种限制提供了一定程度的封装:服务可以对客户端可以做什么和不能做什么施加细粒度限制。

面向服务/微服务架构的一个关键设计目标是通过使服务可独立部署和演化来使应用更易于变更和维护。一个常见原则是每项服务应由一个团队拥有,该团队应能够频繁发布服务的新版本,而无需与其他团队协调。因此,我们应该期望旧版本和新版本的服务器和客户端同时运行,因此服务器和客户端使用的数据编码必须在服务 API 的版本间保持兼容。


Web 服务

当 HTTP 用作与服务通信的底层协议时,称为Web 服务。Web 服务通常用于构建面向服务或微服务架构(前面在”微服务与无服务器”中讨论)。“Web 服务”这个术语可能有点用词不当,因为 Web 服务不仅用于 Web,还用于几种不同的上下文。例如:

场景说明
客户端应用在用户设备上运行的客户端应用(如移动设备上的原生应用或浏览器中的 JavaScript Web 应用)通过 HTTP 向服务发出请求。这些请求通常通过公共互联网
组织内服务间同一组织拥有的一个服务向另一个服务发出请求,通常位于同一数据中心内,作为面向服务/微服务架构的一部分
组织间服务一个服务向不同组织拥有的服务发出请求,通常通过互联网。这用于不同组织的后端系统之间的数据交换。此类别包括在线服务提供的公共 API,如信用卡处理系统或用于共享用户数据的 OAuth

最流行的服务设计理念是REST,它建立在 HTTP 原则之上。它强调简单的数据格式,使用 URL 标识资源,并使用 HTTP 功能进行缓存控制、认证和内容类型协商。按照 REST 原则设计的 API 称为RESTful

需要调用 Web 服务 API 的代码必须知道要查询哪个 HTTP 端点,以及在响应中发送和期望什么数据格式。即使服务采用 RESTful 设计原则,客户端也需要以某种方式发现这些细节。服务开发者通常使用接口定义语言(IDL)来定义和记录其服务的 API 端点和数据模型,并随时间演化它们。然后其他开发者可以使用服务定义来确定如何查询服务。两种最流行的服务 IDL 是OpenAPI(也称 Swagger)和gRPC。OpenAPI 用于发送和接收 JSON 数据的 Web 服务,而 gRPC 服务发送和接收 Protocol Buffers。

开发者通常用 JSON 或 YAML 编写 OpenAPI 服务定义(参见示例 5-3)。服务定义允许开发者定义服务端点、文档、版本、数据模型等。gRPC 定义看起来类似,但使用 Protocol Buffers 服务定义。

示例 5-3:YAML 中的示例 OpenAPI 服务定义

openapi: 3.0.0
info:
  title: Ping, Pong
  version: 1.0.0
servers:
  - url: http://localhost:8080
paths:
  /ping:
    get:
      summary: Given a ping, returns a pong message
      responses:
        '200':
          description: A pong
          content:
            application/json:
              schema:
                type: object
                properties:
                  message:
                    type: string
                    example: Pong!

即使采用了设计理念和 IDL,开发者仍需编写实现其服务 API 调用的代码。通常采用服务框架来简化这项工作。Spring Boot、FastAPI 和 gRPC 等服务框架允许开发者编写每个 API 端点的业务逻辑,而框架代码处理路由、指标、缓存、认证等。示例 5-4 展示了用 Python 实现示例 5-3 定义的服务的示例。

示例 5-4:实现示例 5-3 定义的示例 FastAPI 服务

from fastapi import FastAPI
from pydantic import BaseModel

app = FastAPI(title="Ping, Pong", version="1.0.0")

class PongResponse(BaseModel):
    message: str = "Pong!"

@app.get("/ping", response_model=PongResponse,
         summary="Given a ping, returns a pong message")
async def ping():
    return PongResponse()

许多框架将服务定义和服务器代码耦合在一起。在某些情况下,如流行的 Python FastAPI 框架,服务器用代码编写,IDL 自动生成。在其他情况下,如 gRPC,首先编写服务定义,然后生成服务器代码脚手架。两种方法都允许开发者从服务定义生成各种语言的客户端库和 SDK。除了代码生成,Swagger 等 IDL 工具可以生成文档、验证模式变更兼容性,并为开发者提供图形界面来查询和测试服务。


远程过程调用(RPC)的问题

Web 服务只是通过网络进行 API 请求技术长河的最新化身,其中许多曾受到大量炒作但有严重问题。Enterprise JavaBeans(EJB)和 Java 的远程方法调用(RMI)仅限于 Java。分布式组件对象模型(DCOM)仅限于 Microsoft 平台。公共对象请求代理架构(CORBA)过于复杂,不提供向后或向前兼容性。SOAP 和 WS-* Web 服务框架旨在提供跨供应商的互操作性,但也 plagued by 复杂性和兼容性问题。

所有这些都基于远程过程调用(RPC)的概念,自 1970 年代以来一直存在。RPC 模型试图让对远程网络服务的请求看起来与在同一进程中调用编程语言的函数或方法相同(这种抽象称为位置透明性)。虽然 RPC 起初看起来方便,但这种方法从根本上是有缺陷的

本地函数调用网络请求
可预测,成功或失败仅取决于您控制的参数不可预测:请求或响应可能因网络问题丢失,或远程机器可能缓慢或不可用,这些问题完全不受您控制
要么返回结果,要么抛出异常,要么永不返回(无限循环或进程崩溃)还有另一种可能结果:可能因超时而无结果返回。如果未从远程服务获得响应,您无法知道请求是否通过
重试不是问题如果重试失败的网络请求,可能发生前一次请求实际已通过,只是响应丢失。重试将导致操作执行多次,除非在协议中内置去重(幂等)机制
每次调用通常花费大约相同时间执行网络请求比函数调用慢得多,延迟也 wildly 可变:好的时候可能不到一毫秒完成,但网络拥塞或远程服务过载时可能需要许多秒
可以高效传递对本地内存中对象的引用(指针)所有参数都需要编码为可通过网络发送的字节序列。如果参数是不可变基元如数字或短字符串还可以,但数据量大和可变对象时很快变得有问题
在同一进程、同一语言中不存在数据类型转换问题客户端和服务可能用不同编程语言实现,因此 RPC 框架必须将数据类型从一种语言转换为另一种语言。这可能变得丑陋,因为并非所有语言都有相同类型

所有这些因素意味着,试图让远程服务看起来太像编程语言中的本地对象是没有意义的,因为它是根本不同的东西。REST 的部分吸引力在于它将网络上的状态转移视为与函数调用不同的过程。


负载均衡器、服务发现与服务网格

所有服务都通过网络通信。因此,客户端必须知道它连接的服务的地址——这称为服务发现问题。最简单的方法是将客户端配置为连接到服务运行的 IP 地址和端口。这种配置可以工作,但如果服务器离线、转移到新机器或过载,客户端必须手动重新配置。

为了提供更高的可用性和可扩展性,通常有多个服务实例在不同机器上运行,其中任何一个都可以处理传入请求。在这些实例间分发请求称为负载均衡

解决方案类型说明
硬件负载均衡器安装在数据中心的专用设备。允许客户端连接到单个主机和端口,传入连接路由到运行服务的服务器之一。检测连接到下游服务器时的网络故障并将流量转移到其他服务器
软件负载均衡器行为与硬件负载均衡器类似,但作为可安装在标准机器上的应用,如 Nginx 和 HAProxy
DNS互联网解析域名的方式。支持负载均衡,允许将多个 IP 地址与单个域名关联。客户端可配置为使用域名而非 IP 地址连接服务,客户端网络层在建立连接时选择使用哪个 IP 地址。缺点是 DNS 设计为在较长时间内传播变更并缓存 DNS 条目。如果服务器频繁启动、停止或移动,客户端可能看到不再运行服务器的陈旧 IP 地址
服务发现系统使用集中式注册表而非 DNS 来跟踪哪些服务端点可用。新服务实例启动时,通过声明监听的主机和端口以及相关元数据(如分片所有权信息、数据中心位置等)向服务发现系统注册。服务然后定期向发现系统发送心跳信号以表明仍可用。客户端希望连接服务时,首先查询发现系统获取可用端点列表,然后直接连接端点。与 DNS 相比,服务发现支持服务实例频繁变化的更动态环境。发现系统还给客户端提供关于所连接服务的更多元数据,使客户端能做出更智能的负载均衡决策
服务网格负载均衡的复杂形式,结合软件负载均衡器和服务发现。与传统软件负载均衡器在单独机器上运行不同,服务网格负载均衡器通常部署为进程内客户端库或客户端和服务器上的进程或”边车”容器。客户端应用连接到自己的本地服务负载均衡器,后者连接到服务器的负载均衡器。从那里,连接路由到本地服务器进程。这种拓扑结构提供许多优势:因为客户端和服务器完全通过本地连接路由,连接加密可以完全在负载均衡器级别处理,使客户端和服务器免于处理 SSL 证书和 TLS 的复杂性。网格系统还提供复杂的可观察性:可以实时跟踪哪些服务在相互调用、检测故障、跟踪流量负载等

哪种解决方案合适取决于组织的需求。在 Kubernetes 等编排器运行的非常动态的服务环境中,通常选择运行 Istio 或 Linkerd 等服务网格。数据库或消息系统等专用基础设施可能需要其自己的专用负载均衡器。更简单的部署最好用软件负载均衡器。


RPC 的数据编码与演化

为了可演化性,RPC 客户端和服务器能够独立变更和部署很重要。与通过数据库的数据流(如上一节所述)相比,在服务数据流的情况下我们可以做一个简化假设:可以合理地假设所有服务器将首先更新,所有客户端其次更新。因此,您只需要请求上的向后兼容和响应上的向前兼容

RPC 方案的向后和向前兼容属性继承自它使用的任何编码:

编码方案兼容性特性
gRPC(Protocol Buffers)和 Avro RPC可根据各自编码格式的兼容性规则进行演化
RESTful API最常见使用 JSON 作为响应,JSON 或 URI 编码/表单编码请求参数。添加可选请求参数和向响应对象添加新字段通常被认为是保持兼容性的变更

服务兼容性因 RPC 经常用于跨组织边界通信而变得更加困难,因此服务提供商通常无法控制其客户端,无法强制它们升级。因此,兼容性需要长期保持,可能无限期。如果需要破坏性变更,服务提供商通常最终需要并行维护多个版本的服务 API。

关于 API 版本控制应如何工作(即客户端如何指示要使用哪个版本的 API)没有共识。对于 RESTful API,常见方法是在 URL 或 HTTP Accept 头中使用版本号。对于使用 API 密钥标识特定客户端的服务,另一个选项是在服务器上存储客户端请求的 API 版本,并允许通过单独的管理界面更新此版本选择。


5.3.3 持久执行与工作流

根据定义,基于服务的架构有多个服务,各自负责应用的不同部分。考虑一个处理信用卡并将资金存入银行账户的支付处理应用。该系统可能有不同的服务负责欺诈检测、信用卡集成、银行集成等。

在我们的示例中处理单笔支付需要许多服务调用。支付处理器服务可能调用欺诈检测服务检查欺诈,调用信用卡服务借记信用卡,调用银行服务存入借记资金,如图 5-7 所示。我们将这一步骤序列称为工作流,每个步骤称为任务。工作流通常定义为任务图。工作流定义可以用通用编程语言、领域特定语言(DSL)或标记语言如业务流程执行语言(BPEL)编写。

图5-7:使用业务流程模型和符号(BPMN)表示的工作流示例

任务、活动与函数:不同的工作流引擎对任务使用不同的名称。例如,Temporal 使用”活动”一词。其他将任务称为持久函数。虽然名称不同,概念相同。

工作流由工作流引擎运行或执行。工作流引擎决定何时运行每个任务、任务必须在哪台机器上运行、任务失败时做什么(例如,如果任务运行时机器崩溃)、允许多少任务并行执行等。

工作流引擎通常由编排器执行器组成。编排器负责调度要执行的任务,执行器负责执行任务。工作流触发时开始执行。如果用户定义基于时间的调度(如每小时执行),编排器自己触发工作流。外部源如 Web 服务甚至人也可以触发工作流执行。一旦触发,调用执行器运行任务。

有许多种类的工作流引擎解决不同的用例。有些如 Airflow、Dagster 和 Prefect 与数据系统集成,编排 ETL 任务。其他如 Camunda 和 Orkes 为工作流提供图形符号(如 BPMN,用于图 5-7),使非工程师能更轻松地定义和执行工作流。还有如 Temporal 和 Restate 提供持久执行


持久执行

持久执行框架已成为构建需要事务性的基于服务架构的流行方式。在我们的支付示例中,我们希望每笔支付只处理一次。工作流执行时的失败可能导致信用卡扣款,但没有相应的银行账户存款。在基于服务的架构中,我们不能简单地将两个任务包装在数据库事务中。此外,我们可能与控制有限的第三方支付网关交互。

持久执行框架是为工作流提供恰好一次语义的一种方式。如果任务失败,框架将重新执行任务,但会跳过任务在失败前成功进行的任何 RPC 调用或状态变更。相反,框架将假装进行调用,但会返回前一次调用的结果。这是可能的,因为持久执行框架将所有 RPC 和状态变更记录到预写日志等持久存储中。

示例 5-5:使用 Temporal 支持持久执行的支付工作流定义片段

@workflow.defn
class PaymentWorkflow:
    @workflow.run
    async def run(self, payment: PaymentRequest) -> PaymentResult:
        is_fraud = await workflow.execute_activity(
            check_fraud,
            payment,
            start_to_close_timeout=timedelta(seconds=15),
        )
        if is_fraud:
            return PaymentResultFraudulent
        credit_card_response = await workflow.execute_activity(
            debit_credit_card,
            payment,
            start_to_close_timeout=timedelta(seconds=15),
        )
        # ...

Temporal 等框架并非没有挑战。外部服务,如我们示例中的第三方支付网关,仍必须提供幂等 API。开发者必须记住为这些 API 使用唯一 ID 以防止重复执行。因为持久执行框架按顺序记录每个 RPC 调用,它期望后续执行以相同顺序进行相同的 RPC 调用。这使代码变更变得脆弱:您可能仅仅通过重新排序函数调用就引入未定义行为。与其修改现有工作流的代码,不如单独部署新版本代码更安全,以便现有工作流调用的重新执行继续使用旧版本,只有新调用使用新代码。

类似地,因为持久执行框架期望确定性地重放所有代码(相同输入产生相同输出),非确定性代码如随机数生成器或系统时钟是有问题的。框架通常提供自己的此类库函数的确定性实现,但您必须记住使用它们。在某些情况下,如 Temporal 的 workflowcheck 工具,框架提供静态分析工具来确定是否引入了非确定性行为。

注意:使代码确定性是一个强大的想法,但 robustly 实现很棘手。在”确定性的力量”中我们将回到这个话题。


5.3.4 事件驱动架构

在最后一部分,我们将简要介绍事件驱动架构,这是编码数据从一个过程流向另一种过程的方式。请求称为事件消息;与 RPC 不同,发送者通常不等待接收者处理事件。此外,事件通常不通过直接网络连接发送给接收者,而是通过称为消息代理(也称事件代理、消息队列或面向消息的中间件)的中介,它临时存储消息。

使用消息代理相比直接 RPC 有几个优势:

  1. 如果接收者不可用或过载,它可以充当缓冲区,从而提高系统可靠性
  2. 它可以自动向崩溃的进程重新传递消息,从而防止消息丢失
  3. 它避免了服务发现的需要,因为发送者不需要直接连接到接收者的 IP 地址
  4. 它允许将同一消息发送给多个接收者
  5. 它在逻辑上将发送者与接收者解耦(发送者只发布消息,不关心谁消费它们)

通过消息代理的通信是异步的:发送者不等待消息被传递,只是发送然后忘记。可以通过让发送者在单独通道上等待响应来实现同步 RPC 式模型。


消息代理

过去,消息代理领域由 TIBCO、IBM WebSphere 和 webMethods 等公司的商业企业软件主导,然后 RabbitMQ、ActiveMQ、HornetQ、NATS 和 Apache Kafka 等开源实现变得流行。最近,Amazon Kinesis、Azure Service Bus 和 Google Cloud Pub/Sub 等云服务获得采用。

详细的传递语义因实现和配置而异,但通常使用两种消息分发模式:

模式说明
队列一个过程将消息添加到命名队列,代理将该消息传递给该队列的消费者。如果有多个消费者,其中一个接收消息
发布/订阅一个过程将消息发布到命名主题,代理将该消息传递给该主题的所有订阅者。如果有多个订阅者,他们都接收消息

消息代理通常不强制执行任何特定数据模型——消息只是带有一些元数据的字节序列,因此您可以使用任何编码格式。常见方法是使用 Protocol Buffers、Avro 或 JSON,并在消息代理旁部署模式注册表以存储所有有效模式版本并检查其兼容性。AsyncAPI(基于消息的 OpenAPI 等效物)也可用于指定消息的模式。

消息代理在消息的持久性方面有所不同。许多将消息写入磁盘,以便在消息代理崩溃或需要重启时不会丢失。与数据库不同,许多消息代理在消息被消费后自动删除消息。某些代理可以配置为无限期存储消息,如果您想使用事件溯源(参见”事件溯源与 CQRS”)则需要此功能。

如果消费者将消息重新发布到另一个主题,您可能需要小心保留未知字段,以防止前面数据库上下文中描述的问题(图 5-1)。


分布式 Actor 框架

Actor 模型是单进程中并发的编程模型。逻辑封装在 Actor 中,而不是直接处理线程(以及相关的竞态条件、锁定和死锁问题)。每个 Actor 通常代表一个客户端或实体,可能有一些本地状态(不与其他 Actor 共享),并通过发送和接收异步消息与其他 Actor 通信。消息传递不保证:在某些错误场景中,消息会丢失。由于每个 Actor 一次只处理一条消息,它不需要担心线程,每个 Actor 可以由框架独立调度。

分布式 Actor 框架如 Akka、Orleans 和 Erlang/OTP 中,这种编程模型用于跨多个节点扩展应用。使用相同的消息传递机制,无论发送者和接收者在同一节点还是不同节点。如果它们在不同节点,消息被透明地编码为字节序列,通过网络发送,在另一端解码。

位置透明性在 Actor 模型中比 RPC 中更有效,因为 Actor 模型已经假设即使在单进程内消息也可能丢失。尽管网络上的延迟可能比同一进程内高,但使用 Actor 模型时本地和远程通信之间的根本不匹配较少。

分布式 Actor 框架本质上将消息代理和 Actor 编程模型集成到单个框架中。然而,如果您想对基于 Actor 的应用进行滚动升级,仍需担心向前和向后兼容性,因为消息可能从运行新版本的节点发送到运行旧版本的节点,反之亦然。这可以通过使用本章讨论的编码之一来实现。


5.4 总结

在本章中,我们研究了几种将数据结构转换为网络上或磁盘上的字节的方式。我们看到这些编码的细节不仅影响其效率,更重要的是影响应用的架构和演化的选择。

特别是,许多服务需要支持滚动升级,即新版本的服务逐个部署到少数节点,而不是同时部署到所有节点。滚动升级允许不停机地发布新版本的服务(从而鼓励频繁的小发布而非罕见的大发布),并使部署风险更低(允许在影响大量用户之前检测和回滚有问题的发布)。这些属性对可演化性(对应用进行变更的容易程度)非常有益。

在滚动升级期间,或出于各种原因,我们必须假设不同节点运行着应用代码的不同版本。因此,系统中流动的所有数据必须以提供向后兼容(新代码可以读取旧数据)和向前兼容(旧代码可以读取新数据)的方式编码很重要。

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

格式类型兼容性特性
编程语言特定编码限于单一编程语言,通常无法提供向前和向后兼容
文本格式(JSON、XML、CSV)广泛使用,兼容性取决于使用方式。有可选的模式语言,有时有帮助,有时是阻碍。这些数据类型有些模糊,因此必须小心处理数字和二进制字符串等问题
二进制模式驱动格式(Protocol Buffers、Avro)允许紧凑、高效的编码,具有明确定义的向前和向后兼容语义。模式可用于文档和静态类型语言的代码生成。缺点是数据需要解码才能供人类阅读

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

  • 数据库:写入数据库的过程编码数据,从数据库读取的过程解码数据
  • RPC 和 REST API:客户端编码请求,服务器解码请求并编码响应,客户端最终解码响应
  • 事件驱动架构(使用消息代理或 Actor):节点通过相互发送消息进行通信,消息由发送者编码,由接收者解码

我们可以得出结论,只要稍加小心,向后/向前兼容和滚动升级是相当可实现的。愿您的应用演化迅速,部署频繁。


参考文献

[1] CWE-502: Deserialization of Untrusted Data. Common Weakness Enumeration, cwe.mitre.org, July 2006.

[2] Steve Breen. What Do WebLogic, WebSphere, JBoss, Jenkins, OpenNMS, and Your Application Have in Common? This Vulnerability. foxglovesecurity.com, November 2015.

[3] Patrick McKenzie. What the Rails Security Issue Means for Your Startup. kalzumeus.com, January 2013.

[4] Brian Goetz. Towards Better Serialization. openjdk.org, June 2019.

[5] Eishay Smith. jvm-serializers wiki. github.com, October 2023.

[6] XML Is a Poor Copy of S-Expressions. wiki.c2.com, May 2013.

[7] Julia Evans. Examples of floating point problems. jvns.ca, January 2023.

[8] Matt Harris. Snowflake: An Update and Some Very Important Information. Email to Twitter Development Talk mailing list, October 2010.

[9] Yakov Shafranovich. RFC 4180: Common Format and MIME Type for Comma-Separated Values (CSV) Files. IETF, October 2005.

[10] Andy Coates. Evolving JSON Schemas - Part I and Part II. creekservice.org, January 2024.

[11] Pierre Genevès, Nabil Layaïda, and Vincent Quint. Ensuring Query Compatibility with Evolving XML Schemas. INRIA Technical Report 6711, November 2008.

[12] Tim Bray. Bits On the Wire. tbray.org, November 2019.

[13] Mark Slee, Aditya Agarwal, and Marc Kwiatkowski. Thrift: Scalable Cross-Language Services Implementation. Facebook technical report, April 2007.

[14] Martin Kleppmann. Schema Evolution in Avro, Protocol Buffers and Thrift. martin.kleppmann.com, December 2012.

[15] Doug Cutting, Chad Walters, Jim Kellerman, et al. [PROPOSAL] New Subproject: Avro. Email thread on hadoop-general mailing list, lists.apache.org, April 2009.

[16] Apache Software Foundation. Apache Avro 1.12.0 Specification. avro.apache.org, August 2024.

[17] Apache Software Foundation. Avro schemas as LL(1) CFG definitions. avro.apache.org, August 2024.

[18] Tony Hoare. Null References: The Billion Dollar Mistake. Talk at QCon London, March 2009.

[19] Confluent, Inc. Schema Registry Overview. docs.confluent.io, 2024.

[20] Aditya Auradkar and Tom Quiggle. Introducing Espresso—LinkedIn’s Hot New Distributed Document Store. engineering.linkedin.com, January 2015.

[21] Jay Kreps. Putting Apache Kafka to Use: A Practical Guide to Building a Stream Data Platform (Part 2). confluent.io, February 2015.

[22] Gwen Shapira. The Problem of Managing Schemas. oreilly.com, November 2014.

[23] John Larmouth. ASN.1 Complete. Morgan Kaufmann, 1999. ISBN: 978-0-122-33435-1.

[24] Burton S. Kaliski Jr. A Layman’s Guide to a Subset of ASN.1, BER, and DER. Technical Note, RSA Data Security, Inc., November 1993.

[25] Jacob Hoffman-Andrews. A Warm Welcome to ASN.1 and DER. letsencrypt.org, April 2020.

[26] Lev Walkin. Question: Extensibility and Dropping Fields. lionet.info, September 2010.

[27] Jacqueline Xu. Online migrations at scale. stripe.com, February 2017.

[28] Geoffrey Litt, Peter van Hardenberg, and Orion Henry. Project Cambria: Translate your data with lenses. Technical Report, Ink & Switch, October 2020.

[29] Pat Helland. Data on the Outside Versus Data on the Inside. At 2nd Biennial Conference on Innovative Data Systems Research (CIDR), January 2005.

[30] Roy Thomas Fielding. Architectural Styles and the Design of Network-Based Software Architectures. PhD Thesis, University of California, Irvine, 2000.

[31] Roy Thomas Fielding. REST APIs must be hypertext-driven. roy.gbiv.com, October 2008.

[32] OpenAPI Specification Version 3.1.0. swagger.io, February 2021.

[33] Michi Henning. The Rise and Fall of CORBA. Communications of the ACM, volume 51, issue 8, pages 52–57, August 2008.

[34] Pete Lacey. The S Stands for Simple. harmful.cat-v.org, November 2006.

[35] Stefan Tilkov. Interview: Pete Lacey Criticizes Web Services. infoq.com, December 2006.

[36] Tim Bray. The Loyal WS-Opposition. tbray.org, September 2004.

[37] Andrew D. Birrell and Bruce Jay Nelson. Implementing Remote Procedure Calls. ACM Transactions on Computer Systems (TOCS), volume 2, issue 1, pages 39–59, February 1984.

[38] Jim Waldo, Geoff Wyant, Ann Wollrath, and Sam Kendall. A Note on Distributed Computing. Sun Microsystems Laboratories, Inc., Technical Report TR-94-29, November 1994.

[39] Steve Vinoski. Convenience over Correctness. IEEE Internet Computing, volume 12, issue 4, pages 89–92, July 2008.

[40] Brandur Leach. Designing robust and predictable APIs with idempotency. stripe.com, February 2017.

[41] Sam Rose. Load Balancing. samwho.dev, April 2023.

[42] Troy Hunt. Your API versioning is wrong, which is why I decided to do it 3 different wrong ways. troyhunt.com, February 2014.

[43] Brandur Leach. APIs as infrastructure: future-proofing Stripe with versioning. stripe.com, August 2017.

[44] Alexandre Alves, Assaf Arkin, Sid Askary, et al. Web Services Business Process Execution Language Version 2.0. docs.oasis-open.org, April 2007.

[45] What is a Temporal Service? docs.temporal.io, 2024.

[46] Stephan Ewen. Why we built Restate. restate.dev, August 2023.

[47] Keith Tenzer and Joshua Smith. Idempotency and Durable Execution. temporal.io, February 2024.

[48] What is a Temporal Workflow? docs.temporal.io, 2024.

[49] Jack Kleeman. Solving durable execution’s immutability problem. restate.dev, February 2024.

[50] Srinath Perera. Exploring Event-Driven Architecture: A Beginner’s Guide for Cloud Native Developers. wso2.com, August 2023.

[51] Philip A. Bernstein, Sergey Bykov, Alan Geller, Gabriel Kliot, and Jorgen Thelin. Orleans: Distributed Virtual Actors for Programmability and Scalability. Microsoft Research Technical Report MSR-TR-2014-41, March 2014.