论坛首页 Java企业应用论坛

再论领域模型的困境

浏览 3978 次
精华帖 (5) :: 良好帖 (0) :: 新手帖 (0) :: 隐藏帖 (0)
作者 正文
   发表时间:2009-06-03   最后修改:2009-06-03
距离上次发帖讨论领域模型,已经有半年了。这么久没有炒,估计饭又冷了。我再来炒炒领域模型这锅冷饭吧。且不着急点回退按钮,最近领域驱动设计社区在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。比如
ProductPackage findByName(String name) {
  return productPackages.findByName(name);
}

如果对这些纯委托的方法感觉不爽,不妨提供getProductPackages方法让外边直接调用findByName好了。

Bounded Context
一般来说,通过有效的职责委托,完全可以避免一个Entity的尺寸过大。但是这必须建立在你所写的系统的职责单一的基础之上。也就是说单个类的SRP必须建立里在系统的SRP之上。如果像这位同学说的那样:
coolnight 写道

我们的系统有很多模块组成, 各模块基本上通过数据库来共享信息。
主要的模块大致有: 核心系统(非web), 网站、 bbs、 网站后台,核心系统后台,BI, 推广员等等
原来的打算是写个rich domain model供各模块来使用,以便代码重用减轻个模块开发的工作量
一个简单的例子, User 原有 changePassword, getFriends, addFriend ... 等等方法撇开配置以及获取User对象的复杂性不谈, 实际开发中发现, 这些东西重用的地方太少了,对网站来说很好的rich domain model, 在网站后台里面就麻烦,很多方法在那里根本就是对后台开发人员的干扰,而很多方法对核心系统、BI等等根本就毫无用处。

那么他所说的User是无论如何做不到SRP的。用Eric Evan的术语就是我们在处理不同的Bounded Context。所以对于之前我画的那个图,现在就有不同理解了:

当时我的理解是一个类在不同的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一般人都用不着。如果需要,实现起来也不难。
   发表时间:2009-06-03   最后修改:2009-06-03
观点都是正确。

但是我觉得这么多正确的观点,反而忽略了一个最基本的观点:简单,美妙的代码需要简单、美妙的设计作为底层支撑。
设计体现在 领域模型的设计,整体架构的设计这些基本方面。


很赞同某位软件大师说过的话,具体什么话忘了,大概意思是这么说: 怎么定义这个代码是简单、美妙的呢? 你只需要看它是否自始至终都保持一个核心设计理念。  如果他能做到这点,那么他就是简单美妙的。

所以,很有意思的是,如果你发现你自己写的代码膨胀了, 立即重构,重构有两个层次: 代码级别的重构,设计级别的重构。

前者大家经常做,后者大家也别忘了要经常做,后者的原则就是一点: 保持简单美妙的核心设计理念,贯穿在你所有代码里面。

做到这点了,也不需要像楼主这么费心费力了,呵呵,开玩笑。



5 请登录后投票
   发表时间:2009-06-17   最后修改:2009-06-17
楼上说的没错, 领域建模仍要保持简单的原则。

我们实践中,可能早上听了一堂《领域建模》的培训,觉得无比优雅,“就应该这样”;可是下午因为项目赶进度,就随意添加属性和方法,而违背了领域建模的原则。 更深的原因确实是 framework 没能提供一个符合domain思想的建模规范和约束,例如 Hibernate 侧重ORM,它的 Domain Modeling 还是以 Data Model 为中心的领域建模,而没有上升到行为和事件(虽然它也支持Event,但是是数据级的)。

当然,类膨胀是要势待解决的问题。遵守domain思想下,设计思路要有所突破,怎样优雅的委托出去,怎样做 context mapping等。



0 请登录后投票
   发表时间: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()

这样难道不行?

0 请登录后投票
论坛首页 Java企业应用版

跳转论坛:
Global site tag (gtag.js) - Google Analytics