Cambria项目:用镜头转换您的数据

2020-10-07 09:07:42

为什么改变这么难?如果我们可以随时随地同步更新我们的软件,生活就会很容易。数据库列将在客户端和服务器中同时重命名。每个API客户端都会立即向前跳跃。同行将毫无争议地同意新协议。

唉,因为我们实际上不能一次更改所有的系统,所以我们的更改通常必须同时保留这两个系统:

向后兼容,使新代码与现有数据兼容。向前兼容,使现有代码与新数据兼容。

向后兼容非常简单。它是在新版本的程序中打开旧文档的能力。前向兼容性(以未来发明的格式打开文档的能力)更为罕见。我们可以看到Web浏览器的向前兼容性,编写这些浏览器的目的是为了使HTML中添加的功能不会破坏在旧浏览器中呈现新站点的能力。

正如任何Web开发人员都知道的那样,编写向前兼容的HTML与其说是科学不如说是艺术。Mozilla开发人员网络维护本指南,并提供有关该主题的建议。

维护向后和向前兼容性的需求出现在各种分布式系统中,既有集中式的,也有分散式的。让我们更仔细地看看这些系统中的几个及其采用的解决方案。

公共Web API在试图保持与较旧客户端的向后兼容性时可能面临兼容性挑战。像Stiped这样的组织必须在他们想要更改其API的愿望和客户不愿更改对他们有效的东西之间进行权衡。其结果是,随着时间的推移,通常跨多个版本保持向后兼容性的压力很大。

许多API开发人员对此问题采取了特别的方法。开发人员依靠部落知识来告诉他们哪些操作是安全的-例如,他们凭直觉认为他们可以用额外的数据来响应,相信现有的客户会忽略这些数据,但不需要在请求中提供额外的数据,因为现有的客户不知道要发送这些数据。开发人员还经常求助于鸟枪式解析:将数据检查和备用值分散在整个系统的主逻辑中的不同位置。这通常不仅会导致错误,还会导致安全漏洞。

术语鸟枪解析是由Bratus&;Patterson引入的,描述了将类似解析器的行为散布在应用程序代码中的习惯。正如链接的论文中所述,除了使程序复杂化外,数据处理中的不一致还可能导致安全漏洞。

Stripe已经开发出一种优雅的方法来解决这个问题。他们在API服务器中构建了一个中间件系统,用于拦截传入和传出的通信,并在系统的当前版本和客户端请求的版本之间进行转换。因此,Strip的开发人员在大多数情况下不需要关心旧API请求的特性,因为他们的中间件确保请求将被转换为当前版本。当他们想要更改API的格式时,他们可以将新的翻译添加到他们中间件的“栈”中,并且传入的请求将被翻译成当前版本。

我们的工作是受到该系统提供的封装的启发,但是STRIPE的实现有一定的局限性。编写迁移规则的开发人员必须手动实现翻译,并编写测试以确保它们是正确的。而且,因为STRIPE的系统使用日期来对其迁移进行排序,所以它被限制在单一的线性迁移路径上。

大型组织通常采用事件流架构,这使他们能够扩展进入其系统的数据,并将其与使用数据的进程分离。Apache Kafka是一个可伸缩的持久队列系统,经常用于此目的。数据架构在这些环境中至关重要,因为通过系统发送的格式错误的事件可能会使下游订阅者崩溃。因此,卡夫卡的流可能需要向后和向前兼容。旧消息必须可供新消费者阅读;新消息必须可供旧消费者阅读。

为了帮助管理这种复杂性,Confluent Platform for Kafka提供了Schema Registry工具。此工具定义了帮助开发人员维护架构兼容性的规则-例如,如果架构需要向后兼容,架构注册表只允许添加可选字段,因此新代码永远不会依赖于新添加的字段的存在。

架构注册表可以帮助防止团队部署不兼容的架构,但它通过限制可以对架构进行的更改来做到这一点。希望保持兼容性的开发人员不能添加新的必填字段或重命名现有字段。此外,这些规则限制了模式提供的保证;充满可选字段的记录很难使用,处理它的代码可能会求助于鸟枪解析,在使用它之前测试每个字段是否存在。

