用ABP入门DDD

前言

ABP框架一直以来都是用DDD(领域驱动设计)作为宣传点之一。但是用过ABP的人都知道,ABP并不是一个严格遵循DDD的开发框架,又或者说,它并没有完整实现DDD的所有概念。

但是反过来说,认真学过DDD的人会发现,所谓“完整实现了DDD,严格遵循DDD概念”的开发框架其实并不存在。因为DDD本质上是在分析业务,在“落地”的时候与代码有关,但是关系并没有我们所认为的那么大。

所以,个人觉得,从学习如何正确使用ABP框架,去揣摩框架的部分功能的设计意图,也是一种很好的DDD入门方案。

先抛几个常见问题:

  1. 命名空间该如何组织?
  2. AppService应该怎么写?
  3. 实体类应该充血还是贫血?
  4. 什么时候需要写领域服务(DomainService)?
  5. 领域事件(DomainEvents)应该怎么用?

框架并不会严格规定我们该怎么写代码,但是DDD给出了指导性的建议。但如果我们不了解DDD,那么所谓建议就无从说起。

所以,我们还是要从介绍DDD开始。

DDD是一种业务分析方法

DDD领域驱动设计是计算机软件行业为了项目能尽量趋向成功,根据多年经验总结出来的一套业务分析的方法论。其核心是消化特定业务领域的知识并创建忠实反映它的软件模型。

正确的实施并非极其困难,错误的实施却很容易。

DDD并不难,只是中文资料相对缺少,部分词汇初次接触有可能觉得过于抽象(加上某些词的翻译版本不一样),会有点晦涩的感觉。

想找中文资料学习DDD的,可以去博客园搜一下领域驱动设计,这里首推ENode作者汤雪华的博客

本文重点在于普及,不会讲的特别深入。

要想讲清楚ABP开发框架和DDD的关系,还是要从DDD的作用讲起。

DDD的分析部分——顶层设计

DDD有一些词汇:

  • 统一语言
  • 问题空间,解决方案空间
  • 领域,子领域
  • 上下文,绑定上下文(Bounded Context 有些翻译成边界上下文,简称BC),上下文映射
  • 聚合,实体,值对象
  • 领域服务,领域事件

在分析部分(也有人称之为战略设计,其实就是自上而下的进行分析),我们还不用管聚合、实体、值对象、领域服务、领域事件,只要看前面这些比较抽象的词汇。

统一语言

DDD的第一件事,是定义“统一语言”。

什么是统一语言?

大概解释下,统一语言是为了降低沟通成本(口头、文档、代码等)、减少歧义,通过业务专家(又叫领域专家,就是非常熟悉业务的人)核准和明确语义,项目的官方语言(可以认为是一份术语表,由类似架构师的角色在确认需求的过程中提炼出草案,并后续逐步完善——增加新词汇,明确语义,处理歧义、同义等)。

写代码最头疼的命名问题,统一语言可以帮你解决。不仅是参考,还是标准,原则上不允许随便命名,必须和统一语言保持一致。

问题空间和解决方案空间

问题空间解决方案空间基本就是字面意思。

形象点说,问题空间是我们在白板上画的一个大圈圈,写上“电子商务”。然后大圈圈里再画上一些线分割开来,一部分是“C端商城”,一部分是“后台管理系统”,一部分是“供应链系统”。(下图只是简化的示意图,不具备参考意义,真实场景需要更细化)

all

解决方案空间,可以理解为针对“问题”的“答案”,解决方案空间的划分最终对应到我们的代码实现,但这个粒度依然是很大的,比如我们用一个VS2017里解决方案sln(通常是一个单独的代码库)关联的所有项目去实现“C端商城”,另一个sln涉及的项目去实现“供应链系统”。所有sln合起来是这个“问题空间”的“解决方案空间”。当然有时候简单系统只需要一个sln就够了。

除了代码的大粒度组织,这往往也影响团队分工,影响人员组织。

子领域就是对问题空间的继续划分。划分的参考标准是统一语言中的某些词汇是否出现了歧义——部分词汇出现多重含义往往预示着存在子领域。每个子领域中的统一语言是一致的,无歧义的。

绑定上下文就是对解决方案空间(不是VS2017那种解决方案)的继续划分。

所以子领域对应绑定上下文

上下文映射,就是搞清楚绑定上下文之间的关系(上下游依赖关系,下游依赖上游——下游上下文受上游上下文变更影响,通常说的防腐层就是为了隔离这种影响)。

all

所有这些词汇,其实核心思想非常简单,四个字——“分而治之”。

但是具体怎么“分”,却没有固定的方案,完全依赖个人对业务领域的理解程度。甚至这个划分方案是随着对业务领域理解的加深而持续变化的。体现到“落地”,就是不断的调整架构或者重构代码。

分析部分最擅长处理的两种场景

一个场景是,业务逻辑确实很多,很难消化、提炼和组织。就是非常复杂,也是DDD的主要目的——应对软件核心复杂性。

另一个场景是业务逻辑还没完全清楚,这一般是指初创企业,特别是创新型企业,没有行业参照,自己摸索的情况下。

两个场景都依赖“统一语言”的威力。前者可以通过统一语言促进理解,降低沟通成本。后者可以通过统一语言来表现对业务现状的理解和展望其未来的走向。

分析部分最重要的两个元素

统一语言绑定上下文是DDD分析部分最重要的两个元素。

all

绑定上下文继续向下细分,才会涉及每个绑定上下文的架构问题,此时才开始考虑如何“落地”,也就是下面说的策略部分,选择支撑架构

关于DDD分析部分,还涉及很多具体的指导方法,请自行参阅文末所列相关书籍。分析部分进行顶层设计,最重要的产出就是绑定上下文(BC)的划分及BC之间的关系(上下文映射)。

DDD的策略部分——支撑架构

众所周知,DDD有一定的前期成本,而它的好处是降低了一个系统后续的长期维护代价。

所以,为每个绑定上下文(BC)选择支撑架构(实现方案)的指导原则是看“软件的使用期限”。

上面两句话其实有一点矛盾——看起来好像是用了就丢的一次性软件系统不值得使用DDD,但是这个系统的BC是用DDD划分出来的。

其实这里的DDD,有歧义,指的是DDD的一个推荐支撑架构——领域模型,而我们前面分析得到这个绑定上下文(BC),是DDD分析部分的一个结果。

也只有到了某个BC是核心业务,需要长期维护、迭代演进的时候,我们才会考虑用领域模型(一种特殊的对象模型)来实现这个BC的支撑架构。到这一步,我们才涉及到诸如OOP开发语言,ABP开发框架这些选择具体技术栈的问题。

特殊的对象模型意思是,对象模型关注对象和对象之间的关系,即使贫血模型依然是对象模型,特殊是指领域模型关注对象的行为,即要求充血模型。

我们先看看除了领域模型,对于支撑架构还有哪些可能选择。

CRUD也是一种支撑架构

在看DDD相关的书之前,我们往往认为CRUD相当low,事务脚本相当low,不管什么都该用领域模型(这里不叫DDD了,区分下)来实现。

这就有种,拿着锤子,看什么都像钉子的感觉。

其实所有DDD相关书籍都在劝我们,具体情况具体分析。

如果是短期、一次性项目(这里所有的讨论都是针对某个BC),一般叫“快速应用程序”,工期紧也是一种考虑因素,自然什么熟用什么,CRUD也行,只要行得通。

很多时候优先是解决问题。换句话说:

可以只追求 Make It Work,只要项目是一次性的,无需后续维护的。

再如,一个纯展示的项目,可以直接套用一个现成的CMS系统,而非投入人力去从头开发。

只有当通用软件产品(财务管理,CRM,CMS之类)无法满足需求,而且也无法简单通过一个阶段的定制投入就能解决问题时,我们才需要采用领域模型去分析业务,进行软件建模。

这通常也是老板为什么需要组建一个自己的技术团队的原因。

ABP中的DDD构件

所以,任何开发语言,任何一个能实现CRUD的框架,都可能作为DDD指导下划分出来的某个BC的支撑架构的实现选择。DDD并没有贬低非领域模型式的支撑架构,而是平等的对待它们,因为总有合适的场景,只是依赖个人的经验。

直到这里,我们才开始涉及ABP框架。

分而治之,从大到小

