Domain-Driven Design 学习笔记

参考链接:https://tech.meituan.com/2017/12/22/ddd-in-practice.html

一、前言

早在20世纪,软件设计人员就已经意识到领域建模与设计的重要性,并形成一种思潮。Eric Evans将其定义为领域驱动设计(Domain-Driven Design, 简称DDD)。

在互联网”小步快跑,迭代试错“的大环境下,DDD似乎是一种“古老而缓慢”的思想。然后,互联网也逐渐深入实体经济,业务日益复杂,所以在开发中也越来越多地遇到传统行业软件开发中所面临的问题,这时可以用DDD思想来解决问题。

二、DDD要解决的问题

2.1、过度耦合

在业务初期,功能大多非常简单,普通的CRUD就可以解决,此时系统的设计是清晰的。但随时不断迭代,业务逻辑越来越复杂,系统会越来越冗余:表现为模块间彼此关联(耦合),很难说清某个模块是用于什么功能的,而修改一个功能时,会带来不可预知的回归问题。

下面是场景的系统耦合的反例:
在这里插入图片描述
订单服务中提供了查询、创建订单接口,也提供了订单评价接口,支付接口,保险接口等,而这些接口都共用一个大表,表里包含了很多字段。这样,修改一个功能时,如修改评价功能,可能会影响到创建订单这个核心链路。虽然在修改评价功能后,可通过测试来保证不引起回归问题,但在订单相关的“领域”中,有大量需求在同时迭代,存在改动重叠的问题,这就是高耦合带来的弊端。

按“演进式”理论,即让系统的设计随着系统的实现而增长,我们不需要提前设计。敏捷实践可以解决问题:

  • 重构:抽出行为不变的代码
  • 测试驱动设计:避免回归问题
  • 持续集成:为团队提供了同一代码库

代码重构提炼出来的设计模型,并不具有一个业务描述的含义。这样在开发新需求时,新同学并不容易把业务问题映射到该抽象,这样还是复用性不好。

DDD就是用来解决领域模型到设计模型的同步、演化,因为领域模型表达的是与业务相关的事实。

2.2、贫血症与失忆症

2.2.1、贫血领域对象

贫血领域对象(Amemic Domain Object)是指仅用作数据载体,而没有行为和动作的领域对象。

我们大部分习惯了J2EE开发模式,即Action/Service/DAO这种分层模式,这时对象只是数据的载体,没有行为。以数据为重仓,以数据库ER设计作为驱动。

举例说明:
1)场景:奖池里有许多奖项,抽奖时根据随机数,匹配符合该随机数生成概率的奖项即可。
2)实现方案:
设计两个表:奖池(AwardPool)和奖项(Award)
在设计AwardPool和Award两个对象,只有简单的set和get方法

class AwardPool {
    
    
    int awardPoolId;
    List<Award> awards;
    public List<Award> getAwards() {
    
    
        return awards;
    }
  
    public void setAwards(List<Award> awards) {
    
    
        this.awards = awards;
    }
    ......
}

class Award {
    
    
   int awardId;
   int probability;//概率
  
   ......
}

Service实现:
设计一个领奖类LotteryService,在其中实现一个抽奖的方法drawLottery:

AwardPool awardPool = awardPoolDao.getAwardPool(poolId);//sql查询,将数据映射到AwardPool对象
for (Award award : awardPool.getAwards()) {
    
    
   //寻找到符合award.getProbability()概率的award
}

按照我们的通常思路,可以发现:在业务领域里非常重要的“抽奖”,我们写在了Service层,Award只是个数据载体,里面没有任何行为。这种数据和行为的分离设计,就叫做“贫血”。

对于简单的业务系统,这种贫血的设计是没有问题的。而对于复杂的业务逻辑,业务行为会产生大量的方法,原本代码的意图(如抽奖服务LotterySergvice)会逐渐变得意图不明确,这种情况就叫做贫血症引起的失忆症。

DDD会将数据和行为封装在一起,并与现实世界中的业务对象相映射。
对于上面的抽奖例子,原Service层中的抽奖逻辑,就会放到AwardPool中。

三、解决软件系统的复杂性

解决大规模软件系统的复杂性的方法分三类:抽象、分治、知识(DDD也是知识的一种)。

3.1、DDD与微服务

在创建微服务时,追求的是“高内聚、低耦合”,而DDD中的“限界上下文”则可以完美低匹配微服务,可以把“限界上下文”理解为一个微服务进程。

