如何檢驗單一責任原則? 不如反過來想!

Z-xuan Hong
11 min readFeb 2, 2022

--

開發好維護、擴充、閱讀的軟體不是件容易的事情,為了能開發出品質較好的軟體,通常會使用設計原來提供決策上的幫助,讓開發出的軟體較不容易走偏。

物件導向中有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的類別,一個產生出不好理解的類別。

--

--

Z-xuan Hong
Z-xuan Hong

Written by Z-xuan Hong

熱愛軟體開發、物件導向、自動化測試、框架設計,擅長透過軟工幫助企業達到成本最小化、價值最大化。單元測試企業內訓、導入請私訊信箱:t107598042@ntut.org.tw。

No responses yet