可理解性: 为什么几十万字的小说看一遍我们就可以理解, 而几千行code却要一读再读?
--Objects are principally about people and their mental models, not polymorphism, coupling and cohesion
代码难以理解是软件行业的痼疾. 众多方法和方法论致力于解决这个问题, 不管主观还是客观. 造成理解困难的原因有很多, 我们今天讨论其中一种: 业务流程被分解在代码中, 支离破碎.
而这个原因的引申问题就是: 业务流程在代码中如何组织? 对于这个问题, 争论从未停止:
Transaction Script vs. Domain Model
贫血模型与充血模型之争
Service存废的争论
造成争论的原因是本质的: OO长于刻画structure, 拙于捕捉behavior. OO在把世界分为多个对象的时候, 把行为也分散了, 我要理解一次交互, 需要在不同的对象的不同方法中跳来跳去. 空间不连续. 有时还用回调,异步等, 时间也不连续了.
尝试
让我们试着跳出软件的范围, 尝试在更广泛的范围内寻找思路, 比如为什么小说和电影有复杂的人物关系和情节, 我们却能轻易理解? 是否跟人理解事物时的Mental Model
有关? 如果我们能找出人类理解事物的Mental Model, 据此来编写符合Mental Model的代码是否会提高可理解性? 沿着这个思路走下去, 我们就得到了一种尝试性解决方案: 把世界分解为Data, Context 和 Interaction, 简称DCI
让我们试着从头推导一下.
第一个问题: 当我们错过了开头, 从中间开始看一部电影的时候, 画面上有一个人正在做一件事, 我们会如何入手, 会问什么问题呢?
他是谁?
他是做什么的?
他正在做什么?
这就是我们理解电影剧本或小说的Mental Model: 人物, 角色身份, 然后就是一幕接一幕的场景. 举个例子来说, 电影<<盗梦空间Inception>>中的盗梦团队如下:
The Extractor(盗梦人)
The Architect(筑梦师)
The Point Man(侦察兵)
The Forger(伪造者)
The Tourist(旅客)
The Chemist(药剂师)
盗梦最关键的一步是要在合适的时机穿越回上一层或现实, 电影中叫Kick. 那么 Kick() 这个操作放在哪? 每个人都可以Kick. 这时我们就会想起一个叫做梦主(Dreamer)的角色(Role), Kick应该是Dreamer的操作, 而任何一个人在特定的场景下都可以扮演Dreamer.
class Dreamer {
void Kick();
}
Data
再来看一个稍微贴近软件开发的例子: 转账.
假设储蓄账户的领域模型是一个叫做SavingAccount的class, 它封装了账户余额等属性. 对于如何用它来支持转账操作, 比如取款和存款, 我们至少有两种选择: 我们是仅仅用它来封装简单的余额加减操作, 还是把整个转账流程封装在里面? 也即下面的代码中, Decrease 和 Withdraw 要二选一.
class SavingAccount
{
private Amount balance;
void Decrease(Amount amount) {...}
void Withdraw(Amount amount) {...}
}
从涉及的业务范围, 需要的知识和依赖来看:
Decrease这个操作, 只涉及到Amount, 所需知识无非是数学上的加减运算
而Withdraw, 远远不只是把余额减去多少, 还涉及到事务语义, 用户交互, 恢复, 错误处理, 系统日志以及业务规则, 比如支取额度等. SavingAccount这个类没有能力完成所有的操作
储蓄账户是一个相对稳定的业务概念, 那么简单的Decrease和复杂的Withdraw哪个更能匹配SavingAccount的稳定性呢?
Decrease是非常稳定的, 它涉及的领域概念无非就是数学上的加减运算
而Withdraw发生变化的可能性就大的多, 无论是基础设施的错误处理发生变化, 还是支取额度等规则的变化, 都会导致取款发生变化.
数据模型是相对稳定的, 因此在这里, 我们选择用SavingAccount来表达数据模型, 里面只有Decease等简单的操作数据的方法.
class SavingAccount
{
private Amount balance;
void Decrease(Amount amount) { balance -= amount; }
void Increase(Amount amount) { balance += amount; }
}
Role + Interaction
那么问题来了, 真正的转账操作 Transfer() 放在哪? DCI对此的答案是显式建模, Interaction
交互就自然涉及到Role, 事实上角色是由具体的交互定义的. 如果你不去教课,那么Teacher这个title没有任何意义. 如果你不去跟客户交流, 那么BA这个Role也没任何意义. 换句话说, 只要你在做业务分析,需求分析,此时此刻你就是BA.
那么Transfer涉及到什么角色? Source Account and Destination Account.
Transfer(SourceAccount source, DestinationAccount destination, Amount amount)
{
source.Decrease(amount);
destination.Increase(amount);
...
}
Context
最后一个问题: 谁来指定谁扮演什么角色? DCI的答案是Context
class TransferContext {
void Transfer(SavingAccount source, SavingAccount destination, Amount amount)
{
var sourceAccount = Cast<SourceAccount>(source);
var destinationAccount = Cast<DestinationAccount>(destination);
Transfer(sourceAccount, destinationAccount, amount); // new TransferInteraction(xxx).Transfer();
}
}
DCI
Data: What the system is. (static, structure)
Role + Interaction: What the system does. (dynamic, behavior)
Context: Mapping the data to role, trigger the interaction. (the director)
推论
推论一, 拆! 把行为拆出去.
什么? OO难道不是要封装数据和行为吗? 让我们考一下古. 最初OO说要封装数据和行为. 所解决的问题是对数据访问无法全面控制而导致的隐藏的Bug, 以及概念的缺失带来的理解上的困难. 但这不意味着要不加辨别的封装所有的数据和行为, 把涉及到某片数据的行为都封装在一起. 事实上我们缺乏仔细的分析而做了过多的封装, 是时候把数据和不合适的行为拆开了, 拆的原则就是稳定性和使用场景
推论二, 类的方法只应该操作自己的数据, 方法参数只应该是基础类型或自己的成员类型.
当你发现两个类的对象有交互从而把交互放在任何一方都会违反上述原则的时候, 定义一个交互类,从而三个类又都满足上面的原则