Skip to content
On this page

第2章 应用架构

2.1 贫血模型和充血模型

2.1.1 对象的属性和行为

在学习贫血模型和充血模型之前,首先要理解对象的属性和对象的行为两个概念。

对象的属性:指的是对象的内部状态,通常表现为类的属性,如下文中 Computer 类的 os、keyboard 字段。

对象的行为:指的是对象具备的能力,通常表现为类的方法,如下文中 VideoPlayer 类的 play 方法。

2.1.2 贫血模型

贫血模型指的是只有属性而没有行为的模型。目前业界开发中经常用的 Java Bean 实际上就是贫血模型。

例如下面的 Computer 类:

java
/**
 * Computer类中只有属性,没有行为,所以是贫血模型
 */
@Data
public class Computer {
    /**
     * 操作系统
     */
    private String os;
    /**
     * 键盘
     */
    private String keyboard;
    //……其他属性
}

2.1.3 充血模型

充血模型是指既有属性又有行为的模型。如果采用面向对象的思想建模,产出的模型既具 有属性,又具有行为,这种模型就是充血模型。

例如下面的 VideoPlayer 类,既有属性(playlist),也有行为(play 方法),VideoPlayer 就是充血模型。

java
/**
 * 视频播放器
 */
@Data
public class VideoPlayer {
    /**
     * 播放列表
     */
    public List<String> playlist;

    /**
     * 播放节目
     */
    public void play() {
        for (String v : playlist) {
            System.out.printf("正在播放:" + v);
        }
    }
}

2.1.4 领域驱动设计对模型的要求

由于使用贫血模型编写代码非常方便,因此大部分的 Java 程序员都习惯使用这种模型。 贫血模型的使用方式大致如下:首先,通过 ORM 框架从数据库查询数据;然后,在 Service 层的方法中操作这些数据对象完成业务逻辑;最后,在 Service 层中调用 ORM 框架将执行结 果更新到数据库。

虽然贫血模型的使用很方便,但是采用贫血模型实现的代码通常会存在一些问题。

从业务逻辑封装的角度来看,贫血模型只提供了业务数据的容器,并不会发生业务行为。 贫血模型通过将这些属性暴露给 Service 方法来完成业务逻辑的操作。实际上,Service 方法承 担了实现所有业务逻辑的责任,这导致业务知识分散在 Service 层的各个方法中。经常会发现 某个业务验证逻辑在每个 Service 方法中都会出现一次,这是因为业务知识没有被封装起来。

从业务代码与基础设施操作分离的角度来看,贫血模型实现的 Service 层通常无法将二者 分离。Service 层的方法在实现业务的同时,还需要与外部服务、中间件交互,例如 RPC 调用、缓存、事务控制等,导致业务代码中夹杂着基础设施操作的细节。 通过贫血模型构建的系统经过多次迭代后,其中的 Service 方法变得非常臃肿,难以持续地演进。前文中,笔者提到贫血模型无法真正实践领域驱动设计,这正是因为贫血模型存在这 些问题。想象一下,如果连基本的业务知识封装都无法实现,又怎么能真正创建一个面向业务 的领域模型呢?

接下来看看充血模型。充血模型具有完整的属性,同时包含业务行为(即业务方法),充 血模型内部封装了完整的业务知识,不存在业务逻辑泄露的问题。Service 层的方法获得充血 模型对象后,只需调用充血模型对象上的行为方法,充血模型内部就会自行修改相应的状态来 完成业务操作。这时,Service 层的方法就不再需要理解领域的业务规则,同时将业务逻辑与 基础设施操作分离了。

以上述的 VideoPlayer 为例,展示了充血模型在 service 层的使用方法,如下。

java
public class VideoApplicationService {

    public void play() {
        //TODO 1.获得领域对象

        //2. 执行业务操作,Service只需要调用充血模型的行为就能完成业务操作,
        //   不再需要了解播放的逻辑
        videoPlayer.play( );
    }
}

可以看到,采用充血模型的建模方式后,业务逻辑由对应的充血模型维护,被很好地封装 在模型中,与操作基础设施的代码分离开了,Service 方法会变得更清晰。至此,相信读者能 理解领域驱动设计要求使用充血模型的合理性了。

2.2 经典贫血三层架构

2.2.1 解读贫血三层架构

目前业界许多项目使用的架构大都是贫血三层架构,这种架构通常将应用分为三层: Controller 层、Service 层、Dao 层。有时候贫血三层架构还会包括 Model 层,但是 Model 层基 本上是贫血模型的数据对象,内部不包含任何逻辑,完全可以被合并到 Dao 层。贫血三层架构如图 2-1 所示。

图2-1 图2-1 贫血三层架构

Controller 层:接收用户请求,调用 Service 完成业务操作,并将 Service 输出拼装为响应 报文向客户端返回。

Service 层:初衷是在 Service 层实现业务逻辑,往往还需要与基础设施(数据库、缓存、 外部服务等)交互。

Dao 层:负责数据库读 / 写。

Model 层:贫血模型,往往与数据库的表字段一一对应,用于充当数据库读 / 写的数据 容器。

其中,Controller 层依赖 Service 层,Service 层依赖 Dao 层,Dao 层依赖 Model 层。由于 Model 层只有普通的贫血对象,往往也会将其合并到 Dao 层,此时只有 Controller 层、Service 层、Dao 层这三层,因此被称为贫血三层架构。

2.2.2 贫血三层架构的优点

贫血三层架构具有以下优点。

  • 关注点分离

贫血三层架构将系统的不同功能模块分别放置在不同的层次中,使得每个层次只关注自己的责任范围。Controller 层负责接收用户请求并进行请求处理,Service 层负责业务逻辑的处理, Dao 层负责数据的持久化操作。这种分离关注点的设计使得系统更加清晰,易于扩展和维护。

  • 复用性强

不同功能模块放置在不同的层次中,使得每个层次也可以独立重用。Controller 层可以通 过调用 Service 层提供的接口来处理用户请求,Service 层可以通过调用 Dao 层提供的接口来处 理业务逻辑,Dao 层可以通过调用数据库驱动来处理数据持久化操作。这种可复用性的设计使 得系统的代码可以更加灵活地组织和重用,有助于提高开发效率。

  • 前期开发效率高

