领域驱动设计08-分层架构及其在DDD中的演进

分层架构是运用最为广泛的架构模式,几乎每个软件系统都需要通过层(Layer)来隔离不同的关注点(Concern Point),以此应对不同需求的变化,使得这种变化可以独立进行。

Robert Martin 认为单一职责原则就是“一个类应该只有一个引起它变化的原因”,换言之,如果有两个引起类变化的原因,就需要分离。单一职责原则可以理解为架构原则,这时要考虑的就不是类,而是层次,我们为什么要将业务与基础设施分开?正是因为引起它们变化的原因不同。

1、认识分层架构

分层架构由来已久,把一个软件系统进行分层,似乎已经成为了每个开发人员的固有意识,甚至不必思考即可自然得出,这其中最为经典的就是三层架构以及领域驱动设计提出的四层架构。

1.1 经典分层架构

经典三层架构

经典三层架构自顶向下由用户界面层(User Interface Layer)、业务逻辑层(Business Logic Layer)与数据访问层(Data Access Layer)组成。

领域驱动设计的经典分层架构

领域驱动设计在经典三层架构的基础上做了进一步改良,在用户界面层与业务逻辑层之间引入了新的一层,即应用层(Application Layer)。同时,一些层次的命名也发生了变化,将业务逻辑层更名为领域层自然是题中应有之义,而将数据访问层更名为基础设施层(Infrastructure Layer),则突破了之前数据库管理系统的限制,扩大了这个负责封装技术复杂度的基础层次的内涵。

层次职责
用户界面/展现层负责向用户展现信息以及解释用户命令
应用层很薄的一层,用来协调应用的活动,它不包含业务逻辑,它不保留业务对象的状态,但它保有应用任务的进度状态
领域层本层包含关于领域的信息,这是业务软件的核心所在。在这里保留业务对象的状态,对业务对象和它们状态的持久化被委托给了基础设施层
基础设施层本层作为其他层的支撑库存在。它提供了层间的通信,实现对业务对象的持久化,包含对用户界面层的支撑库等作用

1.2 分层架构溯源

当分层架构变得越来越普及时,我们的设计反而变得越来越僵化,一部分软件设计师并未理解分层架构的本质,只知道依样画葫芦地将分层应用到系统中,要么采用经典的三层架构,要么遵循领域驱动设计改进的四层架构,却未思考和探究如此分层究竟有何道理?这是分层架构被滥用的根源。

分层架构模式有助于构建这样的应用:它能被分解成子任务组,其中每个子任务组处于一个特定的抽象层次上。

显然,这里所谓的“分层”首先是一个逻辑的分层,对子任务组的分解需要考虑抽象层次,一种水平的抽象层次。既然为水平的分层,必然存在层的高与低;而抽象层次的不同,又决定了分层的数量。因此,对于分层架构,我们需要解决如下问题:

  • 分层的依据与原则是什么?
  • 层与层之间是怎样协作的?

分层的依据和原则

分层架构中的层次越往上,其抽象层次就越面向业务、面向用户;分层架构中的层次越往下,其抽象层次就变得越通用、面向设备。

  • 分层的第一个依据是基于关注点为不同的调用目的划分层次

以领域驱动设计的四层架构为例,之所以引入应用层(Application Layer),就是为了给调用者提供完整的业务用例。

  • 分层的第二个依据是面对变化

分层时应针对不同的变化原因确定层次的边界,严禁层次之间互相干扰,或者至少把变化对各层带来的影响降到最低。

层与层之间的关系应该是正交的,所谓“正交”,并非二者之间没有关系,而是垂直相交的两条直线,唯一相关的依赖点是这两条直线的相交点,即两层之间的协作点,正交的两条直线,无论哪条直线进行延伸,都不会对另一条直线产生任何影响(指直线的投影)

  • 在进行分层时,我们还应该保证同一层的组件处于同一个抽象层次

层与层之间的协作

