一. 與 Thrift 的初識
也許大多數人接觸 Thrift 是從序列化開始的。每次搜索 “java序列化” + “方式”、“對比” 或 “性能” 等關鍵字時,搜索引擎總是會返回一大堆有關各種序列化方式的使用方法或者性能對比的結果給你,而其中必定少不了 Thrift,並且其性能還不錯嘞,至少比那戰鬥力只有1的渣渣 java 原生序列化要強很多(好吧原諒我的小情緒……)。
然而,我最初接觸 Thrift 卻是從公司的一個項目開始。
也就在去年的這個時候,我所在事業部發現幾個 UGC 社區的小廣告特別嚴重,Boss 要求所有社區必須接入公司的富媒體監控系統(負責公司所有業務的內容審覈、處罰工作,以下簡稱監控系統),以實現 UGC 內容(包括文本、圖片、音視頻以及用戶頭像、暱稱等UserInfo)的準實時上報與垃圾信息的自動處理(如清理現場、賬號封禁等)。出於對業務服務的最小侵入、功能複用和流程統一等原則的考慮,抽象出介於業務系統和監控系統之間的接入系統,統一負責對數據的接收、上報、重推、搜索、結果查詢以及對監控系統處罰指令的轉發。該業務可簡單抽象成圖 1.1:
圖 1.1
由於監控系統使用 Thrift 提供服務,因此接入系統與監控系統之間的交互都使用 Thrift 協議。考慮到接入的便捷性,業務系統可以使用 Thrift 和 Http 兩種協議與接入系統交互。
當時是我一個人負責這個項目,由於對 Thrift 的認識還是0,且項目時間短,所以總體上項目是非常趕的,一開始以爲自己難以在規定時間內完成,但想不到 Thrift 開發起來還真的是相當的便捷。系統按時上線了,至今也沒出什麼幺蛾子。後來又通過學習進一步瞭解了 Thrift,深以爲是個必須入手的技能。
好吧,至此算是和 Thrift 正式結緣了。
二. 所謂的 RPC
在瞭解 Thrift 之前,先來簡單科普一下什麼是 RPC(遠程過程調用)。
先看下面這個栗子:
1 2 3 4 5 6 7 8 9 |
|
這是一個最簡單不過的本地函數調用代碼,調用方和被調用方都在一個程序內部,屬於進程內調用。
CPU 在執行調用時切換去執行被調用函數,執行完後再切換回來執行後續的代碼。對調用方而言,執行被調用函數時會阻塞(非異步情況下)直到調用函數執行完畢。過程如圖 2.1
圖 2.1
接下來看個 RPC 調用的栗子:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
|
這是一個進程間調用,調用方和被調用方不在同一個進程(甚至不同的服務器或機房)。
進程間調用需要通過網絡來傳輸數據,調用方在執行 RPC 調用時會阻塞(非異步情況下)直到調用結果返回才繼續執行後續代碼。過程如圖 2.2
圖 2.2
一言以蔽之,RPC 是一種通過網絡從遠程計算機程序上請求服務的方式,它使得開發包括網絡分佈式多程序在內的應用程序更加容易。
三. 不僅僅是個序列化工具
Thrift 最初是由 Facebook 開發用做系統內各語言之間的 RPC 通信的一個可擴展且跨語言的軟件框架,它結合了功能強大的軟件堆棧和代碼生成引擎,允許定義一個簡單的定義文件中的數據類型和服務接口,以作爲輸入文件,編譯器生成代碼用來方便地生成RPC客戶端和服務器通信的無縫跨編程語言。
Thrift 是 IDL 描述性語言的一個具體實現,適用於程序對程序靜態的數據交換,需要先確定好數據結構。
Thrift 是完全靜態化的,當數據結構發生變化時,必須重新編輯IDL文件、代碼生成再編譯載入的流程,跟其他IDL工具相比較可以視爲是 Thrift 的弱項。Thrift 適用於搭建大型數據交換及存儲的通用工具,在大型系統中的內部數據傳輸上相對於 JSON 和 XML 無論在性能、傳輸大小上有明顯的優勢。
注意, Thrift 不僅僅是個高效的序列化工具,它是一個完整的 RPC 框架體系!
3.1 堆棧結構
如圖 3.1所示,Thrift 包含一個完整的堆棧結構用於構建客戶端和服務器端。
圖 3.1
其中代碼框架層是根據 Thrift 定義的服務接口描述文件生成的客戶端和服務器端代碼框架,數據讀寫操作層是根據 Thrift 文件生成代碼實現數據的讀寫操作。
3.2 client/server調用流程
首先來看下 Thrift 服務端是如何啓動並提供服務的,如下圖 3.2所示(點擊此處看大圖):
圖 3.2
上圖所示是 HelloServiceServer 啓動的過程,以及服務被客戶端調用時服務器的響應過程。我們可以看到,程序調用了 TThreadPoolServer 的 serve() 方法後,server 進入阻塞監聽狀態,其阻塞在 TServerSocket 的 accept()方法上。當接收到來自客戶端的消息後,服務器發起一個新線程處理這個消息請求,原線程再次進入阻塞狀態。在新線程中,服務器通過 TBinaryProtocol 協議讀取消息內容,調用 HelloServiceImpl 的 helloVoid() 方法,並將結果寫入 helloVoid_result 中傳回客戶端。
在服務啓動後,客戶端就開始調用其服務,如圖 3.3所示(點擊此處看大圖):
圖 3.3
上圖展示的是 HelloServiceClient 調用服務的過程,以及接收到服務器端的返回值後處理結果的過程。我們可以看到,程序調用了 Hello.Client 的 helloVoid() 方法,在 helloVoid() 方法中,通過 send_helloVoid() 方法發送對服務的調用請求,通過 recv_helloVoid() 方法接收服務處理請求後返回的結果。
3.3 數據類型
上一節我們已經大致瞭解了 Thrift 的 server 和 client 的工作流程,現在就來講講 Thrift 可定義的數據類型。Thrift 支持幾大類數據結構:基本類型、結構體和異常類型、容器類型、服務類型。
基本類型:
1 2 3 4 5 6 7 |
|
結構體和異常類型:
Thrift 結構體 (struct) 在概念上類似於 C 語言結構體類型,在 java 中 Thrift 結構體將會被轉換成面嚮對象語言的類。struct 的定義如下:
1 2 3 4 5 6 |
|
struct 具有以下特性:
1 2 3 4 5 6 7 |
|
備註1:數字標籤作用非常大,隨着項目開發的不斷髮展,也許字段會有變化,但是建議不要輕易修改這些數字標籤,修改之後如果沒有同步客戶端和服務器端會讓一方解析出問題。
備註2:關於 struct 字段類型,規範的 struct 定義中的每個域均會使用 required 或者 optional 關鍵字進行標識,但是如果不指定則爲無類型,可以不填充該值,但是在序列化傳輸的時候也會序列化進去。其中 optional 是不填充則不序列化,required 是必須填充也必須序列化。如果 required 標識的域沒有賦值,Thrift 將給予提示;如果 optional 標識的域沒有賦值,該域將不會被序列化傳輸;如果某個 optional 標識域有缺省值而用戶沒有重新賦值,則該域的值一直爲缺省值;如果某個 optional 標識域有缺省值或者用戶已經重新賦值,而不設置它的 __isset 爲 true,也不會被序列化傳輸。
異常在語法和功能上相當於結構體,差別是異常使用關鍵字 exception 而不是 struct 聲明。它在語義上不同於結構體:當定義一個 RPC 服務時,開發者可能需要聲明一個遠程方法拋出一個異常。
容器類型
Thrift 容器與目前流行編程語言的容器類型相對應,有3種可用容器類型:
1 2 3 |
|
其中容器中元素類型可以是除了 service 外的任何合法 Thrift 類型(包括結構體和異常)。
服務類型
服務的定義方法在語義上等同於面嚮對象語言中的接口。Thrift 編譯器會產生執行這些接口的 client 和 server 存根(詳情下一節會具體描述)。下面我們就舉個簡單的例子解釋 service 如何定義:
1 2 3 4 5 6 7 8 9 10 11 |
|
在上面的例子中我們定義了一個 service 類型的結構,裏面包含兩個方法的定義。
在定義 services 的時候,我們還需要了解一下規則:
1 2 3 4 5 6 |
|
除上面所提到的四大數據類型外,Thrift 還支持枚舉類型(enum)和常量類型(const)。
命名空間
Thrift 中的命名空間類似於 java 中的 package,它們提供了一種組織(隔離)代碼的簡便方式。名字空間也可以用於解決類型定義中的名字衝突。
3.4 傳輸體系
傳輸協議
Thrift 支持多種傳輸協議,用戶可以根據實際需求選擇合適的類型。Thrift 傳輸協議上總體可劃分爲文本 (text) 和二進制 (binary) 傳輸協議兩大類,一般在生產環境中使用二進制類型的傳輸協議爲多數(相對於文本和 JSON 具有更高的傳輸效率)。常用的協議包含:
1 2 3 4 |
|
關於以上幾種類型的傳輸協議,如果想更深入更具體的瞭解其實現及工作原理,可以參考站外相關文章《thrift源碼研究》。
傳輸方式
與傳輸協議一樣,Thrift 也支持幾種不同的傳輸方式。
1. TSocket:阻塞型 socket,用於客戶端,採用系統函數 read 和 write 進行讀寫數據。
2. TServerSocket:非阻塞型 socket,用於服務器端,accecpt 到的 socket 類型都是 TSocket(即阻塞型 socket)。
3. TBufferedTransport 和 TFramedTransport 都是有緩存的,均繼承TBufferBase,調用下一層 TTransport 類進行讀寫操作嗎,結構極爲相似。其中 TFramedTransport 以幀爲傳輸單位,幀結構爲:4個字節(int32_t)+傳輸字節串,頭4個字節是存儲後面字節串的長度,該字節串纔是正確需要傳輸的數據,因此 TFramedTransport 每傳一幀要比 TBufferedTransport 和 TSocket 多傳4個字節。
4. TMemoryBuffer 繼承 TBufferBase,用於程序內部通信用,不涉及任何網絡I/O,可用於三種模式:(1)OBSERVE模式,不可寫數據到緩存;(2)TAKE_OWNERSHIP模式,需負責釋放緩存;(3)COPY模式,拷貝外面的內存塊到TMemoryBuffer。
5. TFileTransport 直接繼承 TTransport,用於寫數據到文件。對事件的形式寫數據,主線程負責將事件入列,寫線程將事件入列,並將事件裏的數據寫入磁盤。這裏面用到了兩個隊列,類型爲 TFileTransportBuffer,一個用於主線程寫事件,另一個用於寫線程讀事件,這就避免了線程競爭。在讀完隊列事件後,就會進行隊列交換,由於由兩個指針指向這兩個隊列,交換隻要交換指針即可。它還支持以 chunk(塊)的形式寫數據到文件。
6. TFDTransport 是非常簡單地寫數據到文件和從文件讀數據,它的 write 和 read 函數都是直接調用系統函數 write 和 read 進行寫和讀文件。
7. TSimpleFileTransport 直接繼承 TFDTransport,沒有添加任何成員函數和成員變量,不同的是構造函數的參數和在 TSimpleFileTransport 構造函數裏對父類進行了初始化(打開指定文件並將fd傳給父類和設置父類的close_policy爲CLOSE_ON_DESTROY)。
8. TZlibTransport 跟 TBufferedTransport 和 TFramedTransport一樣,調用下一層 TTransport 類進行讀寫操作。它採用<zlib.h>提供的 zlib 壓縮和解壓縮庫函數來進行壓解縮,寫時先壓縮再調用底層 TTransport 類發送數據,讀時先調用 TTransport 類接收數據再進行解壓,最後供上層處理。
9. TSSLSocket 繼承 TSocket,阻塞型 socket,用於客戶端。採用 openssl 的接口進行讀寫數據。checkHandshake()函數調用 SSL_set_fd 將 fd 和 ssl 綁定在一起,之後就可以通過 ssl 的 SSL_read和SSL_write 接口進行讀寫網絡數據。
10. TSSLServerSocket 繼承 TServerSocket,非阻塞型 socket, 用於服務器端。accecpt 到的 socket 類型都是 TSSLSocket 類型。
11. THttpClient 和 THttpServer 是基於 Http1.1 協議的繼承 Transport 類型,均繼承 THttpTransport,其中 THttpClient 用於客戶端,THttpServer 用於服務器端。兩者都調用下一層 TTransport 類進行讀寫操作,均用到TMemoryBuffer 作爲讀寫緩存,只有調用 flush() 函數纔會將真正調用網絡 I/O 接口發送數據。
TTransport 是所有 Transport 類的父類,爲上層提供了統一的接口而且通過 TTransport 即可訪問各個子類不同實現,類似多態。
四. 選擇 java server 的藝術
Thrift 包含三個主要的組件:protocol,transport 和 server。
其中,protocol 定義了消息是怎樣序列化的;transport 定義了消息是怎樣在客戶端和服務器端之間通信的;server 用於從 transport 接收序列化的消息,根據 protocol 反序列化之,調用用戶定義的消息處理器,並序列化消息處理器的響應,然後再將它們寫回 transport。
Thrift 模塊化的結構使得它能提供各種 server 實現。下面列出了 Java 中可用的 server 實現:
1 2 3 4 5 |
|
有多個選擇固然是很好的,但如果不清楚箇中差別則是個災難。所以接下來就談談這些 server 之間的區別,並通過一些簡單的測試以說明它們的性能特點。
TSimpleServer
TSimplerServer 接受一個連接,處理連接請求,直到客戶端關閉了連接,它纔回去接受一個新的連接。正因爲它只在一個單獨的線程中以阻塞 I/O 的方式完成這些工作,所以它只能服務一個客戶端連接,其他所有客戶端在被服務器端接受之前都只能等待。
TSimpleServer 主要用於測試目的,不要在生產環境中使用它!
TNonblockingServer vs. THsHaServer
TNonblockingServer 使用非阻塞的 I/O 解決了 TSimpleServer 一個客戶端阻塞其他所有客戶端的問題。它使用了 java.nio.channels.Selector,通過調用 select(),它使得你阻塞在多個連接上,而不是阻塞在單一的連接上。當一或多個連接準備好被接受/讀/寫時,select() 調用便會返回。TNonblockingServer 處理這些連接的時候,要麼接受它,要麼從它那讀數據,要麼把數據寫到它那裏,然後再次調用 select() 來等待下一個可用的連接。通用這種方式,server 可同時服務多個客戶端,而不會出現一個客戶端把其他客戶端全部“餓死”的情況。
然而,還有個棘手的問題:所有消息是被調用 select() 方法的同一個線程處理的。假設有10個客戶端,處理每條消息所需時間爲100毫秒,那麼,latency 和吞吐量分別是多少?當一條消息被處理的時候,其他9個客戶端就等着被 select,所以客戶端需要等待1秒鐘才能從服務器端得到迴應,吞吐量就是10個請求/秒。如果可以同時處理多條消息的話,會很不錯吧?
因此,THsHaServer(半同步/半異步的 server)就應運而生了。它使用一個單獨的線程來處理網絡I/O,一個獨立的 worker 線程池來處理消息。這樣,只要有空閒的 worker 線程,消息就會被立即處理,因此多條消息能被並行處理。用上面的例子來說,現在的 latency 就是100毫秒,而吞吐量就是100個請求/秒。
爲了演示做了一個測試,有10客戶端和一個修改過的消息處理器——它的功能僅僅是在返回之前簡單地 sleep 100 毫秒。使用的是有10個 worker 線程的 THsHaServer。消息處理器的代碼看上去就像下面這樣:
1 2 3 4 5 6 7 |
|
特別申明,本章節的測試結果摘自站外文章,詳情請看文末鏈接
圖 4.1
圖 4.2
結果正如我們想像的那樣,THsHaServer 能夠並行處理所有請求,而 TNonblockingServer 只能一次處理一個請求。
THsHaServer vs. TThreadedSelectorServer
Thrift 0.8 引入了另一種 server 實現,即 TThreadedSelectorServer。它與 THsHaServer 的主要區別在於,TThreadedSelectorServer 允許你用多個線程來處理網絡 I/O。它維護了兩個線程池,一個用來處理網絡 I/O,另一個用來進行請求的處理。當網絡 I/O 是瓶頸的時候,TThreadedSelectorServer 比 THsHaServer 的表現要好。爲了展現它們的區別進行一個測試,令其消息處理器在不做任何工作的情況下立即返回,以衡量在不同客戶端數量的情況下的平均 latency 和吞吐量。對 THsHaServer,使用32個 worker 線程;對 TThreadedSelectorServer,使用16個 worker 線程和16個 selector 線程。
圖 4.3
圖 4.4
結果顯示,TThreadedSelectorServer 比 THsHaServer 的吞吐量高得多,並且維持在一個更低的 latency 上。
TThreadedSelectorServer vs. TThreadPoolServer
最後,還剩下 TThreadPoolServer。TThreadPoolServer 與其他三種 server 不同的是:
1 2 3 4 |
|
這意味着,如果有1萬個併發的客戶端連接,你就需要運行1萬個線程。所以它對系統資源的消耗不像其他類型的 server 一樣那麼“友好”。此外,如果客戶端數量超過了線程池中的最大線程數,在有一個 worker 線程可用之前,請求將被一直阻塞在那裏。
我們已經說過,TThreadPoolServer 的表現非常優異。在我正在使用的計算機上,它可以支持1萬個併發連接而沒有任何問題。如果你提前知道了將要連接到你服務器上的客戶端數量,並且你不介意運行大量線程的話,TThreadPoolServer 對你可能是個很好的選擇。
圖 4.5
圖 4.6
我想你可以從上面的描述可以幫你做出決定:哪一種 Thrift server 適合你。
TThreadedSelectorServer 對大多數案例來說都是個安全之選。如果你的系統資源允許運行大量併發線程的話,建議你使用 TThreadPoolServer。
五. Let's do it
上面已經介紹了很多理論知識了,很多同學還是不知道如何使用呢!好吧,是時候表演真正的技術了(LOL...)。
所謂大道至簡,講的就是最簡單的代碼就是最優美的代碼,只要功能強悍,最簡單的代碼也掩蓋不了它出衆的氣質。下面就來給大夥兒講講如何使用 Thrift 強大的代碼生成引擎來生成 java 代碼,並通過詳細的步驟實現 Thrift Server 和 Client 調用。
備註:本文實現基於 Thrift-0.9.2 版本,實現過程忽略日誌處理等非關鍵代碼。
步驟一:首先從官網中下載對應的 Window 平臺編譯器(點擊下載 thrift-0.9.2.exe)。使用 IDL 描述語言建立 .thrift 文件。本文提供一個實現簡單功能的測試案例,如下所示:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
|
步驟二:將上述 TestQry.thrift 文件與 thrift-0.9.2.exe 放在同一目錄,如下:
圖 5.1
在命令提示符 CMD 中進入文件目錄所在目錄,執行代碼生成命令:
1 |
|
執行之後,我們在文件夾中可以看到生成的 java 代碼
圖 5.2
步驟三:接下來我們新建 Maven Project(注意:JDK 版本1.5及以上),將上一步驟生成的代碼拷貝到項目,並在 pom.xml 中加載 Thrift 的依賴,如下
1 2 3 4 5 6 7 8 9 10 11 12 |
|
步驟四:創建 QueryImp.java 實現 TestQry.Iface 接口,關鍵代碼如下
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
|
步驟五:創建 ThriftServerDemo.java 實現服務端(本例採用非阻塞I/O,二進制傳輸協議),關鍵代碼如下
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
|
步驟六:創建 ThriftClientDemo.java 實現客戶端,關鍵代碼如下
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 |
|
好的,所有準備工作都已經做好了,接下來我們就來進行 Client 和 Server 的通信。先運行 ThriftServerDemo 啓動 Server,然後運行 ThriftClientDemo.java 創建 Client 進行調用,當 qryCode = 1 時,結果如下
1 |
|
當 qryCode = 0 時,結果如下
1 |
|
附上項目的代碼結構:
圖 5.3
你看我沒騙你吧,是不是 so easy ?
當然在項目中使用時絕對沒有這麼簡單,但上面的栗子已經足夠用來指導你進行 Thrift 服務端和客戶端開發了。
文章出處:
作者:cyfonly
出處:http://www.cnblogs.com/cyfonly/
本文版權歸作者和博客園共有,歡迎轉載,未經同意須保留此段聲明,且在文章頁面明顯位置給出原文連接。歡迎指正與交流。