前面我们讲到在统一语言中根据同个词汇的多重含义的线索我们可能将一个问题空间划分成多个子域,为每个子域确定绑定上下文(BC)。这可能涉及到多个VS解决方案(sln文件),我们先假设只有一个VS解决方案。

我们通常通过ABP官网的项目模板来初始化我们自己项目的VS解决方案。

在下载完成,解压后,我们可以观察下程序集名称和默认命名空间,这里可以参考ABP系列——QuickStartB:正确理解Abp解决方案的代码组织方式、分层和命名空间

接下来以Personball.Demo.sln为例

对于解决方案Personball.Demo.sln,我们发现多数类库程序集的默认命名空间是Personball.Demo。再下一层,一般就是实体名称的复数形式命名的文件夹(跨程序集保持一致)。

注意,命名空间的层次是没有限制的,而且默认对应了文件夹层次结构。
所以

对于一个解决方案中容纳多个BC,我们可以通过命名空间来体现BC的隔离。

在BC之上,我们描述架构,可能是一系列草图,主要用于分析边界、BC之间的关系,做一些顶层设计。当各个BC的边界划分明确后,开始分析一个BC内的业务,我们就用到了聚合和实体的概念。

实体的定义很简单,ABP有实体的泛型基类Entity<T>,其中主要就是一个属性:Id。其他的FullAuditedEntity或者CreationAuditedEntity都是框架提供的方便审计的基类扩展。

所以,实体就是

领域中具有唯一标识的对象。

从命名空间上看,我们可以给BC一个名字,让它逻辑上“统领”一部分代码,这些代码主要就是一些实体类。但是实体类也是有主次之分的。典型的例子就是Order实体和OrderItem实体。虽然OrderItem有自己的id,但我们几乎不会单独引用OrderItem,因为单独一条OrderItem几乎不会有业务意义(不能说死,不排除个别我没见识过的业务场景)。一个Order有多个OrderItem,对OrderItem的操作通过Order进行代理,这里,Order就是聚合根

把一组实体放一起,就是聚合,其中作为主要代表的实体即是聚合根。聚合之间只能通过聚合根进行引用,不能直接引用聚合中的非聚合根实体。

按Order来说,其他聚合要引用Order的时候,记录的是OrderId(或者订单号),假设其他聚合要处理某个Order的OrderItem,它也只能引用Order,让Order去处理它自己的OrderItem。这其实是一种内聚的思想,或者叫封装,或者叫关注点分离,总之是一种复杂性的隔离(划分BC也是一种复杂性的隔离)。

我们一开始看到ABP的AggregateRoot<T>IAggregateRoot<T>,几乎是懵的,项目模板中也没有这个基类的范例。再看看这个基类提供的属性DomainEvents,以及ABP框架中涉及该属性机制的源码(看AbpDbContext的SaveChange方法实现)。这时候,我们看到了事件怎么用,开始思考领域事件这个词,开始去学习DDD。

当我们开始思考事件的时候,我们很自然的就会去思考实体的行为(方法)。

我们通过实体方法实现实体自己能够处理的业务逻辑。以“Tell,Not Ask”的原则实现实体的行为。在行为成功完成后,抛出事件,以便外部协同。而聚合根(继承AggregateRoot<T>基类或者实现IAggregateRoot<T>接口)作为其他实体的代理,实现本聚合内的逻辑,通过DomainEvents收集各类事件,交由ABP框架底层来触发事件,实现跨聚合甚至跨BC的协同(同时事件的发布订阅模式也是一种逻辑代码的解耦,顺序无关,EventHandler也可以回滚工作单元)。

另外,DDD中的仓储模式是基于聚合根实体的(聚合根同时代理了非聚合根实体的仓储职责,就是说OrderItem不应该有自己的仓储接口和实现),这一点在ABP中并没有严格限制,或许是ABP作者不希望把框架的使用门槛定的太高。

实体(聚合根也是实体),只能实现自己控制范围内的业务逻辑,控制范围外的呢?

所有无法放到单个实体内实现的业务逻辑,都可以放到领域服务中实现。

这包含,需要同一个实体类的多个实例配合的,需要不同实体类的多个实例配合的,还有其他。只要一个实体的实例无法自己完成这部分逻辑,就需要构建领域服务

最后,最小的DDD构件,值对象。ABP框架中有一个基类ValueObject<T>,即用来表示值对象

