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

Z-xuan Hong
17 min readApr 18, 2021

--

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

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

上一篇文章提到了如何設計遵守Single Responsibility的物件,一個良好的物件不能負擔太多責任,因此讓物件之間互相溝通來達成更大的目標是不可避免的,為了溝通物件需要知道其他物件的存在,進而產生Dependency,當物件與物件之間的Dependency沒有管理好時,會慢慢的侵蝕整個系統….。

What is Dependency

不管是透過什麼形式,當物件A需要知道物件B的存在時,我們可以說物件A擁有Dependency物件B

物件A依賴物件B時,當物件B改動時,物件A有可能會受到影響,進而影響依賴物件A的物件,以此類推,這種效應稱之為Cascade Change,即便是微小的改動,也會影響到整個系統。

為了避免Cascade Change我們需要有系統的管理Dependency,為了有效管理Dependency我們需要先了解Dependency有哪些不同的形式。

Understanding Dependency

  • Functional Dependency:元件A使用元件B的功能,並且元件A不知道元件B功能的實作細節,只專注於Behavior,而不是Implementation Details。

程式碼範例:Customer發送TotalPrice訊息給Order,它不知道TotalPrice如何計算、不知道Order內部包含哪些物件,Customer只知道Order提供TotalPrice這個行為。

public class Customer {
List<Order> orders = new ArrayList<>();

double getTotalSpend() {
return orders.stream()
.map(Order::totalPrice)
.reduce(0.0, (a, b) -> a + b)
;
}
}
  • Content Dependency:物件A使用物件B的功能,物件A還知道物件B功能的實作細節,只專注透過現有的實作組出功能,而不是思考需要物件提供什麼行為

程式碼範例:Customer想要計算Order的totalPrice,但跟上面例子不同,Customer抓取Order內部結構自行計算,讓Customer與Order的耦合性上升。

public class Customer {
List<Order> orders = new ArrayList<>();

double getTotalSpend() {
return orders.stream()
.map(order -> order.getItem().getQty() * order.getItem().getProductPrice())
.reduce(0.0, (a, b) -> a + b)
;
}
}
  • Class Dependency:物件A使用物件B的功能,物件A清楚知道物件B是屬於哪個類別。

程式碼範例:AddCustomerService直接實例化JPACustomerRepository類別的物件,讓其沒辦法在不同Context被Reuse (Cloud、MySql、File)。

public class AddCustomerService {
private CustomerRepository customerRepository;

AddCustomerService() {
this.customerRepository = new JPACustomerRepository();
}
}
  • External Dependency:物件A使用外部物件的功能,例如:ORM套件、資料庫套件、Restful API套件等等。

程式碼範例:AddCustomerService直接耦合到JPA的EntityManager,跟上面例子一樣,讓其沒辦法在不同Context被Reuse (Cloud、MySql、File),並且與框架的耦合性更強烈

public class AddCustomerService {
@PersistenceContext
private EntityManager entityManager;

AddCustomerService() {

}
}

上述Dependency中,我們應該盡量追求Functional Dependency。

請花一分鐘思考為什麼。

日常生活中使用手機時,你不在乎手機內部結構,只要能夠使用APP、聽音樂、打電話即可,如果使用手機還需要了解內部結構的話,應該沒有人會想要用。

同理,使用物件時,應專注於物件能提供哪些功能給客戶端,至於功能怎麼實作不重要,Functional Dependency會讓物件與物件之間沒有實作細節的耦合,當修改時物件時不會影響其餘系統,進而避免Cascade Change,也達到物件導向目標Isolate Change into One Place。

Decouple Unnecessary Dependency

上述我們提到,除了Functional Dependency,其他Dependency都應該盡量避免,接下來針對這些Dependency提出Decouple的辦法。

  • Isolate Content Dependency through Move Method:

Content Dependency其實就是Feature Envy Code Smell,使用Move Method把行為放到擁有資料的物件即可降低彼此之間的耦合,遵守Tell, Don’t Ask,專注在行為,而不是實作細節。

// 修改前
public class Customer {
List<Order> orders = new ArrayList<>();

double getTotalSpend() {
return orders.stream()
.map(order -> order.getItem().getQty() * order.getItem().getProductPrice())
.reduce(0.0, (a, b) -> a + b)
;
}
}
// 修改後
public class Customer {
List<Order> orders = new ArrayList<>();

double getTotalSpend() {
return orders.stream()
.map(Order::totalPrice)
.reduce(0.0, (a, b) -> a + b)
;
}
}
  • Isolate Class Dependency through Dependency Injection:

Dependency Injection的概念很簡單,透過注入Dependency給物件,而不是讓物件產生它需要的Dependency來降低物件之間的耦合,修改後的程式碼直接注入CustomerRepository給AddCustomerService,讓其能夠接收不同實作的CustomerRepository (Cloud、File、MySql、Hibernate、JPA)、能夠在不同Context被Reuse。

// 修改前
public class AddCustomerService {
private CustomerRepository customerRepository;

AddCustomerService() {
this.customerRepository = new JPACustomerRepository();
}
}
// 修改後
public class AddCustomerService {
private CustomerRepository customerRepository;

AddCustomerService(CustomerRepository customerRepository) {
this.customerRepository = customerRepository;
}
}
  • Isolate Class Dependency through Factory Method

有時候礙於程式碼政治問題,我們沒辦法使用Dependency Injection來消除Class Dependency,這會讓我們難以測試,不過我們還可以使用Factory Method來Isolate Dependency,測試時候可以透過繼承來Mock掉多餘的Dependency,不過這種方法容易造成Test Fragile,建議盡量使用Dependency Injection來替換不同實作

