Muduo 網(wǎng)絡(luò)編程示例之七:連接服務(wù)器及其自動(dòng)化測(cè)試
陳碩 (giantchen_AT_gmail)
Blog.csdn.net/Solstice t.sina.com.cn/giantchen
這是《Muduo 網(wǎng)絡(luò)編程示例》系列的第七篇文章。
Muduo 全系列文章列表: http://blog.csdn.net/Solstice/category/779646.aspx
本文介紹如何使用 test harness 來(lái)測(cè)試一個(gè)具有內(nèi)部邏輯的網(wǎng)絡(luò)服務(wù)程序。
本文的代碼見(jiàn) http://code.google.com/p/muduo/source/browse/trunk/examples/multiplexer
下載地址:http://muduo.googlecode.com/files/muduo-0.2.0-alpha.tar.gz SHA1 checksum: 75a09a82f96b583004876e95105c679e64c95715
云風(fēng)在他的博客中提到了網(wǎng)游連接服務(wù)器的功能需求(搜“練手項(xiàng)目”),我用 C++ 初步實(shí)現(xiàn)了這些需求,并為之編寫(xiě)了配套的自動(dòng)化 test harness,作為 muduo 網(wǎng)絡(luò)庫(kù)的示例。
注意:本文呈現(xiàn)的代碼僅僅實(shí)現(xiàn)了基本的功能需求,沒(méi)有考慮安全性,也沒(méi)有特別優(yōu)化性能,不適合用作真正的放在公網(wǎng)上運(yùn)行的網(wǎng)游連接服務(wù)器。
功能需求
這個(gè)連接服務(wù)器把多個(gè)客戶連接匯聚為一個(gè)內(nèi)部 TCP 連接,起到“數(shù)據(jù)串并轉(zhuǎn)換”的作用,讓 backend 的邏輯服務(wù)器專心處理業(yè)務(wù),而無(wú)需顧及多連接的并發(fā)性。以下是系統(tǒng)的框圖:

這個(gè)連接服務(wù)器的作用與數(shù)字電路中的數(shù)據(jù)選擇器 (multiplexer) 類似,所以我把它命名為 multiplexer。(其實(shí) IO-Multiplexing 也是取的這個(gè)意思,讓一個(gè) thread-of-control 能有選擇地處理多個(gè) IO 文件描述符。)

(上圖取自 wikipedia,是 public domain 版權(quán))
實(shí)現(xiàn)
Multiplexer 的功能需求不復(fù)雜,無(wú)非是在 backend connection 和 client connections 之間倒騰數(shù)據(jù)。具體來(lái)說(shuō),主要是處理四種事件:
由上可見(jiàn),multiplexer 的功能與 proxy 頗為類似。multiplexer_simple.cc 是一個(gè)線程版的實(shí)現(xiàn),借助 muduo 的 io-multiplexing 特性,可以方便地處理多個(gè)并發(fā)連接。
在實(shí)現(xiàn)的時(shí)候有兩點(diǎn)值得注意:
- TcpConnection 的 id 如何存放?當(dāng)從 backend 收到數(shù)據(jù),如何根據(jù) id 找到對(duì)應(yīng)的 client connection?當(dāng)從 client connection 收到數(shù)據(jù),如何得知其 id ?
第一個(gè)問(wèn)題比較好解決,用 std::map〈int, TcpConnectionPtr〉 clientConns_; 保存從 id 到 client connection 的映射就行。
第二個(gè)問(wèn)題固然可以用類似的辦法解決,但是我想借此介紹一下 muduo::net::TcpConnection 的 context 功能。每個(gè) TcpConnection 都有一個(gè) boost::any 成員,可由客戶代碼自由支配(get/set),代碼如下。這個(gè) boost::any 是 TcpConnection 的 context,可以用于保存與 connection 綁定的任意數(shù)據(jù)(比方說(shuō) connection id、connection 的最后數(shù)據(jù)到達(dá)時(shí)間、connection 所代表的用戶的名字等等)。這樣客戶代碼不必繼承 TcpConnection 就能 attach 自己的狀態(tài),而且也用不著 TcpConnectionFactory 了(如果允許繼承,那么必然要向 TcpServer 注入此 factory)。
class TcpConnection : public boost::enable_shared_from_this<TcpConnection>,
boost::noncopyable
{
public:
void setContext(const boost::any& context)
{ context_ = context; }
boost::any& getContext()
{ return context_; }
const boost::any& getContext() const
{ return context_; }
// ...
private:
// ...
boost::any context_;
};
typedef boost::shared_ptr<TcpConnection> TcpConnectionPtr;
對(duì)于 Multiplexer,在 onClientConnection() 里調(diào)用 conn->setContext(id),把 id 存到 TcpConnection 對(duì)象中。onClientMessage() 從 TcpConnection 對(duì)象中取得 id,連同數(shù)據(jù)一起發(fā)送給 backend,完整實(shí)現(xiàn)如下:
void onClientMessage(const TcpConnectionPtr& conn, Buffer* buf, Timestamp)
{
if (!conn->getContext().empty())
{
int id = boost::any_cast<int>(conn->getContext());
sendBackendBuffer(id, buf);
}
else
{
buf->retrieveAll();
}
}
- TcpConnection 的生命期如何管理?由于 Client Connection 是動(dòng)態(tài)創(chuàng)建并銷毀,其生與滅完全由客戶決定,如何保證 backend 想向它發(fā)送數(shù)據(jù)的時(shí)候,這個(gè) TcpConnection 對(duì)象還活著?解決思路是用 reference counting,當(dāng)然,不用自己寫(xiě),用 boost::shared_ptr 即可。TcpConnection 是 muduo 中唯一默認(rèn)采用 shared_ptr 來(lái)管理生命期的對(duì)象,蓋由其動(dòng)態(tài)生命期的本質(zhì)決定。更多內(nèi)容請(qǐng)參考陳碩《當(dāng)析構(gòu)函數(shù)遇到多線程──C++ 中線程安全的對(duì)象回調(diào)》
multiplexer 是二進(jìn)制協(xié)議,如何測(cè)試呢?
自動(dòng)化測(cè)試
Multiplexer 是 muduo 網(wǎng)絡(luò)編程示例中第一個(gè)具有 non-trivial 業(yè)務(wù)邏輯的網(wǎng)絡(luò)程序,根據(jù)陳碩《分布式程序的自動(dòng)化回歸測(cè)試》一文的思想,我為它編寫(xiě)了 test harness。代碼見(jiàn) http://code.google.com/p/muduo/source/browse/trunk/examples/multiplexer/harness/src/com/chenshuo/muduo/example/multiplexer
這個(gè) Test harness 采用 Java 編寫(xiě),用的是 Netty 庫(kù)。這個(gè) test harness 要扮演 clients 和 backend,也就是既要主動(dòng)發(fā)起連接,也要被動(dòng)接受連接。結(jié)構(gòu)如下:

Test harness 會(huì)把各種 event 匯聚到一個(gè) blocking queue 里邊,方便編寫(xiě) test case。Test case 則操縱 test harness,發(fā)起連接、發(fā)送數(shù)據(jù)、檢查收到的數(shù)據(jù),例如以下是其中一個(gè) test case
http://code.google.com/p/muduo/source/browse/trunk/examples/multiplexer/harness/src/com/chenshuo/muduo/example/multiplexer/testcase/TestOneClientSend.java
這里的幾個(gè) test cases 都以用 java 直接寫(xiě)的,如果有必要,也可以采用 Groovy 來(lái)編寫(xiě),這樣可以在不重啟 test harness 的情況下隨時(shí)修改添加 test cases。具體做法見(jiàn)陳碩《“過(guò)家家”版的移動(dòng)離線計(jì)費(fèi)系統(tǒng)實(shí)現(xiàn)》。
將來(lái)的改進(jìn)
有了這個(gè)自動(dòng)化的 test harness,我們可以比較方便且安全地修改(甚至重新設(shè)計(jì))multiplexer。例如
- 增加“backend 發(fā)送指令斷開(kāi) client connection”的功能。有了自動(dòng)化測(cè)試,這個(gè)新功能可以被單獨(dú)測(cè)試(指開(kāi)發(fā)者測(cè)試),而不需要真正的 backend 參與進(jìn)來(lái)。
- 將 Multiplexer 改用多線程重寫(xiě)。有了自動(dòng)化回歸測(cè)試,我們不用擔(dān)心破壞原有的功能,可以放心大膽地重寫(xiě)。而且由于 test harness 是從外部測(cè)試,不是單元測(cè)試,重寫(xiě) multiplexer 的時(shí)候不用動(dòng) test cases,這樣保證了測(cè)試的穩(wěn)定性。另外,這個(gè) test harness 稍作改進(jìn)還可以進(jìn)行 stress testing,既可用于驗(yàn)證多線程 multiplexer 的正確性,亦可對(duì)比其相對(duì)單線程版的效率提升。
Muduo 網(wǎng)絡(luò)編程示例之六:限制服務(wù)器的最大并發(fā)連接數(shù)
陳碩 (giantchen_AT_gmail)
Blog.csdn.net/Solstice t.sina.com.cn/giantchen
這是《Muduo 網(wǎng)絡(luò)編程示例》系列的第六篇文章。
Muduo 全系列文章列表: http://blog.csdn.net/Solstice/category/779646.aspx
本文已以大家都熟悉的 EchoServer 介紹如何限制服務(wù)器的并發(fā)連接數(shù)。
本文的代碼見(jiàn) http://code.google.com/p/muduo/source/browse/trunk/examples/maxconnection/
《Muduo 網(wǎng)絡(luò)編程示例 系列》計(jì)劃中的第六篇文章原本是“用于測(cè)試兩臺(tái)機(jī)器的帶寬的 pingpong 程序”,pingpong 協(xié)議的程序已經(jīng)在《muduo 與 boost asio 吞吐量對(duì)比》和《muduo 與 libevent2 吞吐量對(duì)比》兩篇文章中介紹過(guò)了,所以我改為寫(xiě)另外一個(gè)有點(diǎn)意思的主題。
這篇文章中的“并發(fā)連接數(shù)”是指一個(gè) server program 能同時(shí)支持的客戶端連接數(shù),連接系由客戶端主動(dòng)發(fā)起,服務(wù)端被動(dòng)接受(accept)連接。(如果要限制應(yīng)用程序主動(dòng)發(fā)起的連接,則問(wèn)題要簡(jiǎn)單得多,畢竟主動(dòng)權(quán)和決定權(quán)都在程序本身。)
為什么要限制并發(fā)連接數(shù)?
一方面,我們不希望服務(wù)程序超載,另一方面,更因?yàn)?file descriptor 是稀缺資源,如果出現(xiàn) file descriptor 耗盡,很棘手(跟 “malloc 失敗/new() 拋出 std::bad_alloc”差不多同樣棘手)。
我在《分布式系統(tǒng)的工程化開(kāi)發(fā)方法》一文中曾談到 libev 作者建議的一種應(yīng)對(duì)“accept()ing 時(shí) file descriptor 耗盡”的辦法。


