海鸥航际

JAVA站
posts - 11, comments - 53, trackbacks - 1, articles - 102

Domain Model Design

Posted on 2005-01-12 20:23 海天一鸥 阅读(859) 评论(0)  编辑  收藏 所属分类: J2EE

一直想系统的整理一下自己有关Domain Model实践的尝试。但总觉得自己的想法还不够系统而作罢。
然而从另一方面看“系统的东西”也许永远做不到,失去了目标的生活该会多乏味。
因此我决定将自己有关Domain Model设计的有关实践和思考和盘托出,也算是抛砖引玉。欢迎大家
参与讨论,遇到同你的观点相左的地方,希望能以包容的态度来面对,我们是朝同一方向走的伙伴而不是
相互对视的敌人。:)

在深入讨论之前我先抛出一些原则和概念,最后你会看到这些概念和原则的威力。
1.按照概念依赖的原则来组织业务层。
2.将业务活动(业务流程)建模成类。
3.用业务活动(业务流程)作为关联整个业务层各种对象的骨架。
4.在业务活动中凿出扩展点,使用不同接口分离不同性质业务对象。
5.将对象的存储理解为业务层概念。
......

概念依赖

这是我认为能否得到良好业务层最重要的概念。
在我系统框架设计将要完成,开始涉及业务层设计时,我脑袋一片空白,书上,大家讨论的大多是整个系统的结构从UI层
到服务层到数据访问层到数据库。到底业务层该如何组织?Martin Fowler的POEAA的书中没有回答。找到的相关
书籍也都过于空泛。Martin Fowler的分析模式有些用处,但不够系统。透过Martin fowler网站,我拿到了
Domain Driven Design的发行前版本。该书给了我很大的启示。其中的要点有:
关于关联:
1.Imposing a traversal direction (强制一个关联的导航方向)
......
关于Responsibility Layers(业务职责层)的划分:
作者给出了三个指导原则:Conceptual dependency.(概念依赖)为其中一项。
书中给出的描述的是业务职责层上层的对象需要通过下层对象才能在概念上完整,
相反下层对象则可独立于上层对象存在含义。这样天然的下层对象相对于上层对象
会更稳定。并且在今后演变的过程中,使同扩展的方式来完善系统,而不是改变对象
的方式。
通过实践,我觉得这条原则可以应用在任何两个有关联的业务对象上。通常可以通过
概念依赖先建立一个导航方向。这能够满足大多数的需求。当确实需要反向导航时,
只要理由充分可以随时加上,并且如果先前将这两个对象放入不同包中,这时需要
将他们合并到同一个包中。
我见过一个不好的设计。Customer具有很多Flag分别标记该客户是否挂失,冻结,注销等等。
通常叫做客户状态,然而这是不对的,这违背了单一职责原则。事实上除了注销外
挂失和冻结都不应该算作Customer的本质属性。相反我把他们看作某种约束,进而把挂失看作
一种协议.....因为Customer的概念可以不依赖于挂失和冻结的概念,相反挂失和冻结却要依赖
Customer的概念,应为这是他们动作的主体。
同样的一开始就让Customer有GetAccount的方法同样不好。因为Customer的概念确实不依赖Account
XXXAccount却可以有Customer的属性,Account在概念上依赖Customer。
按照概念依赖原则我们能更好的理解业务职责层的划分。DDD中建议了如下的职责层。
按从高到低分别为:

依赖方向
| Decision
| Policy
| Commitment
| Operation
V Potential

Potential中包括类似Customer,Employee等Party方面的类。对应支持业务。
Operation中包括了核心业务如存取款,买卖以及同这些业务关联的Account,Product等等。
Commmitment对于客户包括同客户签订的协议等。对于员工来说包括授权等。
Policy包括计算某种收费的策略,比如电信收费的算法。对应支持业务。
Decision包括主要对应于管理业务。监控系统多在这层。
从上到下观察,很好的遵循了概念依赖的原则。
从另一方面来看,可以根据概念随着时间发展的顺序来建立对象之间的关系。这样会天然的满足概念依赖原则。
后面发展起来的概念可以依赖前面的已经存在的概念,而反过来这不可。这是系统稳定的关键。
同客户签订的各种协议可以不断的发展,但是客户对象是稳定的。
同理收费的策略可以变化但是最终反映到帐户上的都只是对Balance的变更。所以收费策略比
帐户更不稳定。
客户对象也要比帐户对象稳定。

