论坛首页 Java企业应用论坛

领域模型的价值与困境

浏览 43677 次
该帖已经被评为精华帖
作者 正文
   发表时间:2008-11-27   最后修改:2008-11-28
很久以前大家就关于这个方面有很多讨论了。前两天我又挖了一个坑来集思广益,非常感谢没有把我的帖子投为新手帖的同志。我不是在装傻,只是想让大家跳出自己的立场,从根本的价值出发来考虑问题。之前有很多讨论,都是在讨论我又发明了一种新方法可以让领域模型充血啦,等等之类的。当提出一个解决方案的时候,一定要有明确的问题。那么领域模型的价值是什么?为什么没有被广泛应用,其困境在哪里?

价值

数据,一定是数据。做企业系统,最核心的东西一定是数据。关于数据,人们有许多需求,但是最根本的一点就是,数据要是对的。在关系数据库的上下文下,为了保证数据是对的,我们有外键,我们有COLUMN的数据类型,我们有主键,我们有constraint,我们有很多很多。但是很多时候还不够,一堆数据在业务上是不是合法的,超过了上述的检查方法的能力范畴。这个时候,以DBA为中心的思考就会导致:我作为DBA,管理这些数据,如果数据出了问题,那就是我的责任了。所以我必须要阻止愚蠢的事情,而我显然是最知道什么是正确数据的人,所以你们(程序员)要访问我的数据,就必须通过我的存储过程。

这种方式显然遇到了问题。问题是很多方面的,有人员素质问题,有工具支持问题。更重要的是,虽然存储过程起到了防火墙的作用,阻挡了外界可能的对数据一致性的破坏,但是其内部却是脆弱的。数据对于包裹它的存储过程都是开放的,写存储过程A的人,可能对数据的假设与写存储过程B的人对数据假设是不一致的。两个人必然只有一个是正确的,但是从数据出发找到修改它的地方并不容易,从而给数据的质量埋下了隐患。

存储过程的问题,就是面向过程的代表。面向对象的主要特征,封装就是为了解决这个问题发明的。把数据放置于对象内部,要修改对象所封装的数据,就必须通过对象所提供的外在行为。有如下图所示。



回到数据的正确性这个问题。程序员不同于DBA,给出的解决方案是领域模型。其实领域模型,只是面向对象的另外一个名字而已。通过把数据封装在领域模型的内部,我们就可以限制模型的使用者对数据的修改,什么值是对的,什么样的值是不对的。具体列出来有:

构造函数
可以确保在创建的时候已经有了所有的必填项
public class Person {
  public Person(String firstName, String lastName) {
  ...
  }
..
}


无Set方法
不能任意的改变值,必须通过特定的合法性检验
public class Publication {
  private State currentState;
  public State publish(Channel to) {
  ...
  }
...
}


关联
可以保证外键,以及强制约束两个表之间数据的关系
public class Cargo {
  public void attachItinerary(final Itinerary itinerary) {
    // Decouple the old itinerary from this cargo
    itinerary().setCargo(null);
    // Couple this cargo and the new itinerary
    this.itinerary = itinerary;
    this.itinerary.setCargo(this);
  }
...
}


一致性
冗余字段的同步更新得到强制
public class ShoppingChart {
  private List<OrderItem> items;
  private int sum; //冗余字段
  public void dropIntoChart(OrderItem newItem) {
    sum += newItem.sum();
    items.add(newItem);
  }
  ...
}


当然,面向对象不光是封装一个特性,它还有继承和多态。所以作为面向对象的另外一个名字,它自然也有继承和多态这个好处。具体到程序里就是

枚举值
不要通过对枚举值的判断来决定程序的路径
// 过去
public void publish(ChannelType channelType, Publication publication) {
  if (channelType.equals(ChannelType.RETUERS)) {
    ...
  } else if (channelType.equals(ChannelType.BLOOMBERG)) {
    ...
  } ...
}
//现在
public interface Channel {
  void publish(Publication publication);
  ...
}
public class RetuersChannel implements Channel {
  ...
}
public class BloombergChannel implements Channel {
  ...
}


数据的含义
另外一个好处是,对数据的访问被集中起来了。所以,从数据出发,很容易发现计算出值并修改它的地方。这就方便了我们去理解数据的含义。数据本身是没有任何意义的,数据只有被使用才有意义。只有理解了数据的上下文的含义,才能编写更多的行为去操作数据。在写新的行为的时候,我们必然要参考过去的行为是怎么理解数据的含义的。这个查找的过程越容易,越有助于我们写出正确的逻辑,也越有助于我们发现过去已经写过一样的行为了,那我就不用写了,也就是所谓的复用。

