再论领域模型的困境距离上次发帖讨论领域模型,已经有半年了。这么久没有炒,估计饭又冷了。我再来炒炒领域模
再论领域模型的困境
距离上次发帖讨论领域模型,已经有半年了。这么久没有炒,估计饭又冷了。我再来炒炒领域模型这锅冷饭吧。且不着急点回退按钮,最近领域驱动设计社区在Greg Young同学的带领下有不少新的发展。保证这一次不会是重复贫血充血的老调调。
上回我们说到领域模型实践中的两个困境。一个是框架带来的Entity无法注入的问题。另外一个是Java无Mixin带来的类膨胀的问题。没有看过上文的同学请先回去复习一下:http://www.iteye.com/topic/281289。今天我们就不谈其他,就这两点来谈一谈吧。
类膨胀
这是一个只有你真正把逻辑都放到领域模型了才会遇到的高级问题。当我们把逻辑不断地从Service层抽到Domain层的时候,一些核心的Entity类往往会变得巨大无比。直觉告诉我们,这肯定违反了Single Responsibility Principle(所谓SRP)。那么我们怎么才能解决这个问题呢?
Mixin
当时我发帖的时候,觉得解决这个问题的方向是Mixin。Ruby有Module,C#有Extension Method,Java缺乏语言原生的支持,所以有qi4j这样的项目(我同意,qi4j的实现确实是有点那个。。。)。但是经过一段时间来的学习和思考,觉得Mixin只是一种头痛医头脚痛医脚的办法,根本没有从根本上解决问题。
使用Mixin只是把行为的定义分开了,分散到了几个源文件去定义了。但是逻辑上行为仍然是在那个Entity上的。而且运行时,行为也是在那个Entity上的。从代码阅读的角度来说,确实一次看到的源代码行数是变少了,但是从整体理解的角度来说,读懂一个Entity的复杂度并没有降低。从某种角度来说,Mixin就像是从前的宏(Macro),都是神奇般地给你的代码加点料。
职责委托
发生膨胀的类往往是一些Aggregate Root,把聚合了很多子Entity。比如说ShoppingBasket聚合了Package,而Package聚合了Item。很多时候,我们可以把职责委托给这些子Entity。比如Item可以计算自己的价格,然后Package再把Item的价格加总,然后Basket再把Package的价格加总。通过把职责委托出去,Aggregate Root更多的是一个Mediator,协调各方面来完成任务,而不是事事都必须亲历亲为。
要把职责委托出去就必须让这些职责有一些接收方。如果之前productPackages只是一个List,这个时候就可以创建一个自定义的ProductPackages类来持有相关的逻辑。如果之前几个field联系紧密(比如一个叫fromDate,一个叫toDate),就可以把这些联系紧密的field打包成一个类把相关的职责委托给它。
当我们把职责委托出去之后,Aggregate Root在某些场合只是Middle Man。比如
当时我的理解是一个类在不同的Context下有不同的职责(Role),所以需要实现不同的Interface代表这些Role。于是乎类就是封装一组数据在不同的Context下的行为。又由于系统往往有很多的context,而类所封装的数据又要被这些context给共享(比如User),所以一个类就无可避免地要变得非常的膨胀。
我犯了两个错误。首先Interface代表的Role不是Bounded Context这个级别的。让一个User去实现ForumUser接口,NewsUser接口,SnsUser接口从而被不同模块共享是不现实的,也没有人去这么做。其次,在边界划分良好的情况下,一个系统内应该不会有太多的context,如果一个系统做了很多不同的事情,那是在系统规划设计上就出了问题,而这样的问题比面向对象设计一个类的问题要大得多。
所以,从根本上避免类膨胀,就必须首先避免系统承担的职责的膨胀。理想情况下一个团队负责一个模块/系统,只处理一个Bounded Context。然后跨Bounded Context的集成不是靠一个对象封装一组数据实现不同系统的接口来实现(那简直是开玩笑),而是靠Context Mapping来实现。具体的Mapping的措施,在下文中讨论。
Entity依赖Service之前我也讨论过,很多朋友也讨论过如何用各种各样tricky的技术实现对Entity的依赖注入。但是,Entity为什么会有这些依赖?没有这些依赖存在的话,Entity就无法完成自己的职责,我们就必须把逻辑写到所谓的Application Service之中吗?
总结起来,Service依赖有三种情况:
没有,就很慢理论上来说,Domain Model就是一个大的对象图。对象之间可以通过之间的关系彼此获得。通过Navigate对象图,我们可以从一个节点到达了任意地方。但是由于效率的原因,很多对象之间的关联必须人为打断。比如说你是一个User,用户可以发信。如果User有一个sentEmails的属性,我们去访问这个属性的成员的时候就可能触发成千上万条SQL。所以从实践中,像User这样的长生命周期对象是不会有链接到Email这样的短生命周期的对象的。
一旦Domain Model不再是一个完整联通的对象图,我们的Entity就无法通过Navigation拿到和自己协同工作的对象了。所以,往往Entity需要一些DAO或者Repository来拿到自己的关联对象。这样的优化我们称之为Replace field with query。解决办法在以前贫血不贫血的讨论中已经有反复提及了:http://www.iteye.com/topic/191261。唯一欠缺的是具有Production Quality的实现方案而已。折衷的措施是把Repository当参数传递进去,或者使用Query Object模式。或者干脆就放到Application Service中做好了。
没有,数据就拿不到,服务拿不到这种情况是一些业务操作需要另外一个系统提供的数据,比如说是一个提供pricing的web service。如果没有这个web service,我们就只能把计算总价的职责从domain model中拿出来,因为它没有办法很容易的拿到一个web service的引用。
再比如说,验证一个ShoppingBasket是不是合法,可能需要规则引擎中定义的一些规则(规则可能是业务专家用Excel定义的)。这样basket就不能validate自己了,这样我们也不能让basket告诉我们是不是可以checkout了。
没有,数据就发布不出去另外一种情况是一些业务操作要把一些数据发出去。比如说publication.distribute需要用ftp把元数据和附件传给一些第三方系统。
又比如,你给一个meeting添加一个note需要给meeting的参与者发一些alert,告诉他们有人更新了meeting的note了。如果这种alert不是系统的内,比如是email或者是MSN的消息,那么就需要在domain model里做一些向外发布数据操作。
Bounded Context Mapping第一种情况是对象图存取的问题,属于另外一个范畴的问题。不过第一种情况是大部分人想要给Entity注入Service的动因。但是这种情况下,注入不是一个好主意。理想的情况应该是Infrastructure(Hibernate这一层的东西)能够提供更好的Replace field with query的支持。
第二三种情况是因为Bounded Context A对Bounded Context B需要做Context Mapping。Mapping可以是从A到B的(发),也可以是从B到A的(取)。根据Mapping发生的时机又分为预先取,实时取(同步),实时发(同步),实时发(异步),事后发。
预先取这种情况适用于另外一个Bounded Context的数据的实时性不强,而且尺寸不大。可以预先获取并缓存。
实时取(同步)这种情况是需要Domain Service的唯一情况。Eric Evan的书中并没有详细说什么情况下需要Domain Service。很多同学都把Domain Service和Application Service搞混了。Domain Service存在,必须是Bounded Context A对于Bounded Context B有实时的同步的获取服务的要求。Shipping那个例子里的ScheuleService,Online Shopping的PricingService,或者依赖于某规则引擎都适用于这种情况。
实时发(同步)一般来说都不需要是同步的,因为只是发。推荐把同步发改为异步发。不然也需要提供一个Domain Service来做同步的发。
实时发(异步)这就是Greg Young同学非常津津乐道的Distributed DDD的基本原理了。如果Bounded Context A需要给Bounded Context B发消息,可以在Bounded Context A中建立一个List代表Bounded Context B的InBox。我们只需要把以往的DTO改名为Message然后往队列里一扔就代表我们给B的InBox发了一封信了。然后由Infrastructure取监听那个List取做真正的跨进程通信,可能是调用某个web service,也可能是往message queue发消息。
事后发如果实时性不强的话。上面提到的那个List都不需要是实时监听的。只需要在业务操作完成之后检查一下List是不是非空。如果有东西,就发出去。
结论上篇帖子提出的两个阻碍领域模型应用的因素按照分析可以列为:
类膨胀 框架没有提供Replace field with query的能力 Entity引用Domain Service Entity做Messaging
对于类膨胀,我们一方面要把职责委托出去,另外一方面是关注应用程序本身(而不仅仅是类)的职责是不是太多。
依旧期待框架提供更好的Replace field with query的能力。
Entity引用Domain Service的情况不多。如果有,可以考虑用参数传进去。注入也可以考虑,如果不麻烦的话。
Entity做Messaging一般人都用不着。如果需要,实现起来也不难。 1 楼 yimlin 2009-06-03 搬个板凳先 2 楼 firebody 2009-06-03 观点都是正确。
但是我觉得这么多正确的观点,反而忽略了一个最基本的观点:简单,美妙的代码需要简单、美妙的设计作为底层支撑。
设计体现在 领域模型的设计,整体架构的设计这些基本方面。
很赞同某位软件大师说过的话,具体什么话忘了,大概意思是这么说: 怎么定义这个代码是简单、美妙的呢? 你只需要看它是否自始至终都保持一个核心设计理念。 如果他能做到这点,那么他就是简单美妙的。
所以,很有意思的是,如果你发现你自己写的代码膨胀了, 立即重构,重构有两个层次: 代码级别的重构,设计级别的重构。
前者大家经常做,后者大家也别忘了要经常做,后者的原则就是一点: 保持简单美妙的核心设计理念,贯穿在你所有代码里面。
做到这点了,也不需要像楼主这么费心费力了,呵呵,开玩笑。
3 楼 raymond2006k 2009-06-17 楼上说的没错, 领域建模仍要保持简单的原则。
我们实践中,可能早上听了一堂《领域建模》的培训,觉得无比优雅,“就应该这样”;可是下午因为项目赶进度,就随意添加属性和方法,而违背了领域建模的原则。 更深的原因确实是 framework 没能提供一个符合domain思想的建模规范和约束,例如 Hibernate 侧重ORM,它的 Domain Modeling 还是以 Data Model 为中心的领域建模,而没有上升到行为和事件(虽然它也支持Event,但是是数据级的)。
当然,类膨胀是要势待解决的问题。遵守domain思想下,设计思路要有所突破,怎样优雅的委托出去,怎样做 context mapping等。
4 楼 testoktest2 2009-08-06 为什么要:让一个User去实现ForumUser接口,NewsUser接口,SnsUser?
不能 ForumUser类/NewsUser/SnsUser 都继承 User类吗
f/n/s 有自己不同的方法
listMessage()
listNews()
changeNews()
deleteNews()
怎么管理用户
有adminUser类,有方法
deleteUser/changeUserPassword/createUser
被管理的user就是 User类,有啥Password属性
那有没有listNews()方法,肯定没有马,被管理的user 当前根本不是NewsUser
如果要看被管理的user,一共发了多少新闻,有个userRole属性,
还有user.userRole.NewsUser
之后就是
user.userRole.NewsUser.NewsCount()
user.userRole.NewsUser.listNews()
这样难道不行?