从零开始使用CodeArt实践最佳领域驱动开发(三)
5.领域模型设计
在开始考虑如何构建账户子系统的领域模型之前,我们先来看看关于CA里领域模型的基本概念。初次接触这些陌生的概念确实会一知半解,不过没有关系,大家实践几次领域设计后就会融会贯通,深刻体会到这些概念背后隐藏的优点。
概念1:领域对象。领域模型里的一切对象都应该是领域对象。所谓的领域对象就是指遵守领域规则的对象,这是它与普通对象最大的区别。领域规则主要由以下几点组成:
1) 领域对象永远都不会为null。在详细说明这一点之前,大家一定要知道这个规则跟数据库里的字段不能为null没有任何关系。到目前为止,我们都没有提到如何使用数据库技术,这个教程之后的内容也不会讲述与数据库有关的话题,因为这些都与领域模型的建立没有一丁点的关系,没有数据库一样可以成功建立领域模型。各位一定要摒弃以前根深蒂固的开发思路,放空自己的思想,重新接收领域模型里的概念。
言归正传,在领域的世界里,一切对象都是有其存在的意义的。什么叫存在的意义?这体现在职责上,一个对象可以履行某项职责那么这个对象就是有意义的存在。在领域世界里绝对不会出现不具备任何职责的对象。大多数情况下,对象需要提供方法以便其履行职责(.Net里的属性也是方法,只是被包装了而已)。
每年企业都会向政府交纳一定的税收,这是企业的职责之一,所以领域世界里的企业模型会有一个计算纳税金额的方法,该方法会根据企业的盈利计算出交税的金额。如果我问你,你创办的公司今年要交多少税?你可以通过该方法计算结果,告诉我需要交纳10万元。那么,你没有公司呢?你根本就没有自己创办的公司,那你要如何回答我这个问题?回答“很抱歉,我没有公司”吗?错,程序的世界是理性的世界,一切都是严谨的,计算纳税金额的方法定义返回值是浮点数,那么这个方法在任何情况下都应该返回一个浮点数(抛出异常例外)。当我问你的公司要交纳多少税,你只能告诉我具体的数值,我不管你是否有自己的公司,那是你自己要考虑的事情。所以,你应该回答“0”。
分析到这里,大家发现一个问题没有,一个没有创办公司的人却也可以知道“自己创办的公司”今年要交纳多少税收,只不过由于他实际上没有公司,所以计算的结果为0。因此,判定事物能否履行某项职责和这个事物的实例是否存在没有必然的联系。我们只要设计了名称为Corp的企业领域模型,在Corp类里定义了方法CalculateTax(计算税收),那么在任何情况下,Corp的实例都应该可以成功的调用该方法,至于计算的结果由Corp内部去处理,外界不用考虑这些细节。所以,即使不存在编号为135的Corp对象,我们依然可以根据编号135找到一个为Empty的Corp对象,调用该对象的CalculateTax方法会计算并得到税收值为0的结果。
所以,在领域的世界里,我们规定对象可以为Empty但是永远不能为null,因为null表示不存在,不存在的对象无法履行任何职责。我们衡量对象是否存在的唯一依据就是职责,一个没有职责的对象根本就不会被设计出来,所以领域世界里没有不存在的对象,而Empty则表示对象是存在的,只不过数据都是空的,空对象依然可以履行职责,只是履行职责的结果会和非空对象执行的结果有所不同。大家千万不要小看“领域对象永远都不会为null”这项特性,它可以解决很多问题。比如说:传统开发中删除一条数据必须级联删除多条数据的情况在CA的开发模式里几乎不存在、对象之间的硬关联也可以由这项特性解除。在以后的示例中我们会帮助各位进一步理解这个概念。下图是体现这一规则的INotNullObject接口定义:
2) 每个领域对象都具有验证固定规则的能力。固定规则与业务规则不同,这组规则不会随着使用对象的场景的变化而变化,它是对象自身固有的规则。例如人的年龄不可能有上万岁,汽车的轮胎个数也不会有上百个,这些都是领域模型里的固定规则,不会随着人或汽车这一事物在不同的使用场景里而发生规则的改变。CA通过实现ISupportFixedRules接口来完成领域对象验证固定规则的能力:
3) 我们可以明确的知道领域对象的仓储状态,领域对象的仓储状态与它在仓储中保存的数据映射有关。当新建一个领域对象时,该对象的仓储状态就是“新建”的;使用完领域对象,将其存入仓储后,该对象的状态就是“干净”的;我们从仓储中获取一个对象,并更改了对象的属性值,但还未提交给仓储再次保存的时候,该对象的状态就是“脏”的。对象的仓储状态与对象的持久化操作息息相关,所以CA明确规定每个领域对象都能提供和更改自身的仓储状态,该领域规则由IStateObject诠释:
除了以上3项规则外,我们在设计领域对象时还需要遵循一些其他的领域规则,这些规则会以约定的形式给出而不是显示的接口,这在后面的实践中会详细讨论。所有的领域对象都实现了IDomainObject接口:
概念2:实体对象。这类对象具有可区别性,可以与其他事物区分开来。不同颜色、款式的衣服肯定是不同的实体,但是同样颜色、同样款式的衣服就一定是同一个实体吗?在现实世界,人们可以感性的判定事物的唯一性,比如我买的衣服和你穿的衣服就算一模一样,但是它们理所当然的不是同一件衣服。然而在程序世界一切都是理性的,所有判断都是要有根据的。
领域实体对象的职责之一就是帮助程序辨别不同的对象实例,区分事物的唯一性。每个实体对象都需要提供唯一的标识符来标识自己的存在。我们能够通过该标识符找到唯一一个对应的实体,这是实体对象的重要特征。通过标识符可以引用到一个对象,这也是实体对象常被称为引用对象的原因。
我们可以为衣服设置一个叫做编号的唯一标示符。我买的衣服编号为1,你穿的衣服编号为2,就算衣服的其他属性值都相同,但是由于唯一标识符不同,所以这两件衣服在系统看来是不同的实体。当我调用洗衣机的方法去洗我的衣服(编号1),不会对你的衣服(编号为2)造成影响。也就是说,随着时间轴的推移,实体对象的状态会由于各种领域行为的发生而导致了改变,但我们依然可以根据标识符找到目标实体并关注状态变化的情况。以洗衣为例,我的衣服(编号1)变的干净了,因此我很满意。而你的衣服(编号2)依然那么脏,但与我无关。
这正是我们使用实体对象的根本原因:追踪目标对象状态的变化情况,以便其行为更清楚且可预测。也就是说,当我们需要持续关注一个事物的变化情况时,我们应该将该事物的模型设计为实体对象。以下是CA里实体对象必须实现的接口(该接口不必程序员实现,CA提供了实体对象的基类):
概念3:值对象。如果一个对象的所有属性都是用于从某种角度来描述另外一个事物的状态时,这个对象就是值对象。
继续以衣服为例,我们可以把衣服的颜色、图案、尺码这3个属性提取出来,作为一个单独的值对象叫“外貌”,再由衣服去引用这个“外貌”。这样我与你的衣服虽然不是同一件衣服,但是外貌特征可以相同,都是白色、花型图案、XL码的体恤衫。由于衣服是实体对象,我们依然可以清楚的区分衣服的唯一性,但是我们的衣服共用了相同的外貌特征。所以,值对象是没有唯一性判断依据的,它只是多个描述事物状态值的综合载体,如果两个值对象的属性值都相同,那么我们认为这两个值对象是同一个对象,因为他们描述事物的结果是一样的。使用值对象需要遵守两个领域规则:
1) 值对象的所有属性都是只读的,只有当构造它的时候才能传入值,构造完毕后,值对象的属性将无法更改。这意味着当我想把衣服染成红色的时候,我只能新建一个“红色、花型图案、XL码”的外貌特征,并将衣服的外貌属性设置成新建的这个值对象。我不能更改现有“外貌”的“颜色”属性,因为一旦更改了,你衣服的外貌也被更改了,因为我和你的衣服都是引用的同一个外貌特征值对象。因此,在CA里,值对象的设计必须保证属性是只读的。
2) 我们要保证值对象里的所有属性都是从同一个角度去描述事物的。使用值对象的一个很重要的原因就是将复杂事物的属性提取出来,单独作为一个对象管理,这样可以提高程序的可维护性。例如在订单这个事物里涉及到的信息会很多:购买的商品、商品数量、优惠活动、购买人、支付方式、发货方式等。其中“收件人所在的省份城市”、“详细地址”、“邮编”这三项信息都与收货地址有关,我们可以把这类描述收货地址的属性集中起来,设计一个地址(Address)的值对象来管理他们。订单就可以使用该对象来承担与收货地址相关的职责。因此,如果一个值对象的属性是杂乱无章的,不能从同一个角度描述事物的特点,那这个值对象是没有存在价值的。
概念4:内聚模型。一个模块内部各个元素彼此结合的越紧密则它的内聚性越强。也正是由于这些元素结合的非常紧密,他们往往也只负责某一项任务,这也就是所谓的单一职责原则。
我们之前创建了门户服务,因为我们认为菜单、角色、权限等事物是整个项目运行的基础,这几个事物之间或多或少存在某些联系,有一定的内聚性,所以将这几个事物划分到一个服务里共同驱动门户服务工作。
另外,角色、权限、账号三者的关系远比菜单更加紧密,即使没有菜单对象,他们仍然可以共同担负起身份识别的职责。因此我们将他们纳入到账户子系统中,账户子系统可以脱离门户服务独立被其他服务使用。所以,账户子系统是一个比门户服务更高的内聚模块。子系统的内聚性要远高于服务。
那么在子系统内部呢?子系统内部我们依然可以按照类似的思路,根据对象之间的紧密程度划分出更高的内聚模型,这就是我们要熟悉的第4个概念:领域模型里的内聚模型。在详细讨论这个概念之前,我们需要搞清楚如何判断对象之间是紧密的。如果对象A引用了对象B,当A在履行某些职责的时候,需要对象B的支持,我们就说A依赖于B。这在程序实现里常常表现为当调用A的方法MA时,MA内部又调用了B的方法MB来协助MA顺利的执行,这种情况下A和B的关系就比较紧密。那么,有没有比这种AB关系更加紧密的关系呢?
有的。如果对象A的生命周期依赖于对象B的生命周期,也就是说,只有构造了B,A才有可能被构造,而当B被销毁了,A就一定会被销毁。对象A的生命周期始终依赖于对象B的生命周期,那么这种关系就是更加紧密的,这也就是内聚模型里的对象之间的关系。下面我们以书这个事物为例详细讲述内聚模型的领域规则:
1) 每个内聚模型里都会有且仅有一个内聚根,内聚根又被称为聚合根,它是一个实体对象。这意味着你可以通过唯一标识找到对应的聚合根。我们将书(Book)设计成为一个聚合根,那么Book对象就一定是实体对象,值对象是不能成为聚合根的。在CA里所有的聚合根都会实现IAggregateRoot接口,IAggregateRoot同时也实现了IEntityObject实体对象的接口:
查看IAggreateRoot的代码,你们会发现这是一个没有任何方法的空接口。那么这个接口有什么意义呢?要回答这个问题,我们首先要搞清楚接口存在的意义。你和我在某项职责上达成统一,并做出约定。按照这个约定,我知道你会担负起什么样的职责。在我有需要的时候可以找你履行这项职责,而你需要保证顺利的将职责履行完毕。怎么去履行职责我不管,履行职责的过程我也不必关心。因为我是决策者,我只用考虑什么时候用你。而你是实施者,你必须知道如何更好的履行职责,但是你不必考虑履行职责范围以外的事情,至于什么时候轮到你工作,一切听我这个决策者的调遣就可以了。这就是接口的调用和接口的实现的本质特点。如果我和你的约定中,你什么事都不用做呢?什么事都不用做这本身就是一个约定,这种约定就是空接口。那既然你什么都不用做,为什么我们决策上还会有这种约定呢?
在大多数时候,我们会有多项职责上的划分来共同完成一个目标,就好比下象棋一样,棋盘上的棋子担负不同的职责,但是他们共同的目标都是吃了对方的将军。也就是说,我们会有多项接口提供给决策者使用,这些接口互相间的调用方式、决策者使用他们的时机等因素就组合成了一个完整策略。因此,接口的调用方代表决策者,接口的实现方代表实施者。一个接口的定义反映的是整体策略的一个环节,接口不是孤立的,是围绕一个目标共同作出多项约定里的某一个方面的约定。我们使用接口绝非是使用某一个接口,而是一整组策略。在这里IAggreateRoot代表的约定是,策略里有一个称之为聚合根的存在。虽然这个聚合根的接口没有任何方法,但是它依然承担起“我是聚合根”的约定,只要你是聚合根你就有义务承担起聚合根的职责,当决策者需要用到你的时候,他需要知道你是聚合根才能将你“派遣“给仓储接口,让仓储接口去持久化你(CA里只有聚合根才能被加入仓储)。所以,空接口也是有意义的!空接口一样可以提供策略上的判断。
另外,接口是可以良性成长的。你可以想象一下,你的孩子(IAggreateRoot)刚出生,他虽然不能帮你分担任何家务,不能对这个家贡献任何价值,但是他难道就不是你的孩子(它还是IAggreateRoot)了吗?随着家庭生活的变化、孩子的成长(需求的改变或者其他原因导致你要改进设计策略),孩子有能力承担家务了(根据需要为IAggreateRoot增加方法),他就可以对这个家庭贡献更多的价值,让家更温暖(改变接口后,让策略更加完美)。将括号内的比喻连接在一起,表达的意思是:在设计领域模型的前期,我们对整体策略的把握还不足,导致有可能我们创建的接口的方法比较少,甚至一个方法都没有,但是这些接口以及定义的方法都是为了实现某一个目标而产生的。那么在后面的开发中,我们对实现的目标更加清晰了、对需求的理解更加深刻了,我们就会追加或更改接口的方法,只要这些改变都是为了更好的实现目标的、不是破坏性的,那么接口就可以变化,这种变化就是良性变化。所以当我们有需要也可以为IAggreateRoot追加新的方法约定,让它更加强大。但是整体策略却不必为此做出重大的改变,因为IAggreateRoot已经存在于现有的策略中,为IAggreateRoot追加方法只会更加完善现有的策略。(事实上在CA的最新版本中为IAggreateRoot已追加了好几个方法以满足框架整体战略升级的需要)
Book的示意代码为:
AggregateRoot<TObject, TIdentity>是聚合根的基类。Book对象继承了该类就代表Book是一个聚合根。关于聚合根的更多话题在后续教程里会结合实践例子详细说明。
2)除了聚合根外,我们还可以在内聚模型里设计其他的对象,这些对象被称为内聚成员。内聚成员可以是实体对象也可以是值对象。我们将书的封面(BookCover)设计为一个内聚值对象成员:
ValueObject是值对象的基类。BookCover继承了该类就表示BookCover是一个值对象。那么我们如何体现出BookCover是Book的内聚成员呢?有两种方式,我们先讲解第一种方式,这种方式可以满足绝大多数应用,而第二种方式是CA里的高级话题,在后续教程中再结合实例说明。我们可以为Book设计一个属性Cover(封面):
大家不用在意代码截图里关于领域属性定义的代码,稍后就会有详细说明。我们现在只用知道Book里有一个类型为BookCover的封面属性,这就意味着BookCover是以Book为聚合根的内聚模型的成员。
3) 我们可以通过聚合根的引用找到所有的内聚成员。或者说,如果你想找到某个内聚成员就必须先加载聚合根,再通过聚合根去查找内聚成员。通过类似book.Cover的代码就可以找到某本书的封面了。但是你无法凭空获取任何一个BookCover实例的信息,必须先找到它所属的聚合根Book再通过Book的属性Cover去访问对应的信息。因此,值对象和实体对象都是无法脱离聚合根而单独存在的,它们必须依附于聚合根组成内聚模型。
也就是说,领域模型层里唯一独立的元素就是“内聚模型”。所有的领域对象都会处于某个内聚模型内,脱离于内聚模型而单独存在的领域对象是不存在的。Book和BookCover就属于“以书为聚合根的内聚模型”。它们共同构建了现实事物里的“书”在领域世界里的模型。
4) 内聚模型A不能直接引用另外一个内聚模型B里的成员,但是A可以引用B的聚合根。只允许外部对象保持对根的引用,对内部成员的临时引用可以被传递出去,但仅在一次操作中有效。假设我们有一项需求,需要知道人们喜欢看的书籍。为了描述人这个事物,我们设计了关于人的内聚模型,该模型的聚合根为Person(人)。那么在该内聚模型内,Person或者其他成员都不能直接引用BookCover,因为BookCover是书的内聚模型的成员。但是Person及其成员可以引用Book,然后通过Book访问Cover,这在代码上体现为person.MostLikeBook.Cover。表示找到person最爱的书籍(MostLikeBook)的封面(Cover)。这项规则的目的是用聚合根来控制其成员的访问细节,由于根控制访问,因此不能绕过它来修改内部对象。聚合根之所以被称为“根”就是因它是内聚模型里一切的根基,它对成员有最高的控制权。
5) 只有聚合根才能被加入到仓储,内聚模型里的其他成员只能在聚合根被加入到仓储的时候随着根被一起保存。我们可以编写代码直接保存聚合根,但是无法编写代码直接保存内聚成员。内聚成员的持久化机制是伴随着根的持久化而被保存的。示例代码如下:
在示例代码中,我们创建了BookCover的示例cover,然后创建了Book的实例book并将book的封面属性值设置为cover,最后创建Book的仓储接口的实现repository,并调用repository.Add方法保存book对象。我们直接仓储化的是Book类,而BookCover的保存会在仓储内部实现,领域模型层并不主动保存内聚成员BookCover。仓储的概念会在后文里单独讲解,这里暂时不过多说明。
虽然我们对以上规则进行了详细的说明,但是大家肯定不能完全理解,甚至会觉得莫名其妙,疑惑遵守这些规则能带来什么好处呢?有这种想法很正常,毕竟各位没有真正实践过领域驱动的开发。在稍后的内容里,我们会结合实际例子告诉各位每项规则落实的细节,到时候你们就能深刻体会到它们带来的好处了。目前你仅需知道有这些规则存在即可,不必太过刨根问底。
在了解了内聚模型的设计原则后,我们不禁要问:为什么会有内聚模型?为什么我们不直接使用领域对象呢?
原因有两个,一方面,我们将关系紧密、职责统一的对象以领域规则的形式约束在一起,可以极大的提高系统的维护性。当程序需要修正的时候只需找到对应的内聚模型,调整其内部的实现细节即可,其他内聚模型受到的影响很少,连锁改动降到了最低点,避免了混乱。另外一方面,我们所有的设计原理都是需要落实到代码上的,需要有技术实施的基础。设计思想可以脱离技术独立存在,但是这些思想实施起来则需要充分考虑技术上的实现方式。举一个常见的例子,在传统开发中,我们为了避免并发冲突会对表相关的数据进行锁定。在领域开发中,我们同样面临类似的问题,只不过锁定的是领域对象而不是直接锁定数据(事实上领域模型层里没有“数据”这个概念)。那么如果没有内聚模型,所有的对象都可以直接从仓储中以带锁的形式加载,这就很容易造成死锁。但是引入了内聚模型的概念后,我们只能加载聚合根,也只能锁根。当根被锁定了,内聚成员的任何操作都是线程安全的,这样不仅避免了死锁同时也满足了开发程序的需要。因此,内聚模型可以提高程序的稳定性、健壮性。
我们总结下领域对象、实体对象、值对象、聚合根、聚合模型五者的关系。
1)实体对象、值对象、聚合根都属于领域对象。在CA里,领域对象的基类是DomainObject,实体对象(EntityObject)、值对象(ValueObject)都继承自DomainObject。
2)聚合根是领域对象,同时也是实体对象。聚合根(AggreateRoot)继承自EntityObject。
3)一个聚合模型由至少1个聚合根和0个或者0个以上的实体对象或值对象组合而成。
概念5:仓储。仓储是用于持久化领域对象的。领域对象要履行自己的职责必定需要有数据的支持,但是领域对象自身并不负责这项工作。对象的加载、修改、删除等操作交由仓储完成。你可以形象的认为仓储就像一个仓库那样存放各类对象。当你需要某个领域对象的时候,它负责帮你从仓库里取出来。当你创建了新对象也可以交由仓储帮你存入仓库。关于仓储我们需要重点说明以下几点设计原则:
1) 在领域模型层里的仓储一定是以接口的形式定义的,它不是一个对象,而是一组方法的约定。这组约定的实现由基础设施层完成,领域模型层不会涉及到具体的存储算法,也不关心如何存储对象。示例代码:
IBookRepository是聚合根Book的仓储接口,这个接口约定了可以通过作者名称查找相关的书籍对象。IBookRepository接口继承了IRepository<TRoot>接口。该接口是CA提供的仓储基础接口,所有的仓储都要继承该接口。部分代码如下:
从代码里我们可以看出IRepository<TRoot>定义了持久化领域对象的基本操作。因此我们使用IBookRepository可以添加、修改、删除以及锁定Book对象,还能根据作者名称查找Book对象。
2) 仓储接口的约定必须是清晰、明确的。这项设计原则的意思是,你必须明确的告诉仓储,你要根据哪些条件找到领域对象,你给出的条件不能是多样化的。
有些朋友在尝试使用领域驱动开发的时候,将查询条件设计成对象Query ,Query对象可以设置各种查询子条件,例如 query.AddCondition(“name”, Condition.Equals,”作者名称”);query.AddCondition(“sex”, Condition.Equals,”男”);这两句代码定义了查询要根据name和sex来查找对象,当创建完query后将其交给仓储,由仓储负责翻译query的含义,执行对应的查询操作。
在CA里是绝对不允许这样设计的!仓储接口处于领域模型层,它虽然是用于持久化领域对象的,但是仓储也是领域模型的一部分,它从“如何找到事物”的角度反映了事物在这方面的本质特征。我们在示例里为IBookRepository仓储定义的方法之一就是“根据作者名查找书”,该方法反应了”书这一事物可以通过作者这个事物被找到的”本质特征。而事物的本质特征必须是明确的,不能含糊不清。
另外,从技术实现的角度来说,在传统的开发模式里,程序员围绕数据库做开发,对数据的操作都是随意的,是不可预知的。你可以在任意代码段里对任意数据表进行任意的操作。这是混乱的根源,正是由于这个原因大多数项目都被做成豆腐渣了,所以我们一定要避免这一点。仓储接口的约定必须是清晰、明确的。
3) 仓储接口的约定越少越好。我们认为仓储接口定义的方法越少,“如何找到事物”这个维度里的本质特征就越清晰。试想一下,如果找到Book对象的方法有10几个,那么当我们需要找Book对象的时候必定混乱不堪(说浅显点,就是10几个方法给你去选,你都需要花费大量的时间去琢磨该用哪个方法)。大家一定要记住,仓储接口不是存储过程,我们不能为了寻求使用的方便而肆意增加它提供的方法。它提供的方法越少,领域模型对基础设施层的依赖就越小。
4) 仓储的实现里不要有任何业务代码。仓储的职责就是存储对象,它的代码里不应该有任何关于“名称是否重复”、“转账是否成功”等一切与业务有关的逻辑。请记住,在绝大多数情况下仓储的实现只是机械式的“找到数据装载领域对象”或者”得到领域对象后保存到数据仓库中”,虽然也有例外的时候,但是我们尽量避免这种例外(例外的产生一般是出于性能优化的考虑,后续教程会有案例分析这种情况)。
5) 只能创建聚合根的仓储。聚合根是访问内聚模型的入口,所以我们的仓储接口里提供的方法仅能持久化聚合根。只是在仓储的实现里会保存聚合根和相关的成员对象。请大家注意“仓储接口”和“仓储实现”的区别,仓储接口属于领域模型层,仓储实现属于基础设施层。我们可以通过配置随时切换仓储接口的实现,而仓储接口是领域模型层里的定义,使用该接口只能对聚合根进行持久化操作。聚合根的成员都是通过内聚根来访问的而不是通过仓储接口访问。
以上是CA里领域模型层的基本概念,这些概念大家一时半会还消化不了,后面的教程里我会结合实际的例子让大家对它们有更加深刻的认识。其他高级话题(富实体对象、引用关系、移动聚合根、领域事件等)涉及到的内容也会在后续教程里依次说明。在下个章节里,我们可以正式开始编码工作了。
程序员不是任何人的工具,更不是碌碌无为的码农。我希望每一位使用CodeArt的程序员都能成为程序世界里的王者,用创造力去构建自己的领域。即使遭遇重重困难也不气馁、受到他人阻碍时亦有不屈服之心、遇到不公正时能毫不畏惧地纠正,不向官僚献媚。