所以,理论上来说,面向对象或者说领域模型是非常适合我们的日常的企业信息系统开发工作的。但是,实践中,却遇到了很多问题。

困境

框架的约束
如Robin所言
robin 写道
如果你用的是Spring,没啥说的,必须贫血,你想充血也充不起来;
如果你用的是RoR,也没啥说的,直接充血,你想贫血也未必贫得下来;

这就是一个基本事实。Spring作者也坦言(Rod Johnson, JAOO, 2006),Spring的编程模型基本上是EJB的延续。从架构和分层的角度,它们是一脉相承的。这种分层的架构决定了,行为在Service里,数据在Entity里。这种做法成为“最佳实现”,不是偶然的,是框架设计给你的必然结果。矛盾的集中体现在于Entity无法被注入(当然你可以注入,但是这是不推荐的做法)。Spring后来尝试修复这个问题,引入了AspectJ来做Entity的注入。不过仍然不是主流,因为框架的阻碍只是一个小问题,更大的问题在于这个Java的OOP实现本身就有问题。

语言的约束
在很多的讨论中,反对“充血”领域模型的同志都提到。把逻辑集中到领域模型中,会造成类的膨胀。在我个人的实践中,近千行的Entity类定义也是有的。而且这些行为往往不稳定,往往流程高度相关,复用程度并不是想象的那么高。
原因是因为,数据并没有所谓的内在行为。你不用它,它就没有行为。所以数据有什么行为,其实是使用者赋予的。而同样的数据往往会在不同的场合(context)下被使用。正如这位同志所言:
coolnight 写道
我们的系统有很多模块组成, 各模块基本上通过数据库来共享信息。
主要的模块大致有: 核心系统(非web), 网站、 bbs、 网站后台,核心系统后台,BI, 推广员等等
原来的打算是写个rich domain model供各模块来使用,以便代码重用减轻个模块开发的工作量
一个简单的例子, User 原有 changePassword, getFriends, addFriend ... 等等方法撇开配置以及获取User对象的复杂性不谈, 实际开发中发现, 这些东西重用的地方太少了,对网站来说很好的rich domain model, 在网站后台里面就麻烦,很多方法在那里根本就是对后台开发人员的干扰,而很多方法对核心系统、BI等等根本就毫无用处。

所以上面所画的图就得改一下了:



同样,很多同志也发现了这个问题,并给出了解决方案
Quake Wang 写道
用mixin实现领域模型是最简洁的做法, 但是受到Java语言的限制,得用大量mark性质的interface和composite模式,反而会让整个结构变得复杂。
相比C#的namespace或ruby自定义dsl(acts_as_xxx),在java玩领域模型需要更多的功力,推荐一下Rickard Öberg的qi4j,它将composite oriented发挥到了极致:
http://www.qi4j.org/

ray_linn 写道
我建议用C#的扩展方法,或者Java用mimix(不知道打错没有),Domain还是POJO,方法在适当的时候“黏合”(C#通过 namespace引用)到POJO上,让POJO “充血”,而在充当VO,DTO的时候,POJO又可以“放血”回到“贫血的”状态上。

这个问题基本上是暴露了Java的OOP实现的缺陷。C#的Partial Class, Extension Method和Ruby的Mixin就是对这种“一个class定义一切”的做法的改进,允许行为被分片定义,而不是集中定义在一个文件中。

现状
很多同学都谈到现状不是完全的没有领域模型,而是所谓的贫血的领域模型。之所以会这样,就是前面所说的困境。正如很多朋友所说的,这未必是一件坏事,比如可以避免核心的domain过度膨胀。不过也未必是一件好事,理由我能想到这么几点:

越俎代庖
这是我们的DAO经常干的事情。比如我们有两个domain,Publication(一份文档)和Distribution(一次分发)。它们两者之间是聚合关系,也就是distribution必然属于一份publication。如果是重写的领域模型,我们可以通过publication来控制对distribution数据的添加。比如
public void distribute() {
  if (isDeleted()) {
     throw new InvalidDistributionException();
  }
  distributions.add(new Distribution(this));
  lastDistributionDate = new Date();
}

但是一旦有了DistributionDao,就不一样了。
distributionDao.save(new Distribution(somePublication));

