实现领域驱动设计 pdf_领域驱动设计和CQRS落地

本文介绍了如何运用Java代码实现领域驱动设计,详细讨论了分层架构、依赖倒置原则、六边形架构、洋葱架构和整洁架构。通过分析不同架构模式,探讨了领域驱动模式如SIDE-EFFECT-FREE和CQRS,并分享了落地领域驱动设计的具体步骤,包括组织代码、用户界面、应用服务、领域模型、基础设施等方面。最后,通过Cargo货物实例展示了落地实践,并提供了参考资料和源码链接。
摘要由CSDN通过智能技术生成

f1d39c2f052c1ce0eafc67f389f6d2e6.png

目录

1. 前言2. 领域驱动架构2.1 分层架构2.2 DIP改进分层架构2.3 六边形架构2.4 洋葱架构、整洁架构3. 领域驱动模式3.1 SIDE-EFFECT-FREE3.2 CQRS4. 领域驱动架构落地5. 领域驱动代码落地5.1 组织代码5.2 落地用户界面5.3 落地应用服务5.4 落地领域模型5.5 落地领域服务5.6 落地基础设施5.7 落地查询服务5.8 落地MQ、Event、Cache5.9 落地RPC和防腐层6. Cargo货物实例和源码7. 参考资料8. 总结

1. 前言

假定你已经初步了解过领域驱动设计(DDD)的基本概念,如果不了解,建议先阅读一些基础文章:

  • 聚合根

  • 实体

  • 值对象

  • 领域服务

  • 领域事件

  • 资源库

  • 限界上下文

  • CQRS

本文重点讲述如何运用Java代码落地领域驱动设计。

2. 领域驱动架构

落地领域驱动的首要问题是选择何种架构去实现?

2.1 分层架构

Evans在它的《领域驱动设计:软件核心复杂性应对之道》书中推荐采用分层架构去实现领域驱动设计,架构图是这样的:

9c84b73ef736aad9804239dd9d911a65.png

分层架构是一种常见的自上而下的依赖关系,其实我们早已驾轻就熟,MVC模式就是一种分层架构:我们尽可能去设计每一层,使其保持高度内聚性,让它们只对下层进行依赖,体现了高内聚低耦合的思想。

用户界面层:我们可以理解成web层的Controller,即对外暴露接口,显示界面;

应用层:和业务无关,它负责协调领域层进行工作;

领域层:领域驱动设计的业务核心,包含领域模型和领域服务,领域层的重点放在如何表达领域模型上,无需考虑显示和存储问题;

基础实施层:最底层,提供基础的接口和实现,领域层和应用服务层通过基础实施层提供的接口实现类如持久化、发送消息等功能。

阿里巴巴开源的整洁面向对向分层架构COLA采取了这样的分层架构来实现领域驱动,有兴趣可以去阅读下。

2.2 DIP改进分层架构

分层架构是一种可落地的架构,但是我们依然可以进行改进,Vernon在它的《实现领域驱动设计》一书中提到了采用依赖倒置原则改进的方案。

7654c0ddc594258f2ecf8facddd3760f.png

所谓的依赖倒置原则指的是:高层模块不应该依赖于低层模块,两者都应该依赖于抽象,抽象不应该依赖于细节,细节应该依赖于抽象。

这句话需要细细品味,正如架构图中看到的,基础实施层位于其他所有层的上方,接口定义在其它层,基础实施实现这些接口。或者可以这样来表述:领域层等其他层不应该依赖于基础实施层,两者都应该依赖于抽象。

这也就是意味着一个重要的落地指导原则: 所有依赖基础实施实现的功能,抽象和接口都应该定义在领域层或应用层中。

依赖倒置原则和分层架构的结合增强了高内聚低耦合的特性,每一层只依赖于抽象,因为具体的实现在基础实施层,无需关心。只要抽象不变,就无需改动那一层,实现如果需要改变,只需要修改基础实施层就可以了。

2.3 六边形架构

