暂无图片
暂无图片
暂无图片
暂无图片
暂无图片

聊聊 DDD

代码凌凌漆 2021-07-05
1340

生存本身就是对荒诞最有力的反抗。
—— 阿尔贝·加缪《西西弗斯的神话》



前言

代码的本质,是对现实世界的模拟(或许,现实世界也是代码虚拟出来的)。人们对现实世界的认知越成熟、越深刻,代码面对的复杂性就越高,就需要更加宽广、健壮的软件架构去囊括、解释这复杂的世界。

软件的架构是开发人员对现实业务认知的集中体现。一个好的架构,能够合理划分业务逻辑,保证内部代码的整洁、清晰,降低开发、维护成本。

近几年,越来越多的公司开始拆服务、搭中台,沉淀自己的核心业务模型,打薄前台应用,以求更快应对市场变化。然而,传统的集中式架构思想无法精准提炼核心业务逻辑、识别业务领域边界,无法指导业务模型的建立。在这种大环境下,04年由著名建模专家 eric evans 提出的 “领域驱动设计” (Domain Driven Design)思想开始重新进入人们的视野,对业务领域识别、微服务拆分、软件架构起到了极大的指导作用。

DDD 思想包含很多概念,实体、值对象、聚合根、领域边界、限界上下文等等,单纯讲这些概念非常抽象,难以在具体项目中进行实践,造成了很多开发人员听说过 DDD,但对其仍是一知半解。

本文直接避开这些抽象概念,从具体案例出发,比较传统软件架构模式与 DDD 模式的区别,同时讲解 DDD 思想指导下的具体业务的实现方式,让各位看官对 DDD 思想有一个更加直观的感受。


MVC 时代
MVC,即 Model-View-Controller,是上世纪八十年代发明的一种软件设计模式,也是现今业界比较常见的软件架构模式。
Model 是核心业务模型,是具体业务逻辑、底层数据模型的总称;View 讲白了就是页面,本质是视图层,是数据模型的可视化界面;Controller 是 Model 和 View 之间的中间人,负责对数据模型进行处理,交由对应视图进行展示。
用户通过 View 对我们的系统下命令,Controller 接收到命令,交由对应的 Model 处理,然后将处理结果返回给对应的 View,展现给用户。举个例子,家里的空调就是一个符合 MVC 模式的系统,天气太热,我们通过遥控器(View)设置温度,空调内的芯片(Controller)收到遥控器发来的指令,启动电机(Model)进行降温。
在前后端不分家的时代,从数据到页面,整个业务逻辑闭环都整合在一个大单体中,MVC 模式非常适合这样的生产方式。Spring MVC 的 DispatcherServlet、ModelAndView 都是 MVC 思想的标准落地体现。
然而随着互联网的发展,业务逻辑愈发复杂,开发领域逐渐细分,前端逐渐从大单体中分离出来。随着 View 的离去,Controller 的地位越来越尴尬,而由于缺乏新设计思想的指导,Model 的逻辑越来越庞大且杂乱。
MVC 思想下典型的软件架构方式就是三层架构。

DAO 层依赖各种 ORM 框架对数据库进行各种原子化操作;Service 层则是对 DAO 层的接口进行整合,完成事务级(transactional)的业务操作;Controller 层的方法在 restful 思想的指导下不再负责页面路由,而是变成了一个个资源点,对 Service 层接口进行整合,返回符合前端渲染需求的数据结构。
Controller 依赖 Service,Service 依赖 DAO,不得越级依赖,层次有序。
在这种分层思想的指引下,开发人员对于一个业务的思考和实现方式自然而然是以数据模型为核心。当出现一个业务需求,开发人员首先是先设计数据库表,再设计DO类,再梳理对外接口,最后以数据模型为核心,实现整个业务逻辑。
而这种以数据模型为核心的分层架构本质是对业务逻辑的横向切割,这种一维切割方式本身就是粗鲁的,会带来很大问题:
1、首先这种分层方式根本没有体现出面向对象语言的优势,项目中大量充斥DO、DTO这样的贫血模型,无论是面向对象还是面向过程,业务实现方式大同小异;
2、底层数据模型一旦变更,必然导致上层代码的“大地震”,这种变更引发的开发、测试工作量是灾难级的;
3、应用对外信息交流的渠道将不仅仅局限于http请求和数据库,还有缓存、消息、mq、索引、RPC等等,一维的业务分割思想完全不知道该如何安放这么多渠道的业务逻辑;
4、随着业务逻辑愈发复杂,Service 层的代码逻辑将越来越冗长,大段大段的事务脚本代码,处理各种渠道来的数据,最终导致 Service 层无法维护,成为一座“屎山”。