哈哈,啥控制都管不了我了吧。很多时候Dao就是这么无法无天的。如果说domain model是数据的防火墙,那么dao就是顶级黑客。由于Entity不能注入等实现和宗教信仰上的限制,往往导致了service对DAO的滥用,就会造成上面这样的by-pass domain model的情况。

舍近求远
明明我有一个contact
public class Contact {
  private List<ContactNote> contactNotes;
...
}

但是我不能直接去取我的contactNotes(性能啊性能,hibernate会load all啊)。所以我得写个查询
from ContactNote where contacted = thisContact and ...

我有一个contact就应该能够让我
contact.allMeeetingContactNotes();

但是不行,你得
contactNoteDao.findMeetingContactNoteByContact(thisContact);

舍近求远也

职责混杂
根据我理解的Service层的现状是:有一部分是作为domain model对外的接口,提供了事务安全等服务,真正是一个“service”对web层提供服务;有的呢,则是当作一些可重用的行为被注入到其他的service之中;而有的呢,则纯粹是一些utils。当然,不过可能是我用的不对吧,或者理解有误。

距离啊距离
数据和行为距离很远。造成了不容易理解数据含义,因为你光看entity压根不知道它会被怎么用。漏掉一些servie中细微的用法,就可能会造成很大的bug。
同时这也更加让可重用的逻辑更难被发现,从一定程度上鼓励了大家各自发明,一份逻辑写n遍,还n遍都不一样。。。
当然啦,这个问题没有想象的那么大,毕竟贫血的领域模型,还是鼓励大家把“只和这个对象,不和外部接口有关的”的逻辑放到对象本身的。问题的大小,取决于团队的稳定性,素质和职业操守。

术语
如nihongye同学所说,xxxService不是domain language。当然啦,讨论这种问题最终是仁者见仁智者见智的。

解困

你真的要解困吗?其实你未必被困住了。你可能根本不需要领域模型,特别是在Java/Spring这种实现下,难以实现。正如很多同志所言,我用贫血模型用得很好。那就行,自己好,就是真的好。但是有朝一日,觉得贫血模型不再适合你了,不妨去了解了解qi4j(如果你不想换语言),或者投入rails的怀抱吧。
  • 大小: 7.9 KB
  • 大小: 12.8 KB
   发表时间:2008-11-28  
我试着小结一下:
领域模型的价值:
1、从业务使用的角度保证数据的准确性(因为贴近业务,所以比单纯的数据库约束更完善)
2、行为与数据一致,不再对数据显式的判断(内置)
3、在领域模型里集中对数据的访问,方便理解数据的含义。(不再被分隔到多个service里)



0 请登录后投票
   发表时间:2008-11-28  
理论啊理论,哲学啊哲学,什么时候才能回到实践的道路上来?

简单谈谈你所说的现状:
taowen 写道

越俎代庖
这是我们的DAO经常干的事情。比如我们有两个domain,Publication(一份文档)和Distribution(一次分发)。它们两者之间是聚合关系,也就是distribution必然属于一份publication。如果是重写的领域模型,我们可以通过publication来控制对distribution数据的添加。比如
public void distribute() {
  if (isDeleted()) {
     throw new InvalidDistributionException();
  }
  distributions.add(new Distribution(this));
  lastDistributionDate = new Date();
}

但是一旦有了DistributionDao,就不一样了。
distributionDao.save(new Distribution(somePublication));

哈哈,啥控制都管不了我了吧。很多时候Dao就是这么无法无天的。如果说domain model是数据的防火墙,那么dao就是顶级黑客。由于Entity不能注入等实现和宗教信仰上的限制,往往导致了service对DAO的滥用,就会造成上面这样的by-pass domain model的情况。


请问,isDeleted()方法由谁来实现?是框架嘛?我想无论如何你都无法避免框架对你的帮助吧。

事实上,多数情况下,如果你想通过publication来控制对distribution的数据添加,我们会这样写代码:

// this could be in Publication
public void addDistribution(Distribution distribution) {
    this.distributions.add(distribution);
    distribution.setPublication(this);
}

// this could be in Service
publication.addDistribution(new Distribution(somePublication));
publicationDao.saveOrUpdate(publication); // this could be ignore using hibernate


至于说到你数据层面的业务校验,这些可能会在Service中完成,因为他们需要有更多的外部依赖。这样写你是不是觉得也可以?你认为越俎代庖了嘛?

taowen 写道

舍近求远
明明我有一个contact
public class Contact {
  private List<ContactNote> contactNotes;
...
}

