Apache Thrift設計概要

jopen 9年前發布 | 46K 次閱讀 WEB服務/RPC/SOA Apache Thrift

最近把Apache Thrift 的Java版代碼翻了一遍,嘗試理解做一個RPC框架所要考慮的方方面面。

網上關于Thrift設計的文章好像不多,于是把自己的筆記整理了一下發上來。

加插招聘廣告:唯品會廣州總部的基礎架構部招人!! 如果你喜歡純技術的工作,對大型互聯網企業的服務化平臺有興趣,愿意在架構的成長期還可以大展拳腳的時候加盟,請電郵 calvin.xiao@vipshop.com

1. Overview

Apache Thrift 的可贊之處是實現了跨超多語言(Java, C++, Go, Python,Ruby, PHP等等)的RPC框架,盡力為每種語言實現了相同抽象的RPC客戶端與服務端。

簡潔的四層接口抽象,每一層都可以獨立的擴展增強或替換,是另一個可贊的地方。

最后,二進制的編解碼方式和NIO的底層傳輸為它提供了不錯的性能。

+-------------------------------------------+
| Server |
| (single-threaded, event-driven etc) |
+-------------------------------------------+
| Processor |
| (compiler generated) |
+-------------------------------------------+
| Protocol |
| (JSON, compact etc) |
+-------------------------------------------+
| Transport |
| (raw TCP, HTTP etc) |
+-------------------------------------------+

Transport層提供了一個簡單的網絡讀寫抽象層,有阻塞與非阻塞的TCP實現與HTTP的實現。

Protocol層定義了IDL中的數據結構與Transport層的傳輸數據格式之間的編解碼機制。傳輸格式有二進制,壓縮二進制,JSON等格式,IDL中的數據結構則包括Message,Struct,List,Map,Int,String,Bytes等。

Processor層由編譯器編譯IDL文件生成。
生成的代碼會將傳輸層的數據解碼為參數對象(比如商品對象有id與name兩個屬性,生成的代碼會調用Protocol層的readInt與readString方法讀出這兩個屬性值),然后調用由用戶所實現的函數,并將結果編碼送回。

在服務端, Server層創建并管理上面的三層,同時提供線程的調度管理。而對于NIO的實現,Server層可謂操碎了心。

在客戶端, Client層由編譯器直接生成,也由上面的三層代碼組成。只要語言支持,客戶端有同步與異步兩種模式。

非死book是Thrift的原作者,還開源有NiftySwift兩個子項目, Cassandra是另一個著名用戶,其跨語言的Client包就是基于Thrift的,這些在下一篇文章中展開討論。

 

2. Transport層

2.1 Transport

TTransport除了open/close/flush,最重要的方法是int read(byte[] buf, int off, int len),void write(byte[] buf, int off, int len),讀出、寫入固定長度的內容。

TSocket使用經典的JDK Blocking IO的Transport實現。1K的BufferedStream, TCP配置項:KeepAlive=true,TCPNoDelay=true,SoLinger=false,SocketTimeout與 ConnectionTimeout可配。

TNonblockingSocket,使用JDK NIO的Transport實現,讀寫的byte[]會每次被wrap成一個ByteBuffer。TCP配置項類似。
其實這個NonBlockingSocket并沒有完全隔離傳輸層,后面異步Client或NIO的Server還有很多事情要做。

相應的,TServerSocket和TNonblockingServerSocket是ServerTransport的BIO、NIO實現,主要實現偵聽端口,Accept后返回TSocket或TNonblockingSocket。其他TCP配置項:ReuseAddress=true,Backlog與SocketTimeout可配。

2.2 WrapperTransport

包裹一個底層的Transport,并利用自己的Buffer進行額外的操作。

1. TFramedTransport,按Frame讀寫數據。

每Frame的前4字節會記錄Frame的長度(少于16M)。讀的時候按長度預先將整Frame數據讀入Buffer,再從Buffer慢慢讀取。寫的時候,每次flush將Buffer中的所有數據寫成一個Frame。

有長度信息的TFramedTransport是后面NonBlockingServer粘包拆包的基礎。

2. TFastFramedTransport 與TFramedTransport相比,始終使用相同的Buffer,提高了內存的使用率。

TFramedTransport的ReadBuffer每次讀入Frame時都會創建新的byte[],WriteBuffer每次flush時如果大于初始1K也會重新創建byte[]。

