02.分层架构

0 阅读33分钟

分层架构

1.贫血模型与充血模型

贫血模型

介绍

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

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

充血模型

介绍

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

代码案例
/**
 * 视频播放器
 */
@Data
public class VideoPlayer {
    /**
     * 播放列表
     */
     public List<String> playList;
     /**
      * 播放节目
      */
      public void play() {
          for (String v : playList) {
              System.out.println("正在播放:" + v);
          }
      }
}

DDD对模型的要求

贫血模型优缺点
优点
  1. 编写代码方便:大部分程序员都习惯使用这种模型
  2. 通常使用步骤:
    • 通过ORM框架从数据库中查询数据
    • 在Service层的方法中操作这些数据对象完成业务逻辑
    • 在Service层中调用ORM框架将执行结果更新到数据库
缺点
  • 业务逻辑封装不够:贫血哦模型只提供了业务数据的容器,并不会发生业务行为。贫血模型通过将这些属性暴露给Service方法来完成业务逻辑的操作。实际上,Service方法承担了实现所有业务逻辑的责任,这导致业务知识分散在Service层的各个方法中。经常会发现某个业务验证逻辑在每个Service方法中都会出现一次,这是因为业务知识没有被封装起来
  • 业务代码混杂基础设施:贫血模型实现的Service层通常无法将业务代码与基础设施操作分离。Service层的方法在实现业务的同时,还需要与外部服务,中间件交互,例如RPC调用,缓存,事务控制等,导致业务代码中夹杂着基础设施操作的细节
  • 迭代困难:通过贫血模型构建的系统经过多次迭代后,其中的Service方法变得非常臃肿,难以持续的演进
为什么贫血模型不适合DDD

因为贫血模型存在上面说的那些缺点,它连基本的业务知识封装都无法实现,又怎么能创建一个面向业务的领域模型呢

充血模型为什么适合DDD

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

以上述的VideoPlayer为例,展示了充血模型在Service层的使用方法,代码如下:

public class VideoApplicationService {
    /**
     * 播放
     */
     public void play() {
         // 1.获得领域对象
         // 2.执行业务操作,Service只需要调用充血模型的行为就能完成业务操作,Service不需要具体了解播放的逻辑是啥
         videoPlayer.play();
     }
}

可以看到,采用充血模型后,业务逻辑由对应的充血模型维护,被很好地封装在模型中,与基础设施的代码分离开了,Service方法会变得更清晰

2.分层架构

严格分层架构与松散分层架构

  • 在严格分层架构中,某层只能与位于其直接下方的层发生耦合
  • 在松散分层架构中,则允许某层与它的任意下方层发生耦合

优缺点

优点

分层架构的目的是通过关注点分离来降低系统的复杂度,同时满足单一职责、高内聚、低耦合、提高可复用性和降低维护成本

  • 单一职责:每一层只负责一个职责,职责边界清晰,如持久层只负责数据查询和存储,领域层只负责处理业务逻辑
  • 高内聚:分层是把相同的职责放在同一个层中,所有业务逻辑内聚在领域层
  • 低耦合:依赖关系非常简单,上层只能依赖于下层,没有循环依赖
  • 可复用:某项能力可以复用给多个业务流程,比如持久层提供按照还款状态查询信用卡的服务,既可以给申请信用卡做判断使用,也可以给展示未还款信用卡使用
  • 易维护:面对变更容易修改。把所有对外接口都放在对外接口层,一旦外部依赖的接口被修改,只需要改这个层的代码即可
缺点
  • 开发成本高:因为多层分别承担各自的职责,增加功能需要在多个层增加代码,这样难免会增加开发成本。但是合理的能力抽象可以提高了复用性,又能降低开发成本
  • 性能略低:业务流需要经过多层代码的处理,性能会有所消耗
  • 可扩展性低:因为上下层之间存在耦合度,所有有些功能变化可能涉及到多层的修改

3.经典贫血三层架构

介绍

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

  • Controller层:接收用户请求,调用Service完成业务操作,并将Service输出拼接位响应报文向客户端返回
  • Service层:初衷是在Service层实现业务逻辑,往往还需要与基础设施(数据库,缓存,外部服务等)交互
  • Dao层:封装数据库读写操作
  • Model层:贫血模型,往往与数据库的表字段一一对应,用于充当数据库读写的数据容器

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

优点

  • 关注点分离:贫血三层架构将系统的不同功能模块分别放置在不同的层次中,使得每个层次只关注自己的责任范围。这种分离关注点的设计使得系统更加清晰,易于扩展和维护
  • 复用性强:不同功能模块放置在不同的层次中,使得每个层次也可以独立重用。这种可复用性的设计使得系统的代码可以更加灵活地组织和重用,有助于提高开发效率
  • 前期开发效率高:在产品和业务的初期,使用贫血三层架构可以快速地实现最小可行产品(MVP),帮助企业进行商业模式验证