但是我不能直接去取我的contactNotes(性能啊性能,hibernate会load all啊)。所以我得写个查询
from ContactNote where contacted = thisContact and ...

我有一个contact就应该能够让我
contact.allMeeetingContactNotes();

但是不行,你得
contactNoteDao.findMeetingContactNoteByContact(thisContact);

舍近求远也


这点更加是无从谈起了,为什么不写个字段,把hibernate的where用上?简单实用。事实上很多项目我们都是这么使用的。


9 请登录后投票
   发表时间:2008-11-28  
downpour 写道

请问,isDeleted()方法由谁来实现?是框架嘛?我想无论如何你都无法避免框架对你的帮助吧。

publication是软删除的。所以isDeleted只是判断自身的一个属性是不是true而已。

downpour 写道

至于说到你数据层面的业务校验,这些可能会在Service中完成,因为他们需要有更多的外部依赖。这样写你是不是觉得也可以?你认为越俎代庖了嘛?

当然是可以的,只是约束数据的地方不在单单是在Domain Model上,还包括Service。别人要修改数据的时候,通不通过你的service就是职业操守的问题了。service给数据添加的约束不是强制的,而domain model可以添加强制的约束。就是这么一个问题。当然啦,你大可以说这是理论。我也说过了,贫血不贫血不是问题,适不适合自己才是问题。

downpour 写道

这点更加是无从谈起了,为什么不写个字段,把hibernate的where用上?简单实用。事实上很多项目我们都是这么使用的。

你是指formula property吗?
0 请登录后投票
   发表时间:2008-11-28  
引用
舍近求远
明明我有一个contact
Java代码 复制代码

   1. public class Contact { 
   2.   private List<ContactNote> contactNotes; 
   3. ... 
   4. } 

public class Contact {
  private List<ContactNote> contactNotes;
...
}


但是我不能直接去取我的contactNotes(性能啊性能,hibernate会load all啊)。所以我得写个查询
Java代码 复制代码

   1. from ContactNote where contacted = thisContact and ... 

from ContactNote where contacted = thisContact and ...


我有一个contact就应该能够让我
Java代码 复制代码

   1. contact.allMeeetingContactNotes(); 

contact.allMeeetingContactNotes();


但是不行,你得
Java代码 复制代码

   1. contactNoteDao.findMeetingContactNoteByContact(thisContact); 

contactNoteDao.findMeetingContactNoteByContact(thisContact);


舍近求远也


我记得DDD上说如果是聚合的话,访问聚合的任何元素都应该从根开始,所以这个例子中Contact得到ContactNote我觉得并不是舍近求远,而是封装的体现。


另外,关于.net中使用partial的方式来定义类,我确实有考虑过,但实际使用中特别依赖于团队规范,否则很容易让开发人员找不到北。扩展方法也是同理,而且扩展方法我认为是不到万不得已都不应该去用的东西。
0 请登录后投票
   发表时间:2008-11-28  
gamix 写道

我记得DDD上说如果是聚合的话,访问聚合的任何元素都应该从根开始,所以这个例子中Contact得到ContactNote我觉得并不是舍近求远,而是封装的体现。

老兄,你把我的意思看反了……
gamix 写道

另外,关于.net中使用partial的方式来定义类,我确实有考虑过,但实际使用中特别依赖于团队规范,否则很容易让开发人员找不到北。扩展方法也是同理,而且扩展方法我认为是不到万不得已都不应该去用的东西。

这是一个很好的问题。AOP,Extension Method, Mixin都会导致对行为的隐式聚合。也就是在没有一个地方告诉我,这段代码都有啥玩意实现了它,修改了它。qi4j做法是最终有一个Composite接口声明了所有的实现(Mixin)和修改者(Modifier)。我个人也比较倾向于把行为分开来写,最后一个地方再把所有的东西集中在一起。
0 请登录后投票
   发表时间:2008-11-28  
我来说几句吧:

第一、DAO层和TransactionScript层是邪恶的!

我们在2004年一直跨度到2007年讨论来讨论去,其实都有一个隐含的前提条件:你的领域模型终究无法脱离对DAO层的依赖,以及需要TransactionScript层的包裹。而这样一来,领域模型的通用性、可移植性、业务逻辑的表达能力基本全部丧失,沦为框架限制下的奴隶。

而我们看看现在Java领域的技术进步,JPA已经普及,EJB3的隐含事务管理,甚至连Spring也可以简化成@Transactional,现在已经是我们可以去掉DAO和TransactionScript的时代了。