DDD 
一、基本概念
DDD,即 domain-driven design,它本质上是一套指导拆分微服务的方法论。整套方法论的执行需要产品、领域专家、业务架构师等多方角色的参与,过程非常专业、复杂,以后再专门开一期讲述,本期只讨论 DDD 理论指导下的软件架构方式。
DDD 有一大堆的专有名词:“聚合根”、“上下文”、“实体”、“值对象”等等,但总结来讲,它就是以业务领域为核心,驱动整个开发流程,与传统的以数据模型为核心的软件架构模式有本质上的不同。
那什么叫 “ 以业务领域为核心 ” 呢?我们知道,我们的应用在运行时,会跟数据库、消息中心、调度中心、网关等多个角色交互。有些角色是给我们的应用 “ 下命令 ”,应用需要对这些 “ 命令 ” 做业务处理,返回响应;而有些角色是要接收我们应用发出的 “命令”,做业务处理。
无论是 “下命令” 的角色,还是 “接收命令” 的角色,对于我们的应用来说,都是等价的
这些角色 “下命令” 或 “接收命令” 的方式不断变化,比如我们要换个数据库存储数据、换个消息中间件收发消息,再比如某某业务方需要我们返回业务定制的数据结构,再比如某某业务方要修改自己的 rpc 接口的返回数据等等情况。
而我们应用的核心业务逻辑却不会发生太大的变动(一个公司的核心业务玩法老是变,容易把自己玩死)。无论外界这些角色如何变化,都不应该影响、污染到我们的核心业务逻辑。针对这些变化,我们只需要在我们的核心业务逻辑之外,设置一层适配器层,屏蔽这些污染就可以。
所以,我们的应用的软件架构的设计,也应该符合这个特征,这就叫做DDD 指导下的 “以业务领域为核心” 的软件架构思想。

二、洋葱架构
我们将上面的思想图像化,就成了下面的 “DDD 分层软件架构” 示意图,这个架构也形象地被称为 “洋葱架构”:
Domain 表示领域层,它包含了最核心的业务领域部分的逻辑,是整个应用规则和协议的制定者,对外提供领域模型内细粒度的领域服务;
Application 表示应用层,它依赖 Domain,对 Domain 提供的服务进行组合和编排,通过 API 网关向前台应用提供粗粒度的服务;
Domain 和 Application 共同构成了应用的业务核心
剩下的则是各种基础设施模块,他们是业务核心应对外界变化的 “适配器”,也叫 “防腐层”:
接收数据的模块(Input),例如 Message 消费者、http、Schedule等,本质上是应用对外允诺的服务,属于业务逻辑,这些模块一般需要依赖 Application 层,对其提供的服务进行组装,实现定制化需求。
对外输出数据的模块(Output),例如 DB、缓存、Message 生产者等,本质上是 Domain 的能力。这些能力需要提供给 Application 进行业务整合。所以,需要在 domain 里制定这些对外模块的规则和协议,然后在这些对外模块中实现这些协议接口。

代码重构实践
一、业务案例

现在,我们举一个简单的业务案例,看看在实际情况中,我们应该如何使用 DDD 去实现业务。业务需求描述如下:

店铺卖家通过申通物流发货后,在商家后台确认发货,确认发货接口逻辑如下:
1、调用申通快递接口,查询物流信息;
2、更新发货单状态以及物流相关信息;
3、更新对应订单的状态;
4、发送 RocketMQ 消息。
需求分析完毕,简单实现如下:
public class ConfirmDeliverGoodsServiceImpl implements ConfirmDeliverGoodsService {


@Autowired
private OrderDao orderDao;


@Autowired
private DeliveryOrderDao deliveryOrderDao;


@Autowired
private STOExpressService stoExpressService;


@Override
@Transcational
public Result<Boolean> confirmDeliverGoods(String deliveryOrderCode, String expressCode) {
        //参数校验
if (StringUtils.isEmpty(deliveryOrderCode) || StringUtils.isEmpty(expressCode) ) {
throw new IllegalArgumentException("deliveryOrderCode/expressCode 不能为空");
        }
        //业务校验
if (!(expressCode.startsWith("268")
|| expressCode.startsWith("368")
|| expressCode.startsWith("468")
|| expressCode.startsWith("968"))) {
throw new RuntimeException("只支持申通发货");
        }
        
        //从数据库读取发货单、订单数据
DeliveryOrderDO deliveryOrderDO = deliveryOrderDao.getByCode(deliveryOrderCode);
        OrderDO orderDO = orderDao.getByCode(deliveryOrderDO.getOrderCode());
        
        //调用申通快递开放接口,查询物流信息
        ExpressBaseInfoDTO expressBaseInfoDTO = stoExpressService.queryExpressBaseInfo(expressCode);
        //更新发货单信息
deliveryOrderDO.setStatus(DeliveryOrderStatusEnum.SHIPPED.getCode());
        deliveryOrderDO.setExpressCompany(expressBaseInfoDTO.getCompanyName());
        deliveryOrderDO.setExpressCode(expressCode);
        //更新订单信息
orderDO.setStatus(OrderStatusEnum.WAITING_RECEIVED.getCode());
orderDO.setShippedTime(new Date());
        orderDO.setExpressCode(expressCode);
        
        //数据持久化
deliveryOrderDao.update(deliveryOrderDO);
        orderDao.update(orderDO);
        
        //发送RocketMQ消息
        rocketMQProducer.send("SEND_GOODS_TOPIC"JSON.toJSONString(orderDO));
return Result.success(true);
}
}
上面是 MVC 中典型的 Service 层代码实现,整段代码包含了参数校验、三方接口调用、数据计算、数据持久化、发送消息等逻辑。我们在平时工作中,经常会遇见这种代码,在 Martin Fowler的 P of EAA 书中,这种常见的代码样式被叫做 Transaction Script(事务脚本)。
这种事务脚本的缺点显而易见:
1、没办法维护整体代码又臭又长,可读性非常差,时间一长,就算是作者本人也无法确定这段代码的业务逻辑。其次,大量对三方服务、基础服务的调用杂糅在各种事务脚本中,一旦这些底层逻辑的协议发生改变(比如三方服务的入参出参发生变化、基础服务的版本升级等),那将造成难以估量的影响;
2、没办法测试。多段逻辑罗列耦合在一起,要写一大堆测试用例,才能达到一定的测试覆盖率。而随着业务愈发复杂,所需要的测试用例将呈指数级增长。

二、DDD 改造
下面,我们按照 DDD 的思路来重新实现这段业务。
确定业务实体
实体,就是业务模型的基本组成单元,它们拥有唯一标志符,拥有完整业务生命周期,并且在生命周期的各个阶段都能保持自身数据业务一致性。我们通过上面的例子进行直观感受。
首先,我们对上面的业务需求进行分析,得出这段需求内的三个业务实体:发货单、订单、物流信息。
其中,发货单、订单两个实体有确认发货的能力,物流信息暂时没有看到有其他能力。
我们通过代码实现这三个实体类:
public class DeliveryOrder {
//发货单号
    private String code;
//发货单状态
    private DeliveryOrderStatusEnum status;
//快递费用(分)
    private Long expressFee;
    //关联订单
private String orderCode;
//物流单号
    private String expressCode;
}


public class Order {
//订单号码
    private String code;
//订单状态
    private OrderStatusEnum status;
//发货时间
    private Date shippedTime;
//物流单号
    private String expressCode;
}


