浅谈如何理解领域驱动设计
本文作者为长沙.NET社区开发者微笑刺客,转载已获得作者授权。
前言
什么是领域,我习惯描述的是制药领域、环境领域、建筑领域、金融领域等,而在领域内,各种业务规则、业务知识盛行,如何有效的把控规则的变化,应对复杂知识,有一个很关键的四字词语,分而治之。分治法在很多场景下体现了其强大的作用力。领域本身很大,那就拆分,得到更小的领域,也即子域,如同递归调用一般,将一个复杂问题拆分单独求解,而最终将解汇总得到复杂问题解。
怎么拆,拆成怎么样合适,依据什么拆,这些在领域驱动设计中有了一套答案,虽然领域驱动设计不是银弹,但可以说的上是一套极好的系统方法论或称为架构设计的方法论。
领域驱动设计常以战略设计与战术设计来将整个领域展现的淋漓尽致,其作用范围既面向业务也面向技术。从战略角度(个人更喜欢称其为上帝视角)去规划系统、划分领域。而从战术角度则从技术层面来指导我们该如何去设计。
战略设计
战略设计主要从高层俯视(上帝视角)我们的软件系统,就如同玩即时战略游戏般,可以一览地图全貌,以此来决定我们是要进攻还是防守哪个方向,同样,在软件中我们也可以以此来划分领域,确定权重方向。
统一语言
提炼领域知识,怎么个提炼法,千万条罗马路,各有各的看家本领。像事件风暴方法,用例分析方法,用户故事,甚至是开大会,各种讨论会等,最终目的都是提炼出领域知识,而提炼过程中,达成描述上的一致性,包括系统目标、系统范围及系统所具有的功能。
这不是领域驱动设计所独有的,但却是软件开发中所必须的,为领域专家、业务分析人员、编码人员和测试人员等团队所有成员交流时构建统一频道。
领域/子域
领域拆分
对于领域这个概念,习惯性会想到制药领域、环境领域、金融领域等这些概念,而领域本身所描述的是范围,是如同现实世界般的复杂,无边际。借助分治法,将问题逐级细分来降低业务和技术复杂度,将这复杂的世界划分出清晰的边界来,反过来控制着划分后不那么复杂的世界,也既领域拆分出细化后的子领域。
子域划分
在实际解决问题时,我们也习惯将问题拆分,而怎么拆,基于什么原则拆,可能会依据相关性,权重,甚至分类原则等,对于系统而言,会从架构方面考虑,基础设施考虑等,在领域驱动设计中,更偏向基于业务拆分,降低业务复杂度,也分离技术实现的复杂度,依照业务拆分后的子领域,本身存在权重上的差异,依照重要性和功能划分为三类,投资占比也就有所不同。
核心域:其所体现的是核心服务,是代表着产品的核心竞争力。
支撑域:其所体现的是支撑服务,没它不行,但又达不到核心的价值,围绕着产品内部所需要,但又不能单独变更为第三方服务,即它不是一个通用的服务。
通用域:其所体现的中间件服务或第三方服务。本身可以通过现有的解决方案集成来完成的服务。
限界上下文
深入到一个子域中,又是一片小天地,在这天地中,却又还是存在着因语义与语境上的差异,让一些概念在这子域中显得额外尴尬。在一个领域 / 子域中,我们会创建一个概念上的领域边界,在这个边界中,任何领域对象都只表示特定于该边界内部的确切含义。这样边界便称为限界上下文。
其本质上是限界+上下文,引用到张逸老师的一句话
上下文(Context)其实是动态的业务流程被边界(Bounded)静态切分的产物。
对于子域与上下文间的关系,看到很多书籍或是文章中所描述的都不一样,这块的争论也没有一个最终答案,个人更倾向于子域中划分上下文,从拆分角度来讲,这样理解更加简单。
上下文识别
对于上下文的识别,没有可遵循的标准可走,从不同的角度切入将会识别到不同的上下文,可从张逸老师的领域驱动设计实践中窥之一二,以业务复杂度、管理复杂度和技术复杂度出发,面对这三个角度去依次分析,从业务视角、工作视角、应用视角去识别,进而识别出准确的上下文,通过不断的分析斟酌考虑,逐渐识别出符合当前预期的上下文,如在实际操作环节发觉当前上下文的设计显得不那么合理,还可再进行变动、拆分上下文。
但需注意的一个是,我们识别上下文的目的是什么,是为了控制上下文,准确的说是为了控制上下文的边界、大小,是为了保住我们所守护的上下文不会因过度成长变大而奔溃,亦或因上下文过度缩减而失去价值,保证上下文内一切的稳定,上下文与上下文间交互的可用性,也或者是当我们退出上下文时,交付出来的上下文是非常可观的,而不是一个烂摊子。
上下文映射
规划了这么多限界上下文,该如何穿针引线将这些上下文串起来便是一个问题了,用例场景的完整实现往往是由多个上下文的协作完成的,怎么去组织这些上下文,领域驱动设计提到的几种方式及软件工程中常用模式。
合作关系:一荣俱荣,一损俱损。
共享内核:上下文间共享领域实体。
客户方-供应方:下游客户依赖于上游供应方。
遵奉者:下游客户顺应上游供应方。
各行其道:没有关系的关系,相互隔离。
防腐层:在下游上下文与上游间增加一道屏障,以此来隔绝与上游的直接交互保护下游。
开放主机服务:在上游与下游上下文间增加一道协议,以此来规范下游对上游的集成。
已发布语言:发布方上下文发布一份包含丰富文档的信息交换语言,消费方上下文翻译并使用。
这些模式其本质是为了协作,为了满足用例场景下对多个限界上下文的调用,通过上下文映射图,可以清楚知晓运行逻辑。为了实现上下文映射,简单讲就是如何将两个上下文连贯起来,常借助的方式是诸如 RPC、HTTP、消息队列等,依照上下文间映射类型,挑选一件趁手的工具。
分层架构
我们通常喜欢对各种事情归纳总结,如文章的层次分明,如建筑结构高低有序、疏密有致,给人一种各处所关注的信息视角不同,而组合起来显得如此美妙。软件中同样运用着分层来隔离关注点,以此来隔离每层的演进速率。
当我们考虑限界上下文时,不仅需要去考虑其内部的领域设计,还得从其应用边界本身考虑,限界上下文是属于架构设计层次,主要针对的是后端架构层次的垂直切分,按照经典 DDD 的分层结构来看,共分为如下四层:
User Interface 为用户界面层,向用户展示信息和传入用户命令。这里指的用户不单单只使用用户界面的人,也可能是外部系统,诸如用例中的参与者。
Application 为应用层,用来协调应用的活动,不包含业务逻辑,通过编排领域模型,包括领域对象及领域服务,使它们互相协作。不保留业务对象的状态,但它保有应用任务的进度状态。
Domain 为领域层,负责表达业务概念,业务状态信息以及业务规则。尽管保存业务状态的技术细节是由基础设施层实现的,但是反映业务情况的状态是由本层控制并且使用的。领域层是业务软件的核心,领域模型位于这一层。
Infrastructure 为基础实施层,提供公共的基础设施组件,如持久化机制、消息管道的读取写入、文件服务的读取写入、调用邮件服务、对外部系统的调用等等。
值得注意的是,给定的分层方式仅仅是逻辑上的分层,而对于实际的物理分层,却又有所不同,但遵守一个前提为好,即限界上下文的边界高于分层的边界。诸如如下两种开发中常见的代码组织方式,都可见到。一种是基于技术分层,而另一种更偏向基于业务分层。
方式一
- application
- productcontext
- ordercontext
- ...
- domain
- productcontext
- ordercontext
- ...
- infrastructure
- productcontext
- ordercontext
- ...
方式二
- productcontext
- application
- domain
- infrastructure
- ordercontext
- application
- domain
- infrastructure
具体采用哪种方式,并没有强制要求,无论代码组织结构是否表达了层的概念,都需要充分理解分层的意义,并使得整个代码结构在架构上要吻合分层架构的理念。
战术设计
相比于战略设计的怎么规划,战术设计更侧重于怎么执行,详细的设计和编码。
聚合
在认识聚合前,我们得对类再次回顾,类是作为我们开发中的最小单元,一切以类构建,而在上下文的视角中,聚合成了最小概念,包装了一组高度相关的对象,上下文内以聚合为最小单元,以此来保证聚合边界。又将分而治之的思想融入到了限界上下文的内部。
聚合本身是由一个或多个实体及值对象组成,其中一个实体作为聚合根。管理着内部关联的实体与值对象,对外代表着聚合,外部来访者仅可通过聚合根进行访问。
对于聚合图的画法,或许因人而异,我更加倾向于用矩形代表实体,椭圆代表值对象,用 UML 类图中的组合-聚合箭头来表示其双方间的关系。
需要注意的是,此处的聚合不要与 UML 类图中的聚合等同起来,两者含义并不相同。
实体
对于实体来讲,这个概念对于我们并不陌生,拥有者唯一的身份标识符,内含属性作为该实体的静态特征,作为聚合所拥有的领域知识,拥有着与自身相关的领域行为。
值对象
对于值对象,我倾向于将它理解为,基础类型之延伸,既能封装基础类型,又能约束内部属性间关系,还能拥有着自身的领域行为,而与实体的区别是,没有唯一身份标识,尽管带来了持久化的一些问题,但还是存在解决方案。以 DateTime 理解值对象最好不过了,DateTime 内部的自身约束保证了,每一次变动的 DateTime 都是最新的,当我们想在 2 月 28 日加 1,这便要依靠 DateTime 中的行为去约束内部的属性。
聚合划分
经统一语言与业务分析阶段,借助一系列如事件风暴、用例分析法、名次动词法、四色建模法等活动后,获得了一系列相关联的对象。或可形成一张庞大的对象关联图。
如不考虑聚合的划分,我们依照以往的思路便是创建一大堆表,运用三范式或是依靠程序去保证数据的一致性不运用主外键。然后疯狂撸码,CRUD 好不快活。
而随着业务的逐渐扩张,这当初的想法已有点吃力了,如同树苗逐渐成长,枝叶也逐渐增多。借助枝干我们可以分清叶子的归属,而对象网中呢,变得错综复杂了,也就隐约有了大泥球的征兆。
借助划分聚合的一些方法,将其规整化。将原有复杂的对象图拆分成可控制的小型对象图。
保持单一导航方向,解除双向依赖,保持依赖简单。
保持聚合设计的小巧
聚合内的业务规则一致性
通过聚合标识符引用其他聚合
聚合与协作聚合间因业务场景、进程边界等因素影响,可依照场景使用强一致性或是最终一致性。
如上的对象图依照关系的强弱,关系的主与次进行了聚合划分,或许得出的部分聚合存在不合理处,可再调整其边界。
聚合协作
聚合与协作聚合之间依照聚合根实体的唯一标识符进行关联,而不是通过依靠协作聚合的引用实例来完成。保持这个原则有助于保持聚合之间的边界并避免加载不必要的对象。如我们常习惯上将关联的集合对象写入到类中,然后在仓储使用时,通过 EF 加载导航属性,以此方便直接加载关联聚合数据。
//一个聚合内建议用
public class Order : AggregateRoot
{
public virtual ICollection<OrderItem> OrdrItems { get; set; }
//...
}
_orderRepository.Include(e=>e.OrderItems).FirstOrDefault();
如 Order 和 OrderItem,当我们考虑将其作为一个聚合时,这么使用,是可以的,但是不能说跨聚合也这么用着,如 Enterprise 和 Order,划分时我们更加倾向于划分为两个聚合,遵循保持聚合原则中,引用聚合根的 Id 这一原则,这将改善聚合的边界使其更加清晰,控制更加妥当。
//多聚合间不建议这么用
public class Order : AggregateRoot
{
//遵循聚合原则引用 Enterprice 聚合根 Id,而不是实例
public int EnterpriceId {get; set;}
//public virtual Enterprice Enterprice { get; set; }
//...
}
考虑到多聚合的协作,便要了解下聚合的首要原则,即在一次事务中,只能更改一个聚合的状态,因此当涉及到多个聚合协作时,如创建订单完毕,需要往库存中某一商品数量减少时,订单本身一般会有商品聚合的标识,借助这个标识,通过领域事件或是集成事件方式,事件接收方将相关联的库存聚合调用起来,以此达到多个聚合间的协作。
又或者考虑到,需要调用商品的信息以使得当前订单中商品信息更加丰富,可通过防腐层调用商品所在上下文远程服务或是应用服务,最终本质上是调用商品聚合中的信息丰富到订单中,也使得多个聚合完成协作。
应用服务
作为限界上下文对外的门户,也即是外观模式的体现。通过用例分析识别出来的用例在此处一一对应存在着,对外提供统一接口,以此满足完整用例场景所需的功能。在应用服务内部,通过编排领域模型对象来完成用例的功能,自身并不包含领域逻辑,但包含着应用逻辑。
可借鉴整洁架构的经典图例来看应用层本身的职责所在,Use Case(用例层)-Application Business Rules,虽然是依靠着领域模型对象才完成的(具体是编排领域模型对象所具有的领域行为),却也说明了应用服务承担着的是用例的职责。
需要注意的是,应用服务的职责不仅限于编排领域模型对象,还需要控制着横切关注点,如验证、日志、事物等的管理。
[UnitOfWork]
[Authorize(PermissionNames.PartType_Create)]
public async Task CreatePartType(CreatePartTypeDto input)
{
await _validatingPartTypeManager.CheckUniqueName(input.Name, input.Category);
var partType= PartType.Create(input.Name, input.Description)
.SetCategory(input.Category)
.SetFactory(input.FactoryName);
await _partTypeRepository.InsertAsync(partType);
await _appNotifier.NewPartTypeAsync();
}
如上,事务、认证、请求参数校验(Dto 内),协调领域模型对象和基础设施服务,这是应用服务的职责,当然也不仅限于这些职责。
领域服务
当我们考虑领域逻辑时,首先想到的应该是实体与值对象中具有的领域逻辑,而有些场景下,实体与值对象无法承载这些领域行为,如对多个领域对象作为输入,进行计算并产出一个值对象;又或是需要将操作成集合化的聚合,如在 Supplier 下需要将所有 Order 中的单价汇总,而本身 Supplier 和 Order 是为两个聚合,若考虑借助 Order 去完成该业务操作,不太妥当,在此场景下,可通过领域服务来承载着这些领域行为。
领域服务存在如下特征:
执行一个显著的业务操作过程
对领域对象进行转换
需要使用多个聚合内的实体和值对象编排业务逻辑
领域行为需要访问外部资源
虽说领域服务能够承载领域逻辑,却不能说将所有的领域逻辑都往里塞,如此,导致领域对象贫血。只有当实体与值对象承载不住或是本身并不属于实体或值对象的职责内时,才考虑领域服务来承载,领域服务是一种妥协的结果,并不是说领域服务越多越好。
值对象(Value Object)→ 实体(Entity)→ 领域服务(Domain Service)
如下场景,创建 Invoice,存在几条业务规则,相应 Order 的状态需已完成,并且对应的 Supplier 提供财月信息,这就需要多个聚合的协作,在领域服务编排这些领域对象模型及通过调用外部服务网关,完成业务逻辑。
// InvoiceManager
public async Task ValidCheck(string orderId, string supplierId)
{
var order = await _orderService.GetAsync(orderId);
if(!order.IsCompleted())
{
throw new UserFriendlyException("Order status is not completed");
}
var supplier = await _supplierService.GetAsync(supplierId);
if(!supplier.IsCompleted())
{
throw new UserFriendlyException("Order status is not completed");
}
}
public async Task SetFinanceMonth(Invoice invoice, string supplierId)
{
var supplierFinanceMonth = await _supplierService.GetFinanceMonthAsync(supplierId, Current.Date);
if(supplierFinanceMonth == null)
{
throw new UserFriendlyException("Supplier not provider finance month");
}
invoice.SetFinanceMonth(supplierFinanceMonth.StartDate, supplierFinanceMonth.EndDate);
}
在应用服务中,通过调用聚合及领域服务,完成这一创建 Invoice 的用例。
[UnitOfWork]
[Authorize(PermissionNames.Invoice_Create)]
public async Task CreateInvoice(CreateInvoiceDto input)
{
await _invoiceManager.ValidCheck(input.orderId, input.SupplierId);
var invoice = Invoice.Create(input.Name, input.Description)
.SetOrder(input.OrderId);
await _invoiceManager.SetFinanceMonth(invoice, input.SupplierId);
await _invoiceRepository.InsertAsync(invoice);
await _appNotifier.NewInvoiceAsync();
}
借助领域服务,以此来完成多聚合间的协作,通过应用服务编排领域模型对象,完成一个业务用例。
领域事件
在软件开发中,事件早已被我们所熟悉,一个按钮按下,产生中断事件,一个回车,前端页面有侦听事件,在事件风暴建模活动中,事件也是作为领域建模的突破口,事件的重要性不言而喻。其本质是发生的事实到引发了相关事情,在这其中的传递的信息便是事件的内容。就如同猫叫了,引发着老鼠跑了,主人醒了,其中的事件便是猫叫了,而该事件是猫执行叫的动作后的结果。
在领域驱动设计中,最开始的版本中并没有领域事件的概念,在 DDD 社区对领域驱动设计的内容不断的充实中,引入了领域事件。领域事件的命名遵循英语中的“名词 + 动词过去分词”格式,如,提交订单后发布的 OrderCreated 事件,订单完成后 OrderCompleted 事件,用以表示我们建模的领域中发生过的一件事情,也符合着事件本身是具有时间特征。
对于领域事件本身,依据各层的使用方式及面对的目标不同,划分出两种事件类型,领域事件与应用事件(或集成事件),应用事件侧重于应用层的使用,而领域事件沿用原领域事件的称呼,更偏向于领域层。而又应侧重点不同,又有着不同的使用方式,如领域事件更多的是从领域模型中发布,其最终接收者为当前聚合所在限界上下文,而应用事件更为广阔,从应用层发布,其接收者为当前上下文或是其他上下文。
基于限界上下文间采用的部署方式不同,也存在着不同的通信方式,如整个应用程序为单体,则所有上下文在同一个进程内,则上下文间事件交互时所采用的可以是进程内的事件总线,或是进程间使用的消息队列,而当在进程间时,就不得不使用进程间的消息队列了。
由于 DDD 中遵循一个用例对应一个事务,在一个事务中更新一个聚合,因此对于实际场景中需要变更多个聚合下,我们常通过编排方式调用其他聚合的服务,这不可避免的加重了对其他服务的依赖,借助领域事件,则可以很方便的降低这种耦合,同时对于多个聚合的变更操作,由单个聚合的事务变成了多个聚合的事务,又依照实际影响的聚合情况,有着不同的处理方式,如多个协作的聚合为同一上下文内时,可通过强一致性去保证数据一致性,而处于多个限界上下文间的聚合时,则可依照最终一致性保证数据的一致性。
领域事件主要用途有:
从事件角度丰富了领域模型
保证聚合间的数据一致性
实现事件事件溯源和 CQRS 等
限界上下文间集成(发布订阅模式)
资源库
在刚接触资源库(Repository)时,第一反应便是这就是个 DAO 层,访问数据库,然后吧啦吧啦,但是,当接触的越久,越发认识到第一反应是错的,资源库更多的是对资源的管理,而不仅仅是数据库中的数据,数据库可以作为资源的一部分,但不是全部,我们习惯将对外部系统的调用称为外部资源的获取,这也是将外部系统作为资源的一部分。
对于聚合来讲,资源库的作用是负责将聚合持久化到数据库的(通常是持久化到数据库),并且由于聚合根负责维持聚合的生命周期,也就使得应考虑仅聚合根才应该拥有资源库,这也是与 DAO 层不同的地方。
在分层设计时,考虑将资源库的抽象划分到领域层,属于领域模型对象的一部分,如同设计防腐层的抽象网关般,资源库的抽象作为特殊的网关,当在应用层或是领域层中操作资源库抽象时,将资源库作为管理聚合状态的工具,可以忽视基础设施层中对资源库的具体实现。而在考虑基础设施层中具体实现时,可根据需要选择适合的工具,以此来管理和操作资源。
工厂
聚合从 0 到 1 的过程,可以通过多种途径创建,一般来讲,我们开发中常直接实例化或是反射实例化,而对于聚合来讲,整个聚合是一个整体,命运共同体,并且由聚合根掌握聚合的生命周期。通常,我们可以借助几种方式来创建聚合,组装聚合,在创建过程中封装业务逻辑。
聚合自身担任工厂,在聚合根中实现 Factory 方法
独立的 Factory 类,用于有一定复杂度的创建过程,或者创建逻辑不适合放在聚合根上
借助其他聚合来创建,其他聚合担任工厂角色
借助构建者模式灵活组装聚合
聚合根的创建有多种方式,依据聚合内掌握知识的多少与创建逻辑的需要可灵活选择。
//...
var partType= PartType.Create(input.Name, input.Description)
.SetCategory(input.Category)
.SetFactory(input.FactoryName);
如借助构建者模式,通过拆分许多小的方法,将过多的参数拆分,以此避免一个创建方法参数中满屏都是参数的情况,需要考虑吧拆分的方法需要满足业务一致性,如内部的一些属性间有约束条件下,需要划分到一个方法中,以维持一致性或不变性。
学无止境
从2004年领域驱动设计到现在已经有17年时间了,并且在其中还有诸如六边形架构,洋葱架构,整洁架构等的出现,考虑的侧重点不同,衍生着大量的新概念,也不断地完善着领域驱动设计的思想。在学习与理解领域驱动设计中,总会有新的东西改变我们以往的思想,见到的越多,越发觉认识的越少,这或许也是学起来有点阻力的原因吧。
参考
《实现领域驱动设计》- Vaughn Verno
《领域驱动设计实践》- 张逸
《软件架构编年史》- herbertograca
领域驱动设计实现之路 - 滕云
领域驱动设计编码实践 - 滕云
Package by component and architecturally-aligned testing - Simon
2021-01-18,望技术有成后能回来看见自己的脚步