今天是 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,哈哈!
Ping,
回覆刪除你忘了 virtual. Java 寫太多了? :-)