Muduo 的 acceptor 正是這么實(shí)現(xiàn)的,但是,這個(gè)做法在多線程下不能保證正確,會(huì)有 race condition。(思考題:是什么 race condition?)
其實(shí)有另外一種比較簡(jiǎn)單的辦法:file descriptor 是 hard limit,我們可以自己設(shè)一個(gè)稍低一點(diǎn)的 soft limit,如果超過(guò) soft limit 就主動(dòng)關(guān)閉新連接,這樣就避免觸及“file descriptor 耗盡”這種邊界條件。比方說(shuō)當(dāng)前進(jìn)程的 max file descriptor 是 1024,那么我們可以在連接數(shù)達(dá)到 1000 的時(shí)候進(jìn)入“拒絕新連接”狀態(tài),這樣留給我們足夠的騰挪空間。
Muduo 中限制并發(fā)連接數(shù)
Muduo 中限制并發(fā)連接數(shù)的做法簡(jiǎn)單得出奇。以在《Muduo 網(wǎng)絡(luò)編程示例之零:前言》中出場(chǎng)過(guò)的 EchoServer 為例,只需要為它增加一個(gè) int 成員,表示當(dāng)前的活動(dòng)連接數(shù)。(如果是多線程程序,應(yīng)該用 muduo::AtomicInt32。)
class EchoServer
{
public:
EchoServer(muduo::net::EventLoop* loop,
const muduo::net::InetAddress& listenAddr,
int maxConnections);
void start();
private:
void onConnection(const muduo::net::TcpConnectionPtr& conn);
void onMessage(const muduo::net::TcpConnectionPtr& conn,
muduo::net::Buffer* buf,
muduo::Timestamp time);
muduo::net::EventLoop* loop_;
muduo::net::TcpServer server_;
int numConnected_; // should be atomic_int
const int kMaxConnections;
};
然后,在 EchoServer::onConnection() 中判斷當(dāng)前活動(dòng)連接數(shù),如果超過(guò)最大允許數(shù),則踢掉連接。
void EchoServer::onConnection(const TcpConnectionPtr& conn)
{
LOG_INFO << "EchoServer - " << conn->peerAddress().toHostPort() << " -> "
<< conn->localAddress().toHostPort() << " is "
<< (conn->connected() ? "UP" : "DOWN");
if (conn->connected())
{
++numConnected_;
if (numConnected_ > kMaxConnections)
{
conn->shutdown();
}
}
else
{
--numConnected_;
}
LOG_INFO << "numConnected = " << numConnected_;
}
這種做法可以積極地防止耗盡 file descriptor。
另外,如果是有業(yè)務(wù)邏輯的服務(wù),可以在 shutdown() 之前發(fā)送一個(gè)簡(jiǎn)單的響應(yīng),表明本服務(wù)程序的負(fù)載能力已經(jīng)飽和,提示客戶端嘗試下一個(gè)可用的 server(當(dāng)然,下一個(gè)可用的 server 地址不一定要在這個(gè)響應(yīng)里給出,客戶端可以自己去 name service 查詢),這樣方便客戶端快速 failover。
后文將介紹如何處理空閑連接的超時(shí):如果一個(gè)連接長(zhǎng)時(shí)間(若干秒)沒(méi)有輸入數(shù)據(jù),則踢掉此連接。辦法有很多種,我用 Time Wheel 解決。
陳碩 (giantchen_AT_gmail)
Blog.csdn.net/Solstice t.sina.com.cn/giantchen
陳碩關(guān)于分布式系統(tǒng)的系列文章:http://blog.csdn.net/Solstice/category/802325.aspx
本作品采用“Creative Commons 署名-非商業(yè)性使用-禁止演繹 3.0 Unported 許可協(xié)議(cc by-nc-nd)”進(jìn)行許可。
http://creativecommons.org/licenses/by-nc-nd/3.0/
本文所談的“測(cè)試”全部指的是“開(kāi)發(fā)者測(cè)試/developer testing”,由程序員自己來(lái)做,不是由 QA 團(tuán)隊(duì)進(jìn)行的系統(tǒng)測(cè)試。這兩種測(cè)試各有各的用途,不能相互替代。
我在《樸實(shí)的C++設(shè)計(jì)》一文中談到“為了確保正確性,我們另外用Java寫(xiě)了一個(gè)測(cè)試夾具(test harness)來(lái)測(cè)試我們這個(gè)C++程序。這個(gè)測(cè)試夾具模擬了所有與我們這個(gè)C++程序打交道的其他程序,能夠測(cè)試各種正常或異常的情況。基本上任何代碼改動(dòng)和bug修復(fù)都在這個(gè)夾具中有體現(xiàn)。如果要新加一個(gè)功能,會(huì)有對(duì)應(yīng)的測(cè)試用例來(lái)驗(yàn)證其行為。如果發(fā)現(xiàn)了一個(gè)bug,先往夾具里加一個(gè)或幾個(gè)能復(fù)現(xiàn)bug的測(cè)試用例,然后修復(fù)代碼,讓測(cè)試通過(guò)。我們積累了幾百個(gè)測(cè)試用例,這些用例表示了我們對(duì)程序行為的預(yù)期,是一份可以運(yùn)行的文檔。每次代碼改動(dòng)提交之前,我們都會(huì)執(zhí)行一遍測(cè)試,以防低級(jí)錯(cuò)誤發(fā)生。”
今天把 test harness 這個(gè)做法仔細(xì)說(shuō)一說(shuō)。
自動(dòng)化測(cè)試的必要性
我想自動(dòng)化測(cè)試的必要性無(wú)需贅言,自動(dòng)化測(cè)試是 absolutely good stuff。
基本上,要是沒(méi)有自動(dòng)化的測(cè)試,我是不敢改產(chǎn)品代碼的(“改”包括添加新功能和重構(gòu))。自動(dòng)化測(cè)試的作用是把程序已經(jīng)實(shí)現(xiàn)的 features 以 test case 的形式固化下來(lái),將來(lái)任何代碼改動(dòng)如果破壞了現(xiàn)有的功能需求就會(huì)觸發(fā)測(cè)試 failure。好比 DNA 雙鏈的互補(bǔ)關(guān)系,這種互補(bǔ)結(jié)構(gòu)對(duì)保持生物遺傳的穩(wěn)定有重要作用。類似的,自動(dòng)化測(cè)試與被測(cè)程序的互補(bǔ)結(jié)構(gòu)對(duì)保持系統(tǒng)的功能穩(wěn)定有重要作用。
單元測(cè)試的能與不能
一提到自動(dòng)化測(cè)試,我猜很多人想到的是單元測(cè)試(unit testing)。單元測(cè)試確實(shí)有很大的用處,對(duì)于解決某一類型的問(wèn)題很有幫助。粗略地說(shuō),單元測(cè)試主要用于測(cè)試一個(gè)函數(shù)、一個(gè) class 或者相關(guān)的幾個(gè) classes。
最典型的是測(cè)試純函數(shù),比如計(jì)算個(gè)人所得稅的函數(shù),輸出是“起征點(diǎn)、扣除五險(xiǎn)一金之后的應(yīng)納稅所得額、稅率表”,輸出是應(yīng)該繳的個(gè)稅。又比如我在《〈程序中的日期與時(shí)間〉第一章 日期計(jì)算》中用單元測(cè)試來(lái)驗(yàn)證 Julian day number 算法的正確性。再比如我在《“過(guò)家家”版的移動(dòng)離線計(jì)費(fèi)系統(tǒng)實(shí)現(xiàn)》和《模擬銀行窗口排隊(duì)叫號(hào)系統(tǒng)的運(yùn)作》中用單元測(cè)試來(lái)檢查程序運(yùn)行的結(jié)果是否符合預(yù)期。(最后這個(gè)或許不是嚴(yán)格意義上的單元測(cè)試,更像是驗(yàn)收測(cè)試。)
為了能用單元測(cè)試,主代碼有時(shí)候需要做一些改動(dòng)。這對(duì) Java 通常不構(gòu)成問(wèn)題(反正都編譯成 jar 文件,在運(yùn)行的時(shí)候指定 entry point)。對(duì)于 C++,一個(gè)程序只能有一個(gè) main() 入口點(diǎn),要采用單元測(cè)試的話,需要把功能代碼(被測(cè)對(duì)象)做成一個(gè) library,然后讓單元測(cè)試代碼(包含 main() 函數(shù))link 到這個(gè) library 上;當(dāng)然,為了正常啟動(dòng)程序,我們還需要寫(xiě)一個(gè)普通的 main(),并 link 到這個(gè) library 上。
單元測(cè)試的缺點(diǎn)
根據(jù)我的個(gè)人經(jīng)驗(yàn),我發(fā)現(xiàn)單元測(cè)試有以下缺點(diǎn)。
單元測(cè)試是白盒測(cè)試,測(cè)試代碼直接調(diào)用被測(cè)代碼,測(cè)試代碼與被測(cè)代碼緊耦合。從理論上說(shuō),“測(cè)試”應(yīng)該只關(guān)心被測(cè)代碼實(shí)現(xiàn)的功能,不用管它是如何實(shí)現(xiàn)的(包括它提供什么樣的函數(shù)調(diào)用接口)。比方說(shuō),以前面的個(gè)稅計(jì)算器函數(shù)為例,作為使用者,我們只關(guān)心它算的結(jié)果是否正確。但是,如果要寫(xiě)單元測(cè)試,測(cè)試代碼必須調(diào)用被測(cè)代碼,那么測(cè)試代碼必須要知道個(gè)稅計(jì)算器的 package、class、method name、parameter list、return type 等等信息,還要知道如何構(gòu)造這個(gè) class。以上任何一點(diǎn)改動(dòng)都會(huì)造成測(cè)試失敗(編譯就不通過(guò))。
在添加新功能的時(shí)候,我們常會(huì)重構(gòu)已有的代碼,在保持原有功能的情況下讓代碼的“形狀”更適合實(shí)現(xiàn)新的需求。一旦修改原有的代碼,單元測(cè)試就可能編譯不過(guò):比如給成員函數(shù)或構(gòu)造函數(shù)添加一個(gè)參數(shù),或者把成員函數(shù)從一個(gè) class 移到另一個(gè) class。對(duì)于 Java,這個(gè)問(wèn)題還比較好解決,因?yàn)?IDE 的重構(gòu)功能很強(qiáng),能自動(dòng)找到 references,并修改之。
對(duì)于 C++,這個(gè)問(wèn)題更為嚴(yán)重,因?yàn)橐桓墓δ艽a的接口,單元測(cè)試就編譯不過(guò)了,而 C++ 通常沒(méi)有自動(dòng)重構(gòu)工具(語(yǔ)法太復(fù)雜,語(yǔ)意太微妙)可以幫我們,都得手動(dòng)來(lái)。要么每改動(dòng)一點(diǎn)功能代碼就修復(fù)單元測(cè)試,讓編譯通過(guò);要么留著單元測(cè)試編譯不通過(guò),先把功能代碼改成我們想要的樣子,再來(lái)統(tǒng)一修復(fù)單元測(cè)試。
這兩種做法都有困難,前者,C++ 編譯緩慢,如果每改動(dòng)一點(diǎn)就修復(fù)單元測(cè)試,一天下來(lái)也前進(jìn)不了幾步,很多時(shí)間浪費(fèi)在等待編譯上;后者,問(wèn)題更嚴(yán)重,單元測(cè)試與被測(cè)代碼的互補(bǔ)性是保證程序功能穩(wěn)定的關(guān)鍵,如果大幅修改功能代碼的同時(shí)又大幅修改了單元測(cè)試,那么如何保證前后的單元測(cè)試的效果(測(cè)試點(diǎn))不變?如果單元測(cè)試自身的代碼發(fā)生了改動(dòng),如何保證它測(cè)試結(jié)果的有效性?會(huì)不會(huì)某個(gè)手誤讓功能代碼和單元測(cè)試犯了相同的錯(cuò)誤,負(fù)負(fù)得正,測(cè)試還是綠的,但是實(shí)際功能已經(jīng)亮了紅燈?難道我們要為單元測(cè)試寫(xiě)單元測(cè)試嗎?
有時(shí)候,我們需要重新設(shè)計(jì)并重寫(xiě)某個(gè)程序(有可能換用另一種語(yǔ)言)。這時(shí)候舊代碼中的單元測(cè)試完全作廢了(代碼結(jié)構(gòu)發(fā)生巨大改變,甚至連編程語(yǔ)言都換了),其中包含的寶貴的業(yè)務(wù)知識(shí)也付之東流,豈不可惜?
- 為了方便測(cè)試而施行依賴注入,破壞代碼的整體性。
為了讓代碼具有“可測(cè)試性”,我們常會(huì)使用依賴注入技術(shù),這么做的好處據(jù)說(shuō)是“解耦”(其實(shí),有人一句話道破真相:但凡你在某個(gè)地方切斷聯(lián)系,那么你必然會(huì)在另一個(gè)地方重新產(chǎn)生聯(lián)系),壞處就是割裂了代碼的邏輯:?jiǎn)慰匆粔K代碼不知道它是干嘛的,它依賴的對(duì)象不知道在哪兒創(chuàng)建的,如果一個(gè) interface 有多個(gè)實(shí)現(xiàn),不到運(yùn)行的時(shí)候不知道用的是哪個(gè)實(shí)現(xiàn)。(動(dòng)態(tài)綁定的初衷就是如此,想來(lái)讀過(guò)“以面向?qū)ο笏枷雽?shí)現(xiàn)”的代碼的人都明白我在說(shuō)什么。)
以《Muduo 網(wǎng)絡(luò)編程示例之二:Boost.Asio 的聊天服務(wù)器》中出現(xiàn)的聊天服務(wù)器 ChatServer 為例,ChatServer 直接使用了 muduo::net::TcpServer 和 muduo::net::TcpConnection 來(lái)處理網(wǎng)絡(luò)連接并收發(fā)數(shù)據(jù),這個(gè)設(shè)計(jì)簡(jiǎn)單直接。如果要為 ChatServer 寫(xiě)單元測(cè)試,那么首先它肯定不能在構(gòu)造函數(shù)里初始化 TcpServer 了。
稍微復(fù)雜一點(diǎn)的測(cè)試要用 mock object。ChatServer 用 TcpServer 和 TcpConenction 來(lái)收發(fā)消息,為了能單元測(cè)試,我們要為 TcpServer 和 TcpConnection 提供 mock 實(shí)現(xiàn),原本一個(gè)具體類 TcpServer 就變成了一個(gè) interface TcpServer 加兩個(gè)實(shí)現(xiàn) TcpServerImpl 和 TcpServerMock,同理 TcpConnection 也一化為三。ChatServer 本身的代碼也變得復(fù)雜,我們要設(shè)法把 TcpServer 和 TcpConnection 注入到其中,ChatServer 不能自己初始化 TcpServer 對(duì)象。
這恐怕是在 C++ 中使用單元測(cè)試的主要困難之一。Java 有動(dòng)態(tài)代理,還可以用 cglib 來(lái)操作字節(jié)碼以實(shí)現(xiàn)注入。而 C++ 比較原始,只能自己手工實(shí)現(xiàn) interface 和 implementations。這樣原本緊湊的以 concrete class 構(gòu)成的代碼結(jié)構(gòu)因?yàn)閱卧獪y(cè)試的需要而變得松散(所謂“面向接口編程”嘛),而這么做的目的僅僅是為了滿足“源碼級(jí)的可測(cè)試性”,是不是有一點(diǎn)因小失大呢?(這里且暫時(shí)忽略虛函數(shù)和普通函數(shù)在性能上的些微差別。)對(duì)于不同的 test case,可能還需要不同的 mock 對(duì)象,比如 TcpServerMock 和 TcpServerFailureMock,這又增加了編碼的工作量。
此外,如果程序中用到的涉及 IO 的第三方庫(kù)沒(méi)有以 interface 方式暴露接口,而是直接提供的 concrete class (這是對(duì)的,因?yàn)镃++中應(yīng)該《避免使用虛函數(shù)作為庫(kù)的接口》),這也讓編寫(xiě)單元變得困難,因?yàn)榭偛荒茏约喊€(gè) wrapper 一遍吧?難道用 link-time 的注入技術(shù)?
- 某些 failure 場(chǎng)景難以測(cè)試,而考察這些場(chǎng)景對(duì)編寫(xiě)穩(wěn)定的分布式系統(tǒng)有重要作用。比方說(shuō):網(wǎng)絡(luò)連不上、數(shù)據(jù)庫(kù)超時(shí)、系統(tǒng)資源不足。
- 對(duì)多線程程序無(wú)能為力。如果一個(gè)程序的功能涉及多個(gè)線程合作,那么就比較難用單元測(cè)試來(lái)驗(yàn)證其正確性。
- 如果程序涉及比較多的交互(指和其他程序交互,不是指圖形用戶界面),用單元測(cè)試來(lái)構(gòu)造測(cè)試場(chǎng)景比較麻煩,每個(gè)場(chǎng)景要寫(xiě)一堆無(wú)趣的代碼。而這正是分布式系統(tǒng)最需要測(cè)試的地方。
總的來(lái)說(shuō),單元測(cè)試是一個(gè)值得掌握的技術(shù),用在適當(dāng)?shù)牡胤酱_實(shí)能提高生產(chǎn)力。同時(shí),在分布式系統(tǒng)中,我們還需要其他的自動(dòng)化測(cè)試手段。
分布式系統(tǒng)測(cè)試的要點(diǎn)
在分布式系統(tǒng)中,class 與 function 級(jí)別的單元測(cè)試對(duì)整個(gè)系統(tǒng)的幫助不大,當(dāng)然,這種單元測(cè)試對(duì)單個(gè)程序的質(zhì)量有幫助;但是,一堆磚頭壘在一起是變不成大樓的。
分布式系統(tǒng)測(cè)試的要點(diǎn)是測(cè)試進(jìn)程間的交互:一個(gè)進(jìn)程收到客戶請(qǐng)求,該如何處理,然后轉(zhuǎn)發(fā)給其他進(jìn)程;收到響應(yīng)之后,又修改并應(yīng)答客戶。測(cè)試這些多進(jìn)程協(xié)作的場(chǎng)景才算測(cè)到了點(diǎn)子上。
假設(shè)一個(gè)分布式系統(tǒng)由四五種進(jìn)程組成,每個(gè)程序有各自的開(kāi)發(fā)人員。對(duì)于整個(gè)系統(tǒng),我們可以用腳本來(lái)模擬客戶,自動(dòng)化地測(cè)試系統(tǒng)的整體運(yùn)作情況,這種測(cè)試通常由 QA 團(tuán)隊(duì)來(lái)執(zhí)行,也可以作為系統(tǒng)的冒煙測(cè)試。
對(duì)于其中每個(gè)程序的開(kāi)發(fā)人員,上述測(cè)試方法對(duì)日常的開(kāi)發(fā)幫助不大,因?yàn)闇y(cè)試要能通過(guò)必須整個(gè)系統(tǒng)都正常運(yùn)轉(zhuǎn)才行,在開(kāi)發(fā)階段,這一點(diǎn)不是時(shí)時(shí)刻刻都能滿足(有可能你用到的新功能對(duì)方還沒(méi)有實(shí)現(xiàn),這反過(guò)來(lái)影響了你的進(jìn)度)。另一方面,如果出現(xiàn)測(cè)試失敗,開(kāi)發(fā)人員不能立刻知道這是自己的程序出錯(cuò),有可能是環(huán)境原因造成的錯(cuò)誤,這通常要去讀程序日志才能判定。還有,作為開(kāi)發(fā)者測(cè)試,我們希望它無(wú)副作用,每天反復(fù)多次運(yùn)行也不會(huì)增加整個(gè)環(huán)境的負(fù)擔(dān),以整個(gè) QA 系統(tǒng)為測(cè)試平臺(tái)不可避免要留下一些垃圾數(shù)據(jù),而清理這些數(shù)據(jù)又會(huì)花一些寶貴的工作時(shí)間。(你得判斷數(shù)據(jù)是自己的測(cè)試生成的還是別人的測(cè)試留下的,不能誤刪了別人的測(cè)試數(shù)據(jù)。)
作為開(kāi)發(fā)人員,我們需要一種單獨(dú)針對(duì)自己編寫(xiě)的那個(gè)程序的自動(dòng)化測(cè)試方案,一方面提高日常開(kāi)發(fā)的效率,另一方面作為自己那個(gè)程序的功能驗(yàn)證測(cè)試集(即回歸測(cè)試/regression tests)。
分布式系統(tǒng)的抽象觀點(diǎn)
一臺(tái)機(jī)器兩根線
形象地來(lái)看,一個(gè)分布式系統(tǒng)就是一堆機(jī)器,每臺(tái)機(jī)器的屁股上拖著兩根線:電源線和網(wǎng)線(不考慮 SAN 等存儲(chǔ)設(shè)備),電源線插到電源插座上,網(wǎng)線插到交換機(jī)上。

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

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

對(duì)于 DataNode 的開(kāi)發(fā)者,他們也可以寫(xiě)一個(gè)專門(mén)的 test harness,模擬 Client 和 NameNode。

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

以上是 NTP 協(xié)議收發(fā)消息的協(xié)議,RTT = (T4-T1) – (T3-T2),時(shí)間差 = ((T4+T1)-(T2+T3))/2。NTP 的要求是往返路徑上的單程延遲要盡量相等,這樣才能減少系統(tǒng)誤差。偶然誤差由單程延遲的不確定性決定。
在我設(shè)計(jì)的 roundtrip 示例程序中,協(xié)議有所簡(jiǎn)化:

簡(jiǎn)化之后的協(xié)議少取一次時(shí)間,因?yàn)?server 收到消息之后立刻發(fā)送回 client,耗時(shí)很少(若干微秒),基本不影響最終結(jié)果。
我設(shè)計(jì)的消息格式是 16 字節(jié)定長(zhǎng)消息:

T1 和 T2 都是 muduo::Timestamp,一個(gè) int64_t,表示從 Epoch 到現(xiàn)在的微秒數(shù)。
為了讓消息的單程往返時(shí)間接近,server 和 client 發(fā)送的消息都是 16 bytes,這樣做到對(duì)稱。
由于是定長(zhǎng)消息,可以不必使用 codec,在 message callback 中直接用
while (buffer->readableBytes() >= frameLen) { ... } 就能 decode。
請(qǐng)讀者思考,如果把 while 換成 if 會(huì)有什么后果?
client 程序以 200ms 為間隔發(fā)送消息,在收到消息之后打印 RTT 和 clock offset。一次運(yùn)作實(shí)例如下:

