• <ins id="pjuwb"></ins>
    <blockquote id="pjuwb"><pre id="pjuwb"></pre></blockquote>
    <noscript id="pjuwb"></noscript>
          <sup id="pjuwb"><pre id="pjuwb"></pre></sup>
            <dd id="pjuwb"></dd>
            <abbr id="pjuwb"></abbr>

            陳碩的Blog

            Muduo 網絡編程示例之七:“串并轉換”連接服務器及其自動化測試

            Muduo 網絡編程示例之七:連接服務器及其自動化測試

            陳碩 (giantchen_AT_gmail)

            Blog.csdn.net/Solstice  t.sina.com.cn/giantchen

            這是《Muduo 網絡編程示例》系列的第七篇文章。

            Muduo 全系列文章列表: http://blog.csdn.net/Solstice/category/779646.aspx

            本文介紹如何使用 test harness 來測試一個具有內部邏輯的網絡服務程序。

            本文的代碼見 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

             

            云風在他的博客中提到了網游連接服務器的功能需求(搜“練手項目”),我用 C++ 初步實現了這些需求,并為之編寫了配套的自動化 test harness,作為 muduo 網絡庫的示例。

            注意:本文呈現的代碼僅僅實現了基本的功能需求,沒有考慮安全性,也沒有特別優化性能,不適合用作真正的放在公網上運行的網游連接服務器。

            功能需求

            這個連接服務器把多個客戶連接匯聚為一個內部 TCP 連接,起到“數據串并轉換”的作用,讓 backend 的邏輯服務器專心處理業務,而無需顧及多連接的并發性。以下是系統的框圖:

            multiplexer

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

            mux

            (上圖取自 wikipedia,是 public domain 版權)

            實現

            Multiplexer 的功能需求不復雜,無非是在 backend connection 和 client connections 之間倒騰數據。具體來說,主要是處理四種事件:

            由上可見,multiplexer 的功能與 proxy 頗為類似。multiplexer_simple.cc 是一個線程版的實現,借助 muduo 的 io-multiplexing 特性,可以方便地處理多個并發連接。

            在實現的時候有兩點值得注意:

            • TcpConnection 的 id 如何存放?當從 backend 收到數據,如何根據 id 找到對應的 client connection?當從 client connection 收到數據,如何得知其 id ?

            第一個問題比較好解決,用 std::map〈int, TcpConnectionPtr〉 clientConns_; 保存從 id 到 client connection 的映射就行。

            第二個問題固然可以用類似的辦法解決,但是我想借此介紹一下 muduo::net::TcpConnection 的 context 功能。每個 TcpConnection 都有一個 boost::any 成員,可由客戶代碼自由支配(get/set),代碼如下。這個 boost::any 是 TcpConnection 的 context,可以用于保存與 connection 綁定的任意數據(比方說 connection id、connection 的最后數據到達時間、connection 所代表的用戶的名字等等)。這樣客戶代碼不必繼承 TcpConnection 就能 attach 自己的狀態,而且也用不著 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;

            對于 Multiplexer,在 onClientConnection() 里調用 conn->setContext(id),把 id 存到 TcpConnection 對象中。onClientMessage() 從 TcpConnection 對象中取得 id,連同數據一起發送給 backend,完整實現如下:

              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 是動態創建并銷毀,其生與滅完全由客戶決定,如何保證 backend 想向它發送數據的時候,這個 TcpConnection 對象還活著?解決思路是用 reference counting,當然,不用自己寫,用 boost::shared_ptr 即可。TcpConnection 是 muduo 中唯一默認采用 shared_ptr 來管理生命期的對象,蓋由其動態生命期的本質決定。更多內容請參考陳碩《當析構函數遇到多線程──C++ 中線程安全的對象回調

            multiplexer 是二進制協議,如何測試呢?

            自動化測試

            Multiplexer 是 muduo 網絡編程示例中第一個具有 non-trivial 業務邏輯的網絡程序,根據陳碩《分布式程序的自動化回歸測試》一文的思想,我為它編寫了 test harness。代碼見 http://code.google.com/p/muduo/source/browse/trunk/examples/multiplexer/harness/src/com/chenshuo/muduo/example/multiplexer

            這個 Test harness 采用 Java 編寫,用的是 Netty 庫。這個 test harness 要扮演 clients 和 backend,也就是既要主動發起連接,也要被動接受連接。結構如下:

            harness

            Test harness 會把各種 event 匯聚到一個 blocking queue 里邊,方便編寫 test case。Test case 則操縱 test harness,發起連接、發送數據、檢查收到的數據,例如以下是其中一個 test case

            http://code.google.com/p/muduo/source/browse/trunk/examples/multiplexer/harness/src/com/chenshuo/muduo/example/multiplexer/testcase/TestOneClientSend.java

            這里的幾個 test cases 都以用 java 直接寫的,如果有必要,也可以采用 Groovy 來編寫,這樣可以在不重啟 test harness 的情況下隨時修改添加 test cases。具體做法見陳碩《“過家家”版的移動離線計費系統實現》。

            將來的改進

            有了這個自動化的 test harness,我們可以比較方便且安全地修改(甚至重新設計)multiplexer。例如
            • 增加“backend 發送指令斷開 client connection”的功能。有了自動化測試,這個新功能可以被單獨測試(指開發者測試),而不需要真正的 backend 參與進來。
            • 將 Multiplexer 改用多線程重寫。有了自動化回歸測試,我們不用擔心破壞原有的功能,可以放心大膽地重寫。而且由于 test harness 是從外部測試,不是單元測試,重寫 multiplexer 的時候不用動 test cases,這樣保證了測試的穩定性。另外,這個 test harness 稍作改進還可以進行 stress testing,既可用于驗證多線程 multiplexer 的正確性,亦可對比其相對單線程版的效率提升。

            posted @ 2011-05-02 19:47 陳碩 閱讀(2459) | 評論 (0)編輯 收藏

            Muduo 網絡編程示例之六:限制服務器的最大并發連接數

            Muduo 網絡編程示例之六:限制服務器的最大并發連接數

            陳碩 (giantchen_AT_gmail)

            Blog.csdn.net/Solstice  t.sina.com.cn/giantchen

            這是《Muduo 網絡編程示例》系列的第六篇文章。

            Muduo 全系列文章列表: http://blog.csdn.net/Solstice/category/779646.aspx

             

            本文已以大家都熟悉的 EchoServer 介紹如何限制服務器的并發連接數。

            本文的代碼見 http://code.google.com/p/muduo/source/browse/trunk/examples/maxconnection/

            《Muduo 網絡編程示例 系列》計劃中的第六篇文章原本是“用于測試兩臺機器的帶寬的 pingpong 程序”,pingpong 協議的程序已經在《muduo 與 boost asio 吞吐量對比》和《muduo 與 libevent2 吞吐量對比》兩篇文章中介紹過了,所以我改為寫另外一個有點意思的主題。

            這篇文章中的“并發連接數”是指一個 server program 能同時支持的客戶端連接數,連接系由客戶端主動發起,服務端被動接受(accept)連接。(如果要限制應用程序主動發起的連接,則問題要簡單得多,畢竟主動權和決定權都在程序本身。)

            為什么要限制并發連接數?

            一方面,我們不希望服務程序超載,另一方面,更因為 file descriptor 是稀缺資源,如果出現 file descriptor 耗盡,很棘手(跟 “malloc 失敗/new() 拋出 std::bad_alloc”差不多同樣棘手)。

            我在《分布式系統的工程化開發方法》一文中曾談到 libev 作者建議的一種應對“accept()ing 時 file descriptor 耗盡”的辦法。

             

            幻燈片35

            幻燈片36

            Muduo 的 acceptor 正是這么實現的,但是,這個做法在多線程下不能保證正確,會有 race condition。(思考題:是什么 race condition?)

            其實有另外一種比較簡單的辦法:file descriptor 是 hard limit,我們可以自己設一個稍低一點的 soft limit,如果超過 soft limit 就主動關閉新連接,這樣就避免觸及“file descriptor 耗盡”這種邊界條件。比方說當前進程的 max file descriptor 是 1024,那么我們可以在連接數達到 1000 的時候進入“拒絕新連接”狀態,這樣留給我們足夠的騰挪空間。

             

            Muduo 中限制并發連接數


            Muduo 中限制并發連接數的做法簡單得出奇。以在《Muduo 網絡編程示例之零:前言》中出場過的 EchoServer 為例,只需要為它增加一個 int 成員,表示當前的活動連接數。(如果是多線程程序,應該用 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() 中判斷當前活動連接數,如果超過最大允許數,則踢掉連接。

            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。

            另外,如果是有業務邏輯的服務,可以在 shutdown() 之前發送一個簡單的響應,表明本服務程序的負載能力已經飽和,提示客戶端嘗試下一個可用的 server(當然,下一個可用的 server 地址不一定要在這個響應里給出,客戶端可以自己去 name service 查詢),這樣方便客戶端快速 failover。

             

            后文將介紹如何處理空閑連接的超時:如果一個連接長時間(若干秒)沒有輸入數據,則踢掉此連接。辦法有很多種,我用 Time Wheel 解決。

            posted @ 2011-04-27 00:03 陳碩 閱讀(4892) | 評論 (9)編輯 收藏

            分布式程序的自動化回歸測試

            陳碩 (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++程序打交道的其他程序,能夠測試各種正常或異常的情況。基本上任何代碼改動和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。以上任何一點改動都會造成測試失敗(編譯就不通過)。

            在添加新功能的時候,我們常會重構已有的代碼,在保持原有功能的情況下讓代碼的“形狀”更適合實現新的需求。一旦修改原有的代碼,單元測試就可能編譯不過:比如給成員函數或構造函數添加一個參數,或者把成員函數從一個 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 等存儲設備),電源線插到電源插座上,網線插到交換機上。

             

            harness_net_power

            這個模型實際上說明,一臺機器的表現出來的行為完全由它接出來的兩根線展現,今天不談電源線,只談網線。(“在乎服務器的功耗”在我看來就是公司利潤率很低的標志,要從電費上摳成本。)

            如果網絡是普通的千兆以太網,那么吞吐量不大于 125MB/s。這個吞吐量比起現在的 CPU 運算速度和內存帶寬簡直小得可憐。這里我想提的是,對于不特別在意 latency 的應用,只要能讓千兆以太網的吞吐量飽和或接近飽和,用什么編程語言其實無所謂。Java 做網絡服務端開發也是很好的選擇(不是指 web 開發,而是做一些基礎的分布式組件,例如 ZooKeeper 和 Hadoop 之類)。盡管可能 C++ 只用了 15% 的 CPU,而 Java 用了 30% 的 CPU,Java 還占用更多的內存,但是千兆網卡帶寬都已經跑滿,那些省下在資源也只能浪費了;對于外界(從網線上看過來)而言,兩種語言的效果是一樣的,而通常 Java 的開發效率更高。(Java 是比 C++ 慢一些,但是透過千兆網絡不一定還能看得出這個區別來。)

            進程間通過 TCP 相互連接

            陳碩在《多線程服務器的常用編程模型》第 5 節“進程間通信”中提倡僅使用 TCP 作為進程間通信的手段,今天這個觀點將再次得到驗證。

            以下是 Hadoop 的分布式文件系統 HDFS 的架構簡圖。

            harness_system

            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)了與被測進程打交道的全部程序。如下圖所示,是不是有點像“缸中之腦”?

            harness_namenode

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

            harness_datanode

            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.htmlhttp://blog.codingnow.com/2006/04/iocp_kqueue_epoll.htmlhttp://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 對于開發分布式程序相當有幫助,它能達到單元測試的自動化程度和細致程度,又避免了單元測試對功能代碼結構的侵入與依賴。

            posted @ 2011-04-25 00:27 陳碩 閱讀(995) | 評論 (0)編輯 收藏

            Muduo 網絡編程示例之五: 測量兩臺機器的網絡延遲

            Muduo 網絡編程示例之五: 測量兩臺機器的網絡延遲

            陳碩 (giantchen_AT_gmail)

            Blog.csdn.net/Solstice  t.sina.com.cn/giantchen

            這是《Muduo 網絡編程示例》系列的第五篇文章。

            Muduo 全系列文章列表: http://blog.csdn.net/Solstice/category/779646.aspx

             

            本文介紹一個簡單的網絡程序 roundtrip,用于測量兩臺機器之間的網絡延遲,即“往返時間 / round trip time / RTT”。這篇文章主要考察定長 TCP 消息的分包,TCP_NODELAY 的作用。

            本文的代碼見 http://code.google.com/p/muduo/source/browse/trunk/examples/roundtrip/roundtrip.cc

            測量 RTT 的辦法很簡單:

            • host A 發一條消息給 host B,其中包含 host A 發送消息的本地時間
            • host B 收到之后立刻把消息 echo 回 host A
            • host A 收到消息之后,用當前時間減去消息中的時間就得到了 RTT。

            NTP 協議的工作原理與之類似,不過,除了測量 RTT,NTP 還需要知道兩臺機器之間的時間差 (clock offset),這樣才能校準時間。

            roundtrip_ntp

            以上是 NTP 協議收發消息的協議,RTT = (T4-T1) – (T3-T2),時間差 = ((T4+T1)-(T2+T3))/2。NTP 的要求是往返路徑上的單程延遲要盡量相等,這樣才能減少系統誤差。偶然誤差由單程延遲的不確定性決定。

            在我設計的 roundtrip 示例程序中,協議有所簡化:

            roundtrip_simple

            簡化之后的協議少取一次時間,因為 server 收到消息之后立刻發送回 client,耗時很少(若干微秒),基本不影響最終結果。

            我設計的消息格式是 16 字節定長消息:

            roundtrip_msg

            T1 和 T2 都是 muduo::Timestamp,一個 int64_t,表示從 Epoch 到現在的微秒數。

            為了讓消息的單程往返時間接近,server 和 client 發送的消息都是 16 bytes,這樣做到對稱。

            由于是定長消息,可以不必使用 codec,在 message callback 中直接用

            while (buffer->readableBytes() >= frameLen) { ... } 就能 decode。

            請讀者思考,如果把 while 換成 if 會有什么后果?

             

            client 程序以 200ms 為間隔發送消息,在收到消息之后打印 RTT 和 clock offset。一次運作實例如下:

            roundtrip_example

            這個例子中,client 和 server 的時鐘不是完全對準的,server 的時間快了 850 us,用 roundtrip 程序能測量出這個時間差。有了這個時間差就能校正分布式系統中測量得到的消息延遲。

            比方說以上圖為例,server 在它本地 1.235000 時刻發送了一條消息,client 在它本地 1.234300 收到這條消息,直接計算的話延遲是 –700us。這個結果肯定是錯的,因為 server 和 client 不在一個時鐘域(這是數字電路中的概念),它們的時間直接相減無意義。如果我們已經測量得到 server 比 client 快 850us,那么做用這個數據一次校正: -700+850 = 150us,這個結果就比較符合實際了。當然,在實際應用中,clock offset 要經過一個低通濾波才能使用,不然偶然性太大。

            請讀者思考,為什么不能直接以 RTT/2 作為兩天機器之間收發消息的單程延遲?

            這個程序在局域網中使用沒有問題,如果在廣域網上使用,而且 RTT 大于 200ms,那么受 Nagle 算法影響,測量結果是錯誤的(具體分析留作練習,這能測試對 Nagle 的理解),這時候我們需要設置 TCP_NODELAY 參數,讓程序在廣域網上也能正常工作。

            posted @ 2011-04-20 09:26 陳碩 閱讀(3125) | 評論 (7)編輯 收藏

            Muduo 設計與實現之一:Buffer 類的設計

            陳碩 (giantchen_AT_gmail)

            Blog.csdn.net/Solstice  t.sina.com.cn/giantchen

            Muduo 全系列文章列表: http://blog.csdn.net/Solstice/category/779646.aspx

             

            本文介紹 Muduo 中輸入輸出緩沖區的設計與實現。

            本文中 buffer 指一般的應用層緩沖區、緩沖技術,Buffer 特指 muduo::net::Buffer class。

            本文前兩節的內容已事先發表在 muduo 英文博客 http://muduo.chenshuo.com/2011/04/essentials-of-non-blocking-tcp-network.html

            Muduo 的 IO 模型

            UNPv1 第 6.2 節總結了 Unix/Linux 上的五種 IO 模型:阻塞(blocking)、非阻塞(non-blocking)、IO 復用(IO multiplexing)、信號驅動(signal-driven)、異步(asynchronous)。這些都是單線程下的 IO 模型。

            C10k 問題的頁面介紹了五種 IO 策略,把線程也納入考量。(現在 C10k 已經不是什么問題,C100k 也不是大問題,C1000k 才算得上挑戰)。

            在這個多核時代,線程是不可避免的。那么服務端網絡編程該如何選擇線程模型呢?我贊同 libev 作者的觀點:one loop per thread is usually a good model。之前我也不止一次表述過這個觀點,見《多線程服務器的常用編程模型》《多線程服務器的適用場合》。

            如果采用 one loop per thread 的模型,多線程服務端編程的問題就簡化為如何設計一個高效且易于使用的 event loop,然后每個線程 run 一個 event loop 就行了(當然、同步和互斥是不可或缺的)。在“高效”這方面已經有了很多成熟的范例(libev、libevent、memcached、varnish、lighttpd、nginx),在“易于使用”方面我希望 muduo 能有所作為。(muduo 可算是用現代 C++ 實現了 Reactor 模式,比起原始的 Reactor 來說要好用得多。)

            event loop 是 non-blocking 網絡編程的核心,在現實生活中,non-blocking 幾乎總是和 IO-multiplexing 一起使用,原因有兩點:

            • 沒有人真的會用輪詢 (busy-pooling) 來檢查某個 non-blocking IO 操作是否完成,這樣太浪費 CPU cycles。
            • IO-multiplex 一般不能和 blocking IO 用在一起,因為 blocking IO 中 read()/write()/accept()/connect() 都有可能阻塞當前線程,這樣線程就沒辦法處理其他 socket 上的 IO 事件了。見 UNPv1 第 16.6 節“nonblocking accept”的例子。

            所以,當我提到 non-blocking 的時候,實際上指的是 non-blocking + IO-muleiplexing,單用其中任何一個是不現實的。另外,本文所有的“連接”均指 TCP 連接,socket 和 connection 在文中可互換使用。

            當然,non-blocking 編程比 blocking 難得多,見陳碩在《Muduo 網絡編程示例之零:前言》中“TCP 網絡編程本質論”一節列舉的難點。基于 event loop 的網絡編程跟直接用 C/C++ 編寫單線程 Windows 程序頗為相像:程序不能阻塞,否則窗口就失去響應了;在 event handler 中,程序要盡快交出控制權,返回窗口的事件循環。

            為什么 non-blocking 網絡編程中應用層 buffer 是必須的?

            Non-blocking IO 的核心思想是避免阻塞在 read() 或 write() 或其他 IO 系統調用上,這樣可以最大限度地復用 thread-of-control,讓一個線程能服務于多個 socket 連接。IO 線程只能阻塞在 IO-multiplexing 函數上,如 select()/poll()/epoll_wait()。這樣一來,應用層的緩沖是必須的,每個 TCP socket 都要有 stateful 的 input buffer 和 output buffer。

            TcpConnection 必須要有 output buffer

            考慮一個常見場景:程序想通過 TCP 連接發送 100k 字節的數據,但是在 write() 調用中,操作系統只接受了 80k 字節(受 TCP advertised window 的控制,細節見 TCPv1),你肯定不想在原地等待,因為不知道會等多久(取決于對方什么時候接受數據,然后滑動 TCP 窗口)。程序應該盡快交出控制權,返回 event loop。在這種情況下,剩余的 20k 字節數據怎么辦?

            對于應用程序而言,它只管生成數據,它不應該關心到底數據是一次性發送還是分成幾次發送,這些應該由網絡庫來操心,程序只要調用 TcpConnection::send() 就行了,網絡庫會負責到底。網絡庫應該接管這剩余的 20k 字節數據,把它保存在該 TCP connection 的 output buffer 里,然后注冊 POLLOUT 事件,一旦 socket 變得可寫就立刻發送數據。當然,這第二次 write() 也不一定能完全寫入 20k 字節,如果還有剩余,網絡庫應該繼續關注 POLLOUT 事件;如果寫完了 20k 字節,網絡庫應該停止關注 POLLOUT,以免造成 busy loop。(Muduo EventLoop 采用的是 epoll level trigger,這么做的具體原因我以后再說。)

            如果程序又寫入了 50k 字節,而這時候 output buffer 里還有待發送的 20k 數據,那么網絡庫不應該直接調用 write(),而應該把這 50k 數據 append 在那 20k 數據之后,等 socket 變得可寫的時候再一并寫入。

            如果 output buffer 里還有待發送的數據,而程序又想關閉連接(對程序而言,調用 TcpConnection::send() 之后他就認為數據遲早會發出去),那么這時候網絡庫不能立刻關閉連接,而要等數據發送完畢,見我在《為什么 muduo 的 shutdown() 沒有直接關閉 TCP 連接?》一文中的講解。

            綜上,要讓程序在 write 操作上不阻塞,網絡庫必須要給每個 tcp connection 配置 output buffer。

            TcpConnection 必須要有 input buffer

            TCP 是一個無邊界的字節流協議,接收方必須要處理“收到的數據尚不構成一條完整的消息”和“一次收到兩條消息的數據”等等情況。一個常見的場景是,發送方 send 了兩條 10k 字節的消息(共 20k),接收方收到數據的情況可能是:

            • 一次性收到 20k 數據
            • 分兩次收到,第一次 5k,第二次 15k
            • 分兩次收到,第一次 15k,第二次 5k
            • 分兩次收到,第一次 10k,第二次 10k
            • 分三次收到,第一次 6k,第二次 8k,第三次 6k
            • 其他任何可能

            網絡庫在處理“socket 可讀”事件的時候,必須一次性把 socket 里的數據讀完(從操作系統 buffer 搬到應用層 buffer),否則會反復觸發 POLLIN 事件,造成 busy-loop。(Again, Muduo EventLoop 采用的是 epoll level trigger,這么做的具體原因我以后再說。)

            那么網絡庫必然要應對“數據不完整”的情況,收到的數據先放到 input buffer 里,等構成一條完整的消息再通知程序的業務邏輯。這通常是 codec 的職責,見陳碩《Muduo 網絡編程示例之二:Boost.Asio 的聊天服務器》一文中的“TCP 分包”的論述與代碼。

            所以,在 tcp 網絡編程中,網絡庫必須要給每個 tcp connection 配置 input buffer。

             

            所有 muduo 中的 IO 都是帶緩沖的 IO (buffered IO),你不會自己去 read() 或 write() 某個 socket,只會操作 TcpConnection 的 input buffer 和 output buffer。更確切的說,是在 onMessage() 回調里讀取 input buffer;調用 TcpConnection::send() 來間接操作 output buffer,一般不會直接操作 output buffer。

            btw, muduo 的 onMessage() 的原型如下,它既可以是 free function,也可以是 member function,反正 muduo TcpConnection 只認 boost::function<>。

            void onMessage(const TcpConnectionPtr& conn, Buffer* buf, Timestamp receiveTime);

            對于網絡程序來說,一個簡單的驗收測試是:輸入數據每次收到一個字節(200 字節的輸入數據會分 200 次收到,每次間隔 10 ms),程序的功能不受影響。對于 Muduo 程序,通常可以用 codec 來分離“消息接收”與“消息處理”,見陳碩《在 muduo 中實現 protobuf 編解碼器與消息分發器》一文中對“編解碼器 codec”的介紹。

            如果某個網絡庫只提供相當于 char buf[8192] 的緩沖,或者根本不提供緩沖區,而僅僅通知程序“某 socket 可讀/某 socket 可寫”,要程序自己操心 IO buffering,這樣的網絡庫用起來就很不方便了。(我有所指,你懂得。)

             

            Buffer 的要求

            http://code.google.com/p/muduo/source/browse/trunk/muduo/net/Buffer.h

            Muduo Buffer 的設計考慮了常見的網絡編程需求,我試圖在易用性和性能之間找一個平衡點,目前這個平衡點更偏向于易用性。

            Muduo Buffer 的設計要點:

            • 對外表現為一塊連續的內存(char*, len),以方便客戶代碼的編寫。
            • 其 size() 可以自動增長,以適應不同大小的消息。它不是一個 fixed size array (即 char buf[8192])。
            • 內部以 vector of char 來保存數據,并提供相應的訪問函數。

            Buffer 其實像是一個 queue,從末尾寫入數據,從頭部讀出數據。

            誰會用 Buffer?誰寫誰讀?根據前文分析,TcpConnection 會有兩個 Buffer 成員,input buffer 與 output buffer。

            • input buffer,TcpConnection 會從 socket 讀取數據,然后寫入 input buffer(其實這一步是用 Buffer::readFd() 完成的);客戶代碼從 input buffer 讀取數據。
            • output buffer,客戶代碼會把數據寫入 output buffer(其實這一步是用 TcpConnection::send() 完成的);TcpConnection 從 output buffer 讀取數據并寫入 socket。

            其實,input 和 output 是針對客戶代碼而言,客戶代碼從 input 讀,往 output 寫。TcpConnection 的讀寫正好相反。

            以下是 muduo::net::Buffer 的類圖。請注意,為了后面畫圖方便,這個類圖跟實際代碼略有出入,但不影響我要表達的觀點。

            bc

            這里不介紹每個成員函數的作用,留給《Muduo 網絡編程示例》系列。下文會仔細介紹 readIndex 和 writeIndex 的作用。

            Buffer::readFd()

            我在《Muduo 網絡編程示例之零:前言》中寫道

            • 在非阻塞網絡編程中,如何設計并使用緩沖區?一方面我們希望減少系統調用,一次讀的數據越多越劃算,那么似乎應該準備一個大的緩沖區。另一方面,我們系統減少內存占用。如果有 10k 個連接,每個連接一建立就分配 64k 的讀緩沖的話,將占用 640M 內存,而大多數時候這些緩沖區的使用率很低。muduo 用 readv 結合棧上空間巧妙地解決了這個問題。

            具體做法是,在棧上準備一個 65536 字節的 stackbuf,然后利用 readv() 來讀取數據,iovec 有兩塊,第一塊指向 muduo Buffer 中的 writable 字節,另一塊指向棧上的 stackbuf。這樣如果讀入的數據不多,那么全部都讀到 Buffer 中去了;如果長度超過 Buffer 的 writable 字節數,就會讀到棧上的 stackbuf 里,然后程序再把 stackbuf 里的數據 append 到 Buffer 中。

            代碼見 http://code.google.com/p/muduo/source/browse/trunk/muduo/net/Buffer.cc#36

            這么做利用了臨時棧上空間,避免開巨大 Buffer 造成的內存浪費,也避免反復調用 read() 的系統開銷(通常一次 readv() 系統調用就能讀完全部數據)。

            這算是一個小小的創新吧。

            線程安全?

            muduo::net::Buffer 不是線程安全的,這么做是有意的,原因如下:

            • 對于 input buffer,onMessage() 回調始終發生在該 TcpConnection 所屬的那個 IO 線程,應用程序應該在 onMessage() 完成對 input buffer 的操作,并且不要把 input buffer 暴露給其他線程。這樣所有對 input buffer 的操作都在同一個線程,Buffer class 不必是線程安全的。
            • 對于 output buffer,應用程序不會直接操作它,而是調用 TcpConnection::send() 來發送數據,后者是線程安全的。

            如果 TcpConnection::send() 調用發生在該 TcpConnection 所屬的那個 IO 線程,那么它會轉而調用 TcpConnection::sendInLoop(),sendInLoop() 會在當前線程(也就是 IO 線程)操作 output buffer;如果 TcpConnection::send() 調用發生在別的線程,它不會在當前線程調用 sendInLoop() ,而是通過 EventLoop::runInLoop() 把 sendInLoop() 函數調用轉移到 IO 線程(聽上去頗為神奇?),這樣 sendInLoop() 還是會在 IO 線程操作 output buffer,不會有線程安全問題。當然,跨線程的函數轉移調用涉及函數參數的跨線程傳遞,一種簡單的做法是把數據拷一份,絕對安全(不明白的同學請閱讀代碼)。

            另一種更為高效做法是用 swap()。這就是為什么 TcpConnection::send() 的某個重載以 Buffer* 為參數,而不是 const Buffer&,這樣可以避免拷貝,而用 Buffer::swap() 實現高效的線程間數據轉移。(最后這點,僅為設想,暫未實現。目前仍然以數據拷貝方式在線程間傳遞,略微有些性能損失。)

             

            Muduo Buffer 的數據結構

            Buffer 的內部是一個 vector of char,它是一塊連續的內存。此外,Buffer 有兩個 data members,指向該 vector 中的元素。這兩個 indices 的類型是 int,不是 char*,目的是應對迭代器失效。muduo Buffer 的設計參考了 Netty 的 ChannelBuffer 和 libevent 1.4.x 的 evbuffer。不過,其 prependable 可算是一點“微創新”。

            Muduo Buffer 的數據結構如下:

            圖 1buffer0

            兩個 indices 把 vector 的內容分為三塊:prependable、readable、writable,各塊的大小是(公式一):

            prependable = readIndex

            readable = writeIndex - readIndex

            writable = size() - writeIndex

            (prependable 的作用留到后面討論。)

            readIndex 和 writeIndex 滿足以下不變式(invariant):

            0 ≤ readIndex ≤ writeIndex ≤ data.size()

            Muduo Buffer 里有兩個常數 kCheapPrepend 和 kInitialSize,定義了 prependable 的初始大小和 writable 的初始大小。(readable 的初始大小為 0。)在初始化之后,Buffer 的數據結構如下:括號里的數字是該變量或常量的值。

            圖 2buffer1

            根據以上(公式一)可算出各塊的大小,剛剛初始化的 Buffer 里沒有 payload 數據,所以 readable == 0。

            Muduo Buffer 的操作

            1. 基本的 read-write cycle

            Buffer 初始化后的情況見圖 1,如果有人向 Buffer 寫入了 200 字節,那么其布局是:

            圖 3buffer2

            圖 3 中 writeIndex 向后移動了 200 字節,readIndex 保持不變,readable 和 writable 的值也有變化。

             

            如果有人從 Buffer read() & retrieve() (下稱“讀入”)了 50 字節,結果見圖 4。與上圖相比,readIndex 向后移動 50 字節,writeIndex 保持不變,readable 和 writable 的值也有變化(這句話往后從略)。

            圖 4buffer3

             

            然后又寫入了 200 字節,writeIndex 向后移動了 200 字節,readIndex 保持不變,見圖 5

            圖 5buffer4

             

            接下來,一次性讀入 350 字節,請注意,由于全部數據讀完了,readIndex 和 writeIndex 返回原位以備新一輪使用,見圖 6,這和圖 2 是一樣的。

            圖 6buffer5

             

            以上過程可以看作是發送方發送了兩條消息,長度分別為 50 字節和 350 字節,接收方分兩次收到數據,每次 200 字節,然后進行分包,再分兩次回調客戶代碼。

             

            自動增長

            Muduo Buffer 不是固定長度的,它可以自動增長,這是使用 vector 的直接好處。

            假設當前的狀態如圖 7 所示。(這和前面圖 5 是一樣的。)

            圖 7buffer4

             

            客戶代碼一次性寫入 1000 字節,而當前可寫的字節數只有 624,那么 buffer 會自動增長以容納全部數據,得到的結果是圖 8。注意 readIndex 返回到了前面,以保持 prependable 等于 kCheapPrependable。由于 vector 重新分配了內存,原來指向它元素的指針會失效,這就是為什么 readIndex 和 writeIndex 是整數下標而不是指針。

            圖 8buffer6

             

            然后讀入 350 字節,readIndex 前移,見圖 9

            圖 9buffer7

             

            最后,讀完剩下的 1000 字節,readIndex 和 writeIndex 返回 kCheapPrependable,見圖 10。

            圖 10buffer8

            注意 buffer 并沒有縮小大小,下次寫入 1350 字節就不會重新分配內存了。換句話說,Muduo Buffer 的 size() 是自適應的,它一開始的初始值是 1k,如果程序里邊經常收發 10k 的數據,那么用幾次之后它的 size() 會自動增長到 10k,然后就保持不變。這樣一方面避免浪費內存(有的程序可能只需要 4k 的緩沖),另一方面避免反復分配內存。當然,客戶代碼可以手動 shrink() buffer size()。

            size() 與 capacity()

            使用 vector 的另一個好處是它的 capcity() 機制減少了內存分配的次數。比方說程序反復寫入 1 字節,muduo Buffer 不會每次都分配內存,vector 的 capacity() 以指數方式增長,讓 push_back() 的平均復雜度是常數。比方說經過第一次增長,size() 剛好滿足寫入的需求,如圖 11。但這個時候 vector 的 capacity() 已經大于 size(),在接下來寫入 capacity()-size() 字節的數據時,都不會重新分配內存,見圖 12

            圖 11buffer6

            圖 12buffer9

             

            細心的讀者可能會發現用 capacity() 也不是完美的,它有優化的余地。具體來說,vector::resize() 會初始化(memset/bzero)內存,而我們不需要它初始化,因為反正立刻就要填入數據。比如,在圖 12 的基礎上寫入 200 字節,由于 capacity() 足夠大,不會重新分配內存,這是好事;但是 vector::resize() 會先把那 200 字節設為 0 (圖 13),然后 muduo buffer 再填入數據(圖 14)。這么做稍微有點浪費,不過我不打算優化它,除非它確實造成了性能瓶頸。(精通 STL 的讀者可能會說用 vector::append() 以避免浪費,但是 writeIndex 和 size() 不一定是對齊的,會有別的麻煩。)

             

            圖 13buffer9a

            圖 14buffer9b

            google protobuf 中有一個 STLStringResizeUninitialized 函數,干的就是這個事情。

            內部騰挪

            有時候,經過若干次讀寫,readIndex 移到了比較靠后的位置,留下了巨大的 prependable 空間,見圖 14

            圖 14buffer10

             

            這時候,如果我們想寫入 300 字節,而 writable 只有 200 字節,怎么辦?muduo Buffer 在這種情況下不會重新分配內存,而是先把已有的數據移到前面去,騰出 writable 空間,見圖 15

            圖 15buffer11

             

            然后,就可以寫入 300 字節了,見圖 16

            圖 16buffer12

            這么做的原因是,如果重新分配內存,反正也是要把數據拷到新分配的內存區域,代價只會更大。

            prepend

            前面說 muduo Buffer 有個小小的創新(或許不是創新,我記得在哪兒看到過類似的做法,忘了出處),即提供 prependable 空間,讓程序能以很低的代價在數據前面添加幾個字節。

            比方說,程序以固定的4個字節表示消息的長度(即《Muduo 網絡編程示例之二:Boost.Asio 的聊天服務器》中的 LengthHeaderCodec),我要序列化一個消息,但是不知道它有多長,那么我可以一直 append() 直到序列化完成(圖 17,寫入了 200 字節),然后再在序列化數據的前面添加消息的長度(圖 18,把 200 這個數 prepend 到首部)。

             

            圖 17buffer13

             

            圖 18buffer14

            通過預留 kCheapPrependable 空間,可以簡化客戶代碼,一個簡單的空間換時間思路。

            其他設計方案

            這里簡單談談其他可能的應用層 buffer 設計方案。

            不用 vector<char>?

            如果有 STL 潔癖,那么可以自己管理內存,以 4 個指針為 buffer 的成員,數據結構見圖 19。

            圖 19alternative

            說實話我不覺得這種方案比 vector 好。代碼變復雜,性能也未見得有 noticeable 的改觀。

            如果放棄“連續性”要求,可以用 circular buffer,這樣可以減少一點內存拷貝(沒有“內部騰挪”)。

            Zero copy ?

            如果對性能有極高的要求,受不了 copy() 與 resize(),那么可以考慮實現分段連續的 zero copy buffer 再配合 gather scatter IO,數據結構如圖 20,這是 libevent 2.0.x 的設計方案。TCPv2介紹的 BSD TCP/IP 實現中的 mbuf 也是類似的方案,Linux 的 sk_buff 估計也差不多。細節有出入,但基本思路都是不要求數據在內存中連續,而是用鏈表把數據塊鏈接到一起。

            圖 20evbuf0

            當然,高性能的代價是代碼變得晦澀難讀,buffer 不再是連續的,parse 消息會稍微麻煩。如果你的程序只處理 protobuf Message,這不是問題,因為 protobuf 有 ZeroCopyInputStream 接口,只要實現這個接口,parsing 的事情就交給 protobuf Message 去操心了。

            性能是不是問題?看跟誰比

            看到這里,有的讀者可能會嘀咕,muduo Buffer 有那么多可以優化的地方,其性能會不會太低?對此,我的回應是“可以優化,不一定值得優化。”

            Muduo 的設計目標是用于開發公司內部的分布式程序。換句話說,它是用來寫專用的 Sudoku server 或者游戲服務器,不是用來寫通用的 httpd 或 ftpd 或 www proxy。前者通常有業務邏輯,后者更強調高并發與高吞吐。

            以 Sudoku 為例,假設求解一個 Sudoku 問題需要 0.2ms,服務器有 8 個核,那么理想情況下每秒最多能求解 40,000 個問題。每次 Sudoku 請求的數據大小低于 100 字節(一個 9x9 的數獨只要 81 字節,加上 header 也可以控制在 100 bytes 以下),就是說 100 x 40000 = 4 MB per second 的吞吐量就足以讓服務器的 CPU 飽和。在這種情況下,去優化 Buffer 的內存拷貝次數似乎沒有意義。

            再舉一個例子,目前最常用的千兆以太網的裸吞吐量是 125MB/s,扣除以太網 header、IP header、TCP header之后,應用層的吞吐率大約在 115 MB/s 上下。而現在服務器上最常用的 DDR2/DDR3 內存的帶寬至少是 4GB/s,比千兆以太網高 40 倍以上。就是說,對于幾 k 或幾十 k 大小的數據,在內存里邊拷幾次根本不是問題,因為受以太網延遲和帶寬的限制,跟這個程序通信的其他機器上的程序不會覺察到性能差異。

            最后舉一個例子,如果你實現的服務程序要跟數據庫打交道,那么瓶頸常常在 DB 上,優化服務程序本身不見得能提高性能(從 DB 讀一次數據往往就抵消了你做的全部 low-level 優化),這時不如把精力投入在 DB 調優上。

            專用服務程序與通用服務程序的另外一點區別是 benchmark 的對象不同。如果你打算寫一個 httpd,自然有人會拿來和目前最好的 nginx 對比,立馬就能比出性能高低。然而,如果你寫一個實現公司內部業務的服務程序(比如分布式存儲或者搜索或者微博或者短網址),由于市面上沒有同等功能的開源實現,你不需要在優化上投入全部精力,只要一版做得比一版好就行。先正確實現所需的功能,投入生產應用,然后再根據真實的負載情況來做優化,這恐怕比在編碼階段就盲目調優要更 effective 一些。

            Muduo 的設計目標之一是吞吐量能讓千兆以太網飽和,也就是每秒收發 120 兆字節的數據。這個很容易就達到,不用任何特別的努力。

            如果確實在內存帶寬方面遇到問題,說明你做的應用實在太 critical,或許應該考慮放到 Linux kernel 里邊去,而不是在用戶態嘗試各種優化。畢竟只有把程序做到 kernel 里才能真正實現 zero copy,否則,核心態和用戶態之間始終是有一次內存拷貝的。如果放到 kernel 里還不能滿足需求,那么要么自己寫新的 kernel,或者直接用 FPGA 或 ASIC 操作 network adapter 來實現你的高性能服務器。

            (待續)

            posted @ 2011-04-17 12:24 陳碩 閱讀(9821) | 評論 (28)編輯 收藏

            在 muduo 中實現 protobuf 編解碼器與消息分發器

            陳碩 (giantchen_AT_gmail)

            Blog.csdn.net/Solstice  t.sina.com.cn/giantchen

            Muduo 全系列文章列表: http://blog.csdn.net/Solstice/category/779646.aspx

            本文是《一種自動反射消息類型的 Google Protobuf 網絡傳輸方案》的延續,介紹如何將前文介紹的打包方案與 muduo::net::Buffer 結合,實現了 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/

            考慮到不是每個人都安裝了 Google Protobuf,muduo 中的 protobuf 相關示例默認是不 build 的,如果你的機器上安裝了 protobuf 2.3.0 或 2.4.0a,那么可以用 ./build.sh protobuf_all 來構建 protobuf 相關的 examples。

             

            在介紹 codec 和 dispatcher 之前,先講講前文的一個未決問題。

            為什么 Protobuf 的默認序列化格式沒有包含消息的長度與類型?

            Protobuf 是經過深思熟慮的消息打包方案,它的默認序列化格式沒有包含消息的長度與類型,自然有其道理。哪些情況下不需要在 protobuf 序列化得到的字節流中包含消息的長度和(或)類型?我能想到的答案有:

            • 如果把消息寫入文件,一個文件存一個消息,那么序列化結果中不需要包含長度和類型,因為從文件名和文件長度中可以得知消息的類型與長度。
            • 如果把消息寫入文件,一個文件存多個消息,那么序列化結果中不需要包含類型,因為文件名就代表了消息的類型。
            • 如果把消息存入數據庫(或者 NoSQL),以 VARBINARY 字段保存,那么序列化結果中不需要包含長度和類型,因為從字段名和字段長度中可以得知消息的類型與長度。
            • 如果把消息以 UDP 方式發生給對方,而且對方一個 UDP port 只接收一種消息類型,那么序列化結果中不需要包含長度和類型,因為從 port 和 UDP packet 長度中可以得知消息的類型與長度。
            • 如果把消息以 TCP 短連接方式發給對方,而且對方一個 TCP port 只接收一種消息類型,那么序列化結果中不需要包含長度和類型,因為從 port 和 TCP 字節流長度中可以得知消息的類型與長度。
            • 如果把消息以 TCP 長連接方式發給對方,但是對方一個 TCP port 只接收一種消息類型,那么序列化結果中不需要包含類型,因為 port 代表了消息的類型。
            • 如果采用 RPC 方式通信,那么只需要告訴對方 method name,對方自然能推斷出 Request 和 Response 的消息類型,這些可以由 protoc 生成的 RPC stubs 自動搞定。

            對于最后一點,比方說 sudoku.proto 定義為:

            service SudokuService {
              rpc Solve (SudokuRequest) returns (SudokuResponse);
            }

            那么 RPC method Sudoku.Solve 對應的請求和響應分別是 SudokuRequest 和 SudokuResponse。在發送 RPC 請求的時候,不需要包含 SudokuRequest 的類型,只需要發送 method name Sudoku.Solve,對方自知道應該按照 SudokuRequest 來解析(parse)請求。這個例子來自我的半成品項目 evproto,見 http://blog.csdn.net/Solstice/archive/2010/04/17/5497699.aspx

            對于上述這些情況,如果 protobuf 無條件地把長度和類型放到序列化的字節串中,只會浪費網絡帶寬和存儲。可見 protobuf 默認不發送長度和類型是正確的決定。Protobuf 為消息格式的設計樹立了典范,哪些該自己搞定,哪些留給外部系統去解決,這些都考慮得很清楚。

            只有在使用 TCP 長連接,且在一個連接上傳遞不止一種消息的情況下(比方同時發 Heartbeat 和 Request/Response),才需要我前文提到的那種打包方案。(為什么要在一個連接上同時發 Heartbeat 和業務消息?請見陳碩《分布式系統的工程化開發方法》 p.51 心跳協議的設計。)這時候我們需要一個分發器 dispatcher,把不同類型的消息分給各個消息處理函數,這正是本文的主題之一。

            以下均只考慮 TCP 長連接這一應用場景。

            先談談編解碼器。

            什么是編解碼器 codec?

            Codec 是 encoder 和 decoder 的縮寫,這是一個到軟硬件都在使用的術語,這里我借指“把網絡數據和業務消息之間互相轉換”的代碼。

            在最簡單的網絡編程中,沒有消息 message 只有字節流數據,這時候是用不到 codec 的。比如我們前面講過的 echo server,它只需要把收到的數據原封不動地發送回去,它不必關心消息的邊界(也沒有“消息”的概念),收多少就發多少,這種情況下它干脆直接使用 muduo::net::Buffer,取到數據再交給 TcpConnection 發送回去,見下圖。

            codec_echo

            non-trivial 的網絡服務程序通常會以消息為單位來通信,每條消息有明確的長度與界限。程序每次收到一個完整的消息的時候才開始處理,發送的時候也是把一個完整的消息交給網絡庫。比如我們前面講過的 asio chat 服務,它的一條聊天記錄就是一條消息,我們設計了一個簡單的消息格式,即在聊天記錄前面加上 4 字節的 length header,LengthHeaderCodec 代碼及解說見《Muduo 網絡編程示例之二:Boost.Asio 的聊天服務器》一文。

            codec 的基本功能之一是做 TCP 分包:確定每條消息的長度,為消息劃分界限。在 non-blocking 網絡編程中,codec 幾乎是必不可少的。如果只收到了半條消息,那么不會觸發消息回調,數據會停留在 Buffer 里(數據已經讀到 Buffer 中了),等待收到一個完整的消息再通知處理函數。既然這個任務太常見,我們干脆做一個 utility class,避免服務端和客戶端程序都要自己處理分包,這就有了 LengthHeaderCodec。這個 codec 的使用有點奇怪,不需要繼承,它也沒有基類,只要把它當成普通 data member 來用,把 TcpConnection 的數據喂給它,然后向它注冊 onXXXMessage() 回調,代碼見 asio chat 示例。muduo 里的 codec 都是這樣的風格,通過 boost::function 粘合到一起。

            codec 是一層間接性,它位于 TcpConnection 和 ChatServer 之間,攔截處理收到的數據,在收到完整的消息之后再調用 CharServer 對應的處理函數,注意 CharServer::onStringMessage() 的參數是 std::string,不再是 muduo::net::Buffer,也就是說 LengthHeaderCodec 把 Buffer 解碼成了 string。另外,在發送消息的時候,ChatServer 通過 LengthHeaderCodec::send() 來發送 string,LengthHeaderCodec 負責把它編碼成 Buffer。這正是“編解碼器”名字的由來。

            codec_chat

            Protobuf codec 與此非常類似,只不過消息類型從 std::string 變成了 protobuf::Message。對于只接收處理 Query 消息的 QueryServer 來說,用 ProtobufCodec 非常方便,收到 protobuf::Message 之后 down cast 成 Query 來用就行。如果要接收處理不止一種消息,ProtobufCodec 恐怕還不能單獨完成工作,請繼續閱讀下文。

            codec_protobuf

             

            實現 ProtobufCodec

            Protobuf 的打包方案我已經在《一種自動反射消息類型的 Google Protobuf 網絡傳輸方案》中講過,并以 string 為載體演示了 encode 和 decode 操作。在 muduo 里,我們有專門的 Buffer class,編碼更輕松。

            編碼算法很直截了當,按照前文定義的消息格式一路打包下來,最后更新一下首部的長度即可。

            解碼算法有幾個要點:

            • protobuf::Message 是 new 出來的對象,它的生命期如何管理?muduo 采用 shared_ptr<Message> 來自動管理對象生命期,這與其他地方的做法是一致的。
            • 出錯如何處理?比方說長度超出范圍、check sum 不正確、message type name 不能識別、message parse 出錯等等。ProtobufCodec 定義了 ErrorCallback,用戶代碼可以注冊這個回調。如果不注冊,默認的處理是斷開連接,讓客戶重連重試。codec 的單元測試里模擬了各種出錯情況。
            • 如何處理一次收到半條消息、一條消息、一條半消息、兩條消息等等情況?這是每個 non-blocking 網絡程序中的 codec 都要面對的問題。

            ProtobufCodec 在實際使用中有明顯的不足:它只負責把 muduo::net::Buffer 轉換為具體類型的 protobuf::Message,應用程序拿到 Message 之后還有再根據其具體類型做一次分發。我們可以考慮做一個簡單通用的分發器 dispatcher,以簡化客戶代碼。

            此外,目前 ProtobufCodec 的實現非常初級,它沒有充分利用 ZeroCopyInputStream 和 ZeroCopyOutputStream,而是把收到的數據作為 byte array 交給 protobuf Message 去解析,這給性能優化留下了空間。protobuf Message 不要求數據連續(像 vector 那樣),只要求數據分段連續(像 deque 那樣),這給 buffer 管理帶來性能上的好處(避免重新分配內存,減少內存碎片),當然也使得代碼變復雜。muduo::net::Buffer 非常簡單,它內部是 vector<char>,我目前不想讓 protobuf 影響 muduo 本身的設計,畢竟 muduo 是個通用的網絡庫,不是為實現 protobuf RPC 而特制的。

            消息分發器 dispatcher 有什么用?

            前面提到,在使用 TCP 長連接,且在一個連接上傳遞不止一種 protobuf 消息的情況下,客戶代碼需要對收到的消息按類型做分發。比方說,收到 Logon 消息就交給 QueryServer::onLogon() 去處理,收到 Query 消息就交給 QueryServer::onQuery() 去處理。這個消息分派機制可以做得稍微有點通用性,讓所有 muduo+protobuf 程序收益,而且不增加復雜性。

            換句話說,又是一層間接性,ProtobufCodec 攔截了 TcpConnection 的數據,把它轉換為 Message,ProtobufDispatcher 攔截了 ProtobufCodec 的 callback,按消息具體類型把它分派給多個 callbacks。

            codec_dispatcher

            ProtobufCodec 與 ProtobufDispatcher 的綜合運用

            我寫了兩個示例代碼,client 和 server,把 ProtobufCodec 和 ProtobufDispatcher 串聯起來使用。server 響應 Query 消息,發生回 Answer 消息,如果收到未知消息類型,則斷開連接。client 可以選擇發送 Query 或 Empty 消息,由命令行控制。這樣可以測試 unknown message callback。

            為節省篇幅,這里就不列出代碼了,請移步閱讀

            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

            在構造函數中,通過注冊回調函數把四方 (TcpConnection、codec、dispatcher、QueryServer) 結合起來。

            ProtobufDispatcher 的兩種實現

            要完成消息分發,那么就是對消息做 type-switch,這似乎是一個 bad smell,但是 protobuf Message 的 Descriptor 沒有留下定制點(比如暴露一個 boost::any 成員),我們只好硬來了。

            先定義

            typedef boost::function<void (Message*)> ProtobufMessageCallback;

            注意,本節出現的不是 muduo dispatcher 真實的代碼,僅為示意,突出重點,便于畫圖。

            ProtobufDispatcherLite 的結構非常簡單,它有一個 map<Descriptor*, ProtobufMessageCallback> 成員,客戶代碼可以以 Descriptor* 為 key 注冊回調(recall: 每個具體消息類型都有一個全局的 Descriptor 對象,其地址是不變的,可以用來當 key)。在收到 protobuf Message 之后,在 map 中找到對應的 ProtobufMessageCallback,然后調用之。如果找不到,就調用 defaultCallback。

            codec_dispatcher_lite

            當然,它的設計也有小小的缺陷,那就是 ProtobufMessageCallback 限制了客戶代碼只能接受基類 Message,客戶代碼需要自己做向下轉型,比如:

            codec_query_server1

             

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

            codec_query_server2

            那么該該如何實現 ProtobufDispatcher 呢?它如何與多個未知的消息類型合作?做 down cast 需要知道目標類型,難道我們要用一長串模板類型參數嗎?

            有一個辦法,把多態與模板結合,利用 templated derived class 來提供類型上的靈活性。設計如下。

            codec_dispatcher_class

            ProtobufDispatcher 有一個模板成員函數,可以接受注冊任意消息類型 T 的回調,然后它創建一個模板化的派生類 CallbackT<T>,這樣消息的類新信息就保存在了 CallbackT<T> 中,做 down casting 就簡單了。

             

            比方說,我們有兩個具體消息類型 Query 和 Answer。

            codec_query

            然后我們這樣注冊回調:

            dispatcher_.registerMessageCallback<muduo::Query>(
                boost::bind(&QueryServer::onQuery, this, _1, _2, _3));
            dispatcher_.registerMessageCallback<muduo::Answer>(
                boost::bind(&QueryServer::onAnswer, this, _1, _2, _3));

            這樣會具現化 (instantiation) 出兩個 CallbackT 實體,如下:

            codec_query_callback

            以上設計參考了 shared_ptr 的 deleter,Scott Meyers 也談到過

            ProtobufCodec 和 ProtobufDispatcher 有何意義?

            ProtobufCodec 和 ProtobufDispatcher 把每個直接收發 protobuf Message 的網絡程序都會用到的功能提煉出來做成了公用的 utility,這樣以后新寫 protobuf 網絡程序就不必為打包分包和消息分發勞神了。它倆以庫的形式存在,是兩個可以拿來就當 data member 用的 class,它們沒有基類,也沒有用到虛函數或者別的什么面向對象特征,不侵入 muduo::net 或者你的代碼。

             

            下一篇文章講《分布式程序的自動回歸測試》會介紹利用 protobuf 的跨語言特性,采用 Java 為 C++ 服務程序編寫 test harness。

            posted @ 2011-04-13 07:47 陳碩 閱讀(4450) | 評論 (1)編輯 收藏

            一種自動反射消息類型的 Google Protobuf 網絡傳輸方案

            陳碩 (giantchen_AT_gmail)

            Blog.csdn.net/Solstice  t.sina.com.cn/giantchen

             

            這篇文章要解決的問題是:在接收到 protobuf 數據之后,如何自動創建具體的 Protobuf Message 對象,再做的反序列化。“自動”的意思是:當程序中新增一個 protobuf Message 類型時,這部分代碼不需要修改,不需要自己去注冊消息類型。其實,Google Protobuf 本身具有很強的反射(reflection)功能,可以根據 type name 創建具體類型的 Message 對象,我們直接利用即可。

            本文假定讀者了解 Google Protocol Buffers 是什么,這不是一篇 protobuf 入門教程。

            本文以 C++ 語言舉例,其他語言估計有類似的解法,歡迎補充。

            本文的示例代碼在: https://github.com/chenshuo/recipes/tree/master/protobuf

            網絡編程中使用 protobuf 的兩個問題

            Google Protocol Buffers (Protobuf) 是一款非常優秀的庫,它定義了一種緊湊的可擴展二進制消息格式,特別適合網絡數據傳輸。它為多種語言提供 binding,大大方便了分布式程序的開發,讓系統不再局限于用某一種語言來編寫。

            在網絡編程中使用 protobuf 需要解決兩個問題:

            • 長度,protobuf 打包的數據沒有自帶長度信息或終結符,需要由應用程序自己在發生和接收的時候做正確的切分;
            • 類型,protobuf 打包的數據沒有自帶類型信息,需要由發送方把類型信息傳給給接收方,接收方創建具體的 Protobuf Message 對象,再做的反序列化。

            第一個很好解決,通常的做法是在每個消息前面加個固定長度的 length header,例如我在 《Muduo 網絡編程示例之二: Boost.Asio 的聊天服務器》 中實現的 LengthHeaderCodec,代碼見 http://code.google.com/p/muduo/source/browse/trunk/examples/asio/chat/codec.h

            第二個問題其實也很好解決,Protobuf 對此有內建的支持。但是奇怪的是,從網上簡單搜索的情況看,我發現了很多山寨的做法。

             

            山寨做法

            以下均為在 protobuf data 之前加上 header,header 中包含 int length 和類型信息。類型信息的山寨做法主要有兩種:

            • 在 header 中放 int typeId,接收方用 switch-case 來選擇對應的消息類型和處理函數;
            • 在 header 中放 string typeName,接收方用 look-up table 來選擇對應的消息類型和處理函數。

            這兩種做法都有問題。

            第一種做法要求保持 typeId 的唯一性,它和 protobuf message type 一一對應。如果 protobuf message 的使用范圍不廣,比如接收方和發送方都是自己維護的程序,那么 typeId 的唯一性不難保證,用版本管理工具即可。如果 protobuf message 的使用范圍很大,比如全公司都在用,而且不同部門開發的分布式程序可能相互通信,那么就需要一個公司內部的全局機構來分配 typeId,每次增加新 message type 都要去注冊一下,比較麻煩。

            第二種做法稍好一點。typeName 的唯一性比較好辦,因為可以加上 package name(也就是用 message 的 fully qualified type name),各個部門事先分好 namespace,不會沖突與重復。但是每次新增消息類型的時候都要去手工修改 look-up table 的初始化代碼,比較麻煩。

            其實,不需要自己重新發明輪子,protobuf 本身已經自帶了解決方案。

             

            根據 type name 反射自動創建 Message 對象

            Google Protobuf 本身具有很強的反射(reflection)功能,可以根據 type name 創建具體類型的 Message 對象。但是奇怪的是,其官方教程里沒有明確提及這個用法,我估計還有很多人不知道這個用法,所以覺得值得寫這篇 blog 談一談。

             

            以下是陳碩繪制的 Protobuf  class diagram,點擊查看原圖

            protobuf_classdiagram

            我估計大家通常關心和使用的是圖的左半部分:MessageLite、Message、Generated Message Types (Person, AddressBook) 等,而較少注意到圖的右半部分:Descriptor, DescriptorPool, MessageFactory。

            上圖中,其關鍵作用的是 Descriptor class,每個具體 Message Type 對應一個 Descriptor 對象。盡管我們沒有直接調用它的函數,但是Descriptor在“根據 type name 創建具體類型的 Message 對象”中扮演了重要的角色,起了橋梁作用。上圖的紅色箭頭描述了根據 type name 創建具體 Message 對象的過程,后文會詳細介紹。

            原理簡述

            Protobuf Message class 采用了 prototype pattern,Message class 定義了 New() 虛函數,用以返回本對象的一份新實例,類型與本對象的真實類型相同。也就是說,拿到 Message* 指針,不用知道它的具體類型,就能創建和它類型一樣的具體 Message Type 的對象。

            每個具體 Message Type 都有一個 default instance,可以通過 ConcreteMessage::default_instance() 獲得,也可以通過 MessageFactory::GetPrototype(const Descriptor*) 來獲得。所以,現在問題轉變為 1. 如何拿到 MessageFactory;2. 如何拿到 Descriptor*。

            當然,ConcreteMessage::descriptor() 返回了我們想要的 Descriptor*,但是,在不知道 ConcreteMessage 的時候,如何調用它的靜態成員函數呢?這似乎是個雞與蛋的問題。

            我們的英雄是 DescriptorPool,它可以根據 type name 查到 Descriptor*,只要找到合適的 DescriptorPool,再調用 DescriptorPool::FindMessageTypeByName(const string& type_name) 即可。眼前一亮?

            在最終解決問題之前,先簡單測試一下,看看我上面說的對不對。

            簡單測試

            本文用于舉例的 proto 文件:query.proto,見 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 是我在前一篇文章這提到的《分布式系統中的進程標識》。
            

            以下代碼驗證 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;

            根據 type name 自動創建 Message 的關鍵代碼

            好了,萬事具備,開始行動:

            1. 用 DescriptorPool::generated_pool() 找到一個 DescriptorPool 對象,它包含了程序編譯的時候所鏈接的全部 protobuf Message types
            2. 用 DescriptorPool::FindMessageTypeByName() 根據 type name 查找 Descriptor。
            3. 再用 MessageFactory::generated_factory() 找到 MessageFactory 對象,它能創建程序編譯的時候所鏈接的全部 protobuf Message types。
            4. 然后,用 MessageFactory::GetPrototype() 找到具體 Message Type 的 default instance。
            5. 最后,用 prototype->New() 創建對象。

            示例代碼見 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;
            }

            調用方式: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() 返回的是動態創建的對象的指針,調用方有責任釋放它,不然就會內存泄露。在 muduo 里,我用 shared_ptr<Message> 來自動管理 Message 對象的生命期。

            線程安全性

            Google 的文檔說,我們用到的那幾個 MessageFactory 和 DescriptorPool 都是線程安全的,Message::New() 也是線程安全的。并且它們都是 const member function。

             

            關鍵問題解決了,那么剩下工作就是設計一種包含長度和消息類型的 protobuf 傳輸格式

            Protobuf 傳輸格式

            陳碩設計了一個簡單的格式,包含 protobuf data 和它對應的長度與類型信息,消息的末尾還有一個 check sum。格式如下圖,圖中方塊的寬度是 32-bit。

            protobuf_wireformat1

            用 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
            };
            注意,這個格式不要求 32-bit 對齊,我們的 decoder 會自動處理非對齊的消息。

            例子

            用這個格式打包一個 muduo.Query 對象的結果是:

            protobuf_wireexample

            設計決策

            以下是我在設計這個傳輸格式時的考慮:

            • signed int。消息中的長度字段只使用了 signed 32-bit int,而沒有使用 unsigned int,這是為了移植性,因為 Java 語言沒有 unsigned 類型。另外 Protobuf 一般用于打包小于 1M 的數據,unsigned int 也沒用。
            • check sum。雖然 TCP 是可靠傳輸協議,雖然 Ethernet 有 CRC-32 校驗,但是網絡傳輸必須要考慮數據損壞的情況,對于關鍵的網絡應用,check sum 是必不可少的。對于 protobuf 這種緊湊的二進制格式而言,肉眼看不出數據有沒有問題,需要用 check sum。
            • adler32 算法。我沒有選用常見的 CRC-32,而是選用 adler32,因為它計算量小、速度比較快,強度和 CRC-32差不多。另外,zlib 和 java.unit.zip 都直接支持這個算法,不用我們自己實現。
            • type name 以 '\0' 結束。這是為了方便 troubleshooting,比如通過 tcpdump 抓下來的包可以用肉眼很容易看出 type name,而不用根據 nameLen 去一個個數字節。同時,為了方便接收方處理,加入了 nameLen,節省 strlen(),空間換時間。
            • 沒有版本號。Protobuf Message 的一個突出優點是用 optional fields 來避免協議的版本號(凡是在 protobuf Message 里放版本號的人都沒有理解 protobuf 的設計),讓通信雙方的程序能各自升級,便于系統演化。如果我設計的這個傳輸格式又把版本號加進去,那就畫蛇添足了。具體請見本人《分布式系統的工程化開發方法》第 57 頁:消息格式的選擇。

             

            示例代碼

            為了簡單起見,采用 std::string 來作為打包的產物,僅為示例。

            打包 encode 的代碼:https://github.com/chenshuo/recipes/blob/master/protobuf/codec.h#L35

            解包 decode 的代碼:https://github.com/chenshuo/recipes/blob/master/protobuf/codec.h#L99

            測試代碼: https://github.com/chenshuo/recipes/blob/master/protobuf/codec_test.cc

            如果以上代碼編譯通過,但是在運行時出現“cannot open shared object file”錯誤,一般可以用 sudo ldconfig 解決,前提是 libprotobuf.so 位于 /usr/local/lib,且 /etc/ld.so.conf 列出了這個目錄。

            $ 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 網絡庫將會集成對本文所述傳輸格式的支持(預計 0.1.9 版本),我會另外寫一篇短文介紹 Protobuf Message <=> muduo::net::Buffer 的相互轉化,使用 muduo::net::Buffer 來打包比上面 std::string 的代碼還簡單,它是專門為 non-blocking 網絡庫設計的 buffer class。

            此外,我們可以寫一個 codec 來自動完成轉換,就行 asio/char/codec.h 那樣。這樣客戶代碼直接收到的就是 Message 對象,發送的時候也直接發送 Message 對象,而不需要和 Buffer 對象打交道。

            消息的分發 (dispatching)

            目前我們已經解決了消息的自動創建,在網絡編程中,還有一個常見任務是把不同類型的 Message 分發給不同的處理函數,這同樣可以借助 Descriptor 來完成。我在 muduo 里實現了 ProtobufDispatcherLite 和 ProtobufDispatcher 兩個分發器,用戶可以自己注冊針對不同消息類型的處理函數。預計將會在 0.1.9 版本發布,您可以先睹為快:

            初級版,用戶需要自己做 down casting: https://github.com/chenshuo/recipes/blob/master/protobuf/dispatcher_lite.cc

            高級版,使用模板技巧,節省用戶打字: https://github.com/chenshuo/recipes/blob/master/protobuf/dispatcher.cc

            基于 muduo 的 Protobuf RPC?

            Google Protobuf 還支持 RPC,可惜它只提供了一個框架,沒有開源網絡相關的代碼,muduo 正好可以填補這一空白。我目前還沒有決定是不是讓 muduo 也支持以 protobuf message 為消息格式的 RPC,muduo 還有很多事情要做,我也有很多博客文章打算寫,RPC 這件事情以后再說吧。

            注:Remote Procedure Call (RPC) 有廣義和狹義兩種意思。狹義的講,一般特指 ONC RPC,就是用來實現 NFS 的那個東西;廣義的講,“以函數調用之名,行網絡通信之實”都可以叫 RPC,比如 Java RMI,.Net Remoting,Apache Thriftlibevent RPC,XML-RPC 等等。

             

            (待續)

            posted @ 2011-04-03 15:56 陳碩 閱讀(5553) | 評論 (1)編輯 收藏

            C++ 工程實踐(5):避免使用虛函數作為庫的接口

            陳碩 (giantchen_AT_gmail)

            Blog.csdn.net/Solstice

             

            摘要:作為 C++ 動態庫的作者,應當避免使用虛函數作為庫的接口。這么做會給保持二進制兼容性帶來很大麻煩,不得不增加很多不必要的 interfaces,最終重蹈 COM 的覆轍。

            本文主要討論 Linux x86 平臺,會繼續舉 Windows/COM 作為反面教材。

            本文是上一篇《C++ 工程實踐(4):二進制兼容性》的延續,在寫這篇文章的時候,我原本以外大家都對“以 C++ 虛函數作為接口”的害處達成共識,我就寫得比較簡略,看來情況不是這樣,我還得展開談一談。

            “接口”有廣義和狹義之分,本文用中文“接口”表示廣義的接口,即一個庫的代碼界面;用英文 interface 表示狹義的接口,即只包含 virtual function 的 class,這種 class 通常沒有 data member,在 Java 里有一個專門的關鍵字 interface 來表示它。

            C++ 程序庫的作者的生存環境

            假設你是一個 shared library 的維護者,你的 library 被公司另外兩三個團隊使用了。你發現了一個安全漏洞,或者某個會導致 crash 的 bug 需要緊急修復,那么你修復之后,能不能直接部署 library 的二進制文件?有沒有破壞二進制兼容性?會不會破壞別人團隊已經編譯好的投入生成環境的可執行文件?是不是要強迫別的團隊重新編譯鏈接,把可執行文件也發布新版本?會不會打亂別人的 release cycle?這些都是工程開發中經常要遇到的問題。

            如果你打算新寫一個 C++ library,那么通常要做以下幾個決策:

            • 以什么方式發布?動態庫還是靜態庫?(本文不考慮源代碼發布這種情況,這其實和靜態庫類似。)
            • 以什么方式暴露庫的接口?可選的做法有:以全局(含 namespace 級別)函數為接口、以 class 的 non-virtual 成員函數為接口、以 virtual 函數為接口(interface)。

            (Java 程序員沒有這么多需要考慮的,直接寫 class 成員函數就行,最多考慮一下要不要給 method 或 class 標上 final。也不必考慮動態庫靜態庫,都是 .jar 文件。)

            在作出上面兩個決策之前,我們考慮兩個基本假設:

            • 代碼會有 bug,庫也不例外。將來可能會發布 bug fixes。
            • 會有新的功能需求。寫代碼不是一錘子買賣,總是會有新的需求冒出來,需要程序員往庫里增加東西。這是好事情,讓程序員不丟飯碗。

            (如果你的代碼第一次發布的時候就已經做到完美,將來不需要任何修改,那么怎么做都行,也就不必繼續閱讀本文。)

            也就是說,在設計庫的時候必須要考慮將來如何升級

            基于以上兩個基本假設來做決定。第一個決定很好做,如果需要 hot fix,那么只能用動態庫;否則,在分布式系統中使用靜態庫更容易部署,這在前文中已經談過。(“動態庫比靜態庫節約內存”這種優勢在今天看來已不太重要。)

            以下本文假定你或者你的老板選擇以動態庫方式發布,即發布 .so 或 .dll 文件,來看看第二個決定怎么做。(再說一句,如果你能夠以靜態庫方式發布,后面的麻煩都不會遇到。)

            第二個決定不是那么容易做,關鍵問題是,要選擇一種可擴展的 (extensible) 接口風格,讓庫的升級變得更輕松。“升級”有兩層意思:

            • 對于 bug fix only 的升級,二進制庫文件的替換應該兼容現有的二進制可執行文件,二進制兼容性方面的問題已經在前文談過,這里從略。
            • 對于新增功能的升級,應該對客戶代碼的友好。升級庫之后,客戶端使用新功能的代價應該比較小。只需要包含新的頭文件(這一步都可以省略,如果新功能已經加入原有的頭文件中),然后編寫新代碼即可。而且,不要在客戶代碼中留下垃圾,后文我們會談到什么是垃圾。

            在討論虛函數接口的弊端之前,我們先看看虛函數做接口的常見用法。

            虛函數作為庫的接口的兩大用途

            虛函數為接口大致有這么兩種用法:

            1. 調用,也就是庫提供一個什么功能(比如繪圖 Graphics),以虛函數為接口方式暴露給客戶端代碼。客戶端代碼一般不需要繼承這個 interface,而是直接調用其 member function。這么做據說是有利于接口和實現分離,我認為純屬脫了褲子放屁。
            2. 回調,也就是事件通知,比如網絡庫的“連接建立”、“數據到達”、“連接斷開”等等。客戶端代碼一般會繼承這個 interface,然后把對象實例注冊到庫里邊,等庫來回調自己。一般來說客戶端不會自己去調用這些 member function,除非是為了寫單元測試模擬庫的行為。
            3. 混合,一個 class 既可以被客戶端代碼繼承用作回調,又可以被客戶端直接調用。說實話我沒看出這么做的好處,但實際中某些面向對象的 C++ 庫就是這么設計的。

            對于“回調”方式,現代 C++ 有更好的做法,即 boost::function + boost::bind,見參考文獻[4],muduo 的回調全部采用這種新方法,見《Muduo 網絡編程示例之零:前言》。本文以下不考慮以虛函數為回調的過時做法。

            對于“調用”方式,這里舉一個虛構的圖形庫,這個庫的功能是畫線、畫矩形、畫圓弧:

               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: };

            這里略去了很多與本文主題無關細節,比如 Graphics 的構造與析構、draw*() 函數應該是 public、Graphics 應該不允許復制,還比如 Graphics 可能會用 pure virtual functions 等等,這些都不影響本文的討論。

            這個 Graphics 庫的使用很簡單,客戶端看起來是這個樣子。

            Graphics* g = getGraphics();

            g->drawLine(0, 0, 100, 200);

            releaseGraphics(g); g = NULL;

            似乎一切都很好,陽光明媚,符合“面向對象的原則”,但是一旦考慮升級,前景立刻變得昏暗。

            虛函數作為接口的弊端

            以虛函數作為接口在二進制兼容性方面有本質困難:“一旦發布,不能修改”。

            假如我需要給 Graphics 增加幾個繪圖函數,同時保持二進制兼容性。這幾個新函數的坐標以浮點數表示,我理想中的新接口是:

            --- 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++ 二進制兼容性方面的限制,我們不能這么做。其本質問題在于 C++ 以 vtable[offset] 方式實現虛函數調用,而 offset 又是根據虛函數聲明的位置隱式確定的,這造成了脆弱性。我增加了 drawLine(double x0, double y0, double x1, double y1),造成 vtable 的排列發生了變化,現有的二進制可執行文件無法再用舊的 offset 調用到正確的函數。

            怎么辦呢?有一種危險且丑陋的做法:把新的虛函數放到 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);
             };

            這么做很丑陋,因為新的 drawLine(double x0, double y0, double x1, double y1) 函數沒有和原來的 drawLine() 函數呆在一起,造成閱讀上的不便。這么做同時很危險,因為 Graphics 如果被繼承,那么新增虛函數會改變派生類中的 vtable offset 變化,同樣不是二進制兼容的。

            另外有兩種似乎安全的做法,這也是 COM 采用的辦法:

            1. 通過鏈式繼承來擴展現有 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);
            +};

            將來如果繼續增加功能,那么還會有 class Graphics3 : public Graphics2;以及 class Graphics4 : public Graphics3 等等。這么做和前面的做法一樣丑陋,因為新的 drawLine(double x0, double y0, double x1, double y1) 函數位于派生 Graphics2 interace 中,沒有和原來的 drawLine() 函數呆在一起,造成割裂。

            2. 通過多重繼承來擴展現有 interface,例如定義一個與 Graphics class 有同樣成員的 Graphics2,再讓實現同時繼承這兩個 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);
            +};
            +
            +// 在實現中采用多重接口繼承
            +class GraphicsImpl : public Graphics,  // version 1
            +                     public Graphics2, // version 2
            +{
            +  // ...
            +};

            這種帶版本的 interface 的做法在 COM 使用者的眼中看起來是很正常的,解決了二進制兼容性的問題,客戶端源代碼也不受影響。

            在我看來帶版本的 interface 實在是很丑陋,因為每次改動都引入了新的 interface class,會造成日后客戶端代碼難以管理。比如,如果代碼使用了 Graphics3 的功能,要不要把現有的 Graphics2 都替換掉?

            • 如果不替換,一個程序同時依賴多個版本的 Graphics,一直背著歷史包袱。依賴的 Graphics 版本越積越多,將來如何管理得過來?
            • 如果要替換,為什么不相干的代碼(現有的運行得好好的使用 Graphics2 的代碼)也會因為別處用到了 Graphics3 而被修改?

            這種二難境地純粹是“以虛函數為庫的接口”造成的。如果我們能直接原地擴充 class Graphics,就不會有這些屁事,見本文“推薦做法”一節。

            假如 Linux 系統調用以 COM 接口方式實現

            或許上面這個 Graphics 的例子太簡單,沒有讓“以虛函數為接口”的缺點充分暴露出來,讓我們看一個真實的案例:Linux Kernel。

            Linux kernel 從 0.10 的 67 個系統調用發展到 2.6.37 的 340 個,kernel interface 一直在擴充,而且保持良好的兼容性,它保持兼容性的辦法很土,就是給每個 system call 賦予一個終身不變的數字代號,等于把虛函數表的排列固定下來。點開本段開頭的兩個鏈接,你就能看到 fork() 在 Linux 0.10 和 Linux 2.6.37 里的代號都是 2。(系統調用的編號跟硬件平臺有關,這里我們看的是 x86 32-bit 平臺。)

            試想假如 Linus 當初選擇用 COM 接口的鏈式繼承風格來描述,將會是怎樣一種壯觀的景象?為了避免擾亂視線,請移步觀看近百層繼承的代碼。(先后關系與版本號不一定 100% 準確,我是用 git blame 去查的,現在列出的代碼只從 0.01 到 2.5.31,相信已經足以展現 COM 接口方式的弊端。)

             

            不要誤認為“接口一旦發布就不能更改”是天經地義的,那不過是“以 C++ 虛函數為接口”的固有弊端,如果跳出這個框框去思考,其實 C++ 庫的接口很容易做得更好。

            為什么不能改?還不是因為用了C++ 虛函數作為接口。Java 的 interface 可以添加新函數,C 語言的庫也可以添加新的全局函數,C++ class 也可以添加新 non-virtual 成員函數和 namespace 級別的 non-member 函數,這些都不需要繼承出新 interface 就能擴充原有接口。偏偏 COM 的 interface 不能原地擴充,只能通過繼承來 workaround,產生一堆帶版本的 interfaces。有人說 COM 是二進制兼容性的正面例子,某深不以為然。COM 確實以一種最丑陋的方式做到了“二進制兼容”。脆弱與僵硬就是以 C++ 虛函數為接口的宿命。

            相反,Linux 系統調用以編譯期常數方式固定下來,萬年不變,輕而易舉地解決了這個問題。在其他面向對象語言(Java/C#)中,我也沒有見過每改動一次就給 interface 遞增版本號的詭異做法。

            還是應了《The Zen of Python》中的那句話,Explicit is better than implicit, Flat is better than nested.

             

            動態庫的接口的推薦做法

            取決于動態庫的使用范圍,有兩類做法。

            如果,動態庫的使用范圍比較窄,比如本團隊內部的兩三個程序在用,用戶都是受控的,要發布新版本也比較容易協調,那么不用太費事,只要做好發布的版本管理就行了。再在可執行文件中使用 rpath 把庫的完整路徑確定下來。

            比如現在 Graphics 庫發布了 1.1.0 和 1.2.0 兩個版本,這兩個版本可以不必是二進制兼容。用戶的代碼從 1.1.0 升級到 1.2.0 的時候要重新編譯一下,反正他們要用新功能都是要重新編譯代碼的。如果要原地打補丁,那么 1.1.1 應該和 1.1.0 二進制兼容,而 1.2.1 應該和 1.2.0 兼容。如果要加入新的功能,而新的功能與 1.2.0 不兼容,那么應該發布到 1.3.0 版本。

            為了便于檢查二進制兼容性,可考慮把庫的代碼的暴露情況分辨清楚。muduo 的頭文件和 class 就有意識地分為用戶可見和用戶不可見兩部分,見 http://blog.csdn.net/Solstice/archive/2010/08/29/5848547.aspx#_Toc32039。對于用戶可見的部分,升級時要注意二進制兼容性,選用合理的版本號;對于用戶不可見的部分,在升級庫的時候就不必在意。另外 muduo 本身設計來是以靜態庫方式發布,在二進制兼容性方面沒有做太多的考慮。

             

            如果庫的使用范圍很廣,用戶很多,各家的 release cycle 不盡相同,那么推薦 pimpl 技法[2, item 43],并考慮多采用 non-member non-friend function in namespace [1, item 23] [2, item 44 abd 57] 作為接口。這里以前面的 Graphics 為例,說明 pimpl 的基本手法。

            1. 暴露的接口里邊不要有虛函數,而且 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. 在庫的實現中把調用轉發 (forward) 給實現 Graphics::Impl ,這部分代碼位于 .so/.dll 中,隨庫的升級一起變化。

            #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. 如果要加入新的功能,不必通過繼承來擴展,可以原地修改,且很容易保持二進制兼容性。先動頭文件:

            --- 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;
             };

            然后在實現文件里增加 forward,這么做不會破壞二進制兼容性,因為增加 non-virtual 函數不影響現有的可執行文件。

            --- 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 的手續,帶來的好處是可擴展性與二進制兼容性,通常是劃算的。pimpl 扮演了編譯器防火墻的作用。

            pimpl 不僅 C++ 語言可以用,C 語言的庫同樣可以用,一樣帶來二進制兼容性的好處,比如 libevent2 里邊的 struct event_base 是個 opaque pointer,客戶端看不到其成員,都是通過 libevent 的函數和它打交道,這樣庫的版本升級比較容易做到二進制兼容。

             

            為什么 non-virtual 函數比 virtual 函數更健壯?因為 virtual function 是 bind-by-vtable-offset,而 non-virtual function 是 bind-by-name。加載器 (loader) 會在程序啟動時做決議(resolution),通過 mangled name 把可執行文件和動態庫鏈接到一起。就像使用 Internet 域名比使用 IP 地址更能適應變化一樣。

             

            萬一要跨語言怎么辦?很簡單,暴露 C 語言的接口。Java 有 JNI 可以調用 C 語言的代碼;Python/Perl/Ruby 等等的解釋器都是 C 語言編寫的,使用 C 函數也不在話下。C 函數是 Linux 下的萬能接口。

            本文只談了使用 class 為接口,其實用 free function 有時候更好(比如 muduo/base/Timestamp.h 除了定義 class Timestamp,還定義了 muduo::timeDifference() 等 free function),這也是 C++ 比 Java 等純面向對象語言優越的地方。留給將來再細談吧。

            參考文獻

            [1] Scott Meyers, 《Effective C++》 第 3 版,條款 35:考慮 virtual 函數以外的其他選擇;條款 23:寧以 non-member、non-friend 替換 member 函數

            [2] Herb Sutter and Andrei Alexandrescu, 《C++ 編程規范》,條款 39:考慮將 virtual 函數做成 non-public,將 public 函數做成 non-virtual;條款 43:明智地使用 pimpl;條款 44:盡可能編寫 nonmember, nonfriend 函數;條款 57:將 class 和其非成員函數接口放入同一個 namespace

            [3] 孟巖,《function/bind的救贖(上)》,《回復幾個問題》中的“四個半抽象”。

            [4] 陳碩,《以 boost::function 和 boost:bind 取代虛函數》,《樸實的 C++ 設計》。

            知識共享許可協議
            作品采用知識共享署名-非商業性使用-相同方式共享 3.0 Unported許可協議進行許可。

            posted @ 2011-03-13 09:04 陳碩 閱讀(12238) | 評論 (8)編輯 收藏

            C++ 工程實踐(4):二進制兼容性

            陳碩 (giantchen_AT_gmail)

            Blog.csdn.net/Solstice

            本文主要討論 Linux x86/x86-64 平臺,偶爾會舉 Windows 作為反面教材。

            C/C++ 的二進制兼容性 (binary compatibility) 有多重含義,本文主要在“頭文件和庫文件分別升級,可執行文件是否受影響”這個意義下討論,我稱之為 library (主要是 shared library,即動態鏈接庫)的 ABI (application binary interface)。至于編譯器與操作系統的 ABI 留給下一篇談 C++ 標準與實踐的文章。

            什么是二進制兼容性

            在解釋這個定義之前,先看看 Unix/C 語言的一個歷史問題:open() 的 flags 參數的取值。open(2) 函數的原型是

            int open(const char *pathname, int flags);

            其中 flags 的取值有三個: O_RDONLY,  O_WRONLY,  O_RDWR。

            與一般人的直覺相反,這幾個值不是按位或 (bitwise-OR) 的關系,即 O_RDONLY | O_WRONLY != O_RDWR。如果你想以讀寫方式打開文件,必須用 O_RDWR,而不能用 (O_RDONLY | O_WRONLY)。為什么?因為 O_RDONLY, O_WRONLY, O_RDWR 的值分別是 0, 1, 2。它們不滿足按位或

            那么為什么 C 語言從誕生到現在一直沒有糾正這個不足之處?比方說把 O_RDONLY, O_WRONLY, O_RDWR 分別定義為 1, 2, 3,這樣 O_RDONLY | O_WRONLY == O_RDWR,符合直覺。而且這三個值都是宏定義,也不需要修改現有的源代碼,只需要改改系統的頭文件就行了。

            因為這么做會破壞二進制兼容性。對于已經編譯好的可執行文件,它調用 open(2) 的參數是寫死的,更改頭文件并不能影響已經編譯好的可執行文件。比方說這個可執行文件會調用 open(path, 1) 來文件,而在新規定中,這表示文件,程序就錯亂了。

            以上這個例子說明,如果以 shared library 方式提供函數庫,那么頭文件和庫文件不能輕易修改,否則容易破壞已有的二進制可執行文件,或者其他用到這個 shared library 的 library。操作系統的 system call 可以看成 Kernel 與 User space 的 interface,kernel 在這個意義下也可以當成 shared library,你可以把內核從 2.6.30 升級到 2.6.35,而不需要重新編譯所有用戶態的程序。

            所謂“二進制兼容性”指的就是在升級(也可能是 bug fix)庫文件的時候,不必重新編譯使用這個庫的可執行文件或使用這個庫的其他庫文件,程序的功能不被破壞。

            見 QT FAQ 的有關條款: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,這是動態鏈接庫的本質問題,怪不到 MFC 頭上。

            有哪些情況會破壞庫的 ABI

            到底如何判斷一個改動是不是二進制兼容呢?這跟 C++ 的實現方式直接相關,雖然 C++ 標準沒有規定 C++ 的 ABI,但是幾乎所有主流平臺都有明文或事實上的 ABI 標準。比方說 ARM 有 EABI,Intel Itanium 有 http://www.codesourcery.com/public/cxx-abi/abi.html,x86-64 有仿 Itanium 的 ABI,SPARC 和 MIPS 也都有明文規定的 ABI,等等。x86 是個例外,它只有事實上的 ABI,比如 Windows 就是 Visual C++,Linux 是 G++(G++ 的 ABI 還有多個版本,目前最新的是 G++ 3.4 的版本),Intel 的 C++ 編譯器也得按照 Visual C++ 或 G++ 的 ABI 來生成代碼,否則就不能與系統其它部件兼容。

            C++ ABI 的主要內容:

            • 函數參數傳遞的方式,比如 x86-64 用寄存器來傳函數的前 4 個整數參數
            • 虛函數的調用方式,通常是 vptr/vtbl 然后用 vtbl[offset] 來調用
            • struct 和 class 的內存布局,通過偏移量來訪問數據成員
            • name mangling
            • RTTI 和異常處理的實現(以下本文不考慮異常處理)

            C/C++ 通過頭文件暴露出動態庫的使用方法,這個“使用方法”主要是給編譯器看的,編譯器會據此生成二進制代碼,然后在運行的時候通過裝載器(loader)把可執行文件和動態庫綁到一起。如何判斷一個改動是不是二進制兼容,主要就是看頭文件暴露的這份“使用說明”能否與新版本的動態庫的實際使用方法兼容。因為新的庫必然有新的頭文件,但是現有的二進制可執行文件還是按舊的頭文件來調用動態庫。

            這里舉一些源代碼兼容但是二進制代碼不兼容例子

            • 給函數增加默認參數,現有的可執行文件無法傳這個額外的參數。
            • 增加虛函數,會造成 vtbl 里的排列變化。(不要考慮“只在末尾增加”這種取巧行為,因為你的 class 可能已被繼承。)
            • 增加默認模板類型參數,比方說 Foo<T> 改為 Foo<T, Alloc=alloc<T> >,這會改變 name mangling
            • 改變 enum 的值,把 enum Color { Red = 3 }; 改為 Red = 4。這會造成錯位。當然,由于 enum 自動排列取值,添加 enum 項也是不安全的,除非是在末尾添加。

            給 class Bar 增加數據成員,造成 sizeof(Bar) 變大,以及內部數據成員的 offset 變化,這是不是安全的?通常不是安全的,但也有例外。

            • 如果客戶代碼里有 new Bar,那么肯定不安全,因為 new 的字節數不夠裝下新 Bar。相反,如果 library 通過 factory 返回 Bar* (并通過 factory 來銷毀對象)或者直接返回 shared_ptr<Bar>,客戶端不需要用到 sizeof(Bar),那么可能是安全的。
            • 如果客戶代碼里有 Bar* pBar; pBar->memberA = xx;,那么肯定不安全,因為 memberA 的新 Bar 的偏移可能會變。相反,如果只通過成員函數來訪問對象的數據成員,客戶端不需要用到 data member 的 offsets,那么可能是安全的。
            • 如果客戶調用 pBar->setMemberA(xx); 而 Bar::setMemberA() 是個 inline function,那么肯定不安全,因為偏移量已經被 inline 到客戶的二進制代碼里了。如果 setMemberA() 是 outline function,其實現位于 shared library 中,會隨著 Bar 的更新而更新,那么可能是安全的。

            那么只使用 header-only 的庫文件是不是安全呢?不一定。如果你的程序用了 boost 1.36.0,而你依賴的某個 library 在編譯的時候用的是 1.33.1,那么你的程序和這個 library 就不能正常工作。因為 1.36.0 和 1.33.1 的 boost::function 的模板參數類型的個數不一樣,其中一個多了 allocator。

            這里有一份黑名單,列在這里的肯定是二級制不兼容,沒有列出的也可能二進制不兼容,見 KDE 的文檔:http://techbase.kde.org/Policies/Binary_Compatibility_Issues_With_C%2B%2B

             

            哪些做法多半是安全的

            前面我說“不能輕易修改”,暗示有些改動多半是安全的,這里有一份白名單,歡迎添加更多內容。

            只要庫改動不影響現有的可執行文件的二進制代碼的正確性,那么就是安全的,我們可以先部署新的庫,讓現有的二進制程序受益。

            • 增加新的 class
            • 增加 non-virtual 成員函數
            • 修改數據成員的名稱,因為生產的二進制代碼是按偏移量來訪問的,當然,這會造成源碼級的不兼容。
            • 還有很多,不一一列舉了。

            歡迎補充

            反面教材:COM

            在 C++ 中以虛函數作為接口基本上就跟二進制兼容性說拜拜了。具體地說,以只包含虛函數的 class (稱為 interface class)作為程序庫的接口,這樣的接口是僵硬的,一旦發布,無法修改。

            比方說 M$ 的 COM,其 DirectX 和 MSXML 都以 COM 組件方式發布,我們來看看它的帶版本接口 (versioned interfaces):

            • IDirect3D7, IDirect3D8, IDirect3D9, ID3D10*, ID3D11*
            • IXMLDOMDocument, IXMLDOMDocument2, IXMLDOMDocument3

            換話句話說,每次發布新版本都引入新的 interface class,而不是在現有的 interface 上做擴充。這樣一樣不能兼容現有的代碼,強迫客戶端代碼也要改寫。

            回過頭來看看 C 語言,C/Posix 這些年逐漸加入了很多新函數,同時,現有的代碼不用修改也能運行得很好。如果要用這些新函數,直接用就行了,也基本不會修改已有的代碼。相反,COM 里邊要想用 IXMLDOMDocument3 的功能,就得把現有的代碼從 IXMLDOMDocument 全部升級到 IXMLDOMDocument3,很諷刺吧。

            tip:如果遇到鼓吹在 C++ 里使用面向接口編程的人,可以拿二進制兼容性考考他。

            解決辦法

            采用靜態鏈接

            這個是王道。在分布式系統這,采用靜態鏈接也帶來部署上的好處,只要把可執行文件放到機器上就行運行,不用考慮它依賴的 libraries。目前 muduo 就是采用靜態鏈接。

            通過動態庫的版本管理來控制兼容性

            這需要非常小心檢查每次改動的二進制兼容性并做好發布計劃,比如 1.0.x 系列做到二進制兼容,1.1.x 系列做到二進制兼容,而 1.0.x 和 1.1.x 二進制不兼容。《程序員的自我修養》里邊講過 .so 文件的命名與二進制兼容性相關的話題,值得一讀。 

            用 pimpl 技法,編譯器防火墻

            在頭文件中只暴露 non-virtual 接口,并且 class 的大小固定為 sizeof(Impl*),這樣可以隨意更新庫文件而不影響可執行文件。當然,這么做有多了一道間接性,可能有一定的性能損失。見 Exceptional C++ 有關條款和 C++ Coding Standards 101.

            Java 是如何應對的

            Java 實際上把 C/C++ 的 linking 這一步驟推遲到 class loading 的時候來做。就不存在“不能增加虛函數”,“不能修改 data member” 等問題。在 Java 里邊用面向 interface 編程遠比 C++ 更通用和自然,也沒有上面提到的“僵硬的接口”問題。

            (待續)

            posted @ 2011-03-09 10:48 陳碩 閱讀(13215) | 評論 (6)編輯 收藏

            C++ 工程實踐(3):采用有利于版本管理的代碼格式

                 摘要: 版本管理(version controlling)是每個程序員的基本技能,C++ 程序員也不例外。版本管理的基本功能之一是追蹤代碼變化,讓你能清楚地知道代碼是如何一步步變成現在的這個樣子,以及每次 check-in 都具體改動了哪些內部。所謂“有利于版本管理”的代碼格式,就是指在代碼中合理使用換行符,對 diff 工具友好,讓 diff 的結果清晰明了地表達代碼的改動。  閱讀全文

            posted @ 2011-03-05 15:16 陳碩 閱讀(3319) | 評論 (7)編輯 收藏

            僅列出標題
            共6頁: 1 2 3 4 5 6 
            <2025年5月>
            27282930123
            45678910
            11121314151617
            18192021222324
            25262728293031
            1234567

            導航

            統計

            常用鏈接

            隨筆分類

            隨筆檔案

            相冊

            搜索

            最新評論

            閱讀排行榜

            評論排行榜

            99久久精品免费国产大片| 亚洲日韩中文无码久久| 99久久亚洲综合精品成人| 久久国产综合精品五月天| 国产A三级久久精品| 久久久久久久尹人综合网亚洲| 久久久久国产成人精品亚洲午夜| 狠狠色噜噜色狠狠狠综合久久| 亚洲综合婷婷久久| 国内精品九九久久精品| 久久精品国产第一区二区| 欧美牲交A欧牲交aⅴ久久| 热综合一本伊人久久精品 | 久久国产午夜精品一区二区三区| 99久久国产亚洲综合精品| 久久线看观看精品香蕉国产| 色欲久久久天天天综合网| 久久久精品人妻无码专区不卡| 无码人妻精品一区二区三区久久久| 久久精品视屏| 草草久久久无码国产专区| 99久久婷婷免费国产综合精品| 久久午夜夜伦鲁鲁片免费无码影视| 国产精品欧美亚洲韩国日本久久 | 亚洲Av无码国产情品久久| 狠狠色丁香久久综合婷婷| 少妇久久久久久被弄高潮| 久久婷婷人人澡人人爽人人爱| 久久久久亚洲AV成人网| 7国产欧美日韩综合天堂中文久久久久 | 久久久久久久人妻无码中文字幕爆| 香港aa三级久久三级老师2021国产三级精品三级在 | 欧美久久综合性欧美| www.久久精品| 久久国产精品久久国产精品| 狠狠色丁香婷婷综合久久来| 国产国产成人精品久久| 久久久久久久综合日本亚洲| 99久久无码一区人妻| 久久久久国产一区二区三区| 久久AAAA片一区二区|