而TFastFramedTransport始終使用相同的ReadBuffer和WriteBuffer,都是1K起步,不夠時自動按1.5倍增長,和NIO的ByteBuffer一樣,加上limit/pos這樣的指針,每次重復使用時設置一下它們。

3. TZlibTransport ,讀取時按1K為單位將數據讀出并調用JDK的zip函數進行解壓再放到Buffer,寫入時,在flush時先zip再寫入。

4. TSaslClientTransport與TSaslServerTransport, 提供SSL校驗。

 

3. Protocol層

3.1 IDL定義

Thrift支持結構見 http://thrift.apache.org/docs/types

* 基本類型: i16,i32,i64, double, boolean,byte,byte[], String。
* 容器類型: List,Set,Map,TList/TSet/TMap類包含其元素的類型與元素的總個數。
* Struct類型,即面向對象的Class,繼承于TBase。TStruct類有Name屬性,還含有一系列的Field。TField類有自己的Name,類型,順序id屬性。
* Exception類型也是個Struct,繼承于TException這個checked exception。
* Enum類型傳輸時是個i32。
* Message類型封裝往返的RPC消息。TMessage類包含Name,類型(請求,返回,異常,ONEWAY)與seqId屬性。

相應的,Protocol 層對上述數據結構有read與write的方法。
對基本類型是直接讀寫,對結構類型則是先調用readXXXBegin(),再調用其子元素的read()方法,再調用readXXXEnd()。
在所有函數中,Protocol層會直接調用Transport層讀寫特定長度的數據。

3.2 TBinaryProtocol

如前所述,i16,i32, double這些原始類型都是定長的,String,byte[]會在前4個字節說明自己的長度,容器類,Strutct類所對應的 TMap,TStruct,TField,TMessage里有如前所述的屬性 (不過Struct與Field里的name屬性會被skip),所以其實現實可以簡單想象的。

3.3 TCompactProtocol

比起TBinaryProtocol,會想方設法省點再省點。

1. 對整數類型使用了 ZigZag 壓縮算法,數值越小壓的越多。比如 i32 類型的整數本來是4個字節, 可以壓縮成 1~5 字節不等。而 i64類型的整數本來是8個字節,可以壓縮成 1~10 字節不等。 因此,值小的i32和i64,和小的Collection和短的String(因為它們都有定義長度的int屬性) 越多,它就能省得越多。

2. 它還會嘗試將Field的fieldId和type擠在一個byte里寫。原本field是short,type也占一個byte,合共三個byte。 它將1個byte拆成兩組4bit,前4bit放與前一個field的Id的delta值(不能相差超過15,如果中間太多沒持久化的optional field),后4bit放type(目前剛好16種type,把byte[]和String合成一種了)

3.4 TTupleProtocol

繼承于TCompactProtocol,Struct的編解碼時使用更省地方但IDL間版本不兼容的TupleScheme,見Processor層。

3.5 TJSONProtocol

與我們平時的Restful JSON還是有點區別,具體的定義看cpp實現的TJSONProtool.h文件

對于容器類和Message,會用數組的方式,前幾個元素是元信息,后面才是value,比如List是[type,size,[1,2,3]],

對于Struct,會是個Map,Key是fieldId而不是name(為了節省空間?),Value又是一個Map,只有一個Key-Value Pair,Key是type,Value才是真正value。

 

4. Processor層

建議使用一個最簡單的IDL文件,用Windows版的Generator生成一個來進行觀察。

4.1 基礎接口

TBase是大部分生成的Struct,參數類,結果類的接口,最主要是實現從Protocol層讀寫自己的函數。

TProcessFunction是生成的服務方法類的基類,它的process函數會完成如下步驟:

1. 調用生成的args對象的read方法從protocol層讀出自己
2. 調用子類生成的getResult()方法:拆分args對象得到參數,調用真正的用戶實現得到結果,并組裝成生成的result對象。
3. 寫消息頭,
4. 調用生成的result對象的write方法將自己寫入protocol層
5 調用transport層的flush()。

TBaseProcessor 只有 boolean process(TProtocol in, TProtocol out) 這個簡單接口,會先調readMessageBegin(),讀出消息名,再從processMap里找出相應的TProcessFunction調用。

TMultiplexedProcessor,支持一個Server支持部署多個Service的情況,在Processor外面再加一層Map,消息名從“add”會變為“Caculator:add”,當然,這需要需要客戶端發送時使用 TMultiplexedProtocol修飾原來的Protocol來實現。

4.2 代碼生成

1. IFace接口

接口里的函數名不能重名,即使參數不一樣也不行。
如果參數是個Struct,則會直接使用繼承于TBase的生成類,對客戶代碼有一定侵入性。
默認拋出TException 這個checked exception,可自定義繼承于TException的其他Exception類。

2. Processor類

繼承于TBaseProcessor,簡單構造出processMap,構造時需要傳入用戶實現的IFace實現類。

3. 接口里所有方法的方法類

繼承于TProcessFunction,見前面TProcessFunction的描述。

4. Struct類,方法參數類和方法結果類

繼承于Base。

讀取Struct時,遇上fieldId為未知的,或者類型不同于期望類型的field,會被Skip掉。所謂skip,就是只按傳過來的type,讀取其內容推動數據流往前滾動,但不往field賦值,這也是為什么有了生成的代碼,仍然要傳輸元數據的原因。

為了保持不同服務版本間的兼容性,永遠對方法的參數與Struct的field只增不減不改就對了。

因為不同版本間,Struct的filed的數量未知,而StructEnd又無特殊標志,所以在Struct最后會放一個type=Stop的filed,讀到則停止Struct的讀入。

寫入Struct時,Java對象(如String,Struct)或者設為optional的原始類型(如int)會先判斷一下這個值被設置沒有。Java對象只要判斷其是否為null,原始類型就要額外增加一個bitset來記錄該field是否已設置,根據field的數量,這個 bitset是byte(8個)或short(16個)。

以上是StandardScheme的行為,每個Struct還會生成一種更節約空間但服務版本間不兼容的TupleScheme,它不再傳輸 fieldId與type的元數據,只在Struct頭寫入一個bitset表明哪幾個field有值,然后直接用生成的代碼讀取這些有值的field。所以如果新版idl中filed類型改動將出錯;新的field也不會被讀取數據流沒有往前滾動,接下來也是錯。

5. Thrift二進制與Restful JSON的編碼效率對比

為了服務版本兼容,Thrift仍然需要傳輸數字型的fieldId,但比JSON的fieldName省地方。
int與byte[],當然比JSON的數字字符串和BASE64編碼字符串省地方。
省掉了’:’和’,'
省掉了””,[], {}, 但作為代價,字符串,byte[],容器們都要有自己的長度定義,Struct也要有Stop Field。
但比起JSON,容器和Field又額外增加了類型定義的元數據。

 

5. 服務端

基類TServer相當于一個容器,擁有生產TProcessor、TTransport、TProtocol的工廠對象。改變這些工廠類,可以修飾包裹Transport與Protocol類,改變TProcessor的單例模式或與Spring集成等等。

https://github.com/m1ch1/mapkeeper/wiki/Thrift-Java-Servers-Compared 這篇文章比較了各種Server

5.1 Blocking Server

TSimpleServer同時只能處理一個Client連接,只是個玩具。

TThreadPoolServer才是典型的多線程處理的Blocking Server實現。
線程池類似 Executors.newCachedThreadPool(),可設最小最大線程數(默認是5與無限)。
每條線程處理一個Client,如果所有線程都在忙,會等待一個random的時間重試直到設定的requestTimeout。
線程對于Client好像沒有斷開連接的機制,只靠捕獲TTransportException來停止服務?

5.2 NonBlockingServer

TThreadedSelectorServer有一條線程專門負責accept,若干條Selector線程處理網絡IO,一個Worker線程池處理消息。

THsHaServer只有一條AcceptSelect線程處理關于網絡的一切,一個Worker線程池處理消息。

TNonblockingServer只有一條線程處理一切。

很明顯TThreadedSelectorServer是被使用得最多的,因為在多核環境下多條Selector線程的表現會更好。所以只對它展開細讀。

5.3 TThreadedSelectorServer

TThreadedSelectorServer屬于 Half-Sync/Half-Async模式。

AcceptThread線程使用TNonblockingServerTransport執行accept操作,將accept到的Transport round-robin的交給其中一條SelectorThread。
因為SelectorThread自己也要處理IO,所以AcceptThread是先扔給SelectorThread里的Queue(默認長度只有4,滿了就要阻塞等待)。

SelectorThread每個循環各執行一次如下動作
1. 注冊Transport
2. select()處理IO
3. 處理FrameBuffer的狀態變化

注冊Transport時,在Selector中注冊OP_READ,并創建一個帶狀態機 (READING_FRAME_SIZE,READING_FRAME,READ_FRAME_COMPLETE, AWAITING_REGISTER_WRITE等)的FrameBuffer類與其綁定。

客戶端必須使用FrameTransport(前4個字節記錄Frame的長度)來發送數據以解決粘包拆包問題。

SelectorThread在每一輪的select()中,對有數據到達的Transport,其FrameBuffer先讀取Frame的長度,然后創建這個長度的ByteBuffer繼續讀取數據,讀滿了就交給Worker線程中的Processor處理,沒讀夠就繼續下一輪循環。

當交給Processor處理時,Processor不像Blocking Server那樣直接和當前的Transport打交道,而是實際將已讀取到的Frame數據轉存到一個MemoryTransport中,output 時同樣只是寫到一個由內存中的ByteArray(初始大小為32)打底的OutputStream。

Worker線程池用newFixedThreadPool()創建,Processor會解包,調用用戶實現,再把結果編碼發送到前面傳入的那個 ByteArray中。FrameBuffer再把ByteArray轉回ByteBuffer,狀態轉為 AWAITING_REGISTER_WRITE,并在SelectorThread中注冊該變化。

回到SelectorThread中,發現FrameBuffer的當前狀態為AWAITING_REGISTER_WRITE,在 Selector中注冊OP_WRITE,等待寫入的機會。在下一輪循環中就會開始寫入數據,寫完的話FrameBuffer又轉到 READING_FRAME_SIZE的狀態,在Selector中重新注冊OP_READ。

還有更多的狀態機處理,略。

 

6. 客戶端

6.1 同步客戶端

同樣通過生成器生成,其中Client類繼承TClient基類實現服務的同步接口。

int add(int num1,int num2)會調用生成的send_add(num1, num2) 與 int receive_add()。
send_add()構造add_args()對象,調用父類的sendBase(“add",args),sendBase()調用protocol的 writeMessage寫入消息頭,然后調用args自己的write(protocol),然后調用transport的flush()發送。
receive_add()構造add_result,調用父類的receive_base(add_result),receive_base()調用 protocol層的readMessage讀出消息頭,如果類型是Exception則 用TApplicationException類來讀取消息,否則調用result類的read()函數。

注意這里的seq_id并不支持并發訪問,在發送時簡單的+1,在接收時再進行比較,如果不對則會報錯。因此Client好像是非線程安全的。

6.2 異步客戶端

異步客戶端,需要傳入一個CallBack實現,在收到返回結果或錯誤時調用。

使用NonblockingSocket,每個客戶端會起一條線程,在這條線程里忙活所有消息Non blocking發送,接收,編解碼及調用Callback類,還有超時調用的處理。
其狀態機的寫法,TThreadedSelectorServer有相似之處,略。

 

7. Http

走Http協議,主要是利用了Thrift的二進制編解碼機制,而放棄了它的底層NIO傳輸與服務線程模型。

THttpClient,使用Apache HttpClient或JDK的HttpURLConnection Post內容。

TServlet,作為Servlet,將request與response的stream交給Processor處理即可,不用處理線程模型與NIO,很簡單。

 

8. Generator

Generator用C++編寫,有Parser類分析thrift文件元數據模型,然后每種語言有自己的生成模型。

為Java生成代碼的t_java_generator.cc有五千多行,不過很規整很容易看。

除了Java,一般還會生成Html的API描述文檔。

謝謝你看到這里,請再看一次招聘廣告:

唯品會廣州總部的基礎架構部招人!! 如果你喜歡純技術的工作,對大型互聯網企業的服務化平臺有興趣,愿意在架構的成長期還可以大展拳腳的時候加盟,請電郵 calvin.xiao@vipshop.com

來自:http://calvin1978.blogcn.com/articles/apache-thrift.html

 本文由用戶 jopen 自行上傳分享,僅供網友學習交流。所有權歸原作者,若您的權利被侵害,請聯系管理員。
 轉載本站原創文章,請注明出處,并保留原始鏈接、圖片水印。
 本站是一個以用戶分享為主的開源技術平臺,歡迎各類分享!