ENode框架Conference案例分析系列之 - 上下文划分和领域建模

前面一片文章,我介绍了Conference案例的核心业务,为了方便后面的分析,我这里再列一下:

业务描述

Conference是这样一个系统,它提供了一个在线创建会议以及预订会议座位的平台。这个系统的用户有两类:1)客户,可以创建和管理会议;2)会议座位预定者,可以预订会议座位。具体的关键业务描述如下:

  1. 客户创建一个会议,并录入会议的基本信息,比如名称、时间段、地点,等;会议创建后,系统会为客户自动生成一个AccessCode,客户可以通过AccessCode访问自己创建的会议;
  2. 客户定义某个会议的座位类型,可以定义多个,每个座位类型包含的信息有:名称、座位价格、座位数量;
  3. 客户发布或取消发布某个会议,当一个会议发布后,预订者就可以在线预订会议的座位了;如果取消发布,则该会议对预订者不可见;只有未发布状态的会议才能修改;
  4. 预订者在预订会议座位时,会生成订单,订单需要进行支付才会生效;
  5. 订单生成后,预订者可以有15分钟的时间付款,超过15分钟,订单预定的座位就会回收,允许其他人预定;
  6. 订单生成后,系统会为预订者生成一个AccessCode,用户可以通过AccessCode查看自己的订单;
  7. 预订者成功预订了座位后,可以指定每个座位的实际参会人信息
  8. 客户(会议的Owner)可以管理他创建的每个会议的所有订单,比如可以查看该会议的所有订单以及参会人信息,以方便联系参会人;

上下文

从上面的业务描述中,我们知道,这个系统主要有三大块:1)会议管理;2)预定会议;3)支付。然后本文的目标是要对领域进行建模。而在建模之前,我们需要先划分模型的上下文边界。

我对上下文的理解

DDD的关键思想是领域建模,领域就是问题域,领域建模的结果就是领域模型。而一个上下文定义了一个领域模型的应用范围,即边界,它规定了什么应该在领域模型内,什么不应该。同时,由于上下文划定了领域模型的边界,所以由领域模型而衍生出来的代码实现、团队成员、数据库,以及UI等元素,也都被划定在这个上下文里面。也就是说,每个上下文里的东西都是自成一体的,解决某个特定问题的,它的领域模型以及解决方案(每个上下文的解决方案或架构可以完全不同,数据存储也可以完全分离),以及团队成员不会受到外界其他上下文的干扰。上下文之间可以通过外层的adapter(适配器,防腐层)来相互交换数据,进行通信,即我们平时所说的context-mapping。

另外,我觉得上下文的划分是一个动态的过程。动态的意思是,当系统随着时间的推移,变得越来越复杂的时候。也许这个系统一开始只有一个上下文即可,这个上下文中有一个领域模型,然后这个领域模型中的所有概念都很明确,无歧义,所以这个领域模型可以有效的解决当下的领域问题。但是,随着系统的业务越来越复杂,比如系统中的某几块业务都变得不断复杂,我们再通过一个大的领域模型就难以有效的解决领域的问题。所以我们需要对领域进行分解,然后每个领域对应的领域模型会有各自的上下文。比如,淘宝原来的商品中心、订单中心、库存中心也许是在一个上下文中,但随着库存管理的复杂,订单处理的复杂;我们慢慢将商品中心、订单中心、库存中心划分到了不同的上下文中。然后每个上下文内各自解决各自的领域问题。商品中心只关心商品的基本信息管理和展示;订单中心只关注订单流程的处理;库存中心则维护商品的库存;然后这三个上下文通过系统集成的方式进行交互。所以,我们通过这种分而自治的思想,对不断变复杂的领域问题,进行切分,然后持续集成。

其实划分上下文,我觉得也是和我们OO中提出的高内聚、低耦合的原则是一致的。用上下文,将一个大领域模型划分为多个小领域模型,每个小领域模型都具有很高的内聚性,容易维护,自成一体,独立演化。同时,由于上下文维护了每个小领域模型的边界,边界之间不会直接通信(通过防腐层),所以做到了低耦合。所以,我们可以看到,没有划分出上下文,我们根本无法开始DDD。我们在寻找聚合根、实体、值对象这些东西前,首先要划分好上下文。

上下文划分思路

上面说了一下我对DDD中的上下文的看法,那我们要如何划分上下文呢?我个人觉得划分上下文是一件很难的事情,因为可能完全要凭我们的经验,没有明确的放之四海都正确的方法论。所以,划分上下文在我看来,更多的是一个主观的思考过程。也许这就是软件设计的魅力所在吧,或者说是我们程序员的价值所在,呵呵。但有一点我认为是非常肯定的,那就是划分上下文之前,我们必须要对我们的业务有很深入的理解。那如何来理解业务呢?一种可行的方法就是从场景入手,通过分析场景用例来理解业务。然后我们可以从用例的所实现的业务功能的角度对用例进行分类,然后不同的分类就代表了不同的上下文。