分散的系统带来了更多的挑战。从历史上看,BitTorrent、IRC和Email等系统都是由集中式协议管理的,尽管它们是分散实现的。通过维护严格、定义良好的协议,软件的每个实例都可以知道从其对等方期望和产生什么。

乳齿象是一个联合的类似推特的社交网络,任何人都可以在这里运行自己的服务器,并决定他们想要包括哪些用户和消息。我们鼓励每个Mastodon服务器开发自己的社区标准和文化,以管理哪些内容是受欢迎的。

在社交网络Mastodon中,服务器都是独立的,并且使用开放式ActivityPub标准交换数据。ActivityPub提供了一种共享格式,并定义了分布式系统的许多重要元素。它规定了“追随者”和“点赞”应该如何操作,但由于协调困难,可能很难添加新功能。

当然,每个本地服务器都可以随心所欲。例如,可以提供描述和隐藏敏感内容的本地方法。但由于每个系统都在当地实施这些想法,没有简单的方法在全球传播这些改进。如果两台服务器以不同的方式处理敏感内容,那么每台服务器都必须永远支持这两种格式吗?更糟糕的是,创新往往发生在边缘。大型服务器采用新功能的速度很慢,而且有许多实现,管理员没有明确的路径来选择采用哪些功能以及何时采用。

Mastodon(或ActivityPub)越成功,实现和服务器就越多。实现和服务器越多,更改协议就越困难。IRC网络多年来一直在与这些问题作斗争,这个问题如此严重,以至于最新的IRC协议IRCV3的关键功能之一就是能够进行未来的更改!或许,如果IRC能够更好地应对不断变化的形势,它就会更有能力与新服务竞争。

IRC,或互联网中继聊天,是一种用于实时聊天的早期互联网协议,也是Slake等系统的前身。2003年,IRC声称有1000万同时在线用户。到2020年,这一数字已经下降到只有37.1万人。

在Ink&;Switch,我们一直在探索一种我们称之为本地优先的去中心化软件。因为本地优先软件可以完全在用户的计算机上运行,而且还支持实时协作,所以我们在升级到新版本的代码时遇到了各种各样的问题。不同版本的程序之间的非托管交互导致了奇怪的行为,例如客户端争相删除和恢复重新定位的数据,或者呈现代码中的无效状态,这些状态是由于重播由较早版本的…创建的文档历史而导致的。或者晚一点的。

联邦系统是一个两层分散系统,其中用户连接到他们选择的服务器,服务器进行协调。BitTorrent不是联合体,但电子邮件是联合体。

我们最初的解决方案是临时的。像所有人一样,我们依赖于散弹枪解析,在发现缺少数组的地方插入条件类型检查。我们会在项目中加入一次性迁移代码,然后在“感觉安全”时将其删除。随着越来越多的开发人员在软件上工作,我们进行得越深入,事情就变得越糟糕。

我们经常会编写类似此代码片段的代码,它需要处理两种情况:一种情况是doc已经有一个标记数组,另一种情况是由于较早版本的应用程序没有填充文档中的该属性,所以它还没有一个标记数组。