這個(gè)例子中,client 和 server 的時(shí)鐘不是完全對(duì)準(zhǔn)的,server 的時(shí)間快了 850 us,用 roundtrip 程序能測(cè)量出這個(gè)時(shí)間差。有了這個(gè)時(shí)間差就能校正分布式系統(tǒng)中測(cè)量得到的消息延遲。
比方說(shuō)以上圖為例,server 在它本地 1.235000 時(shí)刻發(fā)送了一條消息,client 在它本地 1.234300 收到這條消息,直接計(jì)算的話延遲是 –700us。這個(gè)結(jié)果肯定是錯(cuò)的,因?yàn)?server 和 client 不在一個(gè)時(shí)鐘域(這是數(shù)字電路中的概念),它們的時(shí)間直接相減無(wú)意義。如果我們已經(jīng)測(cè)量得到 server 比 client 快 850us,那么做用這個(gè)數(shù)據(jù)一次校正: -700+850 = 150us,這個(gè)結(jié)果就比較符合實(shí)際了。當(dāng)然,在實(shí)際應(yīng)用中,clock offset 要經(jīng)過(guò)一個(gè)低通濾波才能使用,不然偶然性太大。
請(qǐng)讀者思考,為什么不能直接以 RTT/2 作為兩天機(jī)器之間收發(fā)消息的單程延遲?
這個(gè)程序在局域網(wǎng)中使用沒(méi)有問(wèn)題,如果在廣域網(wǎng)上使用,而且 RTT 大于 200ms,那么受 Nagle 算法影響,測(cè)量結(jié)果是錯(cuò)誤的(具體分析留作練習(xí),這能測(cè)試對(duì) Nagle 的理解),這時(shí)候我們需要設(shè)置 TCP_NODELAY 參數(shù),讓程序在廣域網(wǎng)上也能正常工作。
陳碩 (giantchen_AT_gmail)
Blog.csdn.net/Solstice t.sina.com.cn/giantchen
Muduo 全系列文章列表: http://blog.csdn.net/Solstice/category/779646.aspx
本文介紹 Muduo 中輸入輸出緩沖區(qū)的設(shè)計(jì)與實(shí)現(xiàn)。
本文中 buffer 指一般的應(yīng)用層緩沖區(qū)、緩沖技術(shù),Buffer 特指 muduo::net::Buffer class。
本文前兩節(jié)的內(nèi)容已事先發(fā)表在 muduo 英文博客 http://muduo.chenshuo.com/2011/04/essentials-of-non-blocking-tcp-network.html 。
Muduo 的 IO 模型
UNPv1 第 6.2 節(jié)總結(jié)了 Unix/Linux 上的五種 IO 模型:阻塞(blocking)、非阻塞(non-blocking)、IO 復(fù)用(IO multiplexing)、信號(hào)驅(qū)動(dòng)(signal-driven)、異步(asynchronous)。這些都是單線程下的 IO 模型。
C10k 問(wèn)題的頁(yè)面介紹了五種 IO 策略,把線程也納入考量。(現(xiàn)在 C10k 已經(jīng)不是什么問(wèn)題,C100k 也不是大問(wèn)題,C1000k 才算得上挑戰(zhàn))。
在這個(gè)多核時(shí)代,線程是不可避免的。那么服務(wù)端網(wǎng)絡(luò)編程該如何選擇線程模型呢?我贊同 libev 作者的觀點(diǎn):one loop per thread is usually a good model。之前我也不止一次表述過(guò)這個(gè)觀點(diǎn),見(jiàn)《多線程服務(wù)器的常用編程模型》《多線程服務(wù)器的適用場(chǎng)合》。
如果采用 one loop per thread 的模型,多線程服務(wù)端編程的問(wèn)題就簡(jiǎn)化為如何設(shè)計(jì)一個(gè)高效且易于使用的 event loop,然后每個(gè)線程 run 一個(gè) event loop 就行了(當(dāng)然、同步和互斥是不可或缺的)。在“高效”這方面已經(jīng)有了很多成熟的范例(libev、libevent、memcached、varnish、lighttpd、nginx),在“易于使用”方面我希望 muduo 能有所作為。(muduo 可算是用現(xiàn)代 C++ 實(shí)現(xiàn)了 Reactor 模式,比起原始的 Reactor 來(lái)說(shuō)要好用得多。)
event loop 是 non-blocking 網(wǎng)絡(luò)編程的核心,在現(xiàn)實(shí)生活中,non-blocking 幾乎總是和 IO-multiplexing 一起使用,原因有兩點(diǎn):
- 沒(méi)有人真的會(huì)用輪詢 (busy-pooling) 來(lái)檢查某個(gè) non-blocking IO 操作是否完成,這樣太浪費(fèi) CPU cycles。
- IO-multiplex 一般不能和 blocking IO 用在一起,因?yàn)?blocking IO 中 read()/write()/accept()/connect() 都有可能阻塞當(dāng)前線程,這樣線程就沒(méi)辦法處理其他 socket 上的 IO 事件了。見(jiàn) UNPv1 第 16.6 節(jié)“nonblocking accept”的例子。
所以,當(dāng)我提到 non-blocking 的時(shí)候,實(shí)際上指的是 non-blocking + IO-muleiplexing,單用其中任何一個(gè)是不現(xiàn)實(shí)的。另外,本文所有的“連接”均指 TCP 連接,socket 和 connection 在文中可互換使用。
當(dāng)然,non-blocking 編程比 blocking 難得多,見(jiàn)陳碩在《Muduo 網(wǎng)絡(luò)編程示例之零:前言》中“TCP 網(wǎng)絡(luò)編程本質(zhì)論”一節(jié)列舉的難點(diǎn)。基于 event loop 的網(wǎng)絡(luò)編程跟直接用 C/C++ 編寫(xiě)單線程 Windows 程序頗為相像:程序不能阻塞,否則窗口就失去響應(yīng)了;在 event handler 中,程序要盡快交出控制權(quán),返回窗口的事件循環(huán)。
為什么 non-blocking 網(wǎng)絡(luò)編程中應(yīng)用層 buffer 是必須的?
Non-blocking IO 的核心思想是避免阻塞在 read() 或 write() 或其他 IO 系統(tǒng)調(diào)用上,這樣可以最大限度地復(fù)用 thread-of-control,讓一個(gè)線程能服務(wù)于多個(gè) socket 連接。IO 線程只能阻塞在 IO-multiplexing 函數(shù)上,如 select()/poll()/epoll_wait()。這樣一來(lái),應(yīng)用層的緩沖是必須的,每個(gè) TCP socket 都要有 stateful 的 input buffer 和 output buffer。
TcpConnection 必須要有 output buffer
考慮一個(gè)常見(jiàn)場(chǎng)景:程序想通過(guò) TCP 連接發(fā)送 100k 字節(jié)的數(shù)據(jù),但是在 write() 調(diào)用中,操作系統(tǒng)只接受了 80k 字節(jié)(受 TCP advertised window 的控制,細(xì)節(jié)見(jiàn) TCPv1),你肯定不想在原地等待,因?yàn)椴恢罆?huì)等多久(取決于對(duì)方什么時(shí)候接受數(shù)據(jù),然后滑動(dòng) TCP 窗口)。程序應(yīng)該盡快交出控制權(quán),返回 event loop。在這種情況下,剩余的 20k 字節(jié)數(shù)據(jù)怎么辦?
對(duì)于應(yīng)用程序而言,它只管生成數(shù)據(jù),它不應(yīng)該關(guān)心到底數(shù)據(jù)是一次性發(fā)送還是分成幾次發(fā)送,這些應(yīng)該由網(wǎng)絡(luò)庫(kù)來(lái)操心,程序只要調(diào)用 TcpConnection::send() 就行了,網(wǎng)絡(luò)庫(kù)會(huì)負(fù)責(zé)到底。網(wǎng)絡(luò)庫(kù)應(yīng)該接管這剩余的 20k 字節(jié)數(shù)據(jù),把它保存在該 TCP connection 的 output buffer 里,然后注冊(cè) POLLOUT 事件,一旦 socket 變得可寫(xiě)就立刻發(fā)送數(shù)據(jù)。當(dāng)然,這第二次 write() 也不一定能完全寫(xiě)入 20k 字節(jié),如果還有剩余,網(wǎng)絡(luò)庫(kù)應(yīng)該繼續(xù)關(guān)注 POLLOUT 事件;如果寫(xiě)完了 20k 字節(jié),網(wǎng)絡(luò)庫(kù)應(yīng)該停止關(guān)注 POLLOUT,以免造成 busy loop。(Muduo EventLoop 采用的是 epoll level trigger,這么做的具體原因我以后再說(shuō)。)
如果程序又寫(xiě)入了 50k 字節(jié),而這時(shí)候 output buffer 里還有待發(fā)送的 20k 數(shù)據(jù),那么網(wǎng)絡(luò)庫(kù)不應(yīng)該直接調(diào)用 write(),而應(yīng)該把這 50k 數(shù)據(jù) append 在那 20k 數(shù)據(jù)之后,等 socket 變得可寫(xiě)的時(shí)候再一并寫(xiě)入。
如果 output buffer 里還有待發(fā)送的數(shù)據(jù),而程序又想關(guān)閉連接(對(duì)程序而言,調(diào)用 TcpConnection::send() 之后他就認(rèn)為數(shù)據(jù)遲早會(huì)發(fā)出去),那么這時(shí)候網(wǎng)絡(luò)庫(kù)不能立刻關(guān)閉連接,而要等數(shù)據(jù)發(fā)送完畢,見(jiàn)我在《為什么 muduo 的 shutdown() 沒(méi)有直接關(guān)閉 TCP 連接?》一文中的講解。
綜上,要讓程序在 write 操作上不阻塞,網(wǎng)絡(luò)庫(kù)必須要給每個(gè) tcp connection 配置 output buffer。
TcpConnection 必須要有 input buffer
TCP 是一個(gè)無(wú)邊界的字節(jié)流協(xié)議,接收方必須要處理“收到的數(shù)據(jù)尚不構(gòu)成一條完整的消息”和“一次收到兩條消息的數(shù)據(jù)”等等情況。一個(gè)常見(jiàn)的場(chǎng)景是,發(fā)送方 send 了兩條 10k 字節(jié)的消息(共 20k),接收方收到數(shù)據(jù)的情況可能是:
- 一次性收到 20k 數(shù)據(jù)
- 分兩次收到,第一次 5k,第二次 15k
- 分兩次收到,第一次 15k,第二次 5k
- 分兩次收到,第一次 10k,第二次 10k
- 分三次收到,第一次 6k,第二次 8k,第三次 6k
- 其他任何可能
網(wǎng)絡(luò)庫(kù)在處理“socket 可讀”事件的時(shí)候,必須一次性把 socket 里的數(shù)據(jù)讀完(從操作系統(tǒng) buffer 搬到應(yīng)用層 buffer),否則會(huì)反復(fù)觸發(fā) POLLIN 事件,造成 busy-loop。(Again, Muduo EventLoop 采用的是 epoll level trigger,這么做的具體原因我以后再說(shuō)。)
那么網(wǎng)絡(luò)庫(kù)必然要應(yīng)對(duì)“數(shù)據(jù)不完整”的情況,收到的數(shù)據(jù)先放到 input buffer 里,等構(gòu)成一條完整的消息再通知程序的業(yè)務(wù)邏輯。這通常是 codec 的職責(zé),見(jiàn)陳碩《Muduo 網(wǎng)絡(luò)編程示例之二:Boost.Asio 的聊天服務(wù)器》一文中的“TCP 分包”的論述與代碼。
所以,在 tcp 網(wǎng)絡(luò)編程中,網(wǎng)絡(luò)庫(kù)必須要給每個(gè) tcp connection 配置 input buffer。
所有 muduo 中的 IO 都是帶緩沖的 IO (buffered IO),你不會(huì)自己去 read() 或 write() 某個(gè) socket,只會(huì)操作 TcpConnection 的 input buffer 和 output buffer。更確切的說(shuō),是在 onMessage() 回調(diào)里讀取 input buffer;調(diào)用 TcpConnection::send() 來(lái)間接操作 output buffer,一般不會(huì)直接操作 output buffer。
btw, muduo 的 onMessage() 的原型如下,它既可以是 free function,也可以是 member function,反正 muduo TcpConnection 只認(rèn) boost::function<>。
void onMessage(const TcpConnectionPtr& conn, Buffer* buf, Timestamp receiveTime);
對(duì)于網(wǎng)絡(luò)程序來(lái)說(shuō),一個(gè)簡(jiǎn)單的驗(yàn)收測(cè)試是:輸入數(shù)據(jù)每次收到一個(gè)字節(jié)(200 字節(jié)的輸入數(shù)據(jù)會(huì)分 200 次收到,每次間隔 10 ms),程序的功能不受影響。對(duì)于 Muduo 程序,通常可以用 codec 來(lái)分離“消息接收”與“消息處理”,見(jiàn)陳碩《在 muduo 中實(shí)現(xiàn) protobuf 編解碼器與消息分發(fā)器》一文中對(duì)“編解碼器 codec”的介紹。
如果某個(gè)網(wǎng)絡(luò)庫(kù)只提供相當(dāng)于 char buf[8192] 的緩沖,或者根本不提供緩沖區(qū),而僅僅通知程序“某 socket 可讀/某 socket 可寫(xiě)”,要程序自己操心 IO buffering,這樣的網(wǎng)絡(luò)庫(kù)用起來(lái)就很不方便了。(我有所指,你懂得。)
Buffer 的要求
http://code.google.com/p/muduo/source/browse/trunk/muduo/net/Buffer.h
Muduo Buffer 的設(shè)計(jì)考慮了常見(jiàn)的網(wǎng)絡(luò)編程需求,我試圖在易用性和性能之間找一個(gè)平衡點(diǎn),目前這個(gè)平衡點(diǎn)更偏向于易用性。
Muduo Buffer 的設(shè)計(jì)要點(diǎn):
- 對(duì)外表現(xiàn)為一塊連續(xù)的內(nèi)存(char*, len),以方便客戶代碼的編寫(xiě)。
- 其 size() 可以自動(dòng)增長(zhǎng),以適應(yīng)不同大小的消息。它不是一個(gè) fixed size array (即 char buf[8192])。
- 內(nèi)部以 vector of char 來(lái)保存數(shù)據(jù),并提供相應(yīng)的訪問(wèn)函數(shù)。
Buffer 其實(shí)像是一個(gè) queue,從末尾寫(xiě)入數(shù)據(jù),從頭部讀出數(shù)據(jù)。
誰(shuí)會(huì)用 Buffer?誰(shuí)寫(xiě)誰(shuí)讀?根據(jù)前文分析,TcpConnection 會(huì)有兩個(gè) Buffer 成員,input buffer 與 output buffer。
- input buffer,TcpConnection 會(huì)從 socket 讀取數(shù)據(jù),然后寫(xiě)入 input buffer(其實(shí)這一步是用 Buffer::readFd() 完成的);客戶代碼從 input buffer 讀取數(shù)據(jù)。
- output buffer,客戶代碼會(huì)把數(shù)據(jù)寫(xiě)入 output buffer(其實(shí)這一步是用 TcpConnection::send() 完成的);TcpConnection 從 output buffer 讀取數(shù)據(jù)并寫(xiě)入 socket。
其實(shí),input 和 output 是針對(duì)客戶代碼而言,客戶代碼從 input 讀,往 output 寫(xiě)。TcpConnection 的讀寫(xiě)正好相反。
以下是 muduo::net::Buffer 的類圖。請(qǐng)注意,為了后面畫(huà)圖方便,這個(gè)類圖跟實(shí)際代碼略有出入,但不影響我要表達(dá)的觀點(diǎn)。

