DDD落地:从美团抽奖平台,看DDD在大厂如何落地?

尼恩说在前面

在40岁老架构师 尼恩的读者社区(50+)中,最近有小伙伴拿到了一线互联网企业如阿里、滴滴、极兔、有赞、希音、百度、网易、美团的面试资格,遇到很多很重要的面试题:

谈谈你的DDD落地经验?

谈谈你对DDD的理解?

如何保证RPC代码不会腐烂,升级能力强?

微服务如何拆分?

微服务爆炸,如何解决?

你们的项目,DDD是怎么落地实操的?

所以,这里尼恩给大家做一下系统化、体系化的梳理,使得大家可以充分展示一下大家雄厚的 “技术肌肉”,让面试官爱到 “不能自已、口水直流”

也一并把这个题目以及参考答案,收入咱们的 《尼恩Java面试宝典PDF》V127版本,供后面的小伙伴参考,提升大家的 3高 架构、设计、开发水平。

最新《尼恩 架构笔记》《尼恩高并发三部曲》《尼恩Java面试宝典》的PDF,请到文末公号【技术自由圈】获取

除了本文,尼恩输出了一个 《从0到1,带大家精通DDD》系列,帮助大家彻底掌握DDD,链接地址是:

《阿里DDD大佬:从0到1,带大家精通DDD》

《阿里大佬:DDD 落地两大步骤,以及Repository核心模式》

《阿里大佬:DDD 领域层,该如何设计?》

《极兔面试:微服务爆炸,如何解决?Uber 是怎么解决2200个微服务爆炸的?》

《阿里大佬:DDD中Interface层、Application层的设计规范》

《字节面试:请说一下DDD的流程,用电商系统为场景》

《DDD如何落地:去哪儿的DDD架构实操之路》

《DDD落地:从腾讯视频DDD重构之路,看DDD极大价值》

大家可以先看前面的文章,再来看本篇,效果更佳。

另外,尼恩会结合一个工业级的DDD实操项目,在第34章视频《DDD的顶奢面经》中,给大家彻底介绍一下DDD的实操、COLA 框架、DDD的面试题。

文章目录

    • 尼恩说在前面
    • 美团抽奖平台DDD架构实操之路
    • 过度耦合
    • 贫血症和失忆症
    • 软件系统复杂性应对
    • 与微服务架构相得益彰
    • 战略建模
      • 领域
      • 限界上下文
      • 划分限界上下文
      • 上下文映射图
    • 战术建模——细化上下文
    • DDD工程实现
      • 模块
      • 领域对象
      • 资源库
      • 防腐层
      • 领域服务
      • 数据流转
      • 上下文集成
      • 分离领域
    • 参考书籍
    • 说在最后
    • 尼恩技术圣经系列PDF

美团抽奖平台DDD架构实操之路

作者:文彬、子维,美团点评资深研发工程师,毕业于南京大学,现从事美团外卖营销相关的研发工作。

至少30年以前,一些软件设计人员就已经认识到领域建模和设计的重要性,并形成了一种潮流,后来由 Eric Evans 将其命名为领域驱动设计(Domain-Driven Design,简称 DDD)。在互联网开发环境中,DDD 似乎显得有些“陈旧且缓慢”。然而,随着互联网公司逐渐深入实体经济,业务日趋复杂,我们在开发过程中也遇到了越来越多的传统行业软件开发所面临的问题。本文将首先探讨这些问题,然后尝试在实践中运用 DDD 的思想来解决这些问题。

过度耦合

在业务初期,我们的功能相对简单,普通的 CRUD 操作就能满足需求,此时系统结构清晰易懂。然而,随着迭代演进,业务逻辑变得越来越复杂,系统也逐渐变得庞大。各个模块之间的关联日益紧密,以至于难以明确划分某个模块的具体功能意图。在修改某个功能时,不仅要花费大量时间回溯所需修改的点,还需担忧修改带来的未知影响。

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

DDD落地:从美团抽奖平台,看DDD在大厂如何落地?_第1张图片

服务耦合示意图