从按照稳定的方向依赖的原则出发,我们可以得到对象间的单向依赖。当然也会存在双向关联
的对象,然而这种情况在我的实践中不是很多。而且一旦你懂得了单向关联的好处后,你就会
谨慎的使用双向关联。滥用关联会使得整个业务层象DDD中说的,变成一大块“果冻”,你随便触动
果冻某一块,整个果冻都会颤动。
同样为了简化设计,对象的关系中多对多的关系尽量避免。如果可以
则通过限定角色转化为一对多或一对一的关系。

以上是关于概念依赖的观念,下面让我们看看如何建模业务中的活动。
有一种做法是使用分析模型中的控制类直接映射到设计中类中。我看来这不是好的做法。
这里谈谈分析与设计的区别。
从分析的角度来看,业务实体总是被动的。业务是通过控制对象操作业务实体来完成的。
分析时我们是关注是什么问题。这要求我们客观的来描述现实。
进入设计阶段我们关注的是如何解决的问题。控制对象施加与业务实体的操作加入不涉及
第三者,则这个操作可以并入被操作的实体类中。然而分析中控制对象的概念是如此的
深刻,以至于只涉及Customer的ChangePassword方法该放到哪里都成了问题。类不是
“某概念 + 所关心该概念的属性 + 最终施加与这些属性上的操作” 的封装,又是什么呢?
下面的问题是如何建模跨越多个业务实体的操作?
举个例子:银行开户。
现在假设开户时涉及到下面的一些操作对象。
创建一个Customer对象。
创建一个CapitalAccount对象。
存入一定数额的现金。
记录一笔开户流水。
整个业务活动,我可以建模为OpenCustomerAct对象。伪码如下:

public class OpenCustomerAct extends CustomerAct
{
...
public void override doRun()
{
Customer customer = Customer.create(...);
CapitalAccount capitalAccount = CapitalAccount.create(customer,...);
capitalAccount.deposit(...);
OpenCustomerLog.create(this);
}
...
}

所需的参数通过构造函数得到。
将所有的业务活动都建模成一个Act,这非常重要。甚至你可以在Session中放入一个Act来
表示当前正在进行的业务。所有的扩展都是从Act开始的。
假如你要对Act施加某种检查,那么对doRun方法进行拦截可以达到该目的。
用例能够简化到只剩下流程,同样道理Act也可以做到这点。
对于象RichClient的交互模式,通常只在最后才提交业务,中间的交互都是在准备提交的数据。
那么在中间调用的方法中可以只new XXXAct而不执行doRun操作。这样做是因为中间的调用
可能会用到XXXAct来作为上下文。现在我还没有想好在这样的中间过程中,如何能够触发
植入到donRun前的检查?或许可以创建一个空doRun的子类覆盖掉父类实际的操作?

Act

public interface Act
{
Operator getOperator();//谁
Date getOccurDate();//在什么时间
String getOccurPlace();//什么地点
BusinessType getBusinessType();//做什么业务
ActState getActState();//业务运行的当前状态
}
“谁在什么时间什么地点做了什么业务。”
这描述了任何业务的基本方面。从哲学的角度来看,“我们得到了Act,我们就得到了事物的基础”。
当我们具体的描述某项业务时,假如需要向调用方暴露特定的属性。
我们可以随时添加到Act的子接口中。
例如同Customer相关的Act可定义为:
public interface CustomerAct extends Act
{
Cutomer getCustomer();//针对哪个客户
}
在复杂一点的情况下,如业务需要多人协作完成,可以通过组合模式达到目的。

public interface CompositeAct extends Act
{
Act[] getActs();
}
涉及到一段时间有中间状态的工作流也应该可以作为Act的子接口进行扩展。
不过我没有做过这方面的尝试。

将Act放入Session

将Act放入Session使得可以方便得到业务运行的上下文。而且通过扩展Act。
可以从Act或其子接口中得到想得到的任何东西,这使得任何扩展都成为可能。

这里说明一下Act类的位置应当放入Potential层中,并且与Operator在一起。
因为Potential层的业务对象也需要业务活动来维护。
如果你的框架中Sesion在更基础的包中,则可以给Act提供一个空内容的父接口,放入Session所在的包中。
public interface AbstractAct
{
}

public interface Act extends AbstractAct
{
...
}
Session提供得到AbstractAct的入口。
public class Session
{
...
static public AbstractAct getAbstractAct()
{
return Instance().abstractAct;
}
...
}

Act上的扩展点