在我们固有的认识中,分层架构的依赖都是自顶向下传递的,这也符合大多数人对分层的认知模型。从抽象层次来看,层次越处于下端,就会变得越通用越公共,与具体的业务隔离得越远。出于重用的考虑,这些通用和公共的功能往往会被单独剥离出来形成平台或框架,在系统边界内的低层,除了面向高层提供足够的实现外,就都成了平台或框架的调用者。换言之,越是通用的层,越有可能与外部平台或框架形成强依赖。若依赖的传递方向仍然采用自顶向下,就会导致系统的业务对象也随之依赖于外部平台或框架。

依赖倒置原则(Dependency Inversion Principle,DIP)提出了对这种自顶向下依赖的挑战,它要求“高层模块不应该依赖于低层模块,二者都应该依赖于抽象”。

依赖倒置原则隐含的本质是:我们要依赖不变或稳定的元素(类、模块或层),也就是该原则的第二句话:抽象不应该依赖于细节,细节应该依赖于抽象

这一原则实际是“面向接口设计”原则的体现,即“针对接口编程,而不是针对实现编程”。高层模块对低层模块的实现是一无所知的,带来的好处是:

  • 低层模块的细节实现可以独立变化,避免变化对高层模块产生污染
  • 在编译时,高层模块可以独立于低层模块单独存在
  • 对于高层模块而言,低层模块的实现是可替换的

为了更好地解除高层对低层的依赖,我们往往需要将依赖倒置原则与依赖注入结合起来。

层之间的协作并不一定是自顶向下的传递通信,也有可能是自底向上通信。

如网管告警系统,往往会由低层的设备监测系统监测(侦听)设备状态的告警信息状态。当状态发生变化时,需要将变化的状态通知到上层的业务系统。

如果说自顶向下的消息传递往往被描述为“请求(或调用)”,则自底向上的消息传递则往往被形象地称之为“通知”。可以运用观察者模式(Observer Pattern),在上层定义 Observer 接口,并提供 update() 方法供下层在感知状态发生变更时调用;或者,我们也可以认为这是一种回调机制。虽然本质上这并非回调,但设计原理是一样的。

我们必须要打破那种谈分层架构必为经典三层架构又或领域驱动设计推荐的四层架构这种固有思维,而是将分层视为关注点分离的水平抽象层次的体现。

我们也要认识到层次多少的利弊:过多的层会引入太多的间接而增加不必要的开支,层太少又可能导致关注点不够分离,导致系统的结构不合理。

2、分层架构的演化

分层架构是一种架构模式,但终归它的目的是为了改进软件的架构质量,我们在运用分层架构时,必须要遵守架构设计的最高原则,即建立一个高内聚、松耦合的软件系统架构。

2.1 整洁架构

在架构设计时,我们应设计出干净的应用层和领域层,保持它们对业务逻辑的专注,而不掺杂任何具体的技术实现,从而完成领域与技术之间的完全隔离。这一思想被 Robert Martin 称之为整洁架构(Clean Architecture)

该架构思想提出的模型并非传统的分层架构,而是类似于一个内核模式的内外层架构,由内及外分为四层,包含的内容分别为:

  • 企业业务规则(Enterprise Business Rules)
  • 应用业务规则(Application Business Rules)
  • 接口适配器(Interface Adapters)
  • 框架与驱动器(Frameworks & Drivers)

注意“企业业务规则”与“应用业务规则”的区别,前者是纯粹领域逻辑的业务规则,后者则面向应用,需要串接支持领域逻辑正常流转的非业务功能,通常为一些横切关注点,如日志、安全、事务等,从而保证实现整个应用流程(对应一个完整的用例)。

整洁架构将领域模型放在整个系统的核心,这一方面体现了领域模型的重要性,另外一方面也说明了领域模型应该与具体的技术实现无关。领域模型就是业务逻辑的模型,它应该是完全纯粹的,无论你选择什么框架,什么数据库,或者什么通信技术,按照整洁架构的思想都不应该去污染领域模型。如果以 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不需要关系北向网关的组件,例如,作为 RESTful 服务的 OrderController,就是北向网关中的一个类,它通过调用 Use Cases 层的 OrderAppService 服务来实现一个提交订单的业务用例。OrderAppService 并不需要知道作为调用者的 OrderController。