在订单服务接口中,提供了查询、创建订单相关的接口,以及订单评价、支付、保险等接口。同时,我们的表格也是一个包含大量字段的订单大表。在维护代码时,一个小变动可能会影响到整个系统。很多时候,我们只想修改评价相关的功能,却意外地影响了创建订单的核心路径。尽管我们可以通过测试确保功能的完备性,但在订单领域有大量需求同时进行开发时,修改重叠、恶性循环,导致我们疲于奔命地修复各种问题。

这些问题本质上源于系统架构不清晰,划分出来的模块内聚度低、耦合度高。

为解决这一问题,我们可以采用演进式设计的理念,让系统设计随着实现的增长而增长。我们无需提前设计,只需让系统随业务成长而演进。敏捷实践中的重构、测试驱动设计和持续集成可以帮助我们应对各种混乱问题。重构可以改善代码质量,同时保持行为不变;测试驱动设计可以确保对系统的修改不会导致现有功能丢失或破坏;持续集成则让团队共享同一代码库。

在这三种实践中,重构是克服演进式设计中大杂烩问题的关键,通过在单独的类及方法级别上进行一系列小步重构来实现。我们可以很容易地将通用逻辑重构为一个独立的类,但你会发现这个类很难用业务含义来描述,只能给予一个技术维度上的解释。这会带来什么问题呢?新同学并不总是知道对通用逻辑的改动或获取来自该类。显然,制定项目规范并不是一个好主意。这种情况下,我们再次嗅到了代码即将腐败的气味。

实际上,你可能已经意识到问题所在。在解决现实问题时,我们将问题映射到脑海中的概念模型,然后在模型中解决问题,最后将解决方案转换为实际代码。上述问题在于我们解决了设计到代码之间的重构,但提炼出来的设计模型缺乏实际的业务含义,导致在开发新需求时,其他成员无法自然地将业务问题映射到设计模型。设计似乎变成了重构者的独角戏,代码继续恶化,不断重构……形成恶性循环。

领域驱动设计(DDD)能有效地解决领域模型到设计模型的同步和演化问题,最终将反映业务领域的设计模型转化为实际代码。

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

贫血症和失忆症

贫血领域对象

贫血领域对象(Anemic Domain Object)指的是仅具备数据承载功能,而缺乏行为和操作的领域对象。

在习惯于 J2EE 开发模式(如 Action/Service/DAO)的情况下,我们很容易写出过程式代码,从而使得所学的面向对象(OO)理论无法发挥其作用。在这种开发方式中,对象仅作为数据的载体,缺乏实际的行为。以数据为核心,数据库实体关系(ER)设计成为驱动。在这种开发模式下,分层架构可以理解为数据移动、处理和实现的过程。

以笔者最近开发的系统抽奖平台为例:

  • 场景需求

奖池中设有多种奖项,我们需要根据运营预先设置的概率抽取其中一个奖项。实现方法很简单,生成一个随机数,然后匹配符合该随机数生成概率的奖项即可。

  • 贫血模型实现方案

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

DDD落地:从美团抽奖平台,看DDD在大厂如何落地?_第2张图片

抽奖ER图

  • 设计AwardPool和Award两个对象,只有简单的get和set属性的方法
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 对象仅作为数据载体,没有实际行为。简单的业务系统采用这种贫血模型和过程化设计是没有问题的,但随着业务逻辑的复杂化,业务逻辑和状态分散在众多方法中,代码意图逐渐模糊,我们称之为“贫血症引发的失忆症”。

相较而言,领域驱动设计(DDD)将数据和行为封装在一起,并与现实世界中的业务对象相对应。各类对象分工明确,领域逻辑分散在领域对象中。以抽奖为例,概率选择和对应奖品的处理应放入 AwardPool 类中。

通过引入领域驱动设计,我们能更好地应对复杂业务场景,提高代码的可读性和可维护性。

软件系统复杂性应对

解决复杂和大规模软件的武器可以被粗略地归为三类:抽象、分治和知识。

分治:将问题领域划分为若干较小的、易于处理的子问题。这些子问题要足够简单,以便个人能够独立解决。同时,要考虑如何将这些子部分整合为整体。合理的分割能降低问题的复杂性,使整体装配过程中的细节追踪更为简便,从而更容易设计各部分的协作方式。评价分治效果的好坏,即判断其高内聚低耦合的程度。