按照分层的观点,下层不允许依赖上层,然而业务对象却是协作完成某个目的的。
而且只要业务对象需要维护,就需要相关的Act。
例如:银行中的存钱业务,参考上面的分层,我们把它放入Operation层。
在存钱的业务中,我们需要检查该客户是否做了挂失。而挂失协议我们是放在Commitment层。
显然,Operation层不能直接调用Commitment层的协议。
DIP模式发话了“用我”。
在Operation层中定义Commitment层接口,和一个工厂,使用反射实现这种调用。在Act中调用。
abstract public class ActImpl
extends abstractActImpl
implements Act
{
public virtual void run()
{
doPreprocess();
doRun();
doPostprocess();
}
abstract public doPreprocess();
abstract public doRun();
abstract public doPostprocess();
}

public interface CustomerCommitment
{
void affirmCanDo();
}

abstract public class CustomerActImpl
extends ActImpl
implements CustomerAct
{
...
public override void doPreprocess()
{
...
//扩展点
CustomerCommitment customerCommitment = CustomerCommitmentFactory.create(this);
customerCommitment.affirmCanDo();
...
}
...
}

public interface InnerCustomerCommitment
{
void affirmCanDo(CustomerAct customerAct);
}

public class CustomerCommitmentImpl implements CustomerCommitment
{
private CustomerAct customerAct;

public CustomerCommitmentImpl(CustomerAct customerAct)
{
this.customerAct = customerAct;
}

public void affirmCanDo()
{
...
//通过配置得到该customerAct对应需要检查的客户约束,包括协议,逐一检查。
DomainObjectCollection commitmentTypes = CustomerCommimentRepository.findByBusinessType(customerAct.getBusinessType());

...
foreach( CommitmentType typeItem in commitmentTypes )
{
InnerCustomerCommitment commitment = getCommitment(typeItem);
commitmentItem.affirmCanDo(customerAct);
}
...
}
}

public class CustomerLostReportAgreementChecker implements InnerCustomerCommitment
{
public void affirmCanDo(CustomerAct customerAct)
{
Check.require(customerAct.getCustomer() != null,"客户不存在");

CustomerLostReportAgreement customerLostReportAgreement =
CustomerLostReportAgreementRepository.find(customerAct.getCustomer());

if(customerLostReportAgreement != null)
{
agreement.affirmCanDo(customerAct);
}

}
}

public class CustomerLostReportAgreement
{
...
public void AffirmCanDo(CustomerAct customerAct)
{
if(customerAct.getOccurDate <= expiringDate)
throw new CustomerLossReportedException(customer);
}
...
}

同样道理,可以对其他上层的对象使用DIP使依赖倒置。
比如:电信计算费用。就可以通过在CustomerAct的doRun中插入扩展点来实现。
这样复杂的计费算法就被封装在接口之后了。可以分配另外的人员来开发。
业务活动的流程仍然清晰可见。
是啊,这正是接口的威力,大多数的设计模式不也是基于这种原理吗?

还有在Act上的扩展点可以分为两类,显式的和隐式的。
电信费用的计算就是显式的,因为CustomerAct需要知道计算的结果,用来从帐户中扣除金额。
而检查挂失协议是隐式的,CustomerAct可以对此一无所知。

通过在Act上的扩展点,我们可以向上扩展。
这仿佛是在树枝上种木耳,呵呵。

DIP VS Facade

对于上面的情况,另外一种方法是使用Facade。
让我们比较一下两者。
简要说明一下Facade的做法:
abstract public class CustomerActImpl
extends ActImpl
implements CustomerAct
{
...
public override void doPreprocess()
{
...
//注意:这里传递的参数,会使得用Facade方式的人大伤脑筋。
//按照挂失的要求目前传递getBusinessType(),getCustomer(),getOccurDate()就够了
//但是对于所有的CustomerCommitment这些参数就不一定够了。
//比如:客户可能签订指定员工协议。(指只允许协议中指明的员工能操作的业务)
//那么该接口需要添加getOperator()参数。
//接口变得不稳定。
CustomerCommitmentManager.affirmCanDo(getBusinessType(),getCustomer(),getOccurDate(),?,...);
...
}
...
}

Facade可以使得在Act中也是只提供一个调用点,但是因为不是依赖倒置的关系,不得不显示的说明需要用到的参数。
相反使用DIP模式,接口中定义的是Act的接口,而Act是可以扩展的。(是否扩展全部看上层的对象是否需要)。
而正是因为相应的CustomerCommitment总是处于需要检查的XXXAct的上层。这样具体的CustomerCommitment
总是可以依赖XXXAct。因此可以获得任何想要得到的信息。