问题

  • 底层缺乏抽象:经常能看到每层的方法名字都是一样的,并没有体现出有‘‘越上层越具体,越底层越抽象’‘的设计思路
  • 业务逻辑分散:这个主要是由于贫血模型造成的。贫血模型对于领域对象的封装程度较低。由于领域对象只包含数据属性,对于复杂的业务逻辑或数据操作,可能需要在Service或Dao中进行处理。这可能导致领域对象的封装程度较低,使得代码变得分散和难以管理
  • 难以持续演化:在贫血三层架构中,业务逻辑分散到代码的各处,并且与基础设施的操作紧密耦合,会导致代码越来越臃肿和难以维护。在这种情况下,很难编写有效的单元测试用例,代码质量会越来越难以保证

4.DDD经典四层架构

介绍

DDD将软件系统分为四层:基础结构层,领域层,应用层和表现层

在《领域驱动设计——软件核心复杂性的应对之道一书中,DDD范式的创始人Evans提出下图所示的这样一种分层架构:

用户接口层(User Interface)

与贫血三层架构的Controller差不多,用户接口层将应用层的服务按照一定协议对外暴露,用户接口层接收用户请求,并将请求的参数经过处理后,传递给下一层(应用层)进行处理,最后将应用层的结果按照一定的协议向调用者返回

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

应用层(Application)

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

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

领域层(Domain)

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

所有的业务概念,业务规则,业务流程都应该在领域层表达

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

基础设施层(Infrastructure)

基础设施层负责实现领域层的基础设施接口,例如,加载和保存聚合根的仓储接口,调用外部服务的网关接口,发布领域事件到消息中间件的消息发布接口等。基础设施层实现这些接口后,供应用层调用

基础设施层仅包含技术实现细节,不包含任何业务处理逻辑。基础设施层接口的输入和输出应该是领域模型或基础数据类型

5.端口和适配器架构(六边形架构)

介绍

六边形架构是Alistair Cockburn在2005年提出,解决了传统的分层架构所带来的问题,实际上它也是一种分层架构,只不过不是上下或左右,而是变成了内部和外部

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

核心概念

  1. 端口:是系统与外部进行交互的接口,它定义了系统对外提供的的服务以及需要外部提供的支持
    • 定义系统对外提供的服务:通常是指定义可以被外部系统调用的接口,将业务逻辑是现在接口的实现类中,这种属于入站端口
    • 定义需要外部提供的支持:是指执行业务逻辑的过程中,有时候需要依赖外部服务(例如从外部服务加载某些数据用于完成计算),此时定义一个接口,通过调用该接口完成外部调用,这种属于出站端口
  2. 适配器:适配细分为主动适配器和被动适配器
    • 主动适配器:用于对外暴露端口,例如将端口暴露位RESTful接口,或者将端口暴露位RPC服务
    • 被动适配器:用于实现业务逻辑执行过程中需要的使用的端口,如外部调用网关等

端口和适配器之间的交互关系

代码

主动适配器
/**
 * 主动适配器,将创建文章的Port暴露位HTTP服务
 */
@RestController
public class ArticleController {
    @Resource
    private ArticleService service;
    
    @RequestMapping("/create")
    public void create(DTO dto) {
        service.create(dto);
    }
}
进站端口
/**
 * 进站端口接口
 */
public interface ArticleService {
	/**
	 * 端口和适配器架构中的Port,提供创建文章的能力
	 */ 
	 void create(DTO dto);
}
出站端口
/**
 * 进站端口接口
 */
public interface AuthorServiceGateway {
	/**
	 * 查询作者信息
	 */ 
	 AuthorDto queryAuthor(String authorId);
}
被动适配器
/**
 * 被动适配器
 */
public interface AuthorServiceGatewayImpl implements AuthorServiceGateway {
	/**
	 * 作家RPC服务
	 */
	 @Resource
	 private AuthorServiceRepo rep;
	 
	 AuthorDto queryAuthor(String authorId) {
         // 拼装报文
         AuthorRequest req = this.createRequest(authorId);
         // 执行RPC查询
         AuthorResponse res = rpc.queryAuthor();
         // 解析查询结果并返回
         return this.handleAuthorResponse(res);
	 }
}

6.从MVC开始分层架构的演化

介绍

从日常的三层架构出发,演绎出落地领域驱动设计的分层架构

合并数据模型

为什么要合并实体类与Dao

