如何檢驗單一責任原則? 不如反過來想!
開發好維護、擴充、閱讀的軟體不是件容易的事情,為了能開發出品質較好的軟體,通常會使用設計原來提供決策上的幫助,讓開發出的軟體較不容易走偏。
物件導向中有SOLID原則,而SOLID中個人認為最重要也最不好掌握的原則是:單一責任原則 (Single Responsibility Principle),後續簡稱SRP。
其定義如下:
SRP:軟體中的所有模組皆只能有一種改變的原因。
遵守SRP的優點如下:
- 修改影響的範圍有效限縮至少數模組 (isolate change into one place)。
- 針對不同客戶端解除耦合,不同客戶端不會互相影響。
為什麼我認為SRP是SOLID中最重要的原則?
答案:
- OCP:為了讓軟體能針對特定的變化達到開放的效果,我們需要策略性地找出抽象化,而抽象化本身就跟責任有關,良好的抽象化通常被指派單一責任。
- LSP:為了達到替換不同實作的子類別而不影響程式行為的效果,我們需要讓子類別滿足父類別的合約,當父類別的責任越多,代表合約也越多,會導致子類別難以滿足父類別合約。
- ISP:為了避免不同客戶端因為不相干的API耦合在一起,不能讓類別的責任過大,當類別的責任越大時,耦合的客戶端也會越多。
- DIP:高階模組與低階模組皆依賴於抽象化,而良好抽象化通常被指派單一責任,當類別負擔的責任越多,越難找出穩定的抽象化讓高階模組與低階模組依賴。
在開發初期,通常會找出適當的責任並分配給合適的物件,此時,SRP原則便是很好的工具,幫助我們適當的切割模組,避免類別過度複雜的問題。
然而,維護過legacy專案的朋友都知道,legacy專案對於系統如何切割其實沒有什麼規劃,所以容易產生以下現象:
- 相依性混亂:資料庫相依商業邏輯、商業邏輯相依使用者介面、使用者介面相依資料庫。
- 責任分配不適當:顯示夾雜商業邏輯、商業邏輯夾雜資料庫、無意義的間接層。
- 命名無法顯露意圖:因為責任分配不當的關係,導致難以命名,最終變數、函數、類別的命名皆無法有效表達意圖。
因為上述legacy專案的限制,想透過SRP的方式去重新把責任分配是件困難的事。
經過legacy專案的洗禮後,產生了一個想法:SRP教導我們如何decompose來降低系統之間的耦合,但很少原則告訴我們如何compose才能增加系統的聚合。
所以我們需要一項原則與SRP進行互補,此原則稱之為非碎片化責任原則 (Non-Fragmented Responsibility Principle),後續簡稱NFRP。
其定義如下:
NFRP:軟體中的所有模組不應該共享相同改變的原因。
遵守NFRP的優點如下:
- 降低系統中碎片化資訊,提高聚合力。
- 因為高聚合的緣故,一樣能限縮修改的模組 (isolate change into one place)。
當系統遵守SRP與NFRP時,能達到以下目標:
- 高聚合邊界:高聚合邊界能降低系統中碎片化的資訊,提高可讀性。
- 限縮修改範圍:高聚合邊界代表高度封裝,降低修改影響範圍。
- 高彈性設計:系統的架構會依賴於high level policy而不是find grained detail,find grained detail容易變化的特性會影響依賴於它的客戶端,high level policy更加穩定的特性能降低對客戶端的影響。
說了這麼多理論,接下來透過範例讓大家更了解這兩個原則如何一起運用。
實際範例
以下範例在legacy專案中很常見到:所有功能由一個類別負責。
static class ProcessUseCase {
public void process() {
processStageA();
// 20行完全不相干的程式
processStageB();
// 20行完全不相干的程式
processStageC();
}
private void processStageA() {
// ...
}
private void processStageB() {
// ...
}
private void processStageC() {
// ...
}
}
我們先說說上述的問題:
- stage有三個階段,每個階段之間有20行不相關的程式碼混雜再一起,造成資訊碎片化。
- processUseCase明顯負擔過多責任,沒有進行適當切割。
我們知道,為了避免processUseCase變成god class,應使用SRP來切割模組。
但記住,我們身處在legacy專案的context之中,我們不知道事情的全貌,那應該切割那些模組?
常見陷阱:針對目前procedure process進行切割。
由於不知道系統的全貌,很常針對系統目前的設計好的procedure process進行切割。
常見的做法:既然有三個process代表三個責任,那就切三個類別吧!SRP!
static class ProcessUseCase {
private final ProcessStageAService processStageAService;
private final ProcessStageBService processStageBService;
private final ProcessStageCService processStageCService; ProcessUseCase(ProcessStageAService processStageAService, ProcessStageBService processStageBService, ProcessStageCService processStageCService) {
this.processStageAService = processStageAService;
this.processStageBService = processStageBService;
this.processStageCService = processStageCService;
}
public void process() {
this.processStageAService.process();
// 20行
this.processStageBService.process();
// 20行
this.processStageCService.process();
}
}
static class ProcessStageAService {
public void process() {
}
}
static class ProcessStageBService {
public void process() {
}
}
static class ProcessStageCService {
public void process() {
}
}
修改後是否有比較好? 如果僅跟修改前相比確實比較好,但可惜的是,這種切割方式沒辦法有效降低系統複雜度,原因如下:
- 既有專案假設的procedure process不一定為真實、人類好理解的邊界 (暗示有其他可能性我們沒發現)。
- 依舊存在資訊碎片化的問題,沒辦法降低理解的複雜度。
- 增加類別希望換取複雜度降低的期望沒有被兌現。
治療手段:反過來想
既然難以用SRP進行正面思考,此時正是使用NFRP進行反面思考的好時機!
當使用SRP後,如果覺得系統一樣可讀性不佳,我們可以先以降低god class code smell這個目標進行粗略的分割 (如同上述範例的方式)。
接著等待需求的變更,假設新的需求促使stage階段的調整,我們能知道現在有三個模組,每個模組皆會因為stage的調整受到改變。
上述違反了NFRP:不同模組不應該共享同樣改變的原因,我們需要將這些模組merge起來。
那麼新的物件應取什麼名稱呢?就叫做stage吧!因為三個階段分別為processStageA、processStageB、 processStageC,而stage剛剛好比這些概念高了一個抽象層級。(不管是透過繼承將高階抽象與低階抽象分離,或是透過composition將低階抽象封裝在高階抽象,都是良好的設計作法。)
static class ProcessUseCase {
private final Stage stage;
ProcessUseCase(Stage stage) {
this.stage = stage;
}
public void process() {
stage.process();
// 20行
// 20行
}
}
static class Stage {
private final ProcessStageAService processStageAService;
private final ProcessStageBService processStageBService;
private final ProcessStageCService processStageCService;
Stage(ProcessStageAService processStageAService, ProcessStageBService processStageBService, ProcessStageCService processStageCService) {
this.processStageAService = processStageAService;
this.processStageBService = processStageBService;
this.processStageCService = processStageCService;
}
public void process() {
this.processStageAService.process();
this.processStageBService.process();
this.processStageCService.process();
}
}
修改後的優點如下:
- processUseCase複雜度降低、dependency也變少。
- 提高系統抽象化,我們找出stage這個概念,並將內部實作細節進行有效的封裝。
- stage不管怎麼修改,只會影響stage類別,而不會影響processUseCase。
你可能會想:processStageXService類別還是存在阿,怎麼說複雜度降低了呢?
答案:從processUseCase的角度來看,所有類別被抽象化成stage;從stage的角度來看,我們可以選擇將這些類別inline回stage或是選擇留下,都只是實作角度的取捨。
如果哪天不同階段需要變異,能針對processStageXService使用strategy design pattern,不僅增加系統彈性,也不會影響processUseCase。
以下是良好抽象化邊界stage帶來的優點:
- 只有stage類別知道strategy pattern的應用,strategy僅僅是stage抽象化邊界內部的實作細節而已。
- 其他模組只知道stage這個完整的概念,至於它的實作如何變化都不會受到影響。
- 對於讀者來說,相較於先前碎片化的processStageA、processStageB、processStageC,stage是一個完整的概念,更容易理解、閱讀。
上述的過程中能generalize出一個套路:
- 當看不清legacy專案中事物的全貌時,先透過SRP進行粗略的切割 (此時切割方式很大可能是錯誤的)。
- 等需求變動時,再透過NFRP分析那些元件需要進行合併,以找出良好抽象化邊界。
細心的讀者會發現,NFRP僅僅是cohension原則更為細緻的應用,為什麼要增加看似重複的概念來混淆呢?
個人觀點如下 (你不一定認同):
- cohesion為high level原則,對於how的著墨較少,需要靠經驗調整。
- NFRP為low level原則,對於how的著墨較多,對於初學者更好應用,不須過多經驗。
- cohesion、NFRP目標皆一致,找出適當邊界、降低修改影響、提高可讀性。
- 我們需要有不同抽象化層級的原則來提供不同觀點的指導,以高層級的原則為核心,往下延伸出中、低層級的原則。中、低層級能讓我們有效實踐,高層級幫助我們檢驗是否背離重要的大方向,高、中、低缺一不可。
結論
- SRP是非常重要的原則,透過適當的切割來降低系統複雜度。
- 維護legacy專案時,由於資訊碎片化的緣故,較不容易做好SRP。
- 維護legacy專案時,如果根據專案假設的procedure process進行切割很大可能會切錯導致更多問題。
- NFRP為SRP互補原則,透過適當的合併降低系統複雜度,讓系統抽象化變界更為完整、增加可讀性。
- 維護legacy遇到god class時,我們可先使用SRP進行粗略切割,等待需求變異時觀察變動資訊並搭配NFRP將碎片化的類別進行合併。
- 切割要能切割出完整的抽象化才有意義;合併要能合併出完整的抽象化才有意義。否則一個產生出過度fine grained的類別,一個產生出不好理解的類別。