作者 | TWInsights
在前文《当我们谈论DDD时我们在谈论什么》中我们讨论了DDD的战略设计和战术设计。在本文中我们将继续探讨领域模型。
在实际项目中,模型设计者往往过早陷入具体构造块类型的识别,比如实体、聚合、领域服务,而忽略了领域模型表达领域概念的目的。我们应该基于领域概念设计领域模型,然后再采用合适的模式降低领域模型的复杂度,进一步增加领域模型的表达能力。
领域模型的作用,一方面是关联代码实现,一方面是关联通用语言。我们对于模型和实现的关联轻车熟路,但是对于语言和模型关联往往有待提升。在沟通中刻意使用通用语言可以帮助我们验证模型的合理性。
我们以一个题目为例,方便后续讨论。
活动平台提供用户参与活动得到奖品的功能,吸引用户及潜在用户参与,以达到拉新、促活、引流的目的。
运营人员可以创建和修改活动,活动的配置内容包括活动名称、活动介绍、活动开放的开始时间和结束时间、参与资格、权益。
用户可以看到活动列表,在活动开放的时间段内进入活动页面看到活动介绍。用户在活动页面领取权益,经判断符合资格的用户就会获得一份奖品。权益可能是信用卡积分,也可能是优惠券。
参与资格可能是:一天内注册的用户、VIP用户、当月生日的用户等。客户希望系统可以方便扩展支持灵活的资格类型,以支持多样的活动形式。
对于一个活动,一个用户只能参加一次。
第一步是根据需求分析模型。
我们可以找到以下概念:活动、参与资格、权益。其中参与资格是扩展点。
对于需求「一个用户只能参加一次活动的」,需要记录用户是否参与过活动,所以需要「活动参与记录」的概念。
参与活动的结果可能有2种:符合参与资格则返回权益,不符合则返回「不符合」。所以我们用了Optional<权益>类型。
我们到这里只识别了各种名词,需要走查用例,寻找缺失的概念:
对于用例1,创建和修改活动,目前模型已经满足了需求。
对于用例2,这里有一个模型之外的规则:「一个用户只能参加一次活动」。这是所有活动都需要遵守的规则,我们将其称为「活动通用规则」。
虽然只是一个很简单的逻辑,但是提取「活动通用规则」这个概念非常有用。如果没有这个概念,那么每次去描述这个概念,只能用「一个用户只能参加一次活动的规则」去表示,非常繁琐;也让概念没有安身之地,容易被随便放到万能的Service中。
我们将其加入领域模型。
PS:这里故意省略了参与资格的实现。
我们没有把「活动通用规则」放到活动概念里,一部分原因是这个判断逻辑不需要具体活动的信息。
有了领域模型,就有了通用语言。使用通用语言重新描述需求,并尽量在沟通中使用通用语言。
运营人员可以创建和修改活动,活动的配置内容包括活动名称、活动介绍、活动开放的开始时间和结束时间时间段、参与资格、权益。
用户可以看到活动列表,在活动开放的时间段内进入活动页面看到活动介绍。用户在活动页面领取权益,经判断符合资格参与资格的用户就会获得一份奖品权益。权益可能是信用卡积分,也可能是优惠券。
参与资格可能是:一天内注册的用户、VIP用户、当月生日的用户等。客户希望系统可以方便扩展支持灵活的资格类型,以支持多样的活动形式。
同时有「活动通用规则」:对于一个活动,一个用户只能参加一次。
这里去掉了「开始时间」、「资格」、「奖品」等模糊不清晰的描述。使用基于领域模型的语言,让需求描述清晰没有歧义。
到目前为止,主要的领域模型都已经分析出来。所有的模型都对应明确的领域概念,不多也不少。
在分析了领域模型后,我们再来分析构造块类型。
我们通过是否有状态来做区分。
首先识别有状态的对象:活动、各种参与资格、权益、活动参与记录、用户。一般有状态的对象都是事物,对应的构造块类型也就是实体或者值对象。
其次判断其状态是否会改变:
状态会改变的是实体,包括活动和活动参与记录;状态不变的就是值对象,包括参与资格、权益和用户。
最后剩下的就是无状态的对象:活动通用规则。对应的构造块类型是领域服务。
这里的无状态对象大都可以转化成有状态的对象,例如活动通用规则,可以将方法参数的Optional<活动参与记录>变成成员变量。只是这里我们选择了无状态的设计方法。
由于领域服务没有状态,所以可以在应用启动时就创建出来,也可以在使用时才创建。
经过分析,我们的领域模型都有了类型。
首先识别生命周期长的领域对象:在一个操作中被创建出来,操作结束后仍会被其他操作使用的对象。活动、参与资格、权益和活动参与记录都是生命周期长的对象。
其他有状态的对象都是临时对象:在一个操作中被创建出来,操作结束后就不会再被使用。模型中的用户,在一次操作中从其他服务获取,使用后即被丢弃。
这里我们总结下各构造块类型的特点:
实体 |
值对象 |
领域服务 |
|
是否有状态 |
有且状态可变 |
有且状态不可变 |
无 |
生命周期 |
长 |
长或者短 |
长短均可 |
在生命周期的长的对象中,我们要设计聚合。聚合作为操作单元,主要解决以下几个问题:
根据对业务的了解,活动及参与资格、权益都是一起被创建和修改,可以放在一个聚合里;活动和活动参与记录之间没有一致性规则,可以分开;因为活动参与记录数量会很多,如果和活动在一个聚合中,会降低性能。
所以我们将活动、参与资格、权益设计成一个聚合,而活动参与记录作为一个单独的聚合。而活动和活动参与记录分别作为这两个聚合的聚合根。对应的,聚合都会配备其专属的Repository。
同时加上遍历方向箭头。由于活动是聚合根,从活动可以遍历到聚合内部的参与资格和权益。另外查询活动参与记录,可以通过其Repository,所以没有活动到活动参与记录的箭头。
由于我们将活动和活动参与记录之间划分成不同聚合,那他们之间的关联将使用聚合的ID来关联,而不是聚合本身。
PS:如果使用了关联对象,遍历方向也可以是从活动到活动参与记录。
领域模型已经建立完毕,我们来看如何使用领域模型以满足用例。
运营人员创建活动基本信息及其关联的参与资格和权益。领域模型的客户(一般来说是应用服务),使用运营人员输入的参数构造出活动对象,再利用Repository将其保存。
运营人员修改活动。应用服务利用Repository获取需要修改的活动,再根据运营人员提供的参数修改活动,最后利用Repository保存活动对象。
用户参与活动。应用服务:
考虑到并发情况,应用服务可以在第1步前加锁,并在第3步后释放锁。
(1) 配置和参与活动可否是两个模型?
在实现运营人员配置活动的用例过程中,我们会发现可能找到了一个隐藏的领域概念,将输入的参数转换成领域模型的逻辑有些枯燥和复杂,同样将领域模型和数据库的数据模型之间转换也如此。输入参数和数据模型都是只是扁平的数据数据,没有继承结构。如果使用另外一种面向数据的模型,也许这些用例实现起来会简单得多。
每个模型都是为了解决某个问题。这里运营人员配置和用户参与活动是不同的问题,如果用一个模型来解决这两个问题,可能会有些吃力。那么干脆设计成两个模型,使用限界上下文的概念将这两个模型限定在各自的上下文中,也许更加合理。两个模型可以共享同一份数据库数据,并加上一段(非领域层的)逻辑用于模型之间的转换。
这实际上是一种配置-使用模式。在配置阶段,注重配置类型和参数、审批等;在使用阶段,注重逻辑计算和性能。
(2) 活动参与记录是否可以建模成领域事件?
活动参与记录实际上是不可变的,可以将其设计为领域事件。
(3) 用户参与活动的用例里,逻辑复杂,有泄漏领域概念的嫌疑?
如果发现应用服务里逻辑变得复杂,可能意味着我们找到了一个隐藏的领域概念。我们可以定义一个「用户参与活动逻辑」的概念:如果用户通过了活动通用规则的判断,则可以参与活动。将其加入模型和通用语言中,在沟通中验证此概念是否合理。
很多项目虽然也使用了以领域模型为中心的架构,但是设计者仍然是数据模型/贫血领域模型的思考方式,把大量领域逻辑放置在了万能的Service中,让领域概念隐藏在了冗长的过程代码中,无法享受到DDD带来的收益。
最后总结下本文想要强调的要点: