物件導向原則知識專家 (Information expert) — 分分合合之到底要分還是合?
本篇文章架構與先前不一樣 (沒有明確的大綱與章節),而是先介紹情境,從情境中發現問題與要討論的主題,在進行分析。
主要目標如下:
- 了解Information expert。
- 了解設定檔讀取中不同設計的取捨。
- 了解何謂介面隔離。
- 了解資料結構與物件的基本差異。
- 了解何時遵守該Information expert何時不該遵守。
- 了解active record與domain model背後的差異。
在開發應用程式時,我們常常需要使用設定檔 (configuration)的方式來外部化特定系統參數,達到修改系統行為時,只需要修改設定檔而不用修改內部程式的效果。
這樣也有助於系統不同階段的測試,因為系統在不同環境測試時,只需要讀取不同環境的設定檔案即可,像是:不同的資料庫、不同的實作、不同的效能參數等等。
在實作設定檔案的功能時,我們需要將特定路徑的設定檔案讀取出來,系統會透過設定檔的資料進行判斷來決定要使用什麼樣的實作、資料庫。
通常我們會建立一個類別 (class)來封裝設定檔案的資訊,假設我們針對資料庫設定檔建立一個類別叫做:DatabaseConfig,那麼問題來了:讀取設定檔案的責任誰要負責呢?DatabaseConfig嗎?還是另有其人?
這問題牽扯到的是物件導向的基本原則:知識專家 (Information expert),其概念很簡單:
將責任分配到擁有實作此責任所需資料的物件身上。
為什麼?答案很簡單:Isolate change into one place。
為什麼要Isolate change into one place?答案也很簡單:當修改影響的範圍降低,修改的成本就會降低,專案的成本就會降低。
假設DatabaseConfig實作如下:
public static class DatabaseConfig {
private String url;
private String userName;
private String password;
// ...
}
從上述範例我們看到了:DatabaseConfig封裝了url、userName、password三個資料庫連線常見的參數。
為了滿足Information expert,我們將讀取設定檔案的責任指派到DatabaseConfig身上。
public static class DatabaseConfig {
private String url;
private String userName;
private String password;
DatabaseConfig() {
// load url, userName, password from config file
Map<String, String> fileData = ...
this.url = fileData.get("url");
this.userName = fileData.get("userName");
this.password = fileData.get("password");
}
}
從上述範例我們看到了:DatabaseConfig類別實作了讀取外部設定檔案的責任,滿足了Information expert的要求。
假設有個物件A,他需要使用到DatabaseConfig,當我們要針對物件A進行單元測試時 (先不管單元測試合不合理),會發生什麼事情呢?
範例程式碼如下:
private static class A {
private final DatabaseConfig databaseConfig;
private A(DatabaseConfig databaseConfig) {
this.databaseConfig = databaseConfig;
}
}
@Test
void perform_service_of_object_a() {
DatabaseConfig databaseConfig = new DatabaseConfig();
A a = new A(databaseConfig);
a.doSomething();
// assertion the state of object a
}
上述範例測試會有下列問題:
- 測試環境不一定有DatabaseConfig物件所需的設定檔 (ex: application.xml)。
- 雖然測試只關心物件A的責任,我們缺需要建立DatabaseConfig物件所需的環境 (準備設定檔)。
- 測試撰寫變得麻煩,從單元測試升級到整合測試 (耦合檔案系統)。
遇到這種問題時,辦法如下:
- 針對DatabaseConfig設計一個介面來跟外部系統解耦合,並將原本的DatabaseConfig物件命名為DatabaseConfigImp。
- 物件A使用DatabaseConfig介面。
- 測試時,新增一個Fake物件實作DatabaseConfig介面來隔離對於檔案系統的耦合。
修改後如下:
private interface DatabaseConfig {
String getUrl();
String getUserName();
String getPassword();
}
public static class DatabaseConfigImp implements DatabaseConfig {
private String url;
private String userName;
private String password;
DatabaseConfigImp() {
// load url, userName, password from config file
Map<String, String> fileData = ...
this.url = fileData.get("url");
this.userName = fileData.get("userName");
this.password = fileData.get("password");
}
@Override
public String getUrl() {
return this.url;
}
@Override
public String getUserName() {
return this.userName;
}
@Override
public String getPassword() {
return this.password;
}
}
private static class A {
private final DatabaseConfig databaseConfig;
private A(DatabaseConfig databaseConfig) {
this.databaseConfig = databaseConfig;
}
}
@Test
void perform_service_of_object_a() {
DatabaseConfig databaseConfig = new Data();
A a = new A(databaseConfig);
a.doSomething();
// assertion the state of object a
}
private static class FakeDatabaseConfig implements DatabaseConfig {
@Override
public String getUrl() {
return "fakeUrl";
}
@Override
public String getUserName() {
return "fakeUserName";
}
@Override
public String getPassword() {
return "fakePassword";
}
}
上述範例解決了原本測試與外部系統耦合的問題,還算是不錯的解決辦法,不是嗎?
答案:剛學到透過介面隔離外部系統時,我確實認為這是不錯的解決辦法,但其實還有更好的方式能解決上述問題,況且透過介面隔離也有其隱藏的問題。
我提出的另外一個解決辦法為:將讀取外部設定檔案的責任與設定檔案物件分離,將設定檔案物件當成純資料類別來使用。
修改後如下:
public static class DatabaseConfig {
private final String url;
private final String userName;
private final String password;
DatabaseConfig(String url, String userName, String password) {
this.url = url;
this.userName = userName;
this.password = password;
}
public String getUrl() {
return this.url;
}
public String getUserName() {
return this.userName;
}
public String getPassword() {
return this.password;
}
}
private static class A {
private final DatabaseConfig databaseConfig;
private A(DatabaseConfig databaseConfig) {
this.databaseConfig = databaseConfig;
}
}
@Test
void perform_service_of_object_a() {
DatabaseConfig databaseConfig = new DatabaseConfig("fakeUrl", "fakeUseraName", "fakePassword");
A a = new A(databaseConfig);
a.doSomething();
// assertion the state of object a
}
從上述範例我們能看幾件事情:
- 因為DatabaseConfig物件變成純資料類別的關係,跟外部系統沒有任何耦合。
- 因為DatabaseConfig與外部系統沒有耦合,讓使用它的物件A也與外部系統沒有耦合。
- 因為與外部系統沒有耦合,測試不需要在建立Fake物件,只需要自己建立測試用的instance即可,讓撰寫測試更加容易。
為了讓讀者能更理解接下來要探討的問題,我將透過介面隔離外部系統稱之為介面隔離 (Interface isolation),我提出的另外一個辦法稱統稱設定檔資料類別 (Config data class)。
介面隔離概念
為什麼需要介面隔離呢?因為我們需要隔離外部系統。
為什麼需要隔離外部系統呢?因為從物件導向的角度,設定檔物件需要負責載入外部系統的責任。
為什麼呢?因為物件就像人一樣,有自己的生命、對自己負責的事有強烈的控制權。
當我們拜託某人做某件事情時,我們應該信任他能夠完成指派的任務,不應該限制他如何做、或是控制他如何做,這是對於人基本的尊重。
對於物件也是一樣,當一個物件被指派任務時,客戶端也應該尊重提供服務的物件,不應該限制他如何做、或是控制他如何做,這是對物件基本的尊重。
所以從物件導向的角度,設定檔案物件要能自己載入自己、知道載入哪個設定檔案。
但也因為物件自己載入自己的緣故,在測試此物件或是用到此物件的物件時,會導致測試耦合到外部環境,會造成context propogation。
如下圖所示:
從圖中我們看到:
- DatabaseConfig自己載入需要的設定資料,耦合了外部環境。
- 物件A使用到DatabaseConfig,因此DatabaseConfig的context (耦合外部環境)也會擴散到物件A。
- 測試使用到物件A,因此物件A的context (耦合外部環境)也會擴散到測試。
所以我們才需要使用介面隔離的技巧,防止DatabaseConfig物件的context擴散到使用到他的客戶端。
如下圖所示:
在了解物件與介面隔離的關係後,接下來要介紹的是設定檔資料類別的概念…
設定檔資料類別
為什麼要使用設定檔資料類別呢?因為資料結構與外部系統沒有任何耦合。
為什麼要與外部系統沒有任何耦合?因為當物件或是資料結構與外部系統沒有耦合時,測試會變的異常簡單,撰寫也變得容易,不需要多餘的介面。
與物件相反,資料結構就只是資料結構,他沒有生命、沒有控制權、他需要別人的幫助。
假設我們把DatabaseConfig當成資料結構,那我們就必須有個DatabaeConfigLoader物件負責載入設定檔案的責任,程式碼如下:
@Configuration
public static class JavaConfig {
@Bean
public DatabaseConfig databaseConfig() {
return new DatabaseConfigLoader().load();
}
}
public static class DatabaseConfigLoader {
public DatabaseConfig load() {
// load url, userName, password from config file
// Map<String, String> fileData = ...
return new DatabaseConfig(
fileData.get("url"),
fileData.get("userName"),
fileData.get("password")
);
}
}
從上述範例程式我們得知:在應用程式啟動時,我們透過DatabaseConfigLoader物件來讀取設定檔案並且產生DatabaseConfig物件。
上述架構圖如下:
我們介紹完了介面隔離與設定檔案資料類別後,接下來進行兩者之間差異的探討…
介面隔離與設定檔資料類別之差異
在經過上述介紹後我們得出兩個結論:
- 為了滿足物件的精神、遵守Information expert會讓我們需要使用介面隔離。
- 為了避免context propagation會讓我們使用設定檔資料類別。
兩個方法看起來都很吸引人,那我們要怎麼選擇呢?
為了客觀,我使用以下因素 (維護性的常見因素)進行比較:
- Object purity
- Testability
- Single responsibility (reason to change)
- Place to Change
以下為兩個解決辦法的比較表格:
我將會針對一個欄位逐步的解釋:
Object purity
- 介面隔離:比較符合物件導向的精神,因為設定檔類別有自己的生命、控制權、責任。
- 設定檔資料類別:較不符合物件導向的精神,因為設定檔類別變成純資料結構,沒有自己的生命、控制權、責任。
Testability
- 介面隔離:比較不好撰寫單元測試,因為需要使用介面加上Fake物件來隔離外部環境。
- 設定檔資料類別:較容易單元測試,因為我們不需要使用介面,也沒有Fake物件,只需要產生instance即可。
Single responsibility
- 介面隔離:設定檔案物件只會因為設定檔案變動的原因而改變,因此滿足Single responsibility。
- 設定檔資料類別:設定檔案變動時,需要修改負責載入的物件與設定檔案資料類別,算是一種shotgun surgery code smell。沒有像介面隔離一樣那麼強烈遵守Single responsibility。
Place to Change
為了讓讀者更明確的看出改動的元件,我們透過圖的方式說明。
- 介面隔離:
從圖上我們看到改變的地方如下:Composition Root、介面、所有實作類別 (real and fake)、測試客戶端。
- 設定檔資料類別:
從圖上我們看到改變的地方如下:Loader、資料結構、測試客戶端。
那麼哪一種比較好呢?根據設定檔案的設計:我選擇的是設定檔案資料類別。
為什麼?因為所有程式最終的目標是高聚合、低耦合,為了就是降低修改影響的地方,而資料結構的方式修改的數量比介面隔離的數量少。
當過度遵守物件反而增加修改的地方時,我們應該思考有沒有其他方式能更好的降低修改的影響。所以我選擇設定檔案資料類別。
這邊也提供一個策略:先遵守Information expert,當發現有context propagation、違反SRP、不好測試時,在將不同的責任分離開來。
今天介紹的所有概念也不僅僅只能作用在資料結構上,比較general解釋如下:必要時,將in-memory所需要的資料或運算與外部環境切割,能增加系統的測試性、維護性。
其他相似概念
像是Domain Driven Design裡面所強調的domain model isolation,也跟設定檔案範例有異曲同工之妙。
如果是傳統的物件,應該要能夠把自己從資料庫讀取出來、把自己存進資料庫,這就是ruby on rail的active record design pattern。
但在DDD卻不是這樣,因為sql與商業邏輯改變的頻率不同,我們希望程式能夠貼近要解決的問題而不是infrastructure,所以將CRUD的責任與entity分離,而負責CRUD的物件就是Repository。
如此一來,entity的可測試性會大幅度增加,也能更有效的實作複雜的商業邏輯。
但一樣會有缺點:當entity變化時,Repository需要進行修改,沒辦法像active record一樣有效的將改變範圍限縮在一個地方。
這就是設計需要面臨的取捨,對於我來說可測試性、遵守SRP會比Isolate change into one place重要。
除非應用程式沒有商業邏輯、或是非常簡單,我才會考慮使用active record pattern。但這種系統在現在應該沒有被開發的價值。
結論:
- 遵守Information expert能有效限縮改變範圍。
- 當物件為了遵守Information expert而耦合到外部環境時,會導致context propagation。
- 透過介面隔離能避免context propagation,但測試較不容易撰寫,修改影響的範圍也更大,因為有介面跟兩個子類別 (real and fake)。
- 透過設定檔資料類別能提高測試性,修改的範圍影響較小,但會產生 shotgun surgery code smell。
- 先遵守Information expert,再根據情況決定是否要與外部環境責任解耦合。
- 抽象概念為:必要時,將in-memory所需要的資料或運算 (domain物件、資料結構)與外部環境切割,能增加系統的測試性、維護性。
- active record pattern與domain model pattern也面臨設定檔案讀取設計一樣的問題。