在产品和业务的初期,使用贫血三层架构可以快速实现最小可行产品(MVP),帮助企业 进行商业模式验证。

2.2.3 贫血三层架构的问题

如果在开发过程中缺乏思考,贫血三层架构也会引入一些问题。

  • 底层缺乏抽象

经常能看到这样的情况:每层的方法名字都是一样的,并没有体现出“越上层越具体,越底层越抽象”的设计思路。

Controller 层:

java
public class Controller {

    public void updateTitleById(Param param) {
        service.updateTitleById(param);
    }
}

Service 层:

java
public class Service {

    public void updateTitleById(Param param) {
        dao.updateTitleById(param);
    }
}

Dao 层:

java
public class Dao {

    public void updateTitleById(Param param) {
        //TODO update db
    }
}

上面的 Controller、Service、Dao 各层的 updateTitleById 方法中,分别根据自己所处的分 层进行了对应的处理。但是,如果 Controller 每增加一个业务方法,那么 Service 和 Dao 都会 增加一个对应的方法,也就意味着底层的方法缺乏抽象。

解决的办法也很简单:Service 是具体业务操作的实现,所以在新增业务操作时,增加新 的业务方法无可厚非,但是 Dao 层可以抽象出更通用的方法。

  • 业务逻辑分散

这个主要是由贫血模型造成的。贫血模型对于领域对象的封装程度较低。由于领域对象 只包含数据属性,对于复杂的业务逻辑或数据操作,可能需要在 Service 层或 Dao 层中进行处 理。这可能导致领域对象的封装程度较低,使得代码变得分散和难以管理。

贫血三层架构将系统的业务逻辑分散在不同的层次中,使得系统的业务逻辑分散、难以维 护。例如,某个业务逻辑可能涉及多个层级的操作,需要在不同的层级之间进行数据传递和协 调。这种业务逻辑分散会增加系统的复杂性和维护成本。

  • 难以持续演化

在贫血三层架构中,业务逻辑分散到代码的各处,并且与基础设施的操作紧密耦合,会导 致代码越来越臃肿和难以维护。在这种情况下,很难编写有效的单元测试用例,代码质量会越 来越难以保证。

因此,贫血三层架构缺乏持续演化的潜力。

2.3 DDD 常见的应用架构

2.3.1 经典的四层架构

经典的四层架构将软件系统分为四个层次,每个层次都有不同的职责和功能。经典的四层架构如图 2-2 所示。

图2-2 图2-2 经典的四层架构

用户接口(User Interface)层

用户接口层将应用层的服务按照一定协议对外暴露。用户接口层接收用户请求,并将请求 的参数经过处理后,传递给应用层进行处理,最后将应用层的处理结果按照一定的协议向调用 者返回。

用户接口层是应用的最上层,通常表现为 Controller 接口、RPC 服务提供者的实现类、定 时任务、消息队列的监听器等。

用户接口层不应包含任何业务处理逻辑,仅用于暴露应用层服务。用户接口层的代码应该 非常简单。

应用(Application)层

应用层协调领域模型和基础设施层完成业务操作。应用层自身不包含业务逻辑处理的代 码,它收到来自用户接口层的请求后,通过基础设施层加载领域模型(聚合根),再由领域模 型完成业务操作,最后由基础设施层持久化领域模型。

应用层的代码也应该是简单的,仅用于编排基础设施和领域模型的执行过程,既不涉及业 务操作,也不涉及基础设施的技术实现。

领域(Domain)层

领域层是对业务进行领域建模的结果,包含所有的领域模型,如实体、值对象、领域服 务等。

所有的业务概念、业务规则、业务流程都应在领域层中表达。

领域层不包括任何技术细节,相关的仓储、工厂、网关等基础设施应先在领域层进行定 义,然后交给基础设施层或者应用层进行实现。

基础设施(Infrastructure)层

基础设施层负责实现领域层定义的基础设施接口,例如,加载和保存聚合根的仓储 (Repository)接口、调用外部服务的网关(Gateway)接口、发布领域事件到消息中间件的消息发布(Publisher)接口等。基础设施层实现这些接口后,供应用层调用。 基础设施层仅包含技术实现细节,不包含任何业务处理逻辑。基础设施层接口的输入和输出应该是领域模型或基础数据类型。

2.3.2 端口和适配器架构

端口和适配器架构(Ports and Adapters Architecture)又被称为六边形架构(Hexagonal Architecture),其核心思想是将业务逻辑从技术细节中解耦,使业务逻辑能够独立于任何特定 的技术实现。端口和适配器架构通过引入两个关键概念来达到这个目标:端口(Port)和适配 器(Adapter)。

端口是系统与外部进行交互的接口,它定义了系统对外提供的服务以及需要外部提供的支 持。“定义系统对外提供的服务”通常是指定义可以被外部系统调用的接口,将业务逻辑实现在接 口的实现类中,这种端口属于入站端口(Inbound Port)。“定义需要外部提供的支持”,是指执行 业务逻辑的过程中,有时候需要依赖外部服务(例如从外部服务加载某些数据以用于完成计算), 此时定义一个接口,通过调用该接口完成外部调用,这种端口属于出站端口(Outbound Port)。

适配器则细分为主动适配器(Driving Adapter)和被动适配器(Driven Adapter)两种。主 动适配器用于对外暴露端口,例如将端口暴露为 RESTful 接口,或者将端口暴露为 RPC 服务; 被动适配器用于实现业务逻辑执行过程中需要使用的端口,如外部调用网关等。

六边形架构如图 2-3 所示。

图2-3 图2-3 六边形架构

端口和适配器之间的交互关系如图 2-4 所示。

图2-4 图2-4 端口和适配器之间的交互关系

主动适配器伪代码如下。

java
/**
 * 主动适配器,将创建文章的Port暴露为Http服务
 */
@RestController
public class ArticleController {
    @Resource
    private ArticleService service;

    @RequestMapping("/create")
    public void create(DTO dto) {
        service.create(dto);
    }
}

进站的端口伪代码如下。

java
public interface ArticleService {
    /**
     * 端口和适配器架构中的Port,提供创建文章的能力
     * 这是一个进站端口
     * @param dto
     */
    void create(DTO dto);
}

被动适配器伪代码如下。

java
/**
 * 被动适配器
 */
