领域驱动设计DDD入门2:有界上下文和通用语言
这堂课我们讲有界上下文和通用语言,这是两个重要的战略设计工具。之后讨论领域专家和业务驱动,然后看一个没有使用DDD的例子进行对比。最后是通用语言。
战略工具不少,其中最重要的两个就是这堂课的有界上下文和通用语言。什么是有界上下文?它是一个带有明确边界的域,在里面通过通用语言进行建模。能听懂吗?
首先,边界上下文是一个内容上或者语义上的边界,在边界内部所有东西都是和模型明确关联的。在模型中我们提到一个词的时候我们明确知道它的含义,任何东西都有明确含义,含义必须是和通用语言关联的。“通用”的原因是整个团队都用这个语言,在领域内是共同的语言。这个语言是直白的(不难理解的),日常交流就能对应软件模型。从另一个角度看,这个语言又不是通用的,因为它不是整个公司都用的语言,它仅仅在单个项目内使用。这个语言决定了这个项目有多大,因为讲这个语言的人都是这个上下文内的。而团队决定了通用语言是什么样子的,团队内至少有一位领域专家来领导通用语言的成形。领域专家并非一个头衔(所以不用去特意雇佣一个),而是团队内对特定业务领域知识最熟悉的人群,也是引领团队、项目前进方向的人。团队内的开发人员和领域专家交流、协作,一起创建通用语言。
我们把通用语言和人类的语言对比一下。汉族人讲汉语,朝鲜人讲朝鲜语,日本人讲的日语。当你从东北跨入朝鲜时,你听到的语言就变了。这就类似从一个边界上下文进入另一个边界上下文。另外日本人跟我们学习了不少字词,但有些词含义上还是有差别。在不同的边界上下文中,相同的词可能含义不同,不同的词也可能含义相同。所以通用语言在特定的上下文内才有含义
使用DDD及其战略工具的一个原因是不用就会有问题(啥?人择原理啊…)。一开始我们可能有简单的模型能满足业务需求,多次迭代以后,模型就会混乱,变成一个大泥球。 – 我先确认一下,你不会以泥球为傲吧?你肯定会尽可能避免这种情况的。
DDD的基本准则之一是跟着领域专家走。前面说过领域专家一直研究业务,他们为业务发展指明方向。他们的眼光能带领公司业务向前快速发展,在市场上提升竞争力。他们能对我们建模给出专业意见。每个领域内的专家都对至少一个领域精通,比如说保险业内有证券专家、债权专家、检查专家。这些领域都有保单的概念,但保单在证券领域和债权领域含义不同,在检查领域含义又不一样。这个模型该怎么建?如果用同一个模型去代表是不合适的。在DDD领域中,我们想要识别、区分业务领域,每个领域有自己的模型。所以前面这个例子需要三种保单模型对应三种边界上下文。这样就满足了不同领域的需求,边界明确的建立了保单模型。这就是DDD的第一步,感觉如何?
为了增强使用DDD战略设计的需要,我们看一个例子。这个例子是一个敏捷项目管理软件。敏捷项目的工具是project、backlog、sprint等,估计大部分人都熟悉这些,不熟悉百度一下也很清楚。我简单说一下:project,项目,就是团队要开发的软件;backlog,待办,是一个用户故事(user story)和一些内部任务,执行期间待办会被有序完成,可以分布在多个连续的sprint中;sprint,冲刺,是给定时段内要完成的待办集合。明白了吧:冲刺包含待办,是时间敏感的,待办包含任务,任务有估时;任务的总时间接近冲刺的时间。等你完成这些建模,新需求来了:我们有很多目标公司,每个公司的用户有不同的权限,不同用户在这个软件上能做的事情不同,他们只能看见自己的待办和任务。另外还要有论坛,对项目、冲刺、待办、任务等进行讨论;还要有日期控件,可以记录里程碑、计划会议、回顾会议、项目发布日期等。等你重新建模完成,又有新需求来了:我们的软件是收费的,而且有收费级别。不同公司交费不同,他们能用的功能就不同。我们需要记录各公司对收费功能的使用情况,以判断对他们推销下一级别是否有效。之后又有需求:记录每个组织、每个用户的使用情况,他们在各个待办上面花费的时间等等。所以我们要增加一个资源管理,根据不同成员的能力规划他们的任务。这太有用了,每个人都最大可能的被发挥作用。你发现了吗?随着需求变更次数的增多,我们的模型不停滚动,变成了一个大泥球。这太有问题了。很多企业都是眯着眼走到黑,最终出现大泥球。
怎么避免这种情况呢?边界上下文就派上用场了。再说一次,边界上下文帮助我们在域内建模。按照通用语言,什么属于边界上下文,什么不属于?什么在模型内部,什么在模型外?这都很重要。不该在模型内的就踢出去。这有什么好处?通过把领域专家和开发人员放在一起,而不是分成“写文档的”和“写代码的”两组人可以让他们充分协作,清晰地得到边界上下文内的通用语言。领域专家是业务流程的专家,是这个领域知识最丰富的人。在敏捷项目中,专家是管理待办、冲刺、任务等等这些最擅长的人。开发人员侧重技术,他们擅长和爱好的就是开发软件。不过开发人员不能纯粹地钻进技术研究中不出来,那样会脱离业务实际,如同盲人骑瞎马。开发要和领域专家一起工作,以确保他们的目标一致。这样我们在领域专家和开发人员之间可以得到一个反馈环。你意识到他们之间不是命令和控制的关系吗?领域专家不是让开发去干这个去干那个,而是不断的协作反馈,相辅相成的形成了业务专家和技术专家。大家都会主动思考,因为反馈环形成以后问题就会自然地出现:开发人员在想如何用技术服务业务,业务人员在想业务流程如何转换成技术实现。这些问题不停出现,就有了思维突破,关键想法接踵而至。所以团队一定要有领域专家,少也得有一个。
打烂泥球的一种练习方式就是挑战当前的模型,尝试用边界上下文和通用语言去重新建模,不仅要思考什么属于边界内,还要考虑什么不在边界内。所以我们练习一下挑战,首先质疑敏捷项目,租户、用户和许可是否属于它的通用语言。如果你想访问任何敏捷资源,你可能找不到任何地方有对租户、用户、权限或者类似的引用。实际上他们是一个建模概念,它们对敏捷项目管理的成败至关重要,却不属于任何边界上下文。就算我们不考虑租户、用户、许可这些概念,我们依然需要考虑构造团队的那些个体组,以及项目Owner和成员。所以我们在边界上下文内给团队、Owner、成员建模。后面再说怎么把他们和租户、用户、许可集成起来,现在我们再次挑战:账户、支持计划、支付、事件属于敏捷项目管理工具吗?完全不是!我们再看一次敏捷的引用,发现根本找不到这些概念,他们决不会成为我们的模型。时间资源呢?资源管理器、规划、可用性?尽管从概念上讲这些可能非常重要,但对于敏捷团队的整体而言,它们并不属于敏捷模型本身。它们没有上下文,因此应从我们的核心模型中删除。另一方面,我们确实有志愿者的概念。志愿者是分配了任务的团队成员,他们自愿完成任务,因此是志愿者。所以我们想为他们建模。使用日历条目支持提醒、里程碑、目标日期、回顾和计划会议怎么样?这些可能与敏捷模型保持一致,实际上,在敏捷中我们确实讨论了里程碑、回顾和计划会议。但是现在,这些超出范围了。重要的是,我们意识到在产品、待办事项、发布和冲刺中,团队希望能够就项目的每个领域进行线程化的讨论。因此我们想在我们的核心领域内建立讨论模型。我们将与另一个有界上下文进行集成,这个有界上下文处理协作,那个上下文有论坛、讨论、发帖功能,持有大多数模型;我们的核心模型只是线程化讨论功能。所以核心模型很小。在产品、待办、发布、冲刺中都可以进行线程化的讨论;待办中还有任务,任务有志愿者和预估;发布有计划中的待办,冲刺有提交完的待办,产品有Owner和成员。我们的核心领域在语言上是合理的,反映了敏捷的专业知识,不多不少。那么其他概念呢,那些我们拒绝掉的概念?它们可能属于其他不同的上下文。每个DDD项目都会有多个上下文。
那么我们如何真正开发一种通用语言呢?工具之一是开发场景,这些场景专门针对域模型。这些场景不仅是用例,不仅是用户故事,它们是在讨论、说明域模型中的特定对象或概念如何与其他概念协作以实现特定的目标。现在开始我们的敏捷项目管理应用程序中的通用语言。这个语言如此:允许将每个待办提交给一个冲刺。只有已经规划为要发布时的待办才能提交。如果已经提交给其他冲刺,则必须先将其撤销。提交完成就通知有关方面。这样我们有一个关于域模型的场景了,我们命名了一些特定的领域模型概念。例如待办、冲刺。我们还有一些动词或动作,例如提交,我们把待办提交给冲刺。我们讨论了一些约束,例如如果要提交待办,就必须在可以提交前规划它的发布时间;如果已经提交给其他冲刺,则必须先将其取消提交,然后再提交给我们的目标冲刺;提交完成后,我们要通知有关方面。这是场景的起点,它还不完整,但是DDD的强大之处在于我们可以通过不断寻求改进方案的方法来改进模型,编写代码实现场景。那么我们如何改善这种方案呢?其中一件事是可以通过询问谁来做以及谁是有关方面来改进方案。
现在回答第一个问题:谁将待办提交给冲刺? 一般的敏捷团队,也许当整个团队都同意将一个待办提交给一个冲刺,然后有人将一个便笺移动到我们任务板的TODO一栏中,这就是提交。但从软件管理角度看,不得不说应该有特定的角色将待办提交,最好的选择是产品负责人。而作为敏捷团队,我们不希望产品负责人能够随意随时地提交待办,我们整个团队需要同意在特定的冲刺中交付特定的任务,我们不希望他自己这样做。如何解决这个问题?就是通过询问由谁来执行。记住我们现在在开发人员和领域专家之间存在反馈环,当询问时会带来有趣的突破,很快就可以确定答案。 但我想告诉您的一件事是,如果这有助于改善方案,实际上还可以确定在方案中执行这些操作的角色。例如,我们将产品负责人标识为Isabel,则Isabel会将特定的事项,比如“查看用户个人资料”提交到冲刺,比如“交付用户个人资料” ,等等。但我认为在我们这个情况下,它并没有增加多少价值,因为角色并不真正影响此方案的实现方式。现在回到问题所在:通过问谁,我们作为一个团队实际上实现了什么样的突破? 发生的事情是,我们的产品负责人在将待办提交了,迫使我们整个团队来考虑:当我们建模时,如何允许他们提交待办同时是在没有团队输入的情况下没有强扭团队意志。这导致了什么?还记得待办是要规划上线的吗,还可能之前提交到其他冲刺过,这些都是约束;现在注意要加新的约束:要达到一定数量的团队成员批准这个提交。这是一个真正的思维突破,你可以想象一个团队正在参加计划会议,每个团队成员都带着手机或电脑,大家讨论可能要提交给冲刺的每个待办,是同意还是不同意将这些待办事项提交给冲刺。当他们同意时,他们就通过手机或电脑投票,如果超过半数(不能等于半数,要大于)则产品负责人可以将待办提交给一个冲刺。 这是一个巨大的突破,它为我们至关重要的核心域模型增加了很多价值。
提交完成后请通知相关方,那谁是相关方?可能有很多感兴趣的团体,但是我们关心的是一个非常重要的感兴趣的团体,即冲刺本身:因为待办已提交给冲刺,但是冲刺却不知道有待办已提交给它,它需要知道哪些待办已提交给它了。提交完成后,要同时通知被撤回提交的冲刺和现在刚提交过去的冲刺。在我们讨论聚合和域事件时,会进一步进行讨论。我们需要确保冲刺能知道待办已提交给它(或撤销了)。另外,看来待办知道自己被提交,和冲刺知道有待办提交过来是在不同的事务中,因此涉及到最终一致性,我们稍后再讨论最终一致性。系统的各个部分在短时间内不同步,但是很快就会变得一致,在某些情况下这可以提供更好的可伸缩性,甚至更好的性能。
这是我们现在的整体改进方案,回过头再看一遍:产品负责人将待办提交 – 只有待办已被规划发布且足够的人数已批准时,才可以提交;如果已经提交给其他冲刺,则必须先将其取消提交;提交完成后,通知原冲刺和新冲刺。我们可以开始编写测试并开始实现我们的模型代码了,但怎么证明这样是好的设计呢?
我们可以使用两种方法之一。例如,我们可以使用行为驱动开发或BDD,将场景分为三部分“前提-如果-那么”。“前提”是我们的模型已经构建好并且数据已经绑定;“如果”某个业务操作执行了,“那么”我们可以断言某些条件已满足,我们看看是哪些条件满足了。试一个例子:场景是产品负责人将待办提交给了冲刺。“前提”是计划发布的待办、提交待办的负责人、冲刺、批准提交待办的法定人数,共四个。“如果”在产品负责人向冲刺提交待办时前提已满足,“那么”待办就会被成功提交给冲刺。此时我们可以断言待办本身知道现在已将其提交给冲刺了,我们就有一个待办已提交事件。 现在,一些团队使用这种方法并通过后台软件实施此方案。他们创建了所谓的验收测试,置顶了验收测试的标准,做为BDD测试。试图达到这种级别的软件准确性和验收测试可能很诱人,但是它也可能会增加开销。我不会赞成或反对这种方法,只是你要知道它有不平坦的学习曲线,可能还需要一些专家建议再去走这条路。但仍然可以使用“前提-如果-那么”的方法而无需实施支持测试,只要在团队中进行良好的沟通和协作。
可以使用的另一种方法是制定单元测试规范。我曾经用C#写了这个测试,命名为ShouldCommitBacklogItemToSprint。在代码中我编写了三个不同的注释:Given(前提),When(如果)和Then(那么),因此这个单元基本测试遵循的方法与我们之前看过的场景大致相同,但是我们使用的是单元测试方法,而不是在编写场景后实施规范测试。我实现了一个BacklogItemScheduledForRelease,一个ProductOwner,一个SprintForCommitment,一个QuorumOfTeamApproval,所以这些是前提。从本质上讲,这是我们单元测试的基础。在when子句中,当待办被提交给有产品负责人的冲刺并达到团队承诺或团队批准的法定人数时,我们可以断言待办已提交。我们可以向backlog项请求该操作发生时在其上创建的事件,即CommitTo,我们期待一个BacklogItemCommitted域事件,并且断言BacklogItemCommitted域事件不为null。这样我们完成了与前面的BDD可以完成的几乎相同的操作,但是单元测试实现起来要容易得多,快捷得多。
关于体系结构,你可能会有很多疑问:“有界上下文在内部看起来是什么?”仅仅是域模型吗? “什么是有界上下文的体系结构?”“有哪些体系结构”。下面的体系结构图说明了一个端口和适配器的体系结构,也被称为六边形体系结构,有人称其为洋葱体系结构。这种体系结构的优势在于位于中心的是领域模型,这是软件的核心。围绕领域模型,我们通常具有某种应用程序服务,应用程序服务可以为我们管理事务,也许它们也可以管理安全性,但是它们还是软件需要执行的用例的任务管理器。最终是应用程序服务调用域模型上的方法来执行我们刚刚讨论的方案的操作。 在体系结构的外部,深蓝色部分是各个端口的适配器。可以通过几种不同的方式来思考这些适配器。我喜欢将它们视为来自外部世界的传入请求操作的端口。 在左上方有一个地球仪,这代表万维网或浏览器。也就是有浏览器正在向我们的有界上下文发出请求,并且这些请求正在通过专门为浏览器创建的端口适配器进行适配,该端口适配器将请求适配为内部应用程序服务或API,以便这些应用程序服务可以委托域模型执行实际的操作。在浏览器图的下方是手机,也许有一部手机正在向我们的架构发出请求。里面也有云,也许这是来自其他有界上下文的整合。下面有一个带有闪电的消息传递端口,用于接受传入消息,消息又像其他适配器一样将适应于内部模型。 因此,所有这些内部请求都集中适应内部API,然后该API委托给域模型。