抽象:运用抽象能简化问题空间,越小的抽象问题越容易理解。以从北京到上海的出差为例,我们可以先将其理解为使用交通工具出行,而不必一开始就详细考虑是乘坐高铁还是飞机,以及乘坐过程中应注意的事项。

知识 顾名思义,DDD可以认为是知识的一种。

DDD提供了这样的知识手段,让我们知道如何抽象出限界上下文以及如何去分治。

与微服务架构相得益彰

微服务架构与 DDD 在应对复杂性方面有相似之处。在创建微服务时,我们需要构建高内聚、低耦合的微服务。DDD 中的限界上下文理念与微服务不谋而合,可以将限界上下文视为一个微服务进程。

上述是从更直观的角度来描述两者的相似处。

在系统复杂之后,我们都需要用分治来拆解问题。一般有两种方式,技术维度和业务维度。技术维度是类似MVC这样,业务维度则是指按业务领域来划分系统。

微服务架构更强调从业务维度去做分治来应对系统复杂度,而DDD也是同样的着重业务视角。 如果两者在追求的目标(业务维度)达到了上下文的统一,那么在具体做法上有什么联系和不同呢?

我们将架构设计活动精简为以下三个层面:

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

以上三种活动,实际开发中,这些活动有先后顺序,但不一定固定。在面对常规问题时,我们自然会采用熟悉的分层架构(先确定系统架构),或选择合适的编程语言(先确定技术架构)。在业务不复杂的情况下,这种做法是合理的。

然而,跳过业务架构设计出来的架构关注点不在业务响应上,可能就是一个大泥球。在面临需求迭代或响应市场变化时,这种架构就会让人痛苦不堪。

DDD的核心诉求就是将业务架构映射到系统架构上,在响应业务变化调整业务架构时,也随之变化系统架构。而微服务追求业务层面的复用,设计出来的系统架构和业务一致;在技术架构上则系统模块之间充分解耦,可以自由地选择合适的技术架构,去中心化地治理技术和数据

综上所述,领域驱动设计(DDD)和微服务架构在应对复杂性方面有诸多相似之处,但它们在具体实践中有各自的侧重点和优势。在实际项目中,我们可以根据业务需求和系统复杂度,灵活运用这两种方法,实现更高效、易于维护的软件系统。

可以参见下图来更好地理解双方之间的协作关系:

DDD落地:从美团抽奖平台,看DDD在大厂如何落地?_第3张图片

DDD与微服务关系

我们将通过一个抽奖平台的实例,详细阐述如何运用领域驱动设计(DDD)来拆解一个基于微服务架构的中型系统,实现系统的高内聚和低耦合。

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

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

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

通过以上步骤,我们能够将复杂系统分解为更具内聚性和耦合度的模块。在实际开发过程中,团队成员需密切协作,不断积累和共享领域知识,以确保项目的顺利进行。此外,在面临需求变更或市场波动时,我们应灵活调整业务架构,实现系统的高效响应和持续优化。

战略建模

在领域驱动设计(DDD)中,战略和战术设计有着明确的划分。战略设计主要关注高层次和宏观层面的限界上下文划分与集成,而战术设计则聚焦于运用建模工具对上下文进行更为具体的细化。

领域

现实世界中,领域包含了问题域和解系统。软件开发可以视为对现实世界的部分模拟。在 DDD 中,解系统可映射为多个限界上下文,每个上下文代表针对问题域的一个特定、有限解决方案。

限界上下文

限界上下文

一个由显式边界限定的特定职责范围。领域模型位于此边界内。在此范围内,每个模型概念(包括其属性和操作)都具有特殊含义。

一个给定的业务领域包含多个限界上下文。要与某个上下文进行通信,需通过显式边界进行交互。系统通过确定性的限界上下文实现解耦,使每个上下文内部组织紧密、职责明确,具备较高的内聚性。

一个形象的隐喻:细胞质之所以存在,是因为细胞膜限定了细胞内外的物质,并确定了何种物质可通过细胞膜。