《实现领域驱动设计》一书中提到了DDD架构更深层次的变化,Vernon放弃了分层架构,采用了对称性架构:六边形架构,作者认为这是一种具有持久生命力的架构。

e1c008f9c33932d43cfc17dcc8e8195d.png

如图,在这种架构风格中,外部客户和内部系统的交互都会通过端口和适配器完成转换,这些外部客户之间是平等的,比如用户web界面和数据库持久化,当你需要一个新的外部客户时,只需要增加相应的适配器,比如当我们依赖外部一个RPC的服务时,只需要编写对应的适配器即可。

好吧,当将web界面和持久化统称在一起,没有前端和数据库后端之分的时候,这种观察架构的角度已经打动到了我。当你真正理解这种架构的时候,相信你也不得不佩服这种角度不同的设计。

怎么理解适配器呢,或者说适配器在各种外部客户的场景下是什么呢?

如果外部客户时HTTP请求,那么SpringMVC的注解和Controller构成了适配器,如果外部客户时MQ消息,那么适配器就是MQConsumer监听器,如果外部客户时数据库,那么适配器可能就是Mybatis的Mapper。

2.4 洋葱架构、整洁架构

随着架构的演化,后来又提出了洋葱架构和整洁架构,这些架构大同小异,它们都只允许外层依赖内层,不允许内层知道外层的细节,下图是整洁架构图,详细介绍这里就不作赘述,可以参考这篇文章The Clean Architecture:https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html

2baa3a9a80ff16f5cbe2d434b5644256.png

3. 领域驱动模式

当领域驱动设计突出了领域模型的地位,我们会使用一些优秀的设计模式与之结合。

3.1 SIDE-EFFECT-FREE

SIDE-EFFECT-FREE模式被称为无副作用模式,熟悉函数时编程的朋友都知道,严格的函数就是一个无副作用的函数,对于一个给定的输入,总是返回固定的结果,通常查询功能就是一个函数,命令功能就不是一个函数,它通常会执行某些修改。

根据这种模式,就有了CQRS的架构设计。

3.2 CQRS

在领域驱动架构中,通常会将查询和命令操作分开,我们称之为CQRS(命令查询的责任分离Command Query Responsibility Segregation)。这张图是来自Martin Fowler的文章CQRS:https://www.martinfowler.com/bliki/CQRS.html。

356b236e8c493d74ae227bfc1ee5477c.png

这张图读模块Query Model和写模块Command Model只是逻辑分离,物理层面还是使用了同一个数据库,我们可以将数据库改成读库和写库做到物理分离,这时候就需要同步都写库。

最终CQRS落地的方案我们选择了简单化处理,物理层面还是使用一个数据库,查询的时候部分数据直接从数据库读取,部分数据使用到了Elasticsearch,数据同步可以采用两种方案:

  1. 当数据库发生更改时,主动发送Event事件通知ES进行更新

  2. 直接监听Mysql的binlog更新ES

4. 领域驱动架构落地

根据上面的分析,最终落地的架构使用了对称性架构。

我们平等的看待Web、RPC、DB、MQ等外部服务,如下图所示:

9f130c5944e0d493ed377d844bf4b085.png

当一个命令Command请求过来时,会通过应用层的CommandService去协调领域层工作,而一个查询Query请求过来时,则直接通过基础实施的实现与数据库或者外部服务交互。再次强调,我们遵循依赖倒置原则,所有的抽象都定义在圆圈内部,实现都在基础设施。

在具体编写代码中我们发现,Query和Command的有一些数据和抽象服务是公用的,因此我们抽出了一个新的模块:Shared Data & Service,这个模块的功能为公用的数据对象和抽象接口。

5. 领域驱动代码落地

分析领域驱动架构的方法论有很多,但是落地到代码层面的方法论少之又少,这一小节我们将具体到DDD设计的每个小点来阐述如何代码落地。

5.1 组织代码

我们采用如下的package结构组织代码,每个package正好对应了我们的领域驱动架构。

e7f39d9168fab1494632b2e085a271e6.png