這里不介紹每個(gè)成員函數(shù)的作用,留給《Muduo 網(wǎng)絡(luò)編程示例》系列。下文會(huì)仔細(xì)介紹 readIndex 和 writeIndex 的作用。
Buffer::readFd()
我在《Muduo 網(wǎng)絡(luò)編程示例之零:前言》中寫(xiě)道
- 在非阻塞網(wǎng)絡(luò)編程中,如何設(shè)計(jì)并使用緩沖區(qū)?一方面我們希望減少系統(tǒng)調(diào)用,一次讀的數(shù)據(jù)越多越劃算,那么似乎應(yīng)該準(zhǔn)備一個(gè)大的緩沖區(qū)。另一方面,我們系統(tǒng)減少內(nèi)存占用。如果有 10k 個(gè)連接,每個(gè)連接一建立就分配 64k 的讀緩沖的話,將占用 640M 內(nèi)存,而大多數(shù)時(shí)候這些緩沖區(qū)的使用率很低。muduo 用 readv 結(jié)合棧上空間巧妙地解決了這個(gè)問(wèn)題。
具體做法是,在棧上準(zhǔn)備一個(gè) 65536 字節(jié)的 stackbuf,然后利用 readv() 來(lái)讀取數(shù)據(jù),iovec 有兩塊,第一塊指向 muduo Buffer 中的 writable 字節(jié),另一塊指向棧上的 stackbuf。這樣如果讀入的數(shù)據(jù)不多,那么全部都讀到 Buffer 中去了;如果長(zhǎng)度超過(guò) Buffer 的 writable 字節(jié)數(shù),就會(huì)讀到棧上的 stackbuf 里,然后程序再把 stackbuf 里的數(shù)據(jù) append 到 Buffer 中。
代碼見(jiàn) http://code.google.com/p/muduo/source/browse/trunk/muduo/net/Buffer.cc#36
這么做利用了臨時(shí)棧上空間,避免開(kāi)巨大 Buffer 造成的內(nèi)存浪費(fèi),也避免反復(fù)調(diào)用 read() 的系統(tǒng)開(kāi)銷(通常一次 readv() 系統(tǒng)調(diào)用就能讀完全部數(shù)據(jù))。
這算是一個(gè)小小的創(chuàng)新吧。
線程安全?
muduo::net::Buffer 不是線程安全的,這么做是有意的,原因如下:
- 對(duì)于 input buffer,onMessage() 回調(diào)始終發(fā)生在該 TcpConnection 所屬的那個(gè) IO 線程,應(yīng)用程序應(yīng)該在 onMessage() 完成對(duì) input buffer 的操作,并且不要把 input buffer 暴露給其他線程。這樣所有對(duì) input buffer 的操作都在同一個(gè)線程,Buffer class 不必是線程安全的。
- 對(duì)于 output buffer,應(yīng)用程序不會(huì)直接操作它,而是調(diào)用 TcpConnection::send() 來(lái)發(fā)送數(shù)據(jù),后者是線程安全的。
如果 TcpConnection::send() 調(diào)用發(fā)生在該 TcpConnection 所屬的那個(gè) IO 線程,那么它會(huì)轉(zhuǎn)而調(diào)用 TcpConnection::sendInLoop(),sendInLoop() 會(huì)在當(dāng)前線程(也就是 IO 線程)操作 output buffer;如果 TcpConnection::send() 調(diào)用發(fā)生在別的線程,它不會(huì)在當(dāng)前線程調(diào)用 sendInLoop() ,而是通過(guò) EventLoop::runInLoop() 把 sendInLoop() 函數(shù)調(diào)用轉(zhuǎn)移到 IO 線程(聽(tīng)上去頗為神奇?),這樣 sendInLoop() 還是會(huì)在 IO 線程操作 output buffer,不會(huì)有線程安全問(wèn)題。當(dāng)然,跨線程的函數(shù)轉(zhuǎn)移調(diào)用涉及函數(shù)參數(shù)的跨線程傳遞,一種簡(jiǎn)單的做法是把數(shù)據(jù)拷一份,絕對(duì)安全(不明白的同學(xué)請(qǐng)閱讀代碼)。
另一種更為高效做法是用 swap()。這就是為什么 TcpConnection::send() 的某個(gè)重載以 Buffer* 為參數(shù),而不是 const Buffer&,這樣可以避免拷貝,而用 Buffer::swap() 實(shí)現(xiàn)高效的線程間數(shù)據(jù)轉(zhuǎn)移。(最后這點(diǎn),僅為設(shè)想,暫未實(shí)現(xiàn)。目前仍然以數(shù)據(jù)拷貝方式在線程間傳遞,略微有些性能損失。)
Muduo Buffer 的數(shù)據(jù)結(jié)構(gòu)
Buffer 的內(nèi)部是一個(gè) vector of char,它是一塊連續(xù)的內(nèi)存。此外,Buffer 有兩個(gè) data members,指向該 vector 中的元素。這兩個(gè) indices 的類型是 int,不是 char*,目的是應(yīng)對(duì)迭代器失效。muduo Buffer 的設(shè)計(jì)參考了 Netty 的 ChannelBuffer 和 libevent 1.4.x 的 evbuffer。不過(guò),其 prependable 可算是一點(diǎn)“微創(chuàng)新”。
Muduo Buffer 的數(shù)據(jù)結(jié)構(gòu)如下:
圖 1
兩個(gè) indices 把 vector 的內(nèi)容分為三塊:prependable、readable、writable,各塊的大小是(公式一):
prependable = readIndex
readable = writeIndex - readIndex
writable = size() - writeIndex
(prependable 的作用留到后面討論。)
readIndex 和 writeIndex 滿足以下不變式(invariant):
0 ≤ readIndex ≤ writeIndex ≤ data.size()
Muduo Buffer 里有兩個(gè)常數(shù) kCheapPrepend 和 kInitialSize,定義了 prependable 的初始大小和 writable 的初始大小。(readable 的初始大小為 0。)在初始化之后,Buffer 的數(shù)據(jù)結(jié)構(gòu)如下:括號(hào)里的數(shù)字是該變量或常量的值。
圖 2
根據(jù)以上(公式一)可算出各塊的大小,剛剛初始化的 Buffer 里沒(méi)有 payload 數(shù)據(jù),所以 readable == 0。
Muduo Buffer 的操作
1. 基本的 read-write cycle
Buffer 初始化后的情況見(jiàn)圖 1,如果有人向 Buffer 寫(xiě)入了 200 字節(jié),那么其布局是:
圖 3
圖 3 中 writeIndex 向后移動(dòng)了 200 字節(jié),readIndex 保持不變,readable 和 writable 的值也有變化。
如果有人從 Buffer read() & retrieve() (下稱“讀入”)了 50 字節(jié),結(jié)果見(jiàn)圖 4。與上圖相比,readIndex 向后移動(dòng) 50 字節(jié),writeIndex 保持不變,readable 和 writable 的值也有變化(這句話往后從略)。
圖 4
然后又寫(xiě)入了 200 字節(jié),writeIndex 向后移動(dòng)了 200 字節(jié),readIndex 保持不變,見(jiàn)圖 5。
圖 5
接下來(lái),一次性讀入 350 字節(jié),請(qǐng)注意,由于全部數(shù)據(jù)讀完了,readIndex 和 writeIndex 返回原位以備新一輪使用,見(jiàn)圖 6,這和圖 2 是一樣的。
圖 6
以上過(guò)程可以看作是發(fā)送方發(fā)送了兩條消息,長(zhǎng)度分別為 50 字節(jié)和 350 字節(jié),接收方分兩次收到數(shù)據(jù),每次 200 字節(jié),然后進(jìn)行分包,再分兩次回調(diào)客戶代碼。
自動(dòng)增長(zhǎng)
Muduo Buffer 不是固定長(zhǎng)度的,它可以自動(dòng)增長(zhǎng),這是使用 vector 的直接好處。
假設(shè)當(dāng)前的狀態(tài)如圖 7 所示。(這和前面圖 5 是一樣的。)
圖 7
客戶代碼一次性寫(xiě)入 1000 字節(jié),而當(dāng)前可寫(xiě)的字節(jié)數(shù)只有 624,那么 buffer 會(huì)自動(dòng)增長(zhǎng)以容納全部數(shù)據(jù),得到的結(jié)果是圖 8。注意 readIndex 返回到了前面,以保持 prependable 等于 kCheapPrependable。由于 vector 重新分配了內(nèi)存,原來(lái)指向它元素的指針會(huì)失效,這就是為什么 readIndex 和 writeIndex 是整數(shù)下標(biāo)而不是指針。
圖 8
然后讀入 350 字節(jié),readIndex 前移,見(jiàn)圖 9。
圖 9
最后,讀完剩下的 1000 字節(jié),readIndex 和 writeIndex 返回 kCheapPrependable,見(jiàn)圖 10。
圖 10
注意 buffer 并沒(méi)有縮小大小,下次寫(xiě)入 1350 字節(jié)就不會(huì)重新分配內(nèi)存了。換句話說(shuō),Muduo Buffer 的 size() 是自適應(yīng)的,它一開(kāi)始的初始值是 1k,如果程序里邊經(jīng)常收發(fā) 10k 的數(shù)據(jù),那么用幾次之后它的 size() 會(huì)自動(dòng)增長(zhǎng)到 10k,然后就保持不變。這樣一方面避免浪費(fèi)內(nèi)存(有的程序可能只需要 4k 的緩沖),另一方面避免反復(fù)分配內(nèi)存。當(dāng)然,客戶代碼可以手動(dòng) shrink() buffer size()。
size() 與 capacity()
使用 vector 的另一個(gè)好處是它的 capcity() 機(jī)制減少了內(nèi)存分配的次數(shù)。比方說(shuō)程序反復(fù)寫(xiě)入 1 字節(jié),muduo Buffer 不會(huì)每次都分配內(nèi)存,vector 的 capacity() 以指數(shù)方式增長(zhǎng),讓 push_back() 的平均復(fù)雜度是常數(shù)。比方說(shuō)經(jīng)過(guò)第一次增長(zhǎng),size() 剛好滿足寫(xiě)入的需求,如圖 11。但這個(gè)時(shí)候 vector 的 capacity() 已經(jīng)大于 size(),在接下來(lái)寫(xiě)入 capacity()-size() 字節(jié)的數(shù)據(jù)時(shí),都不會(huì)重新分配內(nèi)存,見(jiàn)圖 12。
圖 11
圖 12
細(xì)心的讀者可能會(huì)發(fā)現(xiàn)用 capacity() 也不是完美的,它有優(yōu)化的余地。具體來(lái)說(shuō),vector::resize() 會(huì)初始化(memset/bzero)內(nèi)存,而我們不需要它初始化,因?yàn)榉凑⒖叹鸵钊霐?shù)據(jù)。比如,在圖 12 的基礎(chǔ)上寫(xiě)入 200 字節(jié),由于 capacity() 足夠大,不會(huì)重新分配內(nèi)存,這是好事;但是 vector::resize() 會(huì)先把那 200 字節(jié)設(shè)為 0 (圖 13),然后 muduo buffer 再填入數(shù)據(jù)(圖 14)。這么做稍微有點(diǎn)浪費(fèi),不過(guò)我不打算優(yōu)化它,除非它確實(shí)造成了性能瓶頸。(精通 STL 的讀者可能會(huì)說(shuō)用 vector::append() 以避免浪費(fèi),但是 writeIndex 和 size() 不一定是對(duì)齊的,會(huì)有別的麻煩。)
圖 13
圖 14
google protobuf 中有一個(gè) STLStringResizeUninitialized 函數(shù),干的就是這個(gè)事情。
內(nèi)部騰挪
有時(shí)候,經(jīng)過(guò)若干次讀寫(xiě),readIndex 移到了比較靠后的位置,留下了巨大的 prependable 空間,見(jiàn)圖 14。
圖 14
這時(shí)候,如果我們想寫(xiě)入 300 字節(jié),而 writable 只有 200 字節(jié),怎么辦?muduo Buffer 在這種情況下不會(huì)重新分配內(nèi)存,而是先把已有的數(shù)據(jù)移到前面去,騰出 writable 空間,見(jiàn)圖 15。
圖 15
然后,就可以寫(xiě)入 300 字節(jié)了,見(jiàn)圖 16。
圖 16
這么做的原因是,如果重新分配內(nèi)存,反正也是要把數(shù)據(jù)拷到新分配的內(nèi)存區(qū)域,代價(jià)只會(huì)更大。
prepend
前面說(shuō) muduo Buffer 有個(gè)小小的創(chuàng)新(或許不是創(chuàng)新,我記得在哪兒看到過(guò)類似的做法,忘了出處),即提供 prependable 空間,讓程序能以很低的代價(jià)在數(shù)據(jù)前面添加幾個(gè)字節(jié)。
比方說(shuō),程序以固定的4個(gè)字節(jié)表示消息的長(zhǎng)度(即《Muduo 網(wǎng)絡(luò)編程示例之二:Boost.Asio 的聊天服務(wù)器》中的 LengthHeaderCodec),我要序列化一個(gè)消息,但是不知道它有多長(zhǎng),那么我可以一直 append() 直到序列化完成(圖 17,寫(xiě)入了 200 字節(jié)),然后再在序列化數(shù)據(jù)的前面添加消息的長(zhǎng)度(圖 18,把 200 這個(gè)數(shù) prepend 到首部)。
圖 17
圖 18
通過(guò)預(yù)留 kCheapPrependable 空間,可以簡(jiǎn)化客戶代碼,一個(gè)簡(jiǎn)單的空間換時(shí)間思路。
其他設(shè)計(jì)方案
這里簡(jiǎn)單談?wù)勂渌赡艿膽?yīng)用層 buffer 設(shè)計(jì)方案。
不用 vector<char>?
如果有 STL 潔癖,那么可以自己管理內(nèi)存,以 4 個(gè)指針為 buffer 的成員,數(shù)據(jù)結(jié)構(gòu)見(jiàn)圖 19。
圖 19
說(shuō)實(shí)話我不覺(jué)得這種方案比 vector 好。代碼變復(fù)雜,性能也未見(jiàn)得有 noticeable 的改觀。
如果放棄“連續(xù)性”要求,可以用 circular buffer,這樣可以減少一點(diǎn)內(nèi)存拷貝(沒(méi)有“內(nèi)部騰挪”)。
Zero copy ?
如果對(duì)性能有極高的要求,受不了 copy() 與 resize(),那么可以考慮實(shí)現(xiàn)分段連續(xù)的 zero copy buffer 再配合 gather scatter IO,數(shù)據(jù)結(jié)構(gòu)如圖 20,這是 libevent 2.0.x 的設(shè)計(jì)方案。TCPv2介紹的 BSD TCP/IP 實(shí)現(xiàn)中的 mbuf 也是類似的方案,Linux 的 sk_buff 估計(jì)也差不多。細(xì)節(jié)有出入,但基本思路都是不要求數(shù)據(jù)在內(nèi)存中連續(xù),而是用鏈表把數(shù)據(jù)塊鏈接到一起。
圖 20
當(dāng)然,高性能的代價(jià)是代碼變得晦澀難讀,buffer 不再是連續(xù)的,parse 消息會(huì)稍微麻煩。如果你的程序只處理 protobuf Message,這不是問(wèn)題,因?yàn)?protobuf 有 ZeroCopyInputStream 接口,只要實(shí)現(xiàn)這個(gè)接口,parsing 的事情就交給 protobuf Message 去操心了。
性能是不是問(wèn)題?看跟誰(shuí)比
看到這里,有的讀者可能會(huì)嘀咕,muduo Buffer 有那么多可以優(yōu)化的地方,其性能會(huì)不會(huì)太低?對(duì)此,我的回應(yīng)是“可以優(yōu)化,不一定值得優(yōu)化。”
Muduo 的設(shè)計(jì)目標(biāo)是用于開(kāi)發(fā)公司內(nèi)部的分布式程序。換句話說(shuō),它是用來(lái)寫(xiě)專用的 Sudoku server 或者游戲服務(wù)器,不是用來(lái)寫(xiě)通用的 httpd 或 ftpd 或 www proxy。前者通常有業(yè)務(wù)邏輯,后者更強(qiáng)調(diào)高并發(fā)與高吞吐。
以 Sudoku 為例,假設(shè)求解一個(gè) Sudoku 問(wèn)題需要 0.2ms,服務(wù)器有 8 個(gè)核,那么理想情況下每秒最多能求解 40,000 個(gè)問(wèn)題。每次 Sudoku 請(qǐng)求的數(shù)據(jù)大小低于 100 字節(jié)(一個(gè) 9x9 的數(shù)獨(dú)只要 81 字節(jié),加上 header 也可以控制在 100 bytes 以下),就是說(shuō) 100 x 40000 = 4 MB per second 的吞吐量就足以讓服務(wù)器的 CPU 飽和。在這種情況下,去優(yōu)化 Buffer 的內(nèi)存拷貝次數(shù)似乎沒(méi)有意義。
再舉一個(gè)例子,目前最常用的千兆以太網(wǎng)的裸吞吐量是 125MB/s,扣除以太網(wǎng) header、IP header、TCP header之后,應(yīng)用層的吞吐率大約在 115 MB/s 上下。而現(xiàn)在服務(wù)器上最常用的 DDR2/DDR3 內(nèi)存的帶寬至少是 4GB/s,比千兆以太網(wǎng)高 40 倍以上。就是說(shuō),對(duì)于幾 k 或幾十 k 大小的數(shù)據(jù),在內(nèi)存里邊拷幾次根本不是問(wèn)題,因?yàn)槭芤蕴W(wǎng)延遲和帶寬的限制,跟這個(gè)程序通信的其他機(jī)器上的程序不會(huì)覺(jué)察到性能差異。
最后舉一個(gè)例子,如果你實(shí)現(xiàn)的服務(wù)程序要跟數(shù)據(jù)庫(kù)打交道,那么瓶頸常常在 DB 上,優(yōu)化服務(wù)程序本身不見(jiàn)得能提高性能(從 DB 讀一次數(shù)據(jù)往往就抵消了你做的全部 low-level 優(yōu)化),這時(shí)不如把精力投入在 DB 調(diào)優(yōu)上。
專用服務(wù)程序與通用服務(wù)程序的另外一點(diǎn)區(qū)別是 benchmark 的對(duì)象不同。如果你打算寫(xiě)一個(gè) httpd,自然有人會(huì)拿來(lái)和目前最好的 nginx 對(duì)比,立馬就能比出性能高低。然而,如果你寫(xiě)一個(gè)實(shí)現(xiàn)公司內(nèi)部業(yè)務(wù)的服務(wù)程序(比如分布式存儲(chǔ)或者搜索或者微博或者短網(wǎng)址),由于市面上沒(méi)有同等功能的開(kāi)源實(shí)現(xiàn),你不需要在優(yōu)化上投入全部精力,只要一版做得比一版好就行。先正確實(shí)現(xiàn)所需的功能,投入生產(chǎn)應(yīng)用,然后再根據(jù)真實(shí)的負(fù)載情況來(lái)做優(yōu)化,這恐怕比在編碼階段就盲目調(diào)優(yōu)要更 effective 一些。
Muduo 的設(shè)計(jì)目標(biāo)之一是吞吐量能讓千兆以太網(wǎng)飽和,也就是每秒收發(fā) 120 兆字節(jié)的數(shù)據(jù)。這個(gè)很容易就達(dá)到,不用任何特別的努力。
如果確實(shí)在內(nèi)存帶寬方面遇到問(wèn)題,說(shuō)明你做的應(yīng)用實(shí)在太 critical,或許應(yīng)該考慮放到 Linux kernel 里邊去,而不是在用戶態(tài)嘗試各種優(yōu)化。畢竟只有把程序做到 kernel 里才能真正實(shí)現(xiàn) zero copy,否則,核心態(tài)和用戶態(tài)之間始終是有一次內(nèi)存拷貝的。如果放到 kernel 里還不能滿足需求,那么要么自己寫(xiě)新的 kernel,或者直接用 FPGA 或 ASIC 操作 network adapter 來(lái)實(shí)現(xiàn)你的高性能服務(wù)器。
(待續(xù))
陳碩 (giantchen_AT_gmail)
Blog.csdn.net/Solstice t.sina.com.cn/giantchen
Muduo 全系列文章列表: http://blog.csdn.net/Solstice/category/779646.aspx
本文是《一種自動(dòng)反射消息類型的 Google Protobuf 網(wǎng)絡(luò)傳輸方案》的延續(xù),介紹如何將前文介紹的打包方案與 muduo::net::Buffer 結(jié)合,實(shí)現(xiàn)了 protobuf codec 和 dispatcher。
Muduo 的下載地址: http://muduo.googlecode.com/files/muduo-0.1.9-alpha.tar.gz ,SHA1 dc0bb5f7becdfc0277fb35f6dfaafee8209213bc ,本文的完整代碼可在線閱讀 http://code.google.com/p/muduo/source/browse/trunk/examples/protobuf/codec/ 。
考慮到不是每個(gè)人都安裝了 Google Protobuf,muduo 中的 protobuf 相關(guān)示例默認(rèn)是不 build 的,如果你的機(jī)器上安裝了 protobuf 2.3.0 或 2.4.0a,那么可以用 ./build.sh protobuf_all 來(lái)構(gòu)建 protobuf 相關(guān)的 examples。
在介紹 codec 和 dispatcher 之前,先講講前文的一個(gè)未決問(wèn)題。
為什么 Protobuf 的默認(rèn)序列化格式?jīng)]有包含消息的長(zhǎng)度與類型?
Protobuf 是經(jīng)過(guò)深思熟慮的消息打包方案,它的默認(rèn)序列化格式?jīng)]有包含消息的長(zhǎng)度與類型,自然有其道理。哪些情況下不需要在 protobuf 序列化得到的字節(jié)流中包含消息的長(zhǎng)度和(或)類型?我能想到的答案有:
- 如果把消息寫(xiě)入文件,一個(gè)文件存一個(gè)消息,那么序列化結(jié)果中不需要包含長(zhǎng)度和類型,因?yàn)閺奈募臀募L(zhǎng)度中可以得知消息的類型與長(zhǎng)度。
- 如果把消息寫(xiě)入文件,一個(gè)文件存多個(gè)消息,那么序列化結(jié)果中不需要包含類型,因?yàn)槲募痛砹讼⒌念愋汀?
- 如果把消息存入數(shù)據(jù)庫(kù)(或者 NoSQL),以 VARBINARY 字段保存,那么序列化結(jié)果中不需要包含長(zhǎng)度和類型,因?yàn)閺淖侄蚊妥侄伍L(zhǎng)度中可以得知消息的類型與長(zhǎng)度。
- 如果把消息以 UDP 方式發(fā)生給對(duì)方,而且對(duì)方一個(gè) UDP port 只接收一種消息類型,那么序列化結(jié)果中不需要包含長(zhǎng)度和類型,因?yàn)閺?port 和 UDP packet 長(zhǎng)度中可以得知消息的類型與長(zhǎng)度。
- 如果把消息以 TCP 短連接方式發(fā)給對(duì)方,而且對(duì)方一個(gè) TCP port 只接收一種消息類型,那么序列化結(jié)果中不需要包含長(zhǎng)度和類型,因?yàn)閺?port 和 TCP 字節(jié)流長(zhǎng)度中可以得知消息的類型與長(zhǎng)度。
- 如果把消息以 TCP 長(zhǎng)連接方式發(fā)給對(duì)方,但是對(duì)方一個(gè) TCP port 只接收一種消息類型,那么序列化結(jié)果中不需要包含類型,因?yàn)?port 代表了消息的類型。
- 如果采用 RPC 方式通信,那么只需要告訴對(duì)方 method name,對(duì)方自然能推斷出 Request 和 Response 的消息類型,這些可以由 protoc 生成的 RPC stubs 自動(dòng)搞定。
對(duì)于最后一點(diǎn),比方說(shuō) sudoku.proto 定義為:
service SudokuService {
rpc Solve (SudokuRequest) returns (SudokuResponse);
}
那么 RPC method Sudoku.Solve 對(duì)應(yīng)的請(qǐng)求和響應(yīng)分別是 SudokuRequest 和 SudokuResponse。在發(fā)送 RPC 請(qǐng)求的時(shí)候,不需要包含 SudokuRequest 的類型,只需要發(fā)送 method name Sudoku.Solve,對(duì)方自知道應(yīng)該按照 SudokuRequest 來(lái)解析(parse)請(qǐng)求。這個(gè)例子來(lái)自我的半成品項(xiàng)目 evproto,見(jiàn) http://blog.csdn.net/Solstice/archive/2010/04/17/5497699.aspx 。
對(duì)于上述這些情況,如果 protobuf 無(wú)條件地把長(zhǎng)度和類型放到序列化的字節(jié)串中,只會(huì)浪費(fèi)網(wǎng)絡(luò)帶寬和存儲(chǔ)。可見(jiàn) protobuf 默認(rèn)不發(fā)送長(zhǎng)度和類型是正確的決定。Protobuf 為消息格式的設(shè)計(jì)樹(shù)立了典范,哪些該自己搞定,哪些留給外部系統(tǒng)去解決,這些都考慮得很清楚。
只有在使用 TCP 長(zhǎng)連接,且在一個(gè)連接上傳遞不止一種消息的情況下(比方同時(shí)發(fā) Heartbeat 和 Request/Response),才需要我前文提到的那種打包方案。(為什么要在一個(gè)連接上同時(shí)發(fā) Heartbeat 和業(yè)務(wù)消息?請(qǐng)見(jiàn)陳碩《分布式系統(tǒng)的工程化開(kāi)發(fā)方法》 p.51 心跳協(xié)議的設(shè)計(jì)。)這時(shí)候我們需要一個(gè)分發(fā)器 dispatcher,把不同類型的消息分給各個(gè)消息處理函數(shù),這正是本文的主題之一。
以下均只考慮 TCP 長(zhǎng)連接這一應(yīng)用場(chǎng)景。
先談?wù)劸幗獯a器。
什么是編解碼器 codec?
Codec 是 encoder 和 decoder 的縮寫(xiě),這是一個(gè)到軟硬件都在使用的術(shù)語(yǔ),這里我借指“把網(wǎng)絡(luò)數(shù)據(jù)和業(yè)務(wù)消息之間互相轉(zhuǎn)換”的代碼。
在最簡(jiǎn)單的網(wǎng)絡(luò)編程中,沒(méi)有消息 message 只有字節(jié)流數(shù)據(jù),這時(shí)候是用不到 codec 的。比如我們前面講過(guò)的 echo server,它只需要把收到的數(shù)據(jù)原封不動(dòng)地發(fā)送回去,它不必關(guān)心消息的邊界(也沒(méi)有“消息”的概念),收多少就發(fā)多少,這種情況下它干脆直接使用 muduo::net::Buffer,取到數(shù)據(jù)再交給 TcpConnection 發(fā)送回去,見(jiàn)下圖。

