近期花了一些時間研讀 Robert C. Martin 所著的 Clean Architecture 這本書,剛好看到了一些概念恰巧可以與工作上遇到的架構做一些印證,於是便想寫一些文章做一些紀錄。
在 Clean Architecture 中,提到了軟體元件的內聚與耦合的概念。
何謂內聚(Cohesion)與耦合(Coupling)呢?
在軟體工程(Software Engineering)中,內聚(Cohesion)與耦合(Coupling) 是軟體工程中,用來衡量元件的度量,我們可以用其來衡量模組與其他模組間的關連性或相依性。
內聚力(Cohesion) 顧名思義是指把相關的東西集合在一起,因此,模組本身不需依賴其他模組,就能完成工作。
當模組的內聚力越高,表示模組包含的物件或功能就越多。雖然提高了模組本身的獨立性,減少跟其他模組的耦合性,但也可能造成重覆程式碼,或違背單一職責原則(SRP)的情況發生。
至於耦合力(Coupling)則是該模組與其他模組或元件的相依程度的大小,耦合度越高,代表模組與其他元件有著越大的相依性。
在高耦合的情況下,很容易發生這種情況,你明明只是加了一個很小的需求,但是卻連帶影響到跟它有相依關係的部份,造成修改後,很多地方都出錯,反而需要要花額外時間去修正被影響的程式碼,「牽一髮動全身」,這句俗諺就很適合用來形容這種情形。
由上述敘述我們可以知道,高耦合將伴隨著低內聚,而高內聚則伴隨著低耦合,在軟體開發的世界中,高內聚的系統比高耦合的系統還要來得容易維護。
到這裡,我們來頭看看在元件內聚性,則必須提到以下述三個原則,將是用來衡量元件內聚性的實際標準。
再使用性——發布等價原則 (REP)
再使用性等價原則其實相當好了解,除了「再使用」代表著我們應該要重複利用那些經常被使用到的模組或元件,或是要把相同類型的程式碼歸類在同一個元件中,被歸類在同一個元件中的類別和模組應該要一起被發佈(Release)並伴隨著對應的版號(Version number)。
同時間發佈同元件的模組和程式碼也代表著每次Release都是可追蹤的,把元件依版本號也有一個好處,我們可以透過模組管理工具來管理這些可以重複利用的元件,每次版號的更新耶可以看到對應的更改,常見的模組管理工具,例如 Java的Maven 及 Gradle、Node.js 的 npm、Python的 pip 等等。
共同封閉原則 (CCP)
共同封閉原則的意義在於,要將那些會因為相同理由、在相同時間發生變化的類別收集在相同的元件中,而將那些在不同時間、會因為不同理由產生變化的類別分割到不同元件之中。
實際上的意義其實類似於單一職責原則,每一個類別、元件、方法都應該只有一種理由需要改變(one reason to change),這麼做將可以避免不必要的重新部署、更改與重新驗證。
格蘭特這邊可以舉一個簡單的例子:
以下是一個員工 Employee 類別。
Class Employee {
public int calculateMonthlySalary() {...}
public int generateMonthlyHoursReport() {...}
public void saveEmployee() {...}
}
Employee 裡面有三個功能,計算每月薪水、產生每月工時報告、儲存Employee資料,那我們來看看有甚麼理由會需要更改這個類別呢?
1.會計部想改變時薪的計算方式->需要更改calculateMonthlySalary() 2.人資想改變加班計算方式->需要更改generateMonthlyHoursReport()要改 3.工程師想改變Employee的編碼方式->需要更改saveEmployee()
當今天如果人資想改變加班的計算方式,從原本的 8 小時之後算加班改成上滿 10 小時後才算加班,這個時候你可能會想,只要簡單改一改 generateMonthlyHoursReport() 裡的邏輯就好了。
不過,當改完後發現,哇咧,由於沒有區分好職責的關係,原本計算薪資的 calculateMonthlySalary()方法中呼叫了generateMonthlyHoursReport()方法,導致最後每一個員工的每月薪資都被超時計算了。
是不是很恐怖呢? 由以上的例子我們可以知道 CCP 的重要性,不同類型、會在不同時間觸發更改的方法應該獨立成不同的元件。
如果我們 Refactor 原本的類別成以下的樣貌,將可以避免上面的狀況﹔
class Employee {
private String id;
public String getId() {
return id;
}
}
class PaymentService {
public int calculateMonthlySalary(Employee employee) {...}
}
class WorkHoursServiceService{
public HoursReport generateMonthlyHoursReport(Employee employee) {...}
}
class EmployeeDAO {
public void saveEmployee(Employee employee) {...}
}
共同重複使用原則 (CRP)
共同重複使用原則的意義在於「不要強迫元件的使用者依賴他們不需要的東西」。
試想,當今天你的元件依賴了一堆根本不需要的東西時,只要A發生改變,B就要跟著改,有用到A與B的元件也會需要跟著改變,而這些更改將伴隨著重新編譯、測試以及部署,這將會是一場浩劫啊。
因此,當我們依賴著某一個元件時,要確保我們依賴於該元件中的所有類別,意即該元件中的所有類別都是不可分割的。
元件內聚張力圖
不過.....我們有可能三種原則都遵守嗎?
答案是,不可能的,這三種原則將彼此作用,更像是一種Trade-Off(取捨)。
REP 和 CCP 是包容性原則 (Inclusive) 這兩個原則傾向於讓元件變更大,因為要內聚更多相同類別與方法。
而 CRP 是一個排除性原則 (Exclusive) 這個原則傾向讓元件變更小,要讓元件成為最小且不可分割的單位。
因此,如果太過注重 REP 和 CCP,架構就會需要太多沒必要的release,反之如果太過注重 CCP 和 CRP,那麼你的架構就會很難重複使用,而如果太過注重 CRP 和 REP,那麼每次更改都會動到很多元件。
就如同格蘭特工作上同時要接觸 Legacy code 與 設計良好的 code,如果在元件中內聚太多類別及方法,反而會造成一堆 release,而每次 release 都要花費對應的 Effort 去處理,但如果拆分過多元件的話,又會造成其之中的方法難以重複被利用,也因此,如果不好好考慮可維護性的話,legacy code就會變得越來越龐大。
所以,在衡量及設計實際的系統時,我們更應該需要審慎評估每個系統的需求,找出一個最符合的平衡點,就好像沒有100%的男人/女人,只有彼此磨合,找到雙方都可以接受的那種方法及樣貌。
今天有關元件內聚就先提到這裡,下一篇要來談的是元件耦合。