了解清晰架构之前需要大家先熟悉以下常见架构方案:
EBI架构(Entity-Boundary-Interactor Architecture)
领域驱动设计(Domain-Driven Design)
端口与适配器架构(Ports & Adapters Architecture,又称为六边形架构)
洋葱架构(Onion Architecture)
整洁架构(Clean Architecture)
事件驱动架构(Event-Driven Architecture)
命令查询职责分离模式(CQRS,即Command Query Responsibility Segregation)
面向服务的架构(Service Oriented Architecture)
清晰架构(Explicit Architecture,直译为显式架构)是将上述架构的部分优势整合之后产生的另一种架构,因其2017年已经出现,已经不算是一种新的架构,实际应用的项目尚且较少。以下主要介绍架构的形成及各步骤的意义。
端口和适配器架构明确地识别出了一个系统中的三个基本代码构建块:
在远离【应用核心】的地方,有一些应用会用到的工具,例如数据库引擎、搜索引擎、Web 服务器或者命令行控制台(虽然最后两种工具也是传达机制)。
把命令行控制台和数据库引擎都是应用使用的工具,关键的区别在于,命令行控制台和 Web 服务器告诉我们的应用它要做什么,而数据库引擎是由我们的应用来告诉它做什么。
连接工具和应用核心的代码单元被称为适配器(源自端口和适配器架构)。适配器有效地实现了让业务逻辑和特定工具之间可以相互通信的代码。
“告知我们的应用应该做什么”的适配器被称为主适配器或主动适配器,而那些“由我们的应用告知它该做什么”的适配器被称为从适配器或者被动适配器。
适配器需要按照应用核心某个特定的入口的要求来创建,即端口。在大多数语言里最简单的形式就是接口,但实际上也可能由多个接口和 DTO 组成。
端口(接口)位于业务逻辑内部,而适配器位于其外部,这一点要特别注意。要让这种模式按照设想发挥作用,端口按照应用核心的需要来设计而不是简单地套用工具的 API。
主适配器或主动适配器包装端口并通过它告知应用核心应该做什么。它们将来自传达机制的信息转换成对应用核心的方法调用。
换句话说,我们的主动适配器就是 Controller 或者控制台命令,它们需要的接口(端口)由其他类实现,这些类的对象通过构造方法注入到 Controller 或者控制台命令。
再举一个更具体的例子,端口就是 Controller 需要的 Service 接口或者 Repository 接口。Service、Repository 或 Query 的具体实现被注入到 Controller 供 Controller 使用。
此外,端口还可以是命令总线接口或者查询总线接口。这种情况下,命令总线或者查询总线的具体实现将被注入到 Controller 中, Controller 将创建命令或查询并传递给相应的总线。
和主动适配器包装端口不同,被动适配器实现一个端口(接口)并被注入到需要这个端口的应用核心里。
举个例子,假设有一个需要存储数据的简单应用。我们创建了一个符合应用要求的持久化接口,这个接口有一个保存数据数组的方法和一个根据 ID 从表中删除一行的方法。接口创建好之后,无论何时应用需要保存或删除数据,都应该使用实现了这个持久化接口的对象,而这个对象是通过构造方法注入的。
现在我们创建了一个专门针对 MySQL 实现了该接口的适配器。它拥有保存数组和删除表中一行数据的方法,然后在需要使用持久化接口的地方注入它。
如果未来我们决定更换数据库供应商,比如换成 PostgreSQL 或者 MongoDB,我们只用创建一个专门针对 PostgreSQL 实现了该接口的适配器,在注入时用新适配器代替旧适配器。
这种模式有一个特征,适配器依赖特定的工具和特定的端口(它需要提供接口的特定实现)。但业务逻辑只依赖按照它的需求设计的端口(接口),它并不依赖特定的适配器或工具。
换句话说,适配器根据使用的工具不同可以灵活变更,但是业务逻辑产生的接口基本不会变化。
这意味着依赖的方向是由外向内的,这就是架构层面的控制反转原则。
再一次强调,端口按照应用核心的需要来设计而不是简单地套用工具的 API。
洋葱架构采用了 DDD 的分层,将这些分层融合进了端口和适配器架构。这种分层为位于端口和适配器架构“六边形”内的业务逻辑带来一种结构组织,和端口与适配器架构一样,依赖的方向也是由外向内。
在应用中,由一个或多个用户界面触发的应用核心中的过程就是用例。例如,在一个 CMS 系统中,我们可以提供普通用户使用的应用 UI、CMS 管理员使用的独立的 UI、命令行 UI 以及 Web API。这些 UI(应用)可以触发的用例可能是专门为它设计的,也可以是多个 UI 复用的。
用例定义在应用层中,这是 DDD 提供的第一个被洋葱架构使用的层。
这个层包括了应用服务(以及它们的接口),也包括了端口与适配器架构中的接口,例如 ORM 接口、搜索引擎接口、消息接口等等。如果我们使用了命令总线和查询总线,命令和查询分别对应的处理程序也属于这一层。
继续向内一层就是领域层。这一层中的对象包含了数据和操作数据的逻辑,它们只和领域本身有关,独立于调用这些逻辑的业务过程。它们完全独立,对应用层完全无感知。
1.领域服务
我们偶尔会碰到某种涉及不同实体的领域逻辑,当然,无论实体是否相同,直觉告诉我们这种领域逻辑并不属于这些实体,这种逻辑不是这些实体的直接责任。
所以,我们的第一反应也许是把这些逻辑放到实体外的应用服务中,这意味着这些领域逻辑就不能被其它的用例复用:领域逻辑应该远离应用层。
解决方法是创建领域服务,它的作用是接收一组实体并对它们执行某种业务逻辑。领域服务属于领域层,因此它并不了解应用层中的类,比如应用服务或者 Repository。另一方面,它可以使用其他领域服务,当然还可以使用领域模型对象。
2.领域模型
在架构的正中心,是完全不依赖外部任何层次的领域模型。它包含了那些表示领域中某个概念的业务对象。这些对象的例子首先就是实体,还有值对象、枚举以及其它领域模型中用到的任何对象。
领域事件也“活在”领域模型中。当一组特定的数据发生变化时就会触发这些事件,而这些事件会携带这些变化的信息。换句话说,当实体变化时,就会触发一个领域事件,它携带着发生变化的属性的新值。这些事件可以完美地应用于事件溯源。
目前为止,我们都是使用层次来划分代码,但这是细粒度的代码隔离。根据 Robert C. Martin 在尖叫架构中表达的观点,按照子域和限界上下文对代码进行划分这种粗粒度的代码隔离同样重要。这通常被叫做“按特性分包”或者“按组件分包”,和“按层次分包”相呼应。
我是“按组件分包”方式的坚定拥护者,在此我厚着脸皮将 Simon Brown 按组件分包的示意图做了如下修改:
这些代码块在前面描述的分层基础上再进行了“横切”,它们是应用的组件(译)。
组件的例子包括认证、授权、账单、用户、评论或帐号,而它们总是都和领域相关。像认证和授权这样的限界上下文应该被看作外部工具,我们应该为它们创建适配器,把它们隐藏在某个端口之后。
与细粒度的代码单元(类、接口、特质、混合等等)一样,粗粒度的代码单元(组件)也会从高内聚低耦合中受益。
我们使用依赖注入(通过将依赖注入类而不是在类内部初始化依赖)以及依赖倒置(让类依赖抽象,即接口和抽象类,而不是具体类)来解耦类。这意味着类不用知道它要使用的具体类的任何信息,不用引用所依赖的类的完全限定类名。
以同样的方式完全解耦组件意味着组件不会直接了解其它任何组件的信息。换句话说,它不会引用任何来自其它组件的细粒度的代码单元,甚至都不会引用接口!这意味着依赖注入和依赖倒置对组件解耦是不够用的,我们还需要一些架构层级的结构。我们需要事件、共享内核、最终一致性甚至发现服务!
1.触发其它组件的逻辑
当一个组件(组件 A)中有事情发生需要另一个组件(组件B)做些什么时,我们不能简单地从组件 A 直接调用组件 B 中的类/方法,因为这样 A 就和 B 耦合在一起了。
但是我们可以让 A 使用事件派发器,派发一个领域事件,这个事件将会投递给任何监听它的组件,例如 B,然后 B 的事件监听器会触发期望的操作。这意味着组件 A 将依赖事件派发器,但和 B 解耦了。
然而,如果事件本身“活在” A 中,这将意味着 B 知道了 A 的存在,就和 A 存在耦合。要去掉这个依赖,我们可以创建一个包含应用核心功能的库,由所有组件共享,这就是共享内核。这意味着两个组件都依赖共享内核,而它们之间却没有耦合。共享内核包含了应用事件和领域事件这样的功能,而且还包含规格对象,以及其它任何有理由共享的东西。记住共享内核的范围应该尽可能的小,因为它的任何变化都会影响所有应用组件。
而且,如果我们的系统是语言异构的,比如使用不同语言编写的微服务生态,共享内核需要做到与语言无关的,这样它才能被所有组件理解,无论它们是用哪种语言编写的。例如,共享内核应该包含像 JSON 这样无关语言的事件描述(例如,名称、属性,也许还有方法,尽管它们对规格对象来说更有意义)而不是事件类,这样所有组件或者微服务都可以解析它,还可以自动生成各自的具体实现。
这种方法既适用于单体应用,也适用于像微服务生态系统这样的分布式应用。然而,这种方法只适用于事件异步投递的情况,在需要即时完成触发其它组件逻辑的上下文中并不适用!组件 A 将需要向组件 B 发起直接的调用,例如HTTP。这种情况下,要解耦组件,我们需要一个发现服务,A 可以询问它得知请求应该发送到哪里才能触发期望的操作,又或是向发现服务发起请求并由发现服务将请求代理给相关服务并最终返回响应给请求方。这种方法会把组件和发现服务耦合在一起,但会让组件之间解耦。例如jsf。
2.从其它组件获得数据
原则上,组件不允许修改不“属于”它的数据,但可以查询和使用任何数据。
1)组件之间共享数据存储
当一个组件需要使用属于其它组件的数据时,比如说账单组件需要使用属于账户组件的客户名字,账单组件会包含一个查询对象,可以在数据存储中查询该数据。简单的说就是账单组件知道任何数据集,但它只能通过查询只读地使用不“属于”它的数据。
2)按组件隔离的数据存储
这种情况下,这种模式同样有效,但数据存储层面的复杂度更高。
组件拥有各自的数据存储意味着每个数据存储都包含:
每个组件都会创建其所需的其它组件数据的本地副本,在必要时使用。当数据在其所属的组件中发生了变化,该组件将触发一个携带数据变更的领域事件。拥有这些数据副本的组件将监听这个领域事件并相应地更新它们的本地副本。
如前所述,控制流显然从用户出发,进入应用核心,抵达基础设施工具,再返回应用核心并最终返回给用户。但这些类到底是是如何配合的?哪些类依赖哪些类?我们怎样把它们组合在一起?
如果没有命令总线,控制器要么依赖应用服务,要么依赖查询对象。
上图中我们使用了应用服务接口,尽管我们会质疑这并没有必要。因为应用服务是我们应用代码的一部分,而且我们不会想用另外一种实现来替换它,尽管我们可能会彻底地重构它。
如果我们的应用使用了命令/查询总线,UML 图基本没有变化,唯一的区别是控制器现在会依赖总线、命令或查询。它将实例化命令或查询,将它们传递给总线。总线会找到合适的处理程序接收并处理命令。
在下图中,命令处理程序接下来将使用应用服务。然而,这不总是必须的,实际上大多数情况下,处理程序将包含用例的所有逻辑。只有在其它处理程序需要重用同样的逻辑时,我们才需要把处理程序中的逻辑提取出来放到单独的应用服务中。
总线和命令查询,以及处理程序之间没有依赖。这是因为实际上它们之间应该互相无感知,才能提供足够的解耦。只有通过配置才能设置总线可以发现哪些命令,或者查询应该由哪个处理程序处理。
如你所见,两种情况下,所有跨越应用核心边界的箭头——依赖——都指向内部。如前所述,这是端口和适配器架构、洋葱架构以及整洁架构的基本规则。
共享内核由 DDD 之父 Eric Evans 定义,它是多个限界上下文之间共享的代码,由开发团队决定:
[…] 两个团队同意共享的领域模型的子集。当然,和模型子集一起共享还包括代码的子集,还有和这部分模型有关的数据库设计。这部分明确要共享的内容有着特殊的状态,而且在没有和其他团队达成一致的情况下不应该修改。
Shared Kernel(http://ddd.fed.wiki.org/view/shared-kernel), Ward Cunningham 的 DDD wiki
所以基本上,它可能是任何类型的代码:领域层代码、应用层代码、库,随便什么代码。
然而,在这份心智地图里,我们将它当做一些特定类型的代码的子集。共享内核包含的是领域层和应用层的代码,这些代码会在限界上下文之间共享,让这些上下文可以互相通信。
这意味着,例如,一个或多个限界上下文触发的事件可以在其它的限界上下文里被监听到。需要和这些事件一起共享的还有它们用到的所有数据类型,例如:实体 ID、值对象、枚举,等等。事件不应该直接使用像实体这样的复杂对象,因为将它们序列化到队列中或是从队列中反序列化时都会遇到一些问题,所以共享的代码不应该太宽泛。
当然,如果我们手中的是一个由不同语言开发的微服务组成的多语言系统,共享内核必须是描述性的语言,格式是 json、xml、yaml 或者其它,这样所有的微服务都能理解。
因此,共享内核就完全和其余的代码以及组件完全解耦了。这样很好,因为这意味着尽管组件耦合了共享内核,但组件之间不再耦合。共享代码可以被清晰地识别出来,并轻松地提取到一个独立的库中。
如果我们决定将一个限界上下文从单体中分离出来并提取成一个微服务,这也会很方便。我对共享代码了然于心,可以轻松地将共享内核提取到一个库中。而这个库即可以安装到单体中,也可以安装到微服务中。
第一张脑图由一系列同心圆层级组成,它们最终按照业务维度的应用模块切分,形成组件。在这张图里,依赖的方向由外向内,意味着内层对外层可见,而外层对内层不可见。
第二张则是一组平面的层级,其中最上面的一层就是前面这张同心圆,下一层是组件之间共享的代码(共享内核),再下一层使是我们自己对编程语言的扩展,最下面一层则是实际使用的编程语言。这里的依赖方向是自上而下的。
使用体现架构的代码风格,意味着代码风格(编码规范、类/方法/变量命名约定、代码结构…)某种程度上可以和阅读代码的人交流领域和架构的设计意图。要实现体现架构的代码风格,主要有两种思路。
“[…] 体现架构的代码风格能让你给代码的阅读者留下提示,帮助他们正确地推断出设计意图。”
—George Fairbanks(https://links.jianshu.com/go?to=https%3A%2F%2Fresources.sei.cmu.edu%2Fasset_files%2FPresentation%2F2013_017_001_48651.pdf)
第一种思路是通过代码制品的名字(类、变量、模块…)来传达领域和架构的含义。因此,如果一个类是处理收据(Invoice)实体的仓库(Repository),我们就应该将它命名成InvoiceRepository,从这个名字我们就可以看出,它处理的是收据领域的概念,而它在架构中被当做一个仓库。这可以帮助我们理解它应该放在哪个地方,何时使用它以及如何使用它。但是,我认为代码仓库中并不是每个代码制品都需要这样做,例如,我觉得不必为每个实体(Entity)都加上后缀Entity,这样做就有些画蛇添足,徒增噪音。
“[…] 代码应该体现架构。换句话说,我一看到代码,就应该能够清晰地区分出各种组件[…]”
—Simon Brown(https://links.jianshu.com/go?to=http%3A%2F%2Fwww.codingthearchitecture.com%2F2014%2F06%2F01%2Fan_architecturally_evident_coding_style.html)
第二种思路是让代码仓库中的顶级制品明确地区分出各个子域,即领域维度的模块,也就是组件。
第一种思路应该很清楚,无需赘述。但第二种思路有点儿微妙,我们得深入探讨一下。
在我的第一张图里,我们已经看到,在最粗粒度的层级上,我们只有三种不同用途的代码:
因此,在源代码的根目录下我们可以创建三个文件夹来体现这三类代码,一个文件夹对应一个类别的代码。这三个文件夹表示三个命名空间,稍后我们甚至可以创建测试来断言核心对用户界面和基础设施可见,反过来却不可见,也就是说,我们可以测试由外向内的依赖方向。
一个 Web 企业应用通常拥有多套 API,例如,一套给客户端使用的 REST API,还有一套给第三方应用使用的 web-hook, 业务还有一套需要维护的遗留 SOAP API,或者还有一套给全新移动应用使用的 GraphQL API…
这样的应该通常还有一些 CLI 命令,用于定时作业(Cron Job)或按需的维护操作。
当然,还有普通用户可以使用的网站本身,但也许还有另一个供应用管理员使用的网站。
这些全都是同一个应用的不同视图,全都是同一个应用的不同用户界面。
实际上我们的应用可能拥有多个用户界面,其中有些还是供非人类用户(第三方应用)使用的。我们通过文件/命名空间来区分并隔离这些用户界面,来展现出这一点。
用户界面主要有三类:API、CLI 和网站。所以我们在UserInterface根命名空间里为每个类别创建一个文件夹,将不同界面的类型清晰地区分开来。
下一步,如果有必要的话,我们还可以继续深入每种类型的命名空间,再创建更细分类的用户界面的命名空间(CLI 可能不需要再细分了)。
和用户界面一样,我们的应用使用了多种工具(库和第三方应用),例如 ORM、消息队列、SMS 提供商。
此外,上述每一种工具都可以有不同的实现。例如,考虑一家公司业务扩张到另一个国家的情况,由于价格的因素,不同的国家最好采用不同的 SMS 提供商:我们需要端口相同的适配器的不同实现,这样使用时可以互相替换。另一个例子是对数据库 Schema 进行重构或者切换数据库引擎,需要(或决定要)切换 ORM 时:我们会在应用中注入两种 ORM 适配器。
因此,在Infrastructure命名空间来说,我们先给每一种工具类型创建一个命名空间(ORM、MessageQueue、SmsClient),然后再每一种工具类型内部为每一种用到的供应商(Doctrine、Propel、MessageBird、Twilio…)的适配器在创建一个命名空间。
在Core命名空间下,可以按照最粗粒度的层级划分出三类代码: 组件(Component)、共享内核(Shared Kernel) 和 端口(Port)。为这三个类别创建文件夹/命名空间。
1.组件
在 Component 命名空间下,我们为每个组件创一个命名空间,然后在每个组件命名空间下,我们再分别为应用(Application)层和领域(Domain)层分别创建一个命名空间。 在 Application 和 Domain 命名空间下,我们先将全部类放在一起,随着类的数量不断增加,再来考虑必要的分组(我觉得一个文件夹下就放一个类有些矫枉过正,所以我宁愿在必要时再进行分组)。
这是我们就要考虑是按照业务主题(收据、交易…)分组还是按照技术作用(仓库、服务、值对象…)分组,但我觉得无论怎样分组影响都不大,因为这已经是整个代码组织树的叶子节点了,如果需要,在整个组织结构的最底端进行调整也很简单,不会影响代码仓库的其它部分。
2.端口
和 Infrastructure 命名空间一样,Port 命名空间里核心使用的每一种工具都有一个命名空间,核心通过这些代码才能使用底层的这些工具。
这些代码还会被适配器使用,它们的作用就是端口和真正工具之间的转换。这种形式简单得不能再简单了,端口就是一个接口,但很多时候它还需要值对象、DTO、服务、构建起、查询对象甚至是仓库。
3.共享内核
我们把在组件之间共享的代码放到 Shared Kernel 命名空间下。尝试了几种不同的共享内核内部结构之后,我无法找到一种适用于所有情况的结构。有些代码和Core\Component一样按组件划分很合理(例如 Entity ID 显然属于一个组件),有些代码这样划分却不合适(例如,事件可能被多个组件触发或监听)。也许要结合使用两种划分的思路。
最后,我们还有一些自己对编程语言的扩展。这个系列中前面一篇文章已经讨论过,这些代码本可以放在编程语言中,却因为某些原因没有。比如,在 PHP 中我们可以想到的是 DateTime 类,它基于 PHP 提供的类扩展,提供了一些额外的方法。另一个例子是 UUID 类,尽管 PHP 没有提供,但是这个类天然就是纯粹的、对领域无感,因此可以在任意项目中使用,并且不依赖任何领域。
这些代码用起来和编程语言自己的提供的功能没啥区别,因此我们要完全掌控这些代码。然而,这并不是意味着我们不能使用第三方库。我们能用而且应该用,只要合理,但是这些库应该用我们自己的实现包装起来(这样的话我们可以方便的切换背后的第三方库),而应用代码应该直接使用这些包装代码。最终,这些代码可以自成项目,使用自己的 CVS 仓库,被多个项目使用。
我们有哪些可供选择的文档工具来表达整个应用的构建块以及应用如何工作?!
UML
4+1 架构视图模型
架构决策记录
C4 模型
依赖图
应用地图
C4 模型是 Simon Brown 发明的,是我目前看到的关于软件架构文档的最好思路。我会快速地用自己的语言来阐述主要的思路,但使用的还是他的图例。
其思路是用四种不同粒度(或者“缩放”)层级来记录软件的架构:
第一级:系统上下文图
第二级:容器图
第三级:组件图
第四级:代码图
这是最粗粒度的图。它的细节很少但其主要目标是描述应用所处的上下文。因此,这幅图中只有一个方块代表整个应用,其它围绕着应用的方块代表了应用要进行交付的外部系统和用户。
现在,我们将应用放大,也就是上一级图中的蓝色方块,在这一级它对应的是下图中的虚线框。
在这个粒度级别,我们将看到应用得容器,一个容器就是一个应用中技术上独立的一小部分,例如一个移动 App,一个 API 或者一个数据库。它还描述了应用使用的主要技术和容器之间的通信方式。
组件图展示的是一个容器内的组件。在 C4 模型上下文里,每个组件就是应用的一个模块,不光是领域维度的模块(如账单、用户…)还包括纯粹的功能模块(如 email、sms…)。因此这个层级的图向我们展示了一个容器的主要齿轮和齿轮之间的啮合关系。
这是最细粒度的图,目的是描述一个组件内部的代码结构。在这个层级,我们使用的是表示类级别制品的 UML 图。
清晰架构集百家之长,天然有很多优势:
这只是一份指南!应用才是你的疆域,现实情况和具体用例才是运用这些知识的地方,它们才能勾勒出实际架构的轮廓!
我们需要理解所有这些模式,但我们还时常需要思考和理解我们的应用需要什么,我们应该在追求解耦和内聚的道路上走多远。这个决定可能受到许多因素的影响,包括项目的功能需求,也包括构建应用的时间期限,应用寿命,开发团队的体验等等因素。
应用遵循某种领域结构组成,也遵循某种技术结构(即架构)组成。这两种结构才是一个应用的与众不同之处,而不是它使用的工具、库或者传达机制。如果我们想让一个应用可以长时间的维护,这两种结构都要清晰的体现在代码仓库中,这样开发者才能知道、理解、遵循,并在需要时改进。
这种清晰度让我们可以在编码的同时理解边界,这能反过来帮助我们保持应用的模块化设计,做到高内聚低耦合。
作者:京东物流 李国梁
来源:京东云开发者社区 自猿其说Tech