可落地的DDD代码实践
前言
网上那么多DDD的文章,但代码工程却没有一个比较好的例子,本文将手把手跟你一起写DDD代码,学习DDD思想与代码相结合带来的好处。
一、从六边形架构谈起
六边形架构也称端口与适配器。对于每种外界类型,都有一个适配器与之相对应。外界通过应用层API与内部进行交互。
六边形架构提倡用一种新的视角来看待整个系统。该架构中存在两个区域,分别是“外部区域”和“内部区域”。在外部区域中,不同的客户均可以提交输入;而内部的系统则用于获取持久化数据,并对程序输出进行存储(比如数据库),或者在中途将输出转发到另外的地方(比如消息,转发到消息队列)。
每种类型的客户都有自己的适配器,该适配器用于将客户输入转化为程序内部API所理解的输入。六边形每条不同的边代表了不同种类型的端口,端口要么处理输入,要么处理输出。图中有3个客户请求均抵达相同的输入端口(适配器A、B和C),另一个客户请求使用了适配器D。可能前3个请求使用了HTTP协议(浏览器、REST和SOAP等),而后一个请求使用了MSMQ的协议。端口并没有明确的定义,它是一个非常灵活的概念。无论采用哪种方式对端口进行划分,当客户请求到达时,都应该有相应的适配器对输入进行转化,然后适配器将调用应用程序的某个操作或者向应用程序发送一个事件,控制权由此交给内部区域。
二、依赖倒置
依赖倒置的原则(DIP)由Robert C. Martin提出,核心的定义是:
高层模块不应该依赖于底层模块,两者都应该依赖于抽象
抽象不应该依赖于实现细节,实现细节应该依赖于接口
按照DIP的原则,领域层就可以不再依赖于基础设施层,基础设施层通过注入持久化的实现就完成了对领域层的解耦,采用依赖注入原则的新分层架构模型就变成如下所示:
采用了依赖注入方式后,其实可以发现事实上已经没有分层概念了。无论高层还是底层,实际只依赖于抽象,整个分层好像被推平了。
三、DDD 代码分层
整体代码结构
- com.${company}.${system}.${appname}
|- ui(用户接口层)
|service
|- impl
|- web
|- controller
|- filter
|- application(应用层)
|- service
|- impl
|- command
|- query
|- dto
|- mq
|- domain(领域层)
|- service
|- facade
|- model
|- event
|- repository
|- infrastructure(基础设施层)
|- dal
|-dos
|-dao
|- mapper
|- factory
3.1 用户接口层
用户接口层作为对外的门户,将网络协议与业务逻辑解耦。可以包含鉴权、Session管理、限流、异常处理、日志等功能。
返回值一般使用{"code":0,"msg":"success","data":{}}
的格式进行返回。
一般会封装一些公共的Response
对象,参考如下:
public class Response implements Serializable {
private boolean success;
private String code;
private String msg;
private Map<String, Object> errData;
}
public class SingleResponse<T> extends Response {
private T data;
}
public class ListResponse<T> extends Response {
private int count = 0;
private int pageSize = 20;
private int pageNo = 1;
private List<T> data;
}
用户接口层的接口,无需与应用接口保持一一对应,应该保证不同的场景使用不同的接口,保证后续业务的兼容性与可维护性。
3.2 应用层
应用层连接用户接口层和领域层,主要协调领域层,面向用例和业务流程,协调多个聚合完成服务的组合和编排,在这一层不实现任何业务逻辑,只是很薄的一层。
应用层的核心类:
- ApplicationService应用服务:最核心的类,负责业务流程的编排,但本身不负责任何业务逻辑。有时会简写为“AppService”。一个ApplicationService类是一个完整的业务流程,其中每个方法负责处理一个Use Case,比如订单的各种用例(下单、支付成功、发货、收货、查询)。
- DTO Assembler:负责将内部领域模型转化为可对外的DTO。
- 返回的DTO:作为ApplicationService的出参。
- Command指令:指调用方明确想让系统操作的指令,其预期是对一个系统有影响,也就是写操作。通常来讲指令需要有一个明确的返回值(如同步的操作结果,或异步的指令已经被接受)。
- Query查询:指调用方明确想查询的东西,包括查询参数、过滤、分页等条件,其预期是对一个系统的数据完全不影响的,也就是只读操作。
- Event事件:指一件已经发生过的既有事实,需要系统根据这个事实作出改变或者响应的,通常事件处理都会有一定的写操作。事件处理器不会有返回值。这里需要注意一下的是,Application层的Event概念和Domain层的DomainEvent是类似的概念,但不一定是同一回事,这里的Event更多是外部一种通知机制而已。
ApplicationService的接口入参只能是一个Command、Query或Event对象,CQE对象需要能代表当前方法的语意。这样的好处是提升了接口的稳定性、降低低级的重复,并且让接口入参更加语意化。
案例代码
public interface UserAppService {
UserDTO add(@Valid AddUserCommand cmd);
List<UserDTO> query(UserQuery query);
}
@Data
public class AddUserCommand {
private Integer age;
private String name;
...
}
@Data
public class OrderQuery {
private Long userId;
private int pageNo;
private int pageSize;
}
@Data
public class UserDTO {
private Long userId;
private Integer age;
private String name;
...
}
针对于不同语意的指令,要避免CQE对象的复用。反例:一个常见的场景是“Create创建”和“Update更新”,一般来说这两种类型的对象唯一的区别是一个ID,创建没有ID,而更新则有。所以经常能看见有的同学用同一个对象来作为两个方法的入参,唯一区别是ID是否赋值。这个是错误的用法,因为这两个操作的语意完全不一样,他们的校验条件可能也完全不一样,所以不应该复用同一个对象。正确的做法是产出两个对象。
3.2 1 Response vs Exception
Interface层的HTTP和RPC接口,返回值为Result,捕捉所有异常。
Application层的所有接口返回值为DTO,不负责处理异常。
3.2.2 CQE vs DTO
表面上看,两种对象都是简单的POJO对象,但其实是有很大区别的:
- CQE: 是ApplicationService的输入,有明确的“意图”,对象的内部需要保证其正确性。每一个CQE都是有明确“意图”的,所以要尽量避免CQE的复用,哪怕所有参数都一样,只要语义不同,就不应该复用。
- DTO: 只是数据容器,只是为了和外部交互,所以本身不包含任何逻辑,只是贫血对象。
因为CQE是有“意图”的,所以,理论上CQE的数量是无限的。但DTO作为数据容器,是和模型对应的,所以是有限的。
3.2.3 Anti-Corruption Layer防腐层
在ApplicationService中,经常会依赖外部服务,从代码层面对外部系统产生了依赖。比如创建一个用户时,可能依赖了帐号服务,这个时候我们引入防腐层。防腐层的类名一般用“Facade”。
ACL防腐层的实现方式:
- 对于依赖的外部对象,我们抽取出所需要的字段,生成一个内部所需的VO或DTO类。
- 构建一个新的Facade,在Facade中封装调用链路,将外部类转化为内部类。
- 针对外部系统调用,同样的用Facade方法封装外部调用链路。
3.3 领域层
领域层是领域模型的核心,主要实现领域模型的核心业务逻辑,体现领域模型的业务能力。领域层关注实现领域对象的充血模型和聚合本身的原子业务逻辑,至于用户操作和业务流程,则交给应用层去编排。这样设计可以保证领域模型不容易受外部需求变化的影响,保证领域模型的稳定。
领域层的核心类:
- 实体类(Entity):大多数DDD架构的核心都是实体类,实体类包含了一个领域里的状态、以及对状态的直接操作。Entity最重要的设计原则是保证实体的不变性(Invariants),也就是说要确保无论外部怎么操作,一个实体内部的属性都不能出现相互冲突,状态不一致的情况。
- 值对象(VO):通常是用来度量和描述事物。我们可以非常容易的对其进行创建,测试,使用,优化和维护,所以在建模时,我们尽量采用值对象来建模。
- 聚合根(Aggr):聚合是由业务和逻辑紧密关联的实体和值对象组合而成的。聚合是数据修改和持久化的基本单元。每个聚合都有一个根实体,叫做聚合根,外界只能通过聚合根跟聚合通信。聚合根的主要目的是为了避免由于复杂数据模型缺少统一的业务规则控制,而导致聚合、实体之间数据不一致的问题。
- 领域服务(DomainService):当某个操作不适合放在聚合和值对象上时,最好的方式便是使用领域服务了。可以使用领域服务的地方,过度使用领域服务将导致贫血领域模型。执行一个显著的业务操作过程;对领域对象进行转换;已多个领域对象作为输入进行计算,结果产生一个值对象。
- 仓储层接口(Repository):把我们要的数据当做一个集合放在仓储里面,想要的时候直接获取。仓储作为领域层和基础结构层的连接组件,使得领域层不必过多的关注存储细节。在设计时,将仓储接口放在领域层,而将仓储的具体实现放在基础结构层,领域层通过接口访问数据存储,而不必过多的关注仓储存储数据的细节,这样使得领域层将更多的关注点放在领域逻辑上面。
- 工厂(Factory):对于实体等对象构造比较麻烦的,可以借助工厂进行构造。
通常,对于实体、值对象、聚合根,我们不可以不加类后缀,这样更能体现领域对象本身的含义。
public class Order {
// OrderId是隐性的概念显性化,而不是直接使用一个String,String就只能表示一个值了
private OrderId orderId;
private BuyerId buyerId;
private OrderStatus status;
private Long amount;
private List<OrderItem> orderItems;
public static Order create(...) {
// 如果参数比较多,构造比较麻烦,可以迁移到 Factory
...
}
public void pay(...) {
}
public void deliver(...) {
}
...
}
public class OrderItem {
private Long goodsId;
private Integer count;
public static OrderItem create(Long goodsId, Integer count) {
...
}
}
// 领域服务一般无需接口定义
public class OrderDomainService {
@Resource
private OrderRepository orderRepository;
public Order create(Order order) {
...
orderRepository.create(order);
return order;
}
}
public interface OrderRepository {
void add(Order order);
Order getByOrderId(OrderId orderId);
}
3.4 基础设施层
主要负责技术细节处理,比如数据库CRUD、缓存、消息服务等。
public class OrderDO {
}
public class OrderItemDO {
}
public class OrderDao implements OrderRepository {
@Resource
private OrderMapper orderMapper;
@Resource
private OrderItemMapper orderItemMapper;
@Override
public void add(Order order) {
OrderDO orderDO = OrderFactory.build(order);
List<OrderItemDO> orderItemDOList = OrderFactory.build(order);
orderMapper.insert(orderDO);
orderItemMapper.batchInsert(orderItemDOList);
}
}
参考资料
- 《实现领域驱动设计》
- 殷浩详解DDD:如何避免写流水账代码
- 殷浩详解DDD:领域层设计规范
可落地的DDD代码实践的更多相关文章
- 可落地的DDD(5)-战术设计
摘要 本篇是DDD的战术篇,也就是关于领域事件.领域对象.聚合根.实体.值对象的讨论.也是DDD系列的完结篇. 这一部分在我们团队争论最多的,也有很多月经贴,比如对资源库的操作应该放在领域服务还是领域 ...
- 可落地的DDD(4)-如何利用DDD进行微服务的划分(2)
摘要 在前面一篇介绍了如何通过DDD的思想,来调整单体服务内的工程结构,为微服务的拆分做准备.同时介绍了我们在进行微服务拆分的时候踩过的一些坑. 这篇介绍下我们最终的方案,不一定对,欢迎留言讨论. 微 ...
- 如丝般顺滑:DDD再实践之类目树管理
在上次反思DDD实践之后,在类目树管理项目中再次实践DDD.从需求分析到建模和具体的落地,结合个人体会,都是干货.
- 可落地的DDD(7)-战术设计上的一些误区
背景 几年前我总结过DDD战术设计的一些落地经验可落地的DDD(5)-战术设计,和一次关于聚合根的激烈讨论最近两年有些新的落地体验,回过头来发现,当初对这些概念的理解还是没有深入,这篇文章重新阐述下. ...
- 可落地的DDD(3)-如何利用DDD进行微服务的划分
摘要 前面两篇介绍了DDD的目标管理.DDD的工程结构调整.这篇讨论微服务的划分.微服务是目前后端比较流行的架构体系了,那么如何做好一个微服务的划分?一个微服务的粒度应该是多大呢?这篇主要介绍如何结合 ...
- ReactiveCocoa代码实践之-更多思考
三.ReactiveCocoa代码实践之-更多思考 1. RACObserve()宏形参写法的区别 之前写代码考虑过 RACObserve(self.timeLabel , text) 和 RACOb ...
- ReactiveCocoa代码实践之-RAC网络请求重构
前言 RAC相比以往的开发模式主要有以下优点:提供了统一的消息传递机制:提供了多种奇妙且高效的信号操作方法:配合MVVM设计模式和RAC宏绑定减少多端依赖. RAC的理论知识非常深厚,包含有FRP,高 ...
- 深刻理解Python中的元类(metaclass)--代码实践
根据http://blog.jobbole.com/21351/所作的代码实践. 这篇讲得不错,但以我现在的水平,用到的机会是很少的啦... #coding=utf-8 class ObjectCre ...
- Java的BIO和NIO很难懂?用代码实践给你看,再不懂我转行!
本文原题“从实践角度重新理解BIO和NIO”,原文由Object分享,为了更好的内容表现力,收录时有改动. 1.引言 这段时间自己在看一些Java中BIO和NIO之类的东西,也看了很多博客,发现各种关 ...
随机推荐
- 大爽Python入门教程 0-1 安装python
大爽Python入门公开课教案 点击查看教程总目录 一 如何找到下载地址并下载 下面展示找到下载地址的方法步骤 嫌步骤太慢可直接跳到第4步, 查看详细下载地址 使用搜索引擎搜索python 打开搜索结 ...
- k8s网络模型与集群通信
在k8s中,我们的应用会以pod的形式被调度到各个node节点上,在设计集群如何处理容器之间的网络时是一个不小的挑战,今天我们会从pod(应用)通信来展开关于k8s网络的讨论. 小作文包含如下内容: ...
- NodeJS连接MongoDB和mongoose
1.MongoDB是一个基于分布式文件存储的数据库.由C++语言编写.旨在为WEB应用提供可扩展的高性能数据存储解决方案.是世界上目前用的最广泛的nosql数据库 2.noSql 翻译过来 not o ...
- Springboot 加载配置文件源码分析
Springboot 加载配置文件源码分析 本文的分析是基于springboot 2.2.0.RELEASE. 本篇文章的相关源码位置:https://github.com/wbo112/blogde ...
- 如何看待 SAE 在2014 年 3 月 24 日发生的的大面积宕机事故?
3 月 24 日晚间大约 23 点左右,新浪云 SAE 一处核心机柜掉电,导致 SAE 平台下大量应用无法正常访问,并在 10 小时后才陆续修复.这次事故暴露 SAE 的哪些缺陷?SAE 运维人员又是 ...
- [gym102511K]Traffic Blights
为了方便,对于集合$S$,称$k\equiv S(mod\ M)$当且仅当存在$x\in S$使得$k\equiv x(mod\ M)$ 枚举红绿灯,对每一个点即限制$k$对$g_{i}+r_{i}$ ...
- 31、下一个排列 | 算法(leetode,附思维导图 + 全部解法)300题
零 标题:算法(leetode,附思维导图 + 全部解法)300题之(31)下一个排列 一 题目描述 二 解法总览(思维导图) 三 全部解法 1 方案1 1)代码: // 方案1 "双指针法 ...
- CF1288
A 考虑\(x + 1 = \sqrt{d}\)时在有理域上有最优界. 那我在整数域上附近取三个点取min就行了. // code by fhq_treap #include<bits/stdc ...
- Atcoder Grand Contest 054 题解
那天晚上由于毕业晚会与同学吃饭喝酒没打 AGC,第二天稍微补了下题,目前补到了 E,显然 AGC 的 F 对于我来说都是不可做题就没补了(bushi A 简单题,不难发现如果我们通过三次及以上的操作将 ...
- 有限元边界 Dirichlet 条件处理
参考自百度文档,这里只考虑 Dirichlet 边界条件情况. 有限元法基本方法就是是构造线性方程组 \[\begin{equation} Au = f \end{equation}\] 进行求解.其 ...