第二、Seam在消除了DAO层和TransactionScript以后,领域模型的感觉已经呼之欲出


这个不用多说,大家自己去看Seam文档好了。我唯一想强调的是entity bean和business bean分开有没有必要性,我的看法是有必要!这还是和Java本身语言的限制有关系:

1、Java语法表达能力有限,entity bean又不得不弄一大堆getter/setter方法,都放在一个class里面,代码的可阅读性非常差

2、business bean的很多业务逻辑方法需要容器环境,不像Rails的model可以直接mixin进来

3、Java做为目前最主流的工业语言,开发团队都是大规模编码协作的,你都放在一个class里面,团队协作会遇到很大的麻烦(事实上RoR现在也有这样的问题,但是RoR开发效率高,往往不需要那么大规模的开发团队)

3、领域模型不同类别的业务逻辑可以很容易的分到几个不同的business bean里面,这样对团队协作的好处很大。


第三、不考虑框架限制,All-in-One的领域模型好不好?


比方说RoR的model就是All-in-One的,但是一旦出现可以抽象出来,比较通用的业务逻辑,我们还是会把这些逻辑抽出来,单独实现,然后再mixin回去。


所以最终我对这个问题的总结就是:

一、只要技术框架能够实现,尽量使用领域模型

二、无论Java还是Ruby,必须消灭DAO和TransactionScript

三、领域模型不必All-in-One,Java可以分割为 1个entity bean和几个business bean,而Ruby可以分割为1个model和几个mixin的module。


0 请登录后投票
   发表时间:2008-11-28  
taowen 写道

当然是可以的,只是约束数据的地方不在单单是在Domain Model上,还包括Service。别人要修改数据的时候,通不通过你的service就是职业操守的问题了。service给数据添加的约束不是强制的,而domain model可以添加强制的约束。就是这么一个问题。当然啦,你大可以说这是理论。我也说过了,贫血不贫血不是问题,适不适合自己才是问题。


我明白了,你所谈的哲学问题或者碰到的困境,还是在于程序员素质本身。而你所要解决的问题在于,如果从语法或者自然的角度就能把类似约束或者通用逻辑的问题解决,那是再好不过的事情。

我想这个初衷是没问题的。不过貌似在Java这个层面上,我们需要花费更多的代价来完成,或许借助框架,或许借助其他手段。可能有时候就需要在这些框架技术和哲学之间做出选择了。

taowen 写道

downpour 写道

这点更加是无从谈起了,为什么不写个字段,把hibernate的where用上?简单实用。事实上很多项目我们都是这么使用的。

你是指formula property吗?


formula可以做,不过要设置好lazy,否则会有性能问题。同时,一对多关联关系上也是可以设置查询条件的。
0 请登录后投票
   发表时间:2008-11-28  
robbin 写道


一、只要技术框架能够实现,尽量使用领域模型

二、无论Java还是Ruby,必须消灭DAO和TransactionScript

三、领域模型不必All-in-One,Java可以分割为 1个entity bean和几个business bean,而Ruby可以分割为1个model和几个mixin的module。




第一点基本同意。不过在Java世界,有时候不得不在技术框架和使用哲学上做出balance。

第二点持保留意见,目前Dao要完全消除的可能性不大,JBoss Seam提供了范例,不过必须有容器做环境。但是我认为Dao可以是非常非常薄的一层,甚至可以不需要有实现。这一点Robbin曾经介绍过一个框架,用Annotation实现,论坛上也有人曾经用Annotation在Spring上实现过。

第三点完全同意。
0 请登录后投票
   发表时间:2008-11-28  
我倒不觉得 DAO 是邪恶的。

我更加趋向于存在一个DAO层,或许不因该叫 DAO,应该叫 PAO(Persistence Access Object):持久访问对象的层。

因为在程序设计的早期,应该具有的概念是持久层,而不是“数据库”,数据库仅仅是持久的一个方式。

而这个层应该是面向接口的,至于实现,99%的情况应该都是数据库,但是也可能有的操作是保存到一个特殊的xml,有的操作是保存到远程的一个服务器上。即使都是数据库,也可能使用 ibatis 或者 hibernate 等不同的方式来实现。

所以大概的流程就是:

service -> domain model -> PAO (PAO 的实现就根据实际情况了)

当然,这里的 PAO 只是一个名词,沿用熟悉的称呼,叫它为 DAO 也是一样。
0 请登录后投票
论坛首页 Java企业应用版

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