物件導向原則知識專家 (Information expert) — 分分合合之到底要分還是合?

Z-xuan Hong
17 min readOct 3, 2021

--

本篇文章架構與先前不一樣 (沒有明確的大綱與章節),而是先介紹情境,從情境中發現問題與要討論的主題,在進行分析。

主要目標如下:

  • 了解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也面臨設定檔案讀取設計一樣的問題。

--

--

Z-xuan Hong
Z-xuan Hong

Written by Z-xuan Hong

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

No responses yet