在图的右侧可以看到输出端口。输出端口可以是与关系型数据库或文档数据库或内存网格通信的存储库。还有一个传出的适配器用于连接闪电标记的消息传递。因此,我们在接受消息,也在通过我们的输出端口生成消息发送消息。 因此,该体系结构很好地表示了可以与DDD一起使用的典型体系结构。
另一种观点是,在体系结构的外部具有输入适配器和输出适配器,在内部有应用程序服务并且调用域模型。在输入适配器中可能有安全控制,可能有用户界面通过RESTful表示形式。同样,在应用程序服务中也有安全性、事务、任务协调、用例控制器等。 在我们一直讨论的领域模型中,有我们的实体、业务逻辑、领域事件等。 在我们的输出适配器中,可能具有存储库、文件、缓存和消息传递。 这为您提供了同一体系结构的两种不同视图。
可以与DDD一起使用的体系结构模式很多。例如,在第六课中我们将讨论事件驱动的体系结构和事件溯源,还可以使用命令查询责任分离或CQRS。这里我们不会谈论太多,但是你可以从其他文献中获得有关如何实现CQRS的信息。也可以使用反应模型和参与者模型。我喜欢研究这个领域,使用actor模型进行反应式软件开发。这是一种非常清晰且非常强大的方法,它通过多个actor的并发使用在我们有界上下文中实现事件驱动的体系结构和CQRS。此外,REST和微服务、SOA也可以使用,可以将DDD与微服务一起使用,将DDD与SOA一起使用。