public interface AuthorServiceGatewayImpl implements AuthorServiceGateway {

    /**
     * 作家RPC服务
     */
    @Resource
    private AuthorServiceRpc rpc;

    AuthorDto queryAuthor(String authorId) {
        //拼装报文
        AuthorRequest req = this.createRequest(authorId);
        //执行RPC查询
        AuthorResponse res = rpc.queryAuthor();
        //解析查询结果并返回
        return this.handleAuthorResponse(res);
    }
}

2.4 应用架构演化

前文讲解的经典的四层架构以及端口和适配器架构(六边形架构)并无优劣之分。本节将 从日常的三层架构出发,演绎出落地领域驱动设计的应用架构。

再回顾一下贫血三层架构(见图 2-1)。Model 层只有一些贫血模型对象,都是一些简单的 Java Bean,其中的字段与数据库表中的列(column)一一对应。这样的模型实际上是数据模 型。有些应用程序将其作为单独的层来使用,但实际上可以与 DAO 层合并,因此被称为“三 层架构”,而不是“四层架构”。接下来对三层架构进行抽象和精简。

2.4.1 合并数据模型

为什么要将数据模型与数据访问层合并呢? 首先,数据模型是贫血模型,它不包含业务逻辑,仅作为装载模型属性的容器。 其次,数据模型与数据库表(table)的列(column)是一一对应的。数据模型的主要应用场景是在持久层中用来进行数据库读 / 写操作,将数据库查询结果封装为数据模型,并返回给 Service 层供其获取模型属性以执行业务逻辑。

最后,数据模型的类或属性字段上通常带有 ORM 框架的一些注解,与 DAO 层关联密切。 可以认为数据模型是 DAO 层用来查询或持久化数据的工具。如果将数据模型与 DAO 层分离, 那么其意义将大打折扣,数据模型与 DAO 层合并后的架构图如图 2-5 所示。

图2-5 图2-5 数据模型与 DAO 层合并后的架构图

2.4.2 抽取领域模型

下面是一个常见的 Service 方法的伪代码。该方法中既涉及缓存、数据库等基础设施的调 用,也包含实际的业务逻辑。这种混合了基础设施操作与业务操作的代码非常难以维护,也很 难测试。

java
public class Service {

    @Transactional
    public void bizLogic(Param param) {

        //校验不通过则抛出自定义的运行时异常
        checkParam(param);
        //查询数据模型
        Data data = queryOne(param);

        //根据业务条件执行对应的操作
        if (condition1 == true) {
            biz1 = biz1(param.getProperty1());
        } else {
            biz1 = biz11(param.getProperty1());
        }

        data.setProperty1(biz1);

        //省略其他条件处理逻辑

        //省略一堆set方法

        //更新数据库
        mapper.updateXXXById(data);
    }
}

伪代码中演示的 Service 方法执行逻辑是:首先进行参数校验,然后通过 method1、method2 等子方法进行业务操作,并将其结果通过一系列的 Set 方法设置到数据模型中,再将数据模型 更新到数据库。这是典型的事务脚本式的代码。

由于所有的业务逻辑都实现在 Service 方法中,稍微复杂一点的业务流程就很容易导致 Service 方法变得臃肿。而且,Service 需要了解所有的业务规则,同样一条规则很有可能在每 个方法中都出现,例如 if(condition1==true) 可能在每个方法中都会判断一次。

Service 方法还需要协调基础设施进行相关的支持,例如查询数据模型、更新执行结果等。

如果可以将业务逻辑抽取出来,形成一个只执行业务操作的方法,Service 方法调用这个 方法完成业务逻辑,再调用基础设施层进行数据的加载和保存,那么 Service 方法就实现了业 务逻辑与技术细节分离的效果,Service 层的代码也就变得非常清晰且易于维护了。

假如可以将业务逻辑从 Service 方法中提取出来,形成一个模型,让这个模型的对象去执 行具体的业务逻辑,业务相关的规则都封装到这个模型中,Service 方法就不用再关心其中的 if/else 业务规则了。Service 方法只需要获取这个业务模型,再调用模型上的业务方法,即可完 成业务操作。将业务逻辑抽象成模型,这样的模型就是领域模型的雏形。

在此先不关心领域模型如何获取,如果能实现与基础设施操作分离的领域模型,则 Service 方法的执行过程应该是这样的:根据输入参数完成领域模型的加载,再由模型进行业务操作,业务操作的结果保存在模型的属性中,最后通过 DAO 层将模型中的执行结果更新到数据库。 这种包含纯粹业务逻辑的模型,既包括属性,也包含业务行为。因此,这应该是充血模型。

抽取之后,将得到如下伪代码。

java
public class Service {

    public void bizLogic(Param param) {

        //如果校验不通过,则抛一个运行时异常
        checkParam(param);
        //加载模型
        Domain domain = loadDomain(param);
        //调用外部服务取值
	    SomeValue someValue =
                    this.getSomeValueFromOtherService(param.getProperty2());
        //模型自己去做业务逻辑,Service不关心模型内部的业务规则
        domain.doBusinessLogic(param.getProperty1(), someValue);
        //保存模型
        saveDomain(domain);
    }
}

如伪代码演示,领域相关的业务规则封装在充血的领域模型内部。将业务逻辑抽取出来后 形成单独的一层,被称为领域层,此时 Service 方法非常直观,就是获取模型、执行业务逻辑、 保存模型,再协调基础设施完成其余的操作。此时架构图如图 2-6 所示。

图2-6 图2-6 抽取业务逻辑

2.4.3 维护领域对象生命周期

在 2.4.2 节的伪代码中,引入了两个与领域模型实例对象相关的方法:加载领域模型实例 对象的 loadDomain 方法和保存领域模型实例对象的 saveDomain 方法,这两个方法与领域对象 的生命周期密切相关。本节将对这两个方法进行探讨。

关于领域对象的生命周期的知识,将在 2.5 节进行详细讨论。

无论是 loadDomain 还是 saveDomain,一般都依赖于数据库或其他中间件,所以这两个方 法的实现逻辑与 DAO 相关。

保存或加载领域模型的两个操作可以抽象成一种组件--Repository。Repository 组件内部调用 DAO 完成领域模型的加载和持久化操作,封装了数据库操作的细节。