public class ExpressBaseInfo {
//物流单号
    private String code;
//快递费用
    private Long expressFee;
//快递公司名
private String companyName;
}
这么一看,这实体类跟 DO 类不是一样么?其实不然,实体类与 DO 类有如下不同之处:
1、 DO 类的属性与数据库表字段一一映射,属性的数据类型都是 Long、String、Integer、Date这样的基本类型,与数据库字段类型一一映射。
而实体类只对业务负责,不对存储过程负责,所以其属性不一定要与数据库表字段一一映射。属性的数据类型也大都是包含业务语义的值对象(Value Object),并非是基本类型
所谓值对象,本质就是实体类的多个相关属性的业务组合。它与实体类一样是充血模型,包含一定的业务逻辑,但它本身是无状态的,没有唯一性标志,也没有完整的生命周期。例如:实体类内部有省(Province)、市(City)、区(District)这几个属性,这些属性就可以组合成一个 Address 类,并提供一些校验逻辑(省市区 Code 合理性校验)。
比如上面的三个实体类,都包含 code 作为自己的唯一性标识,但三个 code 都是 String 类型,语意性不强,在业务流程中极易混淆。
其次,物流单号 code 是申通物流按照一定规则生成的,都是以 “268”、“368”“468”“968” 打头,这种对物流单号 code 的业务校验经常会出现在各种业务流程中,我们需要对其进行统一封装。
代码如下:
//发货单号
public class DeliveryOrderCode {
private String code;


public DeliveryOrderCode(String code) {
if (code == null || "".equals(code.trim())) {
throw new IllegalArgumentException("发货单code不能为空");
}
this.code = code;
}


public String getCode() {
return code;
}
}
//订单号
public class OrderCode {
private String code;


public OrderCode(String code) {
if (code == null || "".equals(code.trim())) {
throw new IllegalArgumentException("订单code不能为空");
}
this.code = code;
}


public String getCode() {
return code;
}
}
//物流单号
public class ExpressCode {
private String code;


public ExpressCode(String code) {
if (code == null || "".equals(code.trim())) {
throw new IllegalArgumentException("物流单号code不能为空");
}
if (!(code.startsWith("268")
|| code.startsWith("368")
|| code.startsWith("468")
|| code.startsWith("968"))) {
throw new IllegalArgumentException("物流单号code不是申通快递");
}
this.code = code;
}


public String getCode() {
return code;
}
}
实体类的代码就可以进行改造:
public class DeliveryOrder {
//发货单号
    private DeliveryOrderCode code;
//发货单状态
    private DeliveryOrderStatusEnum status;
//快递费用(分)
    private Long expressFee;
    //关联订单
private OrderCode orderCode;
//物流单号
    private ExpressCode expressCode;
}


public class Order {
//订单号码
    private OrderCode code;
//订单状态
    private OrderStatusEnum status;
//发货时间
    private Date shippedTime;
    //物流单号
private ExpressCode expressCode;
}


public class ExpressBaseInfo {
//物流单号
    private ExpressCode code;
//快递费用
    private Long expressFee;
//快递公司名
private String companyName;
}
这样一来,这些不同含义的 code 在业务流转的各个环节都能保证满足自身的业务校验,保证了这些校验逻辑也不会散落在项目的各个角落。
在分析业务需求时,我们往往能一眼发现其中的实体,但隐含的值对象不容易被发现。实体往往是一个业务领域内的业务承载者,而值对象是所有领域共同承载、认可的业务概念,它所承载的业务是能够跨领域被表达出来的。
2、DO类是贫血模型,只包含字段,而实体类是充血模型,除了字段,它还包含很多业务方法,承载了核心的业务逻辑。
比如,在上述 “确认发货” 的逻辑中,我们在 Service 层修改了发货单、订单 DO 类的相关字段,表达了“确认发货”这个业务逻辑。其实,“确认发货”本质上是发货单、订单自身的能力,应该由 DeliveryOrder、Order 这两个实体类集中表达,而不是将这些逻辑散落在 Service 的各个角落。
所以,我们为 DeliveryOrder、Order 这两个实体类增加“确认发货”能力:
@Getter
@Setter
public class DeliveryOrder {
//发货单号
    private DeliveryOrderCode code;
//发货单状态
    private DeliveryOrderStatusEnum status;
//快递费用(分)
    private Long expressFee;
    //关联订单
private OrderCode orderCode;
//物流单号
private ExpressCode expressCode;


    public void confirmShipped(ExpressBaseInfo baseInfo) {
        if (baseInfo == null) {
throw new IllegalArgumentException("物流信息为空,无法却认发货");
}
setStatus(DeliveryOrderStatusEnum.SHIPPED);
        setExpressFee(baseInfo.getExpressFee());
        setExpressCode(baseInfo.getCode());
}
}
@Getter
@Setter
public class Order {
//订单号码
    private OrderCode code;
//订单状态
    private OrderStatusEnum status;
//发货时间
    private Date shippedTime;
    //物流单号
private ExpressCode expressCode;