同样对于电信计算费用的例子,因为传递的参数是CustomerAct接口。所以对于今后任何可能的扩展该接口都是不会变化的。
能够做到这一点,完全要归功于将计算费用放入Operation的上层Policy中,你能体会到其中的要领吗?

形象一点来说,使用DIP模式,采取的是一种专家模式。
DIP的Act说的是:“CustomerCommitment你看看我现在的情况,还能运行吗?”
相反Facade模式,则是令人厌烦的唠叨模式。
Facade的Act说的是:“CustomerCommitment,现在执行的客户是XXX,业务是XXX,时间是XXX,...你能告诉我还能运行下去吗?”
显然DIP要潇洒得多。

实现接口 VS 继承父类  

这里稍稍偏离一下主题,讨论一下接口同继承的问题。
什么时候使用接口?什么时候使用继承?
这似乎是个感觉和经验问题。或者我们会倾向于多使用接口,少使用继承。
可不可以再进一步呢?
以下是我的观点:

“接口是调用方要求的结果,而继承则是实现方思考的产物。”

毕竟如果我们定义的接口没有被用到,那它就没有任何用处。
接口的目的在于制定虚的标准,从而使调用方不依赖于实现方。
而继承某个父类则多半是基于“偷懒“的考虑,已经存在的东西,我为什么不利用一下?
当然这样说是忽略了继承的真正用意--单点维护。

所以在定义XXXAct的接口时,需要多考虑一下,上层对象需要Act中的提供什么特性,会如何使用它。

接口属于调用方。

业务对象的持久化

一个会引起争议的问题,是业务层是否会涉及业务对象持久化的概念。
答案是肯定的。
DDD中在描述The life cycle of a domain object时,给出了两种形式的持久化。
Store和Archive。我们使用的较多是Store。

但是这不代表业务层要依赖数据访问层。相反依赖关系应该倒过来。数据访问层依赖
业务层。通常我们使用Mapper实现,在hibernate中通过配置达到该目的。
要做到业务层不依赖于数据访问层,同样借助接口来完成。
在业务层定义数据访问的接口,为了方便,可以使用一个类来封装这些操作。

public interface CustomerFinder
{
Customer findByID(ID id);
Customer findByCode(String code);
DomainObjectCollection findByName(String name);
...
}

public class CustomerRepository
{
private static CustomerFinder finder = null;
private static CustomerFinder getFinderInstance()
{
if (finder == null)
{
finder = (CustomerFinder)FinderRegistry.getFinder("CustomerFinder");
}
return finder;
}

public static Customer findByID(ID id)
{
Customer obj = getFinderInstance().findByID(id);
Check.require(obj != null,
"未找到ID为:" + id.toString() +
"对应的 Customer。");
return obj;
}
...
}

在数据访问层实现这些接口。因为是数据访问层依赖业务层,所以你可以采用多种技术来实现,
使用hibernate这样的开源项目,或者手工编写Mapper。

ID id

另外一个有争议的问题是Domain层是否要引入与业务无关的ID来标识不同的对象呢?
我的经验是在业务层引入ID的概念会使很多事情变得方便些。
如:Lazyload。
这是否不属于业务的范畴?是在概念上不属于业务。但在业务上
不是没有对应的概念。
例如:保存客户定购信息的订单,作为标识的就是订单号,这是给人使用的。
在使用电脑后,我们也给对象一个它能理解的统一标识,这就是ID。
另外不要使用业务上的概念作为主键和外键,因为它们本来就不是数据库的概念。
否则,会使得业务概念同数据库的概念混淆起来。

ID的使用通常会选择效率较高的long类型。
不过我们的实现走得更远,我们将其封装为ID对象。

Service层

现在我们向上看看将业务层包裹的服务层。
服务层是架设在应用层和业务层的桥梁,用来封装对业务层的访问,因此
可以把服务层看作中介,充当两个角色:
1.实现应用层接口要求的接口;
2.作为业务层的外观。
服务层的典型调用如下:

public interface CustomerServices
{
void openCustomer(CustomerInfo cutomerInfo);
void customerLostReport(String customerCode,Date expiringDate,String remark);
CutomerBasicInfo getCutomerBasicInfo(String customerCode);
...
}

