本文首发:公众号 「架构精进」
开工大吉!
分层架构是运用最为广泛的架构模式,几乎每个软件系统都需要通过层(Layer)来隔离不同的关注点(Concern Point),以此应对不同需求的变化,使得这种变化可以独立进行;此外,分层架构模式还是隔离业务复杂度与技术复杂度的利器,《领域驱动设计模式、原理与实践》这样写道:
为了避免将代码库变成大泥球(BBoM)并因此减弱领域模型的完整性且最终减弱可用性,系统架构要支持技术复杂性与领域复杂性的分离。引起技术实现发生变化的原因与引起领域逻辑发生变化的原因显然不同,这就导致基础设施和领域逻辑问题会以不同速率发生变化。
这里所谓的“以不同速率发生变化”,其实就是引起变化的原因各有不同,这正好是单一职责原则(Single-Responsibility Principle,SRP)的体现。Robert Martin 认为单一职责原则就是“一个类应该只有一个引起它变化的原因”,换言之,如果有两个引起类变化的原因,就需要分离。单一职责原则可以理解为架构原则,这时要考虑的就不是类,而是层次,我们为什么要将业务与基础设施分开?正是因为引起它们变化的原因不同。
经典分层架构
分层架构由来已久,把一个软件系统进行分层,似乎已经成为了每个开发人员的固有意识,甚至不必思考即可自然得出,这其中最为经典的就是三层架构以及领域驱动设计提出的四层架构。
经典三层架构
在软件架构中,经典三层架构自顶向下由用户界面层(User Interface Layer)、业务逻辑层(Business Logic Layer)与数据访问层(Data Access Layer)组成,该分层架构之所以能够流行,是有其历史原因的。在提出该分层架构的时代,多数企业系统往往较为简单,本质上都是一个单体架构(Monolithic Architecture)的数据库管理系统。这种分层架构已经是 Client-Server 架构的进化了,它有效地隔离了业务逻辑与数据访问逻辑,使得这两个不同关注点能够相对自由和独立地演化。一个经典的三层架构如下所示:
领域驱动设计的经典分层架构
领域驱动设计在经典三层架构的基础上做了进一步改良,在用户界面层与业务逻辑层之间引入了新的一层,即应用层(Application Layer)。同时,一些层次的命名也发生了变化,将业务逻辑层更名为领域层自然是题中应有之义,而将数据访问层更名为基础设施层(Infrastructure Layer),则突破了之前数据库管理系统的限制,扩大了这个负责封装技术复杂度的基础层次的内涵。下图为 Eric Evans 在其经典著作《领域驱动设计》中的分层架构:
该书对各层的职责作了简单的描述:
层次职责
用户界面/展现层负责向用户展现信息以及解释用户命令
应用层 �很薄的一层,用来协调应用的活动,它不包含业务逻辑,它不保留业务对象的状态,但它保有应用任务的进度状态
领域层 �本层包含关于领域的信息,这是业务软件的核心所在。在这里保留业务对象的状态,对业务对象和它们状态的持久化被委托给了基础设施层
基础设施层 �本层作为其他层的支撑库存在。它提供了层间的通信,实现对业务对象的持久化,包含对用户界面层的支撑库等作用
追溯分层架构的本源
当分层架构变得越来越普及时,我们的设计反而变得越来越僵化,一部分软件设计师并未理解分层架构的本质,只知道依样画葫芦地将分层应用到系统中,要么采用经典的三层架构,要么遵循领域驱动设计改进的四层架构,却未思考和探究如此分层究竟有何道理?这是分层架构被滥用的根源。
视分层(Layer)为一个固有的架构模式,其根源应为 Frank Buschmann 等人著的《面向模式的软件架构》第一卷《模式系统》,该模式参考了 ISO 对 TCP/IP 协议的分层。《模式系统》对分层的描述为:
分层架构模式有助于构建这样的应用:它能被分解成子任务组,其中每个子任务组处于一个特定的抽象层次上。
显然,这里所谓的“分层”首先是一个逻辑的分层,对子任务组的分解需要考虑抽象层次,一种水平的抽象层次。既然为水平的分层,必然存在层的高与低;而抽象层次的不同,又决定了分层的数量。因此,对于分层架构,我们需要解决如下问题:
分层的依据与原则是什么?
层与层之间是怎样协作的?
分层的依据与原则
我们之所以要以水平方式对整个系统进行分层,是我们下意识地确定了一个认知规则:机器为本,用户至上,机器是运行系统的基础,而我们打造的系统却是为用户提供服务的。分层架构中的层次越往上,其抽象层次就越面向业务、面向用户;分层架构中的层次越往下,其抽象层次就变得越通用、面向设备。为什么经典分层架构为三层架构?正是源于这样的认知规则:其上,面向用户的体验与交互;居中,面向应用与业务逻辑;其下,面对各种外部资源与设备。在进行分层架构设计时,我们完全可以基于这个经典的三层架构,沿着水平方向进一步切分属于不同抽象层次的关注点。因此,分层的第一个依据是基于关注点为不同的调用目的划分层次。以领域驱动设计的四层架构为例,之所以引入应用层(Application Layer),就是为了给调用者提供完整的业务用例。
分层的第二个依据是面对变化。分层时应针对不同的变化原因确定层次的边界,严禁层次之间互相干扰,或者至少把变化对各层带来的影响降到最低。例如,数据库结构的修改自然会影响到基础设施层的数据模型以及领域层的领域模型,但当我们仅需要修改基础设施层中数据库访问的实现逻辑时,就不应该影响到领域层了。层与层之间的关系应该是正交的,所谓“正交”,并非二者之间没有关系,而是垂直相交的两条直线,唯一相关的依赖点是这两条直线的相交点,即两层之间的协作点,正交的两条直线,无论哪条直线进行延伸,都不会对另一条直线产生任何影响(指直线的投影);如果非正交,即“斜交”,当一条直线延伸时,它总是会投影到另一条直线,这就意味着另一条直线会受到它变化的影响。
在进行分层时,我们还应该保证同一层的组件处于同一个抽象层次。这是分层架构的设计原则,它借鉴了 Kent Beck 在 Smalltalk Best Practice Patterns 一书提出的“组合方法”模式,该模式要求一个方法中的所有操作处于相同的抽象层,这就是所谓的“单一抽象层次原则(SLAP)”,这一原则可以运用到分层架构中。例如,在一个基于元数据的多租户报表系统中,我们特别定义了一个引擎层(Engine Layer),这是一个隐喻,相当于为报表系统提供报表、实体与数据的驱动引擎。引擎层之下,是基础设施层,提供了多租户、数据库访问与元数据解析与管理等功能。在引擎层之上是一个控制层,通过该控制层的组件可以将引擎层的各个组件组合起来,分层架构的顶端是面向用户的用户展现层,如下图所示:
层与层之间的协作
在我们固有的认识中,分层架构的依赖都是自顶向下传递的,这也符合大多数人对分层的认知模型。从抽象层次来看,层次越处于下端,就会变得越通用越公共,与具体的业务隔离得越远。出于重用的考虑,这些通用和公共的功能往往会被单独剥离出来形成平台或框架,在系统边界内的低层,除了面向高层提供足够的实现外,就都成了平台或框架的调用者。换言之,越是通用的层,越有可能与外部平台或框架形成强依赖。若依赖的传递方向仍然采用自顶向下,就会导致系统的业务对象也随之依赖于外部平台或框架。
依赖倒置原则(Dependency Inversion Principle,DIP)提出了对这种自顶向下依赖的挑战,它要求“高层模块不应该依赖于低层模块,二者都应该依赖于抽象”,这个原则正本清源,给了我们严重警告——谁规定在分层架构中,依赖就一定要沿着自顶向下的方向传递?我们常常理解依赖,是因为被依赖方需要为依赖方(调用方)提供功能支撑,这是从功能重用的角度来考虑的。但我们不能忽略变化对系统产生的影响!与建造房屋一样,我们自然希望分层的模块“构建”在稳定的模块之上,谁更稳定?抽象更稳定。因此,依赖倒置原则隐含的本质是:我们要依赖不变或稳定的元素(类、模块或层),也就是该原则的第二句话:抽象不应该依赖于细节,细节应该依赖于抽象。
这一原则实际是“面向接口设计”原则的体现,即“针对接口编程,而不是针对实现编程”。高层模块对低层模块的实现是一无所知的,带来的好处是:
低层模块的细节实现可以独立变化,避免变化对高层模块产生污染
在编译时,高层模块可以独立于低层模块单独存在
对于高层模块而言,低层模块的实现是可替换的
倘若高层依赖于低层的抽象,必然会面对一个问题:如何把具体的实现传递给高层的类?由于在高层通过接口隔离了对具体实现的依赖,就意味着这个具体依赖被转移到了外部,究竟使用哪一种具体实现,由外部的调用者来决定。只有在运行调用者代码时,才将外面的依赖传递给高层的类。Martin Fowler 形象地将这种机制称为“依赖注入(Dependency injection)”。
为了更好地解除高层对低层的依赖,我们往往需要将依赖倒置原则与依赖注入结合起来。
层之间的协作并不一定是自顶向下的传递通信,也有可能是自底向上通信。例如,在 CIMS(计算机集成制造系统)中,往往会由低层的设备监测系统监测(侦听)设备状态的变化。当状态发生变化时,需要将变化的状态通知到上层的业务系统。如果说自顶向下的消息传递往往被描述为“请求(或调用)”,则自底向上的消息传递则往往被形象地称之为“通知”。倘若我们颠倒一下方向,自然也可以视为这是上层对下层的观察,故而可以运用观察者模式(Observer Pattern),在上层定义 Observer 接口,并提供 update() 方法供下层在感知状态发生变更时调用;或者,我们也可以认为这是一种回调机制。虽然本质上这并非回调,但设计原理是一样的。
如果采用了观察者模式,则与前面讲述的依赖倒置原则有差相仿佛之意,因为下层为了通知上层,需要调用上层提供的 Observer 接口。如此看来,无论是上层对下层的“请求(或调用)”,抑或下层对上层的“通知”,都颠覆了我们固有思维中那种高层依赖低层的理解。
现在,我们对分层架构有了更清醒的认识。我们必须要打破那种谈分层架构必为经典三层架构又或领域驱动设计推荐的四层架构这种固有思维,而是将分层视为关注点分离的水平抽象层次的体现。既然如此,架构的抽象层数就不是固定的,甚至每一层的名称也未必遵循固有(经典)的分层架构要求。设计系统的层需得结合该系统的具体业务场景而定。当然,我们也要认识到层次多少的利弊:过多的层会引入太多的间接而增加不必要的开支,层太少又可能导致关注点不够分离,导致系统的结构不合理。
我们还需要正视架构中各层之间的协作关系,打破高层依赖低层的固有思维,从解除耦合(或降低耦合)的角度探索层之间可能的协作关系。另外,我们还需要确定分层的架构原则(或约束),例如是否允许跨层调用,即每一层都可以使用比它低的所有层的服务,而不仅仅是相邻低层。这就是所谓的“松散分层系统(Relaxed Layered System)”。
分层架构是一种架构模式,但终归它的目的是为了改进软件的架构质量,我们在运用分层架构时,必须要遵守架构设计的最高原则,即建立一个高内聚、松耦合的软件系统架构。于是,许多设计大师们纷纷提出了自己的洞见。
整洁架构
在架构设计时,我们应设计出干净的应用层和领域层,保持它们对业务逻辑的专注,而不掺杂任何具体的技术实现,从而完成领域与技术之间的完全隔离,这一思想被 Robert Martin 称之为整洁架构(Clean Architecture)。下图展现了 Robert Martin 的这一设计思想:
该架构思想提出的模型并非传统的分层架构,而是类似于一个内核模式的内外层架构,由内及外分为四层,包含的内容分别为:
企业业务规则(Enterprise Business Rules)
应用业务规则(Application Business Rules)
接口适配器(Interface Adapters)
框架与驱动器(Frameworks & Drivers)
注意“企业业务规则”与“应用业务规则”的区别,前者是纯粹领域逻辑的业务规则,后者则面向应用,需要串接支持领域逻辑正常流转的非业务功能,通常为一些横切关注点,如日志、安全、事务等,从而保证实现整个应用流程(对应一个完整的用例)。
仔细解读这一架构模型,我们会发现许多有用的特征:
层次越靠内的组件依赖的内容越少,处于核心的 Entities 没有任何依赖。
层次越靠内的组件与业务的关系越紧密,因而越不可能形成通用的框架。
Entities 层封装了企业业务规则,准确地讲,它应该是一个面向业务的领域模型。
Use Cases 层是打通内部业务与外部资源的一个通道,因而提供了输出端口(Output Port)与输入端口(Input Port),但它对外的接口展现的其实是应用逻辑,或者说是一个用例。
Gateways、Controllers 与 Presenters 其本质都是适配器(Adapter),用于打通应用业务逻辑与外层的框架和驱动器,实现逻辑的适配以访问外部资源。
系统最外层包括框架和驱动器,负责对接外部资源,不属于系统(仅指限界上下文而言)开发的范畴,但选择这些框架和驱动器,是属于设计决策要考虑的内容。这一层的一些组件甚至与要设计的系统不处于同一个进程边界。
我们学到了什么?Robert Martin 的整洁架构将领域模型放在整个系统的核心,这一方面体现了领域模型的重要性,另外一方面也说明了领域模型应该与具体的技术实现无关。领域模型就是业务逻辑的模型,它应该是完全纯粹的,无论你选择什么框架,什么数据库,或者什么通信技术,按照整洁架构的思想都不应该去污染领域模型。如果以 Java 语言来实现,遵循整洁架构的设计思想,则所有领域模型对象都应该是 POJO(Plain Ordinary Java Object)。整洁架构的 Entities 层对应于领域驱动设计的领域层。
说明:注意 POJO 与 Java Bean 的区别。Java Bean 是指仅定义了为私有字段提供 get 与 set 方法的 Java 对象,这种 Java Bean 对象除了这些 get 和 set 方法之外,几乎没有任何业务逻辑,Martin Fowler 将这种对象称之为“贫血对象”,根据这种贫血对象建立的模型就是“贫血模型”。POJO 指的是一个普通的 Java 对象,意味着这个 Java 对象不依赖除 JDK 之外的其他框架,是一个纯粹 Java 对象,Java Bean 是一种特殊的 POJO 对象。在领域驱动设计中,如果我们遵循面向对象设计范式,就应避免设计出贫血的 Java Bean 对象;如果我们要遵循整洁架构设计思想,则应尽量将领域模型对象设计为具有领域逻辑的 POJO 对象。
属于适配器的 Controllers、Gateways 与 Presenters 对应于领域驱动设计的基础设施层。就我个人的理解来说,适配器这个词并不能准确表达这些组件的含义,反而更容易让我们理解为是对行为的适配,我更倾向于将这些组件都视为是网关(Gateway)。对下,例如,针对数据库、消息队列或硬件设备,可以认为是一个南向网关,对于当前限界上下文是一种输出的依赖;对上,例如,针对 Web 和 UI,可以认为是一个北向网关,对于当前限界上下文是一种输入的依赖。
这两种方向的网关与 Use Cases 层之间的关系是不尽相同的。北向网关会调用 Use Cases 层中表示应用逻辑的服务组件,即发起的是一个由外向内的调用,这种调用在整洁架构体系下是合乎道理的。Use Cases 层的服务组件并不需要关心北向网关的组件,例如,作为 RESTful 服务的 OrderController,就是北向网关中的一个类,它通过调用 Use Cases 层的 OrderAppService 服务来实现一个提交订单的业务用例。OrderAppService 并不需要知道作为调用者的 OrderController,如果存在将 Entities 层的领域模型对象转换为 RESTful 服务的 Resource 对象,也是 OrderController 或者说北向网关的职责。
南向网关作为底层资源的访问者,往往成为 Use Cases 层甚至 Entities 层的被调用者。由于整洁架构思想并不允许内层获知外层的存在,这就导致了我们必须在内层定义与外层交互的接口,然后通过依赖注入的方式将外层的实现注入到内层中,这也是“控制反转(Inversion of Control)”的含义,即将调用的控制权转移到了外层。由是我们可以得出一个结论,即南向网关封装了与外部资源(DB、Devices、MQ)交互的实现细节,但其公开暴露的接口却需要被定义在内层的 Use Cases 或 Entities 中,这实际上阐释了为什么领域驱动设计要求将 Repository 的接口定义在领域层的技术原因。当然,将 Repository 接口定义在领域层还有其业务原因,在后面我会详细介绍。
六边形架构
整洁架构的目的在于识别整个架构不同视角以及不同抽象层次的关注点,并为这些关注点划分不同层次的边界,从而使得整个架构变得更加清晰,减少不必要的耦合。它采用了内外层的架构模型弥补了分层架构无法体现领域核心位置的缺陷。由 Alistair Cockburn 提出的六边形架构(Hexagonal Architecture)在满足整洁架构思想的同时,更关注于内层与外层以及与外部资源之间通信的本质:
如上图所示,六边形架构通过内外两个六边形为系统建立了不同层次的边界。核心的内部六边形对应于领域驱动设计的应用层与领域层,外部六边形之外则为系统的外部资源,至于两个六边形之间的区域,均被 Cockburn 视为适配器(Adapter),并通过端口(Port)完成内外区域之间的通信与协作,故而六边形架构又被称为端口-适配器模式(port-adapter pattern)。在第04课中,我曾经给出了如下的设计图,该图更加清晰地表达了领域驱动设计分层架构与六边形架构的关系,同时也清晰地展现了业务复杂度与技术复杂度的边界:
我在前面分析整洁架构时,将 Gateways、Controllers 与 Presenters 统一看做是网关,而在六边形架构中,这些内容皆为适配器。事实上,它们代表的含义是一致的,不同的命名代表的是对其职责认识上的不同。如果认为是“网关”,则将该组件的实现视为一种门面,内部负责多个对象之间的协作以及职责的委派;如果认为是“适配器”,则是为了解决内外协议(数据协议与服务接口)之间的不一致而进行的适配。若依据领域驱动设计的分层架构,则无论网关还是适配器,都属于基础设施层的内容。
无论理解为网关还是适配器,通过这种架构思想都可以认识到在基础设施层的组件应该是轻量级的实现,甚至可以认为它不过是对第三方框架或平台有选择的调用罢了,归根结底,它虽然是技术实现,却是为业务场景提供服务的。例如,需要操作订单数据库,DB 适配器就是一个传递通道,将需要操作的领域模型传递给它,最后返回结果,真正的实现则通过 JDBC、Hibernate、MyBatis 或 Spring Data 等第三方框架来完成。同理,如果需要为前端提供订单服务能力,Web 适配器负责验证与转换前端消息,至于请求到资源的路由等功能皆由 Spring Boot、DropWizard 或 Airlift 等 REST 框架来完成。所以说这里所谓的“适配器”与“端口”其实就是领域与外部资源的一个转换通道。适配器向内的一端连接了 Application 的领域,向外的一端则通过端口连接了外部资源。
六边形架构通过内外六边形的两个不同边界清晰地展现了领域与技术的边界,同时,外部六边形还体现了系统的进程边界,有助于我们思考分布式架构的物理视图,并通过识别端口来引导我们专注于六边形之间的通信机制,这些通信机制可能包括:
与外部资源(数据库、文件、消息队列等)之间的通信
与 Web 和 UI 等用户界面之间的通信
与第三方服务之间的通信
与其他六边形边界之间的通信
微服务架构
Toby Clemson 在《微服务架构的测试策略》一文中深入探讨了如何对微服务架构制定测试策略。要明确如何对这样的系统进行测试,就需要明确该系统架构的组成部分以及各组成部分承担的职责,同时还需要了解各组成部分之间的协作关系。为此,Toby 在这篇文章中给出了一个典型的微服务架构,如下图所示:
该架构图并未严格按照分层架构模式来约定各个组件的位置与职责,这是完全合理的设计!当我们需要将一个分层架构进行落地实践时,在任何一门语言中我们都找不到所谓 layer 的明确语法。在 Java 语言中,我们可以通过 package 与 module 去划分包与模块,在 Ruby 语言中我们也可以限定 module 的范畴,但我们并不能通过某种语法甚至语法糖去规定 layer 的边界。所以在编码实现中,layer 其实是一个松散且不够严谨的逻辑概念,即使我们规定了层的名称以及各层的职责,但各种“犯规行为”依然屡见不鲜。与其如此,不如将各个组件在逻辑架构中的位置与职责明确定义出来。对于系统的概念模型与设计模型,我们要明确分层架构的本质与设计原则;对于代码模型,分层架构则主要负责设计指导,并酌情弱化层在代码模型中的意义,强化对包与模块的划分。
上图的逻辑边界代表了一个微服务,这是基于微服务的设计原则——“每个微服务的数据单独存储”,因此需要将物理边界(图中定义为网络边界)外的数据库放在微服务的内部。
整幅图的架构其实蕴含了两个方向:自顶向下和由内至外。
外部请求通过代表协议(Protocol)的 Resources 组件调用 Service Layer、Domain 或 Repositories,如果需要执行持久化任务,则通过 Repositories 将请求委派给 ORM,进而访问网络边界外的数据库。所谓“外部请求”可以是前端 UI 或第三方服务,而 Resource 组件就是我们通常定义的 Controller,对应于上下文映射中的开放主机服务。之所以命名为 Resources,则是因为 REST 架构是一种面向资源的架构,它将服务操作的模型抽象为资源(Resource),这是自顶向下的方向。
若当前微服务需要调用外部服务(External Service),且外部服务籍由 HTTP 协议通信,就需要提供一个 HTTP Client 组件完成对外部服务的调用。为了避免当前微服务对外部服务的强依赖,又或者对客户端的强依赖,需要引入 Gateways 来隔离。事实上,这里的 Gateways 即为上下文映射中的防腐层,这是由内至外的方向。
分享交流:
我们为分享交流创建了微信交流群,以方便更有针对性地讨论课程相关问题。入群方式请添加作者微信号:「Robynn-D」,并注明「DDD」,谢谢~
网友评论