    public void confirmShipped(ExpressBaseInfo baseInfo) {
        if (baseInfo == null) {
throw new IllegalArgumentException("物流信息为空,订单无法却认发货");
}
setStatus(OrderStatusEnum.WAITING_RECEIVED);
        setExpressCode(baseInfo.getCode());
setShippedTime(new Date());
}
}
实体类的每个属性的改变,应该都是由具体的业务逻辑引起的,是满足业务数据一致性的,外界不应该有权限随意修改实体类的属性,所以实体类不应该暴露 Setter 方法。
既然 Setter 方法是私有的,那么实体类也没有必要提供无参构造函数,只能提供全参构造函数,以保证实体类的实例在创建之初就应该完成所有属性的业务初始化。如果属性过多,不方便提供全参构造函数,则建议采用建造者模式去构造实体类。
下面我们通过 lombok 注解来改造实体类:
@Builder
@Getter
@Setter(AccessLevel.PRIVATE)
@NoArgsConstructor(access = AccessLevel.PRIVATE)
@AllArgsConstructor
public class DeliveryOrder {
//发货单号
private DeliveryOrderCode code;
//发货单状态
private DeliveryOrderStatusEnum status;
//快递费用(分)
private Long expressFee;
//关联订单
private OrderCode orderCode;
//物流单号
private ExpressCode expressCode;


    public void confirmShipped(ExpressBaseInfo baseInfo) {
        if (baseInfo == null) {
throw new IllegalArgumentException("物流信息为空,无法却认发货");
}
setStatus(DeliveryOrderStatusEnum.SHIPPED);
        setExpressFee(baseInfo.getExpressFee());
        setExpressCode(baseInfo.getCode());
}
}


@Builder
@Getter
@Setter(AccessLevel.PRIVATE)
@NoArgsConstructor(access = AccessLevel.PRIVATE)
@AllArgsConstructor
public class Order {
//订单号码
private OrderCode code;
//订单状态
private OrderStatusEnum status;
//发货时间
    private Date shippedTime;
//物流单号
private ExpressCode expressCode;


    public void confirmShipped(ExpressBaseInfo baseInfo) {
        if (baseInfo == null) {
throw new IllegalArgumentException("物流信息为空,订单无法却认发货");
}
setStatus(OrderStatusEnum.WAITING_RECEIVED);
        setExpressCode(baseInfo.getCode());
setShippedTime(new Date());
}
}
@Builder
@Getter
@Setter(AccessLevel.PRIVATE)
@NoArgsConstructor(access = AccessLevel.PRIVATE)
@AllArgsConstructor
public class ExpressBaseInfo {
//物流单号
private ExpressCode code;
//快递费用
private Long expressFee;
//快递公司名
private String companyName;
}

其次,我们通过业务发现,“确认发货” 这一逻辑,不是一个实体参与就能实现的,它需要发货单、以及发货单关联的订单这两个实体共同 “确认发货” 才能完成。

这种情况就需要创建领域服务(Domain Service),以组合多个实体的行为,实现完整的业务逻辑。我们专门创建一个 ConfirmShippedService:

public interface ConfirmShippedService {
    void confirmShipped(DeliveryOrder deliveryOrder, Order order, ExpressBaseInfo expressBaseInfo);
}


public class ConfirmShippedServiceImpl implements ConfirmShippedService {
@Override
    public void confirmShipped(DeliveryOrder deliveryOrder, Order order, ExpressBaseInfo expressBaseInfo) {
        deliveryOrder.confirmShipped(expressBaseInfo);
        order.confirmShipped(expressBaseInfo);
}
}

