本文首发于 vivo互联网技术 微信公众号
链接: https://mp.weixin.qq.com/s/gk-Hb84Dt7JqBRVkMqM7Eg
作者:张文博
领域驱动设计(Domain Driven Design,DDD)其实并非新理论,大家可以看看 Eric Evans 编著的《领域驱动设计》原稿首版是2003年,距今已十余年时间。与现在的分布式、微服务相比,绝对是即将步入中年的“老家伙”了。
直到近些年微服务理论被提出、被互联网行业广泛使用,人们似乎又重新发现了领域驱动设计的价值。所以看起来也确实是因为微服务,领域驱动设计才迎来了第二春。
不过我发现大家对DDD也存有一些误区,使其渐渐成了一门“高深的玄学”,随之又被大家束之高阁。我本人在过去两年多的时间里,研读过多本DDD相关的经典论著、也请教过一些资深DDDer,并在项目中实践过。
不过在初步学习、实践之后我又带着疑问与自己的思考重新读了一遍相关的著述理论。逐渐领悟到DDD作为一种思想,其实离我们很近。
我把自己的学习过程、思考编写成系列文章,与大家一起探讨学习,希望大家能够有所收获,当然其中不正确的地方也欢迎大家批评指正。
同时,在文章中我也会引用相关的论著或者一些我认为不错的案例素材,权当是我们对这些知识的详细诠释,在这里一并对这些DDD前辈的不倦探索表示感谢。
(DDD相关的经典论著)
一、关于DDD的误区
- DDD是解决大型复杂项目的,我们当前业务比较简单,不适合DDD。
- DDD要有一个完整的、符合DDD原则的代码结构,这可能增加代码的复杂度,有可能导致项目进度失控。
- DDD是一种框架,应该包含聚合根、实体、领域事件、仓储定义、限界上下文等一切DDD所倡导的元素;否则你就不是DDDer。
- DDD需要大家严格遵循各自模块的边界,且存在着过多因为解耦带来的看似冗余没用的代码,会降低编码效率,造成“类膨胀”。
二、DDD离我们很近
DDD是什么?众里寻她千百度,蓦然回首,“DDD是一种可以借鉴的思想,而非严格遵循的方法论”。
1、领域驱动设计中的领域模型
当我们面向业务开发的过程中,应该首先思考领域模型而不是如何建表。
我听过太多业务开发的声音,“面试造航母、工作拧螺丝”,日常工作就是建表写增删改查。为什么会有这样的认知,其根源在于表驱动设计思想而非领域驱动设计。
前者只能增加数据库的表数量,而后者才会形成长期的、具有业务意义的模型,这样的系统生命力才更加长久。我们也才能用工程的方法来编码,从编码转身为业务域的开发专家。
有很多关于领域驱动设计的论述中都并未明确我们如何得到“领域”,只有合理的领域模型才能有效驱动设计开发。所以建好领域模型是关键,对于领域模型的思考与技术框架升级同样重要。我曾经在互联网部门分享过如何进行领域建模,也欢迎大家与我交流沟通,有兴趣的读者也可以重点阅读一下《UML和模式应用》相关章节。
2、架构与解耦
在讨论DDD之前我们先来讨论一下“解耦”,这个词是我们在日常编码时候经常提及的词语。一个具有工匠精神的程序员一定会在代码审查阶段对一些巨无霸函数或者类进行拆分,使各部分的功能更加聚焦、降低耦合。
另一方面,在架构方面我们也会重视“解耦”,因为一个模块之间随意耦合的系统将是所有人的噩梦之源。因此,除了整洁的代码我们还需要关注整洁的架构。
架构的三要素:职责明确的模块或者组件、组件间明确的关联关系、约束和指导原则。内聚的组件一定有明确的边界,而这个明确的边界必然作为相关的约束指导今后的发展。
3、从分层架构到六边形架构
3.1 分层架构
分层架构是运用最为广泛的架构模式,几乎每个软件系统都需要通过层来隔离不同的关注点,以此应对不同需求的变化,使得这种变化可以独立进行;各个层、甚至同一层中的各个组件都会以不同速率发生变化。
这里所谓的“以不同速率发生变化”,其实就是引起变化的原因各有不同,这正好是单一职责原则(Single-Responsibility Principle,SRP)的体现。即“一个类应该只有一个引起它变化的原因”,换言之,如果有两个引起类变化的原因,就需要分离。
单一职责原则可以理解为架构原则,这时要考虑的就不是类,而是层次。例如网络七层协议是一个定义的非常好的、经典的分层架构,简单、易于学习理解,最终被广泛使用进而大大推动了网络通信的发展。
通常情况下,我们会把软件系统分为这几个层:UI界面(或者接入层)、应用独有的业务逻辑、领域普适的业务逻辑、数据库等。
接下来,还有什么不同原因的变更呢?答案正是这些业务逻辑本身!在每一层内部,不同的业务场景发生变化的原因、频次也都不同,不同的场景我们分别定义为业务用例。由此,我们可以总结出一个模式:在将系统水平切分成多个分层的同时,按用例将其切分成多个垂直切片。这样做的好处就是对单个用例的修改并不会影响其他用例。
如果我们同时对支持这些用例的UI和数据库也进行了分组,那么每个用例使用各自的UI表现与数据库,这样就做到了自上而下的解耦。另一方面,有层次就有依赖。在OSI协议中,上层透明的依赖下层。但是在软件架构中,我们更强调“依赖抽象”。即组件A依赖B的功能,我们的做法是在A中定义其需要用到的接口,由B去实现对应接口能力,这样就做到了可插拔,将来我们可以把B替换为同样实现了接口能力的组件C而对系统不会造成影响。
3.2 整洁架构
分层架构中给人的感觉是每一层都同样重要,但如果我们把关注的重点放在领域层,同时把依赖关系按照业务由重到轻形成一个以领域层为中心的环,即演变为一种整洁的架构风格。这里不是说其他层不重要,仅仅是为了凸显承载了业务核心的领域能力。
整洁架构最主要原则是依赖原则,它定义了各层的依赖关系,越往里,依赖越低,代码级别越高。外圆代码依赖只能指向内圆,内圆不知道外圆的任何事情。一般来说,外圆的声明(包括方法、类、变量)不能被内圆引用。同样的,外圆使用的数据格式也不能被内圆使用。
整洁架构各层主要职能如下:
Entities:实现领域内核心业务逻辑,它封装了企业级的业务规则。一个 Entity 可以是一个带方法的对象,也可以是一个数据结构和方法集合。一般我们建议创建充血模型。
Use Cases:实现与用户操作相关的服务组合与编排,它包含了应用特有的业务规则,封装和实现了系统的所有用例。
Interface Adapters:它把适用于 Use Cases 和 entities 的数据转换为适用于外部服务的格式,或把外部的数据格式转换为适用于 Use Casess 和 entities 的格式。
Frameworks and Drivers:这是实现所有前端业务细节的地方,UI,Tools,Frameworks 等以及数据库等基础设施。
3.3 六边形架构
我们把整洁架构的外部依赖按照其输入输出功能、资源类型进行整合。将存储、中间件、与其他系统的集成、http调用分别暴露一个端口。则会演变成下面的架构图。
“Allow an application to equally be driven by users, programs, automated test or batch scripts, and to be developed and tested in isolation from its eventual run-time devices and databases.”“系统能平等地被用户、其他程序、自动化测试或脚本驱动,也可以独立于其最终的运行时设备和数据库进行开发和测试”这是六边形的精髓。
该架构由端口和适配器组成,所谓端口是应用的入口和出口,在许多语言中,它以接口的形式存在。例如以取消订单为例,“发送订单取消通知”可以被认为是一个出口端口,订单取消的业务逻辑决定了何时调用该端口,订单信息决定了端口的输入,而端口为上游的订单相关业务屏蔽了其实现细节。
而适配器分为两种,主适配器(别名Driving Adapter)代表用户如何使用应用,从技术上来说,它们接收用户输入,调用端口并返回输出。Rest API是目前最常见的应用使用方式,以取消订单为例,该适配器实现Rest API的Endpoint,并调用入口端口OrderService,当然service内部可能发送OrderCancelled事件。同一个端口可能被多种适配器调用,本场景的取消订单也可能会被实现消息协议的Driving Adapter调用以便异步取消订单。
次适配器(别名Driven Adapter)实现应用的出口端口,向外部工具执行操作,例如向MySQL执行SQL,存储订单;使用Elasticsearch的API搜索产品;使用邮件/短信发送订单取消通知。有别于传统的分层形象,形成一个六边形,因此也会称作六边形架构。
4、DDD是一种思想
我愚昧的认为,DDD即业务+解耦。大道至简、多么熟悉的场景,因为这就是我们在做的事情,只不过我们可能过于关注使用了什么技术框架、用了哪些中间件、写了哪些通用的class。
实际上DDD如同辩证唯物主义思想一样,哪怕我们在软件项目的某一个环节用到了,只要这个思想为我们解决了实际问题就够了。我们没有必要为了DDD而去DDD,我们一定是从问题中来再回到问题中去。
三、DDD有什么用
借助DDD可以改变开发者对业务领域的思考方式,要求开发者花费大量的时间和精力来仔细思考业务领域,研究概念和术语,并且和领域专家交流以发现,捕捉和改进通用语言,甚至发现模型乃至系统架构层面的不合理之处。当然有可能你的团队中并没有相关业务的专家,那么此时你自己必须成为业务专家。
通常来说我们可以将DDD的业务价值总结为以下几点:
你获得了一个非常有用的领域模型;
你的业务得到了更准确的定义和理解;
领域专家可以为软件设计做出贡献;
更好的用户体验;
清晰的模型边界;
更好的企业架构;
敏捷、迭代式和持续建模;
使用战略和战术新工具;
四、如何DDD
通过前面的论述,你脑海里面一定闪烁几个词语“领域模型”“解耦”“依赖抽象”“边界”。这些通用的分析方法一定是放之四海而皆有效的。所以我认为当你按照这几个原则进行思考的时候就已经在DDD的路上向前迈进了一步,接下来我们结合界限上下文、Repository这两个最容易被大家所忽略的地方来进一步阐述。
在这些步骤都做完以后,你再决定接下来如何去编码开发。不过我敢肯定,你在这个过程中已经得到了很多高业务价值的东西。
接下来如何去实现,你可以根据实际情况。我觉得战略DDD比战术DDD更重要,我想这就是DDD作为一种思想的神奇所在。如同金庸笔下的少林绝学易筋经一样,一套并无明确招式的内功心法却能打遍武林。
1、界限上下文
领域中还同时存在问题空间(problem space)和解决方案空间(solution space)。在问题空间中,我们思考的是业务所面临的挑战,而在解决方案空间中,我们思考如何实现软件以解决这些业务挑战。
问题空间是领域的一部分,对问题空间的开发将产生一个新的核心域。对问题空间的评估应该同时考虑已有子域和额外所需子域。因此,问题空间是核心域和其他子域的组合。问题空间中的子域通常随着项目的不同而不同,他们各自关注于当前的业务问题,这使得子域对于问题空间的评估非常有用。子域允许我们快速地浏览领域中的各个方面,这些方面对于解决特定的问题是必要的。
解决方案空间包含一个或多个界限上下文,即一组特定的软件模型。这是因为界限上下文是一个特定的解决方案,用以解决问题。
通常,我们希望将子域一对一地对应到限界上下文。这种做法显式地将领域模型分离到不同的业务板块中,并将问题空间和解决方案空间融合在一起。
但是在实践中,这种做法并不总是可能的,想像一下,谁没有维护过“毛线团”系统,现在我们就要借助界限上下文来安全的、合理的、快速的理顺这堆交织不清的关系。
很多书籍或者文章讲解DDD,总是说突出应该怎么构建代码包结构,使用什么技术框架。我认为这是不完全适用的,所以我会花较多时间来阐述一下如何借助界限上下文来理顺这堆“毛线团”。
我直接使用了《实现领域驱动设计》的相关章节的配图,权当是我对这个图的注释吧。
遗留的电子商务系统是个典型的“大线团”,我们按照经验将其在逻辑上拆解为:产品目录子域、订单子域、fa票子域,当然你也可以拆解出更多的子域,甚至将产品目录子域继续向下分解为类目子域、商品子域(虚线是逻辑子域)。另外还有一个专门用于库存管理的库存系统、以及用于销售预测的预测系统。
由于历史原因电商系统里面也存在物流相关的业务逻辑,同时物流又不可避免的作用于库存逻辑之上。而往往最难以把握的就是这部分相交的地方,这才是实际的项目场景,我们通常做法是将其归并为一个新的履约系统,作为一个支撑子域去辅助主要的电商系统。
当然,随着业务不断发展,我们的履约模式(比如支持同城当日达、商家仓储发货、电商集货仓发货、退货等等)、库存类型(调拨库存、越库操作、临期库存、残次库存等等)越来越复杂,我们考虑将其再向下分解为履约系统2.0、库存系统2.0。
核心就是我们可以在概念上使用多个子域来分解较大的界限上下文,也可以将多个分散的界限上下文包含在同一个新的子域当中,最终做到“子域和界限上下文一一对应”。我个人觉得,这个过程是最考验内功心法的地方。
上面我们已经说了会拆解出来新的子域,目的使“整洁干净”的界限上下文能够一对一的解决这个子域对应的问题空间,但是随着拆解就必然导致“关联关系”。因为要解决问题空间,必须使用对应的子域,你可以把它拆解出去,但是它始终存在于依赖网中。
我们通用的做法是在相交的地方,定义接口。由支撑的界限上下文去实现,可以做到支撑上下文的插拔式切换。这里仍然是我们强调的“依赖抽象”“解耦”。
2、Repository
“对于每种需要进行全局访问的对象,我们都应该创建另一个对象来作为这些对象的提供方,就像是在内存中访问这些对象的集合一样。为这些对象创建一个全局接口以供客户端访问。为这些对象创建添加和删除方法……
此外,我们还应该提供能够按照某种指定条件来查询这些对象的方法……只为聚合创建资源库”引用自《领域驱动设计》。大家和我的疑问一样,Repository是什么?DAO与Repository什么区别?为什么需要Repository?
首先,Repository 是一个独立的层,介于领域层与数据映射层(数据访问层)之间。
它的存在让领域层感觉不到数据访问层的存在,它提供一个类似集合的接口提供给领域层进行领域对象的访问。Repository 是仓库管理员,领域层需要什么东西只需告诉仓库管理员,由仓库管理员把东西拿给它,并不需要知道东西实际放在哪。其核心还是“解耦”,所以我们应该明确领域层只应该使用Repository获取对象。
接下来,看看DAO与Repository什么区别。
我的理解是这样,你可以将Repository当作 DAO 来看待,但是请注意一点,在设计Repository时,我们应该采用面向集合的方式,而不是面向数据访问的方式。这有助于你将自己的领域当作模型来看待,而不是 CRUD 操作;Repository是面向领域的,Repository定义的目的不是DB驱动的,Repository管理的数据的最小粒度是聚合根,这两点和DAO有很大不同。
通常我们建议把Repository定义为一个集合并且只提供类似集合的接口,比如Add,Remove,Get这种操作。一言以蔽之,我们要用集合的思想来操作聚合根,而不是传统的面向DB的CRUD方法。
最后来看看为什么需要Repository,我理解还是“解耦”。当我们把Repository想象成一个资源库,也不关心背后的持久化,这些也不是DDD该思考的东西,我们可以用mysql来实现,也可以用mongo,甚至redis。尤其是当我们在更换底层存储时候,领域层以及相关的服务并无任何影响。
以下是代码示例:
package zwb.ddd.repository.sample.domain;
import zwb.ddd.repository.sample.domain.model.BaseAggregateRoot;
import java.util.List;
/**
* BaseAggregateRoot领域模型的基类,BaseSpecification适用于较为复杂的查询场景。
* @author wenbo.zhang
* @date 2019-11-20
*/
public interface IRepository<T extends BaseAggregateRoot, Q extends BaseSpecification> {
T ofId(String id);
void add(T t);
void remove(String id);
List<T> querySpecification(Q q);
}
实现类:
package zwb.ddd.repository.sample.infrastructure;
import org.springframework.stereotype.Component;
import org.springframework.util.CollectionUtils;
import zwb.ddd.repository.sample.domain.IRepository;
import zwb.ddd.repository.sample.domain.BaseSpecification;
import zwb.ddd.repository.sample.domain.model.BaseAggregateRoot;
import zwb.ddd.repository.sample.domain.model.Customer;
import zwb.ddd.repository.sample.domain.model.CustomerSpecification;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* @author wenbo.zhang
* @date 2019-11-20
*/
@Component
public class CustomerRepository implements IRepository {
/**
* Repository其具体实现上层是无感知的,如果以后我们要切换为redis、mysql只需要修改这一层即可。
*/
Map<String, Customer> customerMap = new ConcurrentHashMap<>();
@Override
public Customer ofId(String id) {
return customerMap.get(id);
}
@Override
public void add(BaseAggregateRoot aggregateRoot) {
if (!(aggregateRoot instanceof Customer)) {
return;
}
Customer customer = (Customer) aggregateRoot;
customerMap.put(customer.getId(), customer);
}
@Override
public void remove(String id) {
customerMap.remove(id);
}
/**
* 我们在Specification里面定义更加复杂的查询条件
*
* @param specification 此处举例:基于id批量查询
* @return
*/
@Override
public List<Customer> querySpecification(BaseSpecification specification) {
List<Customer> customers = new ArrayList<>();
if (!(specification instanceof CustomerSpecification)) {
return customers;
}
if (CollectionUtils.isEmpty(specification.getIds())) {
return customers;
}
specification.getIds().forEach(id -> {
if (ofId(id) != null) {
customers.add(ofId(id));
}
});
return customers;
}
}
在日常项目中我们使用mybatis,所以在Repository中会使用mybatis的DAO来进行操作,下图是一个涉及到订购的复杂场景。
五、实践:某加盟业务的战略DDD重构
我们举一个加盟业务来描述一下界限上下文的划分,如下图业务流程应该比较清晰,但是涉及一些术语,因此先把重要的术语定义清楚、降低大家的认知差异。
通用术语:
进件:金融领域术语,进件是指把资料准备好后提交给贷款公司或银行的系统里面,叫做进件,进件后银行或贷款公司就会开始审核这个贷款了。
特约商户:金融术语,指银行、其他金融机构和财务公司发行的信用卡作为一种支付手段在流通中被接受并愿意为其提供服务的各种单位。简而言之,指与银行签定受理卡业务协议并同意用银行卡进行商务结算的商户。
上图的1.0版本,银行卡、进件、结算规则都跨越了问题域,因此我们对其抽象“支付”“特约商户”上下文,如下图。
这里有人会有疑问,“特约商户”“商家”什么关系,是否应该把“特约商户”归属为“商家域”,这只是字面意思的相似,“特约商户”是进件审批以后形成的支付相关的业务。当然“商家域”会使用到“特约商户”的能力。
因为进件逻辑复杂因此我们以进件为中心来画出了这样的上下文。另一方面从状态流转来说,“银行进件”是一个重要节点,代表平台、商家的一些权益即将生效,因此以此为核心也是有必要的。
随着店铺外卖团购业务的发展,我们需要一个领域能力更丰富的履约安装域,能够进行社区配送、售后维修等。不可避免地将与订单、fa票、库存、售后等业务都有关系,因此以订单为中心构建了下面的上下文。
六、结语
考虑到篇幅以及内容繁多,领域层相关的内容会在后面的文章中继续讲解。
本文主要讲述了战略层面的DDD原则,相对来说较为抽象,但这是最考验内功、最不可忽视的环节。
再次强调一点,实践DDD绝不是参照一套网上的代码结构,依葫芦画瓢去重写自己的系统,这一定是失败的。建议大家按照本文所讲述的原则、方法去思考自己的系统,当你领悟其精髓以后一定能够“笑傲代码”,掌握解决软件核心复杂性的内功心法。
更多内容敬请关注 vivo 互联网技术 微信公众号
注:转载文章请先与微信号:labs2020 联系。