用户界面Web放在了模块com.deepoove.cargo.web.controller中,实现一些Controller,infrastructure放在了com.deepoove.cargo.infrastructure中,抽象接口的实现,它们都依赖于应用服务和领域模型。

注意的是虽然架构平等的看待外部服务,但是我们依然将用户界面从基础设施抽取了出来,毕竟我们的项目是Web主导的。同理,如果你的项目不是个web项目,而是用来提供RPC服务的项目,那么我们可以创建一个新包去组织RPC适配器的代码:比如com.deepoove.cargo.remoting包。

5.2 落地用户界面

用户界面的代码放在com.deepoove.cargo.web.controller包下面。Controller作为六边形架构中与HTTP端口的适配器,起到了适配请求,委托应用服务处理的任务。

这里我们有一个规范:所有查询的条件封装成XXXQry对象,所有命令的请求封装成XXXCommand对象。

package com.deepoove.cargo.web.controller;@RestController@RequestMapping("/cargo")public class CargoController {@AutowiredCargoQueryService cargoQueryService;@AutowiredCargoCmdService cargoCmdService;@RequestMapping(value = "/{cargoId}", method = RequestMethod.GET)public CargoDTO cargo(@PathVariable String cargoId) {return cargoQueryService.getCargo(cargoId);
}@RequestMapping(method = RequestMethod.POST)public void book(@RequestBody CargoBookCommand cargoBookCommand) {
cargoCmdService.bookCargo(cargoBookCommand);
}@RequestMapping(value = "/{cargoId}/delivery", method = RequestMethod.PUT)public void modifydestinationLocationCode(@PathVariable String cargoId,@RequestBody CargoDeliveryUpdateCommand cmd) {
cmd.setCargoId(cargoId);
cargoCmdService.updateCargoDelivery(cmd);
}
}

对于参数校验我们的原则是:在用户界面层可以有请求参数的基本校验,但是 应用服务层和领域层的校验逻辑是必须存在的,校验和业务的耦合是紧密的,接下来我们就来看看如何落地应用服务层。

5.3 落地应用服务

com.deepoove.cargo.application.command包里面是具体CommandService的抽象和实现,它将协调领域模型和领域服务完成业务功能,此处不包含任何逻辑。我们认为应用服务的每个方法与用例是一一对应的(好像嗅到了行为驱动测试BDD的味道),典型的处理流程是:

  1. 校验

  2. 协调领域模型或者领域服务

  3. 持久化

  4. 发布领域事件

在这一层可以使用流程编排,典型的流程也可以使用技术手段固化,比如抽象模板模式。

package com.deepoove.cargo.application.command.impl;@Servicepublic class CargoCmdServiceImpl implements CargoCmdService {@Autowiredprivate CargoRepository cargoRepository;@AutowiredDomainEventPublisher domainEventPublisher;@Overridepublic void bookCargo(CargoBookCommand cargoBookCommand) {// create CargoDeliverySpecification delivery = new DeliverySpecification(
cargoBookCommand.getOriginLocationCode(),
cargoBookCommand.getDestinationLocationCode());Cargo cargo = Cargo.newCargo(CargoDomainService.nextCargoId(), cargoBookCommand.getSenderPhone(),
cargoBookCommand.getDescription(), delivery);// saveCargo
cargoRepository.save(cargo);// post domain event
domainEventPublisher.publish(new CargoBookDomainEvent(cargo));
}@Overridepublic void updateCargoDelivery(CargoDeliveryUpdateCommand cmd) {// validate// findCargo cargo = cargoRepository.find(cmd.getCargoId());// domain logic
cargo.changeDelivery(cmd.getDestinationLocationCode());// save
cargoRepository.save(cargo);
}
}

发布领域事件的动作放在了应用层没有放在领域层,而领域事件的定义是在领域层(紧接着会提到),这是为什么呢?

如果 不考虑持久化,发布领域事件的确应该在领域模型中,但是在代码落地时,考虑到持久化完成后才代表有了真实的事件,所以我们决定将触发事件的代码放到了资源库后面。