需要注意的是,Repository 是对加载或保存领域模型的抽象,这里的领域模型指的是聚合 根,因为只有聚合根才会拥有 Repository。由于 Repository 需要对上层屏蔽领域模型持久化的 细节,因此其方法的输入或输出参数一定是基本数据类型或领域模型(实体或值对象),不能 是数据库表对应的数据模型。

此外,由于这里提到的 Repository 操作的是领域模型,为了与某些 ORM 框架(如 JPA、 Spring Data JDBC 等)的 Repository 接口区分开,可以考虑将其命名为 DomainRepository。

以下是 DomainRepository 的伪代码。

java
public interface DomainRepository {

    void save(AggregateRoot root);

    AggregateRoot load(EntityId id);
}

接下来探讨在哪一层实现 DomainRepository 接口。

首先可以考虑将 DomainRepository 的实现放在 Service 层,在其实现类中调用 DAO 层进 行数据库操作,但是这意味着 Service 层必须了解数据库的实现细节。Service 层只需要关心通 过 DomainRepository 加载和保存领域模型,并不关心领域模型的存储和加载细节。Service 无 须了解使用哪种数据模型对象、使用哪些 DAO 对象进行操作、将领域模型存储到哪张表、如 何通过数据模型拼装领域模型、如何将领域模型转化为数据模型等细节。因此,在 Service 层 实现 DomainRepository 并不是很好的选择。

接下来考虑将 DomainRepository 的实现放在 DAO 层。在 DAO 层直接引入 Domain 包, 并在 DAO 层提供 DomainRepository 接口的实现。在 DomainRepository 接口的实现类中调用 DAO 层的 Mapper 接口完成领域模型的加载和保存。加载领域模型时,先查询出数据模型,再 将其封装成领域模型并返回。保存领域模型时,先通过领域模型拼装数据模型,再持久化到数 据库中。

经过这样的调整之后,Service 层不再直接调用 DAO 层数据模型的 Mapper 接口,而是直 接调用 DomainRepository 加载或保存领域模型。DAO 层不再向 Service 返回数据模型,而是返 回领域模型。DomainRepository 隐藏了领域模型和数据模型之间的转换细节,也屏蔽了数据库 交互的技术细节。

此时,Service 层只与 DAO 层的 DomainRepository 交互,因此可以将 DAO 层换个名字, 称之为 Repository,如图 2-7 所示。

图2-7 图2-7 维护领域对象生命周期

2.4.4 泛化抽象

在 2.4.3 节中得到的架构图已经和经典四层架构非常相似了,在实际项目中不仅仅是 Controller、Service 和 Repository 这三层,还可能包括 RPC 服务提供者实现类、定时任务、消 息监听器、消息发布者、外部服务、缓存等组件,本节将讨论如何组织这些组件。

基础设施层

Repository 负责加载和持久化领域模型,并封装数据库操作细节,不包括业务操作,但为 上层服务的执行提供支持,因此 Repository 是一种基础设施。因此,可以将 Repository 层改名 为 infrastructure-persistence,即基础设施层持久化包。

之所以采取这种 infrastructure-XXX 的命名格式,是因为在应用中可能存在多种基础设施, 如缓存、消息发布者、外部服务等,通过这种命名方式,可以非常直观地对技术设施进行归类。

举个例子,许多项目还有可能需要引入缓存,此时可以采用类似的命名,再加一个名为 infrastructure-cache 的包。

对于外部服务的调用,领域驱动设计中有防腐层的概念。防腐层可以将外部模型与本地上 下文的模型隔离,避免外部模型污染本地领域模型。Martin Fower 在其著作《企业应用架构模 式》的 18.1 节中,使用入口(Gateway)来封装对外部系统或资源的访问,因此可以参考这个 名称,将外部服务调用这一层称为 infrastructure-gateway。

注意:基础设施层的门面接口应先在领域层进行定义,其方法的入参、出参,都应该是领 域模型(实体、值对象)或者基本数据类型,不应该将外部接口的数据类型作为参数类型或者 返回值类型。

用户接口层

Controller 层的名字有很多,有的叫 Rest,有的叫 Resource,考虑到这一层不仅有 RESTful 接口,还可能有一系列与 Web 相关的拦截器,所以笔者一般更倾向于称之为 Web。

而 Controller 层不包含业务逻辑,仅将 Service 层的方法暴露为 HTTP 接口,实际上是一 种用户接口,即用户接口层。因此,可以将 Controller 层命名为 user-interface-web,即用户接口层的 Web 包。

由于用户接口层是按照一定的协议将 Service 层进行对外暴露的,这样就可能存在许多用户接口分别通过不同的协议提供服务,因此可以根据实现的协议进行区分。例如,如果有对外 提供的 RPC 服务,那么服务提供者实现类所在的包可以命名为 user-interface-provider。

有时候引入某个中间件既会增加基础设施,又会增加用户接口。如果是给 Service 层调用 的,属于基础设施;如果是调用 Service 层的,属于用户接口。

例如,如果引入 Kafka,就需要考虑是增加基础设施还是增加用户接口。Service 层执行完 业务逻辑后,调用 Kafka 客户端发布消息到消息中间件,则应增加一个用于发布消息的基础设 施包,可以命名为 infrastructure-publisher;如果是订阅 Kafka 的消息,然后调用 Service 层执 行业务逻辑,则应该增加一个用户接口包,可以命名为 user-interface-subscriber。

应用层

经过 2.4.3 节的处理后,Service 层已经没有业务逻辑了,业务逻辑都被抽象封装到领域层 中。Service 层只是协调领域模型、基础设施层完成业务逻辑。因此,可以将 Service 层改名为应用层或者应用服务层。

完成以上泛化抽象后,应用的架构如图 2-8 所示。

图2-8 图2-8 泛化抽象后的应用架构

2.4.5 完整的项目结构

将 2.4.4 节涉及的包进行整理,并加入启动包,就得到了完整的项目结构。

此时还需要考虑一个问题,项目的启动类应该放在哪里?因为有很多用户接口,所以启动 类放在任意一个用户接口包中都不合适,并且放置在应用服务中也不合适。因此,启动类应该 存放在单独的模块中。又因为 application 这个名字被应用层占用了,所以将启动类所在的模块 命名为 launcher。

