1. 角色
对象的背后,需要表达、体现出两种概念:“它是什么”和“它能做什么”。
如果以一种『非面向对象的思维』使用 Java<small>(或其它面向对象编程语言)</small>,那么你会发现你的系统中会充斥着大量的『贫血模型』。
注意,你『使用面向对象编程语言』进行开发,并不意味着你在进行『面向对象编程』。
贫血模型意味着这个对象只表达了『它是什么』,而没有表达出『它能做什么』。
有人提出通过『角色』的概念去提炼对象『能做什么』。
在本质上,『角色』体现的是一般化的、抽象的算法。角色没有血肉,并不能做实际的事情,归根结底工作还是落在对象的头上,而对象本身还担负着体现领域模型的责任。
『对象』这个统一的整体有两种不同的模型,即『系统是什么』和『系统做什么』。
正因为最终用户能把两种视角合为一体,类的对象除了支持所属类的成员函数,还可以执行所扮演角色的成员函数,就好像那些函数属于对象本身一样。
Object 由多个 Role 组合而成,在不同的场景下扮演不同的角色,所以 Object 就是多角色对象。
Java 语言可以通过接口来实现对象的角色。而且,从 Java 8 开始接口可以提供默认。
举个生活中的例子,人是多角色对象,由多个角色组合而成,不同的角色履行的职责不同:
- 作为父母:我们要给孩子讲故事,陪他们玩游戏,哄它们睡觉;
- 作为子女:我们要孝敬父母,听取他们的人生建议;
- 作为下属:我们要服从上司的工作安排,并高质量完成任务;
- 作为上司:我们要安排下属的工作,并进行培养和激励;
- ...
人在特定的场景下,只能扮演特定的角色:
- 在孩子面前,我们是父母;
- 在父母面前,我们是子女;
- 在上司面前,我们是下属;
- 在下属面前,我们是上司;
- ...
2. 练习
-
有两个人,一个人是富人 RichMan ,另一个人是穷人 PoorMan ,每个人都有一个唯一身份标识,基本数据有姓名、性别,电话号码和启动资金。
富人是老板 Boss ,家里有事时向公司请假,在公司时主要工作是分配任务; 富人是父母 Parent ,要按时参加孩子的家长会,但参加之前要先向公司请假;富人是投资者 Investor ,经常炒股;穷人是员工 Employee ,家里有事时向公司请假,周末外场有故障时要在公司加班;穷人是父母 Parent ,要按时参加孩子的家长会,但参加之前要先向公司请假;穷人是厨师 Cooker ,会炒菜。
-
每个人都应该申请一个账户 Account ,然后持有 AccountId ,可以通过 AccountId 访问账户完成转账或查询余额等业务。
人与人之间的转账转变为账户之间的转账;在转账时,需要两个账户,其中一个账户作为源账户 Monkey Source ,另一个账户作为目标账户 Money Destination ;转账时,源账户可能余额不足; 转账成功后,双方在手机上都收到了转账成功的信息。
在 2020 年新年来临之际,RichMan 慷慨的给 PoorMan 转账 2000 元红包。
3. 场景驱动设计
对象是强调『行为协作』的,但对象自身却是对『概念』的描述。一旦我们将现实世界映射为对象,由于行为需要正确地分配给各个对象,于是行为就被打散了,缺少了领域场景的连续性。
『场景驱动设计』引入『分解任务』的方法,一方面通过分而治之的思想降低了领域逻辑的复杂度,另一方面也建立了一系列连续的行为去表现领域场景,使得整个领域场景被分解的同时还能保证完整性。
『时序图』体现了领域场景下行为的动态协作过程,并反向驱动出角色构造型来承担各自的职责,就能使得对象的设计变得更加合理。
分解任务之所以能够承担此重任,一个关键原因在于它匹配了软件开发人员的思维模式。在将业务需求转换为软件设计的过程中,要找到一种既具有业务视角又具有设计视角的思维模式,并非易事。
任务分解采用面向过程的思维模式,以业务视角对领域场景进行观察和剖析;然后再采用面向对象的思维模式,以设计视角结合职责与角色构造型,形成对职责的角色分配。这两种视角的切换是自然的,它同时降低了需求理解和设计建模的难度。
软件设计终究是由人做出的决策,在提出一种设计方法时,若能从人的思维模式着手,就容易『找到现实世界与模型世界的结合点』。如果我们将领域场景视为电影或剧本中的场景,它反映了我们需要解决的代表问题域的现实世界。卡尔维诺在 《看不见的城市》 一书中描绘了这样的场景:
梅拉尼亚的人口生生不息:对话者一个个相继死去,而接替他们对话的人又一个个出生,分别扮演对话中的角色。当有人转换角色,或者永远离开或者初次进入广场时,就会引起连锁式变化,直至所有角色都重新分配妥当为止。
这个场景描述了一座奇幻的城市,这种城市的居民会聚集在广场中发生一场一场的对话,对话持续不断地继续下去,但是参与对话的角色却如幻影一般发生变化。这一幕小说情节很好地阐释了 DCI 模式,它开启了另外一种投影现实世界到对象世界的思维模式。
4. DCI 架构模式
Reenskaug 、Trygve 和 James O. Coplien 在 2009 年发表了一篇论文 《A New Vision of Object-Oriented Programming1》 ,标志着 DCI 架构模式的诞生。
值得注意的是 James O. Coplien 曾在年轻时创造了 MVC 架构模式,所以在 DCI 的论文中也有对 MVC 架构模式的反思。
DCI 模式认为,在现实世界到对象世界的映射中,构成元素只有三个:
数据 <small>Data</small>
上下文 <small>Context</small>
交互 <small>Interaction</small>
在梅拉尼亚这座城市,城市的广场是上下文,城市的居民是数据,他们扮演了不同的角色进行不同的对话,这种对话就是交互。
如今的软件开发是前后端分离的,所以 View 我们暂不考虑。
Model 分为业务数据和业务逻辑:
业务数据 <====> DCI 中的 Objects : 系统是什么
业务逻辑 <====> DCI 中的角色关联 : 系统做什么
Controller <====> DCI 中的上下文 Context : 系统的用例场景
Methodless Roles 指的是抽象的角色,而 Methodful Roles 指的是具体的角色,Classes 是组合了多个角色的类,Objects 就是多角色类的实例化。
5. DCI 架构模式
-
Data 层
描述系统有哪些领域概念及其之间的关系,该层专注于领域对象的确立和这些对象的生命周期管理及关系,让程序员站在对象的角度思考系统,从而让『系统是什么』更容易被理解。
-
Context 层
是尽可能薄的一层。Context 往往被实现得无状态,只是找到合适的 Role ,让 Role 交互起来完成业务逻辑即可。但是简单并不代表不重要,显示化 Context 层正是为开发者理解软件业务流程提供切入点和主线。
在典型的实现里,每个『用例』都有其对应的一个 Context 对象,而用例涉及到的每个角色在对应的 Context 里也都有一个标识符。Context 要做的只是将角色标识符与正确的对象绑定到一起,然后我们只要触发 Context 里的『开场』角色,代码就会运行下去。
-
Interactive 层
主要体现在对 Role 的建模,Role 是每个 Context 中复杂的业务逻辑的真正执行者,体现『系统做什么』。Role 所做的是对行为进行建模,它联接了 Context 和领域对象。由于系统的行为是复杂且多变的,Role 使得系统将稳定的领域模型层和多变的系统行为层进行了分离,由 Role 专注于对系统行为进行建模。该层往往关注于系统的可扩展性,更加贴近于软件工程实践,在面向对象中更多的是以类的视角进行思考设计。
换句话说,我们希望把角色的逻辑注入到对象,让这些逻辑成为对象的一部分,而其地位却丝毫不弱于对象初始化时从类所得到的方法。
和场景驱动设计相同,DCI 模式需要从业务需求表现的现实世界中截取一幕场景作为设计的上下文。上下文将参与交互的数据『框定』起来,根据场景要达成的业务目标确定对象要扮演的角色,以及角色之间的交互行为。每个数据对象在扮演各自角色时,只能做出符合自己角色身份的行为,这些行为在 DCI 模式中被称之为『角色方法』<small>(Role Method)</small>,它们反映了数据的目的;数据对象自身还拥有一些固定的行为,称之为『本地方法』<small>(Local Method)</small>,它们反映了数据的特征。数据对象通过角色方法参与到上下文的交互,通过本地方法访问和操作自身拥有的数据,然后采用某种形式将角色绑定到对象之上:
现实世界有很多这样的例子。一个人在上下文中会扮演一种特定的角色,他与别的角色展开不同的交互行为。这时,人作为数据对象,具备 talk()
、walk()
、write()
等本地行为,这些本地行为与角色无关,属于人的固有行为 。
当一个人处于课堂学习上下文时,若扮演了教师角色,就会拥有角色行为 teach()
,与之交互的角色为学生,角色行为是 learn()
。teach()
与 learn()
这样的角色方法由 talk()
、write()
等本地方法实现,本地方法不会随着上下文的变化而变化,因此属于数据对象最为稳定的领域逻辑。
一个数据对象可以同时承担多个角色,例如一个人既可以是教师,也可以是学生,回到家,面对不同的角色,他也在不断变换着角色:父亲、儿子、丈夫、…… 。显然,角色代表了一种身份或者一种能力,更像是一种接口行为。正如上图所示,当一个数据对象参与到上下文的交互中时,就需要将角色绑定到对象上,使得对象拥有角色行为。
3. 转账业务的 DCI 实现
以银行的转账业务为例,它的上下文就是 TransferingContext ,储蓄账户 SavingAccount 作为体现了领域概念的数据对象参与到转账上下文中。按照 DCI 的思维模式,我们需要对上下文中的数据提出两个问题:
它是什么?数据代表了上下文的领域概念;
它做了什么?角色代表了数据在上下文中的身份。
虽然转账上下文牵涉到两个不同的储蓄账户对象,但各自扮演的角色却不相同。一个账户扮演了转出方 TransferSource ,另一个账户扮演了转入方 TransferTarget ,对应的角色方法就是 transferOut()
与 transferIn()
。储蓄账户拥有余额数据,增加和减少余额值都是储蓄账户这个数据对象的固有特征,相当于针对余额数据进行的数学运算,对应的本地方法为 decrease()
与 increase()
。
很明显,通过『本地方法』,数据回答了『它是什么』这个问题,体现了数据的本质特征,这样的行为通常不会发生变化;角色方法回答了『它做了什么』这一问题,操作了数据的业务规则,因此可能会频繁发生改变。一个稳定不变,一个频繁变化,自然就需要隔离它们,这就是角色的价值。最后,由『上下文』来指定角色,并管理角色之间的交互行为。
转出方的角色接口:
public interface TransferSource {
// 本地方法到角色方法的映射
Amount getBalance();
void decrease(Amount amount);
// 角色方法
default void transferOut(Amount amount) {
if (getBalance().lessThan(amount)) {
throw new NotEnoughBalanceException();
}
decrease(amount);
}
}
同时,数据类 SavingAccount 还必须显式实现这两个接口:
public class SavingAccount implements TransferSource, TransferTarget {
...
}
上下文类的实现:
public class TransferContext {
private NotificationService notification;
public void transfer(TransferSource source, TranserTarget, Amount amount) {
source.transferOut(amount);
target.transferIn(amount);
notification.sendMessage();
}
}
一个数据对象可以同时承担多个角色,反过来,一个角色也可能被多个不同的数据对象扮演。还是以转账业务为例,可能不仅是 SavingAccount 才能参与转账,例如通过银行的储蓄账户将钱转入到支付宝,就是由 AlipayAccount 担任转入方角色。如果 AlipayAccount 与 SavingAccount 之间没有任何关系,根据前面的实现,就无法将其传递给 TransferTarget 角色接口;同理,将支付宝的钱转入到储蓄账户,也受到了数据类型的限制。
如果将角色方法的实现留给数据类来实现,角色接口仅提供抽象的定义,就可以为各种不同的数据类戴上“角色”这顶帽子。站在上下文的角度看,它仅关心参与交互的角色方法,而不在意数据对象到底是什么。例如,在课堂学习上下文中,可以是一个人担任教师的角色,以 teach() 角色行为与学生交互,但也可以是一个 AI 机器人担任教师角色,只要它的授课能够满足学生的需要即可。
显然,上下文从抽象角度看待参与交互的角色,这就将角色分成了抽象和实现两个层次。这两个层次在 DCI 模式中分别称为 Methodful Role 与 Methodless Role。Methodful Role 组成了数据类,数据类对象则通过 Methodless Role 对外提供服务,参与到上下文中。仍以转账上下文为例,Methodless Role 的定义如下:
public interface TransferSource {
void transferOut(Amount amount);
}
public interface TransferTarget {
void transferIn(Amount amount);
}
这样的角色接口没有任何实现,仅仅规定了角色参与上下文交互的契约。数据类由本地方法和角色方法共同组成,其中它实现的角色方法代表了它是 Methodful Role:
public class SavingAccount implements TransferSource, TransferTarget {
private Amount balance;
// Methodful Role 的角色方法
@Override
public void transferOut(Amount amount) {
if (balance.lessThan(amount))
throw new NotEnoughBalanceException();
decrease(amount);
}
// Methodful Role 的角色方法
@Override
public void transferIn(Amount amount) {
increase(amount);
}
// 本地方法
private void decrease(Amount amount) {
balance.substract(amount);
}
private void increase(Amount amount) {
balance.add(amount);
}
}
public class AlipayAccount implements TransferSource, TransferTarget {
}
Methodful Role 与 Methodless Role 的分离不会影响角色的定义,因为上下文的交互是面向角色的,与数据类无关,不受数据类类型变化的任何影响,故而 TransferContext 的实现与前面的代码完全一样。
我认为,DCI 模式将角色的承担者命名为数据类是一种糟糕的命名,因为数据这一说法极容易误导设计者,误以为该类仅仅为上下文提供交互行为所需的数据。
若产生这种误解,就有可能将数据类定义为贫血对象,设计出贫血模型。
实际上,数据类更像是实体,在定义了数据属性之外,还需要定义属于自己的方法,即本地方法。这些方法同样表达了领域逻辑,只是该领域逻辑是与数据类强内聚的行为,如 SavingAccount 的
increase()
与decrease()
方法。
DCI 模式与场景驱动设计
毫无疑问,DCI 模式通过数据类、数据对象、角色、角色交互和上下文等设计元素共同实现了现实世界到对象世界的映射。这种思维模式的起点仍然是领域场景,上下文相当于是搭建领域场景的舞台。在这个舞台上,进行的并非冷静而细化的过程分解,而是从角色出发,推断和指导参与领域场景的各个演员之间的互动。因此,我们也可以将 DCI 模式结合到场景驱动设计中。
对比场景驱动设计的角色构造型,DCI 模式的上下文相当于领域服务,数据类相当于聚合。在定义上下文时,DCI 模式通过观察不同角色之间的交互来满足领域场景的业务需求。角色方法的定义体现了面向对象“接口隔离原则”与“面向接口设计”的设计思想,而角色之间的交互模式又体现了对象之间良好的行为协作,这在一定程度上保证了领域设计模型的质量,满足可重用性与可扩展性。在上下文之上,是体现了业务价值的领域场景,仍然由应用服务来实现对外业务接口的包装,在内部的实现中,则糅合诸如事务、认证授权、系统日志等横切关注点。至于数据对象的获得,仍然交给资源库。不同之处在于资源库的注入由应用服务来完成,这是因为作为领域服务的上下文协调的是角色之间的交互,即领域服务依赖于角色,而非数据对象的 ID 。
结合 DCI 模式的场景驱动设计过程为:
- 识别领域场景,并由对应的应用服务承担
- 领域场景对应的业务行为由上下文领域服务执行
- 为了完成该领域场景,明确有哪些角色参与了行为的交互
- 为这些角色定义角色接口,角色方法实现为默认方法,或者分为抽象与实现
- 确定承担这些角色的数据对象,定义数据类以及数据类的本地方法
即使不遵循 DCI 模式,我们也应尽量遵循 “角色接口” 的设计思想。角色、职责、协作本身就是场景驱动设计分配职责过程的三要素。区别在于二者对角色的定义不同。场景驱动设计的角色构造型属于设计角度的角色定义,它来自于职责驱动设计对角色的分类,也参考了领域驱动设计的设计模式。不同的角色构造型承担不同的职责,但并不包含任何业务含义。DCI 模式的角色是直接参与领域场景的对象,如 Martin Fowler 对角色接口的阐述,他认为是从供应者与消费者之间协作的角度来定义的接口,代表了业务场景中与其他类型协作的角色。
在场景驱动设计过程中,当我们将职责分配给聚合时,可以借鉴 DCI 模式,从领域服务的角度去思考抽象的角色交互,引入的角色接口可以在重用性和扩展性方面改进领域设计模型。当然,这在一定程度上要考究面向对象的设计能力,没有足够的抽象与概括能力,可能难以识别出正确的角色。例如,在薪资管理系统的支付薪资场景中,该为计算薪资上下文引入什么样的角色呢?与转账上下文不同,计算薪资上下文并没有两个不同的角色参与交互,这时的角色就应该体现为 数据类在上下文中的能力 ,故而可以获得 PayrollCalculable 角色。数据类 Employee 只有实现了该角色接口,才有“能力”被上下文计算薪资。