隔离数据存储层
实体类已经创建完毕,它即将活跃在业务流程当中,然而一个哲学问题诞生了,这些实体类从哪儿来,又要到哪儿去呢?
通常我们通过 DAO 层读取、存储 DO 类,DAO (Data Access Object)本质就是对 SQL 语句的包装,它直接操作数据库,与数据库强绑定。而 DAO 层代码直接暴露给 Service 层使用,使得业务逻辑耦合了这些 “数据库驱动代码”,造成数据模型污染业务逻辑的局面。
而在 DDD 的思想下,业务逻辑是核心,数据的读写不是业务,它只是业务逻辑运行过程中附带的能力,订单业务可以有数据读写功能,营销业务也可以有数据读写功能,任何业务都可以有数据读写功能。
所以,这种能力不应该对核心业务有任何影响,它对于核心业务来说是可插拔、可替换的,我们可以用 MySQL 做数据存储,也可以用 Redis 做数据存储,业务软件应该做到存储介质的随意更换
所以,我们需要在业务层和 DAO 层之间加一个防腐层 —— Repository 层,以隔离数据存储对核心业务的影响。
Repository 层负责实体类实例的生产与存储,同时我们还需要创建数据转换类,负责实体类与 DO 类之间的数据转换。具体代码如下:
public interface DeliveryOrderRepository {
DeliveryOrder find(DeliveryOrderCode code);
void save(DeliveryOrder order);
}
@Repository
public class DeliveryOrderRepositoryImpl implements DeliveryOrderRepository {
    @Autowired
    private DeliveryOrderMapper mapper;
@Override
public DeliveryOrder find(DeliveryOrderCode code) {
if (code == null) {
return null;
}


DeliveryOrderDO deliveryOrderDO = mapper.selectByPrimaryKey(code.getCode());
return DeliveryOrderConverter.toEntity(deliveryOrderDO);
}
@Override
public void save(DeliveryOrder deliveryOrder) {
DeliveryOrderDO deliveryOrderDO = DeliveryOrderConverter.toDO(deliveryOrder);
if (deliveryOrderDO.getCode() == null) {
mapper.insert(deliveryOrderDO);
} else {
mapper.update(deliveryOrderDO);
}
}
}


public class DeliveryOrderConverter {


public static DeliveryOrder toEntity(DeliveryOrderDO deliveryOrderDO) {
if (deliveryOrderDO == null) {
return null;
}
return DeliveryOrder.builder()
.code(new DeliveryOrderCode(deliveryOrderDO.getCode()))
.status(DeliveryOrderStatusEnum.getEnumByCode(deliveryOrderDO.getCode()))
.expressFee(deliveryOrderDO.getExpressFee())
.orderCode(new OrderCode(deliveryOrderDO.getOrderCode()))
.expressCode(new ExpressCode(deliveryOrderDO.getCode()))
.build();
}
public static DeliveryOrderDO toDO(DeliveryOrder deliveryOrder) {
if (deliveryOrder == null) {
return null;
}
DeliveryOrderDO deliveryOrderDO = new DeliveryOrderDO();
deliveryOrderDO.setCode(deliveryOrder.getCode().getCode());
deliveryOrderDO.setStatus(deliveryOrder.getStatus().getCode());
deliveryOrderDO.setExpressCode(deliveryOrder.getExpressCode().getCode());
deliveryOrderDO.setExpressFee(deliveryOrder.getExpressFee());
deliveryOrderDO.setOrderCode(deliveryOrder.getOrderCode().getCode());
return deliveryOrderDO;
}
}




public interface OrderRepository {
Order find(OrderCode code);
void save(Order order);
}
@Repository
public class OrderRepositoryImpl implements OrderRepository {
@Autowired
private OrderMapper mapper;


@Override
public Order find(OrderCode code) {
if (code == null) {
return null;
}
OrderDO orderDO = mapper.selectByPrimaryKey(code.getCode());
return OrderConverter.toEntity(orderDO);
}
@Override
public void save(Order order) {
OrderDO orderDO = OrderConverter.toDO(order);
if (orderDO.getCode() == null) {
mapper.insert(orderDO);
} else {
mapper.update(orderDO);
}
}
}
public class OrderConverter {


public static Order toEntity(OrderDO orderDO) {
if (orderDO == null) {
return null;
}
return Order.builder()
.code(new OrderCode(orderDO.getCode()))
                .status(OrderStatusEnum.getEnumByCode(orderDO.getStatus()))
.expressCode(new ExpressCode(orderDO.getExpressCode()))
.shippedTime(orderDO.getShippedTime())
.build();
}
public static OrderDO toDO(Order order) {
if (order == null) {
return null;
}
OrderDO orderDO = new OrderDO();
orderDO.setCode(order.getCode().getCode());
orderDO.setStatus(order.getStatus().getCode());
orderDO.setExpressCode(order.getExpressCode().getCode());
orderDO.setShippedTime(order.getShippedTime());
return orderDO;
}
}