5.4 落地领域模型

业务核心领域模型的代码组织在com.deepoove.cargo.domain.aggregate包中。我们采用了aggregate而不是model,是为了将聚合根的概念显现出来,每个聚合根单独成一个子包,在单个聚合根中包含所需要的值对象、领域事件的定义、资源库的抽象接口等。

领域事件的定义、资源库的抽象接口之所以放在相应聚合根的package中,是因为它更能体现这个领域模型,而且资源库的抽象和聚合根有着对应的关系(不大于聚合根的数量)。

package com.deepoove.cargo.domain.aggregate.cargo;import com.deepoove.cargo.domain.aggregate.cargo.valueobject.DeliverySpecification;public class Cargo {private String id;private String senderPhone;private String description;private DeliverySpecification delivery;public Cargo(String id) {this.id = id;
}public Cargo() {}/** * Factory method:预订新的货物 * * @param senderPhone * @param description * @param delivery * @return */public static Cargo newCargo(String id, String senderPhone, String description,DeliverySpecification delivery) {Cargo cargo = new Cargo(id);
cargo.senderPhone = senderPhone;
cargo.description = description;
cargo.delivery = delivery;return cargo;
}public void changeDelivery(String destinationLocationCode) {if (this.delivery
.getOriginLocationCode().equals(destinationLocationCode)) { throw new IllegalArgumentException("destination and origin location cannot be the same."); }this.delivery.setDestinationLocationCode(destinationLocationCode);
}public void changeSender(String senderPhone) {this.senderPhone = senderPhone;
}
}

关于聚合根对象的创建,特别提醒的是聚合根对象的创建不应该被Spring容器管理,也不应该在聚合根中注入其它对象。聚合根对象可以通过静态工厂方法来创建,下文还会介绍如何落地资源库进行聚合根的创建。

5.5 落地领域服务

领域服务的代码组织com.deepoove.cargo.domain.service包中。

很多朋友无法判断业务逻辑什么时候该放在领域模型中,什么时候放在领域服务中,可以从以下几点考虑:

  1. 不是属于单个聚合根的业务或者需要多个聚合根配合的业务,放在领域服务中,注意是业务,如果没有业务,协调工作应该放到应用服务中

  2. 静态方法放在领域服务中

  3. 需要通过rpc等其它外部服务处理业务的,放在领域服务中

package com.deepoove.cargo.domain.service;@Servicepublic class CargoDomainService {public static final int MAX_CARGO_LIMIT = 10;public static final String PREFIX_ID = "CARGO-NO-";/**     * 货物物流id生成规则     *     * @return     */public static String nextCargoId() {return PREFIX_ID + (10000 + new Random().nextInt(9999));
}public void updateCargoSender(Cargo cargo, String senderPhone, HandlingEvent latestEvent) {if (null != latestEvent&& !latestEvent.canModifyCargo()) { throw new IllegalArgumentException("Sender cannot be changed after RECIEVER Status."); }
cargo.changeSender(senderPhone);
}
}

5.6 落地基础设施

基础设施层的代码组织在com.deepoove.cargo.infrastructure包中。

基础设施可以对抽象的接口进行实现,上文中说到资源库Repository的接口定义在领域层,那么在基础设施中就可以具体实现这个接口。

package com.deepoove.cargo.infrastructure.db.repository;@Componentpublic class CargoRepositoryImpl implements CargoRepository {@Autowiredprivate CargoMapper cargoMapper;@Overridepublic Cargo find(String id) {CargoDO cargoDO = cargoMapper.select(id);Cargo cargo = CargoConverter.deserialize(cargoDO);return cargo;
}@Overridepublic void save(Cargo cargo) {CargoDO cargoDO = CargoConverter.serialize(cargo);CargoDO data = cargoMapper.select(cargoDO.getId());if (null == data) {
cargoMapper.save(cargoDO);
} else {
cargoMapper.update(cargoDO);
}
}
}