public class CustomerServicesImpl
extends ServiceFacade
implements CustomerServices
{
...
public void openCustomer(CustomerInfo cutomerInfo)
{
try
{
init();

OpenCustomerAct openCustomerAct =
new OpenCustomerAct(customerInfo.name,
customerInfo.code,
customerInfo.address,
customerInfo.plainpassword
...
);
openCustomerAct.run();

commit();
}
catch(Exception e)
{
throw ExceptionPostprocess(e);
}
}

public void customerLostReport(String customerCode,Date expiringDate,String remark)
{
try
{
Check.require(customerCode != null && customerCode != "",
"无效的客户代码:" + customerCode);
init();

CustomerLostReportAct customerLostReportAct =
new CustomerLostReportAct(customerCode,
expiringDate,
remark);
customerLostReportAct.run();

commit();
}
catch(Exception e)
{
throw ExceptionPostprocess(e);
}
}

public CutomerBasicInfo getCutomerBasicInfo(String customerCode)
{
try
{
Check.require(customerCode != null && customerCode != "",
"无效的客户代码:" + customerCode);
init();
Customer customer = CustomerRepository.findByCode(customerCode);

//这里选择的是在CustomerRepository外抛出CustomerNotFoundException异常,
//另一种方法是在CustomerRepository中抛出CustomerNotFoundException异常。
//因为CustomerRepository在于通过客户代码查找对应的客户。至于是否应该抛出
//异常则交给业务层或服务层来处理。
//这里有很微妙的区别,抛出CustomerNotFoundException应该是谁的职责呢?
//你的想法是什么?Smile
if(customer == null)
throw new CustomerNotFoundException(customerCode);

CutomerBasicInfo cutomerBasicInfo = CutomerBasicInfoAssembler.create(customer);
return cutomerBasicInfo;
}
catch(Exception e)
{
throw ExceptionPostprocess(e);
}
}

...
}

服务层的代码很简单,不是吗?

上面的代码可以通过AOP进一步的简化。使用AOP实现我希望代码象下面这样简单。
public class CustomerServicesImpl
implements CustomerServices
{
...
public void openCustomer(CustomerInfo cutomerInfo)
{
OpenCustomerAct openCustomerAct =
new OpenCustomerAct(customerInfo.name,
customerInfo.code,
customerInfo.address,
customerInfo.plainpassword
...
);
openCustomerAct.run();
}

public void customerLostReport(String customerCode,Date expiringDate,String remark)
{
Check.require(customerCode != null && customerCode != "",
"无效的客户代码:" + customerCode);
CustomerLostReportAct customerLostReportAct =
new CustomerLostReportAct(customerCode,
expiringDate,
remark);
customerLostReportAct.run();
}

public CutomerBasicInfo getCutomerBasicInfo(String customerCode)
{
Customer customer = CustomerRepository.findByCode(customerCode);
if(customer == null)
throw new CustomerNotFoundException(customerCode);

CutomerBasicInfo cutomerBasicInfo = CutomerBasicInfoAssembler.create(customer);
return cutomerBasicInfo;
}

DTO or Not

我认为是否使用DTO取决于项目的大小,开发团队的结构,以及对项目演变预期的评估结果。
不使用DTO而直接使用PO传递到应用层适用于一个人同时负责应用层和业务层的短期简单项目;
一旦采用该模式作为构架,我不知道业务层是否还能叫做面向对象。
原因如下:
1.使用PO承担DTO的职责传递到应用层,迫使PO不能包含业务逻辑,这样业务逻辑会暴露给应用层。
业务逻辑将由类似于XXXManager的类承担,这样看来似乎PO有了更多的复用机会,因为PO只包含getXXX同setXXX类似的属性。
然而这正类似面向过程模式的范例,使用方法操作结构,程序多少又回到了面向过程的方式。
2.将PO直接传递到应用层,迫使应用层依赖于业务层,如果一个人同时负责应用层和业务层那么问题不大;
如果是分别由不同的人开发,将使得应用层开发人员必须了解业务层对象结构的细节,增加了应用层开发人员的知识范围。
同时因为这种耦合,开发的并行受到影响,相互交流增多。
3.此外这也会使得业务层在构建PO时要特别小心,因为需要考虑传递到应用层效率问题,在构建业务层时需要
考虑应用层的需要解决的问题是不是有些奇怪?

有人会抱怨写XXXAssember太麻烦,我的经验是XXXAssembler都很简单。

我们使用手机,会发现大多数手机提供给的接口都是相同的,这包括0-9的数字键,绿色的接听键,红色的挂机键,还有一块显示屏。
无论我是拿到NOkIA,还是MOTO的手机,我都能使用,作为手机使用者我没有必要知道手机界面下的结构,不用关心
使用的是SmartPhone还是Symbian。

确实,应用层将服务层和业务层看作黑箱要比看作白箱好得多。


只有注册用户登录后才能发表评论。


网站导航: