领域驱动设计实践(0)-基础概念

本文解释了领域驱动设计的一些基础概念。

传统三层架构的开发困境

进入计算机行业有些年头了,在大大小小的项目中工作过,大部分后端项目使用的开发模式都是三层架构。三层架构将整个系统的分成三部分:

  • UI (表现层)
  • BLL (业务逻辑层)
  • DAL (数据访问层)

三层架构的一种常见实现方式如下图:

三层架构的常见实现方式

在三层架构中,表现层的 Controller 负责和用户(用户可能是真人或机器)交互,负责验证请求数据的有效性并将数据包装成业务逻辑层容易处理的形式。业务逻辑层中的 Service 从 Controller 接收数据,然后处理业务逻辑,过程中可能会和数据访问层交互来获取数据,数据访问层使用 DAO 从持久化系统中获取和更新数据。一些和第三方系统的交互(比如集成的 SDK、邮件通知等)一般也会放在业务逻辑层的 Service 中。

三层架构对整个系统的切分方式很好理解,上手简单,但同时也存在不少缺点。随着项目的迭代,该架构中间的业务逻辑层会变得越来越厚,表现层和数据访问层相对于业务逻辑层是相当薄的一层。整个系统两头薄中间厚,显得很不协调。由于所有业务逻辑全都聚集在业务逻辑层,单个 Service 文件动辄上千行相当常见,多个程序员协作在同一个文件上,其实是很不利于增加新特性和维护的。除此之外,系统中还会出现大量业务对象,这些对象的名字都和业务相关,但内部除了一些属性和 getter setter 之外,几乎没有任何业务逻辑,我们称这些对象为贫血领域对象。这些对象的最大用处就是承载数据让业务逻辑层中的具体业务方法处理。如果有对接第三方系统的需求,业务逻辑层中还会出现各种外部 SDK 或对外部系统调用的封装,当这些东西和我们的核心业务逻辑混杂在一起时是不利于维护的。

这种实现方式不协调的地方不仅仅体现在代码体量和内部大量的贫血领域对象上,还体现在依赖关系上。基于三层架构的传统 MVC 开发方式中的依赖关系一般是这样的(->表示依赖方向):用户接口层 -> 应用层 -> 领域层 -> 基础设施层。举一个例子,我们一般在 Service 中调用 DAO 来进行数据操作,如从数据库读取或向数据库写入,依赖方向是 Service -> DAO。如果之后出于性能优化目的我们希望先尝试访问缓存获取数据,如果没有再从数据库获取,则此时需要修改 Service。之前提到 Service 是承载业务逻辑的一层,而从数据源获取数据属于技术细节,技术细节不应该对业务逻辑造成影响。另一个例子是对外部服务的调用,例如一个业务操作中的一项数据来自于外部服务。简单的做法是直接在业务操作的方法中调用这个外部服务的 API(通过外部服务提供的 SDK 或自己的实现),这种做法的问题在于让业务逻辑直接依赖于外部服务,一旦外部服务的 API 发生变化,我们不得不修改业务逻辑中的相关方法。

对任何一个项目来说,业务组件的抽象程度都要高于负责具体实现的组件。以上两个例子的根本问题都在于让抽象程度高的业务组件直接依赖于抽象程度低的实现组件。虽然也能工作,但这会造成依赖的阻抗失调,导致灵活性受到限制。

为了解决这种依赖关系失调,我们需要改进依赖关系的建模方式。这就是依赖倒置原则(DIP):

依赖倒置原则

Clean Architecture 与领域驱动设计

Robert C. Martin (Uncle Bob) 在 the-clean-architecture 这篇文章中提到了 clean architecture 的概念。其中列出了几种架构选型,下面将一些重点信息引用并翻译如下:

这些架构尽管在细节上可能有所不同,但却非常相似。它们的目标是一致的,即关注点分离。它们都通过将软件分层来实现这种分离,每种架构都至少有一层用于业务规则,另一层作为接口。

