陳碩 (giantchen_AT_gmail)
Blog.csdn.net/Solstice t.sina.com.cn/giantchen
陳碩關于分布式系統的系列文章:http://blog.csdn.net/Solstice/category/802325.aspx
本作品采用“Creative Commons 署名-非商業性使用-禁止演繹 3.0 Unported 許可協議(cc by-nc-nd)”進行許可。
http://creativecommons.org/licenses/by-nc-nd/3.0/
本文所談的“測試”全部指的是“開發者測試/developer testing”,由程序員自己來做,不是由 QA 團隊進行的系統測試。這兩種測試各有各的用途,不能相互替代。
我在《樸實的C++設計》一文中談到“為了確保正確性,我們另外用Java寫了一個測試夾具(test harness)來測試我們這個C++程序。這個測試夾具模擬了所有與我們這個C++程序打交道的其他程序,能夠測試各種正?;虍惓5那闆r?;旧先魏未a改動和bug修復都在這個夾具中有體現。如果要新加一個功能,會有對應的測試用例來驗證其行為。如果發現了一個bug,先往夾具里加一個或幾個能復現bug的測試用例,然后修復代碼,讓測試通過。我們積累了幾百個測試用例,這些用例表示了我們對程序行為的預期,是一份可以運行的文檔。每次代碼改動提交之前,我們都會執行一遍測試,以防低級錯誤發生。”
今天把 test harness 這個做法仔細說一說。
自動化測試的必要性
我想自動化測試的必要性無需贅言,自動化測試是 absolutely good stuff。
基本上,要是沒有自動化的測試,我是不敢改產品代碼的(“改”包括添加新功能和重構)。自動化測試的作用是把程序已經實現的 features 以 test case 的形式固化下來,將來任何代碼改動如果破壞了現有的功能需求就會觸發測試 failure。好比 DNA 雙鏈的互補關系,這種互補結構對保持生物遺傳的穩定有重要作用。類似的,自動化測試與被測程序的互補結構對保持系統的功能穩定有重要作用。
單元測試的能與不能
一提到自動化測試,我猜很多人想到的是單元測試(unit testing)。單元測試確實有很大的用處,對于解決某一類型的問題很有幫助。粗略地說,單元測試主要用于測試一個函數、一個 class 或者相關的幾個 classes。
最典型的是測試純函數,比如計算個人所得稅的函數,輸出是“起征點、扣除五險一金之后的應納稅所得額、稅率表”,輸出是應該繳的個稅。又比如我在《〈程序中的日期與時間〉第一章 日期計算》中用單元測試來驗證 Julian day number 算法的正確性。再比如我在《“過家家”版的移動離線計費系統實現》和《模擬銀行窗口排隊叫號系統的運作》中用單元測試來檢查程序運行的結果是否符合預期。(最后這個或許不是嚴格意義上的單元測試,更像是驗收測試。)
為了能用單元測試,主代碼有時候需要做一些改動。這對 Java 通常不構成問題(反正都編譯成 jar 文件,在運行的時候指定 entry point)。對于 C++,一個程序只能有一個 main() 入口點,要采用單元測試的話,需要把功能代碼(被測對象)做成一個 library,然后讓單元測試代碼(包含 main() 函數)link 到這個 library 上;當然,為了正常啟動程序,我們還需要寫一個普通的 main(),并 link 到這個 library 上。
單元測試的缺點
根據我的個人經驗,我發現單元測試有以下缺點。
單元測試是白盒測試,測試代碼直接調用被測代碼,測試代碼與被測代碼緊耦合。從理論上說,“測試”應該只關心被測代碼實現的功能,不用管它是如何實現的(包括它提供什么樣的函數調用接口)。比方說,以前面的個稅計算器函數為例,作為使用者,我們只關心它算的結果是否正確。但是,如果要寫單元測試,測試代碼必須調用被測代碼,那么測試代碼必須要知道個稅計算器的 package、class、method name、parameter list、return type 等等信息,還要知道如何構造這個 class。以上任何一點改動都會造成測試失?。ň幾g就不通過)。
在添加新功能的時候,我們常會重構已有的代碼,在保持原有功能的情況下讓代碼的“形狀”更適合實現新的需求。一旦修改原有的代碼,單元測試就可能編譯不過:比如給成員函數或構造函數添加一個參數,或者把成員函數從一個 class 移到另一個 class。對于 Java,這個問題還比較好解決,因為 IDE 的重構功能很強,能自動找到 references,并修改之。
對于 C++,這個問題更為嚴重,因為一改功能代碼的接口,單元測試就編譯不過了,而 C++ 通常沒有自動重構工具(語法太復雜,語意太微妙)可以幫我們,都得手動來。要么每改動一點功能代碼就修復單元測試,讓編譯通過;要么留著單元測試編譯不通過,先把功能代碼改成我們想要的樣子,再來統一修復單元測試。
這兩種做法都有困難,前者,C++ 編譯緩慢,如果每改動一點就修復單元測試,一天下來也前進不了幾步,很多時間浪費在等待編譯上;后者,問題更嚴重,單元測試與被測代碼的互補性是保證程序功能穩定的關鍵,如果大幅修改功能代碼的同時又大幅修改了單元測試,那么如何保證前后的單元測試的效果(測試點)不變?如果單元測試自身的代碼發生了改動,如何保證它測試結果的有效性?會不會某個手誤讓功能代碼和單元測試犯了相同的錯誤,負負得正,測試還是綠的,但是實際功能已經亮了紅燈?難道我們要為單元測試寫單元測試嗎?
有時候,我們需要重新設計并重寫某個程序(有可能換用另一種語言)。這時候舊代碼中的單元測試完全作廢了(代碼結構發生巨大改變,甚至連編程語言都換了),其中包含的寶貴的業務知識也付之東流,豈不可惜?
為了讓代碼具有“可測試性”,我們常會使用依賴注入技術,這么做的好處據說是“解耦”(其實,有人一句話道破真相:但凡你在某個地方切斷聯系,那么你必然會在另一個地方重新產生聯系),壞處就是割裂了代碼的邏輯:單看一塊代碼不知道它是干嘛的,它依賴的對象不知道在哪兒創建的,如果一個 interface 有多個實現,不到運行的時候不知道用的是哪個實現。(動態綁定的初衷就是如此,想來讀過“以面向對象思想實現”的代碼的人都明白我在說什么。)
以《Muduo 網絡編程示例之二:Boost.Asio 的聊天服務器》中出現的聊天服務器 ChatServer 為例,ChatServer 直接使用了 muduo::net::TcpServer 和 muduo::net::TcpConnection 來處理網絡連接并收發數據,這個設計簡單直接。如果要為 ChatServer 寫單元測試,那么首先它肯定不能在構造函數里初始化 TcpServer 了。
稍微復雜一點的測試要用 mock object。ChatServer 用 TcpServer 和 TcpConenction 來收發消息,為了能單元測試,我們要為 TcpServer 和 TcpConnection 提供 mock 實現,原本一個具體類 TcpServer 就變成了一個 interface TcpServer 加兩個實現 TcpServerImpl 和 TcpServerMock,同理 TcpConnection 也一化為三。ChatServer 本身的代碼也變得復雜,我們要設法把 TcpServer 和 TcpConnection 注入到其中,ChatServer 不能自己初始化 TcpServer 對象。
這恐怕是在 C++ 中使用單元測試的主要困難之一。Java 有動態代理,還可以用 cglib 來操作字節碼以實現注入。而 C++ 比較原始,只能自己手工實現 interface 和 implementations。這樣原本緊湊的以 concrete class 構成的代碼結構因為單元測試的需要而變得松散(所謂“面向接口編程”嘛),而這么做的目的僅僅是為了滿足“源碼級的可測試性”,是不是有一點因小失大呢?(這里且暫時忽略虛函數和普通函數在性能上的些微差別。)對于不同的 test case,可能還需要不同的 mock 對象,比如 TcpServerMock 和 TcpServerFailureMock,這又增加了編碼的工作量。
此外,如果程序中用到的涉及 IO 的第三方庫沒有以 interface 方式暴露接口,而是直接提供的 concrete class (這是對的,因為C++中應該《避免使用虛函數作為庫的接口》),這也讓編寫單元變得困難,因為總不能自己挨個 wrapper 一遍吧?難道用 link-time 的注入技術?
- 某些 failure 場景難以測試,而考察這些場景對編寫穩定的分布式系統有重要作用。比方說:網絡連不上、數據庫超時、系統資源不足。
- 對多線程程序無能為力。如果一個程序的功能涉及多個線程合作,那么就比較難用單元測試來驗證其正確性。
- 如果程序涉及比較多的交互(指和其他程序交互,不是指圖形用戶界面),用單元測試來構造測試場景比較麻煩,每個場景要寫一堆無趣的代碼。而這正是分布式系統最需要測試的地方。
總的來說,單元測試是一個值得掌握的技術,用在適當的地方確實能提高生產力。同時,在分布式系統中,我們還需要其他的自動化測試手段。
分布式系統測試的要點
在分布式系統中,class 與 function 級別的單元測試對整個系統的幫助不大,當然,這種單元測試對單個程序的質量有幫助;但是,一堆磚頭壘在一起是變不成大樓的。
分布式系統測試的要點是測試進程間的交互:一個進程收到客戶請求,該如何處理,然后轉發給其他進程;收到響應之后,又修改并應答客戶。測試這些多進程協作的場景才算測到了點子上。
假設一個分布式系統由四五種進程組成,每個程序有各自的開發人員。對于整個系統,我們可以用腳本來模擬客戶,自動化地測試系統的整體運作情況,這種測試通常由 QA 團隊來執行,也可以作為系統的冒煙測試。
對于其中每個程序的開發人員,上述測試方法對日常的開發幫助不大,因為測試要能通過必須整個系統都正常運轉才行,在開發階段,這一點不是時時刻刻都能滿足(有可能你用到的新功能對方還沒有實現,這反過來影響了你的進度)。另一方面,如果出現測試失敗,開發人員不能立刻知道這是自己的程序出錯,有可能是環境原因造成的錯誤,這通常要去讀程序日志才能判定。還有,作為開發者測試,我們希望它無副作用,每天反復多次運行也不會增加整個環境的負擔,以整個 QA 系統為測試平臺不可避免要留下一些垃圾數據,而清理這些數據又會花一些寶貴的工作時間。(你得判斷數據是自己的測試生成的還是別人的測試留下的,不能誤刪了別人的測試數據。)
作為開發人員,我們需要一種單獨針對自己編寫的那個程序的自動化測試方案,一方面提高日常開發的效率,另一方面作為自己那個程序的功能驗證測試集(即回歸測試/regression tests)。
分布式系統的抽象觀點
一臺機器兩根線
形象地來看,一個分布式系統就是一堆機器,每臺機器的屁股上拖著兩根線:電源線和網線(不考慮 SAN 等存儲設備),電源線插到電源插座上,網線插到交換機上。