non-trivial 的網(wǎng)絡(luò)服務(wù)程序通常會(huì)以消息為單位來(lái)通信,每條消息有明確的長(zhǎng)度與界限。程序每次收到一個(gè)完整的消息的時(shí)候才開(kāi)始處理,發(fā)送的時(shí)候也是把一個(gè)完整的消息交給網(wǎng)絡(luò)庫(kù)。比如我們前面講過(guò)的 asio chat 服務(wù),它的一條聊天記錄就是一條消息,我們?cè)O(shè)計(jì)了一個(gè)簡(jiǎn)單的消息格式,即在聊天記錄前面加上 4 字節(jié)的 length header,LengthHeaderCodec 代碼及解說(shuō)見(jiàn)《Muduo 網(wǎng)絡(luò)編程示例之二:Boost.Asio 的聊天服務(wù)器》一文。
codec 的基本功能之一是做 TCP 分包:確定每條消息的長(zhǎng)度,為消息劃分界限。在 non-blocking 網(wǎng)絡(luò)編程中,codec 幾乎是必不可少的。如果只收到了半條消息,那么不會(huì)觸發(fā)消息回調(diào),數(shù)據(jù)會(huì)停留在 Buffer 里(數(shù)據(jù)已經(jīng)讀到 Buffer 中了),等待收到一個(gè)完整的消息再通知處理函數(shù)。既然這個(gè)任務(wù)太常見(jiàn),我們干脆做一個(gè) utility class,避免服務(wù)端和客戶端程序都要自己處理分包,這就有了 LengthHeaderCodec。這個(gè) codec 的使用有點(diǎn)奇怪,不需要繼承,它也沒(méi)有基類,只要把它當(dāng)成普通 data member 來(lái)用,把 TcpConnection 的數(shù)據(jù)喂給它,然后向它注冊(cè) onXXXMessage() 回調(diào),代碼見(jiàn) asio chat 示例。muduo 里的 codec 都是這樣的風(fēng)格,通過(guò) boost::function 粘合到一起。
codec 是一層間接性,它位于 TcpConnection 和 ChatServer 之間,攔截處理收到的數(shù)據(jù),在收到完整的消息之后再調(diào)用 CharServer 對(duì)應(yīng)的處理函數(shù),注意 CharServer::onStringMessage() 的參數(shù)是 std::string,不再是 muduo::net::Buffer,也就是說(shuō) LengthHeaderCodec 把 Buffer 解碼成了 string。另外,在發(fā)送消息的時(shí)候,ChatServer 通過(guò) LengthHeaderCodec::send() 來(lái)發(fā)送 string,LengthHeaderCodec 負(fù)責(zé)把它編碼成 Buffer。這正是“編解碼器”名字的由來(lái)。

Protobuf codec 與此非常類似,只不過(guò)消息類型從 std::string 變成了 protobuf::Message。對(duì)于只接收處理 Query 消息的 QueryServer 來(lái)說(shuō),用 ProtobufCodec 非常方便,收到 protobuf::Message 之后 down cast 成 Query 來(lái)用就行。如果要接收處理不止一種消息,ProtobufCodec 恐怕還不能單獨(dú)完成工作,請(qǐng)繼續(xù)閱讀下文。

實(shí)現(xiàn) ProtobufCodec
Protobuf 的打包方案我已經(jīng)在《一種自動(dòng)反射消息類型的 Google Protobuf 網(wǎng)絡(luò)傳輸方案》中講過(guò),并以 string 為載體演示了 encode 和 decode 操作。在 muduo 里,我們有專門(mén)的 Buffer class,編碼更輕松。
編碼算法很直截了當(dāng),按照前文定義的消息格式一路打包下來(lái),最后更新一下首部的長(zhǎng)度即可。
解碼算法有幾個(gè)要點(diǎn):
- protobuf::Message 是 new 出來(lái)的對(duì)象,它的生命期如何管理?muduo 采用 shared_ptr<Message> 來(lái)自動(dòng)管理對(duì)象生命期,這與其他地方的做法是一致的。
- 出錯(cuò)如何處理?比方說(shuō)長(zhǎng)度超出范圍、check sum 不正確、message type name 不能識(shí)別、message parse 出錯(cuò)等等。ProtobufCodec 定義了 ErrorCallback,用戶代碼可以注冊(cè)這個(gè)回調(diào)。如果不注冊(cè),默認(rèn)的處理是斷開(kāi)連接,讓客戶重連重試。codec 的單元測(cè)試里模擬了各種出錯(cuò)情況。
- 如何處理一次收到半條消息、一條消息、一條半消息、兩條消息等等情況?這是每個(gè) non-blocking 網(wǎng)絡(luò)程序中的 codec 都要面對(duì)的問(wèn)題。
ProtobufCodec 在實(shí)際使用中有明顯的不足:它只負(fù)責(zé)把 muduo::net::Buffer 轉(zhuǎn)換為具體類型的 protobuf::Message,應(yīng)用程序拿到 Message 之后還有再根據(jù)其具體類型做一次分發(fā)。我們可以考慮做一個(gè)簡(jiǎn)單通用的分發(fā)器 dispatcher,以簡(jiǎn)化客戶代碼。
此外,目前 ProtobufCodec 的實(shí)現(xiàn)非常初級(jí),它沒(méi)有充分利用 ZeroCopyInputStream 和 ZeroCopyOutputStream,而是把收到的數(shù)據(jù)作為 byte array 交給 protobuf Message 去解析,這給性能優(yōu)化留下了空間。protobuf Message 不要求數(shù)據(jù)連續(xù)(像 vector 那樣),只要求數(shù)據(jù)分段連續(xù)(像 deque 那樣),這給 buffer 管理帶來(lái)性能上的好處(避免重新分配內(nèi)存,減少內(nèi)存碎片),當(dāng)然也使得代碼變復(fù)雜。muduo::net::Buffer 非常簡(jiǎn)單,它內(nèi)部是 vector<char>,我目前不想讓 protobuf 影響 muduo 本身的設(shè)計(jì),畢竟 muduo 是個(gè)通用的網(wǎng)絡(luò)庫(kù),不是為實(shí)現(xiàn) protobuf RPC 而特制的。
消息分發(fā)器 dispatcher 有什么用?
前面提到,在使用 TCP 長(zhǎng)連接,且在一個(gè)連接上傳遞不止一種 protobuf 消息的情況下,客戶代碼需要對(duì)收到的消息按類型做分發(fā)。比方說(shuō),收到 Logon 消息就交給 QueryServer::onLogon() 去處理,收到 Query 消息就交給 QueryServer::onQuery() 去處理。這個(gè)消息分派機(jī)制可以做得稍微有點(diǎn)通用性,讓所有 muduo+protobuf 程序收益,而且不增加復(fù)雜性。
換句話說(shuō),又是一層間接性,ProtobufCodec 攔截了 TcpConnection 的數(shù)據(jù),把它轉(zhuǎn)換為 Message,ProtobufDispatcher 攔截了 ProtobufCodec 的 callback,按消息具體類型把它分派給多個(gè) callbacks。

ProtobufCodec 與 ProtobufDispatcher 的綜合運(yùn)用
我寫(xiě)了兩個(gè)示例代碼,client 和 server,把 ProtobufCodec 和 ProtobufDispatcher 串聯(lián)起來(lái)使用。server 響應(yīng) Query 消息,發(fā)生回 Answer 消息,如果收到未知消息類型,則斷開(kāi)連接。client 可以選擇發(fā)送 Query 或 Empty 消息,由命令行控制。這樣可以測(cè)試 unknown message callback。
為節(jié)省篇幅,這里就不列出代碼了,請(qǐng)移步閱讀
http://code.google.com/p/muduo/source/browse/trunk/examples/protobuf/codec/client.cc
http://code.google.com/p/muduo/source/browse/trunk/examples/protobuf/codec/server.cc
在構(gòu)造函數(shù)中,通過(guò)注冊(cè)回調(diào)函數(shù)把四方 (TcpConnection、codec、dispatcher、QueryServer) 結(jié)合起來(lái)。
ProtobufDispatcher 的兩種實(shí)現(xiàn)
要完成消息分發(fā),那么就是對(duì)消息做 type-switch,這似乎是一個(gè) bad smell,但是 protobuf Message 的 Descriptor 沒(méi)有留下定制點(diǎn)(比如暴露一個(gè) boost::any 成員),我們只好硬來(lái)了。
先定義
typedef boost::function<void (Message*)> ProtobufMessageCallback;
注意,本節(jié)出現(xiàn)的不是 muduo dispatcher 真實(shí)的代碼,僅為示意,突出重點(diǎn),便于畫(huà)圖。
ProtobufDispatcherLite 的結(jié)構(gòu)非常簡(jiǎn)單,它有一個(gè) map<Descriptor*, ProtobufMessageCallback> 成員,客戶代碼可以以 Descriptor* 為 key 注冊(cè)回調(diào)(recall: 每個(gè)具體消息類型都有一個(gè)全局的 Descriptor 對(duì)象,其地址是不變的,可以用來(lái)當(dāng) key)。在收到 protobuf Message 之后,在 map 中找到對(duì)應(yīng)的 ProtobufMessageCallback,然后調(diào)用之。如果找不到,就調(diào)用 defaultCallback。

當(dāng)然,它的設(shè)計(jì)也有小小的缺陷,那就是 ProtobufMessageCallback 限制了客戶代碼只能接受基類 Message,客戶代碼需要自己做向下轉(zhuǎn)型,比如:

如果我希望 QueryServer 這么設(shè)計(jì):不想每個(gè)消息處理函數(shù)自己做 down casting,而是交給 dispatcher 去處理,客戶代碼拿到的就已經(jīng)是想要的具體類型。如下:

那么該該如何實(shí)現(xiàn) ProtobufDispatcher 呢?它如何與多個(gè)未知的消息類型合作?做 down cast 需要知道目標(biāo)類型,難道我們要用一長(zhǎng)串模板類型參數(shù)嗎?
有一個(gè)辦法,把多態(tài)與模板結(jié)合,利用 templated derived class 來(lái)提供類型上的靈活性。設(shè)計(jì)如下。

ProtobufDispatcher 有一個(gè)模板成員函數(shù),可以接受注冊(cè)任意消息類型 T 的回調(diào),然后它創(chuàng)建一個(gè)模板化的派生類 CallbackT<T>,這樣消息的類新信息就保存在了 CallbackT<T> 中,做 down casting 就簡(jiǎn)單了。
比方說(shuō),我們有兩個(gè)具體消息類型 Query 和 Answer。

然后我們這樣注冊(cè)回調(diào):
dispatcher_.registerMessageCallback<muduo::Query>(
boost::bind(&QueryServer::onQuery, this, _1, _2, _3));
dispatcher_.registerMessageCallback<muduo::Answer>(
boost::bind(&QueryServer::onAnswer, this, _1, _2, _3));
這樣會(huì)具現(xiàn)化 (instantiation) 出兩個(gè) CallbackT 實(shí)體,如下:

以上設(shè)計(jì)參考了 shared_ptr 的 deleter,Scott Meyers 也談到過(guò)。
ProtobufCodec 和 ProtobufDispatcher 有何意義?
ProtobufCodec 和 ProtobufDispatcher 把每個(gè)直接收發(fā) protobuf Message 的網(wǎng)絡(luò)程序都會(huì)用到的功能提煉出來(lái)做成了公用的 utility,這樣以后新寫(xiě) protobuf 網(wǎng)絡(luò)程序就不必為打包分包和消息分發(fā)勞神了。它倆以庫(kù)的形式存在,是兩個(gè)可以拿來(lái)就當(dāng) data member 用的 class,它們沒(méi)有基類,也沒(méi)有用到虛函數(shù)或者別的什么面向?qū)ο筇卣鳎磺秩?muduo::net 或者你的代碼。
下一篇文章講《分布式程序的自動(dòng)回歸測(cè)試》會(huì)介紹利用 protobuf 的跨語(yǔ)言特性,采用 Java 為 C++ 服務(wù)程序編寫(xiě) test harness。
陳碩 (giantchen_AT_gmail)
Blog.csdn.net/Solstice t.sina.com.cn/giantchen
這篇文章要解決的問(wèn)題是:在接收到 protobuf 數(shù)據(jù)之后,如何自動(dòng)創(chuàng)建具體的 Protobuf Message 對(duì)象,再做的反序列化。“自動(dòng)”的意思是:當(dāng)程序中新增一個(gè) protobuf Message 類型時(shí),這部分代碼不需要修改,不需要自己去注冊(cè)消息類型。其實(shí),Google Protobuf 本身具有很強(qiáng)的反射(reflection)功能,可以根據(jù) type name 創(chuàng)建具體類型的 Message 對(duì)象,我們直接利用即可。
本文假定讀者了解 Google Protocol Buffers 是什么,這不是一篇 protobuf 入門(mén)教程。
本文以 C++ 語(yǔ)言舉例,其他語(yǔ)言估計(jì)有類似的解法,歡迎補(bǔ)充。
本文的示例代碼在: https://github.com/chenshuo/recipes/tree/master/protobuf
網(wǎng)絡(luò)編程中使用 protobuf 的兩個(gè)問(wèn)題
Google Protocol Buffers (Protobuf) 是一款非常優(yōu)秀的庫(kù),它定義了一種緊湊的可擴(kuò)展二進(jìn)制消息格式,特別適合網(wǎng)絡(luò)數(shù)據(jù)傳輸。它為多種語(yǔ)言提供 binding,大大方便了分布式程序的開(kāi)發(fā),讓系統(tǒng)不再局限于用某一種語(yǔ)言來(lái)編寫(xiě)。
在網(wǎng)絡(luò)編程中使用 protobuf 需要解決兩個(gè)問(wèn)題:
- 長(zhǎng)度,protobuf 打包的數(shù)據(jù)沒(méi)有自帶長(zhǎng)度信息或終結(jié)符,需要由應(yīng)用程序自己在發(fā)生和接收的時(shí)候做正確的切分;
- 類型,protobuf 打包的數(shù)據(jù)沒(méi)有自帶類型信息,需要由發(fā)送方把類型信息傳給給接收方,接收方創(chuàng)建具體的 Protobuf Message 對(duì)象,再做的反序列化。
第一個(gè)很好解決,通常的做法是在每個(gè)消息前面加個(gè)固定長(zhǎng)度的 length header,例如我在 《Muduo 網(wǎng)絡(luò)編程示例之二: Boost.Asio 的聊天服務(wù)器》 中實(shí)現(xiàn)的 LengthHeaderCodec,代碼見(jiàn) http://code.google.com/p/muduo/source/browse/trunk/examples/asio/chat/codec.h
第二個(gè)問(wèn)題其實(shí)也很好解決,Protobuf 對(duì)此有內(nèi)建的支持。但是奇怪的是,從網(wǎng)上簡(jiǎn)單搜索的情況看,我發(fā)現(xiàn)了很多山寨的做法。
山寨做法
以下均為在 protobuf data 之前加上 header,header 中包含 int length 和類型信息。類型信息的山寨做法主要有兩種:
- 在 header 中放 int typeId,接收方用 switch-case 來(lái)選擇對(duì)應(yīng)的消息類型和處理函數(shù);
- 在 header 中放 string typeName,接收方用 look-up table 來(lái)選擇對(duì)應(yīng)的消息類型和處理函數(shù)。
這兩種做法都有問(wèn)題。
第一種做法要求保持 typeId 的唯一性,它和 protobuf message type 一一對(duì)應(yīng)。如果 protobuf message 的使用范圍不廣,比如接收方和發(fā)送方都是自己維護(hù)的程序,那么 typeId 的唯一性不難保證,用版本管理工具即可。如果 protobuf message 的使用范圍很大,比如全公司都在用,而且不同部門(mén)開(kāi)發(fā)的分布式程序可能相互通信,那么就需要一個(gè)公司內(nèi)部的全局機(jī)構(gòu)來(lái)分配 typeId,每次增加新 message type 都要去注冊(cè)一下,比較麻煩。
第二種做法稍好一點(diǎn)。typeName 的唯一性比較好辦,因?yàn)榭梢约由?package name(也就是用 message 的 fully qualified type name),各個(gè)部門(mén)事先分好 namespace,不會(huì)沖突與重復(fù)。但是每次新增消息類型的時(shí)候都要去手工修改 look-up table 的初始化代碼,比較麻煩。
其實(shí),不需要自己重新發(fā)明輪子,protobuf 本身已經(jīng)自帶了解決方案。
根據(jù) type name 反射自動(dòng)創(chuàng)建 Message 對(duì)象
Google Protobuf 本身具有很強(qiáng)的反射(reflection)功能,可以根據(jù) type name 創(chuàng)建具體類型的 Message 對(duì)象。但是奇怪的是,其官方教程里沒(méi)有明確提及這個(gè)用法,我估計(jì)還有很多人不知道這個(gè)用法,所以覺(jué)得值得寫(xiě)這篇 blog 談一談。
以下是陳碩繪制的 Protobuf class diagram,點(diǎn)擊查看原圖。