在系统复杂之后,我们需要用分治法来解决,拆解方式通常有技术维度和业务维度。技术维度如MVC分层,业务维度就是指按业务领域来划分系统。

微服务架构更强调从业务维度去做分治来解决系统的复杂度,而DDD也是同样看重业务视角。

架构设计可概括为三个层面:

  • 业务架构:根据业务需求设计业务模块及其关系
  • 系统架构:设计系统和子系统模块
  • 技术架构:采用的技术及框架

DDD的核心就是将业务架构映射到系统架构上,在响应业务变化调整业务架构时,系统架构也随着变化。

而微服务追求业务层面的复用,设计出来的系统架构和业务一致;在技术架构上则系统模块之间充分解耦,可以自由地选择合适的技术架构,去中心化地治理技术和数据。

下面是DDD和微服务之间的关系:
在这里插入图片描述

3.2、DDD在抽奖案例的应用

下面通过上面说的抽奖平台,来详细介绍怎样通过DDD来设计一个中型的基于微服务的系统,从而做到系统高内聚、低耦合。

先看抽奖系统的需求:公司运营组织了一个抽奖活动,面向一个特定的用户群体,针对同一个用户群体发放一批不同类型的奖品(优惠券、激活码、实物奖品等)。用户通过活动页面参与不同类型的抽奖活动。

设计“领域模型”的步骤如下:

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

从DDD的角度,分为战略和战术设计,战略偏重于宏观地划分限界上下文,而战术则关注具体使用建模工具来细化上下文。

3.2.1、战略建模

3.2.1.1、领域

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

3.2.1.2、限界上下文

限界上下文是一个由显示边界限定的特定职责。领域模型便存在于这个边界之内。在边界内,每一个模型概念,包括他的属性和操作,都具有特定的含义。

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

有一个很形象的比喻:细胞质所以能够存在,是因为细胞膜限定了什么在细胞内,什么在细胞外,并确定了什么物质可以通过细胞膜。

3.2.1.3、怎样划分限界上下文

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

该抽奖系统的用户分两类:运营和用户,运营用系统来配置抽奖规则,用户用系统来抽奖。所以我们将抽奖平台划分为C端抽奖和M端抽奖管理两个子领域,二者完全解耦:
在这里插入图片描述
在确认了M端领域和C端领域的限界上下文后,我们再对各自上下文的内部进行限界上下文的划分。

先看C端:

1)抽奖活动有限制,例如用户的抽奖次数有限制,抽奖的开始和结束时间等
2)一个抽奖活动包含多个奖品,可以针对一个或多个用户群体
3)奖品有自身的奖品配置,例如库存量,被抽中的概率等,最多被一个用户抽中的次数等等
4)用户群体有多种区别方式,如按用户所在城市区分,按照新老客户区分等
5)活动具有风控配置,能够限制用户参与抽奖的频率等

根据产品的需求,先提取一些关键性的概念作为子域,形成限界上下文:
在这里插入图片描述
首先,抽奖上下文作为整个领域的核心,承担着用户抽奖的核心业务,抽奖中包含了奖品和用户群体的概念。

通过DDD上下文划分,界定出了抽奖、活动准入、风控、计数、库存等五个上下文,每个上下文在系统中都“高内聚”。

3.2.1.4、上下文映射图

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

梳理清上下文之间的关系,好处是:
1)任务更好拆分,一个开发人员可以全身心的投入到相关的一个单独的上下文中;
2)沟通更加顺畅,一个上下文可以明确自己对其他上下文的依赖关系,从而使团队内开发更好的对接

抽奖平台上下文映射关系如下图:
在这里插入图片描述
由于抽奖、风控、活动准入、库存、计数5个上下文都处在抽奖领域的内部,他们之间符合“一损俱损、一荣俱荣”的合作关系,即PartnerShip关系,简称PS

抽奖上下文通过防腐层(Anticorruption Layer,ACL)对三个上下文进行了隔离,而三个券通过开放主机服务(Open Host Service,简称OHS)作为发布语言(Published Language,简称PL)对抽奖上下文提供访问机制

3.2.2、战术建模–细化上下文

在梳理清楚上下文之间的关系后,我们需要从战术层面剖析,上下文内部的组织关系。

先看DDD中的一些定义:

1、实体:当一个对象由其标识(而不是属性)区分时,这种对象成为实体(Entity)
例如:公安系统的身份信息录入,可认为是实体,因为每个人是独一无二的,且具有唯一标识(如身份证号码)

2、值对象
当一个对象用于对事物进行描述而没有唯一标识时,他被称为值对象(Value Object)。
例如:颜色信息

在实践中,需要保证值对象创建后就不能被修改,即不允许外部再修改其属性。

3、聚合根
Aggregate(聚合)是一组相关对象的集合,作为整体被外界访问,聚合根(Aggregate Root)是这个聚合的根节点。

聚合内部多个组成对象的关系可以用来指导数据库创建,但不可避免存在一定的抗阻。例如聚合中存在List<值对象>, 那么在数据库中建立1:N的关联需要将值对象单独建表,此时是有ID的,建议不要将ID暴露到资源库外部,对外隐藏。

4、领域服务
一些重要的领域行为,可以归类为领域服务,它既不是实体,也不是值对象的范畴。

5、领域事件
领域事件是对领域内发生的活动进行的建模。

抽奖平台的核心上下文是抽奖上下文,下面看下抽奖上下文的建模:
在这里插入图片描述
在抽奖上下文中,我们通过抽奖(DrawLottery)这个聚合根来控制抽奖行为,可以看到,一个抽奖包括了抽奖ID(LotteryId)及多个奖池(AwardPool),而一个奖池针对一个特定的用户群体(UserGroup)设置了多个奖品(Award)。
另外,在抽奖领域中,我们还会使用抽奖结果(SendResult)作为输出信息,使用用户领奖记录(UserLotteryLog)作为领奖凭据和存根。

3.2.3、DDD工程实现

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

3.2.3.1、模块

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

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

import com.company.team.bussiness.lottery.*;//抽奖上下文
import com.company.team.bussiness.riskcontrol.*;//风控上下文
import com.company.team.bussiness.counter.*;//计数上下文
import com.company.team.bussiness.condition.*;//活动准入上下文
import com.company.team.bussiness.stock.*;//库存上下文

对于模块内的组织结构,一般情况下,我们按照领域对象、领域服务、领域资源库、防腐层等组织方式定义的:

import com.company.team.bussiness.lottery.domain.valobj.*;//领域对象-值对象
import com.company.team.bussiness.lottery.domain.entity.*;//领域对象-实体
import com.company.team.bussiness.lottery.domain.aggregate.*;//领域对象-聚合根
import com.company.team.bussiness.lottery.service.*;//领域服务
import com.company.team.bussiness.lottery.repo.*;//领域资源库
import com.company.team.bussiness.lottery.facade.*;//领域防腐层

3.2.3.2、领域对象

前面提到,DDD要解决一个重要的问题就是贫血的问题。这里用抽奖(DrawLottery)聚合根和奖池(AwardPool)值对象来具体说明:

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

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

package com.company.team.bussiness.lottery.domain.aggregate;
import ...;
  
public class DrawLottery {
    
    
    private int lotteryId; //抽奖id
    private List<AwardPool> awardPools; //奖池列表
  
    //getter & setter
    public void setLotteryId(int lotteryId) {
    
    
        if(id<=0){
    
    
            throw new IllegalArgumentException("非法的抽奖id"); 
        }
        this.lotteryId = lotteryId;
    }
  
    //根据抽奖入参context选择奖池
    public AwardPool chooseAwardPool(DrawLotteryContext context) {
    
    
        if(context.getMtCityInfo()!=null) {
    
    
            return chooseAwardPoolByCityInfo(awardPools, context.getMtCityInfo());
        } else {
    
    
            return chooseAwardPoolByScore(awardPools, context.getGameScore());
        }
    }
     
    //根据抽奖所在城市选择奖池
    private AwardPool chooseAwardPoolByCityInfo(List<AwardPool> awardPools, MtCifyInfo cityInfo) {
    
    
        for(AwardPool awardPool: awardPools) {
    
    
            if(awardPool.matchedCity(cityInfo.getCityId())) {
    
    
                return awardPool;
            }
        }
        return null;
    }
  
    //根据抽奖活动得分选择奖池
    private AwardPool chooseAwardPoolByScore(List<AwardPool> awardPools, int gameScore) {
    
    ...}
}

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

package com.company.team.bussiness.lottery.domain.valobj;
import ...;
  
