Web MVC 三層架構已盛行多年,因此在許多專案中經常可看到 VO, DTO 只有資料而沒有邏輯的物件,也稱為貧血模型(Anemic Domain Model)。然而這種設計並不符合物件導向的設計理念,因為它破壞了物件的封裝特性,使物件的內部資訊容易被外部濫用,提高與外部物件的耦合程度。Tell, Don’t Ask 軟體設計原則意旨在提醒開發者應盡量避免類似情形。
問題描述
舉例來說,專案中有個 Customer class 只存放顧客的基本資料,和一些 getter, setter,沒有任何邏輯:
public class Customer {
private int age;
private CreditCard creditCard;
private LocalDate registerDate;
// getter, setter...
}
若有一需求:
- 程式需判斷顧客是否有 VIP 的會員資格:年齡滿18歲、信用額度大於100萬、註冊滿一年的會員
- Service 根據會員資格,並做出相應業務邏輯。
因此 Service 需多次呼叫 Customer 的 getter 來取得資料,程式如下:
// In Service.java
public void doBusiness(Customer customer) {
if (customer.getAge() >= 18 &&
customer.getCreditCard().getLimit() >= 1_000_000 &&
DAYS.between(customer.getRegisterDate(), today()) >= 365) {
doForVip();
} else {
doForOther();
}
}
從這簡短的程式碼就可以發現幾個議題:
doBusiness
多次詢問 Customer,導致 Customer 本不應該被暴露的內部資料都洩漏出去,也造成了雙方緊密耦合。這是一種壞味道 – Feature Envy,意思是對另一個物件的興趣高於自己。- 若專案中其他地方需要使用相同的資料或邏輯時,同事可能會不小心再實作一遍,造成知識的重複 (DRY原則)。
- Service 和 Customer 內部的 CreditCard 發生耦合,違反了最小知識原則 (Law of Demeter)。
- 因此,當 Customer 的內部資料幾乎掌握在別人手上時,就容易被濫用、零散在各處,導致降低物件內聚力,提高與外部的耦合程度。
- 同上,當資料零散在各處,萬一需要修改時,不僅很難找到它們,也容易忘記某個重要的修改 (Shotgun Surgery,散彈式修改)。
- 以單元測試的角度來說,會需要建立許多測試資料或 mocked object,因此測試可讀性、維護性變得比較差。Service 的單元測試如下:
// ServiceTest.java
@Test
public void do_for_vip() {
CreditCard creditCard = new CreditCard().setLimit(5_000_000);
Customer customer = new Customer()
.setAge(20)
.setCreditCard(creditCard)
.setRegisterDate(LocalDate.of(2010, 1, 1));
service.doBusiness(customer);
verify(service).doForVip();
}
@Test
public void do_for_other() {
CreditCard creditCard = new CreditCard().setLimit(5_000_000);
Customer customer = new Customer()
.setAge(17)
.setCreditCard(creditCard)
.setRegisterDate(LocalDate.of(2010, 1, 1));
service.doBusiness(customer);
verify(service).doForOther();
}
- 測試中暴露過多 Customer 的內部細節,導致每個 Customer setter 都可能影響 Service 的測試結果。所以在 Service 的單元測試中,開發者可能會寫出較敏感的 test case。這種現象表明程式碼需要被重構。
應用 Tell, Don’t Ask 原則
Tell, Don’t Ask 原則提醒開發者:與其一直詢問物件的內部資料然後執行運算,還不如直接命令它執行任務。
因此 Service 應該直接命令 Customer 做事,做完後回傳結果即可,也不必了解 Customer 內部具體是怎麼實作的。此時可用 Move Method 的重構手法,將這段邏輯搬進 Customer 之中,讓它處在對的地方,萬一日後有異動發生時,可能會需要同時被修改的程式碼都放在一起,這也是一種讓物件更具備內聚力的手法。
public class Customer {
private int age;
private CreditCard creditCard;
private LocalDate registerDate;
// Move from Service class
public boolean isVip() {
return this.age >= 18 &&
this.creditCard.getLimit() >= 1_000_000 &&
DAYS.between(this.registerDate, today()) >= 365;
}
// setter...
}
經過重構後,Service 不再耦合判斷 vip 實作細節,而是直接呼叫 customer.isVip()
。對比重構前的例子,這樣不只降低物件之間的耦合性,也提高 Customer 的內聚力和資訊隱藏的封裝性。
// Service.java
public void doBusiness(Customer customer) {
if (customer.isVip()) {
doForVip();
} else {
doForOther();
}
}
ServiceTest
撰寫單元測試時,我們不必再準備各種 Customer 資料來測試不同行為,現在只要控制 mocked customer 就很容易 verify,測試意圖變得更明確:
// ServiceTest.java
@Test
public void do_for_vip() {
Customer customer = mock(Customer.class);
when(customer.isVip()).thenReturn(true);
service.doBusiness(customer);
verify(service).doForVip();
}
@Test
public void do_for_other() {
Customer customer = mock(Customer.class);
when(customer.isVip()).thenReturn(false);
service.doBusiness(customer);
verify(service).doForOther();
}
CustomerTest
同場加映,重構後當然也可以很容易的測試 Customer 內部的 isVip()
邏輯,寫出更多不同的 test case:
// CustomerTest.java
@Test
public void customer_age_under_18_is_not_vip() {
Customer customer = new Customer().setAge(17);
assertFalse(customer.isVip());
}
@Test
public void customer_credit_limit_under_1M_is_not_vip() {
CreditCard card = new CreditCard().setLimit(999_999);
Customer customer = new Customer()
.setAge(18)
.setCreditCard(card);
assertFalse(customer.isVip());
}
@Test
public void customer_register_less_than_1_year_is_not_vip() {
CreditCard card = new CreditCard().setLimit(1_000_000);
Customer customer = new Customer()
.setAge(18)
.setCreditCard(card)
.setRegisterDate(LocalDate.of(2021, 11, 9));
givenTodayIs(2022, 11, 8);
assertFalse(customer.isVip());
}
// a lot of test cases...
結論
封裝,是物件導向設計的重要特性之一,Tell, Don’t Ask 原則建議我們盡量不要破壞封裝,應該直接命令物件去完成任務,而不是暴露物件內部資訊。若是能妥善運用此原則,盡量將對資料的操作集中在一個地方,而不是隨意貫穿整個專案,就能更容易設計出好理解、好測試、高內聚、有組織性的程式。
當然,此原則並不是鐵律,VO,DTO 仍然有其存在的價值,因此開發者需要衡量與評估專案的情況,再決定出最適合的作法。