这些架构产生的系统具有这样的属性:

  1. 独立于框架。架构不依赖于某些功能丰富的软件库的存在。这使你可以将这些框架用作工具,而不必使系统被它们所限制。
  2. 可测试的。可以在没有UI、数据库、Web 服务器或其他任何外部元素的情况下测试业务规则。
  3. 独立于UI。UI 可以被轻松更改,而无需更改系统的其余部分。例如,可以用控制台 UI 替换 Web UI,而无需更改业务规则。
  4. 独立于数据库。你可以将 Oracle 或 SQL Server 换成 Mongo、BigTable、CouchDB 或其他东西。你的业务规则不与数据库绑定。
  5. 独立于任何外部事物。事实上,你的业务规则根本不了解外部世界。

文章配了一张图来表达 Clean Architecture 的思想:

the clean architecture

另外,文中还提到了软件开发中依赖关系的一些规则:

图中的每个同心圆表示软件中的一层,越往内部软件的层次越高,也越抽象。外圈是机制(实现),内圈是政策(规则)。

使这个架构工作的最重要的规则是依赖规则。该规则表示源代码依赖项只能指向同心圆的内部。内圈中的任何东西都对外圈中的事物一无所知。特别是,在外圈中声明的事物的名称不能被内圈中的代码提及。这包括函数、类、变量或任何其他命名的软件实体。

同样,在外圈中使用的数据格式也不应该被内圈使用,特别是如果这些格式是由外圈中的框架生成的。我们不希望外圈的任何东西影响内圈。

上面的所有要点全都指向两个核心概念:软件的隔离抽象

文章中提到的架构选型中有一种叫作 Hexagonal Architecture (a.k.a. Ports and Adapters) (六边形架构,又叫端口和适配器),该架构的示意图如下:

domain driven hexagon

之后的文章我都将以六边形架构为依据来讲解,先分析一下这幅图中的元素。

Interface & Infrastructure (接口与基础设施层)

最外层是接口和基础设施层。Controller 位于接口层,Controller 有很多种类型,包括但不限于 HTTP Controller, CLI Controller 和 Event Controller。Controller 的外部是各种调用方,Controller 主要负责和调用方通信,调用方可以是真实用户、外部服务或其他任何主动对当前系统产生影响(调用、消息推送等)的事物。

基础设施层包含各种 Adapters,比如 Repository Adapter 实现了如何从数据库中存取数据的细节。External Resources Adapters 实现了如何和外部资源沟通的细节。Microservices Adapters 实现了微服务间的通信细节等等。

Boundary Between Interface & Infrastructure And Core (接口与基础设施层与核心层之间的边界)

注意看接口与基础设施层和它相邻的一层核心层之间的边界,有三种组件:

  • Queries (查询)
  • Commands (命令)
  • Ports (端口)

对于外部任何事物对当前系统的主动调用或消息推送,可以分两种类型:Queries (查询)和 Commands (命令)。Queries 是读操作,一般是幂等的。Commands 是更新操作,会修改系统中的数据。Ports 是在核心层声明的抽象概念,需要由基础设施层中的各种 Adapters 实现。

部分不涉及业务逻辑的 Queries 可以直接跳过核心层使用 Repository Adapter 读取数据返回。

Controller 会对从外部进来的 DTO 进行简单的数据校验,这里的校验基本只是验证下数据类型,比如 age 需要是一个数字但用户传入一个字符串就会报错。校验通过后 Controller 会将数据包装成 Commands 对象。Commands 对象承载了具体 Use Case 需要的数据,它穿过接口与基础设施层与核心层的边界进入核心层。

核心层中的组件只能依赖 Ports,具体的实现在基础设施层对应的 Adapters 中。Adapters 在运行时通过依赖注入(DI)的方式进入核心层。