// 修改前
public class AddCustomerService {
private CustomerRepository customerRepository;

AddCustomerService() {
this.customerRepository = new JPACustomerRepository();
}
}
// 修改後
public class AddCustomerService {
private CustomerRepository customerRepository;

AddCustomerService() {
}

CustomerRepository getCustomerRepository() {
return new JPACustomerRepository();
}
}
// 測試時...
class FakeAddCustomerService extends AddCustomerService {
private InMemoryCustomerRepository inMemoryCustomerRepository;

FakeAddCustomerService(InMemoryCustomerRepository inMemoryCustomerRepository) {

this.inMemoryCustomerRepository = inMemoryCustomerRepository;
}

@Override
CustomerRepository getCustomerRepository() {
return this.inMemoryCustomerRepository;
}
}

@Test
public void addCustomer() {
InMemoryCustomerRepository inMemoryCustomerRepository = new InMemoryCustomerRepository();
FakeAddCustomerService addCustomerService = new FakeAddCustomerService(inMemoryCustomerRepository);
// ...
}
  • Isolate External Dependency through Adapter

當依賴於框架、第三方套件時,應該先定義我們需要的介面 (Domain Specific Interface),再透過Adapter轉接External Dependency,Adapter能把External Dependency限縮在Adapter Class,降低External Dependency對於整個系統的影響。

// 修改前
public class AddCustomerService {
@PersistenceContext
private EntityManager entityManager;

AddCustomerService() {

}
}
// 修改後
public class AddCustomerService {
private CustomerRepository customerRepository;
AddCustomerService(CustomerRepository customerRepository) {
this.customerRepository = customerRepository
}
}

public interface CustomerRepository {
Result<CustomerId> addCustomer(Customer customer);
}

public class JPACustomerRepository implements CustomerRepository {
@PersistenceContext
private EntityManager entityManager;

@Override
public Result<CustomerId> addCustomer(Customer customer) {
// do something
}
}

Manage Dependency Direction

當我們學會分辨Dependency的種類,並且學會如何Decouple Dependency後,最後就是了解如何管理Dependency的方向。

如何管理Dependency的方向呢?很簡單:變動性較高的元件依賴變動性較為穩定的元件 (Dependent on Stable Component)。

這邊舉例子讓大家更容易理解:

  • MVC:UI依賴於Model,UI的變動性相較於Model更大,我們不希望UI的變動會影響Model。
  • Clean Architecture:Adapter Layer依賴UseCase Layer、UseCase Layer依賴Entity Layer,Adapter Layer用來轉接外部依賴變動性比UseCase Layer大,UseCase與外部依賴互動、與Entity溝通,變動性比Entity Layer大,Entity Layer是商業核心,我們不希望其他Layer影響Entity Layer。
  • Dependency Inversion Principle:高階模組不該依賴於低階模組、高階模組不能包含實作細節。高階模組是系統的核心,我們不希望低階模組的修改會影響系統核心。高階模組不能包含實作細節是因為,高階模組通常有許多客戶端使用,當實作細節變動進而影響高階模組時,會連帶影響其客戶端,造成Cascade Change。
  • Domain Driven Design:Core Domain不應該依賴於Database、External Dependency,理由與Clean Architecture一樣。

書中也提出了Dependent Matrix,讓我們能幫系統的模組進行分類。

Dependency Matrix

Area A:Dependent非常多、變動可能性很低,良好的Abstraction通常落於Area A,良好的Abstraction會被很多客戶端擴充,良好的Abstraction通常不會包含實作細節,因此變動可能性很低。

Area B:Dependent非常少、變動可能性很低,落於Area B的元間稱之為中立元件.通常會往Area A、C、D邁進。

Area C:Dependent非常少、變動性很高,UI元件、框架依賴通常落於Area C,這也是不要依賴框架、UI元件的根本原因,違反Dependency Direction的建議 (Dependent on Stable Component)。

Area D:Dependent非常多、變動性很高,God Object通常落於Area D,基本上違反Single Responsibility、Dependency Inversion,我們應該避免設計出Area D的物件,否則Cascade Change的副作用會讓系統難以維護。

Whether to Decomposition or not

當遇到複雜的問題時,為了降低系統複雜度,我們會把問題切割並讓各個物件負責,為了要讓這些物件互動會產生Dependency,因此這邊想要探討Decompose的目的。

Why Decomposition?

Decomposition目的其實跟物件導向系統一樣,Isolate Change into One Place,但Decomposition也會造成一些缺點,所以我們需要能夠了解它帶來的好處與壞處,並且在這些限制中做出適當的決定。

Advantage:

  • Isolate Change into One Place

如下圖:每個物件擁有各自責任,修改Wheel不會影響其他系統、修改Gear不會影響其他系統。

Good Decomposition

Disadvantage:

  • 物件數量上升
  • 多個物件耦合在一起像是Big ball of mud,修改其中一個物件影響其他物件。

如下圖:Wheel、A、B、C、D等與Gear有強烈耦合 (Content Dependency),當修改Gear時會影響Wheel、A、B、C、D等物件,沒辦法有效的Isolate Change,容易造成Cascade Change,喪失了Decomposition的目的。

Bad Decomposition

經過上述分析,在Decompose物件時,我們要確點Decompose帶來的好處 (Isolate Change)遠遠大於其帶來的壞處 (多個元件、維護成本上升)。

結論:

  • 物件與物件之間的Dependency,應該追求Functional Dependency
  • 使用Dependency Injection能夠消除Class Dependency
  • 使用Adapter能夠Isolate External Dependency
  • 使用Factory Method能夠Isolate Class Dependency
  • 變動性較高的元件依賴於變動性較低的元件
  • 確保Decomposition能夠帶來足夠的好處,否則先不要

--

--

Z-xuan Hong
Z-xuan Hong

Written by Z-xuan Hong

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

No responses yet