其实DDD中的值对象对应到代码,有一个很宽泛的范围,可以认为

所有没有唯一标识的数据对象,都是值对象。  

最基本的,比如C#语言的值类型,像string,int,decimal,都是值对象。那么我们为什么还需要一个基类来辅助构造值对象?

第一个原因是,值类型,业务表达能力弱。  

通过float,我们可以知道数量,但是不知道是重量还是体积;
通过decimal我们能表示金额,但是不知道是人民币还是美元。

所以,我们需要自己构建值对象,来更准确的表达业务概念。

第二个原因是,方便。

值对象只能通过各个属性的具体值比较来唯一确定,这个基类帮我们重写了Equals()GetHashCode(),并重载了相等和不等操作符。

但,这里有个坑

值对象必须保证其不变性

具体看Abp系列——为什么值对象必须设计成不可变的,而ABP框架是无法控制你如何使用ValueObject<T>的子类的。具体地说,

你的值对象必须关闭所有属性的setter,必须通过构造函数来初始化,且不允许通过方法改变属性值。
忘了分层,应用服务层和基础设施层

上面讲的(聚合、聚合根、实体、值对象、领域服务、领域事件)基本都是领域层。
DDD讲领域模型支撑架构的时候,特别提到分层,也是我们从ABP中学到的分层方式:表现层、应用服务层、领域层、基础设施层。

  • 表现层并不特指前端界面,MVC框架也只是一种表现层框架,它只是特别擅长处理Http协议。
  • 应用服务层就是Application程序集,是DDD建议的体现用例的一层,直接对接表现层(类似MVC控制器的协调作用,接受请求,返回DTO/ViewModel),用来编排任务,将工作指派给下层。所以应用服务(AppService)的代码,根据用例进行组织即可。
  • 领域层即是业务模型的完整实现。
  • 基础设施层侧重于持久化技术,比如EF,但是不限于持久化技术(通用功能接口的具体技术实现,类似仓储,接口定义在领域层,实现放在基础设施层)。ABP按照ORM框架名称作为基础设施层的程序集命名可以理解,但不能被其限制。个人建议另开一个程序集如Personball.Demo.Infrastructure,依赖于Personball.Demo.EntityFramework,再让启动模块依赖Infrastructure模块。
扩展:CQRS和事件溯源

当我们说经典领域模型的时候,指的就是基于对象模型来实现业务,数据存储走关系型数据库,一切看起来都很完美。

但是DDD研究的是复杂性。

软件开发行业几十年的经验累积下,前辈们发现如果把软件功能分成两方面,假设系统中查询部分的复杂度是N,命令(创建或变更数据)部分的复杂度也是N。

那么经典领域模型的情况下,系统的命令和查询混在一起,这个总体复杂度就是N乘以N,如果分开,那么系统总体复杂度就会降低到N加N

另一种说法是,对象模型的局限性日益显现,现在发现关注事件比关注对象更方便业务建模,因为现实世界是基于事件的。这引导我们可以使用函数式编程来实现支撑架构,同时也引出了事件溯源架构。

CQRS,命令与查询职责分离,正如其字面上的意思,一个相当简单的原则,却非常有效的降低了系统的复杂性。

这里并不是要推荐一个CQRS开发框架,只是提一下,大家可以在任何开发框架,任何场景下,按CQRS的方式去思考,都可以获得实际的好处。

再理一遍

  • 统一语言
  • 问题空间、子领域
  • 解决方案空间、绑定上下文/上下文映射、聚合/聚合根、实体、值对象

如果还有不明白的,可以参考下列书籍;如果还想深入学习的,可以参考下列书籍。

希望本文能对你有所启示,由于本人水平有限,若有表达错误的地方,欢迎斧正。

相关书籍

《Microsoft.Net企业级应用架构设计》
架构师参考书,后半本基本都是讲DDD的,也是本文的主要参考(这本最近刚重新看完,也在整理思维导图,下面几本专讲DDD的还没复习,忘得差不多了)

《领域驱动设计》
又称DDD

《实现领域驱动设计》
又称IDDD

《领域驱动设计模式、原理与实践》
又称PPPDDD(英文版书名三个P开头的词在前面)

Published: December 07 2018

prize