在一些项目的实现中,会在业务组件中直接调用具体实现,这相当于让抽象程度高的组件依赖抽象程度低的组件。此时如果技术细节变更时(比如换了一种数据数据库或外部服务接口变了),核心层也需要修改。如果在设计上实现依赖倒置,则一般只需要修改基础设施层(除非在核心层中定义的 Ports 本身发生变化)。这样的设计实现了业务逻辑和具体实现的解耦,在核心层 Ports 不变的情况下,外部的变更对核心层几乎没有影响。

Core (核心层)

核心层的抽象程度比接口与基础设施层高,我们在这里定义业务规则。图中核心层内部还有三层:

  • Application Services (应用服务)
  • Domain Services (领域服务)
  • Entities (实体)
  • Value Objects (值对象)

Application Services (应用服务)

Application Services 是基于用例的,实际上就是一堆 Use Cases,它负责编排用例执行流程。Application Services 不包含任何领域特定的业务逻辑,它应该是相对较薄的一层。Application Services 通过使用 Ports 声明其为了编排用例需要的基础设施层的依赖。 Application Services 可能还会有一些 Ports 来声明它们与外部系统通信(比如发送 email)的规则,具体实现同样位于基础设施层的对应 Adapters 中。一般来说,正如六边形架构图中所展示的,所有与外部系统通信的抽象规则都定义在 Application Services 的 Ports 中。

Ports 还相当于一个防腐层(Anti-corruption layer, ACL),由于我们核心层的抽象程度较高,让它们直接依赖于具体实现细节显然不合适。好的实践是,当核心层需要某种能力时,我们声明这种能力作为 Ports,外部的基础设施层的某个 Adapter 实现这个 Ports 的能力,然后在需要时我们将这个 Adapter 注入到核心层的 Application Services 中。在 Ports 声明的抽象规则不变的情况下,当我们想要替换具体实现时,只要换一个 Adapter 注入进去即可。

Domain Services (领域服务)

Domain Services 被用于处理那些“领域中不属于实体或值对象的天然职责的那些重要的过程或转换”。Domain Services 是一种特定类型的领域层类,用于执行依赖于两个或更多实体的领域逻辑。如果有一些逻辑,将它们放在某个 Entities 中时会破坏它们的封装性,那就提取出来放到 Domain Services 中。

Entities (实体)

Entities 是领域的核心,代表业务模型。Entities 封装了业务规则,表达特定模型具有的属性。相较于开篇提到的超大业务层中的贫血领域对象(几乎只有业务对象属性的 getter 和 setter)来说,在这里 Entities 是充血领域对象,不但包含了业务数据,还包含了业务操作。

Entities 具有唯一标识,常用的唯一表示可以是 UUID 这种业务无关的标识,在某些业务场景下,一些业务属性本身就具有唯一性,也可以拿来当做唯一表示,比如公民的身份证号。

当我们需要区分不同对象时,就需要引入 Entities 这个领域概念。一个实体是一个唯一的东西,它可以在一段时间内持续变化。我们可以对一个实体做出修改,但由于它的唯一标识是不变的,所以它还是它自己。另外,只要唯一标识不同,就算两个实体的所有其他属性都相等,它们也是不同的实体。

一般来说,具体的业务对象都可以被当成 Entities 看待,这些对象具有天然的唯一性且可以被修改。比如一个用户、一笔订单和一篇文章。

Value Objects (值对象)

Value Objects 和 Entities 经常被混淆。《实现领域驱动设计》一书中对值对象的解释是:

当你决定对一个领域概念是否是一个值对象时,你需要考虑它是否拥有一下特性:

  • 它度量或者描述了领域中的一件东西。
  • 它可以作为不变量。
  • 它将不同的相关的属性组合成一个概念整体。
  • 当度量和描述改变时,可以用另一个值对象予以替换。
  • 它可以和其他值对象进行相等性比较。
  • 它不会对协作对象造成副作用。

根据上述结束,很容易发现一些东西可以被定义成值对象:日期、颜色(如RGB值)、温度、金额(数量和货币类型的组合)等。

在各个层中穿越