前面我提到,上下文内部是高内聚的,上下文之间是低耦合的,这个只是表象,或者说是我们希望达到的目的,它并没有指导我们如何划分上下文。我觉得划分上下文最根本的出发点就是从业务功能的角度来划分。因为我们通常都很清楚,哪些功能应该在一起实现,哪些功能最好不好和其他功能掺和在一起。比如我们希望处理订单的时候,最好不要直接和支付系统耦合,尽管处理订单时,必须要和支付系统打交道。为什么我们认为处理订单的部分不要直接和处理支付的部分耦合呢?因为我们通常认为,这是两件不同的事情。关于这点,又可以用OO的另一个原则来解释,就是单一职责原则。单一职责原则告诉我们,一个类只做一件事情,上升到上下文,我想也同样适用。如果我们的一个上下文只做一件重要的事情,那这个上下文的命名也就比较容易,比如ConferenceManagement BC, Registration BC, Payment BC。当你发现要为上下文命名时,需要用好几个单词组合起来才行的时候,很可能是你的上下文划分的过大了。

Conference上下文划分

上面我们了解了上下文的概念以及划分思路,现在我们可以开始我们这个案例的上下文划分了。首先,我们先找出上面的业务描述中的关键业务场景:

  1. 客户创建会议;
  2. 客户管理会议座位;
  3. 客户发布会议;
  4. 预订人预定会议座位;
  5. 预订人支付订单;
  6. 预订人填写参会人信息;
  7. 客户查询会议的订单信息;

上面这几个我觉得就是这个系统的主要业务场景了。然后我们分析这些场景的功能,看看有哪几类。我们发现1,2,3,7这几个场景,都是由客户发起,并且做的事情都是和管理会议相关。所以我们可以认为它们表示的是同一类功能,即会议管理相关的功能;我们可以把它们放在一个上下文中,我命名为会议管理上下文。然后预订人操作的有4,5,6三个场景。那这三个场景应该在一个上下文中吗?仔细看一下,我们感觉,支付订单这个场景,最好能独立出来,因为支付的过程和逻辑往往需要和第三方系统交互。如果直接把支付耦合到处理订单的过程逻辑中,那会让订单处理的逻辑边界不清楚。所以,我们希望通过一个独立的上下文来封装和第三方支付系统交互的逻辑。这样的话,以后支付和订单处理这两块业务,都可以独立演化,互相不受影响。所以,我们又识别出了两个上下文:预定上下文、支付上下文。

到这里,我们划分好了Conference这个系统的上下文。接下来,我们开始来进行领域建模。

领域建模

领域建模的目标就是要设计出领域模型。首先我想说明一下,我所理解的领域模型是不包括领域服务、仓储、工厂这些东西的。而仅仅只有聚合,聚合根、实体、值对象这些概念。因为我认为领域服务、仓储、工厂这些是属于领域层的,领域模型不等于领域层。所以,下面我的目的是要设计出领域模型,即找出有哪些聚合,以及每个聚合里包括的内容。

我的领域建模的主要方法是,从场景出发,分析出每个场景中出现的领域概念,然后分析哪些概念是场景中的核心概念,哪些是相对次要的或者仅仅是描述性的概念。从而一步步识别出每一个聚合根。找出了聚合根之后,我们再去关心我们对每个聚合根关心哪些信息,把这些信息作为聚合根上的属性(值对象)即可。这样说可能还是比较含糊。我从另一个角度来说明一下:一般一个业务场景的结果都是核心聚合根,然后参与这个场景的所有角色的扮演者往往也是聚合根,这些聚合根最后就体现在前面我说的核心聚合根的某个属性或者子实体一部分(值对象)。比如一个买家在淘宝上下一个订单,那下订单的结果就是订单核心聚合根,然后订单由买家信息和卖家信息,这些是订单的值对象。另外,订单里还有订单明细,每个明细是一个子实体,然后订单明细里包含了商品信息,商品信息是订单明细的值对象。简单的说,这种分析思路是一种面向结果的分析法。我个人认为很有效,至少对我比较有效。

下面我们来看看上面所描述的业务,出现了哪些领域概念:

ENode框架Conference案例分析系列之 - 上下文划分和领域建模 

这个图就是上面业务描述中加粗字体的那些领域概念,这里只是对领域里出现的概念的罗列,并不是最后的领域模型。罗列这些概念,有助于我们不会在建模的过程中漏掉一些明显的概念从而导致漏掉一些聚合根的识别。

上面,我们已经知道了有哪些业务场景,下面我们分析一下每个业务场景的结果聚合根和参与者聚合根。