为什么要将数据模型与数据访问层合并呢?

  1. 数据模型是贫血模型,它不包含业务逻辑,仅作为装载模型属性的容器
  2. 数据模型与数据库表的列是一一对应的。数据模型的主要应用场景是在持久层中用来进行数据库读写操作,将数据库查询结果封装为数据模型,并返回给Service层供其获取模型属性以执行业务逻辑
  3. 数据模型的类或属性字段上通常带有ORM框架的一些注解,与DAO层关联密切。 可以认为数据模型是DAO 层用来查询或持久化数据的工具。如果将数据模型与DAO层分离, 那么其意义将大打折扣
合并后的架构

数据模型与DAO层合并后的架构图如图所示

抽取领域模型

案例代码

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

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

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

优化思路

说白了这一步就是使用充血模型优化代码

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

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

优化案例

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

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

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方法非常直观,就是获取模型、执行业务逻辑、 保存模型,再协调基础设施完成其余的操作。此时架构图如图所示

维护领域对象生命周期

介绍

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

引入Repository

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

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

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

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

以下是DomainRepository的伪代码

public interface DomainRepository {
	/**
	 * 保存聚合根
	 */
    void save(AggregateRoot root);
    /**
     * 根据id查询聚合根
     */
    AggregateRoot load(EntityId id);
}
那一层实现Repository接口更好
Service层

首先可以考虑将DomainRepository的实现放在Service层,在其实现类中调用DAO层进行数据库操作,但是这并不好,原因如下:

Service层只需要关心通过DomainRepository加载和保存领域模型,并不关心领域模型的存储和加载细节。Service无须了解使用哪种数据模型对象、使用哪些DAO对象进行操作、将领域模型存储到哪张表、如何通过数据模型拼装领域模型、如何将领域模型转化为数据模型等细节。因此,在Service层实现DomainRepository并不是很好的选择

Dao层

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

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

结果图示

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

泛化抽象

介绍

在节中得到的架构图已经和经典四层架构非常相似了,在实际项目中不仅仅是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

应用层

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

完整结构

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

完整的项目结构

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

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

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

至此,得到了完整可运行的领域驱动设计应用架构

7.领域对象的生命周期

介绍

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

创建过程

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

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

Factory的示例代码如下

/**
 * 通过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进行领域对象的创建,伪代码如下

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

    private final String value;

    public ArticleId(String input){
        this.value=input;
    }
}
@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 省略后续操作
    }
}

保存过程

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

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

细节以后咱们探讨,这里知道领域对象是通过Repository进行持久化的即可

重建过程

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

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

  1. 数据持久化和恢复:当从持久化存储中加载领域对象时,需要使用持久化状态的数据对领域对象进行重建。在这种情况下,我们通过Repository的load方法对领域对象进行加载。在load方法中,需要先将持久化存储的数据查询出来,一般利用ORM框架可以很方便地完成这个查询过程,查询的结果为数据模型,最后将得到的数据模型组装成领域模型
  2. 业务重试:在业务执行的过程中,例如更新数据库时发生了乐观锁冲突,导致上层代码捕获到异常,此时需要重新加载领域对象,获得领域对象的最新状态,以便正确执行业务逻辑。 此时捕获到异常后,重新通过Repository 的load方法进行领域对象重建
  3. 事件驱动:在事件驱动架构中,领域对象通常通过订阅事件来获取数据更新或状态变化的通知。当用户接口层收到相关事件后,需要对领域对象进行重建,以响应捕获到的领域事件。 在事件溯源的模式中,还需要通过对历史事件的回放,以达到领域对象重建的目的

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

/**
 * 重建,通过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;
    }
}

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

@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,造成领域模型生命周期缺失,而且直接转换得到的领域对象有可能状态并不完整

错误的示例伪代码如下

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

归档过程

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

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

8.四层架构如何处理增删改查

查询过程

介绍

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

加载单个聚合根的查询

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

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

CQRS

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

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

用户接口层

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

@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,伪代码如下\

/**
 * 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 省略其他方法
}
应用层

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

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

@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读

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

伪代码如下

/**
 * 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的场景,基础设施层会先查询出数据模型,再将数据模型组装为领域模型并向上返回,伪代码如下

/**
 * 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;
    }
}

创建过程

介绍

创建过程的类型转换如图所示

用户接口层

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

对于HTTP接口,示例如下:

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

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

/**
 * 创建过程的用户接口层,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类型

示例代码如下

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

应用层方法如下

/**
 * 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对象也会再次创建一遍草稿,可能再次产生一个新的事件,这是不合理的

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包的伪代码如下

/**
 * 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);
    }
}

修改过程

修改过程的类型转换如图所示

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

/**
 * 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);
    }
}