划分限界上下文

划分限界上下文的方法在 Eric Evans 和 Vaughn Vernon 的著作中并未详细提及。

我们不应根据技术架构或开发任务来创建限界上下文,而应关注语义边界。

我们的实践是,首先考虑产品所使用的通用语言,提取一些术语称为概念对象,并探讨对象之间的联系;或者从需求中提取动词,观察动词与对象之间的关系;我们将紧密耦合的元素圈在一起,研究其内在联系,从而确定相应的界限上下文。形成后,我们可以尝试用语言描述界限上下文的职责,看其是否清晰、准确、简洁和完整。简而言之,限界上下文应从需求出发,根据领域进行划分

如前所述,我们将用户分为运营和用户。运营负责复杂但相对低频的抽奖活动配置,而用户对配置的使用则呈高频且无感知。根据这一业务特点,我们将抽奖平台划分为面向 C 端的抽奖和面向 M 端的抽奖管理平台两个子域,实现完全解耦。

DDD落地:从美团抽奖平台,看DDD在大厂如何落地?_第4张图片

抽奖平台领域

在明确了 M 端和 C 端的界限上下文后,我们进一步对各自主体内进行界限上下文的划分。此处,我们以 C 端为例进行说明。

产品的需求概述如下:

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

依据产品需求,我们提炼出关键概念作为子域,构建限界上下文。

DDD落地:从美团抽奖平台,看DDD在大厂如何落地?_第5张图片

C端抽奖领域

首先,抽奖上下文作为整个领域的核心,负责处理用户抽奖业务,涵盖奖品和用户群体概念。

  • 在设计初期,我们曾考虑将抽奖与发奖划分为两个领域,前者负责选奖,后者负责发放奖品。然而,实际开发过程中发现这两部分逻辑紧密相连,不易分割。并且,发奖逻辑相对简单,仅需调用第三方服务发放奖品,不足以独立成为一个领域。

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

关于抽奖奖品库存量,库存行为与奖品本身相对独立,库存关注点更多在于库存核销,且库存具备通用性,可被非奖品内容使用。因此,我们设立了独立的库存上下文。

鉴于 C 端存在刷单行为,我们根据产品需求设立了风控上下文,用于对活动进行风险控制。最后,活动准入、风控、抽奖等领域均涉及次数限制,故我们设立了计数上下文。

通过领域驱动设计(DDD)的限界上下文划分,我们明确了抽奖、活动准入、风控、计数、库存等五个上下文,确保每个上下文在系统中具备高度内聚性。

上下文映射图

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

康威(梅尔·康威)定律

任何组织在设计一套系统(广义概念上的系统)时,其提交的设计方案在结构上都会与该组织的沟通架构保持一致。

康威定律表明,系统结构应尽可能与组织结构保持一致。在这里,我们认为团队结构(无论是内部团队还是跨团队)都属于组织结构,而限界上下文则是系统的业务结构。因此,团队结构应与限界上下文保持一致。

梳理清楚上下文之间的关系,从团队内部的关系来看,有如下好处:

  1. 任务更好拆分,一个开发人员都能全力以赴地投入到某个单一的上下文中;
  2. 沟通交流更为顺畅,每个上下文都能明确自己对其他上下文的依赖关系,使团队内部开发工作更好地相互对接。

从团队间的关系来看,明确的上下文关系能带来以下帮助:

  1. 每个团队在自己的上下文中能更明确地理解自己领域内的概念,因为上下文是领域的子系统;
  2. 对于限界上下文之间的交互,团队与上下文的一致性保证了我们有明确的对接团队和依赖的上下游。

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

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

上述内容定义了上下文之间的映射关系。经过仔细考虑,抽奖平台上下文的映射关系图如下:

DDD落地:从美团抽奖平台,看DDD在大厂如何落地?_第6张图片

上下文映射关系

由于抽奖、风控、活动准入、库存、计数五个上下文均属于抽奖领域内部,因此它们之间形成了“同甘共苦,共进退”的合作关系(Partnership,简称 PS)。

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