业务场景之 - 客户创建会议

  • 场景结果:会议,即一个Conference对象;
  • 场景参与者:客户;

Conference肯定是聚合根了,因为它是场景的结果。那客户是聚合根吗?不是,因为在我们的系统中,相对于会议来说,我们只关心会议是谁创建的,即会议的Owner。所以,对于Conference聚合根来说,客户只需要作为Conference聚合根的一个值对象属性即可。另一方面,我们的系统中不会对所有的客户进行维护,也就是说,客户不需要有生命周期。所以,客户不是聚合根。

另外,当这个会议有一个新的订单产生后,由于订单会先进行预扣库存的处理。所以,需要Conference支持预扣会议座位的支持,我们可以在Conference聚合根内部维护一个当前所有订单的预定信息。然后,当订单要求提交预扣信息时,才删除预扣信息,并真正扣减座位的库存余额。

最后,我们还会关心会议的一些基本信息,如名称、开始时间、结束时间、开会地点,描述,等信息;这些都作为Conference聚合根的属性值对象即可。另外,为了方便客户下次访问这个会议,并做后续的座位管理,系统会为这个会议聚合根自动生成一个AccessCode。

业务场景之 - 客户管理会议座位

当客户创建完会议后,客户可以凭借AccessCode访问管理这个会议。然后他就可以管理会议的座位信息了。这里所说的座位信息实际上是指座位的分类,比如给开发者做的座位,给嘉宾坐的位置,等。某一类座位可以设置价格以及座位数。我们很容易理解,一类座位就是Conference聚合根下的一个实体。因为我们需要区分是这类座位还是那类座位,且无法通过名称来区分,必须通过ID来区分。所以,我们得出结论,一个Conference聚合根下有一个座位分类的明细,每个座位分类明细包含了座位的名称、价格,以及数量。这里有一个业务规则需要提一下,就是当客户删除某一类座位时,系统要检查当前是否有人预定了这类座位,如果有,则不允许删除。这个规则应该也好理解。因为如果允许删除,那意味着可能有些订单的数据一致性就无法保证了。

业务场景之 - 客户发布会议

客户创建完一个会议后,预订者还不能理解看到预定的座位。需要客户发布该会议后,预订者才能看得到。 这个应该很好理解,就像淘宝上的宝贝,需要店家发布后才能对买家可见。同理,店家还可以下架宝贝,这样对买家就不可见了。

要实现这个功能,很简单,我们只需要为Conference聚合根设计一个IsPublished标记位属性即可。

业务场景之 - 预订人预定会议座位

这个场景可以说是整个系统的核心业务场景,是整个系统的核心价值所在,是我们做这个系统的目的。 就像淘宝、天猫平台的目的就是为了提供一个平台,让买家可以在平台上订购商品。淘宝天猫其他环节都可以挂,但是下订单绝对不能挂。可见下订单业务的可用性是多么重要。所以,我们应该花最好的资源,最多的时间,做最好的设计,在这一块业务上面。

这个场景的交互流程大概是下面这样的:

  1. 预订者浏览某个已发布的会议;
  2. 进入会议的详情页面,该页面显示了所有可预订的座位分类信息;
  3. 预订者选择好要预订的座位分类,录入每个分类的预定数量;
  4. 点击提交按钮,提交订单;
  5. 系统处理订单,处理订单;
  6. 处理订单的主要步骤是:1)生成订单(初始状态,对用户不可见);2)扣减库存(内部有预扣逻辑);3)修改订单状态;4)通知预订者下单是否成功;
  7. 如果下单成功,预订者被导航到支付页面进行支付;预订者可以选择支付,也可以放弃支付;
  8. 如果支付成功,则提示预订者订单处理完成,交易完成;如果放弃支付,则关闭订单;如果超过规定时间(15分钟)未支付,则视作订单已过期,系统自动回收订单所预定的座位;
  9. 如果订单交易完成,则预订者可以通过系统给定的AccessCode访问自己的订单,然后可以接下来安排预定座位的参会人信息;
  10. 预定流程结束;

通过上面的流程我们知道,整个场景主要都是在围绕订单在进行处理。所以,毫无疑问,订单是核心聚合根,且订单有一个状态,表示当前被处理的阶段。然后,订单在一定的业务规则下进行状态变更。订单的状态是订单的核心属性。那我们如何设计订单的状态呢?还是要分析上面的订单处理流程。通过上面的分析我们可以知道,订单处理主要有:订单生成、减库存、支付三个环节。所以我们可以针对每个环节可能出现的不同情况,设计不同的状态即可。目前我设计了如下的状态枚举:

ENode框架Conference案例分析系列之 - 上下文划分和领域建模

