Practical Object-Oriented Design Book 心得&整理 (8)
不管DDD、TDD、Design Pattern、Refactoring都是大師們淬鍊出來的結晶,如果我們連原料都沒有的話要如何淬鍊出跟大師們一樣的結晶呢?話不多說就讓本篇文章承接上一回繼續介紹物件導向設計,讓大家對於原料 (物件)的使用有更深入的認知。這樣一來不管是DDD、TDD、Design Pattern、Refactoring都能更容易上手。
未來文章根據下方依序介紹:
- What is Object-Oriented Design
- Designing Classes with a Single Responsibility
- Managing Dependencies
- Creating Flexible Interfaces
- Reducing Costs with Duck Typing
- Acquiring Behavior through Inheritance
- Sharing Role Behavior with Module
- Combining Object with Composition (*)
- Designing Cost-Effective Tests
在前兩篇文章中,我們討論到了繼承和Module的使用方式。繼承適合在相關聯的類別 (Related Class)上使用、Module適合在扮演相同角色的不同類別 (Unrelated Class)上使用。
在有些語言中並沒有Module的概念,只有繼承的概念,但在之前文章中說過,繼承在彈性與維護性上有一定的限制,因此今天要來介紹除了繼承的另外一種選擇:Composition。
What is object composition ?
書中的定義如下:
Composition is the act of combining distinct parts into a complex whole such that the whole becomes more than the sum of its parts.
翻成白話文就是:Composition是一種手段,透過結合數個獨立的子元件來產生出組合元件,讓組合元件的整體功能遠遠大於所有獨立子元件的功能總和。發揮出1 + 1 + 1 + 1 > 10的效果。
像是音樂就是Composition的一種例子,每個音符都是獨立的子元件,彼此之間沒有關聯性,由多個音符混合出來的音樂就是複雜元件,不同組合方式也能產生出各式各樣的旋律,達到組合元件 (音樂)遠遠大於所有獨立子元件 (音符)的功能總和之效果。
物件導向中,我們一樣可以使用Composition的概念來將多個獨立的物件組合在一起來產生非常複雜的效果,不管是在框架或是設計模式中都能夠看到Composition的影子:
- Redux Store中的Middleware。
- Spring Security中的GenericFilterBean。
- Decorator Design Pattern。
- Composition Design Pattern。
Composition的好處如下:
- 各個物件皆遵守單一責任原則 (SRP)。
- 高度彈性,改變行為只需要改變物件的組裝方式。
以下透過實際範例來顯示出Composition的威力:
假設我們要讀取系統的所有的學生資料,需求如下:
- 讀取學生資料速度要快。
- 當使用者存取資料時需要Audit Log。
- 使用者需要有一定的權限才能存取資料。
上述的需求就是很常見的:Cache、Audit Log、Security。
如果用直覺的方式來實作,會產生以下程式碼:
public class GetAllStudentQueryHandler implements QueryHandler<GetAllStudentQuery, List<StudentDto>> {
private final UserContext userContext;
private final UserAuditLog userAuditLog;
private final StudentCache studentCache;
@PersistenceContext
private EntityManager entityManager;
@Autowired
GetAllStudentQueryHandler(
UserContext userContext,
UserAuditLog userAuditLog,
StudentCache studentCache) {
this.userContext = userContext;
this.userAuditLog = userAuditLog;
this.studentCache = studentCache;
}
@Override
public Result<List<StudentDto>> handle(GetAllStudentQuery getAllStudentQuery) {
if (!hasSecurity(userContext)) {
Result.failure(new Error("permission.denied", "User hasn't authenticate yet"));
}
List<StudentDto> cachedStudent = this.studentCache.getStudents();
if (!cachedStudent.isEmpty()) {
return Result.success(cachedStudent);
}
List<StudentDto> results = entityManager
.createQuery("Select new com.mars.hong.IShopping.shoppingCart.ShoppingCartControllerTest.StudentDto(s.id, s.name) from Student s", StudentDto.class)
.getResultList();
this.studentCache.save(results);
this.userAuditLog.record(GetAllStudentQuery.class, userContext);
return Result.success(results);
}
}
GetAllStudentQueryHandler類別處理了以下責任:
- 判斷使用者是否有足夠的權限。
- 判斷Cache中是否有學生資料。
- 使用EntityManager讀取學生資料。
- Audit log使用者的存取過程。
也就是說GetAllStudentQueryHandler負擔了權限管理、Cache檢查、跟資料庫存取資料、Audit log等責任,有以下缺點:
- 違反SRP,降低維護性,每個功能改變的頻率皆不同。
- 難以理解程式碼真正意圖 (主要邏輯為讀取資料庫資料,但被其餘功能掩蓋)。
- 難以Reuse,為了要使用GetAllStudentQueryHandler類別,客戶端需要提供全部的Dependency才能使用GetAllStudentQueryHandler類別。沒辦法自行客製化不同的功能。
- 難以針對各個功能進行單元測試,為了要測試權限、Cache、資料庫讀取、Audit Log,我們需要Mock其餘不相干的Dependency,導致Test Fixture Setup過度複雜。
- 如果要擴充新的功能需要繼續修改GetAllStudentQueryHandler類別,沒辦法達到OCP的效果。
為了解決上述的問題,我們可以使用Decorator Design Pattern,Decorator讓我們能動態增加不同的責任到特定物件,而不是讓所有相同類別的物件都獲得責任。而Decorator本身就是一種Object Composition概念的展現。
修改後的程式碼:
static class SecurityGetAllStudentQueryHandler implements QueryHandler<GetAllStudentQuery, List<StudentDto>> {
private final QueryHandler<GetAllStudentQuery, List<StudentDto>> queryHandler;
private final UserContext userContext;
@Autowired
public SecurityGetAllStudentQueryHandler(
QueryHandler<GetAllStudentQuery, List<StudentDto>> queryHandler,
UserContext userContext) {
this.queryHandler = queryHandler;
this.userContext = userContext;
}
@Override
public Result<List<StudentDto>> handle(GetAllStudentQuery getAllStudentQuery) {
if (!hasSecurity(userContext)) {
Result.failure(new Error("permission.denied", "User hasn't authenticate yet"));
}
return queryHandler.handle(getAllStudentQuery);
}
private boolean hasSecurity(UserContext userContext) {
// perform security checking ....
}
}
static class CacheGetAllStudentQueryHandler implements QueryHandler<GetAllStudentQuery, List<StudentDto>> {
private final QueryHandler<GetAllStudentQuery, List<StudentDto>> queryHandler;
private final StudentCache studentCache;
@Autowired
CacheGetAllStudentQueryHandler(QueryHandler<GetAllStudentQuery, List<StudentDto>> queryHandler, StudentCache studentCache) {
this.queryHandler = queryHandler;
this.studentCache = studentCache;
}
@Override
public Result<List<StudentDto>> handle(GetAllStudentQuery getAllStudentQuery) {
List<StudentDto> cachedStudent = this.studentCache.getStudents();
if (!cachedStudent.isEmpty()) {
return Result.success(cachedStudent);
}
Result<List<StudentDto>> results = this.queryHandler.handle(getAllStudentQuery);
if (results.isSuccess()) {
this.studentCache.save(results.get());
}
return results;
}
}
static class AuditLogGetAllStudentQueryHandler implements QueryHandler<GetAllStudentQuery, List<StudentDto>> {
private final QueryHandler<GetAllStudentQuery, List<StudentDto>> queryHandler;
private final UserAuditLog userAuditLog;
private final UserContext userContext;
@Autowired
AuditLogGetAllStudentQueryHandler(QueryHandler<GetAllStudentQuery, List<StudentDto>> queryHandler, UserAuditLog userAuditLog, UserContext userContext) {
this.queryHandler = queryHandler;
this.userAuditLog = userAuditLog;
this.userContext = userContext;
}
@Override
public Result<List<StudentDto>> handle(GetAllStudentQuery getAllStudentQuery) {
Result<List<StudentDto>> results = this.queryHandler.handle(getAllStudentQuery);
this.userAuditLog.record(GetAllStudentQuery.class, userContext);
return results;
}
}
static class GetAllStudentQueryHandler implements QueryHandler<GetAllStudentQuery, List<StudentDto>> {
@PersistenceContext
private EntityManager entityManager;
@Override
public Result<List<StudentDto>> handle(GetAllStudentQuery getAllStudentQuery) {
List<StudentDto> results = entityManager
.createQuery("Select new com.mars.hong.IShopping.shoppingCart.ShoppingCartControllerTest.StudentDto(s.id, s.name) from Student s", StudentDto.class)
.getResultList();
return Result.success(results);
}
}
static class CompositionRoot {
public static void main(String[] args) {
UserContext userContext = new SpringSecurityUserContextAdapter();
QueryHandler<GetAllStudentQuery, List<StudentDto>> handler =
new SecurityGetAllStudentQueryHandler(
new CacheGetAllStudentQueryHandler(
new AuditLogGetAllStudentQueryHandler(
new GetAllStudentQueryHandler(),
new UserAuditLog(),
userContext
),
new StudentCache()
),
userContext
);
handler.handle(...);
}
}
上述圖片為程式碼的Class Diagram,從中可以看到Decorator常見的手法:Decorator與被Decorate的物件共享同樣的介面,再透過Object Composition的方式來擴充新的功能。
組裝過程能在上述程式碼的CompositionRoot中看到,各個物件彼此之間互相獨立,不會互相影響,然而在組裝成複雜物件後能夠處理複雜的功能。
上述圖片為Decorator在動態時的物件圖,我們可以看到當客戶端傳遞訊息時,第一個接收者為SecurityGetAllStudentQueryHandler,當它負責完擴充的功能後,會傳遞訊息給CacheGetAllStudentQueryHandler,以此類推…。
使用Decorator後,改善了以下的缺點:
- 解決了原先違反單一責任原則的問題。
- 能夠從GetAllStudentQueryHandler中明確了解程式碼意圖。
- GetAllStudentQueryHandler能更好的在不同的Context底下被Reuse,因為客戶端可以根據自身的需求來調整Decorator的組裝方式,降低了物件所需的Context。
- 針對不同功能測試變的更為簡單,我們只需要提供一個Mock物件即可。
- 擴充新的功能只需要實作QueryHandler介面,並在完成擴充功能後Delegate給後續的QueryHandler即可,因此滿足OCP原則。
在我們了解了Composition帶來的好處後,接下來將針對繼承與Composition進行比較。
Inheritance vs Composition
接下來將針對以下幾點進行比較:
- 彈性
- 維護性
- 測試性
彈性
繼承:
- 由於綁定到特定的類別的特性,沒辦法有效在動態時切換不同的類別。
- 大多數的語言,每個類別只能使用一次繼承,沒辦法多重繼承。
- 讓類別強烈耦合到特定的結構,當兩個類別需要的結構不同時,沒辦法有效的解決此問題。
- 只能支援一種variation,沒辦法支援一個類別有多種variation的情況。
Composition:
- 透過物件組裝的方式,能夠在動態時切換不同物件。
- 使用物件組裝沒有次數上限至的問題。
- 由於只是物件與物件之間的association,搭配適當的抽象化能夠有效降低不必要的耦合。
- 要讓類別支援多種的variation,只需要注入不同variation的介面即可。
相信在看過上述的比較後,大家能清楚理解為什麼Composition彈性較好,也證實了GoF書中的經典原則:Favor Object Composition over Class Inheritance。
維護性:
繼承:
- 如果類別只需要一種variation,使用繼承是非常恰當的,能夠簡化類別的數量。
- 如果類別需要多種variation,使用繼承可能會導致類別數量爆炸的問題。
Composition:
- 如果類別只需要一種variation,使用Composition會導致類別數量增加,間接層增加,降低程式碼的聚合性。
- 如果類別需要多種variation,使用Composition能解決類別數量爆炸的問題。只要針對不同variation point提供stable interface,再注入到類別即可完成多種variation的需求。
看完上述比較後,從維護性的層面來看,繼承與Composition各有千秋,關鍵點就是:是否有多種variation points。
繼承在只有一種variation時能發揮最大的效用,而Composition則是在多種variation時能發揮最大的效用。
測試性
繼承:
- 如果類別只需要一種variation,使用繼承會增加元件可測試性,因為能讓Fixture Setup Code減少,因爲我們不需要組裝過多不必要的物件。
- 如果類別需要多種variation,使用繼承會降低元件可測試性,因為要測試variation point1需要隔離其他的variation point、測試variation point2需要隔離其他的variation point。會大幅度增加Fixture Setup Code的數量、測試類別過度肥大等問題,降低測試維護性。
Composition:
- 如果類別只需要一種variation,使用Composition會降低元件可測試性,因為過多的間接層會讓Fixture Setup Code增加,造成不必要的複雜度。
- 如果類別需要多種variation,使用Composition會增加元件可測試性。因為不同的variation point會有個別的介面,針對不同的variation point能達到更好的隔離效果,組合元件也能使用Mock的方式來降低測試的複雜度。
看完上述比較後,從測試性的層面來看,繼承與Composition各有千秋,關鍵點跟維護性一樣:是否有多種variation points。
多種variation points代表違反SRP,當一個類別違反SRP時,客戶端要使用此類別時,需要花非常大的心力來建立此類別需要的Context,同理測試也是客戶端的一種,會導致測試Fixture Setup非常複雜,測試類別肥大等問題。
透過Composition針對variation point執行extract class,能有效遵守SRP,解決測試Fixture Setup複雜、測試類別肥大等問題。
結論:
- Composition相較於繼承有更多的彈性。
- 透過觀察variation point來決定要使用Composition還是繼承。
- 繼承適合在類別只有一種variation point時使用。
- Composition適合在類別有多種variation point時使用。
- 當一個類別有多種variation point時,代表違反SRP,使用繼承沒辦法有效解決問題,應該使用Composition。