通过上下文映射关系,我们明确的限制了限界上下文的耦合性,即在抽奖平台中,无论是上下文内部交互(合作关系)还是与外部上下文交互(防腐层),耦合度都限定在数据耦合(Data Coupling)的层级。

战术建模——细化上下文

梳理清楚上下文之间的关系后,我们需要从战术层面上剖析上下文内部的组织关系。首先看下DDD中的一些定义。

实体

当一个对象的识别(而非属性)使其具有独特性时,该对象被称为实体(Entity)。

例:最简单的,公安系统的身份信息录入,对于人的模拟,即认为是实体,因为每个人是独一无二的,且其具有唯一标识(如公安系统分发的身份证号码)。

在实践上建议将属性的验证放到实体中。

值对象

当一个对象用于对事务进行描述而没有唯一标识时,它被称作值对象(Value Object)。

例:比如颜色信息,我们只需要知道{“name”:“黑色”,”css”:“#000000”}这样的值信息就能够满足要求了,这避免了我们对标识追踪带来的系统复杂性。

值对象很重要,尤其在习惯使用数据库进行数据建模后,容易将所有对象都视为实体。使用值对象可以优化系统性能、简化设计。

它具有不变性、相等性和可替换性。

实践中,值对象创建后不应再允许外部修改其属性。在不同上下文整合时,可能会出现模型概念的共享,如商品模型在电商的各个上下文中都有。若在订单上下文中仅关注下单时的商品信息快照,将商品对象视为值对象是合理的选择。

聚合根

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

聚合是一个非常重要的概念,核心领域往往都需要用聚合来表达。其次,聚合在技术上有非常高的价值,可以指导详细设计。

聚合由根实体,值对象和实体组成。

如何创建好的聚合?

  • 边界内的内容具有一致性:在一个事务中只修改一个聚合实例。若边界内难以实现强一致性,无论出于性能或产品需求考虑,都应考虑拆分成独立的聚合,采用最终一致性。
  • 设计小聚合:大部分聚合仅包含根实体,无需包含其他实体。即使需要包含,也可以考虑将其创建为值对象。
  • 通过唯一标识来引用其他聚合或实体:当对象之间存在关联时,建议引用其唯一标识而非整体对象。如果是外部上下文中的实体,引用其唯一标识或将需要的属性构造为值对象。若聚合创建复杂,建议使用工厂方法来屏蔽内部创建逻辑。

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

领域服务

一些重要的领域行为或操作可以归为领域服务。领域服务既不属于实体,也不属于值对象的范畴。

采用微服务架构风格后,所有领域逻辑的对外暴露都需通过领域服务进行。例如,原本由聚合根暴露的业务逻辑也需要依赖领域服务。

领域事件

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

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

DDD落地:从美团抽奖平台,看DDD在大厂如何落地?_第7张图片

抽奖上下文

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

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

谨慎使用值对象

在实践中,我们发现部分领域对象符合值对象理念,但随着业务变化,许多原有定义需调整。值对象可能在业务层面具有唯一标识需求,而对这类值对象的重构成本较高。因此,在特定场景下,我们要根据实际情况权衡领域对象选择。

DDD工程实现

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

模块

在 DDD 中,模块(Module)被明确作为控制限界上下文的一种方法,我们一般在工程中力求用一个模块来体现一个领域的限界上下文。

如代码中所示,一般的工程中包的组织方式为{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.*;//库存上下文

代码演示1 模块的组织

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

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.*;//领域防腐层

代码演示2 模块的组织

每个模块的具体实现,我们将在下文中展开。

领域对象

前文提到,领域驱动的一个重要目标是解决对象的贫血问题。这里,我们用之前定义的抽奖(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) {...}
}

代码演示3 DrawLottery

在匹配到一个具体的奖池之后,需要确定最后给用户的奖品是什么。这部分的领域功能在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;
    }
}

代码演示4 AwardPool

与以往的仅有getter、setter的业务对象不同,领域对象拥有了行为,变得更加丰满。同时,相较于将此类逻辑写在服务中(例如**Service),领域功能的内聚性更强,职责更加明确。

资源库