public class AwardPool {
    
    
    private String cityIds;//奖池支持的城市
    private String scores;//奖池支持的得分
    private int userGroupType;//奖池匹配的用户类型
    private List<Awrad> awards;//奖池中包含的奖品
  
    //当前奖池是否与城市匹配
    public boolean matchedCity(int cityId) {
    
    ...}
  
    //当前奖池是否与用户得分匹配
    public boolean matchedScore(int score) {
    
    ...}
  
    //根据概率选择奖池
    public Award randomGetAward() {
    
    
        int sumOfProbablity = 0;
        for(Award award: awards) {
    
    
            sumOfProbability += award.getAwardProbablity();
        }
        int randomNumber = ThreadLocalRandom.current().nextInt(sumOfProbablity);
        range = 0;
        for(Award award: awards) {
    
    
            range += award.getProbablity();
            if(randomNumber<range) {
    
    
                return award;
            }
        }
        return null;
    }
}

3.2.3.3、资源库

领域对象需要资源存储,存储的手段可以多样化,场景的无非是数据库、分布式缓存、本地缓存等。资源库(Repository)的作用,就是对领域的存储和访问进行统一管理的对象。

在抽奖平台中,通过下面的方式组织资源库的:

//数据库资源
import com.company.team.bussiness.lottery.repo.dao.AwardPoolDao;//数据库访问对象-奖池
import com.company.team.bussiness.lottery.repo.dao.AwardDao;//数据库访问对象-奖品
import com.company.team.bussiness.lottery.repo.dao.po.AwardPO;//数据库持久化对象-奖品
import com.company.team.bussiness.lottery.repo.dao.po.AwardPoolPO;//数据库持久化对象-奖池
  
import com.company.team.bussiness.lottery.repo.cache.DrawLotteryCacheAccessObj;//分布式缓存访问对象-抽奖缓存访问
import com.company.team.bussiness.lottery.repo.repository.DrawLotteryRepository;//资源库访问对象-抽奖资源库

资源库对外的整体访问由Repository提供,他聚合了各个资源库的数据信息,同时也承担了资源存储的逻辑(例如缓存更新机制等)

在抽奖资源库中,我们屏蔽了对底层奖池和奖品的直接访问,而是仅对抽奖的聚合根进行资源管理,

在抽奖资源库中,我们屏蔽了对底层奖池和奖品的直接访问,而仅对抽奖的聚合根进行资源管理。代码示例中展示了抽奖资源获取的方法(最常见的Cache Aside Pattern)

比起以往将资源管理放在Service层中的做法,由资源库对资源进行管理,职责更加明确,代码的可读性和可维护性更强。

package com.company.team.bussiness.lottery.repo;
import ...;
  
@Repository
public class DrawLotteryRepository {
    
    
    @Autowired
    private AwardDao awardDao;
    @Autowired
    private AwardPoolDao awardPoolDao;
    @AutoWired
    private DrawLotteryCacheAccessObj drawLotteryCacheAccessObj;
  
    public DrawLottery getDrawLotteryById(int lotteryId) {
    
    
        DrawLottery drawLottery = drawLotteryCacheAccessObj.get(lotteryId);
        if(drawLottery!=null){
    
    
            return drawLottery;
        }
        drawLottery = getDrawLotteyFromDB(lotteryId);
        drawLotteryCacheAccessObj.add(lotteryId, drawLottery);
        return drawLottery;
    }
  
    private DrawLottery getDrawLotteryFromDB(int lotteryId) {
    
    ...}
}

3.2.2.4、防腐层

防腐层也称为适配层,在一个上下文中,有时需要对外部上下文进行访问,通常会引入防腐层的概念来对外部上下文的访问进行一次转义。

下面几种情况适合引入防腐层:

  • 需要将外部上下文中的模型翻译成本上下文理解的模型
  • 不同上下文之间的团队协作关系,如是provider关系,可引入防腐层,避免外部上下文对本上下文的侵蚀
  • 该访问本上下文被广泛使用,为了避免改动,影响访问过大

如果内部多个上下文要对外部上下文访问,那么可考虑将其放入通用上下文中。

在抽奖平台中,我们定义了用户城市信息防腐层(UserCityInfoFacade),用于外部的用户城市信息上下文(微服务架构下表现为用户城市信息服务)。

以用户信息防腐层举例,他以抽奖请求参数(LotteryContext)为入参,以城市信息(MtCityInfo)为输出。