隔离第三方服务
其实,三方服务可以看成是 DAO,数据库本质上也是属于一种三方服务,所以我们可以将三方服务返回的 DTO 类抽象成实体类,由此隔离三方服务对我们核心业务的影响。
上面我们已经创建了物流信息的实体类 ExpressBaseInfo,此外我们还需要创建转换类,负责实体类与三方服务生产的DTO类之间的转换。代码如下:
public interface ExpressBaseInfoService {
    ExpressBaseInfo queryExpressBaseInfo(ExpressCode expressCode);
}
@Component
public class ExpressBaseInfoServiceImpl implements ExpressBaseInfoService {
@Autowired
private STOExpressService stoExpressService;


@Override
    public ExpressBaseInfo queryExpressBaseInfo(ExpressCode expressCode) {
if (expressCode == null) {
return null;
}
        ExpressBaseInfoDTO expressBaseInfoDTO = stoExpressService.queryExpressBaseInfo(expressCode.getCode());
        return ExpressBaseInfoConverter.toEntity(expressBaseInfoDTO);
}
}


public class ExpressBaseInfoConverter {
    public static ExpressBaseInfo toEntity(ExpressBaseInfoDTO dto) {
if (dto == null) {
return null;
}
        return ExpressBaseInfo.builder()....build();
}
}

隔离中间件
中间件的隔离,也应该像隔离数据存储一样,避免核心业务逻辑受中间件使用的影响。
上述 “确认发货” 的业务逻辑的最后一步,是通过 RocketMQ 向外发送 “确认发货” 的消息,我们需要对 RocketMQ 独有的消息发送的逻辑加以包装、屏蔽,代码如下:
public abstract class AbstractMessage {
    public abstract String getTopic();
public String parseJsonStr() {
return JSON.toJSONString(this);
    }
public String getMessageId() {
return UUID.randomUUID().toString().replace("-", "");
}
}
public class ConfirmShippedMessage extends AbstractMessage {
    private String deliveryCode;
//快递费用(分)
    private Long expressFee;
//物流单号
private String expressCode;


public ConfirmShippedMessage(DeliveryOrder deliveryOrder) {
this.deliveryCode = deliveryOrder.getCode().getCode();
this.expressFee = deliveryOrder.getExpressFee();
this.expressCode = deliveryOrder.getExpressCode().getCode();
}
@Override
public String getTopic() {
return "SEND_GOODS_TOPIC";
}
}


public interface MessageProducer {
void send(AbstractMessage abstractMessage);
}
@Component
public class MessageProducerImpl implements MessageProducer, InitializingBean {


private DefaultMQProducer producer;


@Override
public void send(AbstractMessage abstractMessage) {
try {
Message message = new Message(
abstractMessage.getTopic(),
abstractMessage.parseJsonStr().getBytes(RemotingHelper.DEFAULT_CHARSET));
SendResult sendResult = producer.send(message);
} catch (MQClientException | RemotingException | MQBrokerException | InterruptedException | UnsupportedEncodingException e) {
e.printStackTrace();
}
}


@Override
public void afterPropertiesSet() throws Exception {
producer = new DefaultMQProducer("group_name");
producer.start();
}
}

最终结果
经过上述改造,我们再回过头来重构我们原先的 Service 层代码:
@Component
public class ConfirmDeliverGoodsServiceV2Impl implements ConfirmDeliverGoodsServiceV2 {


@Autowired
private DeliveryOrderRepository deliveryOrderRepository;
@Autowired
private OrderRepository orderRepository;


@Autowired
private ExpressBaseInfoService expressBaseInfoService;


@Autowired
private ConfirmShippedService confirmShippedService;


@Autowired
private MessageProducer messageProducer;


@Override
public Result<Boolean> confirmDeliverGoods(DeliveryOrderCode deliveryOrderCode, ExpressCode expressCode) {
DeliveryOrder deliveryOrder = deliveryOrderRepository.find(deliveryOrderCode);
Order order = orderRepository.find(deliveryOrder.getOrderCode());
ExpressBaseInfo expressBaseInfo = expressBaseInfoService.queryExpressBaseInfo(expressCode);


confirmShippedService.confirmShipped(deliveryOrder, order, expressBaseInfo);


deliveryOrderRepository.save(deliveryOrder);
orderRepository.save(order);


messageProducer.send(new ConfirmShippedMessage(deliveryOrder));
return Result.success(true);
}
}
这么一改,干净利落,可读性大大增强,减轻了后期的代码维护工作量。
其次,参数校验、三方接口调用、数据计算、数据持久化、发送消息等逻辑分散在各自的模块内,不再与主体业务流程强耦合,测试人员可以先针对各个逻辑模块进行测试,然后再对核心业务流程测试。这样不仅能保证很高的测试覆盖率,提升测试质量,还大大减轻了软件测试的工作量。
最后,核心业务逻辑与三方服务、中间件的隔离,使得软件基础设施的更新迭代更加灵活,代码鲁棒性更强。