如果(文档。标记&;&;数组。IsArray(文档。标签)){文档。标签。Push(MyNewTag);}Else{//处理没有标记数组文档的旧文档。标签=[myNewTag];}。

这类代码通常会在事后添加。新文档将在创建期间正确初始化,但旧文档将缺少字段,并且仅通过在运行时抛出错误来暴露问题。

更糟糕的是重命名字段造成的问题。考虑以下示例,其中我们将作者重命名为投稿人:

这个错误很明显吗?此代码将正确地将字段迁移到其新名称,但前提是没有软件的旧副本仍在处理此文档!访问此文档的程序的旧版本容易重新引入“作者”字段,从而导致数据丢失。

当然,实现这种迁移还有更好的方法,但我们可以看到,即使单用户测试行为正确,也会出现一些微妙的问题。这类排序问题在集中式系统中很少见,但在分散式系统中很常见。

所有这些系统都必须设计为同时支持多个数据版本。当数据来自API客户端时,很难做到这一点;当数据存储在不变的Kafka队列中时,很难做到这一点;当有一大群分散的客户端都以任意顺序发展时,更是难以做到这一点。

做好这件事并不是学术上的问题。在分布式系统中无法正确发送和接收数据可能会导致中断。现有客户端可能会失去与服务器通信的能力。如果只有一些节点升级,则可能会发生网络分区。不一致的实现可能会导致安全漏洞。

有一种深刻的感觉,那就是这些都是同一个问题。数据模式只能以多种方式更改。不断演变的模式可以添加或删除字段、重命名或重新定位它们、更改它们的基数,或者将它们的类型从一种形式转换为另一种形式。要管理这种复杂性,程序必须检查字段是否存在,填写默认值,并将旧数据迁移到新形状中。

与其以特别的方式管理这些问题,我们可以建立在条纹和汇流团队的洞察力之上,并提供一个令人满意的框架,该框架可以跨所有这些问题域一致且可重用地解决这些问题。我们可以使同时支持多个版本的数据变得非常容易。

我们已经开发了一个名为Cambria的模式演化库,它支持具有不同模式的应用程序的不同版本之间的实时协作。它的工作原理是为开发人员提供一种符合人体工程学的方式来定义称为镜头的双向翻译功能。

当开发人员定义单个镜头时,它可以双向转换数据,不需要指定单独的向前和向后转换。我们在这一领域进行了广泛的学术研究,最直接的是霍夫曼、皮尔斯和瓦格纳的“编辑镜头”。我们的目标是将这些技术集成到一个实用的工具包中,该工具包可用于支持实际应用程序中的兼容性。

一些读者可能已经看到镜头被用作“功能性的getter和setter”,就像在Haskell镜片库中一样。在这里,我们使用它们来同步相关的数据结构,这是Foster等人设想的镜头的最初目的。有关寒武纪双向性的更多细节以及它如何与先前关于镜头的工作相关,请参见附录I。

Cambria是一个轻量级的库,旨在集成到JavaScript和TypeScript环境中。它由一小部分函数组成,这些函数获取一个版本中的数据,然后返回另一个版本中的数据。它可以处理来自任何来源的JSON数据:网络上的另一个客户端、文件或数据库。Cambria执行编译时验证以确保镜头有效和一致,并生成类型脚本类型和JSON Schema定义,以便开发人员可以对数据的形状进行推理。

随着时间的推移,使用Cambria的项目将积累许多镜头,每个镜头都描述了两个版本之间的关系。远距离版本之间的迁移是通过将镜头组合到图形中来创建的,其中每个节点都是一个架构,每个边都是一个镜头。为了在两个模式之间转换数据,Cambria通过镜头图中最短的可用路径发送数据。这些镜头必须保存在即使是旧版本的程序也可以检索它们的地方,例如在数据库中、在众所周知的URL处,或者作为文档本身的一部分。

为了理解这一切在实践中的意义,让我们看看Cambria在一个真正的项目中的行动。

为了在现实环境中开发Cambria,我们构建了一个问题跟踪器应用程序。这是一个本地优先的程序,在每个用户的计算机上运行,但也支持互联网上的实时点对点协作。这种组合立即带来兼容性挑战。如果用户运行的是不同版本的客户端,他们如何进行协作?

虽然我们选择在本地优先的应用程序环境中工作,但此演示中的许多想法也适用于其他环境,如集中式Web API、事件流和数据库,如下所述。

我们的目标是利用寒武纪实现完全兼容。用户应该能够在文档上进行协作,而不管他们运行的是什么软件版本。新客户端应该能够打开旧客户端创建的文档,反之亦然。

为了获得实际模式演变的经验,我们开始使用具有最小功能集的问题跟踪器,并根据需要添加功能,始终保持完全兼容性。这帮助我们关注真正的问题,而不是想象中的问题,但可能会导致我们忽略其他类型的架构更改。

虽然我们确实使用了我们的原型问题跟踪器来帮助开发Cambria,但我们发现它太不稳定了,不能作为我们在项目期间唯一的记录系统。主要问题是我们正在改变Cambria本身使用的底层存储格式;这类问题似乎不太可能影响只使用Cambria而不是开发它的项目。

让我们看看随着应用程序的发展,我们所做的一些更改,以及镜头如何在整个过程中帮助保持兼容性。

最初,我们的应用程序使用布尔完成字段跟踪每个任务的状态。但当我们使用该软件时,我们意识到查看是否已开始未完成的任务是有价值的。因此,我们决定改为以三值字符串类型跟踪状态:TODO、进行中或完成。

使用寒武纪,我们写了一个镜头来表达这种转换。在下图中,您可以看到左侧是原始JSON文档,中间是镜头(使用YAML语法),右侧是演进文档。

首先,镜头将Complete属性重命名为Status。然后,它将属性的值从布尔值转换为相应的字符串。例如,此处Complete:False映射到Status:TODO。

-CONVERT:名称:状态映射:-';FALSE';:TODO';TRUE';:DONE-TODO:FALSE INPROGRESS:FALSE DONE:TRUE DEFAULT:FALSE源类型:布尔目标类型:字符串。

让我们看看这个镜头在实际应用程序UI中的作用。在这里,我们看到两个版本的问题跟踪器并行运行,就同一任务进行协作。左边是带有“清理化石”任务的复选框的旧版本;右边是带有下拉列表的新版本。新版本的更改会传播到旧版本-当我们在右侧进行更新时,请注意左侧的复选框是如何实时更新的:

每个客户端使用其本地数据结构编辑任务,Cambria来回转换这些编辑。在许多其他系统中,在模式版本之间重命名字段是一个复杂的过程,但在这里它只需要编写一个小镜头。

让我们考虑另一个变化。起初,我们的问题跟踪器支持为每个任务分配一个人。然后,在项目后期,我们开始进行大量的结对编程,所以我们希望能够分配多个人。

与前面的示例一样,完全兼容是不可能的。老客户只知道如何显示单个受理人。如果有人使用新客户端将多人分配给一项任务,则无法在旧客户端中显示该信息。

但是,我们仍然可以尽最大努力保持兼容性。我们可以使用WRAP操作符编写镜头,该操作符将标量值转换为数组。当数组包含单个元素时,两边显示相同的信息。当数组中存在多个元素时,标量值反映数组中的第一个元素。

有许多不同的方法来表示标量到数组的转换,每种方法都具有微妙的不同属性。有关更多详细信息,请参阅附录III。

让我们看看镜头在应用程序中的表现,再一次并排使用两个版本。左侧的旧版本支持单个受理人;右侧的新版本支持多个受理人。当旧版本编辑任务的单个受理人时,双方保持完全同步:

当新版本将多人分配给一项任务时,旧版本通过在列表中显示第一个受理人来尽其所能:

根据上下文的不同,像这样不太完美的翻译可能有用,也可能令人困惑。如果在某个时候成本大于收益,最好的选择可能是要求所有协作者进行升级。Cambria没有强迫用户跨兼容性不完善的版本进行协作,但它提供了这样做的选项。

上面的例子展示了相对复杂的进化,但Cambria也可以帮助管理更普通的变化,比如添加一个新字段。

例如,我们一度决定为每个任务添加一个标记数组,以帮助跟踪不同的工作流。即使是这样的小更改也会带来向后兼容性挑战,因为新代码需要处理不存在标记数组的旧文档。如果Tags属性不存在,这样的简单代码将引发异常:

使用特殊的猎枪解析,我们可以在使用数据时填写默认值,但这样做的缺点是将数据验证混合到主程序中:

有了Cambria,我们可以通过使用直截了当的镜头添加新的领域来实现更强的关注点分离。

添加镜头操作符填充空数组作为默认值。这意味着我们可以切换回“朴素”的代码,但这一次我们可以放心地使用它,因为task.tag保证始终是一个数组。

我们现在已经看到了几个镜头如何在概念上帮助处理不断变化的数据的例子。但Cambria的另一个重要部分是开发人员在应用程序中实际使用这些镜片的人机工程学工作流程。在本节中,我们将展示开发人员如何与镜头交互,以及Cambria如何提供工具来简化编程时对数据模式的推理。

寒武纪的一个关键设计原则是,应该只有一个真理来源来描述系统中预期的数据形状,以及这种形状是如何随着时间的推移而演变的。要更改此模式,开发人员只需编写一个镜头来描述所需的更改,Cambria会自动生成三个。

.