DDD领域驱动设计入门目录
- Q1:为什么需要DDD领域驱动设计模型?
- Q2:DDD领域驱动设计模型怎么用?
- 设计领域模型的一般步骤
- 一、战略建模(从高处俯瞰业务 - 微服务的宏观规划)
- 1. 领域划分
- 2. 界限上下文定义
- 3. 统一语言构建
- 二、战术建模(深入细节看业务 - 微服务内部的具体构建)
- 1. 领域对象建模
- 2. 聚合设计
- 3. 领域服务设计
- Q3:那么具体该怎么应用DDD呢?
- 步骤1:根据需求划分领域及细化上下文
- 步骤2:分析上下文的实体和值对象
- Q4:为什么要使用聚合
- Q5:如何创建好的聚合?
- Q6: 如何在工程中使用DDD?
- 数据流转
- 上下文集成
- 分离领域
- 讨论
- References
Q1:为什么需要DDD领域驱动设计模型?
- 为了解决对象的贫血问题
- 为了解决服务的过度耦合问题
- 解决微服务的边界模糊问题
问题1: 对象的贫血问题
贫血症和失忆症
贫血领域对象
贫血领域对象(Anemic Domain Object)是指仅用作数据载体,而没有行为和动作的领域对象。
看到这个描述,有没有感到很熟悉?是的,这就是平时后端开发中用到很多的bean对象。
在我们习惯了J2EE的开发模式后,Action/Service/DAO这种分层模式,会很自然地写出过程式代码,而学到的很多关于OO理论的也毫无用武之地。使用这种开发方式,对象只是数据的载体,没有行为。以数据为中心,以数据库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;//概率......
}
然后设计一个LotteryService,在其中的drawLottery()方法写服务逻辑。
按照我们通常思路实现,可以发现:在业务领域里非常重要的抽奖,我的业务逻辑都是写在Service中的,Award充其量只是个数据载体,没有任何行为。简单的业务系统采用这种贫血模型和过程化设计是没有问题的,但在业务逻辑复杂了,业务逻辑、状态会散落到在大量方法中,原本的代码意图会渐渐不明确,我们将这种情况称为由贫血症引起的失忆症。
更好的是采用领域模型的开发方式,将数据和行为封装在一起,并与现实世界中的业务对象相映射。各类具备明确的职责划分,将领域逻辑分散到领域对象中。继续举我们上述抽奖的例子,使用概率选择对应的奖品就应当放到AwardPool类中。
问题2: 服务的过度耦合问题
业务初期,业务的功能大都非常简单,普通的CRUD就能满足,此时系统是清晰的。随着业务的扩大,程序也在不断迭代,业务逻辑变得越来越复杂,系统也变得越来越冗杂。模块彼此关联,谁都很难说清模块的具体功能意图是啥。修改一个功能时,往往光回溯该功能需要的修改点就需要很长时间,数据库的字段还可能复用,更别提修改带来的不可预知的影响面。
下图是一个常见的系统耦合病例
订单服务接口中提供了查询、创建订单相关的接口,也提供了订单评价、支付、保险的接口。同时我们的表也是一个订单大表,包含了非常多字段。在我们维护代码时,牵一发而动全身,很可能只是想改下评价相关的功能,却影响到了创单核心路径。虽然我们可以通过测试保证功能完备性,但当我们在订单领域有大量需求同时并行开发时,改动重叠、恶性循环、疲于奔命修改各种问题。
上述问题,归根到底在于系统架构不清晰,划分出来的模块内聚度低、高耦合。
问题3: 决微服务的边界模糊问题
微服务拆分困境产生的根本原因:不知道业务或者微服务的边界到底在什么地方。
DDD 核心思想:通过领域驱动设计方法定义领域模型,从而确定业务和应用边界,保证业务模型与代码模型的一致性。
一拍即合,通过DDD先确定业务和应用边界,自然也就确定了对应的微服务的边界,确保微服务不会因为边界模糊而变的过于庞大和复杂。
假设没有使用DDD,当一个系统变得过度庞大负责,充斥着冗余逻辑以及复杂的交互逻辑的时候,大多数的解决方案就是重构!重构!重构!这也是我入职后听同事喊得最多的一句话。
在重构过程中,我们可以很容易重构出一个独立的类来放某些通用的逻辑,但是你会发现你很难给它一个业务上的含义,只能给予一个技术维度描绘的含义。
这就是,没有将业务架构反映到系统架构上,导致部分类的含义模糊不清。这还会导致,代码在维护性差,随着不断交接,接手代码的人无法理解代码和业务的对应关系,就又会导致重构,不断循环。
这就是为什么需要DDD领域驱动设计模型。
Q2:DDD领域驱动设计模型怎么用?
设计领域模型的一般步骤
- 根据需求划分出初步的领域和限界上下文,以及上下文之间的关系;
- 进一步分析每个上下文内部,识别出哪些是实体,哪些是值对象;
- 对实体、值对象进行关联和聚合,划分出聚合的范畴和聚合根;
- 为聚合根设计仓储,并思考实体或值对象的创建方式;
- 在工程中实践领域模型,并在实践中检验模型的合理性,倒推模型中不足的地方并重构。
限界上下文
为了方便理解,限界上下文可以片面的理解为微服务,但是不等同。限界上下文主要是从领域知识的角度划分出不同的语义边界,在这个边界内,领域模型有自己独立的概念、规则和语言。当将 DDD 应用于微服务架构时,一个限界上下文通常可以对应一个微服务,但也有可能多个限界上下文在一些情况下会合并到一个微服务中,或者一个限界上下文被拆分成多个微服务。限界上下文和微服务如何对应取决于具体的业务复杂度、性能要求、团队组织等因素。
限界上下文关系
按照这种理解的话,上下文之间的关系也可以片面的理解为各个微服务的交互逻辑。例如,可能是上下游关系,一个限界上下文为另一个限界上下文提供数据或者服务;也可能是共享核心领域知识的平行关系
关联
在微服务架构下,关联和聚合是对微服务内部领域对象(实体和值对象)之间关系的梳理。关联体现了对象之间的相互联系,例如在一个 “用户服务” 微服务中,“用户” 实体和 “用户角色” 实体可能存在关联关系,一个用户可以拥有多个角色,这种多对多的关系就是通过关联来表示的。
聚合
聚合是一种更高级的组合关系,它将一组相关的对象组合成一个整体,用于维护领域对象之间的一致性边界。例如,在 “订单服务” 微服务中,“订单” 实体是聚合根,“订单项”(表示订单中的具体商品项)和 “订单支付信息” 等值对象围绕在 “订单” 实体周围,它们共同构成一个聚合。“订单” 作为聚合根负责控制整个聚合内部对象的生命周期和一致性。当创建一个订单时,会同时创建相关的订单项和支付信息;当删除订单时,订单项和支付信息也会相应地被删除或更新。
站在DDD的角度,上述的步骤可以被划分为两个部分,战略建模(1)和战术建模(2-5)
一、战略建模(从高处俯瞰业务 - 微服务的宏观规划)
1. 领域划分
核心领域确定
假设我们在构建一个电商系统的微服务架构。核心领域就像是最关键的微服务,它承载着电商系统最核心的业务价值。以电商为例,商品交易这个核心领域可以对应一个 “订单处理微服务”。这个微服务负责处理从顾客下单、支付到商家发货等关键流程,就像心脏对于人体一样,是整个电商系统的核心动力,直接关系到企业的主要收入来源。
子领域划分
把电商业务这个大蛋糕划分成子领域,就像是划分不同的微服务模块。例如,用户管理子领域可以对应 “用户服务微服务”,专门负责处理用户的注册、登录、信息修改等操作;商品管理子领域对应 “商品服务微服务”,用于商品的添加、修改、删除以及商品信息的管理;订单管理子领域就是前面提到的 “订单处理微服务”。每个微服务(子领域)都有自己相对独立的边界,它们可以独立开发和更新,就像一个个独立的小机器人,但它们之间又通过接口等方式相互协作,共同构成完整的电商系统。
2. 界限上下文定义
明确边界
每个微服务可以先片面地理解为一个界限上下文。在电商系统的 “订单处理微服务”(界限上下文)中,“订单状态” 有自己独立的定义和转换规则。例如,从 “已下单” 到 “已支付”、“已发货” 等状态的转变条件和流程,都在这个微服务内部定义,就像一个小国家(微服务)有自己的法律(业务规则)来管理国内事务(订单状态),和其他微服务(国家)的规则是分开的。
上下文映射
不同微服务(界限上下文)之间的关系,类似于不同国家之间的贸易或合作关系。在电商系统中,“用户服务微服务” 和 “订单处理微服务” 之间存在上下游关系,就像原材料供应国和加工国的关系。用户信息(原材料)从 “用户服务微服务” 提供给 “订单处理微服务” 来创建订单(加工)。而 “商品服务微服务” 和 “订单处理微服务” 之间可能存在共享数据关系,就像两个国家共享一些资源一样。“订单处理微服务” 需要从 “商品服务微服务” 获取商品信息,用于订单中的商品详情展示等功能。
需要注意的是,限界上下文和微服务有相似之处,但不完全相同。在实际应用中,一个限界上下文可能会拆分成多个微服务,或者多个限界上下文合并为一个微服务,这取决于业务的复杂程度、性能要求等多种因素。
3. 统一语言构建
业务与技术术语统一
在电商系统的微服务架构中,统一语言就像是一种通用的 “国际语言”。以物流系统为例,如果是一个 “物流跟踪微服务”,“包裹派送轨迹” 这个术语对于业务人员和技术人员都要有明确一致的含义。对于业务人员,它是包裹从发货地到收货地的运输路线记录;对于技术人员,这个术语涉及到微服务中的数据库存储结构(如何存储轨迹数据)、数据更新时机(什么时候记录新的轨迹点)以及如何向其他微服务或者外部用户展示轨迹信息等技术细节。
二、战术建模(深入细节看业务 - 微服务内部的具体构建)
1. 领域对象建模
实体建模
在微服务内部,实体就像是有身份标识的重要 “居民”。以银行系统微服务为例,“客户” 和 “账户” 是实体。“客户” 实体有唯一的客户编号作为身份标识,在 “客户服务微服务” 中存储和管理。它有属性如姓名、联系方式等,还有行为如开户操作。“账户” 实体有账号作为身份标识,在 “账户服务微服务”(如果有单独划分的话)中管理,有余额等属性和存款、取款等行为。这些实体及其属性和行为,体现了它们在微服务所负责的业务领域中的关键角色,就像每个居民在小镇(微服务)中有自己的身份、财产和活动一样。
值对象建模
值对象在微服务里像是辅助实体的小工具。在电商系统的 “商品服务微服务” 中,“商品价格” 和 “商品尺寸” 是值对象。它们没有像实体那样的身份标识,而且一旦创建就不可变。例如,“商品价格” 值对象用于在计算购物车总价(可能在 “购物车微服务” 中)时传递价格数据和进行计算。它们就像一次性的工具,用完即扔(如果要修改价格,就相当于重新创建一个新的值对象),主要用于帮助实体完成一些特定的业务操作。
2. 聚合设计
聚合根定义
在微服务中,聚合就像一个小社区,聚合根是这个社区的 “居委会主任”。以 “订单处理微服务” 为例,“订单” 可以作为聚合根。所有和订单相关的操作,如添加商品到订单、修改订单状态等,都要通过 “订单” 这个聚合根来进行,就像社区里的任何事务都要通过居委会主任来协调一样。聚合根负责维护聚合内部对象之间的一致性和完整性,它执行社区(聚合)内的业务规则。例如,当修改订单状态时,聚合根要确保订单明细、商品折扣等相关信息也能正确更新,以保证整个订单聚合的状态是正确的。
聚合边界确定
每个聚合的边界确定了微服务内部的一个小范围,里面的实体和值对象就像社区里的居民和公共设施一样关系紧密。在订单聚合这个小社区(在 “订单处理微服务” 中),除了订单这个聚合根(居委会主任),还包括订单明细(实体居民)、商品折扣信息(值对象公共设施)等。这个边界规定了哪些对象要一起被处理和维护,就像社区的范围规定了哪些居民和设施归这个居委会管理一样,这样可以保证业务规则在这个小范围内的一致性,避免混乱。
3. 领域服务设计
服务识别
在微服务中,有些业务逻辑就像特殊的 “服务机构”,没办法放在实体或者值对象这些普通 “居民” 家里。例如在电商系统的 “库存管理微服务” 中,“库存检查和扣减” 这个业务逻辑涉及多个实体(商品库存实体、订单商品明细实体)和复杂的业务规则。它就像一个大型的公共服务机构,不适合只放在商品实体或者订单实体这些小居民家里,所以我们定义一个 “库存管理服务” 来处理这些复杂的业务逻辑。这个服务就像一个专门的服务中心,为整个微服务或者其他相关微服务提供特定的服务。
服务接口定义
“库存管理服务” 的接口就像是服务中心的服务窗口和服务指南。它规定了服务提供的操作和输入输出参数。例如,接口可能包括 “checkStockAvailability(商品 ID 列表,订单数量列表)” 和 “deductStock(订单 ID)” 等操作。其他微服务(就像其他社区或者个人)看到这个服务窗口的指南(接口),就知道怎么用这个服务来完成库存检查和扣减的业务操作。
Q3:那么具体该怎么应用DDD呢?
现在需要设计一个抽奖活动,需求如下
- 抽奖活动的使用者分为抽奖用户及运营
- 抽奖活动有活动限制,例如用户的抽奖次数限制,抽奖的开始和结束的时间等;
- 一个抽奖活动包含多个奖品,可以针对一个或多个用户群体;
- 奖品有自身的奖品配置,例如库存量,被抽中的概率等,最多被一个用户抽中的次数等等;
- 用户群体有多种区别方式,如按照用户所在城市区分,按照新老客区分等;
- 活动具有风控配置,能够限制用户参与抽奖的频率。
步骤1:根据需求划分领域及细化上下文
我们进行战略建模,根据需求划分出初步的领域和限界上下文,以及上下文之间的关系。限界上下文应该从需求出发,按领域划分。
根据上述需求,可知使用者分为抽奖用户及运营,那么就先考虑这两个使用群体的特点。
- 运营:对抽奖活动的配置十分复杂但相对低频
- 用户:用户对这些抽奖活动的使用是高频次,且不需要使用配置
根据这样的特点,可以尝试将抽奖领域分为运营使用的抽奖管理平台领域,以及用户的用户抽奖领域。
因为该产品主要是面向用户的,这里以用户的C端来举例。结合上面的产品活动需求描述,我们进一步细化C端的用户抽奖领域。
- 抽奖上下文:负责抽奖、发奖
- 活动准入上下文:定义活动条件,活动开始时间、结束时间、活动参与次数等限制条件
- 库存上下文:由于库存的行为与奖品本身相对解耦,关注点更多是库存内容的核销,且库存本身具备通用性,可以被奖品之外的内容使用,因此我们定义了独立的库存上下文。
- 风控上下文:防止刷单行为
- 计数上下文:活动准入、风控、抽奖等领域都涉及到一些次数的限制,因此定义了计数上下文
现在已经完成了上下文的划分,下一步应该明确上下文之间的关系。明确上下文关系的时候,可以遵守康威定律
康威定律
任何组织在设计一套系统(广义概念上的系统)时,所交付的设计方案在结构上都与该组织的沟通结构保持一致。
明确上下文之间的关系,从团队内部的关系来看,有如下好处:
- 任务更好拆分,一个开发人员可以全身心的投入到相关的一个单独的上下文中;
- 沟通更加顺畅,一个上下文可以明确自己对其他上下文的依赖关系,从而使得团队内开发直接更好的对接。
从团队间的关系来看,明确的上下文关系能够带来如下帮助:
- 每个团队在它的上下文中能够更加明确自己领域内的概念,因为上下文是领域的解系统;
- 对于限界上下文之间发生交互,团队与上下文的一致性,能够保证我们明确对接的团队和依赖的上下游。
简单的说就是明确了上下文关系可以帮助团队内外更明确系统的整体交互以及自己的工作,总之就是好处挺多的。
限界上下文之间的映射关系
- 合作关系(Partnership):两个上下文紧密合作的关系,一荣俱荣,一损俱损。
- 共享内核(Shared Kernel):两个上下文依赖部分共享的模型。
- 客户方-供应方开发(Customer-Supplier Development):上下文之间有组织的上下游依赖。
- 遵奉者(Conformist):下游上下文只能盲目依赖上游上下文。
- 防腐层(Anticorruption Layer):一个上下文通过一些适配和转换与另一个上下文交互。
- 开放主机服务(Open Host Service):定义一种协议来让其他上下文来对本上下文进行访问。
- 发布语言(Published Language):通常与OHS一起使用,用于定义开放主机的协议。
- 大泥球(Big Ball of Mud):混杂在一起的上下文关系,边界不清晰。
- 另谋他路(SeparateWay):两个完全没有任何联系的上下文。
那么基于上面的上下文划分图,以及上下文之间的映射关系,可以得到下面这张上下文关系图
其中上半边是我们上面划分的C端-用户抽奖系统,既然将他们划分为了1个系统,也就说明了这5个上下文是一荣俱荣,一损俱损”的合作关系(PartnerShip,简称PS)。
除去用户抽奖系统的上下文部分,下面还多了券码、平台券、外卖券三个上下文。抽奖上下文通过防腐层(Anticorruption Layer,ACL)对三个上下文进行了隔离,而三个券上下文通过开放主机服务(Open Host Service)作为发布语言(Published Language)对抽奖上下文提供访问机制。
注意!这里的三个券上下文通过开放主机服务(Open Host Service)作为发布语言(Published Language)对抽奖上下文提供访问机制,可能听起来像是通过接口调用的方式访问这三个券对应的服务,但不全对。
开放主机服务的语意更大,接口调用只是其中的一种形式,例如在物流系统中,运输管理限界上下文提供的开放主机服务可能包括 “安排包裹运输”、“查询包裹运输状态” 等操作,这些操作名称和相关的数据结构都是基于物流领域的业务语言来定义的,能够更好地体现业务意图,而普通接口调用可能更侧重于技术实现层面的方法命名和参数传递。
那么明确了各上下文之间的关系,就完成了战略建模的部分,下一步就是进行战术建模,细化各上下文。
步骤2:分析上下文的实体和值对象
梳理清楚上下文之间的关系后,我们需要从战术层面上剖析上下文内部的组织关系。这里导入几个概念:实体、值对象和聚合根。
聚合是一个非常重要的概念,核心领域往往都需要用聚合来表达。其次,聚合在技术上有非常高的价值,可以指导详细设计。聚合由根实体,值对象和实体组成。
Q4:为什么要使用聚合
在领域驱动设计(DDD)中,聚合是一种组织领域对象的方式,对于核心领域的表达很关键。
例如,在电商系统的订单处理这个核心领域,订单相关的信息(如订单本身、订单项、商品信息等)可以组成一个聚合。
还是从电商系统来看,在电商系统中,将订单作为聚合实体来考虑是合理的,因为订单确实是一个核心的业务概念。从业务流程角度看,订单相关的操作(如创建订单、修改订单、删除订单等)构成了一个相对独立的业务单元,围绕订单的信息(如订单项、商品信息等)都与之紧密相关,所以订单可以作为一个聚合来组织这些相关的领域对象。
Q5:如何创建好的聚合?
边界内的内容具有一致性
假设在电商系统中有一个库存管理聚合,包括库存(Stock)和商品(Product)。在一个事务中,应该尽量只修改库存管理聚合内的一个实例。比如,当有一个商品入库操作时,只修改库存的数量,而不涉及其他无关的聚合实例。但如果发现库存管理聚合内很难保证强一致性,例如,因为系统性能要求,需要快速更新库存数量,同时又要更新商品的其他属性(如更新商品的分类信息),这种情况下,就应该考虑把商品相关的操作从库存管理聚合中剥离出来,形成独立的聚合,然后采用最终一致性的方式,比如通过消息队列等机制来保证库存数量和商品分类信息最终能够同步更新。
设计小聚合
在用户管理系统中,大部分情况下,用户(User)聚合可以只包含根实体用户本身。例如,用户的基本信息(姓名、年龄、联系方式等)都可以作为用户根实体的属性。即使有一些相关的信息,如用户的登录凭证(可以看作一个实体),也可以考虑将其创建为值对象,比如将用户名和密码组合成一个登录凭证值对象,附属于用户根实体。这样设计小聚合可以使聚合更简单,更容易维护和理解。
通过唯一标识来引用其他聚合或实体
在电商系统的订单和用户关联中,订单聚合不会直接引用完整的用户对象,而是引用用户的唯一标识(如用户 ID)。当需要显示订单所属用户的信息时,再根据用户 ID 去获取用户信息。例如,可以创建一个订单工厂类,其中的工厂方法接收用户 ID、商品 ID 列表和订单项数量列表等参数,在工厂方法内部完成订单聚合的复杂创建过程,外部调用者只需要知道如何调用工厂方法即可。
聚合内部多个组成对象的关系和数据库创建
还是以电商订单聚合为例,假设订单聚合中有一个订单项列表(List),这是一个值对象列表。在数据库设计时,为了表示这种 1:N(一个订单对应多个订单项)的关系,需要将订单项单独建表,并且订单项在数据库中有自己的 ID。但是,从领域驱动设计的角度,这个 ID 不应该暴露到资源库外部,应该对外隐蔽。这是因为在领域模型中,订单项是作为订单聚合的一部分存在的,外部不应该直接通过订单项的 ID 来操作订单项,而是应该通过订单聚合来间接操作订单项,这样可以保证聚合内部的一致性和封装性。
这里再引入两个概念,领域对象和领域事件。
领域服务
一些重要的领域行为或操作,可以归类为领域服务。它既不是实体,也不是值对象的范畴。
当我们采用了微服务架构风格,一切领域逻辑的对外暴露均需要通过领域服务来进行。如原本由聚合根暴露的业务逻辑也需要依托于领域服务。
领域事件
领域事件是对领域内发生的活动进行的建模。
回到我们的抽奖平台示例,抽奖平台的核心上下文是抽奖上下文,接下来介绍下我们对抽奖上下文的建模。
以电商订单聚合为例,订单(Order)可以是根实体,它有唯一的订单编号作为标识。订单项(OrderItem)是实体,每个订单项都有自己的标识,并且与订单存在关联,因为订单项是属于某个订单的。商品价格(Price)可以是一个值对象,它没有自己的独立标识,主要用于描述商品的价格属性,它的值在创建后通常是不可变的。当计算订单总价时,会使用订单项中的商品价格值对象来进行计算。
结合上面电商的聚合设计案例,在我们的抽奖上下文中,聚合根是抽奖(DrawLottery),它有唯一的抽奖ID(LotteryId)作为标识。一个抽奖包括了抽奖ID(LotteryId)以及多个奖池(AwardPool),而一个奖池针对一个特定的用户群体(UserGroup)设置了多个奖品(Award)。
另外,在抽奖领域中,我们还会使用抽奖结果(SendResult)作为输出信息,使用用户领奖记录(UserLotteryLog)作为领奖凭据和存根。
Q6: 如何在工程中使用DDD?
代码演示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.*;//库存上下文
对于模块内的组织结构,一般情况下我们是按照领域对象、领域服务、领域资源库、防腐层等组织方式定义的。
代码演示2 模块的组织
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.;//领域防腐层
领域对象
还记得文章一开始提到的贫血症和失忆症吗,指的就是没有任何逻辑行为的bean。我们用之前定义的抽奖(DrawLottery)聚合根和奖池(AwardPool)值对象来具体说明。
抽奖聚合根持有了抽奖活动的id和该活动下的所有可用奖池列表,它的一个最主要的领域功能就是根据一个抽奖发生场景(DrawLotteryContext),选择出一个适配的奖池,即chooseAwardPool方法。
chooseAwardPool的逻辑是这样的:DrawLotteryContext会带有用户抽奖时的场景信息(抽奖得分或抽奖时所在的城市),DrawLottery会根据这个场景信息,匹配一个可以给用户发奖的AwardPool。
按照DDD的设计方式,会设计出这么一个抽奖类
代码演示3 DrawLottery
package com.company.team.bussiness.lottery.domain.aggregate;
import ...;public class DrawLottery {private int lotteryId; //抽奖idprivate List<AwardPool> awardPools; //奖池列表//getter & setterpublic 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;}
}
注意看,根据DDD的设计方式定义出的这两个对象,与以往的仅有getter、setter的业务对象不同,领域对象具有了行为,对象更加丰满。同时,比起将这些逻辑写在服务内(例如**Service),领域功能的内聚性更强,职责更加明确。
资源库(资源库的数据信息+资源存储的逻辑)
领域对象需要资源存储,存储的手段可以是多样化的,常见的无非是数据库,分布式缓存,本地缓存等。资源库(Repository)的作用,就是对领域的存储和访问进行统一管理的对象。在抽奖平台中,我们是通过如下的方式组织资源库的。
代码演示5 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)。
比起以往将资源管理放在服务中的做法,由资源库对资源进行管理,职责更加明确,代码的可读性和可维护性也更强。
代码演示6 DrawLotteryRepository
package com.company.team.bussiness.lottery.repo;
import ...;@Repository
public class DrawLotteryRepository {@Autowiredprivate AwardDao awardDao;@Autowiredprivate AwardPoolDao awardPoolDao;@AutoWiredprivate 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) {...}
}
防腐层
亦称适配层。在一个上下文中,有时需要对外部上下文进行访问,通常会引入防腐层的概念来对外部上下文的访问进行一次转义。
有以下几种情况会考虑引入防腐层:
- 需要将外部上下文中的模型翻译成本上下文理解的模型。
- 不同上下文之间的团队协作关系,如果是供奉者关系,建议引入防腐层,避免外部上下文变化对本上下文的侵蚀。
- 该访问本上下文使用广泛,为了避免改动影响范围过大。
如果内部多个上下文对外部上下文需要访问,那么可以考虑将其放到通用上下文中。
在抽奖平台中,我们定义了用户城市信息防腐层(UserCityInfoFacade),用于外部的用户城市信息上下文(微服务架构下表现为用户城市信息服务)。
以用户信息防腐层举例,它以抽奖请求参数(LotteryContext)为入参,以城市信息(MtCityInfo)为输出。
代码演示7 UserCityInfoFacade
package com.company.team.bussiness.lottery.facade;
import ...;@Component
public class UserCityInfoFacade {@Autowiredprivate 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),可以看到在省略了一些防御性逻辑(异常处理,空值判断等)后,领域服务的逻辑已经足够清晰明了。
代码演示8 LotteryService
package com.company.team.bussiness.lottery.service.impl
import ...;@Service
public class LotteryServiceImpl implements LotteryService {@Autowiredprivate DrawLotteryRepository drawLotteryRepo;@Autowiredprivate UserCityInfoFacade UserCityInfoFacade;@Autowiredprivate AwardSendService awardSendService;@Autowiredprivate AwardCounterFacade awardCounterFacade;@Overridepublic 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) {...}
}
数据流转
在抽奖平台的实践中,我们的数据流转如上图所示。 首先领域的开放服务通过信息传输对象(DTO)来完成与外界的数据交互;在领域内部,我们通过领域对象(DO)作为领域内部的数据和行为载体;在资源库内部,我们沿袭了原有的数据库持久化对象(PO)进行数据库资源的交互。同时,DTO与DO的转换发生在领域服务内,DO与PO的转换发生在资源库内。
与以往的业务服务相比,当前的编码规范可能多造成了一次数据转换,但每种数据对象职责明确,数据流转更加清晰。
上下文集成
通常集成上下文的手段有多种,常见的手段包括开放领域服务接口、开放HTTP服务以及消息发布-订阅机制。
在抽奖系统中,我们使用的是开放服务接口进行交互的。最明显的体现是计数上下文,它作为一个通用上下文,对抽奖、风控、活动准入等上下文都提供了访问接口。 同时,如果在一个上下文对另一个上下文进行集成时,若需要一定的隔离和适配,可以引入防腐层的概念。这一部分的示例可以参考前文的防腐层代码示例。
分离领域
接下来讲解在实施领域模型的过程中,如何应用到系统架构中。
一个从前到后的应用系统
下图中领域服务是使用微服务技术剥离开来,独立部署,对外暴露的只能是服务接口,领域对外暴露的业务逻辑只能依托于领域服务。而在Vernon著作中,并未假定微服务架构风格,因此领域层暴露的除了领域服务外,还有聚合、实体和值对象等。此时的应用服务层是比较简单的,获取来自接口层的请求参数,调度多个领域服务以实现界面层功能。
随着业务发展,业务系统快速膨胀,我们的系统属于核心时:
应用服务虽然没有领域逻辑,但涉及到了对多个领域服务的编排。当业务规模庞大到一定程度,编排本身就富含了业务逻辑(除此之外,应用服务在稳定性、性能上所做的措施也希望统一起来,而非散落各处),那么此时应用服务对于外部来说是一个领域服务,整体看起来则是一个独立的限界上下文。
此时应用服务对内还属于应用服务,对外已是领域服务的概念,需要将其暴露为微服务。
以下是一个示例。我们定义了抽奖、活动准入、风险控制等多个领域服务。在本系统中,我们需要集成多个领域服务,为客户端提供一套功能完备的抽奖应用服务。这个应用服务的组织如下:
代码演示9 LotteryApplicationService
package ...;import ...;@Service
public class LotteryApplicationService {@Autowiredprivate LotteryRiskService riskService;@Autowiredprivate LotteryConditionService conditionService;@Autowiredprivate 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){...}
}
在本文中,我们采用了分治的思想,从为什么使用DDD到具体该如何在实际业务中使用DDD。通过领域驱动设计这个强大的武器,我们将系统解构的更加合理。
但值得注意的是,如果你面临的系统很简单或者做一些SmartUI之类,那么你不一定需要DDD。尽管本文对贫血模型、演进式设计提出了些许看法,但它们在特定范围和具体场景下会更高效。读者需要针对自己的实际情况,做一定取舍,适合自己的才是最好的。
本篇通过DDD来讲述软件设计的术与器,本质是为了高内聚低耦合,紧靠本质,按自己的理解和团队情况来实践DDD即可。
鉴于作者经验有限,对领域驱动的理解难免会有不足之处,欢迎大家共同探讨,共同提高。
讨论
至此,DDD的内容,理论已经有了一定难度,落实到实际中就更难。如果你有疑问,可以在评论区留言,大家一起讨论下DDD在什么场景下该如何设计,一起加深理解!
如果觉得本文比较好理解的话,可以【点赞 + 关注】,以后我会不断更新图文并茂的文章的。
同时,如果有不懂的地方可以在评论区留言,只要我看到就会立刻回复。、
References
美团技术沙龙