DDD 实施过程中的点滴思考
前一段时间,陆陆续续给几个客户开展了领域驱动设计工作坊。在这个过程中,遇到了客户提出的各种各样的问题,出现频率比较高的是以下几个。稍微整理了一下,也加入了自己的一些思考。
如何区分实体和值对象
这可以说是实施DDD时肯定会遇到的老大难问题。基本上所有客户都会有这个问题,实体和值对象如何区分?有没有区分原则?坦率说,绝对的区分原则是没有的,相对普适的我总结了2条:
1. 实体具有生命周期并且具有明确的业务含义的状态变更
2. 值对象没有生命周期,只关心值本身,不关心值如何变化
其中,原则1是最关键的,也是比较难理解的。难就难在很多人搞不清楚怎么才算是具有生命周期并且具有状态变更。很多人会误把对数据库的增删改操作当作对象的生命周期与状态变更。这两者其实是完全不相关的两个东西。当我们在识别实体和值对象的时候,我们还在领域建模阶段,这个时候是不需要关注具体实现的,甚至我都不知道在持久层是否需要使用数据库技术。所以不能简单地以是否有数据库记录的新增与删除操作作为区分实体和值对象的原则,增删不是业务状态。很多人建议以是否具有唯一的ID来区分二者,认为具有唯一ID就是实体。我个人认为也不是特别好,因为很多人并不能轻易区分这里所说的唯一ID与数据库里面的主键。
那怎么才算有生命周期并且存在状态变更呢? 最关键的是要紧紧抓住业务含义这个点。 最典型的实体的例子是银行账户。每个银行账户都有业务意义上的状态,例如active、inactive、frozen、closed等,并且可以在这些状态之间变更切换;同时,在一个交易过程当中,账户的实例是一直存活着的,在交易中途并不能销毁掉然后以另外一个账户实例取而代之,即使是一模一样的实例也不行。以业内的术语来说,这个账户是stateful的。
既然实体是stateful的,值对象自然而然就是stateless。最简单的一个例子就是账户里面的余额。余额当然会变化,但是我们并不关心其变化过程,我们只关注当它变化后的值,在一笔交易的过程中,余额可以随着业务逻辑变化,也可以随时读取,只要保证变化后的值是正确就ok。
也就是说,实体和值对象,二者都是对象,不同的是,对于实体来说,我们关注的是对象本身;对于值对象来说,我们关注的是对象所承载的数据。
领域服务是什么?
又是一个比较难理解的名词。在复杂的业务中,有时候会遇到一些概念,感觉既不是实体,又不是值对象,它似乎承载了一些行为,但好像找不到承载所需的对象。听起来比较虚,不过领域服务有一个很突出的特点,就是事件里面的名词往往也可以作为一个动词,然后比较难找到一个合适的动词来充当事件里面的动作。最典型的例子就是转账。转账就是一个典型的领域服务。转账既可以是名词,也可以是动词。当遇到这种情况时,就需要打个心眼,这个概念会不会是个领域服务。不过我们在使用领域服务的时候,需要特别小心,我一般在万不得已的情况下才把一个对象识别为领域服务。因为领域服务过多,容易导致系统模型贫血。
如何识别聚合?
首先,第一步是要识别出聚合根。因为聚合根本质上也是一个实体,所以一般来说我是在识别出实体后再识别聚合根。那怎么识别聚合根呢?我的做法是基于实体生命周期的长短来识别。在做事件风暴的时候,我们是可以分析对象在一个业务场景下的生命周期长短的。生命周期相对最长的实体就可以考虑作为这个场景下的聚合根,生命周期被覆盖的实体作为这个聚合根的实体,从而形成一个聚合。举一个曾经实战的例子。重大故障作战室与重大故障作战视频会议被识别成为重大故障抢修场景下的两个实体,二者都具有生命周期。但是,重大故障作战室的生命周期比重大故障作战视频会议长。先有重大故障作战室,后面才会启动重大故障作战视频会议。并且视频会议会先于作战室结束。重大故障作战室必须得在重大故障完全修复之后才能结束。所以,在这个场景下,重大故障作战室就是聚合根,重大故障作战视频会议就是实体,二者形成一个聚合。
然而,在真实的业务场景中,有时候会找不到一个实体的生命周期贯穿始终,或者是生命周期间无法完全重合,导致不能比较生命周期长短的情况。这个时候,可以考虑拆分两个聚合根,形成两个聚合。
场景只有CRUD怎么办?
坦白说,如果只有CRUD,是不需要DDD建模的。但是一个复杂系统里面难免会存在某些小的业务场景只有CRUD。这个时候,在这个场景下往往只有值对象,没有实体,更不用提聚合。这个时候有两种处理方法,第一种是把值对象上升为实体,相当于无状态实体,然后形成聚合。这往往是安全的,但反过来,把实体识别为值对象则会破坏模型。第二种处理方法是把值对象融入别的业务相关性强的聚合。也有可能意味着业务场景的拆分不清晰,或者过细,导致做事件风暴的时候场景粒度太小。这个时候就需要从新review一下场景拆分的合理性,把过小的场景合并,然后再做事件风暴。
子域与限界上下文的关系是什么?
这也是DDD的老大难问题了。基本上每个客户都会问。子域是问题域,限届上下文是解决方案域,子域的问题通过限界上下文解决,就是这个问题的答案。但是这样说了,客户就更迷糊了。坦率说,对这个问题的答案,以及基于这个答案的实施过程,我个人都不觉得满意。这个问题难以解释清楚的根源在于,问题域与解决方案域有时候难以区分。不同的人从不同的视角出发,看到的问题与解决方案往往不一样。
还是以重大故障的例子来说,从IT的视角出发,重大故障作战室就是IT要解决的问题,但是从业务的视角出发,重大故障作战室就是业务赖以快速修复重大故障的解决方案。这个问题就会处于说不清道不明的尴尬境地。所以很多DDD的引导者,包括我自己在内,在实施的过程中,都活多或少的刻意回避这个问题,在得到了解决方案后再反推其想要解决的问题。但是坦白讲, 我个人对这种做法是持质疑态度的。我也曾经尝试使用名词动词等方法在事件风暴前从业务的角度梳理子域,但由于试验的次数不多,效果还是需要进一步验证。
如何划分限界上下文?
划分限界上下文,很重要的一个点是要识别并去除二义性。那什么是二义性呢?举个直观的例子,女儿这个名词,在不同的家庭所代表的人是不一样的。例如在我家,女儿代表的是我的2岁半的女儿;但是在我岳母家,女儿代表的就是我妻子。我家和我岳母家,就是两个典型的限界上下文,当说“女儿”的时候,必须明确告知是在哪个家庭的上下文。这就是二义性。而在实施DDD时,我一般会在识别出聚合后,进行聚合细化,说白了就是查漏补缺,把事件风暴中遗漏的对象补全,然后鉴别一下是否存在二义性,如果没有的话,基本上就可以基于聚合之间的关系划分限界上下文。
能不能基于聚合划分微服务?
当然可以。这样划分出来的微服务粒度更小,职责更清晰。不好的地方是这种划分方法可能会导致最后的微服务数量比较多,需要考虑微服务治理的成本。
如何识别API?
API有两部分:处理第三方交互的API与处理服务间调用的API。第三方交互的API可以通过事件风暴中角色为人或者是第三方系统触发的命令转化而来,服务间调用的API可以通过事件风暴中系统内触发的命令转化而来。但因为并不是所有的系统内触发的命令都是服务间调用,所有这一步需要结合聚合来识别。
如何进行类方法的设计?
坦白来说,DDD不会设计到类内行为的设计。但是,事件风暴中系统内触发的命令,可以转化为部分类内行为,也就是方法,但不全,需要结合TDD等微观方法来驱动代码级的设计。本人觉得DDD + TDD的方法论在衔接上是比较顺畅的,也是值得一试的,宏观的架构设计与微观的代码设计都覆盖到了。有兴趣的同学可以尝试一下。
基本上就是这些了。之后在DDD的实施过程中,如果遇到一些新的问题,会再写文章分享。
评论