可理解性: 為什麼幾十萬字的小說看一遍我們就可以理解, 而幾千行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, 以及概念的缺失帶來的理解上的困難. 但這不意味著要不加辨別的封裝所有的資料和行為, 把涉及到某片資料的行為都封裝在一起. 事實上我們缺乏仔細的分析而做了過多的封裝, 是時候把資料和不合適的行為拆開了, 拆的原則就是穩定性和使用情境
推論二, 類的方法只應該操作自己的資料, 方法參數只應該是基礎類型或自己的成員類型.
當你發現兩個類的對象有互動從而把互動放在任何一方都會違反上述原則的時候, 定義一個互動類,從而三個類又都滿足上面的原則