资源库Repository的实现就是将聚合根对象持久化,往往聚合根的定义和数据库中定义的结构并不一致,我们将数据库的对象称为数据对象DO,当持久化时就需要将聚合根 序列化 成数据库数据对象,通过资源库获取(构造)聚合根时,也需要 反序列化 数据库数据对象。

我们可以基于反射或其它技术手段完成序列化和反序列化操作,这样可以避免聚合根中编写过多的getter和setter方法。

5.7 落地查询服务

查询服务的代码组织在com.deepoove.cargo.application.query包中。application应用服务包含了commond和query两个子包,query也可以提取出去和application平级,这两种做法没有对错,只是选择问题。

运用CQRS设计,查询服务不会调用应用服务,也不会调用领域模型和资源库Repository,它会直接查询数据库或者ES获取原始数据对象DO,然后组装成数据传输对象DTO给用户界面,这个组装的过程称为Assembler,由于与用户界面有一定的对应关系,所以Assembler放在查询服务中。

那么问题来了,查询服务中如何获取DO呢?通常的做法是在查询模块中定义抽象接口,由基础设施实现从数据库获取数据 ,但是DO的定义不是在基础设施层吗,查询服务怎么可以访问到这些对象呢?我们有两个办法:

  1. 查询服务中定义一套一摸一样的DO,然后基础设施做转换,缺点是有点复杂,冗余了DO,优点是完美符合DIP原则:抽象在查询服务中,实现在基础设施

  2. 将DO放到shared Data & Service中去,这样就只要一套DO供查询服务和命令服务使用,查询服务定义接口,基础设施实现接口

具体落地我们发现方法1有点冗余,方法2和mybatis结合会产生疑惑,因为mybatis Mapper就是一个接口,何须在查询服务中再定义一套接口呢?

最终落地的方式仁者见仁智者见智,ddd-cargo示例项目中我选择了在查询服务和DB交互时 破坏了DIP原则,直接依赖Mapper读取数据对象进行组装。

我们来看看一个查询服务的实现,其中CargoDTOAssembler是一个组装器:

package com.deepoove.cargo.application.query.impl;@Servicepublic class CargoQueryServiceImpl implements CargoQueryService {@Autowiredprivate CargoMapper cargoMapper;@Autowiredprivate CargoDTOAssembler converter;@Overridepublic List queryCargos() {List cargos = cargoMapper.selectAll();return cargos.stream().map(converter::apply).collect(Collectors.toList());
}@Overridepublic List queryCargos(CargoFindbyCustomerQry qry) {List cargos = cargoMapper.selectByCustomer(qry.getCustomerPhone());return cargos.stream().map(converter::apply).collect(Collectors.toList());
}@Overridepublic CargoDTO getCargo(String cargoId) {CargoDO select = cargoMapper.select(cargoId);return converter.apply(select);
}
}

是否需要将每个对象都转化成DTO返回给用户界面这个要看情况,个人认为当DO能满足界面需求时是可以直接返回DO数据的。

5.8 落地MQ、Event、Cache

毫无疑问,MQ、Event、Cache的实现都应该在基础设施层,它们接口的定义放在哪里呢?一个方案是哪一层使用了抽象就在那一层定义接口,另一个方案是放到一个共有的抽象包下,基础设施和对应层依赖这个共有的抽象包。

最终落地我选择将这些接口代码组织在了com.deepoove.cargo.shared包下,这个包的定义就是共有的数据和抽象。

我们以领域事件为例来看看代码如何实现,首先定义抽象接口com.deepoove.cargo.shared.DomainEventPublisher

package com.deepoove.cargo.shared;public interface DomainEventPublisher {public void publish(Object event);
}

然后在基础实施中实现,具体实现采用guava的Eventbus方案:

package com.deepoove.cargo.infrastructure.event;@Componentpublic class GuavaDomainEventPublisher implements DomainEventPublisher {@AutowiredEventBus eventBus;public void publish(Object event) {
eventBus.post(event);
}
}

