随着互联业务的发展、业务逐渐的复杂,传统代码架构在日常开发中存在的多种弊端,如代码混乱、补丁式开发、迭代成本高等问题,大大影响了迭代的效率。本文作者借助 DDD 的战略设计和战术设计,介绍了如何通过限界上下文、领域模型、聚合、资源库等概念,实现业务逻辑与技术的解耦,提升代码的可维护性、扩展性和稳定性。同时,文章作者结合团队在落地DDD时,遇到的卡点、痛点,创新性的提出一种 DDD 的分层实践,并在实际开发中取得了较好的效果
01 背景
不知不觉从事To B业务已经3年,笔者在工作中看了很多、也写了很多的代码,由此也产生很多的思考和感悟:在日常的工作中,我们的主要矛盾在于日渐复杂、动态变化的业务诉求与有限的人力之间的矛盾。而为了解决这一矛盾,我们要尽可能的保证代码的优雅。
但是传统的代码设计,如:面条式代码架构、基于面向对象+MVC的代码架构,大部分无法保证在日趋复杂的业务中以优雅的代码架构持续发展。一旦迭代时间拉长,这类代码往往会或多或少地表现出以下特征:
-
代码组织混乱(数据的获取随意、业务逻辑与数据逻辑纠缠、结构随意);
-
业务逻辑透传数据数据库(业务逻辑层层透传到数据库层);
-
隐式代码逻辑横行(业务代码到处散落、对象的初始化通过隐式init)。
笔者在这里画一下这套代码的逻辑组织结构:
具体来分析,笔者认为主要存在以下几种问题:
-
稳定性&性能低下:由于代码组织结构的混乱,导致开发模式变成了打补丁,迭代方式变成了在原有的代码基础上继续填充if else、或者新开辟一个func用于实现本次新加的代码逻辑。这往往会导致重复数据的获取、重复的数据校验、重复的对象创建.....,从而导致性能的大幅下降。
-
代码复杂程度高
a.补丁式的开发模式:
i. 由于补丁的开发,数据的获取随意从而导致代码的复用性降低,毕竟每同学在开发时,如果不全盘梳理代码已无法抽取合理的公共代码逻辑。而新添加的代码又会成为下一个同学的开发负担,从而导致_代码一直处于恶性循环,从而导致复杂度一直增长*。笔者就见过一些超过1500行的函数,这些函数除了重新推倒重来基本无迭代的可能性,因此我们应该尽可能避免这种情况发生。除了以上问题,补丁式的开发缺少统一的规划,*圈复杂度的急剧上升也是代码复杂度上升_的一个重要原因。
b. 代码组织混乱:
i. 数据获取随意:由于没有统一的代码格式层级,数据的获取散列在整个代码的各处、赋值修改亦如此,导致数据污染。
ii.据获取通过Map结构:笔者见过一些代码通过Map获取数据,后续的开发的同学必须不仅要关注这个map的成员、还要关注Map中每个成员的生命周期是否有过修改、更新,迭代过程中十分痛苦。
iii .....
c. 隐式的业务逻辑:
i.代码只体现对数据修改、更新,而没有显示注明业务逻辑。
d. 业务逻辑透传数据数据库:
i. 笔者见过一些代码,请求的参数直接从api透传到dao层,导致对这段代码进行扩展十分困难。
- 迭代成本高:由于代码复杂度过高,导致迭代复杂度同比增加。
从实际工作中来看,一旦代码选择这种架构,便只能沿着混乱继续一路狂奔,从而无法挽回直到业务无法忍受技术的短板,选择进行重构!笔者在这里截取一些代码片段,作为示例:
△map获取
△超长代码
02 那什么才是好的代码架构?优雅的结构?
从笔者的工作经验来看,好的代码结构一般具有以下几种特点:
-
代码的可扩展性:好的代码架构帮助代码开发人员将业务与技术解耦,增加代码的扩展性;
-
代码的可维护性:好的代码架构能将业务和依赖进行解耦,增加代码的可维护性;同时好的代码架构可以降低代码和文档的腐化程度;
-
代码复用性高、可测试性强:代码架构有助于提升代码的复用性、可测试性;
锦上添花:
- 系统稳定性高:好的代码架构有助于提升系统的稳定性。
很高兴的事情是DDD是从一种更高纬度的设计思想去思考问题,他以业务驱动为核心,从稳定性的角度去构建代码结构。某种意义而言,DDD也许是一种更巧妙的解决方案,帮助我们去构建更优雅的结构,从一定维度上减缓项目代码的腐化。
03 什么是领域驱动设计
DDD是一种围绕领域建模来解决复杂业务交付的设计思想。
- 什么是复杂?如何理解复杂?
-
复杂可能是现状业务就复杂,也可能是业务日渐演变成复杂。复杂来自规模在变,比如几个业务对象的逻辑不复杂,几十上百个业务对象就会变得错综复杂;
-
复杂来自结构化不足,例如结构化的中国结比非结构化的意大利面更有序、易于大脑理解。此外,如何协同不同团队完成软件交付也是一种复杂。
- 什么是领域建模?
-
领域模型跟技术毫无关系,而是为了更有结构化的拆解和表达业务逻辑。业务逻辑来自现实世界里的具体场景,涉及可视画面、操作动作和流程。要准确表达业务逻辑需要先讲清楚每个概念是什么,再建立概念之间的联系,基于这些关系再组合出更多的流程;
-
概念、联系、流程就是领域模型。围绕领域模型去表达业务时也自然而然地把技术实现细节分离出去了。后续代码实现就是将业务架构映射到系统架构的过程,以后业务架构调整了能快速的调整技术架构。
- DDD用哪些领域概念表达业务?
-
表示业务逻辑的是:实体、值对象、领域服务、领域事件。这意味着所有领域逻辑都应该在这四种对象里,统一称为领域模型对象,这将极大_减少业务逻辑的蔓延;_
-
引入聚合进一步封装实体和值对象,让领域逻辑更内聚,起到边界保护的作用。聚合的引入使得业务对象间的关联变少。如何设计聚合见下面实践部分;
-
围绕聚合的操作引入工厂和资源库。工厂负责复杂聚合的创建,资源库负责聚合的加载、添加、修改、删除。聚合内的实体状态变更通过领域事件来推动;
-
应用服务处于应用层,对领域逻辑编排、封装之后对上层接口层暴露。一次编排就是一个用户用例。
04 领域驱动设计如何解决问题
DDD 包括战略设计和战术设计两部分。
-
战略设计:主要从业务视角出发,建立业务领域模型,划分领域边界,建立通用语言的限界上下文,限界上下文可以作为微服务设计的参考边界。
-
战术设计:从技术视角出发,侧重于领域模型的技术实现,完成软件开发和落地,包括:聚合根、实体、值对象、领域服务、应用服务和资源库等代码逻辑的设计和实现。
4.1 战略设计:分割你的设计,以免无法控制
4.1.1 Bounded Context(限界上下文)
限界上下文是围绕应用程序和/或项目部分的概念边界,涉及业务领域、团队和代码。它将相关组件和概念分组,避免歧义,因为其中一些可能在没有明确上下文的情况下具有相似的含义。
-
比如电商领域的商品,商品在不同的阶段有不同的术语,在销售阶段是商品,而在运输阶段则变成了货物。同样的一个东西,由于业务领域的不同,赋予了这些术语不同的涵义和职责边界,这个边界就可能会成为未来微服务设计的边界。领域边界就是通过限界上下文来定义的。
-
比如财务领域、审核领域。DDD里的限界上下文(Bouded Context)是对广告领域的软件实现,比如钱包体系、账户体系就是财务领域内的限界上下文。
-
限界上下文定义了解决方案的明显边界,边界里的每一个领域概念,包括领域概念内的属性和行为都有特殊含义。出了限界上下文这个边界这层含义就不复存在。
-
如何划分限界上下文?
-
根据相关性做归类。一般是优先考虑功能相关性而不是语义相关性,比如创建订单和支付订单都是订单语义,但功能相差比较大,应该划分为两个限界上下文。
-
根据团队粒度做裁剪、根据技术特点做裁剪。一些通用的技术功能应该尽可能归拢到一个限界上下文,比如每个业务限界上下文都有监控,但监控能力应该归拢到监控限界上下文。
4.1.2 Context Mapping(上下文映射)
识别并以图形方式记录项目中的每个限界上下文称为上下文映射。上下文映射有助于更好地理解有界上下文和团队如何相互关联和沟通。它们给出了实际边界的清晰概念,并帮助团队直观地描述系统设计的概念细分。
受限上下文之间的关系可能会有所不同,这取决于设计要求和其他特定于项目的约束,本文将省略某些关系,但以下四种关系除外:
4.1.3 Anti-corruption Layer(防腐层):
下游限界上下文实现了一个转换来自上游上下文的数据或对象的层,确保它支持内部模型。
4.1.4 Conformist(跟随者关系):
下游有界上下文符合并适应上游上下文,如果需要,必须进行更改。在这种情况下,上游环境对满足下游需求不关心。
4.1.5 Customer/Supplier(客户/供应商关系):
上游向下游提供服务,下游上下文充当客户,确定需求并向上游请求更改以满足其需求。
4.1.6 Shared Kernel(共享内核):
有时,两个(或更多)上下文不可避免地重叠,最终共享资源或组件。这种关系要求两个上下文在需要更改时保持连续同步,因此如果可能的话应该避免。
4.2 战术设计:DDD的螺母和螺栓
4.2.1 Entity(实体)
具有唯一标识并具有连续性的对象称为实体,它们不仅仅由属性定义,更多地由它们是谁定义。
它们的属性可能会发生变异,它们的生命周期可能会发生剧烈变化,但它们的身份依然存在。身份通过唯一密钥或保证唯一的属性组合来维护。
例如,在电子商务领域,订单有一个唯一的标识符,它经历几个不同的阶段:打开、确认、发货和其他,因此它被视为领域实体。
重点关注:Entity最重要的设计原则是保证实体的不变性(Invariants),也就是说要确保无论外部怎么操作,一个实体内部的属性都不能出现相互冲突,状态不一致的情况。
这里给出一些总结的规范:
-
创建一致性 ,实体的创建尽量通过Factory或者规约进行创建;
-
在代码实践中,尽量保证实体的创建唯一性(避免过多的创建实体的方法)。
-
实体的属性尽量使用小写,避免外部直接对属性的操作,从而导致实体与业务出现不一致的情况;
-
通过聚合根对子实体进行访问;子实体的一致性交由聚合根保证;
-
任何实体的行为只能直接影响到本实体(和其子实体)。
应当遵守的原则:在一个系统里一个实体对象的所有变更操作应该都是预期内的,如果一个实体能随意被外部直接修改的话,会增加代码bug的风险**。**
4.2.2 Value Object(值对象)
描述特征的对象,不具有任何唯一性的对象称为价值对象,它们只关心自己是什么,而不关心自己是谁,值对象是多个实体的属性,可以由多个实体共享。
例如:两个客户可以具有相同的发货地址,尽管存在风险,但如果其中一个属性需要更改,则共享这些属性的所有实体都会受到影响。为了防止这种情况发生,值对象必须是不可变的,当需要更新时,强制系统用新实例替换它们。
此外,价值对象的创建应始终取决于用于创建它们的数据的有效性,以及它如何尊重业务不变量。
因此,如果数据无效,将不会创建对象实例。例如,在北美,带有非字母数字字符的邮政编码将违反业务不变量,并将触发地址创建异常。
4.2.3 Aggregate(聚合)
聚合是相关实体和值对象的集合,聚集在一起表示事务边界。每个聚合都有一个朝外的实体,控制对边界内对象的所有访问,该实体称为聚合根,是其他对象可以交互的唯一对象。
聚合中的任何对象都不能直接从外部世界调用,从而保持内部的一致性。业务不变量是保证聚合及其内容完整性的业务规则,换句话说,它是一种确保其状态始终与业务规则一致的机制。例如,当某个产品的库存量为零时,就永远不能下订单。
4.2.4 Repository(资源库)
为了能够从持久性中检索对象,无论是在内存、文件系统还是数据库中,我们需要提供一个对客户机隐藏实现细节的接口,以便它不依赖于基础架构细节,而仅仅依赖于抽象。
存储库提供了一个接口,域层可以使用该接口来检索存储的对象,避免了与存储逻辑的紧密耦合,并使客户端产生了直接从内存检索对象的错觉。
值得一提的是,所有存储库接口定义都应该位于domain层,但它们的具体实现属于基础架构层。
4.2.5 Domain Event(领域事件)
领域事件是过去时态的业务事实,当聚合根状态变更时触发,它的核心职责是跨聚合/微服务的业务协同,我们将它定义在领域层,发布/处理可在应用层。
例如订单创建后触发库存更新、通知等跨系统操作,它不需要强一致性保证,只需要保证最终一致性。
4.2.6 Domain Service(领域服务)
在许多情况下,领域模型需要某些与实体或值对象不直接相关的动作或操作,将这些动作或操作强制到它们的实现中会导致它们的定义失真。
如电商订单支付,需要协调订单、库存、支付三个实体完成事务。
服务****应该精心设计,始终确保它们不会剥夺实体和价值对象的直接责任和行为。
它们还应该是无状态的,这样客户机就可以使用服务的任何给定实例,而不考虑该实例在应用程序生命周期中的历史记录。
4.2.7 Application Service(应用服务)
和领域服务的区别在于,应用服务处理流程编排、捕获异常,领域服务处理核心业务规则。
应用服务是协调领域模型与外部系统交互的中间层,负责处理非业务逻辑的横切关注点,如事务管理、安全认证、参数校验、事件发布等。
通过一个应用服务,我们能够清晰地看出对哪些实体行为进行了调度,它依赖于领域服务和基础设施组件,进行日志记录、异常捕获、权限验证、数据转换等基础设施层交互,保持领域层与技术实现解耦。
此外,应用服务还需要对外暴露REST API或RPC接口,对内将DTO转换为领域对象,隔离外部请求与内部模型。
05 领域驱动设计分层架构
** 六边形架构**
从代码演进的角度来看/需求变更的速度来看,我们将各层按照变更速度排序:
-
Domain(领域)层属于核心业务逻辑,属于经常被修改的地方;这部分的需求经常随着产品的迭代进行变更。领域层不依赖其他层,通过资源库包下的接口定义做到依赖倒置,接口参数不能体现具体技术实现细节,领域模型里的实现逻辑只依赖接口。这样做到对领域逻辑的一层防腐。本层里以聚合为单位放置代码,便于以后系统拆分,以聚合为单位。
-
Application(应用)层属于Use Case(业务用例)。业务用例一般都是描述比较大方向的需求,接口相对稳定,特别是对外的接口一般不会频繁变更。_添加业务用例可以通过新增Application Service或者新增接口实现功能的扩展_**。**此外应用层还可以处理横切面事务比如启动数据库事务。
-
Interface(接口)层主要负责解决外部通信、协议等问题,将外部的定时任务、请求、rpc、事件消费都进行透明处理。
-
Infrastructure(基础设施)层属于最低频变更的。基础设施层完成资源库的实际实现,以及领域层定义的其他接口的实现如对外部服务的访问,领域事件发布到消息队列中间件等。一般这个层的模块只有在外部依赖变更了之后才会跟着升级,而外部依赖的变更频率一般远低于业务逻辑的变更频率。
5.1 DDD FrameWork(四层架构)
后文中,我们对于层的介绍将类比接口的概念进行介绍,重点关注3个概念:
-
入参
-
出参
-
内容
5.1.1 用户接口层
-
定义:用户接口层负责向用户显示和解释用户指令。这里的用户可能是:用户、程序、自动化测试和批处理脚本等等。
-
目的:我们希望通过分层,来提升application层的稳定性。
-
构成结构
-
入参: CQE对象;
-
出参:DTO对象;
-
内容 : 对玩不输入进行校验,输出内容的处理结果;
- 笔者在这里给出一些落地规范:
-
统一的鉴权:比如在一些需要AppKey+Secret的场景,需要针对某个租户做鉴权的,包括一些加密串的校验;
-
网络协议的转化:这个尽量交给框架处理,我们主要的工作是关注如何将参数反序列化或者序列化;
-
Session的管理:一般在面向用户的接口或者有登陆态的,通过Session或者RPC上下文可以拿到当前调用的用户,以便传递给下游服务;
-
限流配置:对接口进行限流;
-
**异常处理:**通常在接口层要避免将异常直接暴露给调用端,所以需要在接口层做统一的异常捕获,转化为调用端可以理解的数据格式;
-
日志打印:在该层进行日志的打印;
-
CQE的校验:在该层进行业务无关的校验,推荐依赖框架本身实现;
-
可选:部分代码会在interface层引入缓存。
补充:浅谈CQE模型和CQRS
从笔者的经验来看,这两者概念上区别不大,他的思路就是将系统的Input,根据语义拆分成write和read。这里先只谈CQE模型:
Command指令:指调用方明确想让系统执行的指令,他的预期是对一个系统进行影响,即写操作。通常来说,需要一个明确的返回值(如:同步的操作结构、异步的指令被接受)
Query指令:指调用方明确想查询的东西,包括查询参数、过滤、分页等条件,其预期是对一个系统的数据完全不影响的,也就是只读操作
Event指令:指一件已经发生过的事情,需要系统根据这个事实进行相应,通常都会伴随一个写操作。事件处理器不会有返回值。补充一下,Application层的Event概念和Domain层的DomainEvent是类似的概念,但不一定是同一回事,这里的Event更多是外部一种通知机制而已。
Q:为什么使用CQE对象?
这是一个好的问题,从笔者的经验来看,DDD在于解决复杂的业务;
从某种意义上来说,笔者认为读不算真正的业务,读往往可以理解成数据的组装。
因此,对问题进行拆分,分而治之,从工程学上来说是一种简单可行的方案。从完美的角度上来说,如果能有一种可以全治理的方案一定是最好的!But we live in the real world!
5.1.2 应用层(落地重点)
-
定义:应用层是很薄的一层,理论上不应该有业务规则或逻辑,主要面向用例和流程相关的操作。
-
目的:应用层的核心是:Manage或者Orchestration,编排是应用层最关心的事情,他负责将业务编排到各个领域中。
-
构成结构
-
入参: CQE(Command、Query、Event)对象
-
出参:DTO对象
-
内容:
a.应用层位于领域层之上,可以协调多个聚合的服务和领域对象完成服务编排和组合,协作完成业务操作。
b.应用层也是微服务之间交互的通道,它可以调用其它微服务的应用服务,完成微服务之间的服务组合和编排。
c.应用服务是在应用层的,它负责服务的组合、编排和转发,负责处理业务用例的执行顺序以及结果的拼装,以粗粒度的服务通过 API 网关向前端发布。
d.应用服务还可以进行安全认证、权限校验、事务控制、发送或订阅领域事件等。
- 落地规范
a.应用层应该只包含业务流程的封装,不处理业务逻辑;
b.避免进程内部的EDA驱动。
Q:什么是DTO?为什么要有DTO?带来的额外成本是什么?
DTO存在的意义在于我们可以将实体与数据传输解耦,使得领域层只和应用层有关联性、对外透明;额外成本:性能&冗余&额外引入的DTO Assembler层(用于实体到DTO的转换)。一个基本的DTO对象,如下(简单的pojo对象):
type AdWalletDTO struct {
AdAccountID string `json:"ad_account_id" orm:"ad_account_id" ` // 子账户ID
WalletStatus uint `json:"wallet_status" orm:"wallet_status" ` // 钱包状态:1-正常 2-欠费状态 3-关闭
DepositBalance decimal.Decimal `json:"deposit_balance" orm:"deposit_balance" ` // 存款余额
FrozenDepositBalance decimal.Decimal `json:"frozen_deposit_balance" orm:"frozen_deposit_balance" ` // 冻结存款余额
CreditBalance decimal.Decimal `json:"credit_balance" orm:"credit_balance" ` // 信用余额
FrozenCreditBalance decimal.Decimal `json:"frozen_credit_balance" orm:"frozen_credit_balance" ` // 冻结信用余额
CouponBalance decimal.Decimal `json:"coupon_balance"` // 代金券余额
Title string `json:"title"` // 名字
Currency string `json:"currency"` // 货币
}
Q:CQE 和 DTO有什么区别?
ApplicationService的入参是CQE对象,但是出参却是一个DTO,从代码格式上来看都是简单的POJO对象,那么他们之间有什么区别呢?
可以简单做如下理解:
-
入水口的pojo是CQE,出水口的是DTO(因为入水口天然自带业务含义,因此需要严格的校验;出水口是安全的,只承担数据传输的载体)
-
复用性上而言:CQE对象复用性更低,DTO的复用性更强
CQE对象:CQE对象是ApplicationService层的输入,也可以是interface层的输入,有明确的意图,这个对象必须保证输入的正确性。
DTO对象:是负责承接数据的容器,不负责具体的业务,不包含任何逻辑,只是贫血对象
最重要的一点:因为CQE是”意图“,所以CQE对象在理论上可以有”无限“个,每个代表不同的意图;但是DTO作为模型数据容器,和模型一一对应,所以是有限的。
Q:为什么出参应该返回DTO而不是Entity?
这个是一个非常好的问题,笔者在DDD落地实践的时候,笔者是这么去想的:
-
稳定性思考:Entity里面通常会包含业务规则,如果ApplicationService返回Entity,则会导致调用方直接依赖业务规则,如果内部规则变更可能直接影响到外部。
-
DTO的不稳定性大于实体:业务经常会增加、改变一些字段,而实体的字段相对更加稳定,这也让我们尽量在application层的出参去屏蔽实体
-
领域边界稳定性:ApplicationService的入参是CQE对象,出参是DTO,这些基本上都属于简单的POJO,来确保Application层的内外互相不影响。
-
通过DTO组合降低成本:Entity是有限的,DTO可以是多个Entity、VO的自由组合,一次性封装成复杂DTO,或者有选择的抽取部分参数封装成DTO可以降低对外的成本。
Q:在上述的过程中,我们似乎只解决了C、E对象,我们应该如何针对Query进行处理?
在实践过程中,我们发现Query往往是复杂的、随意的、且交付对象是异变的,因此如果强行将Query作为业务来嵌入我们的DDD中,简直是自讨苦吃——我们很难保证一个操作既是高效读、又是高效写、同时还要兼顾一致性。因此我们的解决思路是,针对Query采用传统的开发模型,虽然不够优雅、但是足够有效,毕竟因为改动代码,导致读错的成本实在是太小了。
在实践过程中,我们推荐使用调用链去尽量简化、复用读的场景,并且取得较好的实践效果。
应用层,因为我们操作的对象是Entity,但是输出的对象是DTO,这里就需要一个专属类型的对象叫DTO Assembler。DTO Assembler的唯一职责是将一个或多个Entity/VO,转化为DTO。
注意:DTO Assembler通常不建议有反操作,也就是不会从DTO到Entity**,因为通常一个DTO转化为Entity时是无法保证Entity的准确性的。**
5.1.3 领域层
-
定义:领域层的作用是实现企业核心业务逻辑,通过各种校验手段保证业务的正确性。
-
目的:领域层的目的是完成业务的核心逻辑,降低实体之间的依赖性。
-
构成结构
-
入参: 实体、聚合根、基础的数据结构(int、string......)......
-
出参:实体、聚合根、基础的数据结构
-
内容:
a.应领域层包含聚合根、实体、值对象、领域服务等领域模型中的领域对象,主要体现领域模型的业务能力,它用来表达业务概念、业务状态和业务规则。
b.领域模型的业务逻辑主要是由实体和领域服务来实现的,其中实体会采用充血模型来实现所有与之相关的业务功能。
c.当领域中的某些功能,单一实体(或者值对象)不能实现时,我们就会将这样的逻辑放在领域服务里,__通过领域服务组合聚合内的多个实体(或者值对象),实现复杂的业务逻辑。
- 落地规范
a.避免领域事件的使用。
Q:为什么我们要避免进程内部的领域事件的使用?
进程内的领域事件,会导致显示的调度变成隐式,这种隐式的场景在测试阶段很难发现问题,从而导致线上问题的产生。从迭代的发展来看,隐式的事件驱动对于我们设计一个好的代码架构不是一件好事情,反而会大大提高代码的复杂度!因此,我们要尽量去避免进程内部级别的事件驱动~
5.1.4 基础设施层
-
定义:基础层是贯穿所有层的,它的作用就是为其它各层提供通用的技术和基础服务,包括第三方工具、驱动、消息中间件、网关、文件、缓存以及数据库等。
-
目的:对业务提供最基本的服务
-
落地规范
a.比较常见的功能还是提供数据库持久化
b.基础层包含基础服务,它采用依赖倒置设计,封装基础资源服务,实现应用层、领域层与基础层的解耦,降低外部资源变化对应用的影响。
5.1.5 Data flow direction(数据链路)
在数据链路维度,我们来看DDD的数据流转,可以更清晰地看出每一层之间的交互。
数据持久化对象 PO(Persistent Object),与数据库结构一一映射,是数据持久化过程中的数据载体。
领域对象 DO(Domain Object),微服务运行时的实体,是核心业务的载体。
数据传输对象 DTO(Data Transfer Object),用于前端与应用层或者微服务之间的数据组装和传输,是应用之间数据传输的载体。
视图对象 VO(View Object),用于封装展示层指定页面或组件的数据。
5.1.6 附录:生产环境中项目结构
目录结构:
5.2 领域驱动VS数据驱动
5.2.1 对比
传统的接口-逻辑-数据访问三层架构里,往往是这么个逻辑。
前几行代码做validation,接下来做convert,然后是业务处理逻辑的代码,中间穿插着通过RPC或者DAO获取更多的数据,拿到数据后,又是convert代码,然后接着一段业务逻辑代码,最后可能还要落库,发消息等等。
MVC三层架构
-
用户界面层(View/Controller)**负责用户交互和界面展示,接收用户输入并传递至业务逻辑层,同时将处理结果返回给前端。
-
业务逻辑层(Service)**包含核心业务逻辑,但常因过度集中而臃肿,容易成为“大泥球”。业务逻辑可能分散在多个Service类中,甚至通过SQL实现部分逻辑,导致耦合度高。
-
数据访问层(DAO)**直接操作数据库,依赖ORM框架,与数据库表结构紧密绑定。
MVC VS DDD:
5.2.2 转化
三层架构向 DDD 分层架构演进,主要发生在业务逻辑层和数据访问层。
DDD 分层架构在用户接口层引入了 DTO,给前端提供了更多的可使用数据和更高的展示灵活性。
DDD 分层架构对三层架构的业务逻辑层进行了更清晰的划分,改善了三层架构核心业务逻辑混乱,代码改动相互影响大的情况。DDD 分层架构将业务逻辑层的服务拆分到了应用层和领域层。应用层快速响应前端的变化,领域层实现领域模型的能力。
另外一个重要的变化发生在数据访问层和基础层之间。三层架构数据访问采用 DAO 方式;DDD 分层架构的数据库等基础资源访问,采用了仓储(Repository)设计模式,通过依赖倒置实现各层对基础资源的解耦。
仓储又分为两部分:仓储接口和仓储实现。仓储接口放在领域层中,仓储实现放在基础层。原来三层架构通用的第三方工具包、驱动、Common、Utility、Config 等通用的公共的资源类统一放到了基础层。
5.2.3 CQRS
不得不提的CQRS结构(参考文档:learn.microsoft.com/zh-cn/azure…
简介:CQRS 是“命令查询责任分离”(Command Query Responsibility Segregation)的缩写
定义:CQRS是一种设计模式,可将数据存储的读取和写入操作隔离到单独的数据模型中。 此方法允许每个模型独立优化,并可以提高应用程序的性能、可伸缩性和安全性
核心:将外部系统的输入区分为:Cmd(包含Event)和Qurey。
因为我们知道,在DDD的模式中,所有的操作都是以实体为基础的,一个实体很有可能很大,涵盖了多种数据来源组成。
那这里就出现了一个问题,业务中需要出一个接口,查询一个account list,以label value的形式返回,我们该怎么做?
是先通过一系列工厂校验,拿到一个大实体,然后通过这个实体操作数据,拿到account list,再处理成dto返回。
还是直接写一个简单的mvc,查询然后拿结果组装返回。
毫无疑问,我们选择后者。
这就是CQRS,用比较粗糙的说法:我们建议在业务逻辑的写入Cmd(包含Event)用DDD来进行,也建议在业务逻辑的查询Query用MVC来进行。
一切模型或者框架,都是为了简化我们的工作,如果我们因为使用了某一种设计模式,而导致开发严重受限,那说明这种设计模式,并不适合我们。
06 总结
正如那句话说的,DDD不是银弹,它不能解决所有问题,但是我们在尝试解决的路上,发现了这样一种模式。
07 彩蛋
看到了这里想必你的脑子里现在全是实体、领域等等这些理论概念,已经忘记了文章一开始我们要干什么,这其实也是在落地DDD时一大痛点。那就让我们不忘初心,再重新回顾和强调一下:我们的目的是要去写一个好的代码。
-----END-----
推荐阅读