我之前的文章是关于当人们把一个单体系统分割成几个部分时出现的额外复杂性。其基本思想是,无论底层的通信基础设施如何,SOA系统都有一个额外的 "通信复杂性",基本上就是关于服务接口的额外知识。例如,如果服务A与服务B和C进行通信,负责A的工程师必须对B和C有部分了解:她知道它们的接口,她被告知变化,甚至可能掌握了一些实现细节。每一对沟通的服务都会对系统的整体复杂性做出贡献。在一个具有相当密集的通信网络的大型软件系统中,这导致了两个大问题。
- 大量的复杂性存在于通信层中,并且在添加新服务时,复杂性会迅速增加。
- 工程师们 "只见树木不见森林"--他们只用服务级模型来操作,而不能看到整个系统的全貌。
很明显,服务的隔离有很多好处,如果没有的话,SOA就根本没有意义。主要的好处是将系统的一部分封装成一个独立的单元,与系统的其他部分有一个薄的接口。因此,一组工程师可以专注于组件的内部,而对系统的其他部分干扰最小。他们有一个可理解的工作范围,可以更快地交付,独立部署,等等。由于与系统隔离,工程师们在设计决策方面具有灵活性,不仅在组织代码方面,而且在他们在服务的 "有界环境 "中创建的 "泛在语言 "方面。这种与服务相关的词汇的主要问题是,它可能过于独立于系统中的其他词汇而发展。经过几年的这种演变,人们可能会注意到,类似的抽象在不同的服务中有不同的名称和行为,服务之间有数据重复,而且这些无处不在的语言之间的 "翻译 "变得更加复杂。此外,一个共同的 "系统语言",以某种方式存在于最初的单体中,逐渐从代码中消失,转移到文档中,或者只是转移到几个有经验的工程师和产品所有者的头脑中。
因此,解决这个 "通信复杂性 "的问题有两个方面。第一个是关于设计一个通信层的方式,以减少和简化不同部分的 "泛在语言 "之间的 "翻译"。第二个是关于通用泛在语言或系统泛在语言,或者简单地说,只是系统词汇。
正如在上一篇文章中所讨论的,在一个系统中拥有一个共同的精心设计的通信方法可能有助于这两个方面。而这篇文章是关于一个库(包,gem,什么的),它有两个目的。
- 定义一个系统级模型:描述系统整体功能的所有抽象(数据结构和命令)。
- 封装通信层,为系统提供一个通用接口。
一点背景
用现实世界的例子来解释这个想法要容易得多。我是一个伟大团队的一员,他们设计和开发了一个使保险机构自动化的系统。而文章的其余部分是关于我们的SOA系统所面临的挑战,它从一开始就发展得太快了。它开始是一个单体的Rails应用程序,由保险代理人管理客户:为他们请求保险报价并发放保险单。然后出现了一个单独的分销项目,用于管理客户的数据,并与大企业的特定分销渠道进行整合。然后,第一个应用被分成了两个。代理管理平台(AMP)和与保险公司的整合(报价)。随着时间的推移,当新的分销来源出现或新的第三方服务被整合时,该系统已经发展到约10个不同服务的规模。
但由于这一切发生得相当快,在1-2年期间,并非所有事情都很顺利。
首先,数据重复。很多信息以某种方式在系统的不同部分之间重复。例如,像 "人"、"财产"、"线索"、"报价"、"保险 "这样的基本抽象概念几乎存在于每个服务中。而每个服务都在自己的 "有界环境 "中展示、存储和管理相应的数据。这样的重复对于系统功能来说并不是一个大问题,但当人们试图理解细节时,它肯定会带来混乱。当我们开始在系统中加入商业智能层时,这个问题变得更加明显了。我们很难正确地汇总来自不同服务的数据。我们甚至为一些实体引入了 "gid"(全局ID)的概念,以简化连接分散数据的过程。
第二,服务之间非标准化的API。大多数API接口是为每一对通信服务临时设计的。没有客户端库,每个服务必须自己处理JSON数据。只有文档、定期讨论和详细的测试有助于避免错误。
最后,逐渐减少的工程意识。随着系统的发展和工程师数量的增加,这是一个自然的专业化过程。一个工程师只知道他或她积极从事的2-3项服务,而对系统的其他部分只有相当模糊的概念。
但是,也有一个好消息。系统中的所有服务都是用一种编程语言--Ruby编写的。这些服务主要通过REST API调用进行同步通信,只有很少一部分是异步事件。而且,当时整个工程团队相对较小(大约10名工程师),所以至少人与人之间的沟通很顺利。
巨大的重构计划
整个重构史诗由三个大的部分组成。首先,我们必须重新组织数据,然后库本身应该被设计和实现,最后,整合到所有的服务中。每个阶段都需要大量的努力。数据重组和图书馆与每个服务的实际整合都需要对领域和应用层进行重大改变。第一个任务(重组数据)影响了系统中几乎所有服务的数据层。而最后一项(库的整合)需要对一些服务进行重大的改变甚至是重新实现,因为每一个服务的领域和应用层的很大一部分都应该被改变甚至是重新实现。
从技术角度来看,库本身是比较简单的,但由于它实现了系统的每一部分的接口,你可以想象它引起了多少与所有利益相关者的会议。
好消息是,你不需要一下子重构整个系统。你可以(并且建议)从系统中的一个小服务开始:定义该服务所负责的数据,稍微重构其接口,在库中实现相关的 "模型 "和 "动作",并在所有需要与该服务通信的其他服务中使用该库。对于系统的现有部分来说,这应该是一个进化的过程,但是,如果你要建立一个新的服务,最好从一开始就使用新的方法。
但是,让我们详细回顾一下每个步骤
把你的数据整理好
与每个特定业务实体相关的数据必须在一个相应的服务中存储和管理。该服务对实体的数据负有完全的责任,因此,它成为关于实体实际状态的单一真理来源(实体的 "主服务")。其他服务("实体的消费者")对其进行初始化,并且只通过使用负责的主服务进行更改。在某些情况下,消费者可能有一些实体数据的本地副本(如缓存),甚至用仅在其边界环境中相关的额外属性来扩展实体。但是所有这些修改都应该只在消费者服务内部进行封装,而不应该暴露给系统。任何对其他消费者可见的修改都必须通过主服务完成。
这样的数据分离可能是一个相当复杂的问题,涉及所有的利益相关者,包括工程师和产品所有者。为了解决这个问题,我们应该记住,在一个软件系统中,总是有两种类型的数据。首先是 "外部数据" - 代表系统周围的 "世界 "的数据。在我们的系统中,很好的例子是 "人 "和 "财产"。人(和他们的属性)的存在与系统无关,所以系统可能处理这类数据的最好方式是确保它们与外部世界的现实相一致。而 "内部数据 "实际上是由系统本身产生的所有其他数据。这并不意味着这些实体与 "真实 "世界毫无共同之处。它们确实有,例如,像 "线索 "或 "保险请求 "这样的抽象概念肯定存在于系统的业务领域中。但这些数据是系统和与系统一起运作的业务的产物。
让我们详细看看我们系统中的 "人"-"候选人"-"线索 "三者的关系。其业务逻辑如下。一个客户(人)可能对购买新的保险单感兴趣,他在公司页面上提交了一个表格。系统中出现了两个实体。"人 "和 "候选人"。然后系统检查是否有可能为客户找到更好的(更便宜的)保单,如果是,"候选人 "就转变为 "线索 "并进入系统的另一个服务。因此,客户作为一个人在系统中被表示为一个 "人",这些是 "外部数据"。同时,"候选人 "和 "领导 "是由系统创建的实体。
实际上,在我们的系统中处理 "内部数据 "并不是一个大问题。我们在服务之间有非常明确的责任分工,因此这些数据最初被储存和管理在相应的服务中。真正的问题是与 "外部数据 "有关的:人、财产,等等。这些实体几乎在系统中的每个服务中都有呈现(以不同的方式)。解决办法很简单--我们只是把所有这些 "外部 "的抽象概念移到一个单一的服务中--"起源"。原点 "服务不只是简单地存储客户、属性和其他实体。事实上,它是整个系统的外部世界的一个大模型。所有的实体,它们之间的关系,以及访问和修改它们的接口都在一个单一的地方。
在数据迁移完成后,人们有了一个很好的基础来定义系统中的基本数据结构。然后,它们将被放入库中,在系统的任何服务中都可以被观察和访问。这些数据结构、它们的名称以及它们的属性名称将成为系统的 "名词",并涵盖整个概念、实体、状态等的集合。
最有可能的是,在数据重组之后,服务的接口也将需要修改。而且,很有可能,它们会变得更简单。每个实体都有自己的位置,你可以用id或uuid来引用它。所以,如果以前你必须在服务之间传递大量的实体属性,现在你只需传递它们的id即可。然后一个服务可以自己获取所有必要的数据。当然,有一个性能和简单性的权衡,但我们在这篇文章中倾向于简单性。
记住,我们将把整个通信封装在Ruby库中,最后,我们将使用普通的Ruby对象和它们的方法进行通信。当然,在幕后,它将是(大部分)REST API,它有不同的语义(URI,HTTP动词,状态代码)。这些细微的差别应该被考虑到,但是,首先,在为库中的对象和方法选择名称时,必须关注Ruby和人类语言。REST语义的优先级较低,它只是基础设施层的一部分,将被隐藏在库内。基本上,就是这样。在正确组织了数据和相应的接口之后,我们准备创建一个库,用简单而漂亮的Ruby对象来包装所有这些。
认识Palantir
让我们回顾一下这个库背后的基本想法。有两个主要目的:定义系统级模型和封装服务间通信。
该库的结构如下所示。
库的高层次结构
"模型"、"验证器 "服务于第一个目的,而其余的抽象则与通信层有关。
模型
模型是具有简单验证的数据结构。所有的模型类都继承自Palantir::BaseModel。
module Palantir class BaseModel include ActiveModel::Validations include ActiveModel::Serializers::JSON attr_accessor :id
...
endend
所以,唯一的额外功能是验证和序列化。而每个模型实际上是一组带有相应验证的属性。
module Palantir class Person
模型的接口由 "build "类方法(继承自Palantir::BaseModel)组成,它从属性中建立模型,还有几个用于验证和序列化的实例方法。
在Palantir中保留一个微小的验证层的想法是双重的。首先,验证语句提供了关于属性的可能值的额外信息,换句话说,更严格地定义类型。第二,这些验证在每个请求前都会被应用,如果有错误,请求将不会被发送,但会返回一个错误。
ActiveModel::Serializers::JSON模块只是简化了json序列化的程序。
行动
"Action "是系统中每个通信行为的一个通用词。它的含义与 "请求 "有一些不同。请求更多的是关于两个特定服务之间的通信。但由于Palantir隐藏了通信方,所以最好使用 "行动 "这个词,并将通信视为系统中的一个行动(业务-行动)。
系统中既有同步行动,也有异步行动,我们引入了这两类通信的严格分离。同步请求是通信的基本方式,业务逻辑建立在它们之上,任何对业务实体的 "重大 "改变都是同步进行的。异步消息只是通知系统关于 "小的 "变化。这些消息也需要在一个或多个服务中进行一些处理,但它们对系统行为的影响不大。可以肯定的是,重要的和不重要的操作之间的区别应该在业务层面上做出,但一个好的经验法则是,系统应该只用同步消息保持功能,而异步消息则控制一些非关键的方面。或者,从技术上讲,人们可以问两个问题:我们是否需要确保消息处理成功,以及如果消息丢失会发生什么。如果你不关心通信的结果,而且如果消息在途中消失了,系统的一致性也不会被破坏,那么异步请求就是一个正确的选择。
在系统中,有两种类型的同步动作。"问题 "和 "命令"。分离背后的想法是为了保持GET(问题)和POST/PUT/DELETE(命令)HTTP请求的语义。一个问题表达了对一些数据的需求--一个服务向系统 "询问 "数据。而命令则是告诉系统要做一些事情或/和改变系统中的数据。就系统中的数据流而言,问题是关于从系统流向服务的数据,而命令则是向系统发送新数据。
而 "语句 "呈现的是异步通信。
行动 "的接口模仿了RPC(远程过程调用)的常见方法,每个行动都有相应的 "请求 "和 "响应 "对象,它们是包含执行通信所需实体的复合对象。每个动作都是一个类,只有一个 "调用 "方法,接受一个 "请求 "对象并返回一个 "响应 "对象。下面是一个Palantir::CreateProperty命令的例子。
request
response
注意命令的命名惯例,它是一个动词与对象(名词)--为理解命令目的所需的最小语义信息。
请求对象包含数据的验证和序列化的逻辑。
module Palantir class CreateProperty
而命令本身定义了执行行动所需的所有必要步骤,同时也有关于数据发送地点的信息。
module Palantir class CreateProperty < BaseOrganizer organize Common::SetDefaults, Common::Validate, Common::Serialize, DoRequest, Common::Deserialize, Common::CheckStatus, Common::BuildProperty, Common::BuildAddress
def self.endpoint 'properties' end def self.location Locator.origin_location end
def self.call(request) ... super(request: request, organizer: self).response end ... endend
我们使用一个伟大的交互器库来定义中间步骤。而人们可以看到在引擎盖下实际发生的情况。首先,一些默认值可能被填充到动作的对象中。然后进行验证,(客户端),如果数据有问题,将立即返回错误响应。如果一切正常,那么将执行一个实际的HTTP请求(Common::Serialize -> DoRequest -> Common::Deserialize)。Common::CheckStatus会验证响应的状态,并可能以错误响应停止管道。最后,最后的两个步骤是建立Property和Address对象。
响应类定义了人们必须在命令输出中期待的数据。在给定的例子中,它是非常简单的。
module Palantir class CreateProperty class Response
所以我们可以确定,响应对象将有 "属性 "和 "地址 "对象,这些对象在命令成功后被建立。
response
这基本上是关于库中的主要抽象的内容。"模型"、"行动"、"请求/响应"。有几个实用组件(RestApiClient、MqClient、Locator)实现了低级别的通信和服务发现逻辑,但这些不值得在文章中考虑。
服务中的库
当你把库集成到一个特定的服务中时,会出现一个问题:可以在服务领域/应用层中直接使用库模型,还是应该用其他对象来包装它们?对于简单的使用情况,直接使用库中的类是可以的。在更复杂的情况下,可以使用继承或组合。但是,一般来说,库是系统代码库的一部分,而不是第三方代码库,所以它应该被视为服务代码的 "本地 "部分。
其他困难与通信层有关:每个动作都需要一些时间来完成,而且请求可能会失败(网络问题或其他服务器已经崩溃)。可以肯定的是,缓慢的请求应该在服务端以某种方式进行处理,人们可以考虑对这些动作进行并发处理(例如后台处理)。一个更好的解决方案是改变逻辑,做几个快速请求来代替。例如,与其使用DoSlowRequest命令,不如先启动进程,然后在准备好后获得结果。StartProcess命令,ResultReady语句,GetResult问题。
结束语
系统域层和通用的泛在语言
在分层架构方面,Palantir部分占据了基础设施和领域层。在基础设施层,该库负责在服务之间传递消息。而在领域层,它代表了一个 "系统领域层"--代表业务基本概念的领域的公共部分。领域层是最重要的部分,因为它定义了每个人都会遵循的 "泛在语言",并且在未来很难改变它。因此,将系统领域层作为一个独立的软件,可以在系统的每个部分重复使用,可以简化很多事情。工程师可以只关注应用层--一组应该在系统域层之上执行的任务或工作。
每个服务通过自己的抽象来扩展系统域层,以满足特定的业务需求。如果新的概念只是局部的(只在它的边界范围内有意义),它们将只是对系统域层的补充,并将在代码中与库的抽象一起存在。但如果一个新引入的概念对整个系统有意义,并将在系统的其他部分使用,那么它就必须包含在库本身。在任何情况下,"本地语言 "和对 "系统语言 "的扩展都是建立在定义良好的 "通用泛在语言 "之上的,每个人都会说和理解。
单一化
有人可能会说,通过Palantir库的方法,我们实际上又回到了单片机。这部分是正确的。目前,每个服务都严重依赖于渗透到所有代码中的通用抽象概念。库中的任何变化都会导致相应的代码库的大量变化,放慢系统的开发速度,并减少SOA方法的优势。
但是,让我们再次回顾一下这种耦合发生在什么层面上。它是在共同的数据结构和行动的层面上--一个经过充分讨论和设计的系统核心。这是一个协作性的架构决定的产物,(就像任何这样的决定一样)在任何情况下都难以改变。这样的 "系统级 "耦合在SOA中是不可能避免的:将一个系统分割成若干部分,以解耦特定的服务功能,但总有一些共同的东西会影响两个或更多 "解耦 "的部分。
从哲学上讲,如果没有连接各部分的共同抽象,就不可能将系统作为一个整体(而不仅仅是其部分的集合)来理解(感知)。
考虑一个简单的例子,我们需要在一个共同的数据结构中引入一个新的属性,为了支持这个属性,许多服务中的逻辑应该被改变。或者想象一个复杂的案例,当对一个现有的命令接口进行重大改变时。在这两种情况下,新的功能应该首先在库中实现,然后所有的依赖服务都将被更新。
但是,如果没有这个库,人们也会做同样的事情。新的数据结构将出现在系统的某个地方,负责的服务的接口将被改变,然后所有其他部分的代码将被改变。实际上,即使在一个单体应用中,人们也会经历同样的操作。
因此,是的,系统被库耦合了,但它是 "自然 "耦合的,基于共同的东西,在任何情况下它都是耦合的。同时,在没有Palantir的情况下,我们系统内部的相互依赖关系是在文档、API规范和厨房对话的某个地方表达的。但现在,它们被明确地定义在代码中。
异质系统
所描述的方法在用一种编程语言编写的系统中是可行的,但如果一个系统中使用了几种语言,那该怎么办呢。有几种可能的情况。
第一种相当流行的情况是,系统的大部分关键部分使用一种 "核心 "语言,而有几个实用服务是用其他语言编写的。在这种情况下,有一个类似Palantir的系统核心库是绝对可以的。如果有必要的话,甚至可以用其他语言实现其基本变体。
第二种情况是一个 "技术动物园 "或 "标签塔 "系统,可能只用一种语言描述--英语(大部分),而且只在文档中描述。也许,有可能用一种元语言来描述核心行为,并为每个服务自动生成客户端库(类似于gRPC框架的方法),但我怀疑这是否能简化事情。对于这种情况,最合适的解决方案是创建一个单一的通信点(如API网关),并通过其接口将系统 "单一化"。网关的接口与库的作用相同--所有常见的抽象(名词和动词)都将在一个地方定义。
另一个颇受关注的方法是事件源模式,它基本上也是这样做的--它在通信层之上建立了一个通用语言。但它是以一种棘手的方式实现的--通过用系统中发生的 "领域事件 "来表示系统逻辑,而每个服务对这些事件做出 "反应"。这种方法比较复杂,因为它给由服务组成的系统带来了一种反应式范式,里面有一个(大部分)"主动 "的方法。事件的语言与服务中使用的类似RPC的语言有很大的不同。它更多的是关于系统内部的变化,隐含地影响系统的状态,而不是直接改变状态的声明性动作。这种思维方式与我们在传统编程中的思维方式有很大的不同,因此在选择它时应该仔细考虑。
为SOA中的服务间通信设计一个库》最初发表在《Nerd For Tech》杂志上,人们通过强调和回应这个故事继续对话。