2012年1月14日

也來聊聊物件導向程式的測試、封裝和相依性注入

今天是 2012 總統大選的日子,一大早就看到朋友 H.C. 貼出來的 Hinet 網頁截圖,實在是太歡樂了!



Hinet 的報票網站,在總統大選投票日 2012/1/14 早上 9:23 被網友 H.C. 抓到的截圖。投票還沒結束,票都開出來了?!

現在是 2012/1/14 傍晚 5:40,票剛開始開,早上 Hinet 就透過時光機看到結果了?還是有人早早「把票數算好了」?

我猜是有人在用上線系統做新元件的測試。

什麼年代了,這麼不專業的事還有人做?

就來聊聊物件導向程式的封裝和相依性注入好了,這和測試有關,也順帶聊一點點單元測試。

封裝和相依性注入

剛學物件導向程式設計時,一定學過要把類別(class)的介面設計好,

  • 找出對的抽象定義(Abstraction),讓使用類別 A 的人很容易理解 A 是做什麼的;
  • 適當地封裝(Encapsulation),讓別的程式不用了解 A 的實作細節就能正確使用 A。就像開車的駕駛,要加速只要會〈看速度表〉和〈踩油門〉兩件事,不用知道油門踩下去時,有什麼訊號傳到了什麼機構、用什麼方式控制了汽缸和噴油嘴的動作、甚至不用知道車子裡有這些機構存在。

等到開始寫有點規模的程式、有許多物件要互相合作時,一定會做單元測試(Unit tests),沒多久就會開始用仿製物件(Mock Objects)來測試程式的行為,總有一天你會聽到人家在說,相依性注入(Dependency Injection)是寫易測程式的重要技巧。

但你發現,相依性注入和封裝是互相矛盾的,怎麼辦?

如果你不知道為何矛盾,我來舉個例子。如果你懂,可以跳到下一節

封裝和相依性注入

假設你在設計一個網站的帳號系統,前台有個登入畫面,後台有個帳號資料庫。登入系統和資料庫顯然都是物件,所以你這樣寫:(我用 C++ 說明,但觀念應該適用在大多數物件導向語言上)
class AccountDB {
 public:
  AccountDB();
  bool GetPassword(const string& name, string* password) const;  // password 不會是明碼吧? XD
 private:
  // ...
};

class LoginSystem {
 public:
  LoginSystem();
  bool Verify(const string& name, const string& password) const;
 private:
  AccountDB* account_db_;
  // …
};

登入系統必需向資料庫要(編碼過的)密碼,才能驗證密碼是否正確。一種典型做法是在 LoginSystem 的 constructor 裡建立和資料庫的連結,把連結記住,之後就可以重複使用這個連結來讀密碼了。

LoginSystem::LoginSystem() {
  account_db_ = new AccountDB();  // account_db_ 是私有資料
  // ...
}

bool LoginSystem::Verify(const string& name, const string& password) const {
  string stored_password;
  if (!account_db_->GetPassword(name, &stored_password)) {
    return false;  // 也可以傳錯誤訊息
  }
  return Encode(password) == stored_password;  // Encode() 是某個編碼函式
}

這種寫法的抽象概念很清楚、封裝很乾淨,問題是 LoginSystem 和 AccountDB 緊緊卡在一起,如果你要測試 LoginSystem::Verify(),你必須在真的帳號資料庫裡塞測試帳號,然後餵測試帳號的資料進 LoginSystem::Verify()。這種做法很容易產生開頭講的問題,勸你千萬不要這樣做。

把測試系統和上線系統分離的一種做法是讓 AccountDB 的 constructor 接受 hostname 引數,就可以找台測試機器,在上面建個測試專用的資料庫,測試時不用碰到使用者的帳號資料庫。為了讓測試程式和上線程式用不同的 hostname 引數,就要讓 LoginSystem::LoginSystem() 也吃這個引數,好從 main() 和測試程式餵進去。

class AccountDB {
 public:
  AccountDB(const sting& db_hostname);
  bool GetPassword(const string& name, string* password) const { /* ... */ }
  // ...
};
class LoginSystem {
 public:
  LoginSystem(const string& db_hostname);
  // …
};

LoginSystem::LoginSystem(const string& db_hostname) {
  account_db_ = new AccountDB(db_hostname);  // account_db_ 是私有資料
  // ...
}

這種寫法程式不難測試,可是犧牲了一點封裝:為什麼登入系統需要吃個資料庫主機名稱?資料庫在哪裡不該是登入系統的實作細節嗎?

這樣的設計,LoginSystem 和 AccountDB 仍然是緊密結合的(Tightly-coupled),如果將來有一天因為現有資料庫不夠快、長不大、或是其他原因要換掉,新資料庫可能會有不太一樣的介面,那你該為它寫個 AccountDB2 類別吧?寫完還要改 LoginSystem 裡和資料庫聊天的程式。