南向网关作为底层资源的访问者,往往是被Use Cases层甚至是Entities层去掉用。由于整洁架构思想并不允许内层获知外层的存在,这就导致了我们必须在内层定义与外层交互的接口,然后通过依赖注入的方式将外层的实现注入到内层中,这也是“控制反转(Inversion of Control)”的含义,即将调用的控制权转移到了外层。

南向网关封装了与外部资源(DB、Devices、MQ)交互的实现细节,但其公开暴露的接口却需要被定义在内层的 Use Cases 或 Entities 中,这实际上阐释了为什么领域驱动设计要求将 Repository 的接口定义在领域层的技术原因。

2.2 六边形架构

整洁架构的目的在于识别整个架构不同视角以及不同抽象层次的关注点,并为这些关注点划分不同层次的边界,从而使得整个架构变得更加清晰,减少不必要的耦合。它采用了内外层的架构模型弥补了分层架构无法体现领域核心位置的缺陷。

由 Alistair Cockburn 提出的六边形架构(Hexagonal Architecture)在满足整洁架构思想的同时,更关注于内层与外层以及与外部资源之间通信的本质:

六边形架构通过内外两个六边形为系统建立了不同层次的边界。核心的内部六边形对应于领域驱动设计的应用层与领域层,外部六边形之外则为系统的外部资源,至于两个六边形之间的区域,均被 Cockburn 视为适配器(Adapter),并通过端口(Port)完成内外区域之间的通信与协作,故而六边形架构又被称为端口-适配器模式(port-adapter pattern)。下更加清晰地表达了领域驱动设计分层架构与六边形架构的关系,同时也清晰地展现了业务复杂度与技术复杂度的边界:

前面分析整洁架构时,将 Gateways、Controllers 与 Presenters 统一看做是网关,而在六边形架构中,这些内容皆为适配器。如果认为是“网关”,则将该组件的实现视为一种门面,内部负责多个对象之间的协作以及职责的委派;如果认为是“适配器”,则是为了解决内外协议(数据协议与服务接口)之间的不一致而进行的适配。若依据领域驱动设计的分层架构,则无论网关还是适配器,都属于基础设施层的内容。

适配器向内的一端连接了 Application 的领域,向外的一端则通过端口连接了外部资源。

2.3 微服务架构

上图的逻辑边界(Logic Boundary)代表了一个微服务,这是基于微服务的设计原则——“每个微服务的数据单独存储”,因此需要将物理边界(图中定义为网络边界)外的数据库放在微服务的内部。

整幅图的架构其实蕴含了两个方向:自顶向下由内至外

外部请求通过代表协议(Protocol)的 Resources 组件调用 Service Layer、Domain 或 Repositories,如果需要执行持久化任务,则通过 Repositories 将请求委派给 ORM,进而访问网络边界外的数据库。

所谓“外部请求”可以是前端 UI 或第三方服务,而 Resource 组件就是我们通常定义的 Controller,对应于上下文映射中的开放主机服务。之所以命名为 Resources,则是因为 REST 架构是一种面向资源的架构,它将服务操作的模型抽象为资源(Resource),这是自顶向下的方向。

若当前微服务需要调用外部服务(External Service),且外部服务籍由 HTTP 协议通信,就需要提供一个 HTTP Client 组件完成对外部服务的调用。为了避免当前微服务对外部服务的强依赖,又或者对客户端的强依赖,需要引入 Gateways 来隔离。事实上,这里的 Gateways 即为上下文映射中的防腐层,这是由内至外的方向。

3、领域驱动架构的演进

Vaughn Vernon 在《实现领域驱动设计》一书中给出了改良版的分层架构,他将基础设施层奇怪地放在了整个架构的最上面:

整个架构模型清晰地表达了领域层别无依赖的特质,但整个架构却容易给人以一种错乱感。单以这个分层模型来看,虽则没有让高层依赖低层,却又反过来让低层依赖了高层,这仍然是不合理的。

这个架构模型仍然没有解决人们对分层架构的认知错误,例如它并没有很好地表达依赖倒置原则与依赖注入。还需要注意的是,这个架构模型将基础设施层放在了整个分层架构的最顶端,导致它依赖了用户展现层,这似乎并不能自圆其说。