我估計(jì)大家通常關(guān)心和使用的是圖的左半部分:MessageLite、Message、Generated Message Types (Person, AddressBook) 等,而較少注意到圖的右半部分:Descriptor, DescriptorPool, MessageFactory。
上圖中,其關(guān)鍵作用的是 Descriptor class,每個(gè)具體 Message Type 對(duì)應(yīng)一個(gè) Descriptor 對(duì)象。盡管我們沒(méi)有直接調(diào)用它的函數(shù),但是Descriptor在“根據(jù) type name 創(chuàng)建具體類型的 Message 對(duì)象”中扮演了重要的角色,起了橋梁作用。上圖的紅色箭頭描述了根據(jù) type name 創(chuàng)建具體 Message 對(duì)象的過(guò)程,后文會(huì)詳細(xì)介紹。
原理簡(jiǎn)述
Protobuf Message class 采用了 prototype pattern,Message class 定義了 New() 虛函數(shù),用以返回本對(duì)象的一份新實(shí)例,類型與本對(duì)象的真實(shí)類型相同。也就是說(shuō),拿到 Message* 指針,不用知道它的具體類型,就能創(chuàng)建和它類型一樣的具體 Message Type 的對(duì)象。
每個(gè)具體 Message Type 都有一個(gè) default instance,可以通過(guò) ConcreteMessage::default_instance() 獲得,也可以通過(guò) MessageFactory::GetPrototype(const Descriptor*) 來(lái)獲得。所以,現(xiàn)在問(wèn)題轉(zhuǎn)變?yōu)?1. 如何拿到 MessageFactory;2. 如何拿到 Descriptor*。
當(dāng)然,ConcreteMessage::descriptor() 返回了我們想要的 Descriptor*,但是,在不知道 ConcreteMessage 的時(shí)候,如何調(diào)用它的靜態(tài)成員函數(shù)呢?這似乎是個(gè)雞與蛋的問(wèn)題。
我們的英雄是 DescriptorPool,它可以根據(jù) type name 查到 Descriptor*,只要找到合適的 DescriptorPool,再調(diào)用 DescriptorPool::FindMessageTypeByName(const string& type_name) 即可。眼前一亮?
在最終解決問(wèn)題之前,先簡(jiǎn)單測(cè)試一下,看看我上面說(shuō)的對(duì)不對(duì)。
簡(jiǎn)單測(cè)試
本文用于舉例的 proto 文件:query.proto,見(jiàn) https://github.com/chenshuo/recipes/blob/master/protobuf/query.proto
package muduo;
message Query {
required int64 id = 1;
required string questioner = 2;
repeated string question = 3;
}
message Answer {
required int64 id = 1;
required string questioner = 2;
required string answerer = 3;
repeated string solution = 4;
}
message Empty {
optional int32 id = 1;
}
其中的 Query.questioner 和 Answer.answerer 是我在前一篇文章這提到的《分布式系統(tǒng)中的進(jìn)程標(biāo)識(shí)》。
以下代碼驗(yàn)證 ConcreteMessage::default_instance()、ConcreteMessage::descriptor()、 MessageFactory::GetPrototype()、DescriptorPool::FindMessageTypeByName() 之間的不變式 (invariant):
https://github.com/chenshuo/recipes/blob/master/protobuf/descriptor_test.cc#L15
typedef muduo::Query T;
std::string type_name = T::descriptor()->full_name();
cout << type_name << endl;
const Descriptor* descriptor = DescriptorPool::generated_pool()->FindMessageTypeByName(type_name);
assert(descriptor == T::descriptor());
cout << "FindMessageTypeByName() = " << descriptor << endl;
cout << "T::descriptor() = " << T::descriptor() << endl;
cout << endl;
const Message* prototype = MessageFactory::generated_factory()->GetPrototype(descriptor);
assert(prototype == &T::default_instance());
cout << "GetPrototype() = " << prototype << endl;
cout << "T::default_instance() = " << &T::default_instance() << endl;
cout << endl;
T* new_obj = dynamic_cast<T*>(prototype->New());
assert(new_obj != NULL);
assert(new_obj != prototype);
assert(typeid(*new_obj) == typeid(T::default_instance()));
cout << "prototype->New() = " << new_obj << endl;
cout << endl;
delete new_obj;
根據(jù) type name 自動(dòng)創(chuàng)建 Message 的關(guān)鍵代碼
好了,萬(wàn)事具備,開(kāi)始行動(dòng):
- 用 DescriptorPool::generated_pool() 找到一個(gè) DescriptorPool 對(duì)象,它包含了程序編譯的時(shí)候所鏈接的全部 protobuf Message types。
- 用 DescriptorPool::FindMessageTypeByName() 根據(jù) type name 查找 Descriptor。
- 再用 MessageFactory::generated_factory() 找到 MessageFactory 對(duì)象,它能創(chuàng)建程序編譯的時(shí)候所鏈接的全部 protobuf Message types。
- 然后,用 MessageFactory::GetPrototype() 找到具體 Message Type 的 default instance。
- 最后,用 prototype->New() 創(chuàng)建對(duì)象。
示例代碼見(jiàn) https://github.com/chenshuo/recipes/blob/master/protobuf/codec.h#L69
Message* createMessage(const std::string& typeName)
{
Message* message = NULL;
const Descriptor* descriptor = DescriptorPool::generated_pool()->FindMessageTypeByName(typeName);
if (descriptor)
{
const Message* prototype = MessageFactory::generated_factory()->GetPrototype(descriptor);
if (prototype)
{
message = prototype->New();
}
}
return message;
}
調(diào)用方式:https://github.com/chenshuo/recipes/blob/master/protobuf/descriptor_test.cc#L49
Message* newQuery = createMessage("muduo.Query");
assert(newQuery != NULL);
assert(typeid(*newQuery) == typeid(muduo::Query::default_instance()));
cout << "createMessage(\"muduo.Query\") = " << newQuery << endl;
古之人不余欺也 :-)
注意,createMessage() 返回的是動(dòng)態(tài)創(chuàng)建的對(duì)象的指針,調(diào)用方有責(zé)任釋放它,不然就會(huì)內(nèi)存泄露。在 muduo 里,我用 shared_ptr<Message> 來(lái)自動(dòng)管理 Message 對(duì)象的生命期。
線程安全性
Google 的文檔說(shuō),我們用到的那幾個(gè) MessageFactory 和 DescriptorPool 都是線程安全的,Message::New() 也是線程安全的。并且它們都是 const member function。
關(guān)鍵問(wèn)題解決了,那么剩下工作就是設(shè)計(jì)一種包含長(zhǎng)度和消息類型的 protobuf 傳輸格式。
Protobuf 傳輸格式
陳碩設(shè)計(jì)了一個(gè)簡(jiǎn)單的格式,包含 protobuf data 和它對(duì)應(yīng)的長(zhǎng)度與類型信息,消息的末尾還有一個(gè) check sum。格式如下圖,圖中方塊的寬度是 32-bit。

用 C struct 偽代碼描述:
struct ProtobufTransportFormat __attribute__ ((__packed__))
{
int32_t len;
int32_t nameLen;
char typeName[nameLen];
char protobufData[len-nameLen-8];
int32_t checkSum; // adler32 of nameLen, typeName and protobufData
};
注意,這個(gè)格式不要求 32-bit 對(duì)齊,我們的 decoder 會(huì)自動(dòng)處理非對(duì)齊的消息。
例子
用這個(gè)格式打包一個(gè) muduo.Query 對(duì)象的結(jié)果是:

