• DDD领域驱动设计-视频讲解+实战


    目录

    简介

    解决的问题

    过度耦合

    现状

    DDD的分层架构和构成要素

    小结

    分包应用

    DDD领域驱动设计:实体、值对象、聚合根

    DDD应用

    战略建模

    领域

    限界上下文

    需求分析

    上下文映射图

    战术建模——细化上下文

    DDD工程实现

    最终数据流向


    简介

    DDD 领域驱动设计,当软件越来越复杂,实际开发中,大量的业务逻辑堆积在一个巨型类中的例子屡见不鲜,代码的复用性和扩展性无法得到保证。为了解决这样的问题,DDD提出了清晰的分层架构和领域对象的概念,让面向对象的分析和设计进入了一个新的阶段,对企业级软件开发起到了巨大的推动作用。

    解决的问题

    过度耦合

    业务初期,我们的功能大都非常简单,普通的CRUD就能满足,此时系统是清晰的。随着迭代的不断演化,业务逻辑变得越来越复杂,我们的系统也越来越冗杂。模块彼此关联,谁都很难说清模块的具体功能意图是啥。修改一个功能时,往往光回溯该功能需要的修改点就需要很长时间,更别提修改带来的不可预知的影响面。

    下图是一个常见的系统耦合病例。

    订单服务接口中提供了查询、创建订单相关的接口,也提供了订单评价、支付、保险的接口。同时我们的表也是一个订单大表,包含了非常多字段。在我们维护代码时,牵一发而动全身,很可能只是想改下评价相关的功能,却影响到了创单核心路径。虽然我们可以通过测试保证功能完备性,但当我们在订单领域有大量需求同时并行开发时,改动重叠、恶性循环、疲于奔命修改各种问题。

    上述问题,归根到底在于系统架构不清晰,划分出来的模块内聚度低、高耦合。

    用DDD则可以很好地解决领域模型到设计模型的同步、演化,最后再将反映领域的设计模型转为实际的代码。

    注:模型是我们解决实际问题所抽象出来的概念模型,领域模型则表达与业务相关的事实;设计模型则描述了所要构建的系统。

    现状

    在我们习惯了J2EE的开发模式后,Action/Service/DAO这种分层模式,会很自然地写出过程式代码,而学到的很多关于OO理论的也毫无用武之地。使用这种开发方式,对象只是数据的载体,没有行为。以数据为中心,以数据库ER设计作驱动。分层架构在这种开发模式下,可以理解为是对数据移动、处理和实现的过程。

    系统抽奖平台为例:

    • 场景需求

    奖池里配置了很多奖项,我们需要按运营预先配置的概率抽中一个奖项。 实现非常简单,生成一个随机数,匹配符合该随机数生成概率的奖项即可。

    • 贫血模型实现方案

    先设计奖池和奖项的库表配置。

    设计AwardPool和Award两个对象,只有简单的get和set属性的方法

     

    Service代码实现

    设计一个LotteryService,在其中的drawLottery()方法写服务逻辑

    按照我们通常思路实现,可以发现:在业务领域里非常重要的抽奖,我的业务逻辑都是写在Service中的,Award充其量只是个数据载体,没有任何行为。简单的业务系统采用这种贫血模型和过程化设计是没有问题的,但在业务逻辑复杂了,业务逻辑、状态会散落到在大量方法中,原本的代码意图会渐渐不明确,我们将这种情况称为由贫血症引起的失忆症。

    更好的是采用领域模型的开发方式,将数据和行为封装在一起,并与现实世界中的业务对象相映射。各类具备明确的职责划分,将领域逻辑分散到领域对象中。继续举我们上述抽奖的例子,使用概率选择对应的奖品就应当放到AwardPool类中。

    DDD的分层架构和构成要素

    分层架构

    整个架构分为四层,其核心就是领域层(Domain),所有的业务逻辑应该在领域层实现,具体描述如下:

    接口层(Interface),负责向用户展现信息以及解释用户命令。

    应用层(Application),定义软件要完成的作业并指导领域对象解决问题。 这一层负责执行对业务具有意义的任务或与其他系统的应用层进行交互时需执行的任务。 这一层很“薄”。 它不包含业务规则或知识,仅针对下一层中领域对象之间的协作,协调任务和委派工作。 它不具有反映业务状况的状态,但它可以具有状态,用于反映用户或程序的任务的进度。

    领域层(Domain),负责表示业务概念、有关业务状况的信息和业务规则。 反映业务状况的状态是通过这个层进行控制和利用的,但有关状态存储的具体技术细节则由基础结构负责实施。 这一层是业务软件的核心。

    基础设施层(Infrastructure),基础设施层是关于如何将最初存放在域实体中的数据(内存中)持久保存在数据库或另一个持久性存储区中。

    核心域、通用域和支撑域

    领域会细分为不同的子域,子域可以根据自身重要性和功能属性划分为三类子域,它们分别是:核心域、通用域和支撑域。

    域种类

    划分依据

    说明

    核心域

    产品核心竞争力、业务主要因素

    最重要

    通用域

    无个性化的述求,同时被多个子域使用的通用功能

    例如认证、权限等,很容易买到,与企业耦合性不强,不需要做定制化

    支撑域

    不是产品核心竞争力、不包含通用功能

    具有企业特性,但不具通用性

    这三类子域相较之下,核心域是最重要的,通用域和支撑域如果对应到企业系统,举例来说的话,通用域则是你需要用到的通用系统,比如认证、权限等等,这类应用很容易买到,没有企业特点限制,不需要做太多的定制化。而支撑域则具有企业特性,但不具有通用性,例如数据代码类的数据字典等系统。

    领域拆分为子领域就是做减法的过程,降低了业务的理解复杂度和系统实现的复杂度,而核心域,通用域,支撑域的划分是跟公司的商业模式有关系的,决定了子领域的不同优先级和资源投入策略。

    小结

    DDD是为解决软件复杂性而诞生,与OOP最大的区别就是划分边界的方式不一样,所以DDD本身掌握起来并不会感觉复杂,DDD其实是研究将包含业务逻辑的,if else语句放在哪里的学问。

    OOP,以“对象”为边界,软件复杂度中适用,例如“盖小区”。

    DDD,以“问题域”为边界,软件复杂度大适用,例如“盖城市”。

    分包应用

     

    DDD领域驱动设计:实体、值对象、聚合根

    值对象

    值对象有两个主要特征:它们没有任何标识。它们是不可变的。

    实体

    实体主要特征:具有唯一标识。

    聚合根

    聚合根与实体的区别,实体只在聚合内部进行操作,聚合根是对外打交道的唯一实体。我们在这里设计时聚合根需要有增改删状态字段。

    领域事件

    领域事件是在领域中发生的事,你希望同一个领域(进程)的其他部分了解它。 通知部分通常以某种方式对事件作出反应。

    DDD应用

    继续上面提到的抽奖活动,首先看下抽奖系统的大致需求: 运营——可以配置一个抽奖活动,该活动面向一个特定的用户群体,并针对一个用户群体发放一批不同类型的奖品(优惠券,激活码,实物奖品等)。 用户-通过活动页面参与不同类型的抽奖活动。

    设计领域模型的一般步骤如下:

    1. 根据需求划分出初步的领域和限界上下文,以及上下文之间的关系;
    2. 进一步分析每个上下文内部,识别出哪些是实体,哪些是值对象;
    3. 对实体、值对象进行关联和聚合,划分出聚合的范畴和聚合根;
    4. 为聚合根设计仓储,并思考实体或值对象的创建方式;
    5. 在工程中实践领域模型,并在实践中检验模型的合理性,倒推模型中不足的地方并重构。

    战略建模

    战略和战术设计是站在DDD的角度进行划分。战略设计侧重于高层次、宏观上去划分和集成限界上下文,而战术设计则关注更具体使用建模工具来细化上下文。

    领域

    现实世界中,领域包含了问题域和解系统。一般认为软件是对现实世界的部分模拟。在DDD中,解系统可以映射为一个个限界上下文,限界上下文就是软件对于问题域的一个特定的、有限的解决方案。

    限界上下文

    一个给定的业务领域会包含多个限界上下文,想与一个限界上下文沟通,则需要通过显示边界进行通信。系统通过确定的限界上下文来进行解耦,而每一个上下文内部紧密组织,职责明确,具有较高的内聚性。

    我们的实践是,考虑产品所讲的通用语言,从中提取一些术语称之为概念对象,寻找对象之间的联系;或者从需求里提取一些动词,观察动词和对象之间的关系;我们将紧耦合的各自圈在一起,观察他们内在的联系,从而形成对应的限界上下文。形成之后,我们可以尝试用语言来描述下界限上下文的职责,看它是否清晰、准确、简洁和完整。简言之,限界上下文应该从需求出发,按领域划分。

    需求分析

    前文提到,我们的用户划分为运营和用户。其中,运营对抽奖活动的配置十分复杂但相对低频。用户对这些抽奖活动配置的使用是高频次且无感知的。根据这样的业务特点,我们首先将抽奖平台划分为C端抽奖和M端抽奖管理平台两个子域,让两者完全解耦。

    C端产品的需求概述如下:

    1. 抽奖活动有活动限制,例如用户的抽奖次数限制,抽奖的开始和结束的时间等;

    2. 一个抽奖活动包含多个奖品,可以针对一个或多个用户群体;

    3. 奖品有自身的奖品配置,例如库存量,被抽中的概率等,最多被一个用户抽中的次数等等;

    4. 用户群体有多种区别方式,如按照用户所在城市区分,按照新老客区分等;

    5. 活动具有风控配置,能够限制用户参与抽奖的频率。

    根据产品的需求,我们提取了一些关键性的概念作为子域,形成我们的限界上下文。

    首先,抽奖上下文作为整个领域的核心,承担着用户抽奖的核心业务,抽奖中包含了奖品和用户群体的概念。

    • 在设计初期,曾经考虑划分出抽奖和发奖两个领域,前者负责选奖,后者负责将选中的奖品发放出去。但在实际开发过程中,发现这两部分的逻辑紧密连接,难以拆分。并且单纯的发奖逻辑足够简单,仅仅是调用第三方服务进行发奖,不足以独立出来成为一个领域。

    对于活动的限制,我们定义了活动准入的通用语言,将活动开始/结束时间,活动可参与次数等限制条件都收拢到活动准入上下文中。

    对于抽奖的奖品库存量,由于库存的行为与奖品本身相对解耦,库存关注点更多是库存内容的核销,且库存本身具备通用性,可以被奖品之外的内容使用,因此我们定义了独立的库存上下文。

    由于C端存在一些刷单行为,我们根据产品需求定义了风控上下文,用于对活动进行风控。 最后,活动准入、风控、抽奖等领域都涉及到一些次数的限制,因此我们定义了计数上下文。

    可以看到,通过DDD的限界上下文划分,我们界定出抽奖、活动准入、风控、计数、库存等五个上下文,每个上下文在系统中都高度内聚。

    上下文映射图

    在进行上下文划分之后,我们还需要进一步梳理上下文之间的关系。

    限界上下文之间的映射关系

    • 合作关系(Partnership):两个上下文紧密合作的关系,一荣俱荣,一损俱损。
    • 共享内核(Shared Kernel):两个上下文依赖部分共享的模型。
    • 客户方-供应方开发(Customer-Supplier Development):上下文之间有组织的上下游依赖。
    • 遵奉者(Conformist):下游上下文只能盲目依赖上游上下文。
    • 防腐层(Anticorruption Layer):一个上下文通过一些适配和转换与另一个上下文交互。
    • 开放主机服务(Open Host Service):定义一种协议来让其他上下文来对本上下文进行访问。
    • 发布语言(Published Language):通常与OHS一起使用,用于定义开放主机的协议。
    • 大泥球(Big Ball of Mud):混杂在一起的上下文关系,边界不清晰。
    • 另谋他路(SeparateWay):两个完全没有任何联系的上下文。

    上游(U:Upstream)和下游(D:DownStream) 

    由于抽奖,风控,活动准入,库存,计数五个上下文都处在抽奖领域的内部,所以它们之间符合“一荣俱荣,一损俱损”的合作关系(PartnerShip,简称PS)。

    同时,抽奖上下文在进行发券动作时,会依赖券码、平台券、外卖券三个上下文。抽奖上下文通过防腐层(Anticorruption Layer,ACL)对三个上下文进行了隔离,而三个券上下文通过开放主机服务(Open Host Service)作为发布语言(Published Language)对抽奖上下文提供访问机制。

    战术建模——细化上下文

    抽奖平台的核心上下文是抽奖上下文,接下来介绍下我们对抽奖上下文的建模。

    在抽奖上下文中,我们通过抽奖(DrawLottery)这个聚合根来控制抽奖行为,可以看到,一个抽奖包括了抽奖ID(LotteryId)以及多个奖池(AwardPool),而一个奖池针对一个特定的用户群体(UserGroup)设置了多个奖品(Award)。

    另外,在抽奖领域中,我们还会使用抽奖结果(SendResult)作为输出信息,使用用户领奖记录(UserLotteryLog)作为领奖凭据和存根。

    谨慎使用值对象

    在实践中,我们发现虽然一些领域对象符合值对象的概念,但是随着业务的变动,很多原有的定义会发生变更,值对象可能需要在业务意义具有唯一标识,而对这类值对象的重构往往需要较高成本。因此在特定的情况下,我们也要根据实际情况来权衡领域对象的选型。

    DDD工程实现

    在对上下文进行细化后,我们开始在工程中真正落地DDD。

    模块

    模块(Module)是DDD中明确提到的一种控制限界上下文的手段,在我们的工程中,一般尽量用一个模块来表示一个领域的限界上下文。

    如代码中所示,一般的工程中包的组织方式为{com.公司名.组织架构.业务.上下文.*},这样的组织结构能够明确的将一个上下文限定在包的内部。

     以抽象上下文为例,代码结构

    领域对象

    前文提到,领域驱动要解决的一个重要的问题,就是解决对象的贫血问题。这里我们用之前定义的抽奖(DrawLottery)聚合根和奖池(AwardPool)值对象来具体说明。

    抽奖聚合根持有了抽奖活动的id和该活动下的所有可用奖池列表,它的一个最主要的领域功能就是根据一个抽奖发生场景(DrawLotteryContext),选择出一个适配的奖池,即chooseAwardPool方法。

    chooseAwardPool的逻辑是这样的:DrawLotteryContext会带有用户抽奖时的场景信息(抽奖得分或抽奖时所在的城市),DrawLottery会根据这个场景信息,匹配一个可以给用户发奖的AwardPool。

    1. package com.company.team.bussiness.lottery.domain.model;
    2. import ...;
    3. public class DrawLottery {
    4. private int lotteryId; //抽奖id
    5. private List awardPools; //奖池列表
    6. //getter & setter
    7. public void setLotteryId(int lotteryId) {
    8. if(id<=0){
    9. throw new IllegalArgumentException("非法的抽奖id");
    10. }
    11. this.lotteryId = lotteryId;
    12. }
    13. //根据抽奖入参context选择奖池
    14. public AwardPool chooseAwardPool(DrawLotteryContext context) {
    15. if(context.getMtCityInfo()!=null) {
    16. return chooseAwardPoolByCityInfo(awardPools, context.getMtCityInfo());
    17. } else {
    18. return chooseAwardPoolByScore(awardPools, context.getGameScore());
    19. }
    20. }
    21. //根据抽奖所在城市选择奖池
    22. private AwardPool chooseAwardPoolByCityInfo(List awardPools, MtCifyInfo cityInfo) {
    23. for(AwardPool awardPool: awardPools) {
    24. if(awardPool.matchedCity(cityInfo.getCityId())) {
    25. return awardPool;
    26. }
    27. }
    28. return null;
    29. }
    30. //根据抽奖活动得分选择奖池
    31. private AwardPool chooseAwardPoolByScore(List awardPools, int gameScore) {...}

    在匹配到一个具体的奖池之后,需要确定最后给用户的奖品是什么。这部分的领域功能在AwardPool内。

    1. package com.company.team.bussiness.lottery.domain.valobj;
    2. import ...;
    3. public class AwardPool {
    4. private String cityIds;//奖池支持的城市
    5. private String scores;//奖池支持的得分
    6. private int userGroupType;//奖池匹配的用户类型
    7. private List awards;//奖池中包含的奖品
    8. //当前奖池是否与城市匹配
    9. public boolean matchedCity(int cityId) {...}
    10. //当前奖池是否与用户得分匹配
    11. public boolean matchedScore(int score) {...}
    12. //根据概率选择奖池
    13. public Award randomGetAward() {
    14. int sumOfProbablity = 0;
    15. for(Award award: awards) {
    16. sumOfProbability += award.getAwardProbablity();
    17. }
    18. int randomNumber = ThreadLocalRandom.current().nextInt(sumOfProbablity);
    19. range = 0;
    20. for(Award award: awards) {
    21. range += award.getProbablity();
    22. if(randomNumber
    23. return award;
    24. }
    25. }
    26. return null;
    27. }
    28. }

    与以往的仅有getter、setter的业务对象不同,领域对象具有了行为,对象更加丰满。同时,比起将这些逻辑写在服务内(例如**Service),领域功能的内聚性更强,职责更加明确。

    应用层-领域层-资源库

    访问资源库,通常是两种情况,一种是查询,另外一种是操作(更新、插入、删除)

    查询比较简单直接查询查询条件即可;

    操作需要依赖聚合根

    1. package com.company.team.bussiness.lottery.application;
    2. import com.company.team.bussiness.lottery.domain.factory
    3. public class LogterrayFacade{
    4. public AwardPublishInfo publishAward(PublishAwardCommand command) {
    5. Aggregate aggregate = awardFactory.createAggregate(command);
    6. commentRepository.save(aggregate);
    7. }
    8. }
    1. package com.company.team.bussiness.lottery.domain.factory
    2. public class AwardFactory{
    3. public Aggregate createAggregate(PublishCommentCommand command) {
    4. Award award = Award.builder()
    5. .id(command.getId())
    6. .content(command.getContent())
    7. .build();
    8. return Aggregate.createNew(award);
    9. }
    10. }
    1. package com.company.team.bussiness.lottery.domain.repo;
    2. public class AwardRepository {
    3. public void save(Aggregate aggregate) {
    4. if (aggregate.isNew()) {
    5. AwardEntity entity = awardConverter.convertToNewEntity(aggregate);
    6. commentPersistence.create(entity);
    7. aggregate.getRoot().setId(entity.getId());
    8. } else {
    9. CommentEntity entity = commentConverter.convertToUpdateEntity(commentAggregate);
    10. if (Objects.isNull(entity)) {
    11. return ;
    12. }
    13. commentPersistence.updateNotNullById(entity);
    14. }
    15. }
    16. }
    1. package com.company.team.bussiness.lottery.infra.dao.persistence;
    2. public class AwardPersistence {
    3. public void create(AwardEntity entity) {
    4. int ret = mapper.insert(entity);
    5. AssertUtils.equal(DBConsts.ONE_LINE_AFFECTED, ret, IResponseStatusMsg.APIEnum.DB_ERROR, "创建信息失败");
    6. }

    最终数据流向

  • 相关阅读:
    终于有阿里p8进行了大汇总(Redis+JVM+MySQL+Spring)还有面试题解全在这里了!
    mysql(七)------数据库的索引
    基于飞书WebHook机器人的Alert Manager报警实现
    Vue学习笔记之Vue脚手架使用 6.23
    Games104 gameplay系统笔记
    地球人口承载力估计(c++基础)
    uni-app canvas 签名
    ALSA pcm接口的概念解释
    【STM32】DMA(直接存储器访问)
    react useMemo 用法
  • 原文地址:https://blog.csdn.net/qq_37909508/article/details/127738472