這個模型實際上說明,一臺機器的表現出來的行為完全由它接出來的兩根線展現,今天不談電源線,只談網線。(“在乎服務器的功耗”在我看來就是公司利潤率很低的標志,要從電費上摳成本。)
如果網絡是普通的千兆以太網,那么吞吐量不大于 125MB/s。這個吞吐量比起現在的 CPU 運算速度和內存帶寬簡直小得可憐。這里我想提的是,對于不特別在意 latency 的應用,只要能讓千兆以太網的吞吐量飽和或接近飽和,用什么編程語言其實無所謂。Java 做網絡服務端開發也是很好的選擇(不是指 web 開發,而是做一些基礎的分布式組件,例如 ZooKeeper 和 Hadoop 之類)。盡管可能 C++ 只用了 15% 的 CPU,而 Java 用了 30% 的 CPU,Java 還占用更多的內存,但是千兆網卡帶寬都已經跑滿,那些省下在資源也只能浪費了;對于外界(從網線上看過來)而言,兩種語言的效果是一樣的,而通常 Java 的開發效率更高。(Java 是比 C++ 慢一些,但是透過千兆網絡不一定還能看得出這個區別來。)
進程間通過 TCP 相互連接
陳碩在《多線程服務器的常用編程模型》第 5 節“進程間通信”中提倡僅使用 TCP 作為進程間通信的手段,今天這個觀點將再次得到驗證。
以下是 Hadoop 的分布式文件系統 HDFS 的架構簡圖。