一个项目可以存在多个 launcher,按需引用用户接口即可。launcher 包需要提供启动类以 及项目运行所需要的配置文件,例如数据库配置等。完整的项目结构如图 2-9 所示。

图2-9 图2-9 完整的项目结构

至此,得到了完整可运行的领域驱动设计应用架构。笔者已经将这个应用架构实现为 Maven Archetype 脚手架,并在 GitHub 上开源。在 14.1 节中详细介绍了如何安装和使用这个 脚手架。本书第 20 章和第 21 章的案例都是使用这个脚手架创建的。

2.5 领域对象的生命周期

2.5.1 领域对象的生命周期介绍

领域对象的生命周期如图 2-10 所示。该图是理解领域对象生命周期以及领域对象与各个 组件交互的关键。

图2-10 图2-10 领域对象的生命周期

领域对象的生命周期包括创建、重建、归档和删除等过程,接下来分别探讨。

2.5.2 领域对象的创建过程

领域对象创建的过程是领域对象生命周期的首个阶段,包括实例化领域对象,设置初始状 态、属性和关联关系。

在创建领域对象时,通常需要提供一些必要的参数或初始化数据,用于设置其初始状态。 简单的领域对象可以直接通过静态工厂方法创建,复杂的领域对象则可以使用 Factory 创建。

Factory 的示例代码如下。

java
/**
 * 通过Factory创建领域对象
 */
public interface ArticleDomainFactory {
    ArticleEntity newInstance(ArticleTitle articleTitle,
                    ArticleContent articleContent);
}

/**
 * ArticleDomainFactory的实现类
 */
@Component
public class ArticleDomainFactoryImpl implements ArticleDomainFactory{

    /**
     * IdService是一个Id生成服务
     */
    @Resource
    IdService idService;

    public ArticleEntity newInstance(ArticleTitle articleTitle,
                            ArticleContent articleContent){
        ArticleEntity entity = new ArticleEntity();
        //为新创建的聚合根赋予唯一标识
        entity.setArticleId(new ArticleId(idService.nextSeq()));
        entity.setArticleTitle(articleTitle);
        entity.setArticleContent(articleContent);
        //TODO 其余逻辑

        return entity;
    }
}

在应用层调用 Factory 进行领域对象的创建,伪代码如下。

java
/**
 * ArticleEntity的唯一标识,是一个值对象
 */
@Getter
public class ArticleId{

    private final String value;

    public ArticleId(String input){
        this.value=input;
    }
}

java
@Service
public class AppilcationService{

    @Resource
    private Factory factory;

    public void createArticle(Command cmd){

        //创建ArticleTitle值对象
        ArticleTitle title = new ArticleTitle(cmd.getTitle());
        //创建ArticleContent值对象
        ArticleContent content = new ArticleContent(
cmd.getContent());
        //通过Factory创建ArticleEntity
        ArticleEntity root= factory.newInstance(title,content);
        //TODO 省略后续操作
    }
}

2.5.3 领域对象的保存过程

领域对象的保存过程是将活动状态下的领域对象持久化到存储设备中,其中的存储设备可 能是数据库或者其他媒介。

领域对象的保存过程是通过 Repository 完成的。Repository 提供了 save 方法,使用该方法 先将领域模型转成数据模型,再将数据模型持久化到数据库。

关于 Repository 保存领域对象的实现细节,将在 5.2 节中进行讨论,读者在此仅需要清楚 领域对象是通过 Repository 进行持久化的即可。

2.5.4 领域对象的重建过程

重建是领域对象生命周期中的一个重要过程,用于恢复、更新或刷新领域对象的状态。创 建的过程由 Factory 来支持,领域对象重建的过程则通过 Repository 来支持。

领域对象的重建过程一般发生在以下几种场景。

数据持久化和恢复。当从持久化存储(如数据库、文件系统等)中加载领域对象时,需 要使用持久化状态的数据对领域对象进行重建。在这种情况下,我们通过 Repository 的 load 方法对领域对象进行加载。在 load 方法中,需要先将持久化存储的数据查询出来,一般利用 ORM 框架可以很方便地完成这个查询过程,查询的结果为数据模型,最后将得到的数据模型 组装成领域模型。

业务重试。在业务执行的过程中,例如更新数据库时发生了乐观锁冲突,导致上层代码捕 获到异常,此时需要重新加载领域对象,获得领域对象的最新状态,以便正确执行业务逻辑。 此时捕获到异常后,重新通过 Repository 的 load 方法进行领域对象重建。

事件驱动。在事件驱动架构中,领域对象通常通过订阅事件来获取数据更新或状态变化的 通知。当用户接口层收到相关事件后,需要对领域对象进行重建,以响应捕获到的领域事件。 在事件溯源的模式中,还需要通过对历史事件的回放,以达到领域对象重建的目的。

重建领域对象的伪代码如下。

java
/**
 * 重建,通过Repository
 */
public interface ArticleDomainRepository {

    /**
     * 根据唯一标识加载领域模型
     */
    ArticleEntity load(ArticleId articleId);

    //省略其他方法
}


@Component
public class ArticleDomainRepositoryImpl implements ArticleDomainRepository{

    @Resource
    ArticleDao articleDao;

    public ArticleEntity load(ArticleId articleId){
        Article data = articleDao.getByArticleId(
articleId.getValue());
        ArticleEntity entity =new ArticleEntity();
        entity.setArticleId(articleId);
        //创建ArticleTitle值对象
        ArticleTitle title = new ArticleTitle(data.getTitle());
        entity.setArticleTitle(title);
        //创建ArticleContent值对象
        ArticleContent content = new ArticleContent(
data.getContent());
        entity.setArticleContent(content);
        //TODO 省略其他重建ArticleEntity的逻辑

        return entity;
    }
}

在应用层,重建领域对象的伪代码如下。

java
@Service
public class ApplicationService{

    @Resource
    private Repository repository;

    public void modifyArticleTitle(Command cmd){

        //重建领域模型
        ArticleEntity entity = repository.load
                                    (new ArticleId(
                                            cmd.getArticleId()));

        //创建ArticleTitle值对象
        ArticleTitle title = new ArticleTitle(cmd.getTitle());
        //修改ArticleEntity的标题
        entity.modifyTitle(title);

        //TODO 省略后续操作
    }
}