领域对象需要存储资源,存储方式多种多样,如数据库、分布式缓存、本地缓存等。资源库(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;//资源库访问对象-抽奖资源库

代码演示5 Repository组织结构

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

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

与过去将资源管理放在服务中的做法相比,由资源库负责管理资源,职责更明确,代码可读性和可维护性更强。

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) {...}
}

代码演示6 DrawLotteryRepository

防腐层

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

有以下几种情况会考虑引入防腐层:

  • 需要将外部上下文的模型转换为本地上下文能理解的模型。
  • 不同上下文之间的团队合作,如果是供体关系,建议引入防腐层,以防止外部上下文变化对本上下文的侵蚀。
  • 该访问本上下文使用广泛,为了避免改动影响范围过大。

如果内部多个上下文需要访问外部上下文,可以考虑将其纳入通用上下文中。

在抽奖平台中,我们定义了用户城市信息防腐层(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) {...}
}

代码演示7 UserCityInfoFacade

领域服务

上文中,我们将领域行为封装至领域对象,资源管理行为纳入资源库,外部上下文交互行为通过防腐层实现。如此一来,领域服务的职责变得更加明确,即充当领域内对象行为(如领域对象、资源库和防腐层等)的串联,为其他上下文提供交互接口。

我们以抽奖服务(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) {...}
}

代码演示8 LotteryService

数据流转

DDD落地:从美团抽奖平台,看DDD在大厂如何落地?_第8张图片

数据流转

在抽奖平台实践中,数据流动如图所示。首先,领域开放服务通过数据传输对象(DTO)与外界进行数据交互;领域内部利用领域对象(DO)作为数据和行为载体;资源库内部则沿用原有数据库持久化对象(PO)进行数据库资源交互。DTO 与 DO 的转换发生在领域服务内,DO 与 PO 的转换则在资源库内完成。

相较于传统业务服务,当前编码规范可能多了一次数据转换,但各数据对象职责清晰,数据流动更加明确。

上下文集成

通常有多种方法可以集成上下文,常见的包括开放领域服务接口、开放 HTTP 服务以及消息发布 - 订阅机制。

在抽奖系统中,我们使用开放服务接口进行交互。最明显的例子是计数上下文,它作为一个通用上下文,为抽奖、风控、活动准入等上下文提供了访问接口。同时,如果在集成一个上下文时需要一定的隔离和适配,可以引入防腐层的概念。这部分的示例可以参考前文的防腐层代码示例。

分离领域

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

我们采用微服务架构风格,与 Vernon 在《实现领域驱动设计》中的观点略有差异。具体差异可以参考他的书籍。

如果我们维护一个从前到后的应用系统:

在下图中,领域服务采用微服务技术剥离,独立部署,对外仅暴露服务接口。领域对外的业务逻辑需依托于领域服务。而在 Vernon 的著作中,未假设微服务架构,因此领域层除领域服务外,还包括聚合、实体和值对象等。应用服务层相对简单,负责接收接口层请求参数,调度多个领域服务以实现界面层功能。

DDD落地:从美团抽奖平台,看DDD在大厂如何落地?_第9张图片

DDD-分层

随着业务发展,业务系统快速膨胀,我们的系统处于核心地位时:

应用服务虽然没有领域逻辑,但涉及了对多个领域服务的编排。当业务规模庞大到一定程度,编排本身就富含了业务逻辑(除此之外,应用服务在稳定性、性能方面所做的措施也希望统一起来,而非散落各处),那么此时应用服务对于外部来说是一个领域服务,整体看起来则是一个独立的限界上下文。

此时应用服务对内仍属于应用服务,对外已是领域服务的概念,需要将其暴露为微服务。

DDD落地:从美团抽奖平台,看DDD在大厂如何落地?_第10张图片

DDD-系统架构图

注:具体架构实践可根据团队和业务实际情况进行,此处仅为作者业务实践。除分层架构外,CQRS 架构也是不错的选择。

以下为一个示例。我们定义了抽奖、活动准入、风险控制等多个领域服务。在本系统中,需集成多个领域服务,为客户端提供功能完备的抽奖应用服务。这个应用服务的组织如下:

package ...;
  
import ...;
  
