编辑推荐: |
本文介绍了DDD领域驱动设计,从理论到实践掌握DDD分层架构设计,希望对您的学习有所帮助。
本文来自于微信公众号老板来一杯Java,由火龙果软件Linda编辑、推荐。 |
|
1、背景
1.1. 前言
小编先问大家一个问题,也算是高级工程师面试中常问的问题,怎么样才能设计出一个好的软件系统,或者说一个高质量的大型软件系统应该具有哪些特点?
欢迎大家在评论区积极留言探讨,或者关注【微信公众号】微信搜索【老板来一杯java】回复【进群】即可进入无广告交流群!回复【java】即可获取【java基础经典面试】一份!加群后即可获取【DDD领域驱动设计实战落地解惑】PDF一份!
DDD也是设计一个高质量软件系统中的一种解决方案。了解DDD之前,小编建议读者还是需要具备一定的设计模式的思想,不太了解设计模式的可以先参考小编的文章:
2.5万字详解23种设计模式:
https://blog.csdn.net/qq_41889508/article/details/105953114?spm=1001.2014.3001.5501
DDD这个思想呢,最早是Eric Evans(埃里克·埃文斯)在2003年《Domain-Driven
Design –Tackling Complexity in the Heart of Software》书中提出的一个概念,该书翻译过来就是领域驱动设计—软件核心复杂性应对之道,但是提出的时候微服务当时并没有流行,所以一直没有火起来,DDD最近开始流行的原因,主要是借着微服务的东风。
1.2. MVC模式 VS DDD模式
MVC三层开发模式大家应该都非常熟悉,现在公司开发基本都是这种模式。
MVC开发流程:
用户需求转化为产品需求
需求评审会pm讲解需求转化为研发需求
研发人员根据需求进行设计库表结构
编写dao层代码
编写service代码
编写controller代码
这一步步下来,是不是感觉非常的丝滑。比如产品提了一个需求,首先我们一般会想考虑设计几张表,怎么存储数据,然后建立dao层,service层,controller层来实现这个功能。但是严格来讲,mvc本质上是一种面向数据的设计,主要关注数据,自低向上的思想。虽然在开发速度上有一定优势,如果只追求开发速度,面向数据模型编程在短期之内可以搞定需求,但一味追求速度,如果你系统的业务变化快速,从长远来看随着时间的增长,系统堆了杂七杂八以后,MVC的短板就会日益明显。
1.2.1. MVC存在的问题:
1.新需求的开发会越来越难。
2.代码维护越来越难,一个类代码太多,这怎么看对吧,就是一堆屎山。
3.技术创新越来越难,代码没时间重构,越拖越烂。
4.测试越来越难,没办法单元测试,一个小需求又要回归测试,太累。
1.2.2. 使用DDD的意义:
单体架构局部业务膨胀可以拆分成微服务,微服务架构局部业务膨胀,又拆成什么呢?
DDD就是为了解决这些问题的存在,从一个软件系统的长期价值来看,就需要用DDD,虽然一开始从设计到开发需要成本,但是随着时间的增长,N年以后代码依然很整洁,利于扩展和维护,高度自治,高度内聚,边界领域划分的很清楚。当然了,针对于简单的系统用DDD反而用复杂了,杀鸡焉用宰牛刀!
MVC的开发模式:是数据驱动,自低向上的思想,关注数据。
DDD的开发模式:是领域驱动,自顶向下,关注业务活动。
1.3. 总结:
DDD 分层架构中的要素其实和三层架构类似,只是在 DDD 分层架构中,这些要素被重新归类,重新划分了层,确定了层与层之间的交互规则和职责边界。
MVC是一个短暂的快乐但不足以支撑漫长的生活,DDD是一个不要短暂的温存而是一世的陪伴,如果是你来抉择你会选择哪一个?
2、DDD领域驱动模型
2.1. 概念
1. DDD:
DDD(Domain Driven Design)领域驱动模型,是一种处理高度复杂领域的设计思想,不是一种架构,而是一种架构设计方法论,是一种设计模式。说白了就是把一个复杂的软件应用系统的其中各个部分进行一个很好的拆解和封装,以达到高内聚低耦合的这样一个效果。
说白了就是,DDD就是以高内聚低耦合为目的,对软件系统进行模块化的一种思想。
2. 战略设计:
指的是领域名词、动词分析、提取领域模型。官方解释,在某个领域,核心围绕上下文的设计,主要关注上下文的划分、上下文映射的设计,通用语言的设计。
说白了就是,在某个系统,核心围绕子系统的设计;主要关注,这些子系统的划分,子系统的交互方式,还有子系统的核心术语的定义。
3. 战术设计:
用领域模型指导设计及编码的实现。官方解释,核心关注上下文中的实体建模,定义值对象,实体等,更偏向开发细节。
说白了就是,上下文对应的就是某一个子系统,子系统里代码实现怎么设计,就是战术设计要解决的问题。核心关注某个子系统的代码实现,以面向对象的思维设计类的属性和方法,和设计类图没有什么区别,只是有一些规则而已。就是
指导我们划分类。
4. 问题空间:
问题空间属于需求分析阶段,重点是明确这个系统要解决什么问题,能够提供什么价值,也就是关注系统的What与Why。
问题空间将问题域提炼成更多可管理的子域,是真对于问题域而言的。问题空间
在研究和解决业务问题时,DDD 会按照一定的规则将业务领域进行细分,当领域细分到一定的程度后,DDD
会将问题范围限定在特定的边界内,在这个边界内建立领域模型,进而用代码实现该领域模型,解决相应的业务问题。简言之,DDD
的领域就是这个边界内要解决的业务问题域。
领域可以进一步划分为子领域。我们把划分出来的多个子领域称为子域,每个子域对应一个更小的问题域或更小的业务范围,每个子域又包含了核心子域,支撑子域,通用子域。
领域的核心思想就是将问题域逐级细分,来降低业务理解和系统实现的复杂度。通过领域细分,逐步缩小服务需要解决的问题域,构建合适的领域模型。
说白了就是,就是系统下面有多个子系统,就是分了一些类型,比如电商系统,订单就是核心领域,支付调用银行,支付宝什么的就是支撑子域,相当于我们俗称的下游,通用子域,就是一些鉴权,用户中心,每个系统都会用到,就设计成通用子域,关键就是讨论过程如何得出这些问题域,是战略设计要解决的。
5. 解决空间:
解决方案域属于系统设计阶段,针对识别出来的问题域,寻求合理的解决方案,也就是关注系统的How。在领域驱动设计中,核心领域(Core
Domain)与子领域(Sub Domain)属于问题域的范畴,限界上下文(Bounded Context)则属于解决方案域的范畴。
说白了就是,得出这些问题域之后,就基于这些问题域来求解,属于解决空间。相当于,知道了y=2x,知道了x是多少,然后求y的值。解决空间就是指,领域之间的关系是什么样子,每个领域中通用的术语
,具体在领域内怎么实现代码,进行领域建模就可以了。
从问题域到解决方案域,实际上就是从需求分析到设计的过程,也是我们逐步识别限界上下文的过程。
6. 事件风暴:
事件风暴的基本思想,就是将软件开发人员和领域专家聚集在一起,完成领域模型设计(领域分析和领域建模)。划分出微服务逻辑边界和物理边界,定义领域模型中的领域对象,指导微服务设计和开发。
领域分析,是根据需求划分出初步的领域和限界上下文,以及上下文之间的关系;然后分析每个上下文内部,抽取每个子域的领域概念,识别出哪些是实体,哪些是值对象;
领域建模,就是对实体、值对象进行关联和聚合,划分出聚合的范畴和聚合根;
DDD需要进行领域分析和领域建模,除了事件风暴之外实现的方法有,领域故事讲述,四色建模法,用例法等。
事件风暴是建立领域模型的主要方法,但是在 DDD 领域建模和系统建设过程中,有很多的参与者,包括领域专家、产品经理、项目经理、架构师、开发经理和测试经理等。对同样的领域知识,不同的参与角色可能会有不同的理解,那大家交流起来就会有障碍,怎么办呢?因此,在
DDD 中就出现了“通用语言”和“限界上下文”这两个重要的概念。
7. 通用语言:
DDD的主要参与者:领域专家+开发人员。领域专家擅长某个领域的知识,专注于交付的业务价值。而开发人员则注重于技术实现,总是想着类、接口、方法、设计模式、架构等。这也就导致了团队交流的困难性。因此找到双方的通用语言是解决该问题的有效途径。
通用语言定义上下文含义。在事件风暴过程中,通过团队交流达成共识的,能够简单、清晰、准确描述业务涵义和规则的语言就是通用语言。
通用语言包含术语和用例场景,并且能够直接反映在代码中。通用语言中的名词可以给领域对象命名,如商品、订单等,对应实体对象;而动词则表示一个动作或事件,如商品已下单、订单已付款等,对应领域事件或者命令。
通用语言说白了就是,使用团队中大家都懂的概念,解决交流障碍的问题,使领域专家和开发人员能够协同合作,从而能够确保业务需求的正确表达。
8. 限界上下文:
官方解释:限界上下文主要用来封装通用语言和领域对象。
限界上下文可以拆分为两个词,限界和上下文。
限界:适用的对象一般是抽象事物,指不同事物的分界,指定某些事物的范围。
上下文:个人理解就是语境。语言都有它的语义环境,同样,通用语言也有它的上下文环境。为了避免同样的概念或语义在不同的上下文环境中产生歧义,DDD
在战略设计上提出了“限界上下文”这个概念,用来确定语义所在的领域边界。限界上下文就是用来定义领域边界,以确保每个上下文含义在它特定的边界内都具有唯一的含义,领域模型则存在于这个边界之内。这个边界定义了模型的适用范围,使团队所有成员能够明确地知道什么应该在模型中实现,什么不应该在模型中实现。
比如说,商品在不同的阶段有不同的术语,在销售阶段是商品,而在运输阶段则变成了货物。同样的一个东西,由于业务领域的不同,赋予了这些术语不同的涵义和职责边界,这个边界就可能会成为未来微服务设计的边界。那么,领域边界就是通过限界上下文来定义的。
限界上下文是微服务设计和拆分的主要依据。在领域模型中,如果不考虑技术异构、团队沟通等其它外部因素,一个限界上下文理论上就可以设计为一个微服务。
限界上下文是业务概念的边界,是业务问题最小粒度的划分。在某个业务领域中会包含多个限界上下文,我们通过找出这些确定的限界上下文对系统进行解耦,要求每一个限界上下文其内部必须是紧密组织的、职责明确的、具有较高的内聚性。说白了就是,上下文对应的就是某一个子系统,系统之间要划分好边界。
9. 上下文映射:
上下文之间交互方式就是上下文映射,相对于系统里面这就是RPC,http等交互方式。
10. 领域:
从广义上讲,领域具体指一种特定的范围或区域。在DDD中上下文的划分完的东西叫作领域,领域下面又划分了,核心领域,支撑子域,通用子域。
子域:在领域不断划分的过程中,领域会细分为不同的子域,子域可以根据自身重要性和功能属性划分为三类子域,它们分别是:核心域、通用域和支撑域。
核心域:它是业务成功的主要因素和公司的核心竞争力
通用域:没有太多个性化的诉求,同时被多个子域使用的通用功能子域是通用域
支撑域:有一种功能子域是必需的,但既不包含决定产品和公司核心竞争力的功能,也不包含通用功能的子域,就是支撑域。
说白了就是,系统下面有多个子系统,就是分了一些类型,比如电商系统,订单就是核心领域,支付调用银行,支付宝什么的就是支撑子域,相当于我们俗称的下游,通用子域,就是一些鉴权,用户中心,每个系统都会用到,就设计成通用子域,关键就是讨论过程如何得出这些域,是战略设计要解决的。
11. 领域模型:
领域模型是对领域内的概念类或现实世界中对象的可视化表示。它专注于分析问题领域本身,发掘重要的业务领域概念,并建立业务领域概念之间的关系。
是描述业务用例实现的对象模型。它是对业务角色和业务实体之间应该如何联系和协作以执行业务的一种抽象。
领域模型分为领域对象和领域服务两大类,领域对象用于存储状态,领域服务用于改变领域对象的状态。
特点:
领域模型是对具有某个边界的领域的一个抽象,反映了领域内用户业务需求的本质;领域模型是有边界的,只反应了我们在领域内所关注的部分;
领域模型只反映业务,和任何技术实现无关;领域模型不仅能反映领域中的一些实体概念,如货物,书本,应聘记录,地址,等;还能反映领域中的一些过程概念,如资金转账,等;
领域模型确保了我们的软件的业务逻辑都在一个模型中,都在一个地方;这样对提高软件的可维护性,业务可理解性以及可重用性方面都有很好的帮助;
领域模型能够帮助开发人员相对平滑地将领域知识转化为软件构造;
领域模型贯穿软件分析、设计,以及开发的整个过程;领域专家、设计人员、开发人员通过领域模型进行交流,彼此共享知识与信息;因为大家面向的都是同一个模型,所以可以防止需求走样,可以让软件设计开发人员做出来的软件真正满足需求;
要建立正确的领域模型并不简单,需要领域专家、设计、开发人员积极沟通共同努力,然后才能使大家对领域的认识不断深入,从而不断细化和完善领域模型;
为了让领域模型看的见,我们需要用一些方法来表示它;图是表达领域模型最常用的方式,但不是唯一的表达方式,代码或文字描述也能表达领域模型;
领域模型是整个软件的核心,是软件中最有价值和最具竞争力的部分;设计足够精良且符合业务需求的领域模型能够更快速的响应需求变化;
12. 领域事件:
聚合之间产生的业务协同使用领域事件的方式来完成,领域事件就是将上游聚合处理完成这个动作通过事件的方式进行抽象。
在DDD中有一个原则,一个业务用例对应一个事务,一个事务对应一个聚合根,也就是在一次事务中只能对一个聚合根操作。但在实际应用中,一个业务用例往往需要修改多个聚合根,而不同的聚合根可能在不同的限界上下文中,引入领域事件即不破坏DDD的一个事务只修改一个聚合根的原则,也能实现限界上下文之间的解耦。对于领域事件发布,在领域服务发布,在不使用领域服务的情况下,则由应用层在调用资源库持久化聚合根之后再发布领域事件。
一个事件可能当前限界上下文内也需要消费,即可能有多个限界上下文需要消费,一个事件对应多个消费者。
一个完整的领域事件 = 事件发布 + 事件存储 + 事件分发 + 事件处理。
事件发布:构建一个事件,需要唯一标识,然后发布;
事件存储:发布事件前需要存储,因为接收后的事建也会存储,可用于重试或对账等;就是每次执行一次具体的操作时,把行为记录下来,执行持久化。
事件分发:服务内的应用服务或者领域服务直接发布给订阅者,服务外需要借助消息中间件,比如Kafka,RabbitMQ等,支持同步或者异步。
事件处理:先将事件存储,然后再处理。
当然了,实际开发中事件存储和事件处理不是必须的。
因此实现方案:发布订阅模式,分为跨上下文(kafka,RocketMq)和上下文内(spring事件,Guava
Event Bus)的领域事件。
e.g. 用户注册后,发送短信和邮件,使用spring事件实现领域事件代码如下:
1.创建用户注册事件
/** * 用户注册事件 * @Author WDYin **/public class UserRegisterEvent extends ApplicationEvent { public UserRegisterEvent(Object source) { super(source); }}
|
2.用户监听事件
/** * 用户监听事件 * @Author WDYin **/@Componentpublic class UserListener {
@EventListener(UserRegisterEvent.class) public void userRegister(UserRegisterEvent event) { User user = (User) event.getSource(); System.out.println("用户注册。。。发送短信。。。" + user); System.out.println("用户注册。。。发送邮件。。。" + user); }
@EventListener(UserCancelEvent.class) public void userCancelEvent(UserCancelEvent event) { User user = (User) event.getSource(); System.out.println("用户注销。。。" + user); } }
|
3.发布用户注册事件
/** * 发布用户注册事件 * @Author : WDYin */@RunWith(value = SpringJUnit4ClassRunner.class )@SpringBootTest(classes = DemoApplication.class)public class MyClient {
@Autowired private ApplicationContext applicationContext;
@Test public void test() { User user = new User(); //发布事件 applicationContext.publishEvent(new UserRegisterEvent(user)); }}
|
13. 实体:
官方解释,实体和值对象会形成聚合,每个聚合一般是在一个事务中操作,一般都有持久性操作。聚合中,跟实体的生命周期决定了聚合整体的生命周期。说白了,就是对象之间的关联,只是规定了关联对象规则(必须是由实体和值对象组成的),操作聚合时类似hibernate的One-Many对象的操作,一起操作,不能单独操作。
e.g. 权限管理系统——用户实体,代码如下:
@NoArgsConstructor@Getterpublic class User extends Aggregate<Long, User> {
/** * 用户id-聚合根唯一标识 */ private UserId userId;
/** * 用户名 */ private String userName;
/** * 姓名 */ private String realName;
/** * 手机号 */ private String phone;
/** * 密码 */ private String password;
/** * 锁定结束时间 */ private Date lockEndTime;
/** * 登录失败次数 */ private Integer failNumber;
/** * 用户角色 */ private List<Role> roles;
/** * 部门 */ private Department department;
/** * 领导 */ private User leader;
/** * 下属 */ private List<User> subordinationList = new ArrayList<>();
/** * 用户状态 */ private UserStatus userStatus;
/** * 用户地址 */ private Address address;
public User(String userName, String phone, String password) {
saveUserName(userName); savePhone(phone); savePassword(password); }
/** * 保存用户名 * @param userName */ private void saveUserName(String userName) { if (StringUtils.isBlank(userName)){ Assert.throwException("用户名不能为空!"); }
this.userName = userName; }
/** * 保存电话 * @param phone */ private void savePhone(String phone) { if (StringUtils.isBlank(phone)){ Assert.throwException("电话不能为空!"); }
this.phone = phone; }
/** * 保存密码 * @param password */ private void savePassword(String password) { if (StringUtils.isBlank(password)){ Assert.throwException("密码不能为空!"); }
this.password = password; }
/** * 保存用户地址 * @param province * @param city * @param region */ public void saveAddress(String province,String city,String region){ this.address = new Address(province,city,region); }
/** * 保存用户角色 * @param roleList */ public void saveRole(List<Role> roleList) {
if (CollectionUtils.isEmpty(roles)){ Assert.throwException("角色不能为空!"); }
this.roles = roleList; }
/** * 保存领导 * @param leader */ public void saveLeader(User leader) { if (Objects.isNull(leader)){ Assert.throwException("leader不能为空!"); } this.leader = leader; }
/** * 增加下属 * @param user */ public void increaseSubordination(User user) {
if (null == user){ Assert.throwException("leader不能为空!"); }
this.subordinationList.add(user); }}
|
###14. 值对象:
官方解释,描述了领域中的一件东西,将不同的相关属性组合成了一个概念整体,当度量和描述改变时,可以用另外一个值对象予以替换,属性判等,固定不变。
说白了就是,不关心唯一值,具有校验逻辑,等值判断逻辑,只关心值的类。只有数据初始化操作和有限的不涉及修改数据的行为,基本不包含业务逻辑。比如下单的地址。
当你决定一个领域概念是否是一个值对象时,需考虑它是否拥有以下特征:
度量或者描述了领域中的一件东西
可作为不变量
将不同的相关的属性组合成一个概念整体(Conceptual Whole)
当度量和描述改变时,可以用另一个值对象予以替换
可以和其他值对象进行相等性比较
不会对协作对象造成副作用
当你只关心某个对象的属性时,该对象便可作为一个值对象。为其添加有意义的属性,并赋予它相应的行为。需要将值对象看成不变对象,不要给它任何身份标识,
还应尽量避免像实体对象一样的复杂性。
值对象本质上就是一个集。该集合有若干用于描述目的、具有整体概念和不可修改的属性。该集合存在的意义是在领域建模的过程中,值对象可保证属性归类的清晰和概念的完整性,避免属性零碎。
代码如下:
/** * 地址数据 * * @Author WDYin * @Date 2022/5/24 */@Getterpublic class Address extends ValueObject { /** * 省 */ private String province;
/** * 市 */ private String city;
/** * 区 */ private String region;
public Address(String province, String city, String region) { if (StringUtils.isBlank(province)){ Assert.throwException("province不能为空!"); } if (StringUtils.isBlank(city)){ Assert.throwException("city不能为空!"); } if (StringUtils.isBlank(region)){ Assert.throwException("region不能为空!");
} this.province = province; this.city = city; this.region = region; }}
|
14. 聚合:
官方解释,实体和值对象会形成聚合,每个聚合一般是在一个事务中操作,一般都有持久性操作。聚合中,跟实体的生命周期决定了聚合整体的生命周期。说白了,就是对象之间的关联,只是规定了关联对象规则(必须是由实体和值对象组成的),操作聚合时类似hibernate的One-Many对象的操作,一起操作,不能单独操作。
聚合的规范:
我们把一些关联性极强、生命周期一致的实体、值对象放到一个聚合里。
聚合是领域对象的显式分组,旨在支持领域模型的行为和不变性,同时充当一致性和事务性边界。
聚合在 DDD 分层架构里属于领域层,领域层包含了多个聚合,共同实现核心业务逻辑。跨多个实体的业务逻辑通过领域服务来实现,跨多个聚合的业务逻辑通过应用服务来实现。
比如有的业务场景需要同一个聚合的 A 和 B 两个实体来共同完成,我们就可以将这段业务逻辑用领域服务来实现;而有的业务逻辑需要聚合
C 和聚合 D 中的两个服务共同完成,这时你就可以用应用服务来组合这两个服务。
在DDD中,聚合也可以用来表示整体与部分的关系,但不再强调部分与整体的独立性。聚合是将相关联的领域对象进行显示分组,来表达整体的概念(也可以是单一的领域对象)。比如将表示订单与订单项的领域对象进行组合,来表达领域中订单这个整体概念。
15. 聚合根:
聚合根(Aggreate Root, AR)就是软件模型中那些最重要的以名词形式存在的领域对象。聚合根是主要的业务逻辑载体,DDD中所有的战术实现都围绕着聚合根展开。70%的场景下,一个聚合内都只有一个实体,那就是聚合根。
说白了就是:聚合的根实体,最具代表性的实体。比如订单和订单项聚合之后的聚合根就是订单。
聚合根的特征:
它作为实体本身,拥有实体的属性和业务行为,实现自身的业务逻辑。
它作为聚合的管理者,在聚合内部负责协调实体和值对象按照固定的业务规则协同完成共同的业务逻辑。
聚合根之间的引用通过ID完成。在聚合之间,它还是聚合对外的接口人,以聚合根 ID 关联的方式接受外部任务和请求,在上下文内实现聚合之间的业务协同。也就是说,聚合之间通过聚合根
ID 关联引用,如果需要访问其它聚合的实体,就要先访问聚合根,再导航到聚合内部实体,外部对象不能直接访问聚合内实体。
简单概括一下:
通过事件风暴(我理解就是头脑风暴,不过我们一般都是先通过个人理解,然后再和相关核心同学进行沟通),得到实体和值对象;
将这些实体和值对象聚合为“投保聚合”和“客户聚合”,其中“投保单”和“客户”是两者的聚合根;
找出与聚合根“投保单”和“客户”关联的所有紧密依赖的实体和值对象;
在聚合内根据聚合根、实体和值对象的依赖关系,画出对象的引用和依赖模型。
16. 贫血模型:
贫血模型具有一堆属性和set get方法,存在的问题就是通过pojo这个对象上看不出业务有哪些逻辑,一个pojo可能被多个模块调用,只能去上层各种各样的service来调用,这样以后当梳理这个实体有什么业务,只能一层一层去搜service,也就是贫血失忆症,不够面向对象。
代码如下:
public class User {
private Long id; private String userName;//用户名 private String password;//密码 private String gesture; //手势密码 private String phone; //手机号码 private String email; private int status; //账户状态 private Date lockEndTime; //锁定结束时间 private int failNumber; //登录失败次数
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getUserName() { return userName; }
public void setUserName(String userName) { this.userName = userName; }
public String getPassword() { return password; }
public void setPassword(String password) { this.password = password; }
public String getGesture() { return gesture; }
public void setGesture(String gesture) { this.gesture = gesture; }
public String getPhone() { return phone; }
public void setPhone(String phone) { this.phone = phone; }
public String getEmail() { return email; }
public void setEmail(String email) { this.email = email; }
public int getStatus() { return status; }
public void setStatus(int status) { this.status = status; }
public Date getLockEndTime() { return lockEndTime; }
public void setLockEndTime(Date lockEndTime) { this.lockEndTime = lockEndTime; }
public int getFailNumber() { return failNumber; }
public void setFailNumber(int failNumber) { this.failNumber = failNumber; }}
|
17. 充血模型:
比如如下user用户有改密码,改手机号,修改登录失败次数等操作,都内聚在这个user实体中,每个实体的业务都是清晰的,就是充血模型,充血模型的内存计算会多一些,内聚核心业务逻辑处理。
说白了就是,不只是有贫血模型中setter getter方法,还有其他的一些业务方法,这才是面向对象的本质,通过user实体就能看出有哪些业务存在。
代码如下:
@NoArgsConstructor@Getterpublic class User extends Aggregate<Long, User> {
/** * 用户名 */ private String userName;
/** * 姓名 */ private String realName;
/** * 手机号 */ private String phone;
/** * 密码 */ private String password;
/** * 锁定结束时间 */ private Date lockEndTime;
/** * 登录失败次数 */ private Integer failNumber;
/** * 用户角色 */ private List<Role> roles;
/** * 部门 */ private Department department;
/** * 用户状态 */ private UserStatus userStatus;
/** * 用户地址 */ private Address address;
public User(String userName, String phone, String password) {
saveUserName(userName); savePhone(phone); savePassword(password); }
/** * 保存用户名 * @param userName */ private void saveUserName(String userName) { if (StringUtils.isBlank(userName)){ Assert.throwException("用户名不能为空!"); }
this.userName = userName; }
/** * 保存电话 * @param phone */ private void savePhone(String phone) { if (StringUtils.isBlank(phone)){ Assert.throwException("电话不能为空!"); }
this.phone = phone; }
/** * 保存密码 * @param password */ private void savePassword(String password) { if (StringUtils.isBlank(password)){ Assert.throwException("密码不能为空!"); }
this.password = password; }
/** * 保存用户地址 * @param province * @param city * @param region */ public void saveAddress(String province,String city,String region){ this.address = new Address(province,city,region); }
/** * 保存用户角色 * @param roleList */ public void saveRole(List<Role> roleList) {
if (CollectionUtils.isEmpty(roles)){ Assert.throwException("角色不能为空!"); }
this.roles = roleList; }}
|
18. 领域服务:
聚合根与领域服务负责封装实现业务逻辑。领域服务负责对聚合根进行调度和封装,同时可以对外提供各种形式的服务,对于不能直接通过聚合根完成的业务操作就需要通过领域服务。
说白了就是,聚合根本身无法完全处理这个逻辑,例如支付这个步骤,订单聚合不可能支付,所以在订单聚合上架一层领域服务,在领域服务中实现支付逻辑,然后应用服务调用领域服务。
在以下几种情况时,我们可以使用领域服务:
对于不能直接通过聚合根完成的业务操作就需要通过领域服务。
在DDD中,每个实体只能操作自己实体的变化,不能改另一个实体的状态。跨实体的状态变化需要抽象出一个领域服务,不能直接修改实体的状态,只能调用实体的业务方法。
以多个领域对象作为输入参数进行计算,结果产生一个值对象。
执行一个显著的业务操作
对领域对象进行转换
遵守以下规范:
同限界上下文内的聚合之间的领域服务可直接调用
两个限界上下文的交互必须通过应用服务层抽离接口->适配层适配。
e.g. 用户升职,上级领导要变,上级领导的下属要变
代码如下:
/** * @Author WDYin * @Date 2022/5/15 **/@Servicepublic class UserDomainServiceImpl implements UserDomainService {
@Override public void promote(User user, User leader) {
//保存领导 user.saveLeader(leader);
//领导增加下属 leader.increaseSubordination(user); }}
|
19. 应用服务:
应用服务作为总体协调者,先通过资源库获取到聚合根,然后调用聚合根或者领域服务中的业务方法,最后再次调用资源库保存聚合根。
作用:
除了同步方法调用外,还可以发布或者订阅领域事件,权限校验、事务控制,一个事务对应一个聚合根。
应用层方法主要执行服务编排等轻量级逻辑,尤其针对跨多个领域的业务场景,效果明显。
参数校验,简单的crud,可直接调用仓库接口
/** * @Author WDYin **/@Servicepublic class UserApplicationServiceImpl implements UserApplicationService {
@Resource private DomainEventPublisher domainEventPublisher;
@Resource private UserRepository userRepository;
@Resource private UserAssembler userAssembler;
@Resource private UserDomainService userDomainService;
/** * 用户注册 * @param userAddCommand 注册信息 */ @Override public void register(UserAddCommand userAddCommand) {
//业务检查 userDomainService.check(userAddCommand);
//组装user领域模型 User user = userAssembler.commandToDo(userAddCommand);
//落库 userRepository.add(user);
//发送用户注册事件 domainEventPublisher.publish(new UserRegisterEvent(user, UserEventTypeEnum.REGISTER));
}
/** * 用户修改 * @param userUpdateCommand 修改信息 */ @Override public void update(UserUpdateCommand userUpdateCommand) { User user = userRepository.getById(userUpdateCommand.getId()); //发送用户注册事件 domainEventPublisher.publish(new UserRegisterEvent(user, UserEventTypeEnum.CREATED)); }
/** * 用户升职 * @param userId 用户id * @param leaderId 领导的id */ @Override public void promote(UserId userId, UserId leaderId) { //获取用户领域模型 User user = userRepository.getById(userId.getUserId()); //获取用户的新领导 User leader = userRepository.getById(leaderId.getUserId()); //领域服务升职方法 userDomainService.promote(user,leader); }}
|
20. 仓库:
负责提供聚合根或者持久化聚合根。仓库帮助我们持久化整个聚合的,存一个对象会把相关对象都存下来。从技术上讲,Repository和DAO所扮演的角色相似,不过DAO的设计初衷只是对数据库的一层很薄的封装,而Repository是更偏向于领域模型。另外,在所有的领域对象中,只有聚合根才“配得上”拥有Repository,而DAO没有这种约束。
代码如下:
/** * @Author WDYin * @Date 2022/5/15 **/@Repositorypublic class UserRepositoryImpl implements UserRepository {
@Resource private UserPersistence userPersistence;
@Resource private UserConverter userConverter;
@Override public void add(User user) { UserPO userPo = userConverter.doToPo(user); userPersistence.insert(userPo); user.setId(userPo.getId()); }
@Override public User getById(Long id) { UserPO userPO = userPersistence.selectByPrimaryKey(id); return userConverter.poToDo(userPO); }}
|
21. 工厂:
比如说创建一个实体,里面有五个值对象组成,每次创建的时候都得new一次,这里用工厂简化,工厂帮助我们创建聚合。这一方面可以享受到工厂模式本身的好处,另一方面,DDD中的Factory还具有将“聚合根的创建逻辑”显现出来的效果。Factory有两种实现方式:
1)直接在聚合根中实现Factory方法,常用于简单的创建过程
2)独立的Factory类,用于有一定复杂度的创建过程,或者创建逻辑不适合放在聚合根上
工厂也可以使用converter来代替
代码如下:
属性的拷贝使用BeanUtils或者mapstruct都可以。
@Componentpublic class UserConverter {
public User poToDo(UserPO userPO) { User user = new User(); BeanUtils.copyProperties(userPO,user); return user; }
public UserPO doToPo(User user) { UserPo userPO = new UserPo(); BeanUtils.copyProperties(userPO,user); return userPO; }}
|
22. 防腐层:
当某个功能模块需要依赖第三方系统提供的数据或者功能时,我们常用的策略就是直接使用外部系统的API、数据结构。这样存在的问题就是,因使用外部系统,而被外部系统的质量问题影响,从而“腐化”本身设计的问题。
因此我们的解决方案就是在两个系统之间加入一个中间层,隔离第三方系统的依赖,对第三方系统进行通讯转换和语义隔离,这个中间层,我们叫它防腐层。
说白了就是,两个系统之间加了中间层,中间层类似适配器模式,解决接口差异的对接,接口转换是单向的(即从调用方向被调用方进行接口转换);防腐层强调两个子系统语义解耦,接口转换是双向的。
防腐层作用:
使两方的系统解耦,隔离双方变更的影响,允许双方独立演进;
防腐层允许其它的外部系统能够在不改变现有系统的领域层的前提下,与该系统实现无缝集成,从而降低系统集成的开发工作量。
2.4. 落地方式
DDD分为战略设计和战术设计。
2.4.1. 战略设计:
参与人员:业务专家(领域专家),产品经理,技术专家(研发人员)
战略设计更偏向于软件架构的层面,官方解释,在某个领域,核心围绕上下文的设计。讲求的是领域和限界上下文(Bounded
Context,BC)的划分,以及各个限界上下文之间的上下游关系,还有通用语言的设计。也就是从业务视角出发,归好类,把边界划分好,明确界限上下文,可以用事件风暴来做。会得到通用语言,上下文,上下文之间的交互关系,边界,不同的域。
说白了就是,在某个系统,核心围绕子系统的设计;主要关注,这些子系统的划分,子系统的交互方式,还有子系统的核心术语的定义。当前如此火热的“在微服务中使用DDD”这个命题,究其最初的逻辑无外乎是“DDD中的限界上下文可以用于指导微服务中的服务划分”,
事实上,限界上下文依然是软件模块化的一种体现。
三步走:
第一步:需求分析,根据需求划分出初步的领域和限界上下文,以及上下文之间的关系;
第二步:领域分析,进一步分析每个上下文内部,抽取每个子域的领域概念,识别出哪些是实体,哪些是值对象;
第三步:领域建模,对实体、值对象进行关联和聚合,划分出聚合的范畴和聚合根;
2.4.2. 战术设计:
参与人员:技术专家(研发人员)
战术设计便更偏向于编码实现,官方解释,核心关注上下文中的实体建模,定义值对象,实体等,更偏向开发细节。用领域模型指导设计及编码的实现,以技术为主导。
说白了就是,上下文对应的就是某一个子系统,子系统里代码实现怎么设计,就是战术设计要解决的问题。核心关注某个子系统的代码实现,以面向对象的思维设计类的属性和方法,和设计类图没有什么区别,只是有一些规则而已,就是指导我们划分类。DDD战术设计的目的是使得业务能够从技术中分离并突显出来,让代码直接表达业务的本身。
其中包含了实体,聚合,聚合根,值对象,聚合之间的关系,仓库,工厂,防腐层,充血模型,领域服务,领域事件等概念。
战术层面可以说DDD是一种放大的设计模式。
三步走:
第一步:编写核心业务逻辑,由领域模型驱动软件设计,通过代码来表现该领域模型,在实体和领域服务中实现核心业务逻辑;
第二步:为聚合根设计仓储,并思考实体或值对象的创建方式;
第三步:在工程中实践领域模型,并在实践中检验模型的合理性,倒推模型中不足的地方并重构。
3、DDD架构
分层架构的一个重要原则是每层只能与位于其下方的层发生耦合,较低层绝不能直接访问较高层。分层架构可以简单分为两种:
严格分层架构:
某层只能与位于其直接下方的层发生耦合
松散分层架构:
则允许某层与它的任意下方层发生耦合
我们在实际运用过程中多使用的是松散分层架构。
3.1. 传统四层架构:
将领域模型和业务逻辑分离出来,并减少对基础设施、用户界面甚至应用层逻辑的依赖,因为它们不属业务逻辑。将一个夏杂的系统分为不同的层,每层都应该具有良好的内聚性,并且只依赖于比其自身更低的层。
传统分层架构的 基础设施层 位于底层,持久化和消息机制便位于该层。可将基础设施层中所有组件看作应用程序的低层服务,较高层与该层发生耦合以复用技术基础设施。即便如此,依然应避免核心的领域模型对象与基础设施层直接耦合。
3.2. 改良版四层架构
传统架构的缺陷:就是将基础设施层放在最底层存在缺点,比如此时领域层中的一些技术实现令人头疼:违背分层架构的基本原则,难以编写测试用例等。
因此通过Java设计六大原则中的依赖倒置原则实现各层对基础资源的解耦:也就是低层服务(如基础设施层)应依赖高层组件(比如用户界面层、应用层和领域层)所提供接口。高层定义好仓库的接口,基础设施层实现各层定义好的仓库接口。
依赖倒置原则:具体依赖于抽象,而不是抽象依赖于具体。
(1)用户接口层:
①一般包括用户接口、Web 服务、rpc请求,mq消息等外部输入均被视为外部输入的请求。对外暴露API,具体形式不限于RPC、Rest
API、消息等。
②一般都很薄,提供必要的参数校验和异常捕获流程。
③一般会提供VO或者DTO到Entity或者ValueObject的转换,用于前后端调用的适配,当然dto可以直接使用command和query,视情况而定。
④用户接口层很重要,在于前后端调用的适配。若你的微服务要面向很多应用或渠道提供服务,而每个渠道的入参出参都不一样,你不太可能开发出太多应用服务,这样Facade接口就起很好的作用了,包括DO和DTO对象的组装和转换等。
(2)应用层:
①应用层方法提供用例级别的能力透出,不处理业务逻辑,而只是调用领域层,对领域服务/聚合根方法调用的封装,负责领域的组合、编排、转发、转换和传递。
②应用服务作为总体协调者,先通过资源库获取到聚合根,然后调用聚合根中的业务方法,最后再次调用资源库保存聚合根。
③除了同步方法调用外,还可以发布或者订阅领域事件,权限校验、事务控制,一个事务对应一个聚合根。
④应用层方法主要执行服务编排等轻量级逻辑,尤其针对跨多个领域的业务场景,效果明显。
⑤参数校验,简单的crud,可直接调用仓库接口
⑥跨上下文(kafka,RocketMq)和上下文内(spring事件,Guava Event Bus)的领域事件
⑦仓储层接口
(3)领域层:
①包含了业务核心的领域模型:实体(聚合根+值对象),使用充血模型实现所有与之相关的业务功能,主要表达业务概念,业务状态信息以及业务规则。
②真正的业务逻辑都在领域层编写,聚合根负责封装实现业务逻辑,对应用层暴露领域级别的服务接口。
③聚合根不能直接操作其它聚合根,聚合根与聚合根之间只能通过聚合根ID引用;同限界上下文内的聚合之间的领域服务可直接调用;两个限界上下文的交互必须通过应用服务层抽离接口->适配层适配。
④跨实体的状态变化,使用领域服务,领域服务不能直接修改实体的状态,只能调用实体的业务方法
⑤在所有的领域对象中,只有聚合根才拥有Repository,因为Repository不同于DAO,它所扮演的角色只是向领域模型提供聚合根。
⑥防腐层接口
⑦仓储层接口
(4)基础设施层:
①为业务逻辑提供支撑能力,提供通用的技术能力,仓库写增删改查类似DAO。
② 防腐层实现(封装变化)用于业务检查和隔离第三方服务,内部try catch
③ 聚合根工厂负责创建聚合根,但并非必须的,可以将聚合根的创建写到聚合根下并改为静态方法。工厂组组装复杂对象,可能会调用第三方服务,仓库集成工厂Facotry/build应对复杂对象的封装,也可以使用converter。
④ 多于技术有关,如:DB交互的接口、Cache相关、MQ、工具类等
⑤抽象系统内第三方组件的交互,为上层提供技术层面的支持,与业务细节无关。
3.3. 整洁架构(洋葱架构)
在整洁架构里,同心圆代表应用软件的不同部分,从里到外依次是领域模型、领域服务、应用服务和最外围的容易变化的内容,比如用户界面和基础设施。
整洁架构最主要的原则是依赖原则,它定义了各层的依赖关系,越往里依赖越低,代码级别越高,越是核心能力。外圆代码依赖只能指向内圆,内圆不需要知道外圆的任何情况。
在洋葱架构中,各层的职能划分:
领域模型实现领域内核心业务逻辑,它封装了企业级的业务规则。领域模型的主体是实体,一个实体可以是一个带方法的对象,也可以是一个数据结构和方法集合。
领域服务实现涉及多个实体的复杂业务逻辑。应用服务实现与用户操作相关的服务组合与编排,它包含了应用特有的业务流程规则,封装和实现了系统所有用例。
最外层主要提供适配的能力,适配能力分为主动适配和被动适配。主动适配主要实现外部用户、网页、批处理和自动化测试等对内层业务逻辑访问适配。被动适配主要是实现核心业务逻辑对基础资源访问的适配,比如数据库、缓存、文件系统和消息中间件等。
红圈内的领域模型、领域服务和应用服务一起组成软件核心业务能力。
3.4. CQRS架构(命令查询隔离架构)
CQRS — Command Query Responsibility Segregation,故名思义是读写分离,就是将
command 与 query 分离的一种模式。
Command :命令则是对会引起数据发生变化操作的总称,即我们常说的新增,更新,删除这些操作,都是命令。
Query:查询则和字面意思一样,即不会对数据产生变化的操作,只是按照某些条件查找数据。
Command 与 Query 对应的数据源可以公用一种数据源,也可以是互相独立的,即更新操作在一个数据源,而查询操作在另一个数据源上。
CQRS三种模式
(1)共享模型/共享存储:读写公用一种领域模型,读写模型公用一种。
(2)分离模型/共享存储:读写分别用不同的领域模型,读操作使用读领域模型,写操作使用写领域模型。
(3)分离模式/分离存储:也叫做事件源 (Event source) CQRS,使用领域事件保证读写数据的一致性。也就是当
command 系统完成数据更新的操作后,会通过领域事件的方式通知 query 系统。query 系统在接受到事件之后更新自己的数据源。
CQRS(读写操作分别使用不同的数据库)
软件中的读模型和写模型是很不一样的,我们通常所讲的业务逻辑更多的时候是在写操作过程中需要关注的东西,而读操作更多关注的是如何向客户方返回恰当的数据展现。
因此在DDD的写操作中,我们需要严格地按照“应用服务 -> 聚合根 -> 资源库”的结构进行编码,而在读操作中,采用与写操作相同的结构有时不但得不到好处,反而使整个过程变得冗繁,还多了模型转换,影响效率。本来读操作就需要速度快,性能高。
因此本文CQRS实战中的读操作是基于数据模型,应用层提供一个单独的用于读的仓库,然后绕过聚合根和资源库,也就是绕过领域层,在应用层直接返回数据。而写操作是基于领域模型,通过应用服务->聚合根/领域服务->资源库的代码结构进行编码。
3.5. 六边形架构(端口适配器架构)
六边形架构的核心理念是:应用是通过端口与外部进行交互的
下图的红圈内的核心业务逻辑(应用程序和领域模型)与外部资源(包括 APP、Web 应用以及数据库资源等)完全隔离,仅通过适配器进行交互。它解决了业务逻辑与用户界面的代码交错问题,很好地实现了前后端分离。六边形架构各层的依赖关系与整洁架构一样,都是由外向内依赖。
六边形架构将系统分为内六边形和外六边形两层,这两层的职能划分如下:红圈内的六边形实现应用的核心业务逻辑;外六边形完成外部应用、驱动和基础资源等的交互和访问,对前端应用以
API 主动适配的方式提供服务,对基础资源以依赖倒置被动适配的方式实现资源访问。六边形架构的一个端口可能对应多个外部系统,不同的外部系统也可能会使用不同的适配器,由适配器负责协议转换。这就使得应用程序能够以一致的方式被用户、程序、自动化测试和批处理脚本使用。
3.6. 总结
这三种架构模型的设计思想微服务架构高内聚低耦合原则的完美体现,而它们身上闪耀的正是以领域模型为中心的设计思想,将核心业务逻辑与外部应用、基础资源进行隔离。
红色框内部主要实现核心业务逻辑,但核心业务逻辑也是有差异的,有的业务逻辑属于领域模型的能力,有的则属于面向用户的用例和流程编排能力。按照这种功能的差异,我们在这三种架构中划分了应用层和领域层,来承担不同的业务逻辑。
领域层实现面向领域模型,实现领域模型的核心业务逻辑,属于原子模型,它需要保持领域模型和业务逻辑的稳定,对外提供稳定的细粒度的领域服务,所以它处于架构的核心位置。
应用层实现面向用户操作相关的用例和流程,对外提供粗粒度的 API 服务。它就像一个齿轮一样进行前台应用和领域层的适配,接收前台需求,随时做出响应和调整,尽量避免将前台需求传导到领域层。应用层作为配速齿轮则位于前台应用和领域层之间。
4、 CQRS实战
4.1. 概念
CQRS(Command Query Responsibility Segregation)是将Command(命令)与Query(查询)分离的一种模式。其基本思想在于:任何一个方法都可以拆分为命令和查询两部分:
Command:不返回任何结果(void),但会改变对象的状态。Command是引起数据变化操作的总称,一般会执行某个动作,如:新增,更新,删除等操作。操作都封装在Command中,用户提交Commond到CommandBus,然后分发到对应的CommandHandler中执行。Command执行后通过Repository将数据持久化。事件源(Event
source)CQRS,Command将特定的Event发送到EventBus,然后由特定的EventHandler处理。
Query:返回查询结果,不会对数据产生变化的操作,只是按照某些条件查找数据。基于Query条件,返回查询结果;为不同的场景定制不同的Facade。
4.2. 架构图
基于四层的CQRS架构图:
4.3. 代码布局
第一种是
用户界面层调用应用服务
应用服务调用领域服务
在领域服务中
①通过仓库获取聚合根
②通过资源库持久化聚合根
③调用聚合根的业务方法
④发布领域事件
第二种是:
用户界面层调用应用服务
应用服务
①通过资源库获取聚合根
②调用聚合根的业务方法或者领域服务的方法
③通过资源库持久化聚合根
④发布领域事件
4.4. 数据模型转换
每一层都有自己特定的数据,可以做如下区分:
VO(View Object):视图对象,主要对应界面显示的数据对象。对于一个WEB页面,小程序,微信公众号等前端需要的数据对象。也有团队用VO表示领域层中的Value
Object值对象,这个要根据团队的规范来定义。
DTO(Data Transfer Object):数据传输对象,主要用于远程调用之间传输的对象的地方。比如我们一张表有
100 个字段,那么对应的 PO 就有 100 个属性。但是客户端只需要 10 个字段,没有必要把整个
PO 对象传递到客户端,这时我们就可以用只有这 10 个属性的 DTO 来传递结果到客户端,这样也不会暴露服务端表结构。到达客户端以后,如果用这个对象来对应界面显示,那此时它的身份就转为
VO。DTO泛指用于展示层与服务层之间的数据传输对象,当然VO也相当于数据DTO的一种。
DO(Domain Object):领域对象,就是从现实世界中抽象出来的有形或无形的业务实体,使用的是充血模型设计的对象。也有团队使用用
BO(Business Objects)表示业务对象的概念。
PO(Persistent Object):持久化对象,它跟持久层(通常是关系型数据库)的数据结构形成一一对应的映射关系,如果持久层是关系型数据库,那么,数据表中的每个字段(或若干个)就对应
PO 的一个(或若干个)属性。最形象的理解就是一个 PO 就是数据库中的一条记录,好处是可以把一条记录作为一个对象处理,可以方便的转为其它对象。也有团队使用DO(Data
Object)表示数据对象
POJO(Plain Ordinary Java Object):简单对象,是只具有setter
getter方法对象的统称。但是不要把对象名命名成 xxxPOJO!
模型转换架构图:
(1)从应用层->基础设施层的过程:
(2)从基础设施层->应用层的过程:
4.5. 项目目录结构
│ │ ├─interface 用户接口层 │ │ └─controller 控制器,对外提供(Restful)接口 │ │ └─facade 外观模式,对外提供本地接口和dubbo接口 │ │ └─mq mq消息,消费者消费外部mq消息 │ │ │ ├─application 应用层 │ │ ├─assembler 装配器 │ │ ├─dto 数据传输对象,xxxCommand/xxxQuery/xxxVo │ │ │ ├─command 接受增删改的参数 │ │ │ ├─query 接受查询的参数 │ │ │ ├─vo 返回给前端的vo对象 │ │ ├─service 应用服务,负责领域的组合、编排、转发、转换和传递 │ │ ├─repository 查询数据的仓库接口 │ │ ├─listener 事件监听定义 │ │ │ ├─domain 领域层 │ │ ├─entity 领域实体 │ │ ├─valueobject 领域值对象 │ │ ├─service 领域服务 │ │ ├─repository 仓库接口,增删改的接口 │ │ ├─acl 防腐层接口 │ │ ├─event 领域事件 │ │ │ ├─infrastructure 基础设施层 │ │ ├─converter 实体转换器 │ │ ├─repository 仓库 │ │ │ ├─impl 仓库实现 │ │ │ ├─mapper mybatis mapper接口 │ │ │ ├─po 数据库orm数据对象 │ │ ├─ack 实体转换器 │ │ ├─mq mq消息 │ │ ├─cache 缓存 │ │ ├─util 工具类 │ │ │
|
5、总结
MVC是一个短暂的快乐但不足以支撑漫长的生活,而DDD是一个不要短暂的温存而是一世的陪伴,如果是你来抉择你会选择哪一个?
MVC的开发模式:是数据驱动,自低向上的思想,关注数据。DDD的开发模式:是领域驱动,自顶向下,关注业务活动。为了应对业务快速变化的软件系统,DDD是面向对象的最终体现,大家一起用起来吧!
DDD是一套方法论,一千个读者一千个哈姆雷特。 |