設(shè)計(jì)決策
以下是我在設(shè)計(jì)這個(gè)傳輸格式時(shí)的考慮:
- signed int。消息中的長(zhǎng)度字段只使用了 signed 32-bit int,而沒(méi)有使用 unsigned int,這是為了移植性,因?yàn)?Java 語(yǔ)言沒(méi)有 unsigned 類型。另外 Protobuf 一般用于打包小于 1M 的數(shù)據(jù),unsigned int 也沒(méi)用。
- check sum。雖然 TCP 是可靠傳輸協(xié)議,雖然 Ethernet 有 CRC-32 校驗(yàn),但是網(wǎng)絡(luò)傳輸必須要考慮數(shù)據(jù)損壞的情況,對(duì)于關(guān)鍵的網(wǎng)絡(luò)應(yīng)用,check sum 是必不可少的。對(duì)于 protobuf 這種緊湊的二進(jìn)制格式而言,肉眼看不出數(shù)據(jù)有沒(méi)有問(wèn)題,需要用 check sum。
- adler32 算法。我沒(méi)有選用常見(jiàn)的 CRC-32,而是選用 adler32,因?yàn)樗?jì)算量小、速度比較快,強(qiáng)度和 CRC-32差不多。另外,zlib 和 java.unit.zip 都直接支持這個(gè)算法,不用我們自己實(shí)現(xiàn)。
- type name 以 '\0' 結(jié)束。這是為了方便 troubleshooting,比如通過(guò) tcpdump 抓下來(lái)的包可以用肉眼很容易看出 type name,而不用根據(jù) nameLen 去一個(gè)個(gè)數(shù)字節(jié)。同時(shí),為了方便接收方處理,加入了 nameLen,節(jié)省 strlen(),空間換時(shí)間。
- 沒(méi)有版本號(hào)。Protobuf Message 的一個(gè)突出優(yōu)點(diǎn)是用 optional fields 來(lái)避免協(xié)議的版本號(hào)(凡是在 protobuf Message 里放版本號(hào)的人都沒(méi)有理解 protobuf 的設(shè)計(jì)),讓通信雙方的程序能各自升級(jí),便于系統(tǒng)演化。如果我設(shè)計(jì)的這個(gè)傳輸格式又把版本號(hào)加進(jìn)去,那就畫(huà)蛇添足了。具體請(qǐng)見(jiàn)本人《分布式系統(tǒng)的工程化開(kāi)發(fā)方法》第 57 頁(yè):消息格式的選擇。
示例代碼
為了簡(jiǎn)單起見(jiàn),采用 std::string 來(lái)作為打包的產(chǎn)物,僅為示例。
打包 encode 的代碼:https://github.com/chenshuo/recipes/blob/master/protobuf/codec.h#L35
解包 decode 的代碼:https://github.com/chenshuo/recipes/blob/master/protobuf/codec.h#L99
測(cè)試代碼: https://github.com/chenshuo/recipes/blob/master/protobuf/codec_test.cc
如果以上代碼編譯通過(guò),但是在運(yùn)行時(shí)出現(xiàn)“cannot open shared object file”錯(cuò)誤,一般可以用 sudo ldconfig 解決,前提是 libprotobuf.so 位于 /usr/local/lib,且 /etc/ld.so.conf 列出了這個(gè)目錄。
$ make all # 如果你安裝了 boost,可以 make whole
$ ./codec_test
./codec_test: error while loading shared libraries: libprotobuf.so.6: cannot open shared object file: No such file or directory
$ sudo ldconfig
與 muduo 集成
muduo 網(wǎng)絡(luò)庫(kù)將會(huì)集成對(duì)本文所述傳輸格式的支持(預(yù)計(jì) 0.1.9 版本),我會(huì)另外寫(xiě)一篇短文介紹 Protobuf Message <=> muduo::net::Buffer 的相互轉(zhuǎn)化,使用 muduo::net::Buffer 來(lái)打包比上面 std::string 的代碼還簡(jiǎn)單,它是專門(mén)為 non-blocking 網(wǎng)絡(luò)庫(kù)設(shè)計(jì)的 buffer class。
此外,我們可以寫(xiě)一個(gè) codec 來(lái)自動(dòng)完成轉(zhuǎn)換,就行 asio/char/codec.h 那樣。這樣客戶代碼直接收到的就是 Message 對(duì)象,發(fā)送的時(shí)候也直接發(fā)送 Message 對(duì)象,而不需要和 Buffer 對(duì)象打交道。
消息的分發(fā) (dispatching)
目前我們已經(jīng)解決了消息的自動(dòng)創(chuàng)建,在網(wǎng)絡(luò)編程中,還有一個(gè)常見(jiàn)任務(wù)是把不同類型的 Message 分發(fā)給不同的處理函數(shù),這同樣可以借助 Descriptor 來(lái)完成。我在 muduo 里實(shí)現(xiàn)了 ProtobufDispatcherLite 和 ProtobufDispatcher 兩個(gè)分發(fā)器,用戶可以自己注冊(cè)針對(duì)不同消息類型的處理函數(shù)。預(yù)計(jì)將會(huì)在 0.1.9 版本發(fā)布,您可以先睹為快:
初級(jí)版,用戶需要自己做 down casting: https://github.com/chenshuo/recipes/blob/master/protobuf/dispatcher_lite.cc
高級(jí)版,使用模板技巧,節(jié)省用戶打字: https://github.com/chenshuo/recipes/blob/master/protobuf/dispatcher.cc
基于 muduo 的 Protobuf RPC?
Google Protobuf 還支持 RPC,可惜它只提供了一個(gè)框架,沒(méi)有開(kāi)源網(wǎng)絡(luò)相關(guān)的代碼,muduo 正好可以填補(bǔ)這一空白。我目前還沒(méi)有決定是不是讓 muduo 也支持以 protobuf message 為消息格式的 RPC,muduo 還有很多事情要做,我也有很多博客文章打算寫(xiě),RPC 這件事情以后再說(shuō)吧。
注:Remote Procedure Call (RPC) 有廣義和狹義兩種意思。狹義的講,一般特指 ONC RPC,就是用來(lái)實(shí)現(xiàn) NFS 的那個(gè)東西;廣義的講,“以函數(shù)調(diào)用之名,行網(wǎng)絡(luò)通信之實(shí)”都可以叫 RPC,比如 Java RMI,.Net Remoting,Apache Thrift,libevent RPC,XML-RPC 等等。
(待續(xù))
陳碩 (giantchen_AT_gmail)
Blog.csdn.net/Solstice
摘要:作為 C++ 動(dòng)態(tài)庫(kù)的作者,應(yīng)當(dāng)避免使用虛函數(shù)作為庫(kù)的接口。這么做會(huì)給保持二進(jìn)制兼容性帶來(lái)很大麻煩,不得不增加很多不必要的 interfaces,最終重蹈 COM 的覆轍。
本文主要討論 Linux x86 平臺(tái),會(huì)繼續(xù)舉 Windows/COM 作為反面教材。
本文是上一篇《C++ 工程實(shí)踐(4):二進(jìn)制兼容性》的延續(xù),在寫(xiě)這篇文章的時(shí)候,我原本以外大家都對(duì)“以 C++ 虛函數(shù)作為接口”的害處達(dá)成共識(shí),我就寫(xiě)得比較簡(jiǎn)略,看來(lái)情況不是這樣,我還得展開(kāi)談一談。
“接口”有廣義和狹義之分,本文用中文“接口”表示廣義的接口,即一個(gè)庫(kù)的代碼界面;用英文 interface 表示狹義的接口,即只包含 virtual function 的 class,這種 class 通常沒(méi)有 data member,在 Java 里有一個(gè)專門(mén)的關(guān)鍵字 interface 來(lái)表示它。
C++ 程序庫(kù)的作者的生存環(huán)境
假設(shè)你是一個(gè) shared library 的維護(hù)者,你的 library 被公司另外兩三個(gè)團(tuán)隊(duì)使用了。你發(fā)現(xiàn)了一個(gè)安全漏洞,或者某個(gè)會(huì)導(dǎo)致 crash 的 bug 需要緊急修復(fù),那么你修復(fù)之后,能不能直接部署 library 的二進(jìn)制文件?有沒(méi)有破壞二進(jìn)制兼容性?會(huì)不會(huì)破壞別人團(tuán)隊(duì)已經(jīng)編譯好的投入生成環(huán)境的可執(zhí)行文件?是不是要強(qiáng)迫別的團(tuán)隊(duì)重新編譯鏈接,把可執(zhí)行文件也發(fā)布新版本?會(huì)不會(huì)打亂別人的 release cycle?這些都是工程開(kāi)發(fā)中經(jīng)常要遇到的問(wèn)題。
如果你打算新寫(xiě)一個(gè) C++ library,那么通常要做以下幾個(gè)決策:
- 以什么方式發(fā)布?動(dòng)態(tài)庫(kù)還是靜態(tài)庫(kù)?(本文不考慮源代碼發(fā)布這種情況,這其實(shí)和靜態(tài)庫(kù)類似。)
- 以什么方式暴露庫(kù)的接口?可選的做法有:以全局(含 namespace 級(jí)別)函數(shù)為接口、以 class 的 non-virtual 成員函數(shù)為接口、以 virtual 函數(shù)為接口(interface)。
(Java 程序員沒(méi)有這么多需要考慮的,直接寫(xiě) class 成員函數(shù)就行,最多考慮一下要不要給 method 或 class 標(biāo)上 final。也不必考慮動(dòng)態(tài)庫(kù)靜態(tài)庫(kù),都是 .jar 文件。)
在作出上面兩個(gè)決策之前,我們考慮兩個(gè)基本假設(shè):
- 代碼會(huì)有 bug,庫(kù)也不例外。將來(lái)可能會(huì)發(fā)布 bug fixes。
- 會(huì)有新的功能需求。寫(xiě)代碼不是一錘子買(mǎi)賣(mài),總是會(huì)有新的需求冒出來(lái),需要程序員往庫(kù)里增加?xùn)|西。這是好事情,讓程序員不丟飯碗。
(如果你的代碼第一次發(fā)布的時(shí)候就已經(jīng)做到完美,將來(lái)不需要任何修改,那么怎么做都行,也就不必繼續(xù)閱讀本文。)
也就是說(shuō),在設(shè)計(jì)庫(kù)的時(shí)候必須要考慮將來(lái)如何升級(jí)。
基于以上兩個(gè)基本假設(shè)來(lái)做決定。第一個(gè)決定很好做,如果需要 hot fix,那么只能用動(dòng)態(tài)庫(kù);否則,在分布式系統(tǒng)中使用靜態(tài)庫(kù)更容易部署,這在前文中已經(jīng)談過(guò)。(“動(dòng)態(tài)庫(kù)比靜態(tài)庫(kù)節(jié)約內(nèi)存”這種優(yōu)勢(shì)在今天看來(lái)已不太重要。)
以下本文假定你或者你的老板選擇以動(dòng)態(tài)庫(kù)方式發(fā)布,即發(fā)布 .so 或 .dll 文件,來(lái)看看第二個(gè)決定怎么做。(再說(shuō)一句,如果你能夠以靜態(tài)庫(kù)方式發(fā)布,后面的麻煩都不會(huì)遇到。)
第二個(gè)決定不是那么容易做,關(guān)鍵問(wèn)題是,要選擇一種可擴(kuò)展的 (extensible) 接口風(fēng)格,讓庫(kù)的升級(jí)變得更輕松。“升級(jí)”有兩層意思:
- 對(duì)于 bug fix only 的升級(jí),二進(jìn)制庫(kù)文件的替換應(yīng)該兼容現(xiàn)有的二進(jìn)制可執(zhí)行文件,二進(jìn)制兼容性方面的問(wèn)題已經(jīng)在前文談過(guò),這里從略。
- 對(duì)于新增功能的升級(jí),應(yīng)該對(duì)客戶代碼的友好。升級(jí)庫(kù)之后,客戶端使用新功能的代價(jià)應(yīng)該比較小。只需要包含新的頭文件(這一步都可以省略,如果新功能已經(jīng)加入原有的頭文件中),然后編寫(xiě)新代碼即可。而且,不要在客戶代碼中留下垃圾,后文我們會(huì)談到什么是垃圾。
在討論虛函數(shù)接口的弊端之前,我們先看看虛函數(shù)做接口的常見(jiàn)用法。
虛函數(shù)作為庫(kù)的接口的兩大用途
虛函數(shù)為接口大致有這么兩種用法:
- 調(diào)用,也就是庫(kù)提供一個(gè)什么功能(比如繪圖 Graphics),以虛函數(shù)為接口方式暴露給客戶端代碼。客戶端代碼一般不需要繼承這個(gè) interface,而是直接調(diào)用其 member function。這么做據(jù)說(shuō)是有利于接口和實(shí)現(xiàn)分離,我認(rèn)為純屬脫了褲子放屁。
- 回調(diào),也就是事件通知,比如網(wǎng)絡(luò)庫(kù)的“連接建立”、“數(shù)據(jù)到達(dá)”、“連接斷開(kāi)”等等。客戶端代碼一般會(huì)繼承這個(gè) interface,然后把對(duì)象實(shí)例注冊(cè)到庫(kù)里邊,等庫(kù)來(lái)回調(diào)自己。一般來(lái)說(shuō)客戶端不會(huì)自己去調(diào)用這些 member function,除非是為了寫(xiě)單元測(cè)試模擬庫(kù)的行為。
- 混合,一個(gè) class 既可以被客戶端代碼繼承用作回調(diào),又可以被客戶端直接調(diào)用。說(shuō)實(shí)話我沒(méi)看出這么做的好處,但實(shí)際中某些面向?qū)ο蟮?C++ 庫(kù)就是這么設(shè)計(jì)的。
對(duì)于“回調(diào)”方式,現(xiàn)代 C++ 有更好的做法,即 boost::function + boost::bind,見(jiàn)參考文獻(xiàn)[4],muduo 的回調(diào)全部采用這種新方法,見(jiàn)《Muduo 網(wǎng)絡(luò)編程示例之零:前言》。本文以下不考慮以虛函數(shù)為回調(diào)的過(guò)時(shí)做法。
對(duì)于“調(diào)用”方式,這里舉一個(gè)虛構(gòu)的圖形庫(kù),這個(gè)庫(kù)的功能是畫(huà)線、畫(huà)矩形、畫(huà)圓弧:
1: struct Point
2: { 3: int x;
4: int y;
5: };
6:
7: class Graphics
8: { 9: virtual void drawLine(int x0, int y0, int x1, int y1);
10: virtual void drawLine(Point p0, Point p1);
11:
12: virtual void drawRectangle(int x0, int y0, int x1, int y1);
13: virtual void drawRectangle(Point p0, Point p1);
14:
15: virtual void drawArc(int x, int y, int r);
16: virtual void drawArc(Point p, int r);
17: };
這里略去了很多與本文主題無(wú)關(guān)細(xì)節(jié),比如 Graphics 的構(gòu)造與析構(gòu)、draw*() 函數(shù)應(yīng)該是 public、Graphics 應(yīng)該不允許復(fù)制,還比如 Graphics 可能會(huì)用 pure virtual functions 等等,這些都不影響本文的討論。
這個(gè) Graphics 庫(kù)的使用很簡(jiǎn)單,客戶端看起來(lái)是這個(gè)樣子。
Graphics* g = getGraphics();
g->drawLine(0, 0, 100, 200);
releaseGraphics(g); g = NULL;
似乎一切都很好,陽(yáng)光明媚,符合“面向?qū)ο蟮脑瓌t”,但是一旦考慮升級(jí),前景立刻變得昏暗。
虛函數(shù)作為接口的弊端
以虛函數(shù)作為接口在二進(jìn)制兼容性方面有本質(zhì)困難:“一旦發(fā)布,不能修改”。
假如我需要給 Graphics 增加幾個(gè)繪圖函數(shù),同時(shí)保持二進(jìn)制兼容性。這幾個(gè)新函數(shù)的坐標(biāo)以浮點(diǎn)數(shù)表示,我理想中的新接口是:
--- old/graphics.h 2011-03-12 13:12:44.000000000 +0800
+++ new/graphics.h 2011-03-12 13:13:30.000000000 +0800
@@ -7,11 +7,14 @@
class Graphics
{
virtual void drawLine(int x0, int y0, int x1, int y1);
+ virtual void drawLine(double x0, double y0, double x1, double y1);
virtual void drawLine(Point p0, Point p1);
virtual void drawRectangle(int x0, int y0, int x1, int y1);
+ virtual void drawRectangle(double x0, double y0, double x1, double y1);
virtual void drawRectangle(Point p0, Point p1);
virtual void drawArc(int x, int y, int r);
+ virtual void drawArc(double x, double y, double r);
virtual void drawArc(Point p, int r);
};
受 C++ 二進(jìn)制兼容性方面的限制,我們不能這么做。其本質(zhì)問(wèn)題在于 C++ 以 vtable[offset] 方式實(shí)現(xiàn)虛函數(shù)調(diào)用,而 offset 又是根據(jù)虛函數(shù)聲明的位置隱式確定的,這造成了脆弱性。我增加了 drawLine(double x0, double y0, double x1, double y1),造成 vtable 的排列發(fā)生了變化,現(xiàn)有的二進(jìn)制可執(zhí)行文件無(wú)法再用舊的 offset 調(diào)用到正確的函數(shù)。
怎么辦呢?有一種危險(xiǎn)且丑陋的做法:把新的虛函數(shù)放到 interface 的末尾,例如:
--- old/graphics.h 2011-03-12 13:12:44.000000000 +0800
+++ new/graphics.h 2011-03-12 13:58:22.000000000 +0800
@@ -7,11 +7,15 @@
class Graphics
{
virtual void drawLine(int x0, int y0, int x1, int y1);
virtual void drawLine(Point p0, Point p1);
virtual void drawRectangle(int x0, int y0, int x1, int y1);
virtual void drawRectangle(Point p0, Point p1);
virtual void drawArc(int x, int y, int r);
virtual void drawArc(Point p, int r);
+
+ virtual void drawLine(double x0, double y0, double x1, double y1);
+ virtual void drawRectangle(double x0, double y0, double x1, double y1);
+ virtual void drawArc(double x, double y, double r);
};
這么做很丑陋,因?yàn)樾碌?drawLine(double x0, double y0, double x1, double y1) 函數(shù)沒(méi)有和原來(lái)的 drawLine() 函數(shù)呆在一起,造成閱讀上的不便。這么做同時(shí)很危險(xiǎn),因?yàn)?Graphics 如果被繼承,那么新增虛函數(shù)會(huì)改變派生類中的 vtable offset 變化,同樣不是二進(jìn)制兼容的。
另外有兩種似乎安全的做法,這也是 COM 采用的辦法:
1. 通過(guò)鏈?zhǔn)嚼^承來(lái)擴(kuò)展現(xiàn)有 interface,例如從 Graphics 派生出 Graphics2。
--- graphics.h 2011-03-12 13:12:44.000000000 +0800
+++ graphics2.h 2011-03-12 13:58:35.000000000 +0800
@@ -7,11 +7,19 @@
class Graphics
{
virtual void drawLine(int x0, int y0, int x1, int y1);
virtual void drawLine(Point p0, Point p1);
virtual void drawRectangle(int x0, int y0, int x1, int y1);
virtual void drawRectangle(Point p0, Point p1);
virtual void drawArc(int x, int y, int r);
virtual void drawArc(Point p, int r);
};
+
+class Graphics2 : public Graphics
+{
+ using Graphics::drawLine;
+ using Graphics::drawRectangle;
+ using Graphics::drawArc;
+
+ // added in version 2
+ virtual void drawLine(double x0, double y0, double x1, double y1);
+ virtual void drawRectangle(double x0, double y0, double x1, double y1);
+ virtual void drawArc(double x, double y, double r);
+};
將來(lái)如果繼續(xù)增加功能,那么還會(huì)有 class Graphics3 : public Graphics2;以及 class Graphics4 : public Graphics3 等等。這么做和前面的做法一樣丑陋,因?yàn)樾碌?drawLine(double x0, double y0, double x1, double y1) 函數(shù)位于派生 Graphics2 interace 中,沒(méi)有和原來(lái)的 drawLine() 函數(shù)呆在一起,造成割裂。
2. 通過(guò)多重繼承來(lái)擴(kuò)展現(xiàn)有 interface,例如定義一個(gè)與 Graphics class 有同樣成員的 Graphics2,再讓實(shí)現(xiàn)同時(shí)繼承這兩個(gè) interfaces。
--- graphics.h 2011-03-12 13:12:44.000000000 +0800
+++ graphics2.h 2011-03-12 13:16:45.000000000 +0800
@@ -7,11 +7,32 @@
class Graphics
{
virtual void drawLine(int x0, int y0, int x1, int y1);
virtual void drawLine(Point p0, Point p1);
virtual void drawRectangle(int x0, int y0, int x1, int y1);
virtual void drawRectangle(Point p0, Point p1);
virtual void drawArc(int x, int y, int r);
virtual void drawArc(Point p, int r);
};
+
+class Graphics2
+{
+ virtual void drawLine(int x0, int y0, int x1, int y1);
+ virtual void drawLine(double x0, double y0, double x1, double y1);
+ virtual void drawLine(Point p0, Point p1);
+
+ virtual void drawRectangle(int x0, int y0, int x1, int y1);
+ virtual void drawRectangle(double x0, double y0, double x1, double y1);
+ virtual void drawRectangle(Point p0, Point p1);
+
+ virtual void drawArc(int x, int y, int r);
+ virtual void drawArc(double x, double y, double r);
+ virtual void drawArc(Point p, int r);
+};
+
+// 在實(shí)現(xiàn)中采用多重接口繼承
+class GraphicsImpl : public Graphics, // version 1
+ public Graphics2, // version 2
+{
+ // ...
+};
這種帶版本的 interface 的做法在 COM 使用者的眼中看起來(lái)是很正常的,解決了二進(jìn)制兼容性的問(wèn)題,客戶端源代碼也不受影響。
在我看來(lái)帶版本的 interface 實(shí)在是很丑陋,因?yàn)槊看胃膭?dòng)都引入了新的 interface class,會(huì)造成日后客戶端代碼難以管理。比如,如果代碼使用了 Graphics3 的功能,要不要把現(xiàn)有的 Graphics2 都替換掉?
- 如果不替換,一個(gè)程序同時(shí)依賴多個(gè)版本的 Graphics,一直背著歷史包袱。依賴的 Graphics 版本越積越多,將來(lái)如何管理得過(guò)來(lái)?
- 如果要替換,為什么不相干的代碼(現(xiàn)有的運(yùn)行得好好的使用 Graphics2 的代碼)也會(huì)因?yàn)閯e處用到了 Graphics3 而被修改?
這種二難境地純粹是“以虛函數(shù)為庫(kù)的接口”造成的。如果我們能直接原地?cái)U(kuò)充 class Graphics,就不會(huì)有這些屁事,見(jiàn)本文“推薦做法”一節(jié)。
假如 Linux 系統(tǒng)調(diào)用以 COM 接口方式實(shí)現(xiàn)
或許上面這個(gè) Graphics 的例子太簡(jiǎn)單,沒(méi)有讓“以虛函數(shù)為接口”的缺點(diǎn)充分暴露出來(lái),讓我們看一個(gè)真實(shí)的案例:Linux Kernel。
Linux kernel 從 0.10 的 67 個(gè)系統(tǒng)調(diào)用發(fā)展到 2.6.37 的 340 個(gè),kernel interface 一直在擴(kuò)充,而且保持良好的兼容性,它保持兼容性的辦法很土,就是給每個(gè) system call 賦予一個(gè)終身不變的數(shù)字代號(hào),等于把虛函數(shù)表的排列固定下來(lái)。點(diǎn)開(kāi)本段開(kāi)頭的兩個(gè)鏈接,你就能看到 fork() 在 Linux 0.10 和 Linux 2.6.37 里的代號(hào)都是 2。(系統(tǒng)調(diào)用的編號(hào)跟硬件平臺(tái)有關(guān),這里我們看的是 x86 32-bit 平臺(tái)。)
試想假如 Linus 當(dāng)初選擇用 COM 接口的鏈?zhǔn)嚼^承風(fēng)格來(lái)描述,將會(huì)是怎樣一種壯觀的景象?為了避免擾亂視線,請(qǐng)移步觀看近百層繼承的代碼。(先后關(guān)系與版本號(hào)不一定 100% 準(zhǔn)確,我是用 git blame 去查的,現(xiàn)在列出的代碼只從 0.01 到 2.5.31,相信已經(jīng)足以展現(xiàn) COM 接口方式的弊端。)
不要誤認(rèn)為“接口一旦發(fā)布就不能更改”是天經(jīng)地義的,那不過(guò)是“以 C++ 虛函數(shù)為接口”的固有弊端,如果跳出這個(gè)框框去思考,其實(shí) C++ 庫(kù)的接口很容易做得更好。
為什么不能改?還不是因?yàn)橛昧薈++ 虛函數(shù)作為接口。Java 的 interface 可以添加新函數(shù),C 語(yǔ)言的庫(kù)也可以添加新的全局函數(shù),C++ class 也可以添加新 non-virtual 成員函數(shù)和 namespace 級(jí)別的 non-member 函數(shù),這些都不需要繼承出新 interface 就能擴(kuò)充原有接口。偏偏 COM 的 interface 不能原地?cái)U(kuò)充,只能通過(guò)繼承來(lái) workaround,產(chǎn)生一堆帶版本的 interfaces。有人說(shuō) COM 是二進(jìn)制兼容性的正面例子,某深不以為然。COM 確實(shí)以一種最丑陋的方式做到了“二進(jìn)制兼容”。脆弱與僵硬就是以 C++ 虛函數(shù)為接口的宿命。
相反,Linux 系統(tǒng)調(diào)用以編譯期常數(shù)方式固定下來(lái),萬(wàn)年不變,輕而易舉地解決了這個(gè)問(wèn)題。在其他面向?qū)ο笳Z(yǔ)言(Java/C#)中,我也沒(méi)有見(jiàn)過(guò)每改動(dòng)一次就給 interface 遞增版本號(hào)的詭異做法。
還是應(yīng)了《The Zen of Python》中的那句話,Explicit is better than implicit, Flat is better than nested.
動(dòng)態(tài)庫(kù)的接口的推薦做法
取決于動(dòng)態(tài)庫(kù)的使用范圍,有兩類做法。
如果,動(dòng)態(tài)庫(kù)的使用范圍比較窄,比如本團(tuán)隊(duì)內(nèi)部的兩三個(gè)程序在用,用戶都是受控的,要發(fā)布新版本也比較容易協(xié)調(diào),那么不用太費(fèi)事,只要做好發(fā)布的版本管理就行了。再在可執(zhí)行文件中使用 rpath 把庫(kù)的完整路徑確定下來(lái)。
比如現(xiàn)在 Graphics 庫(kù)發(fā)布了 1.1.0 和 1.2.0 兩個(gè)版本,這兩個(gè)版本可以不必是二進(jìn)制兼容。用戶的代碼從 1.1.0 升級(jí)到 1.2.0 的時(shí)候要重新編譯一下,反正他們要用新功能都是要重新編譯代碼的。如果要原地打補(bǔ)丁,那么 1.1.1 應(yīng)該和 1.1.0 二進(jìn)制兼容,而 1.2.1 應(yīng)該和 1.2.0 兼容。如果要加入新的功能,而新的功能與 1.2.0 不兼容,那么應(yīng)該發(fā)布到 1.3.0 版本。
為了便于檢查二進(jìn)制兼容性,可考慮把庫(kù)的代碼的暴露情況分辨清楚。muduo 的頭文件和 class 就有意識(shí)地分為用戶可見(jiàn)和用戶不可見(jiàn)兩部分,見(jiàn) http://blog.csdn.net/Solstice/archive/2010/08/29/5848547.aspx#_Toc32039。對(duì)于用戶可見(jiàn)的部分,升級(jí)時(shí)要注意二進(jìn)制兼容性,選用合理的版本號(hào);對(duì)于用戶不可見(jiàn)的部分,在升級(jí)庫(kù)的時(shí)候就不必在意。另外 muduo 本身設(shè)計(jì)來(lái)是以靜態(tài)庫(kù)方式發(fā)布,在二進(jìn)制兼容性方面沒(méi)有做太多的考慮。
如果庫(kù)的使用范圍很廣,用戶很多,各家的 release cycle 不盡相同,那么推薦 pimpl 技法[2, item 43],并考慮多采用 non-member non-friend function in namespace [1, item 23] [2, item 44 abd 57] 作為接口。這里以前面的 Graphics 為例,說(shuō)明 pimpl 的基本手法。
1. 暴露的接口里邊不要有虛函數(shù),而且 sizeof(Graphics) == sizeof(Graphics::Impl*)。
class Graphics
{
public:
Graphics(); // outline ctor
~Graphics(); // outline dtor
void drawLine(int x0, int y0, int x1, int y1);
void drawLine(Point p0, Point p1);
void drawRectangle(int x0, int y0, int x1, int y1);
void drawRectangle(Point p0, Point p1);
void drawArc(int x, int y, int r);
void drawArc(Point p, int r);
private:
class Impl;
boost::scoped_ptr<Impl> impl;
};
2. 在庫(kù)的實(shí)現(xiàn)中把調(diào)用轉(zhuǎn)發(fā) (forward) 給實(shí)現(xiàn) Graphics::Impl ,這部分代碼位于 .so/.dll 中,隨庫(kù)的升級(jí)一起變化。
#include <graphics.h>
class Graphics::Impl
{
public:
void drawLine(int x0, int y0, int x1, int y1);
void drawLine(Point p0, Point p1);
void drawRectangle(int x0, int y0, int x1, int y1);
void drawRectangle(Point p0, Point p1);
void drawArc(int x, int y, int r);
void drawArc(Point p, int r);
};
Graphics::Graphics()
: impl(new Impl)
{
}
Graphics::~Graphics()
{
}
void Graphics::drawLine(int x0, int y0, int x1, int y1)
{
impl->drawLine(x0, y0, x1, y1);
}
void Graphics::drawLine(Point p0, Point p1)
{
impl->drawLine(p0, p1);
}
// ...
3. 如果要加入新的功能,不必通過(guò)繼承來(lái)擴(kuò)展,可以原地修改,且很容易保持二進(jìn)制兼容性。先動(dòng)頭文件:
--- old/graphics.h 2011-03-12 15:34:06.000000000 +0800
+++ new/graphics.h 2011-03-12 15:14:12.000000000 +0800
@@ -7,19 +7,22 @@
class Graphics
{
public:
Graphics(); // outline ctor
~Graphics(); // outline dtor
void drawLine(int x0, int y0, int x1, int y1);
+ void drawLine(double x0, double y0, double x1, double y1);
void drawLine(Point p0, Point p1);
void drawRectangle(int x0, int y0, int x1, int y1);
+ void drawRectangle(double x0, double y0, double x1, double y1);
void drawRectangle(Point p0, Point p1);
void drawArc(int x, int y, int r);
+ void drawArc(double x, double y, double r);
void drawArc(Point p, int r);
private:
class Impl;
boost::scoped_ptr<Impl> impl;
};
然后在實(shí)現(xiàn)文件里增加 forward,這么做不會(huì)破壞二進(jìn)制兼容性,因?yàn)樵黾?non-virtual 函數(shù)不影響現(xiàn)有的可執(zhí)行文件。
--- old/graphics.cc 2011-03-12 15:15:20.000000000 +0800
+++ new/graphics.cc 2011-03-12 15:15:26.000000000 +0800
@@ -1,35 +1,43 @@
#include <graphics.h>
class Graphics::Impl
{
public:
void drawLine(int x0, int y0, int x1, int y1);
+ void drawLine(double x0, double y0, double x1, double y1);
void drawLine(Point p0, Point p1);
void drawRectangle(int x0, int y0, int x1, int y1);
+ void drawRectangle(double x0, double y0, double x1, double y1);
void drawRectangle(Point p0, Point p1);
void drawArc(int x, int y, int r);
+ void drawArc(double x, double y, double r);
void drawArc(Point p, int r);
};
Graphics::Graphics()
: impl(new Impl)
{
}
Graphics::~Graphics()
{
}
void Graphics::drawLine(int x0, int y0, int x1, int y1)
{
impl->drawLine(x0, y0, x1, y1);
}
+void Graphics::drawLine(double x0, double y0, double x1, double y1)
+{
+ impl->drawLine(x0, y0, x1, y1);
+}
+
void Graphics::drawLine(Point p0, Point p1)
{
impl->drawLine(p0, p1);
}
采用 pimpl 多了一道 explicit forward 的手續(xù),帶來(lái)的好處是可擴(kuò)展性與二進(jìn)制兼容性,通常是劃算的。pimpl 扮演了編譯器防火墻的作用。
pimpl 不僅 C++ 語(yǔ)言可以用,C 語(yǔ)言的庫(kù)同樣可以用,一樣帶來(lái)二進(jìn)制兼容性的好處,比如 libevent2 里邊的 struct event_base 是個(gè) opaque pointer,客戶端看不到其成員,都是通過(guò) libevent 的函數(shù)和它打交道,這樣庫(kù)的版本升級(jí)比較容易做到二進(jìn)制兼容。
為什么 non-virtual 函數(shù)比 virtual 函數(shù)更健壯?因?yàn)?virtual function 是 bind-by-vtable-offset,而 non-virtual function 是 bind-by-name。加載器 (loader) 會(huì)在程序啟動(dòng)時(shí)做決議(resolution),通過(guò) mangled name 把可執(zhí)行文件和動(dòng)態(tài)庫(kù)鏈接到一起。就像使用 Internet 域名比使用 IP 地址更能適應(yīng)變化一樣。
萬(wàn)一要跨語(yǔ)言怎么辦?很簡(jiǎn)單,暴露 C 語(yǔ)言的接口。Java 有 JNI 可以調(diào)用 C 語(yǔ)言的代碼;Python/Perl/Ruby 等等的解釋器都是 C 語(yǔ)言編寫(xiě)的,使用 C 函數(shù)也不在話下。C 函數(shù)是 Linux 下的萬(wàn)能接口。
本文只談了使用 class 為接口,其實(shí)用 free function 有時(shí)候更好(比如 muduo/base/Timestamp.h 除了定義 class Timestamp,還定義了 muduo::timeDifference() 等 free function),這也是 C++ 比 Java 等純面向?qū)ο笳Z(yǔ)言優(yōu)越的地方。留給將來(lái)再細(xì)談吧。
參考文獻(xiàn)
[1] Scott Meyers, 《Effective C++》 第 3 版,條款 35:考慮 virtual 函數(shù)以外的其他選擇;條款 23:寧以 non-member、non-friend 替換 member 函數(shù)。
[2] Herb Sutter and Andrei Alexandrescu, 《C++ 編程規(guī)范》,條款 39:考慮將 virtual 函數(shù)做成 non-public,將 public 函數(shù)做成 non-virtual;條款 43:明智地使用 pimpl;條款 44:盡可能編寫(xiě) nonmember, nonfriend 函數(shù);條款 57:將 class 和其非成員函數(shù)接口放入同一個(gè) namespace。
[3] 孟巖,《function/bind的救贖(上)》,《回復(fù)幾個(gè)問(wèn)題》中的“四個(gè)半抽象”。
[4] 陳碩,《以 boost::function 和 boost:bind 取代虛函數(shù)》,《樸實(shí)的 C++ 設(shè)計(jì)》。
本作品采用知識(shí)共享署名-非商業(yè)性使用-相同方式共享 3.0 Unported許可協(xié)議進(jìn)行許可。
陳碩 (giantchen_AT_gmail)
Blog.csdn.net/Solstice
本文主要討論 Linux x86/x86-64 平臺(tái),偶爾會(huì)舉 Windows 作為反面教材。
C/C++ 的二進(jìn)制兼容性 (binary compatibility) 有多重含義,本文主要在“頭文件和庫(kù)文件分別升級(jí),可執(zhí)行文件是否受影響”這個(gè)意義下討論,我稱之為 library (主要是 shared library,即動(dòng)態(tài)鏈接庫(kù))的 ABI (application binary interface)。至于編譯器與操作系統(tǒng)的 ABI 留給下一篇談 C++ 標(biāo)準(zhǔn)與實(shí)踐的文章。
什么是二進(jìn)制兼容性
在解釋這個(gè)定義之前,先看看 Unix/C 語(yǔ)言的一個(gè)歷史問(wèn)題:open() 的 flags 參數(shù)的取值。open(2) 函數(shù)的原型是
int open(const char *pathname, int flags);
其中 flags 的取值有三個(gè): O_RDONLY, O_WRONLY, O_RDWR。
與一般人的直覺(jué)相反,這幾個(gè)值不是按位或 (bitwise-OR) 的關(guān)系,即 O_RDONLY | O_WRONLY != O_RDWR。如果你想以讀寫(xiě)方式打開(kāi)文件,必須用 O_RDWR,而不能用 (O_RDONLY | O_WRONLY)。為什么?因?yàn)?O_RDONLY, O_WRONLY, O_RDWR 的值分別是 0, 1, 2。它們不滿足按位或。
那么為什么 C 語(yǔ)言從誕生到現(xiàn)在一直沒(méi)有糾正這個(gè)不足之處?比方說(shuō)把 O_RDONLY, O_WRONLY, O_RDWR 分別定義為 1, 2, 3,這樣 O_RDONLY | O_WRONLY == O_RDWR,符合直覺(jué)。而且這三個(gè)值都是宏定義,也不需要修改現(xiàn)有的源代碼,只需要改改系統(tǒng)的頭文件就行了。
因?yàn)檫@么做會(huì)破壞二進(jìn)制兼容性。對(duì)于已經(jīng)編譯好的可執(zhí)行文件,它調(diào)用 open(2) 的參數(shù)是寫(xiě)死的,更改頭文件并不能影響已經(jīng)編譯好的可執(zhí)行文件。比方說(shuō)這個(gè)可執(zhí)行文件會(huì)調(diào)用 open(path, 1) 來(lái)寫(xiě)文件,而在新規(guī)定中,這表示讀文件,程序就錯(cuò)亂了。
以上這個(gè)例子說(shuō)明,如果以 shared library 方式提供函數(shù)庫(kù),那么頭文件和庫(kù)文件不能輕易修改,否則容易破壞已有的二進(jìn)制可執(zhí)行文件,或者其他用到這個(gè) shared library 的 library。操作系統(tǒng)的 system call 可以看成 Kernel 與 User space 的 interface,kernel 在這個(gè)意義下也可以當(dāng)成 shared library,你可以把內(nèi)核從 2.6.30 升級(jí)到 2.6.35,而不需要重新編譯所有用戶態(tài)的程序。
所謂“二進(jìn)制兼容性”指的就是在升級(jí)(也可能是 bug fix)庫(kù)文件的時(shí)候,不必重新編譯使用這個(gè)庫(kù)的可執(zhí)行文件或使用這個(gè)庫(kù)的其他庫(kù)文件,程序的功能不被破壞。
見(jiàn) QT FAQ 的有關(guān)條款:http://developer.qt.nokia.com/faq/answer/you_frequently_say_that_you_cannot_add_this_or_that_feature_because_it_woul
在 Windows 下有惡名叫 DLL Hell,比如 MFC 有一堆 DLL,mfc40.dll, mfc42.dll, mfc71.dll, mfc80.dll, mfc90.dll,這是動(dòng)態(tài)鏈接庫(kù)的本質(zhì)問(wèn)題,怪不到 MFC 頭上。
有哪些情況會(huì)破壞庫(kù)的 ABI
到底如何判斷一個(gè)改動(dòng)是不是二進(jìn)制兼容呢?這跟 C++ 的實(shí)現(xiàn)方式直接相關(guān),雖然 C++ 標(biāo)準(zhǔn)沒(méi)有規(guī)定 C++ 的 ABI,但是幾乎所有主流平臺(tái)都有明文或事實(shí)上的 ABI 標(biāo)準(zhǔn)。比方說(shuō) ARM 有 EABI,Intel Itanium 有 http://www.codesourcery.com/public/cxx-abi/abi.html,x86-64 有仿 Itanium 的 ABI,SPARC 和 MIPS 也都有明文規(guī)定的 ABI,等等。x86 是個(gè)例外,它只有事實(shí)上的 ABI,比如 Windows 就是 Visual C++,Linux 是 G++(G++ 的 ABI 還有多個(gè)版本,目前最新的是 G++ 3.4 的版本),Intel 的 C++ 編譯器也得按照 Visual C++ 或 G++ 的 ABI 來(lái)生成代碼,否則就不能與系統(tǒng)其它部件兼容。
C++ ABI 的主要內(nèi)容:
- 函數(shù)參數(shù)傳遞的方式,比如 x86-64 用寄存器來(lái)傳函數(shù)的前 4 個(gè)整數(shù)參數(shù)
- 虛函數(shù)的調(diào)用方式,通常是 vptr/vtbl 然后用 vtbl[offset] 來(lái)調(diào)用
- struct 和 class 的內(nèi)存布局,通過(guò)偏移量來(lái)訪問(wèn)數(shù)據(jù)成員
- name mangling
- RTTI 和異常處理的實(shí)現(xiàn)(以下本文不考慮異常處理)
C/C++ 通過(guò)頭文件暴露出動(dòng)態(tài)庫(kù)的使用方法,這個(gè)“使用方法”主要是給編譯器看的,編譯器會(huì)據(jù)此生成二進(jìn)制代碼,然后在運(yùn)行的時(shí)候通過(guò)裝載器(loader)把可執(zhí)行文件和動(dòng)態(tài)庫(kù)綁到一起。如何判斷一個(gè)改動(dòng)是不是二進(jìn)制兼容,主要就是看頭文件暴露的這份“使用說(shuō)明”能否與新版本的動(dòng)態(tài)庫(kù)的實(shí)際使用方法兼容。因?yàn)樾碌膸?kù)必然有新的頭文件,但是現(xiàn)有的二進(jìn)制可執(zhí)行文件還是按舊的頭文件來(lái)調(diào)用動(dòng)態(tài)庫(kù)。
這里舉一些源代碼兼容但是二進(jìn)制代碼不兼容例子
- 給函數(shù)增加默認(rèn)參數(shù),現(xiàn)有的可執(zhí)行文件無(wú)法傳這個(gè)額外的參數(shù)。
- 增加虛函數(shù),會(huì)造成 vtbl 里的排列變化。(不要考慮“只在末尾增加”這種取巧行為,因?yàn)槟愕?class 可能已被繼承。)
- 增加默認(rèn)模板類型參數(shù),比方說(shuō) Foo<T> 改為 Foo<T, Alloc=alloc<T> >,這會(huì)改變 name mangling
- 改變 enum 的值,把 enum Color { Red = 3 }; 改為 Red = 4。這會(huì)造成錯(cuò)位。當(dāng)然,由于 enum 自動(dòng)排列取值,添加 enum 項(xiàng)也是不安全的,除非是在末尾添加。
給 class Bar 增加數(shù)據(jù)成員,造成 sizeof(Bar) 變大,以及內(nèi)部數(shù)據(jù)成員的 offset 變化,這是不是安全的?通常不是安全的,但也有例外。
- 如果客戶代碼里有 new Bar,那么肯定不安全,因?yàn)?new 的字節(jié)數(shù)不夠裝下新 Bar。相反,如果 library 通過(guò) factory 返回 Bar* (并通過(guò) factory 來(lái)銷毀對(duì)象)或者直接返回 shared_ptr<Bar>,客戶端不需要用到 sizeof(Bar),那么可能是安全的。
- 如果客戶代碼里有 Bar* pBar; pBar->memberA = xx;,那么肯定不安全,因?yàn)?memberA 的新 Bar 的偏移可能會(huì)變。相反,如果只通過(guò)成員函數(shù)來(lái)訪問(wèn)對(duì)象的數(shù)據(jù)成員,客戶端不需要用到 data member 的 offsets,那么可能是安全的。
- 如果客戶調(diào)用 pBar->setMemberA(xx); 而 Bar::setMemberA() 是個(gè) inline function,那么肯定不安全,因?yàn)槠屏恳呀?jīng)被 inline 到客戶的二進(jìn)制代碼里了。如果 setMemberA() 是 outline function,其實(shí)現(xiàn)位于 shared library 中,會(huì)隨著 Bar 的更新而更新,那么可能是安全的。
那么只使用 header-only 的庫(kù)文件是不是安全呢?不一定。如果你的程序用了 boost 1.36.0,而你依賴的某個(gè) library 在編譯的時(shí)候用的是 1.33.1,那么你的程序和這個(gè) library 就不能正常工作。因?yàn)?1.36.0 和 1.33.1 的 boost::function 的模板參數(shù)類型的個(gè)數(shù)不一樣,其中一個(gè)多了 allocator。
這里有一份黑名單,列在這里的肯定是二級(jí)制不兼容,沒(méi)有列出的也可能二進(jìn)制不兼容,見(jiàn) KDE 的文檔:http://techbase.kde.org/Policies/Binary_Compatibility_Issues_With_C%2B%2B
哪些做法多半是安全的
前面我說(shuō)“不能輕易修改”,暗示有些改動(dòng)多半是安全的,這里有一份白名單,歡迎添加更多內(nèi)容。
只要庫(kù)改動(dòng)不影響現(xiàn)有的可執(zhí)行文件的二進(jìn)制代碼的正確性,那么就是安全的,我們可以先部署新的庫(kù),讓現(xiàn)有的二進(jìn)制程序受益。
- 增加新的 class
- 增加 non-virtual 成員函數(shù)
- 修改數(shù)據(jù)成員的名稱,因?yàn)樯a(chǎn)的二進(jìn)制代碼是按偏移量來(lái)訪問(wèn)的,當(dāng)然,這會(huì)造成源碼級(jí)的不兼容。
- 還有很多,不一一列舉了。
歡迎補(bǔ)充
反面教材:COM
在 C++ 中以虛函數(shù)作為接口基本上就跟二進(jìn)制兼容性說(shuō)拜拜了。具體地說(shuō),以只包含虛函數(shù)的 class (稱為 interface class)作為程序庫(kù)的接口,這樣的接口是僵硬的,一旦發(fā)布,無(wú)法修改。
比方說(shuō) M$ 的 COM,其 DirectX 和 MSXML 都以 COM 組件方式發(fā)布,我們來(lái)看看它的帶版本接口 (versioned interfaces):
- IDirect3D7, IDirect3D8, IDirect3D9, ID3D10*, ID3D11*
- IXMLDOMDocument, IXMLDOMDocument2, IXMLDOMDocument3
換話句話說(shuō),每次發(fā)布新版本都引入新的 interface class,而不是在現(xiàn)有的 interface 上做擴(kuò)充。這樣一樣不能兼容現(xiàn)有的代碼,強(qiáng)迫客戶端代碼也要改寫(xiě)。
回過(guò)頭來(lái)看看 C 語(yǔ)言,C/Posix 這些年逐漸加入了很多新函數(shù),同時(shí),現(xiàn)有的代碼不用修改也能運(yùn)行得很好。如果要用這些新函數(shù),直接用就行了,也基本不會(huì)修改已有的代碼。相反,COM 里邊要想用 IXMLDOMDocument3 的功能,就得把現(xiàn)有的代碼從 IXMLDOMDocument 全部升級(jí)到 IXMLDOMDocument3,很諷刺吧。
tip:如果遇到鼓吹在 C++ 里使用面向接口編程的人,可以拿二進(jìn)制兼容性考考他。
解決辦法
采用靜態(tài)鏈接
這個(gè)是王道。在分布式系統(tǒng)這,采用靜態(tài)鏈接也帶來(lái)部署上的好處,只要把可執(zhí)行文件放到機(jī)器上就行運(yùn)行,不用考慮它依賴的 libraries。目前 muduo 就是采用靜態(tài)鏈接。
通過(guò)動(dòng)態(tài)庫(kù)的版本管理來(lái)控制兼容性
這需要非常小心檢查每次改動(dòng)的二進(jìn)制兼容性并做好發(fā)布計(jì)劃,比如 1.0.x 系列做到二進(jìn)制兼容,1.1.x 系列做到二進(jìn)制兼容,而 1.0.x 和 1.1.x 二進(jìn)制不兼容。《程序員的自我修養(yǎng)》里邊講過(guò) .so 文件的命名與二進(jìn)制兼容性相關(guān)的話題,值得一讀。
用 pimpl 技法,編譯器防火墻
在頭文件中只暴露 non-virtual 接口,并且 class 的大小固定為 sizeof(Impl*),這樣可以隨意更新庫(kù)文件而不影響可執(zhí)行文件。當(dāng)然,這么做有多了一道間接性,可能有一定的性能損失。見(jiàn) Exceptional C++ 有關(guān)條款和 C++ Coding Standards 101.
Java 是如何應(yīng)對(duì)的
Java 實(shí)際上把 C/C++ 的 linking 這一步驟推遲到 class loading 的時(shí)候來(lái)做。就不存在“不能增加虛函數(shù)”,“不能修改 data member” 等問(wèn)題。在 Java 里邊用面向 interface 編程遠(yuǎn)比 C++ 更通用和自然,也沒(méi)有上面提到的“僵硬的接口”問(wèn)題。
(待續(xù))
摘要: 版本管理(version controlling)是每個(gè)程序員的基本技能,C++ 程序員也不例外。版本管理的基本功能之一是追蹤代碼變化,讓你能清楚地知道代碼是如何一步步變成現(xiàn)在的這個(gè)樣子,以及每次 check-in 都具體改動(dòng)了哪些內(nèi)部。所謂“有利于版本管理”的代碼格式,就是指在代碼中合理使用換行符,對(duì) diff 工具友好,讓 diff 的結(jié)果清晰明了地表達(dá)代碼的改動(dòng)。
閱讀全文