Practical Object-Oriented Design Book 心得&整理 (8)

Z-xuan Hong
19 min readJul 25, 2021

--

不管DDD、TDD、Design Pattern、Refactoring都是大師們淬鍊出來的結晶,如果我們連原料都沒有的話要如何淬鍊出跟大師們一樣的結晶呢?話不多說就讓本篇文章承接上一回繼續介紹物件導向設計,讓大家對於原料 (物件)的使用有更深入的認知。這樣一來不管是DDD、TDD、Design Pattern、Refactoring都能更容易上手。

未來文章根據下方依序介紹:

在前兩篇文章中,我們討論到了繼承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);
}
}
Class Diagram

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 PatternDecorator讓我們能動態增加不同的責任到特定物件,而不是讓所有相同類別的物件都獲得責任。而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(...);
}
}
使用Decorator Pattern後的Class Diagram

上述圖片為程式碼的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。

--

--

Z-xuan Hong
Z-xuan Hong

Written by Z-xuan Hong

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

No responses yet