值得注意的是,要避免在应用层直接将 DTO 转成聚合根来执行业务操作,这种做法实际 上架空了 Factory 和 Repository,造成领域模型生命周期缺失,而且直接转换得到的领域对象 有可能状态并不完整。

错误的示例伪代码如下。

java
@Service
public class ApplicationService{
    public void newDraft(ArticleCreateCmd cmd) {
        //错误!!!直接将Command转成领域模型
        ArticleEntity articleEntity = converter.convert(cmd)
        //省略后续业务逻辑
    }
}

2.5.5 领域对象的归档过程

归档是指将领域对象从活动状态转移到非活动状态的过程。

对于已经不再使用的领域对象,可以将其永久存储在归档系统中。这些数据可以用于后续 的分析、审计或法律要求等。 领域对象也就是业务数据,通常会定期进行数据结转,将一定时间之前(例如三年前)的 数据首先从生产库迁移到历史库或大数据平台,然后从生产库中清理已结转的数据。

2.6 应用架构的类型变化链

本节将探讨应用架构中每层涉及的对象,以及对象在应用架构各层传递时的类型变化。 整体的类型变化链如图 2-11 所示。

图2-11 图2-11 整体的类型变化链

2.6.1 应用架构各层的对象类型

2.4 节的应用架构包含四层,分别是用户接口层、应用层、领域层和基础设施层。应用架 构的每层都会有不同的对象类型。在完成一次完整的业务过程中,需要不同层次的对象类型进 行协作,在这个过程中会涉及对象类型的转换。

2.6.1.1 领域层

领域层是系统的核心,包含业务领域对象和业务规则。在领域层中,数据以领域模型的形式存在,对象类型包括实体(Entity)、值对象(Value Object)和领域事件(Domain Event)。 一般不会直接对外部暴露领域层的领域模型。

2.6.1.2 基础设施层

在基础设施层,对象以数据模型的形式存在。数据模型通常是对数据库表(table)逆向生成的贫血模型,其字段与 table 的 column 一一对应,如图 2-12 所示。

图2-12 图2-12 数据模型

领域模型通过 DomainRepository 进行持久化和加载,而 DomainRepository 的实现在基础 设施层的 infrastructure-persistence 包中。

DomainRepository 通过 save 方法保存领域模型,save 方法内部会将领域模型转换成 infrastructure-persistence 包中的数据模型对象,数据模型对象的字段与数据库的表一一对应; DomainRepository 通过 load 方法加载领域模型,需要先从数据库查询出数据模型,再将数据模 型拼装为领域模型。

关于 DomainRepository 的详细内容,可以参考 5.2 节。

2.6.1.3 应用层

应用层的对象类型主要有 Command、Query 和 View。

Command 是指命令对象,代表应用层收到的更新操作。执行这些操作将会引起聚合根内部状态的改变,并且往往会触发领域事件。Command 类型的对象用于应用层方法的入参。 Query 是指查询对象,代表应用层收到的查询请求。执行查询操作不会引起领域对象内部状态的改变。Query 类型的对象也用于应用层方法的入参。

View 是指视图对象,代表应用层执行 Query 请求进行查询后得到的结果,用于应用服务方法的返回。View 对象的字段按需提供,可以对外隐藏领域模型的实现细节。 这几种数据类型的使用示例如下。

java
public interface ApplicationService{

    /**
     * Command类型用于应用服务方法入参,代表将改变聚合根状态
     */
    void modifyTitle(ModifyTitleCommand cmd);

    /**
     * Query类型用于应用服务方法入参,代表查询条件
     * View对象应用应用服务Query方法返回,代表查询结果
     */
    ArticleView getArticle(ArticleQuery query);
}

2.6.1.4 用户接口层

用户接口层主要通过各种协议暴露应用服务以供外部调用。因此,用户接口层的逻辑应该 尽量简洁。用户接口层主要使用数据传输对象(Data Transfer Object,简称 DTO)作为主要对 象类型,用于在不同的服务之间进行数据传输。

对于 HTTP 接口而言,一般会返回 JSON 格式的数据给调用者,因此可以直接使用应用层 的 Command、Query 和 View,无须额外定义 DTO 对象。

对于 RPC 接口,服务提供者通常会在单独的 jar 包中定义接口,并在该 jar 包中定义接口 的入参和出参对象类型。这些入参和出参都是 DTO。当服务提供者的用户接口层实现这些接 口时,需要将 DTO 转换为其自身的 Command、Query 和 View 对象。

RPC 接口的伪代码如下。

java
public interface ArticleApi {
    public void createNewDraft(ArticleCreateRequest req);
    public ArticleDTO queryArticle(ArticleQueryRequest req);
}

涉及到的几个 DTO 对象的定义如下。

java
public class ArticleCreateRequest implements Serializable{
    private String title;
    private String content;
    //getter 和setter
}

public class ArticleQueryRequest implements Serializable{
    private String title;
    //getter 和setter
}

public class ArticleDTO implements Serializable{
    private String title;
    private String content;
    //getter 和setter
}

下面将分别对查询、创建、修改这几个过程的类型转换进行梳理。

2.6.2 查询过程的类型变化

查询过程的类型变化过程有两种情况,一种是经典领域驱动设计中加载单个聚合根的查 询,另一种是实现 CQRS 后通过数据模型的查询。

加载单个聚合根的查询过程如图 2-13 所示。

图2-13 图2-13 加载单个聚合根的查询过程

加载单个聚合根时,应用层首先从收到的 Query 查询对象中取出实体的唯一标识,然后通 过 Repository 加载聚合根,因此在这种场景下应用层和基础设施层持久化包(即 infrastructure- persistence)都通过领域模型交互。

CQRS 后通过数据模型查询进行的过程如图 2-14 所示。

图2-14 图2-14 CQRS 后通过数据模型查询进行的过程

通过 CQRS 进行数据查询时,应用层收到 Query 查询对象后,将数据传递给基础设施层, 基础设施层的数据查询接口(例如 MyBatis 的 Mapper 接口)直接查询数据库并返回数据模型。 应用层收到数据模型后,将其转为 View 对象向上返回。