這時你可能會想弄個 AccountDBInterface 介面,讓舊的 AccountDB 和新的 AccountDB2 都實作這個介面,LoginSystem 只需要和 AccountDBInterface 打交道就好了,至於打交道的對象倒底是哪個類別的物件?不重要。打交道的對象是哪個物件?LoginSystem 不必自行決定,由造 LoginSystem 物件的程式決定,再把資料庫物件和登入系統物件「送作堆」就好了。這個作法就是物件導向中很重要的 "Program to an interface, not an implementation" 概念。

所以程式變成這樣:

class AccountDBInterface {
 public:
  virtual bool GetPassword(const string& name, string* password) const = 0;
  // ...
};

class AccountDB2 : public AccountDBInterface {
  AccountDB2(/* 可能有不一樣的引數 */);
  virtual bool GetPassword(const string& name, string* password) const { /* ... */ }
};
// 以上是新的 classes

class AccountDB : public AccountDBInterface {
 public:
  AccountDB(const string& db_hostname);
  virtual bool GetPassword(const string& name, string* password) const { /* ... */ }
  // ...
};


class LoginSystem {
 public:
  LoginSystem(AccountDBInterface* db);
  bool Verify(const string& name, const string& password) const;
 private:
  AccountDBInterface* account_db_;
  // …
};

LoginSystem::LoginSystem(AccountDBInterface* db) {
  account_db_ = db;
  // 或許要檢查 account_db_ 不是 NULL
  // ...
}

寫到這裡,要造 LoginSystem 物件的程式要先造好資料庫物件,再把資料庫物件餵給 LoginSystem 的 constructor,不知不覺寫成相依性注入了。

一旦寫成相依性注入,連帶有個好處:測試時可以用仿製的資料庫物件(Mock database object),也就是個實作 AccountDBInterface 但不真的連到任何資料庫的物件,只要能提供測試用的資料,連測試專用資料庫都不用架設了。

到此功德圓滿,資料庫可以隨意抽換,甚至可以抽換成仿製的資料庫以便測試 LoginSystem 的邏輯。

可是封裝怎麼辦?LoginSystem 依賴 AccountDBInterface 這件事因為放在 constructor 的引數裡而曝露出來了,要使用 LoginSystem 的程式要自己造好帳號資料庫物件再餵給 LoginSystem。如果要用到帳號資料庫的只有 LoginSystem 這 101 個類別(這個例子沒舉好,事實上改密碼系統也要用到帳號資料庫,但不想改例子了,請讀者包涵),以封裝的角度來看,帳號資料庫可以變成 LoginSystem 的私有類別,不必讓任何其他類別看到。要造出和使用 LoginSystem 的程式,不用知道帳號資料庫的存在,LoginSystem 知道就夠了。

這個例子只有一個相依類別,讀者可以想像在較大的軟體系統裡,有的類別會有不少相依類別,造這種物件時,要把相依物件一一造好餵進去。要造這種物件的程式需要知道太多事,封裝性破壞殆盡。

一種解法:工廠法

既然〈相依性注入〉影響到的是造物件時的封裝性,不是使用物件時的封裝性,很自然會想到在造物件的部分用點技巧。

講到造物件,OOP 四人幫的書裡講到五個樣式:工廠法(Factory Method)、造物師(Builder)、抽象工廠(Abstract Factory)、芻型(Prototype)、Singleton(我想譯成「獨支」,不知道誰有更好的譯法?),在這裡可以用工廠法。

工廠法的做法是:提供統一的造物介面,但不同的實作可以造出不同類別的物件。這正好可以用來造用到不同類別資料庫的 LoginSystem:

class LoginSystemFactory {
 public:
  LoginSystemFactory();
  virtual LoginSystem* Build() = 0;
  // ...
};

class LoginSystemWithType1DBFactory : public LoginSystemFactory {
  virtual LoginSystem* Build() {
    return new LoginSystem(new AccountDB("localhost"));  // 參數部分為了方便說明先寫死。
  }
};

class LoginSystemWithMockDBFactory : public LoginSystemFactory {
  virtual LoginSystem* Build() {
    return new LoginSystem(new MockAccountDB());
  }
};
也就是說,把「清楚記錄相依性、一一造好相依物件、再造好登入系統」這件事設計成工廠的責任,不同的工廠可以生產不同的相依物件、和最後的產品物件。這樣子在上線程式中任何需要造登入系統物件的程式不用知道細節,達到封裝的目的,在測試登入系統時也能注入仿製物件。

後話

一個留給讀者的問題:如果你很重視單元測試的覆蓋度(coverage),時時追求 100%,你要怎麼測試這裡的工廠法呢?

【2012/01/15 編輯】謝謝 fr3@k 的提醒,寫太快還真的忘了 virtual,哈哈!

1 則留言 :

  1. Ping,

    你忘了 virtual. Java 寫太多了? :-)

    回覆刪除