HDFS 有四個角色參與其中,NameNode(保存元數據)、DataNode(存儲節點,多個)、Secondary NameNode(定期寫 check point)、Client(客戶,系統的使用者)。這些進程運行在多臺機器上,之間通過 TCP 協議互聯。程序的行為完全由它在 TCP 連接上的表現決定(TCP 就好比前面提到的“網線”)。
在這個系統中,一個程序其實不知道與自己打交道的到底是什么。比如,對于 DataNode,它其實不在乎自己連接的是真的 NameNode 還是某個調皮的小孩用 Telnet 模擬的 NameNode,它只管接受命令并執行。對于 NameNode,它其實也不知道 DataNode 是不是真的把用戶數據存到磁盤上去了,它只需要根據 DataNode 的反饋更新自己的元數據就行。這已經為我們指明了方向。
一種自動化的回歸測試方案
假如我是 NameNode 的開發者,為了能自動化測試 NameNode,我可以為它寫一個 test harness (這是一個獨立的進程),這個 test harness 仿冒(mock)了與被測進程打交道的全部程序。如下圖所示,是不是有點像“缸中之腦”?

對于 DataNode 的開發者,他們也可以寫一個專門的 test harness,模擬 Client 和 NameNode。

Test harness 的優點
- 完全從外部觀察被測程序,對被測程序沒有侵入性,代碼該怎么寫就怎么寫,不需要為測試留路。
- 能測試真實環境下的表現,程序不是單獨為測試編譯的版本,而是將來真實運行的版本。數據也是從網絡上讀取,發送到網絡上。
- 允許被測程序做大的重構,以優化內部代碼結構,只要其表現出來的行為不變,測試就不會失敗。(在重構期間不用修改 test case。)
- 能比較方便地測試 failure 場景。比如,若要測試 DataNode 出錯時 NameNode 的反應,只要讓 test harness 模擬的那個 mock DataNode 返回我們想要的出錯信息。要測試 NameNode 在某個 DataNode 失效之后的反應,只要讓 test harness 斷開對應的網絡連接即可。要測量某請求超時的反應,只要讓 Test harness 不返回結果即可。這對構建可靠的分布式系統尤為重要。
- 幫助開發人員從使用者的角度理解程序,程序的哪些行為在外部是看得到的,哪些行為是看不到的。
- 有了一套比較完整的 test cases 之后,甚至可以換種語言重寫被測程序(假設為了提高內存利用率,換用 C++ 來重新實現 NameNode),測試用例依舊可用。這時 test harness 起到知識傳承的作用。
- 發現 bug 之后,往 test harness 里添加能復現 bug 的 test case,修復 bug 之后,test case 繼續留在 harness 中,反正出現回歸(regression)。
實現要點
- Test harness 的要點在于隔斷被測程序與其他程序的聯系,它冒充了全部其他程序。這樣被測程序就像被放到測試臺上觀察一樣,讓我們只關注它一個。
- Test harness 要能發起或接受多個 TCP 連接,可能需要用某個現成的 NIO 網絡庫,如果不想寫成多線程程序的話。
- Test harness 可以與被測程序運行在同一臺機器,也可以運行在兩臺機器上。在運行被測程序的時候,可能要用一個特殊的啟動腳本把它依賴的 host:port 指向 test harness。
- Test harness 只需要表現得跟它要 mock 的程序一樣,不需要真的去實現復雜的邏輯。比如 mock DataNode 只需要對 NameNode 返回“Yes sir, 數據已存好”,而不需要真的把數據存到硬盤上。若要 mock 比較復雜的邏輯,可以用“記錄+回放”的方式,把預設的響應放到 test case 里回放(replay)給被測程序。
- 因為通信走 TCP 協議,test harness 不一定要和被測程序用相同的語言,只要符合協議就行。試想如果用共享內存實現 IPC,這是不可能的。陳碩在《在 muduo 中實現 protobuf 編解碼器與消息分發器》中提到利用 protobuf 的跨語言特性,我們可以采用 Java 為 C++ 服務程序編寫 test harness。其他跨語言的協議格式也行,比如 XML 或 Json。
- Test harness 運行起來之后,等待被測程序的連接,或者主動連接被測程序,或者兼而有之,取決于所用的通信方式。
- 一切就緒之后,Test harness 依次執行 test cases。一個 NameNode test case 的典型過程是:test harness 模仿 client 向被測 NameNode 發送一個請求(eg. 創建文件),NameNode 可能會聯絡 mock DataNode,test harness 模仿 DataNode 應有的響應,NameNode 收到 mock DataNode 的反饋之后發送響應給 client,這時 test harness 檢查響應是否符合預期。
- Test harness 中的 test cases 以配置文件(每個 test case 有一個或多個文本配置文件,每個 test case 占一個目錄)方式指定。test harness 和 test cases 連同程序代碼一起用 version controlling 工具管理起來。這樣能復現以外任何一個版本的應有行為。
- 對于比較復雜的 test case,可以用嵌入式腳本語言來描述場景。如果 test harness 是 Java 寫的,那么可以嵌入 Groovy,就像陳碩在《“過家家”版的移動離線計費系統實現》中用 Groovy 實現計費邏輯一樣。Groovy 調用 test harness 模擬多個程序分別發送多份數據并驗證結果,groovy 本身就是程序代碼,可以有邏輯判斷甚至循環。這種動靜結合的做法在不增加 test harness 復雜度的情況下提供了相當高的靈活性。
- Test harness 可以有一個命令行界面,程序員輸入“run 10”就選擇執行第 10 號 test case。
幾個實例
Test harness 這種測試方法適合測試有狀態的、與多個進程通信的分布式程序,除了 Hadoop 中的 NameNode 與 DataNode,我還能想到幾個例子。
1. chat 聊天服務器
聊天服務器會與多個客戶端打交道,我們可以用 test harness 模擬 5 個客戶端,模擬用戶上下線,發送消息等情況,自動檢測聊天服務器的工作情況。
2. 連接服務器、登錄服務器、邏輯服務器
這是云風在他的 blog 中提到的三種網游服務器(http://blog.codingnow.com/2007/02/user_authenticate.html,http://blog.codingnow.com/2006/04/iocp_kqueue_epoll.html,http://blog.codingnow.com/2010/11/go_prime.html),我這里借用來舉例子。
如果要為連接服務器寫 test harness,那么需要模擬客戶(發起連接)、登錄服務器(驗證客戶資料)、邏輯服務器(收發網游數據),有了這樣的 test harness,可以方便地測試連接服務器的正確性,也可以方便地模擬其他各個服務器斷開連接的情況,看看連接服務器是否應對自如。
同樣的思路,可以為登錄服務器寫 test harness。(我估計不用為邏輯服務器再寫了,因為肯定已經有自動測試了。)
3. 多 master 之間的二段提交
這是分布式容錯的一個經典做法。用 test harness 能把 primary master 和 secondary masters 單獨拎出來測試。在測試 primary master 的時候,test harness 扮演 name service 和 secondary masters。在測試 secondary master 的時候,test harness 扮演 name service、primary master、其他 secondary masters??梢员容^容易地測試各種 failure 情況。如果不這么做,而直接部署多個 masters 來測試,恐怕很難做到自動化測試。
4. paxos 的實現
Paxos 協議的實現肯定離不了單元測試,因為涉及多個角色中比較復雜的狀態變遷。同時,如果我要寫 paxos 實現,那么 test harness 也是少不了的,它能自動測試 paxos 節點在真實網絡環境下的表現,并且輕易模擬各種 failure 場景。
局限性
如果被測程序有 TCP 之外的 IO,或者其 TCP 協議不易模擬(比如通過 TCP 連接數據庫),那么這種測試方案會受到干擾。
對于數據庫,如果被測程序只是簡單的從數據庫 select 一些配置信息,那么或許可以在 test harness 里內嵌一個 in-memory H2 DB engine,然后讓被測程序從這里讀取數據。當然,前提是被測程序的 DB driver 能連上 H2 (或許不是大問題,H2 支持 JDBC 和 部分 ODBC)。如果被測程序有比較復雜的 SQL 代碼,那么 H2 表現的行為不一定和生產環境的數據庫一致,這時候恐怕還是要部署測試數據庫(有可能為每個開發人員部署一個小的測試數據庫,以免相互干擾)。
如果被測程序有其他 IO (寫 log 不算),比如 DataNode 會訪問文件系統,那么 test harness 沒有能把 DataNode 完整地包裹起來,有些 failure cases 不是那么容易測試。這是或許可以把 DataNode 指向 tmpfs,這樣能比較容易測試磁盤滿的情況。當然,這樣也有局限性,因為 tmpfs 沒有真實磁盤那么大,也不能模擬磁盤讀寫錯誤。我不是分布式存儲方面的專家,這些問題留給分布式文件系統的實現者去考慮吧。(測試 paxos 節點似乎也可以用 tmpfs 來模擬 persist storage,由 test case 填充所需的初始數據。)
其他用處
Test harness 除了實現 features 的回歸測試,它還有別的用處。
前面提到,如果有個新功能(增加一種新的 request type)需要改動兩個程序,有可能造成相互等待:客戶程序 A 說要先等服務程序 B 實現對應的功能響應,這樣 A 才能發送新的請求,不然每次請求就會被拒絕,無法測試;服務程序 B 說要先等 A 能夠發送新的請求,這樣自己才能開始編碼與測試,不然都不知道請求長什么樣子,也觸發不了新寫的代碼。(當然,這是我虛構的例子。)
如果 A 和 B 都有各自的 test harness,事情就好辦了,雙方大致商量一個協議格式,然后分頭編碼。程序 A 的作者在自己的 harness 里邊添加一個 test case,模擬他認為 B 應有的響應,這個響應可以 hard code 某種最常見的響應,不必真的實現所需的判斷邏輯(畢竟這是程序 B 的作者該干的事情),然后程序 A 的作者就可以編碼并測試自己的程序了。同理,程序 B 的作者也不用等 A 拿出一個半成品來發送新請求,他往自己的 harness 添加一個 test case,模擬他認為 A 應該發送的請求,然后就可以編碼并測試自己的新功能。雙方齊頭并進,減少扯皮。等功能實現得差不多了,兩個程序互相連一連,如果發現協議有不一致,檢查一下 harness 中的新 test cases(這代表了 A/B 程序對對方的預期),看看那邊改動比較方便,很快就能解決問題。
Test harness 稍作改進還可以變功能測試為壓力測試,供程序員 profiling 用。比如反復不間斷發送請求,向被測程序加壓。不過,如果被測程序是 C++ 寫的,而 test harness 是 Java 寫的,有可能出現 test harness 占 100% CPU,而被測程序還跑得優哉游哉的情況。這時候可以單獨用 C++ 寫一個負載生成器。
小結
以單獨的進程作為 test harness 對于開發分布式程序相當有幫助,它能達到單元測試的自動化程度和細致程度,又避免了單元測試對功能代碼結構的侵入與依賴。