在图 2-13、图 2-14 中,RPC 消费者和 RPC 服务提供者直接通过 DTO 进行数据传递。DTO 一般定义在接口契约的 jar 包中。例如,使用 Apache Dubbo 进行微服务开发时,服务提供者通 常会将接口定义在独立的 jar 包中,并将该 jar 包提供给服务消费者。服务消费者通过引用该 jar 包进行服务调用,接口入参和出参的 DTO 也会定义在该 jar 包中。

用户接口层的服务提供者在实现这些 RPC 服务接口时,会将 DTO 转为 Query 对象并向应 用层传递。应用层使用 Query 执行数据查询并将查询结果封装为 View 对象进行返回,服务提 供者再将这些 View 对象拼装成 DTO 向 RPC 服务消费者返回。

为了提高开发效率,考虑到 Web 接口通常采用 JSON 格式进行数据传输,我们可以直接 使用 Query 接收入参,使用 View 返回出参。 以下是更详细的探讨。

用户接口层

对于 HTTP 接口,由于一般向调用方返回 JSON 格式的数据,因此此时可以直接使用应用层的 Query、View。伪代码如下。

java
@RestController
@RequestMapping("/article")
public class ArticleController{

    /**
     * 直接使用Query对象作为入参
     */
    @RequestMapping("/getArticle")
    public ArticleView getArticle(@RequestBody AeticleQuery query){
        ArticleView view = applicationService.getArticle(query);
        //直接返回View对象
        return view;
    }
}

对于 RPC 接口,实现上面的 ArticleApi,伪代码如下。

java
/**
 * RPC接口实现类
 */
public class ArticleProvider implements ArticleApi {

    @Resource
    private ApplicationService applicationService;

    public ArticleDTO queryArticle(ArticleQueryRequest req){
        //把接口定义中的ArticleQueryRequest翻译成Query对象
        ArticleQuery query = converter.convert(req);
        //调用应用服务进行查询,返回View对象
        ArticleView view = applicationService.getArticle(query);
        //将View对象转成DTO并返回
        ArticleDTO dto = converter.convert(view);
        return dto
    }
	//TODO 省略其他方法
}

converter 的 convert 方法主要用于 Java Bean 之间的属性映射,也可以使用 MapStruct 组 件,既简单,又高效。

应用层

在加载领域模型的场景中,应用层会将接收到的 Query 对象转换成领域模型中的值对象, 之后通过 Repository 加载聚合根,并将聚合根转换为 View 对象进行返回。

转换为 View 对象的原因是,需要对外隐藏领域模型的实现细节,避免未来领域模型调整时影响到调用方。伪代码如下。

java
@Service
public class  ApplicationServiceImpl implements ApplicationService{

    @Resource
    private Repository repository;

    /**
     * Query类型用于应用服务方法入参,代表查询条件
     * View对象应用应用服务Query方法返回,代表查询结果
     */
    public ArticleView getArticle(AeticleQuery query){
        //Query对象换成领域模型中的值对象(即ArticleId)
        ArticleId articleId =new ArticleId(query.getArticleId());
        //加载领域模型
        ArticleEntity entity = repository.load(articleId);
        //将领域模型转成View对象
        ArticleView view = converter.convert(entity);
        return view;
    }
}

在 CQRS 的场景中,应用层通过 Query 对象获得查询条件,并将查询条件交给数据模 型的 Mapper 接口进行查询,Mapper 接口查询后得到数据模型,应用层再将数据模型转化为 View 对象。

伪代码如下。

java
/**
 * CQRS 后的查询服务
 * */
public class QueryApplicationService {
    /**
     * 数据模型的Mapper
     * 即MyBatis中的Mapper接口
     */
    @Resource
    private DataMapper dataMapper;
    
    public View queryOne(Query query) {
        // 取出 Query 的查询条件
        String condition = query.getCondition(); 
        // 查询出数据模型
        Data data = dataMapper.queryOne(condition); 
        // 数据模型换成 View
        View view = this.toView(data);
        return view;
    }
}

基础设施层

对于非 CORS 的场景,基础设施层会先查询出数据模型,再将数据模型组装为领域模型并向上返回,伪代码如下。

java
/**
 * Repository加载聚合根的过程
 */
@Component
public class ArticleDomainRepositoryImpl implements ArticleDomainRepository {

    @Resource
    private ArticleDao dao;
    /**
     * 根据唯一标识加载领域模型
     */
    public ArticleEntity load(ArticleId articleId){
        //查询数据模型
       Article data = dao.selectOneByArticleId(articleId.getValue());
       ArticleEntity entity = new ArticleEntity();
       entity.setArticleId(article);
       entity.setTitle(new ArticleTitle(data.getTitle()));
       entity.setArticleContent(new ArticleContent(data.getContent()));
       //省略其他逻辑
       return entity;
    }
}

2.6.3 创建过程的类型转换

创建过程的类型转换如图 2-15 所示。

图2-15 图2-15 创建过程的类型转换

用户接口层

如果用户接口层方法的入参复用了应用层的 Command,则透传给应用层即可;如果用户 接口层有自己的入参类型,例如,RPC 接口会在 API 包中定义一些类型,则需要在用户接口 层中将其转换为应用层的 Command。

对于 HTTP 接口,示例如下:

java
/**
 * 创建过程的用户接口层,HTTP接口
 */
@RestController
@RequestMapping("/article")
public class ArticleController {
    @RequestMapping("/createNewDraft")
    public void createNewDraft(@RequestBody CreateDraftCmd cmd) {
        //直接透传给应用服务层
        applicationService.newDraft(cmd);
    }
}

对于 RPC 接口,示例代码如下:

java
/**
 * 创建过程的用户接口层,RPC接口
 */
@Component
public class ArticleProvider implements ArticleApi {

    public void createNewDraft(CreateDraftRequest req) {
        //把CreateDraftRequest换成Command
        CreateDraftCmd cmd = converter.convert(req);
        applicationService.newDraft(cmd);
    }
}

应用层

在创建的过程中,有可能先使用 Command 携带的数据创建值对象,再将值对象传递 给 Factory 完成领域对象的创建。例如,Command 中定义的 content 字段是 String 类型,而 领域内定义了一个 ArticleContent 的领域类型,此时需要使用 String 类型的 Content 创建 ArticleContent 类型。

示例代码如下。

java
/**
 * Application层的命令对象
 */