当订单创建出来后,默认状态为Placed;然后系统自动去进行预扣座位的库存,如果预扣成功,则状态为ReservationSuccess;失败,则状态为ReservationFailed;然后,如果预扣成功,那用户就可以去支付页面支付订单,如果支付成功,则状态为PaymentSuccess;如果用户拒绝支付,则状态为PaymentRejected;然后,如果用户忘记支付,超过规定时间,则状态由系统自动设置为Expired,即过期;如果用户支付完成,则状态为Success,即订单交易成功;最后,当订单过期或用户拒绝付款后,订单最后的状态会被设置为Closed,即关闭。

这些状态是我对这个系统的理解设计出来的,和微软的Conference案例并不同。这里我不对微软的案例做分析,大家有兴趣的可以自己去看一下。

大家可以看到,整个订单的状态还是比较多的,订单处理的流程也有点复杂。订单处理的流程是我们的系统的关键,我们需要用最好的最健壮的设计来支持它。

我们再来看看订单还有什么其他的重要信息我们需要关心的。订单的明细也是必须要关心的,没有明细的订单就不是一个有效的订单。明细包括什么?这些都很简单,大家都思考过。那就是需要包括预定的Conference的座位信息以及数量,也就是说,我们关心这个订单预定了哪个Conference的哪些座位,分别预定了多少个。这里我就不展开了。

最后,我们还关心预订人的信息,因为我们总要知道是谁预定了这些座位的。

业务场景之 - 预订人支付订单

上面的订单处理流程中,还有一个很重要的环节就是订单支付。上面分析上下文时我们知道了支付是在一个独立的上下文中处理的。那这个上下文要负责做什么事情呢?我想,主要的事情就是:

  1. 根据订单生成支付信息;
  2. 然后提供用户支付功能(可以跳转到其他外部网站进行实际支付);
  3. 向订单处理的上下文通知支付结果;

总结起来,就是负责封装支付信息(Payment)的存储以及与外部系统集成以便完成实际支付的交互逻辑;

订单支付现实生活中,就是像在淘宝上买东西后跳转到支付宝站点那样,进行付款。而我们这个由于只是一个案例,所以没有真正实现支付的功能。但是我们可以通过做一个简单的页面来模拟这个支付的场景。比如通过提供用户一个页面,让用户来选择是否同意支付。如果用户选择同意,则系统认为用户已经支付完成;如果用户选择拒绝,则系统认为用户拒绝支付;不管用户选择哪个,都把支付的结果通知给订单处理上下文即可。对于订单处理上下文,它不关心支付的细节,只关心支付的结果。

所以,我们很清楚的知道,我们需要设计一个Payment聚合根,表示订单的支付信息。Payment需要记录是对哪个订单的支付,同时还可以记录这次支付的具体明细描述(可以来自订单信息的明细)。同时,还会关心此次支付共需支付多少钱,以及支付的结果(比如成功还是拒绝)。

业务场景之 - 预定人填写参会人信息

当预订人支付完成后,订单状态就变成交易成功了。然后预订人就可以开始填写他预定的座位的参会人的信息了,主要填写参会人的姓名和联系方式即可。方便Conference的Owner与参会人联系。那问题是,这些参会人信息需要聚合在Order聚合根下吗?你可以聚合。但我选择不聚合,理由是:此时订单的生命周期可以理解为已经结束了,然后预订人填写参会人信息,是属于后续的活动操作。所以,我们应该用新的聚合根来跟踪这个活动,这个聚合根关心的是这个订单的所有参会人信息,以及每个参会人和对应的座位的对应关系的维护。这些信息由订单来维护不是很妥当,订单的职责是记录预定的座位的信息以及记录订单处理的状态,一旦订单处理完成,那他的生命周期就结束了。它不关心预订人后面又做了什么事情。所以,我设计了一个新的聚合根叫OrderSeatAssignments。

业务场景之 - 查看会议信息、查看订单信息

这个场景只涉及到数据的查询,不涉及到修改。所以,对领域建模无影响,我就忽略了。

最后的领域模型

通过上面的分析,我们终于可以得到一个最终的领域模型了,如下图所示:

ENode框架Conference案例分析系列之 - 上下文划分和领域建模

上图中,每个边界就是一个聚合,Conference, Order, OrderSeatAssignments, Payment这四个分别是聚合根。

结束语

本文通过从业务需求描述,到上下文划分,最后到领域模型的设计。重点表达了我个人的思考过程,DDD Bounded Context我划分的依据,以及整个领域模型是怎么出来的。写这篇文章费了我不少脑子,希望对大家有帮助哦!下一篇文章打算介绍一下Conference案例的架构设计。


上一篇:DDD CQRS架构和传统架构的优缺点比较


下一篇:ENode框架Conference案例分析系列之 - 业务简介