三、应用结构
重构后的软件有以下几个模块:

各个模块的依赖结构图如下图所示:

Common 模块

Common 模块不依赖任何模块,里面定义的是全业务域都应该承认、遵守的业务规则、业务概念,比如上图中 vo 包内的三个 Code 值对象。
此外,Common 模块内还可以定义一些通用的业务 exception,以及一些常用工具类。

Domain模块

Domain 模块只依赖 Common 模块,它定义了实体类和领域服务,以此表达核心业务逻辑。
同时,它制定了三方服务、数据存储、中间件的隔离层接口协议,体现出 Domain 模块作为规则制定者的核心地位。

Application 模块

Application 模块只依赖 Domain 模块,对 Domain 模块提供的核心域能力以及防腐接口进行编排,实现完整的业务流程。
你可以把它当作 MVC 里的 Service 层,它对 web、rpc等各个端口的应用层负责,提供事务级别的业务服务。但与 Service 层不同的是,它是纯粹的流程编排,不掺杂参数业务校验、数据计算、数据结构映射等业务逻辑,不应该出现 for 循环、if/else 这样的逻辑分支。
Application 模块的代码应该达到文档的效果。如果我想了解一个业务流程具体做了哪些事情,直接看 Application 模块的对应接口实现,一行代码就是一个环节,从第一行代码到最后一行看下来,就像读说明文档一样,整个业务流程清晰地展现在眼前。

基础设施模块
基础设施模块包括消息(message)、远程调用(remote)、数据存储(persistence)、http请求处理(web)等各个模块,每个模块负责一个端口的信息交互,是我们应用对外 “沟通交流” 的能力。
基础设施模块只依赖 Application 模块,所以也就间接依赖了 Domain 模块,实现 Domain 模块制定的对应的接口协议。下图是 message 模块、persistence 模块的目录结构:

基础设施模块属于应用层代码,应该在这些模块的运行过程的关键节点打印日志。
其次,Application 模块和 Domain 模块不负责捕捉任何异常,只管抛出异常,异常应该由各个基础设施模块负责捕捉,自定义异常的处理逻辑。

starter 模块

应用的 Spring Boot 启动模块,依赖基础设施层的各个模块,带动各个端口启动。

应用采用 Spring 的 IOC 机制保证基础设施模块对于 Domain 模块的接口协议的实现能够灵活插拔、替换,使得我们的应用很好遵循了依赖倒置原则


client 模块

client 模块是我们应用的客户端模块,是要被其他应用所集成、使用的,一般微服务应用的 client 模块就是 RPC 服务的接口协议的 jar 包。

其他应用通过我们应用的 client 模块访问我们的服务。既然要使用我们的服务,就要遵循我们的规则、协议。

所以,client 模块依赖 common 模块,一起打包给其他业务方,让我们的通用业务概念、业务规则能够被兄弟业务方感知、遵循。所以,common 模块内的值对象应该实现 Serializable 接口,支持序列与反序列化。

同时,client 模块内一些接口的入参(读命令(Query)、写命令(Command))实例的构造应该包含一些参数校验逻辑,将这些逻辑抛给服务调用方去执行,保证服务调用方传过来的参数一定是合法的,杜绝一些荒唐的无效调用,节省带宽资源。


结语

至此,DDD 的大致思想和落地实践就表述完了。当然还有很多点没有涉及到,聚合根该怎么设计?领域事件的作用?CQRS 模式怎么构建?..... DDD 应用的优化方向、细节问题还有很多,需要我们在具体业务中不断思考、实践。欢迎关注本公众号后续文章,博主会在这里继续分享在 DDD 实践方面的心得。




本文的编写受到了以下文章的启发
阿里技术专家详解DDD系列 第二弹 - 应用架构
DDD实战-基于DDD的微服务拆分与设计:https://time.geekbang.org/column/intro/238



文章转载自代码凌凌漆,如果涉嫌侵权,请发送邮件至:contact@modb.pro进行举报,并提供相关证据,一经查实,墨天轮将立刻删除相关内容。

评论