public class CreateDraftCmd {
    private String title;
    private String content;
}

应用层方法如下:

java
/**
 * Application层的创建草稿方法
 */
public class ArticleApplicationService {

    @Resource
    private ArticleEntityFactory factory;

    @Resource
    private ArticleEntityRepository repository;
    /**
     * 创建草稿
     */
    public void newDraft(CreateDraftCmd cmd) {

        //将 Command 转化成领域内的值对象,传递给领域工厂以创建领域模型
        //此处需要将String类型的title、content分别转成值对象
        ArticleTitle title = new ArticleTitle(cmd.getTitle());
        ArticleContent content = new ArticleContent(
cmd.getContent());
        ArticleEntity articleEntity =factory.newInstance(
title,content);
        //执行创建草稿的业务逻辑
        articleEntity.createNewDraft();
        //保存聚合根
        repository.save(articleEntity);
    }
}

领域层

领域模型内部执行创建草稿的业务逻辑。

创建草稿看起来很像一个对象的初始化逻辑,但是不要将创建草稿的逻辑放在对象的构造 方法中,因为创建草稿是业务操作,对象初始化是编程语言的技术实现。

每个对象都会调用构造方法初始化,但是不可能每次构造一个对象都创建一遍草稿。有的article 对象已经发布了,如果将创建草稿的初始化放在构造方法中,那么已经发布的 article 对象也会再次创建一遍草稿,可能再次产生一个新的事件,这是不合理的。

java
public class ArticleEntity {

    public void createNewDraft() {
        Objects.requireNonNull(this.title);
        Objects.requireNonNull(this.content);
        this.state = ArticleState.NewDraft;
    }
}

基础设施层

infrastructure-persistence 包内部有用于对象关系映射的数据模型,作为领域模型持久化过 程中的数据容器。

值得注意的是,领域模型和数据模型的属性不一定是一对一的。在一些领域模型中,值 对象可能会在数据模型中有单独的对象类型。例如,Article 在数据库层面拆分为能由多个表存 储,比如主表 cms_article 和正文表 cms_article_content。在 Repository 内部,也需要完成转换 并进行持久化。

一些 ORM 框架(例如 JPA)可以通过技术手段,直接在领域模型上加入一系列注解,将 领域模型内的字段映射到数据库表中。 存在即合理,这种方式如果使用得当,就可能会带来一些便利,但是本书不会采用这种方 法。因为这样会使得领域模型承载过多的责任:领域模型应该只关心业务逻辑的实现,而不必 关心领域模型如何持久化,这是基础设施层和数据模型应该关心的事情。

infrastructure-persistence 包的伪代码如下。

java
/**
 * Repository保存聚合根的过程
 */
@Component
public class ArticleDomainRepositoryImpl implements ArticleDomainRepository {

    @Resource
    private ArticleDao dao;

    /**
     * 保存聚合根
     */
    @Transactional
    public ArticleEntity save(ArticleEntity entity){
        //初始化数据模型对象
        Article data = new Article();
        //赋值
        data.setArticleId(entity.getArticleId().getValue());
        data.setArticleTitle(entity.getArticleTitle().getValue());
        data.setArticleContent(
entity.getArticleContent().getValue());
        //插入数据模型记录
        dao.insert(data);
    }
}

2.6.4 修改过程的类型转换

修改过程的类型转换如图 2-16 所示。

图2-16 图2-16 修改过程的类型转换

修改过程与创建过程的区别仅在于创建时用 Factory 生成聚合根,而修改时用 Repository加载聚合根。

java
/**
 * Application层的Command
 */
public class ModifyTitleCmd {
    private String articleId;
    private String title;
}

@Service
public class ArticleApplicationService {

    @Resource
    private ArticleEntityRepository repository;

    public void modifyTitle(ModifyTitleCmd cmd) {
        //以Command中的参数创建值对象
        ArticleId articleId = new ArticleId(cmd.getArticleId());
        //由Repository加载聚合根
        ArticleEntity articleEntity = repository.load(articleId);
        //聚合根执行业务操作
        articleEntity.modifyTitle(new ArticleTitle(cmd.getTitle()));
        //保存聚合根
	    repository.save(articleEntity);
    }
}

附录部分

Ⅰ 学习交流

  • 读者交流微信群

欢迎加入 DDD 交流群。微信扫以下二维码添加作者微信,标注“DDD”,好友申请通过后拉您进群。

qr.jpg
  • 微信公众号

本书专属公众号“悟道领域驱动设计”。

pi1rmB6.jpg

Ⅱ 版权声明

  • 本作品代码部分

采用 Apache 2.0 协议进行许可。

遵循许可的前提下,你可以自由地对代码进行修改,再发布,可以将代码用作商业用途。但要求你:

署名:在原有代码和衍生代码中,保留原作者署名及代码来源信息。

必须提供作者的署名以及本作品的链接(http://ddd.feiniaojin.com/)

保留许可证:在原有代码和衍生代码中,保留 Apache 2.0 协议文件。

  • 本作品文档、图片等内容部分

采用署名-非商业性使用-禁止演绎 4.0 国际 (CC BY-NC-ND 4.0 DEED)进行许可。

在遵守以下条件的前提下:

署名: 您必须给出 适当的署名 ,提供指向本许可协议的链接,同时 标明是否(对原始作品)作了修改 。您可以用任何合理的方式来署名,但是不得以任何方式暗示许可人为您或您的使用背书。

引用本作品任何内容时,必须提供作者的署名以及本作品的链接(http://ddd.feiniaojin.com/)

非商业性使用: 您不得将本作品用于 商业目的 。

在媒体、自媒体平台(包括但不限于微信公众号、头条号等)转载、二次创作、发表等行为将被视为商业应用,必须取得作者的授权。

禁止演绎: 如果您 再混合、转换、或者基于该作品创作 ,您不可以分发修改作品。

基于本作品任何内容,进行任何形式(包括但不限于文章、视频、语音、有声书等)的二次创作(包括翻译为其他语言),都必须取得作者的授权。

没有附加限制: 您不得适用法律术语或者 技术措施 从而限制其他人做许可协议允许的事情。

您可以自由地:

共享: 在任何媒介以任何形式复制、发行本作品。

只要你遵守许可协议条款,许可人就无法收回你的这些权利。