package com.company.team.bussiness.lottery.facade;
import ...;
  
@Component
public class UserCityInfoFacade {
    
    
    @Autowired
    private LbsService lbsService;//外部用户城市信息RPC服务
     
    public MtCityInfo getMtCityInfo(LotteryContext context) {
    
    
        LbsReq lbsReq = new LbsReq();
        lbsReq.setLat(context.getLat());
        lbsReq.setLng(context.getLng());
        LbsResponse resp = lbsService.getLbsCityInfo(lbsReq);
        return buildMtCifyInfo(resp);
    }
  
    private MtCityInfo buildMtCityInfo(LbsResponse resp) {
    
    ...}
}

领域服务
我们将领域行为封装到了领域对象中,将资源管理行为封装到了资源库中,将外部上下文的交互行为封装到了防腐层中。这时,当我们回头看领域服务时,能发现领域服务本身所承载的职责也就更加清晰了,即通过串联领域对象、资源库、防腐层当一系列领域内的对象的行为,对其他上下文提供交互的接口。

我们以抽奖服务(issueLottery)为例,可以看到在省略了一些防御性逻辑(异常处理、控制判断等)后,领域服务的逻辑已经足够清晰明了了:

package com.company.team.bussiness.lottery.service.impl
import ...;
  
@Service
public class LotteryServiceImpl implements LotteryService {
    
    
    @Autowired
    private DrawLotteryRepository drawLotteryRepo;
    @Autowired
    private UserCityInfoFacade UserCityInfoFacade;
    @Autowired
    private AwardSendService awardSendService;
    @Autowired
    private AwardCounterFacade awardCounterFacade;
  
    @Override
    public IssueResponse issueLottery(LotteryContext lotteryContext) {
    
    
        DrawLottery drawLottery = drawLotteryRepo.getDrawLotteryById(lotteryContext.getLotteryId());//获取抽奖配置聚合根
        awardCounterFacade.incrTryCount(lotteryContext);//增加抽奖计数信息
        AwardPool awardPool = lotteryConfig.chooseAwardPool(bulidDrawLotteryContext(drawLottery, lotteryContext));//选中奖池
        Award award = awardPool.randomChooseAward();//选中奖品
        return buildIssueResponse(awardSendService.sendAward(award, lotteryContext));//发出奖品实体
    }
  
    private IssueResponse buildIssueResponse(AwardSendResponse awardSendResponse) {
    
    ...}
}

3.2.2.5、数据流转

在这里插入图片描述
抽奖平台实践中,数据流转如上图。首先领域的开放服务通过信息传输对象(Data Transfer Object, DTO)来完成与外界数据的交互;在领域内部,我们通过领域对象(Domain Object,DO)作为领域内部的数据和行为载体;在资源库内部,我们沿袭了原有的数据库持久化对象(Persistence Object, PO)进行数据库资源的交互。同时,DTO与DO的转换发生在资源库内。

3.2.2.6、上下文集成

集成上下文的手段有多种,常见的手段包括开发领域服务接口、开放http服务,及消息sub/pub。

在抽奖系统中,我们使用的是开放服务接口进行交互的。最明显的体现是计数上下文,他作为一个通用上下文,对抽奖、风控、活动准入等上下文都提供了访问接口。同时,如果在一个上下文对另一个上下文进行集成时,若需要一定的隔离和适配,可以引入防腐层的概念。

3.2.2.7、分离领域

接下来将在实施领域模型的过程中,如何应用到系统架构中。

如果我们维护一个从前到后的应用系统:
下图中领域服务是使用微服务技术剥离开来,独立部署,对外暴露的只是服务接口,领域对外暴露的业务逻辑职能依托于领域服务。

在这里插入图片描述
随着业务的发展,业务系统快速膨胀,我们的系统属于核心时:应用服务虽然没有领域逻辑,但涉及到了多个领域服务的编排。当业务规模庞大到一定程度,编排本身就包含了业务逻辑,那么此时应用服务对于外部来说,就是一个领域服务,整体看起来则是一个独立的限界上下文。

此时应用服务对内还属于应用服务,但对外已是领域服务的概念,需要将其暴露为微服务。
在这里插入图片描述

四、总结

DDD是讲述软件设计的“术”与“器”,本质是为了“高内聚、低耦合”。

猜你喜欢

转载自blog.csdn.net/shijinghan1126/article/details/110820126