发送事件的代码已经落地,那么监听事件的代码应该如何落地了呢?同样的,监听MQ的代码如何落地呢?按照架构图的指导,这些 监听器都应该充当着适配器的作用,所以它们的落地都应该放在基础设施层。

我们来看看具体监听器的实现:

package com.deepoove.cargo.infrastructure.event.comsumer;@Componentpublic class CargoListener {@Autowiredprivate CargoCmdService cargoCmdService;@Autowiredprivate EventBus eventBus;@PostConstructpublic void init(){
eventBus.register(this);
}@Subscribepublic void recordCargoBook(CargoBookDomainEvent event) {// invoke application service or domain service
}
}

监听器的基本流程就是适配领域事件,然后调用应用服务去处理。

5.9 落地RPC和防腐层

前面提到过,当我们暴露一个RPC服务时和web层是平等对待的,比如暴露一个dubbo协议的服务就和暴露一个http的服务是平等的。这一小节我们将来探讨如何与第三方系统的RPC服务进行交互。

这里涉及到DDD中Bounded Context和Context Map的概念,在领域驱动设计中,限界上下文之间是不能直接交互的,它们需要通过Context Map进行交互,在微服务足够细致的年代,我们可以做到一个微服务就代表着一个限界上下文。

当我们与第三方系统RPC交互时,就要考虑如何设计Context Map,典型的模式有Shared Kernel共享内核模式和Anti-corruption防腐层模式,最终落地时我们选择了防腐层模式,它的结构如下图(图来自《实现领域驱动设计》一书)所示:

63555c37c8b67530ac670431ac164ba6.png

图中Adapter就是适配器,通用做法会再创建一个Translator实现上下文模型之间的翻译功能。

在看具体代码落地前还有一个问题需要强调,其它限界上下文的模型在我们系统中并不是一个模型实体,而是一个值对象,很显然Adapter应该放在基础设施层中,那么这个值对象存放在哪里呢?

我们可以将值对象和抽象接口定义在领域层,然后基础设施通过适配器和翻译器实现抽象接口,很明显这个做法是非常可取的。在具体落地时我们发现,这些值对象可能同时又被查询服务依赖,所以值对象和抽象接口定义在shared Data & Service中也是可取的,具体放在那里因看法而异。

接下来我们来看看适配器的基本实现,其中RemoteServiceTranslator起到了模型之间翻译的作用。

package com.deepoove.cargo.infrastructure.rpc.salessystem;@Componentpublic class RemoteServiceAdapter {@Autowiredprivate RemoteServiceTranslator translator;// @Autowired// remoteServicepublic UserDO getUser(String phone) {// User user = remoteService.getUser(phone);// return this.translator.toUserDO(user);return null;
}public EnterpriseSegment deriveEnterpriseSegment(Cargo cargo) {// remote service// translatorreturn EnterpriseSegment.FRUIT;
}
}

6. Cargo货物实例和源码

落地代码实现了一个简单的货运系统,主要功能包括预订货物、修改货运信息、添加货运事件和追踪货运物流信息等,具体源码参见GitHub:https://github.com/Sayi/ddd-cargo

b66e4919572859b417a004addad94d91.png

7. 参考资料

  • 在整个落地过程中,每次阅读《领域驱动设计》和《实现领域驱动设计》这两本书都会给我带来新的想法,值得推荐。

  • The Clean Architecture

  • DDD, Hexagonal, Onion, Clean, CQRS

  • dddsample-core

8. 总结

所有的落地代码都是当前的想法,它一定会变化,架构和设计有魅力的地方就在于它会不断的变迁和升级,我们会不断丰富在领域驱动设计中的代码落地,也欢迎在下方留言与我探讨DDD相关的话题。

PS:本文写于2019年,这篇文章是2020年重排版版本。


原创声明:本文是卅一原创文章,我喜欢在互联网上分享一些东西,空闲时间会打理Evoopeed公众号。

Photo by Nicolas Lobos on Unsplash.

  • 0
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值