该怎么演进领域驱动架构?可以从两个方向着手:

  • 避免领域模型出现贫血模型
  • 保证领域模型的纯粹性

3.1 避免贫血的领域模型

经典三层架构中,领域逻辑被定义在业务逻辑层的 Service 对象中,至于反映了领域概念的领域对象则被定义为 Java Bean,这些 Java Bean 并没有包含任何领域逻辑,因此被放在了数据访问层。注意,这是经典三层架构的关键,即代表领域概念的 Java Bean 被放在了数据访问层,而非业务逻辑层。 经典三层架构采用了 J2EE 开发的 DAO 模式,即将访问数据库的逻辑封装到数据访问对象(Data Access Object)中。这些 DAO 对象仅负责与数据库的交互,并实现领域对象到数据表的 CRUD(增删改查)操作,因而也被放到了数据访问层中,如下图所示:

我们需要遵循面向对象的设计原则,其中最重要的设计原则就是“数据与行为应该封装在一起”,这也是 GRASP 模式中“信息专家模式”的体现。

Java Bean 由于仅包含了访问私有字段的 get 和 set 方法,可以说是对面向对象设计原则的“背叛”,Martin Fowler 则将这种没有任何业务行为的对象称之为“贫血对象”。基于这样的贫血对象进行领域建模,得到的模型则被称之为“贫血模型”。

贫血模型出现的问题:

在面向对象设计背景下,当我们面对相对复杂的业务逻辑时,应避免设计出贫血模型。

要避免贫血模型,就需要合理地将操作数据的行为分配给这些领域模型对象(Domain Model),即战术设计中的 Entity 与 Value Object,而不是前面提及的 Service 对象。

由于 DAOs 对象需要操作这些领域模型对象,使得处于数据访问层的 DAOs 对象必须依赖领域层的领域模型对象,也就是说,要避免贫血的领域模型,就不可能避免底层的数据访问层对业务逻辑层的依赖。

一个系统的基础不仅仅限于对数据库的访问,还包括访问诸如网络、文件、消息队列或者其他硬件设施,因此 Eric Evans 将其更名为“基础设施层”是非常合理的。遵循整洁架构思想,基础设施层属于架构的外层,它依赖于处于内部的领域层亦是正确的做法。在领域层,封装了领域逻辑的 Services 对象则可能需要持久化领域对象,甚至可能依赖基础设施层的其他组件。于是,之前的分层架构就演进为:

3.2 保证领域模型的纯粹性

在刚才给出的分层架构图中,加粗的两条依赖线可以清晰地看到领域层与基础设施层之间产生了“双向依赖”。在实际开发中,若这两层又被定义为两个模块,双向依赖就成为了设计坏味,它导致了两个层次的紧耦合。此时,领域模型变得不再纯粹,根由则是高层直接依赖了低层,而不是因为低层依赖了高层。故而我们需要去掉右侧 Services 指向 DAOs 的依赖。

DAOs 负责访问数据库,其实现逻辑是容易变化的。基于“稳定依赖原则”,我们需要让领域层建立在一个更加稳定的基础上。抽象总是比具体更稳定,因此,改进设计的方式是对 DAOs 进行抽象,然后利用依赖注入对数据访问的实现逻辑进行注入,如下图所示:

DAOs 的抽象到底该放在哪里?莫非需要为基础设施层建立一个单独的抽象层吗?

从编码角度看,领域对象实例的容身之处不过就是一种数据结构而已,区别仅在于存储的位置。领域驱动设计将管理这些对象的数据结构抽象为资源库(Repository)。通过这个抽象的资源库访问领域对象,自然就应该看作是一种领域行为。

倘若资源库的实现为数据库,并通过数据库持久化的机制来实现领域对象的生命周期管理,则这个持久化行为就是技术因素。

抽象的资源库接口代表了领域行为,应该放在领域层;实现资源库接口的数据库持久化,需要调用诸如 MyBatis 这样的第三方框架,属于技术实现,应该放在基础设施层。于是,分层架构就演进为:

由于抽象的 Repositories 被搬迁至领域层,图中的领域层就不再依赖任何其他层次的组件或类,成为一个纯粹的领域模型。我们的演进正逐步迈向整洁架构!