要让整个系统运行起来,除了分层我们还需要让数据真正流动起来,这就涉及在不同层之间的数据流转。数据会在各层间穿越,这里的问题是数据以什么样的形式穿过各层。一般来说,我们需要在各层的边界上创建 DTO 来包装其他层穿越过来数据,只包装需要的数据字段。

一个简单的例子

考虑一个用户登录的场景,用户使用账号密码登录。先对这个场景进行规划:

  • Epic: Authentication
  • Use Case: User login by username and password

Authentication 可以作为一个 Module 存在,模块将同一个业务领域的 Use Case 组织起来,里面可以有不同的 Use Cases (Application Services)。在当前场景中,用户使用账号密码登录是一个 Use Case,用户通过第三方平台 OAuth 登录是一个 Use Case,用户使用人脸识别登录也是一个 Use Case。

对应到 DDD 中就是:

模块 Module (Epic): Authentication Module
应用服务 Application Service (Use Case): User login by username and password

再往里就来到领域层,这时可以有一个 User 业务对象,这个对象包含具体的业务逻辑,用户登录需要验证用户名和密码,可能还有额外的逻辑比如需要这个用户的状态是 active 的才能登录。总之这里都是一些业务规则。

我们从用户请求开始来看这个 Use Case,当用户登录的时候,客户端发送请求数据到服务端,请求数据会先到 router,会由某个 controller 来处理这个请求,controller 把数据取出来,包装成 Command 对象,传给 Module 中的 Application Service,这里可能会有一个叫作 UserLoginService 的应用服务在应用层面处理这个请求。应用层面的意思是它只负责编排整个 Use Case Flow 的过程,并不涉及具体的业务逻辑。

来看看 UserLoginService 在这里会有什么操作(为方便描述,这里省略错误处理部分):

  1. 基于 username 入参从数据库查询得到某个用户的信息
  2. 创建一个 User 业务对象,将从数据库查出的用户信息(用户名,密码hash,状态等)作为构造参数传入,初始化这个 User 对象。
  3. 调用 User 对象的 isValid 方法(传入用户的密码作为参数),验证用户有效性。
  4. 返回验证结果给 controller

之后 controller 会包装数据并返回给客户端。

这里有一个问题,在从数据库中取出用户信息时,我们该怎么做?

用户登录这个 Use Case 是抽象的(因为 Use Case 只描述了一个用例,没有提到任何技术细节),它在软件中层次较高,而数据库操作是技术细节,抽象层次较低。如果我们在这个 UserLoginService 里直接初始化并使用一个 DAO 对象来从数据库查询数据会怎么样?这确实可以工作,但是当具体实现有变化时,比如我们换了一个 DAO 实现,我们就需要修改 UserLoginService。这对我们来说有点难受了,我们只是想换一个 DAO 实现就要动到 UserLoginService 吗?实际上 UserLoginService 根本不关心具体用的是哪个 DAO,只要有某个对象可以帮我查询到用户就好了。

比较好的做法是在应用服务级别声明一个 Repository Port。在基础设施层,我们编写一个 Repository Adapter 来实现这个接口,这里面就是具体技术细节了。在运行时,通过依赖注入的方式,注入一个实现到应用层以实现真正从数据库获取数据。

当外部数据库发生变化时,比如从 MySQL 换到 SQL Server,或者在真正查询数据库前先检查缓存,我们只要修改相应 Adapter 这个负责具体实现的组件,应用层以内几乎不用变。

让抽象程度高的组件直接依赖抽象程度低的组件的做法除了在开发上会引入强耦合外,还会导致测试变得困难。假设 UserLoginService 直接依赖一个基于 MySQL 的 DAO,当我们编写 UserLoginService 的单元测试时,还需要处理 MySQL 这个外部依赖。事实上,如果在这里将依赖倒置,我们可以传入一个 MockDAO,MockDAO 实现了普通 DAO 对象的全部方法,唯一不同是它直接返回 mock data 而不查询数据库,这可以帮助我们隔离掉 MySQL。

参考资料

The Clean Architecture
domain-vs-application-services
领域驱动的六边形架构
实现领域驱动设计