@Service
public class LotteryApplicationService {
    @Autowired
    private LotteryRiskService riskService;
    @Autowired
    private LotteryConditionService conditionService;
    @Autowired
    private LotteryService lotteryService;
     
    //用户参与抽奖活动
    public Response<PrizeInfo, ErrorData> participateLottery(LotteryContext lotteryContext) {
        //校验用户登录信息
        validateLoginInfo(lotteryContext);
        //校验风控 
        RiskAccessToken riskToken = riskService.accquire(buildRiskReq(lotteryContext));
        ...
        //活动准入检查
        LotteryConditionResult conditionResult = conditionService.checkLotteryCondition(otteryContext.getLotteryId(),lotteryContext.getUserId());
        ...
        //抽奖并返回结果
        IssueResponse issueResponse = lotteryService.issurLottery(lotteryContext);
        if(issueResponse!=null && issueResponse.getCode()==IssueResponse.OK) {
            return buildSuccessResponse(issueResponse.getPrizeInfo());
        } else {   
            return buildErrorResponse(ResponseCode.ISSUE_LOTTERY_FAIL, ResponseMsg.ISSUE_LOTTERY_FAIL)
        }
    }
  
    private void validateLoginInfo(LotteryContext lotteryContext){...}
    private Response<PrizeInfo, ErrorData> buildErrorResponse (int code, String msg){...}
    private Response<PrizeInfo, ErrorData> buildSuccessResponse (PrizeInfo prizeInfo){...}
} 

代码演示9 LotteryApplicationService

在本文中,我们采用了分治的思想,从抽象到具体探讨了领域驱动设计(DDD)在实际互联网业务系统中的应用。通过这一有力工具,我们使得系统架构更加合理。

然而,需要指出的是,如果你的系统相对简单,或者只是类似于 SmartUI 这样的项目,那么可能并不需要采用 DDD。尽管本文对贫血模型、演进式设计提出了一些观点,但在特定范围和场景下,它们可能更为高效。读者应根据自身实际情况做出选择,找到最适合自己的方案。

本文通过介绍 DDD 的软件设计原则和方法,旨在实现高内聚低耦合,紧密贴合本质。读者可以根据自己的理解和团队状况实践 DDD。

参考书籍

  • Eric Evans.领域驱动设计.赵俐 盛海艳 刘霞等译.人民邮电出版社,2016.
  • Vaughn Vernon.实现领域驱动设计.滕云译.电子工业出版社,2014.

说在最后

DDD架构如何落地,是是非常常见的面试题。

以上的内容,如果大家能对答如流,如数家珍,基本上 面试官会被你 震惊到、吸引到。

在面试之前,建议大家系统化的刷一波 5000页《尼恩Java面试宝典PDF》,并且在刷题过程中,如果有啥问题,大家可以来 找 40岁老架构师尼恩交流。

最终,让面试官爱到 “不能自已、口水直流”。offer, 也就来了。

当然,关于DDD,尼恩即将给大家发布一波视频 《第34章:DDD的顶奢面经》, 帮助大家彻底穿透DDD。

尼恩技术圣经系列PDF

  • 《NIO圣经:一次穿透NIO、Selector、Epoll底层原理》
  • 《Docker圣经:大白话说Docker底层原理,6W字实现Docker自由》
  • 《K8S学习圣经:大白话说K8S底层原理,14W字实现K8S自由》
  • 《SpringCloud Alibaba 学习圣经,10万字实现SpringCloud 自由》
  • 《大数据HBase学习圣经:一本书实现HBase学习自由》
  • 《大数据Flink学习圣经:一本书实现大数据Flink自由》
  • 《响应式圣经:10W字,实现Spring响应式编程自由》
  • 《Go学习圣经:Go语言实现高并发CRUD业务开发》

……完整版尼恩技术圣经PDF集群,请找尼恩领取

《尼恩 架构笔记》《尼恩高并发三部曲》《尼恩Java面试宝典》PDF,请到下面公号【技术自由圈】取↓↓↓

你可能感兴趣的:(技术圣经,面试,面试,java,后端,架构,系统架构,中间件,微服务)