3.3 用户展现层的变迁

现代软件系统变得日趋复杂,对于一个偏向业务领域的分层架构,领域层的调用者决不仅限于用户展现层的 UI 组件,比如说可以是第三方服务发起对领域逻辑的调用。即使是用户展现层,也可能需要不同的用户交互方式与呈现界面,例如 Web、Windows 或者多种多样的移动客户端。因此在分层架构中,无法再用“用户展现层”来涵盖整个业务系统的客户端概念。通常,我们需要采用前后端分离的架构思想,将用户展现层彻底分离出去,形成一个完全松耦合的前端层。

不管前端的展现方式如何,它的设计思想是面向调用者,而非面向领域。因此,我们在讨论领域驱动设计时,通常不会将前端设计纳入到领域驱动设计的范围

准确地讲,前端可以视为是与基础设施层组件进行交互的外部资源,如前面整洁架构中的 Web 组件与 UI 组件。为了简化前端与后端的通信集成,我们通常会为系统引入一个开放主机服务(OHS),为前端提供统一而标准的服务接口。该接口实际上就是之前整洁架构中提及的 Controllers 组件,也即基础设施层的北向网关。于是,分层架构就演变为:

这个分层架构展现了“离经叛道”的一面,因为基础设施层在这里出现了两次,但同时也充分说明了基础设施层的命名存在不足。当我们提及基础设施(Infrastructure)时,总还是会想当然地将其视为最基础的层。同时,这个架构也凸显了分层架构在表现力方面的缺陷。

3.4 引入应用层

上面的分层架构多了一个作为“北向网关”的基础设施层,也就是我们所说的Controllers,这可以看作是领域层的客户端,意味着他需要和领域层中的领域对象/entity/值对象/Service以及抽象的Repository借口协作,感知到的东西比较多。

基于KISS原则(Keep It Simple and Stupid)或者最小知识原则,我们希望调用者了解的知识越少越好,调用变得越简单越好,这就需要引入一个间接的层来封装,这就是应用层存在的主要意义:

领域驱动分层架构中的应用层其实是一个外观(Facade)。GOF 的《设计模式》认为外观模式的意图是“为子系统中的一组接口提供一个一致的接口,外观模式定义了一个高层接口,这个接口使得这一子系统更加容易使用。”我们要理解“高层接口”的含义。

一方面,它体现了一个概念层次的高低之分,以上图的分层架构来说,应用层是高层抽象的概念,但表达的是业务的含义,领域层是底层实现的概念,表达的是业务的细节。领域驱动设计要求应用层“不包含业务逻辑”,但对外它却提供了一个一致的体现业务用例的接口。

3.5 基础设施层的本质

引入应用层后,整个分层架构的职责变得更加清晰了,唯一显得较为另类的是同为灰色部分的基础设施层。我们将整洁架构、六边形架构与领域驱动设计的四层架构综合起来考虑,可以得到结论:

1
Controllers + Gateways + Presenters = Adapters = Infrastructure Layer

这些组件确乎有适配的语义,将它们视为适配器(Adapter)并无不对之处,但我觉得 Martin Fowler 在《企业应用架构模式》中提出的网关(Gateway)模式似乎更准确。Martin Fowler 对该模式的定义为:An object that encapsulates access to an external system or resource. (封装访问外部系统或资源行为的对象。)基础设施层要做的事情不正是封装对外部系统或资源的访问吗?至于“适配”的语义,仅仅是这种封装的实现模式罢了,更何况在这些组件中,不仅仅做了适配的工作。

理解网关的含义,可以帮助我们更好地理解基础设施层的本质。扮演网关角色的组件其实是一个出入口(某种情况下,网关更符合六边形架构中端口+适配器的组合概念),所以它们的行为特征是:网关组件自身会参与到业务中,但真正做的事情只是对业务的支撑,提供了与业务逻辑无关的基础功能实现。

4、 总结

分层架构是一种架构模式,遵循了“关注点分离”原则。因此,在针对不同限界上下文进行分层架构设计时,还需要结合当前限界上下文的特点进行设计,合理分层,保证结构的清晰和简单。