迪米特法则(Law of Demeter)与领域模型行为

领域模型的行为设计中我们提到

2013-04-22 15:37 "@banq
"的内容
我们把A对象自身固有行为看成是A的一种能力,而把需要依赖其他对象的方法称为交互行为。哪些属于A的自身方法?哪些属于交互方法?设计思路和方法是如何考虑的? ...

那么什么是对象的固有行为?我们认为是那些保证该对象逻辑一致性的行为,称为对象的基本职责,保证自己的存在。

迪米特法则(Law of Demeter)则详细地定义了对象的方法行为,其定义是:

一个对象的方法只应该调用下面对象的方法:
1. 可以调用自己的方法
2. 参数对象的方法
3. 创建自己或初始化时涉及到其他对象的方法
4. 它的直接组件的对象的方法(聚合体内部等)


迪米特法则实际从一个公理原则角度对对象的行为设计进行了界定,举例如下:
顾客有一个钱包,PayBoy收款员要求顾客支付,首先,顾客对象如下:


public class Customer {
private String firstName;
private String lastName;
private Wallet myWallet;
public String getFirstName(){
return firstName;
}
public String getLastName(){
return lastName;
}
public Wallet getWallet(){
return myWallet;
}
}

钱包:


public class Wallet {
private float value;
public float getTotalMoney() {
return value;
}
public void setTotalMoney(float newValue) {
value = newValue;
}
public void addMoney(float deposit) {
value += deposit;
}
public void subtractMoney(float debit) {
value -= debit;
}
}

收款员进行收款时代码如下:


//这段代码是位于Payboy类中:
payment = 2.00;
// “I want my two dollars!”
Wallet theWallet = myCustomer.getWallet();
if (theWallet.getTotalMoney() > payment) {
theWallet.subtractMoney(payment);
} else {
// come back later and get my money
}

这段代码的意思是,检查顾客的钱包中余额,是否足够,然后支付。

注意,检查顾客钱包中余额这一功能是在PayBoy中实现的,PayBoy有权检查顾客的钱足够吗?应该是顾客知道自己钱包余额是否足够。

如果类似Payboy这样调用者进行下面代码:
myCustomer.setWallet(null);

这不是将顾客这个对象的钱包清空了吗?

这实际就是破坏了顾客这个对象内部的逻辑一致性,顾客对自己的钱包拥有支配权,不能随便将钱包的操作暴露给外界。

也就是说问题出在Customer,它只有setter/getter方法,是一种纯粹的数据结构,是一种失血模型。

我们需要重构Customer,将保证顾客逻辑一致性的行为显式的表达出来,顾客应该拥有这样的基本职责:对自己钱包余额情况有足够了解。

代码实现如下:


public class Customer {
private String firstName;
private String lastName;
private Wallet myWallet;
public String getFirstName(){
return firstName;


public String getLastName(){
return lastName;
}
public float getPayment(float bill) {
if (myWallet != null) {
if (myWallet.getTotalMoney() > bill) {
theWallet.subtractMoney(payment);
return payment;
}
}
}
}

我们看到,将钱包检查验证是否足够放在Customer这个对象中,这实际也回答了“关于领域驱动设计中的“合法”性校验?”:
http://www.jdon.com/45363

这样Payboy的调用代码如下:


payment = 2.00; // “I want my two dollars!”
paidAmount = myCustomer.getPayment(payment);
if (paidAmount == payment) {
// say thank you and give customer a receipt
} else {
// come back later and get my money
}


迪米特法则可能带来Customer行为增加,导致复杂性,其实熟悉DDD应该知道,我们可以使用规格模式Specification将那些确保
实体逻辑一致性的合法性检查划分出去,这样,Customer和那些规格对象就组成一个聚合群,成为一个聚合体。
[该贴被banq于2013-05-05 13:32修改过]
[该贴被admin于2013-05-05 17:11修改过]

从依赖和松耦合角度看,Payboy前后两段代码区别是没有了Wallet这个对象,这样,类Payboy只与Customer发生耦合,而不会直接和Wallet发生紧耦合。

Payboy只要告诉(Tell)Customer做什么即可,而不直接参与怎么做(Ask)。这实也是一种“Tell, Don't Ask”原则。

或者可以说:Payboy只要命令(Tell)Customer做什么即可,而不关注Customer是怎么做的。

DDD中领域模型表达的是一种业务目标,也就是做什么问题,不能将大量怎么做细节放入领域模型中。


[该贴被banq于2013-05-05 14:42修改过]

2013-05-05 14:35 "@banq
"的内容
Payboy前后两段代码区别是没有了Wallet这个对象 ...

Payboy后面的代码是:
paidAmount = myCustomer.getPayment(payment);

我们注意到这是使用了一个整数型的值paidAmount,也就是说,这里可以使用Wallet的一个值对象,而不必直接引用整个实体Wallet,这也符合DDD中聚合的设计。可以默认Customer为一个聚合根。

2013-05-05 14:35 "@banq
"的内容
Payboy只要告诉(Tell)Customer做什么即可,而不直接参与怎么做(Ask)。这实也是一种“Tell, Don't Ask”原则。 ...

实现Tell, Don't Ask”原则或迪米特法则(Law of Demeter)的最好方法是在技术上使用消息机制,在语义上引入事件。

消息为信封,事件为信函内容。事件也就是Tell的内容。如下图

使用这种编程风格后,更加符合TDD测试驱动方式,见:
"Tell, Don't Ask" Object Oriented Design

使用依赖注入实现聚合根之间调用的逻辑悖论:
http://www.jdon.com/45318

依赖注入与事件编程:
http://www.jdon.com/45264

[该贴被banq于2013-05-05 15:01修改过]


如果不结合领域模型的业务逻辑一致性来分析行为,单纯从迪米特法则分析一切对象行为,有时容易走极端。

比如凡是看到:
// customer.wallet.totalMoney;
// customer.apartment.bedroom.mattress.totalMoney;
// customer.apartment.kitchen.kitchenCabinet.totalMoney;
都应该考虑是否使用
customer.getPayment(..)
重构替代。

在JSP中,其实我们经常使用遍历XX.YY.ZZ来获取某对象子对象的值。如果我们将所有的子对象获取行为都放入XX根对象中,根对象将非常复杂。

因此,必须结合DDD聚合设计原则,需要从业务上考虑它们是否属于一个聚合边界内,比如顾客Customer有职责对自己的钱包Wallet进行操作,这很符合日常知识。但是如果你得出所有A都有职责对自己的B进行操作这个普遍真理就不恰当了。

换句话说,迪米特法则只是规定了一个对象只能和哪些对象直接交互,不能与除此之外的其他对象交互,分析其定义如下:
1. 可以调用自己的方法,那么除自己之外的对象怎么办?
2. 参数对象的方法,如果自己有方法参数,那么可以调用参数对象的方法,比如PayBoy调用参数Customer的getPayment方法。
3. 创建自己或初始化时涉及到其他对象的方法,创建自己时,涉及到一些其他对象,比如规格对象,其中封装了自己创建的合法性检查。
4. 它的直接组件对象的方法(聚合体内部等),如果它在一个组件边界内,应用在业务模型上,一个聚合边界看成一个组件边界,聚合体内部可以直接相互调用。

除以上情况以外都不能调用,如果需要调用,那么向允许的这些对象发出Tell命令,委托他们实现即可。

由此看来:迪米特法则特别适合聚合根实体的行为设计。

[该贴被admin于2013-05-05 17:12修改过]

先给出我的观点:

业务对象除了一些固有的行为(比如有些对象的内部状态位的管理),尽量不要绑定行为。行为最好根据业务场景(上下文)分配给角色来完成,合法性校验就是依赖于上下文的一种行为。

比如这个例子中,涉及Customer(顾客),Paperboy(报童),Wallet(钱包)三个业务对象,BuyPaperContext(买报)一个业务场景。

付款的代码既不适合放在Payperboy中,也不适合放在Customer中,适合放在BuyPaperContext中,如果场景涉及多个角色,可以在BuyPaperContext抽象出一个Payer的角色,把付款的行为绑定在Payer身上。

如果多个业务场景的角色存在共性,可以提取抽象类,场景也是如此。场景之间的交互一般是通过异步消息的方式,不同场景间的角色或聚合根之间的交互也是如此,而同一场景内的角色之间的交互一般以同步的方式进行。

再说原因:

文章中已经指出了放在将付款行为放在Paperboy与现实不符合,那么放在Customer就与现实符合了吗?以日常生活为例,去超市时,经常用到刷卡(卡相当于钱包),可是刷卡人可能不知道

卡里还有多少钱,只有通过机器来进行检测,检查顾客的卡的余额,不是由顾客来完成的。

好,也许你会说钱包跟卡不同,那我举一个极端的情况,比如顾客本身可能是个残疾人,没有双手,那么TA如何查看钱包的余额呢?也许TA真的可能让报童帮TA检查一下呢。


文章在结尾出提到一个比“耦合和内聚”更严谨的概念:正交性(Orthogonality)。这个概念在数学上很常见,用于指导代码的设计其实很有用。“正交”(Orthogonality)代码设计,或许可称为无冗余设计。

相似的东西看出共同点,算不了什么,看出它们真正不同的地方,更有意义;不同的东西看出不同点,也算不了什么,看出实质相通的地方,更有价值。这也是我对“正交”这一概念非数学化的理解。

比如,看出strategy和bridge的UML图形上的相似性,而看不清其语义上的正交性,又比如,observer和command其正交的地方又是什么呢?诸如此类的问题,不想清楚,想实现并运用好它们,恐怕需要运气。

把精力放在API或业务场景的“正交”设计上,比花在业务对象职责上,更容易也更清晰。因为业务场景一旦确定下来,角色也随之确定,很自然地,也会顺利地找到适合的业务对象、业务规则。

我大致赞同jdon007的观点。

一、我认为对象“只能”拥有“在逻辑上只与自己相关”的方法或行为,如wallet拥有币值换算等。聚合根内部对象只能聚合根调用就是这一规则的另一方面体现。

二、更进一步地,我认为状态与逻辑分离是必要的。所以我不推荐在进行业务逻辑时对对象进行任何更改,一切以值进,以值出。对场景的理解可以变为这样:场景是发生前与发生后的纽带。

三、对于可以更改状态的对象,我认为是一种异类,或者说逻辑上是异类,相当于Haskell中Monad的IO Monad(修改就相当于IO操作),不可避免,但绝不可以混淆。

在例子中,Customer(顾客),Paperboy(报童),Wallet(钱包)三个对象,到底怎么分配没有定则。Wallet的确可以放到Customer内,这是一种整体的看法,但不能修改值。付款代码的确不应该放在3个中的任何一个,因为与我第一条相悖。而BuyPaperContext中调用这3个或其中2个对象,又正好满足第一条,即使引入角色也必须满足第一条。

PS:其实这一切是状态引起的,不搞定状态,逻辑上仍然会产生各种问题。

看来大家对getPayment是否放在Customer中有不同意见。我想角度虽然不同,应该有一个原则,每个角度都不能发生逻辑悖论,也就是逻辑不一致性。

该getPayment方法核心是检查钱包是否足够,然后扣除取出的钞票。如下:


public float getPayment(float bill) {
if (myWallet != null) {
if (myWallet.getTotalMoney() > bill) {
theWallet.subtractMoney(payment);
return payment;
}
}
}

这一行为如果不放在Customer中,比如放在一个参与支付场景的角色中,可能会发生支付主体和支付客体两者分离的局面,当然,也可以将支付场景的角色Mixin混入Customer中(DCI),但是这种运行时组合方式无法在设计阶段显式突出逻辑一致性。

逻辑一致性应该在设计或写代码时突出显式出来,让别人看到你的代码马上知道你的设计意图是什么。

两种行为设计方式比较:
1.固有行为,也就是基本职责。
优点:显式突出逻辑一致性,缺点:不容易确定是否是基本

2.角色场景行为,采取DCI混入方式。
优点:理解方便,有用的锤子,缺点:没有显式突出逻辑一致性

我再举一个例子,表达如果没有显式突出逻辑一致性导致的后果。


public class A {
private int lower, upper; //两个状态值
public int getLower() { return lower; }
public int getUpper() { return upper; }
….setter方法….
}


A模型要求逻辑上一致性是:状态值lower必须小于状态值upper,lower<upper。但是A类代码并没有显式突出这种逻辑约束。

如果我们在某个场景类或服务类中对A进行操作:


class AService{
private A a;

public void setAUpper(int value){
if (value < a.getUpper())
a.setLower(value);
}

public void setALower(int value){
if (value > a.getLower())
a.setUpper(value);
}
}


逻辑一致性判断被放在了客户端或service中,而不是在模型A内部,在多线程环境下会造成A的状态结果混乱,会出现A.upper<A.lower情况。

假设A的lower和upper的初始值是(0, 5);
同时下面两个请求事件发生:
一个客户端请求线程A: setLower(4)
一个客户端请求线程B: setUpper(3)

A的状态是:lower和upper是 (4, 3)
破坏A模型逻辑一致性要求lower<upper。

2013-05-07 07:56 "@banq
"的内容
逻辑一致性的问题 ...

呀,这里回复咋有点困难,按钮不好找啊。。。
插一句,你们讨论的走偏了,一个在说read,一个在说write,所以,,,都对。。。能否统一下,看看有没有反对,然后再继续,否则你们3个说再多也没意义。。。
附,banq,说明你这个例子有点避重就轻了。。。

一?为什么一定要我选中一句,才能回复,呵呵。。。
多啰嗦下,DDD里面有个policy层,负责策略性东西,看看是不是适合这些isavailable啊,enoughMoney啊,它可能围绕在聚合根那里,也蛮合理。。。
另外一个话题供大家继续,有人纠结于DCI,先有C啊还是先有D,或者先有I,呵呵,行为先行,对象先行,模型先行,都来了。。。是不是有点像唯物和唯心的争论?
你们继续,我旁观,呵呵

2013-05-07 07:56 "@banq
"的内容
逻辑一致性判断被放在了客户端或service中,而不是在模型A内部,在多线程环境下会造成A的状态结果混乱,会出现A.upper<A.lower情况。

假设A的lower和upper的初始值是(0, 5);
同时下面两个请求事件发生:
一个 ...


很明显,这是状态引发的问题。在一个完整的逻辑中,所有定义都不能改变,引入改变实际上是无视掉语言上的时间问题。(0,5)变(4,3)就是例子,在客户端A,B中的逻辑依据是(0,5),也就是说结果应该分别是(4,5)和(0,3),到底取哪个取决于决策,而并非混合。若果是先A后B,则B失败,先B后A,则A失败,若果同时,则取决于策略选择进行排斥选择。

2013-05-05 13:28 "@banq
"的内容

public class Customer {
private String firstName;
private String lastName;
private Wallet myWallet;
public float getPayment(float bill) {
if (myWallet != null) {
if (myWallet.getTotalMoney() > bill) {
theWallet.subtractMoney(payment);
return payment;
}
}
}

对于以上代码,看似把逻辑都放进了模型内,但却让customer与myWallet的行为耦合了起来。如果customer除了支付外,还有看书、看电影、购物、聊天、交友等很多业务,那么customer会臃肿成什么样呢?

我在设计时坚持2条原则:
1、单对象方法中不涉及其他对象;
2、只有场景方法才能组合不同对象的方法。
[该贴被flyzb于2013-05-10 00:05修改过]

2013-05-10 00:04 "@flyzb
"的内容
对于以上代码,看似把逻辑都放进了模型内,但却让customer与myWallet的行为耦合了起来。如果customer除了支付外,还有看书、看电影、购物、聊天、交友等很多业务,那么customer会臃肿成什么样呢? ...


这样的话“边界”在哪里?

2013-05-10 10:15 "@gsft
"的内容

这样的话“边界”在哪里? ...

从customer本身来说可能会参与到多种业务场景,“至于划分边界”当然是按“核心业务场景”来划分的,比如“支付”就可能是一个“核心业务场景”,它是由许多小的活动场景组成的。

检查钱包里的金额是否足够支付是customer的一部分
至于说刷卡支付,这只是customer的一种委托支付的形式