Practical Object-Oriented Design Book 心得&整理 (3)
不管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
上一篇文章提到了如何設計遵守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,讓我們能幫系統的模組進行分類。
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不會影響其他系統。
Disadvantage:
- 物件數量上升
- 多個物件耦合在一起像是Big ball of mud,修改其中一個物件影響其他物件。
如下圖:Wheel、A、B、C、D等與Gear有強烈耦合 (Content Dependency),當修改Gear時會影響Wheel、A、B、C、D等物件,沒辦法有效的Isolate Change,容易造成Cascade Change,喪失了Decomposition的目的。
經過上述分析,在Decompose物件時,我們要確點Decompose帶來的好處 (Isolate Change)遠遠大於其帶來的壞處 (多個元件、維護成本上升)。
結論:
- 物件與物件之間的Dependency,應該追求Functional Dependency
- 使用Dependency Injection能夠消除Class Dependency
- 使用Adapter能夠Isolate External Dependency
- 使用Factory Method能夠Isolate Class Dependency
- 變動性較高的元件依賴於變動性較低的元件
- 確保Decomposition能夠帶來足夠的好處,否則先不要