一、通信協定概述
通信協定是兩個節點之間為了協同工作、實作資訊交換,而協商的規則和約定,例如規定位元組序,各個字段類型,使用什麼壓縮算法或加密算法等。
1、原始資料
假設A和B通信,擷取或設定使用者基本資料,一般開發人員第一步就是定義一個協定結構:
struct userbase
{
unsigned short cmd; //1-get, 2-set
unsigned char gender; //1 – man , 2-woman
char name[8];
};
1
2
3
4
5
6
在這種方式下,A基本不用編碼,直接從記憶體copy出來,再做一下網絡位元組序變換,發送給B,B也能解析。
這種編碼方式,除了資料本身外,沒有一點額外備援資訊,可以看成是Raw Data。
2、版本号控制
有一天,A在基本資料裡面加一個生日字段,然後告訴B:
unsigned short cmd;
unsigned char gender;
unsigned int birthday;
7
可是當B收到A的資料包後,并不知道第3個字段到底是舊協定中的name字段,還是新協定中的birthday。
于是他們意識到,一個好的協定應該具有相容性和可擴充性。
他們決定制定一個以後每個版本相容的新協定。方法很簡單,就是加一個version字段:
unsigned short version;
8
這樣子以後就可以很友善的擴充,通過版本号來做不同的解析處理。
3、使用tag
過了一段時間,A和B發現又有新的問題:每增加一個字段就要改變一下版本号,這樣代碼維護起來相當麻煩,每個版本一個case分支,到了最後,代碼裡面幾十個case分支,看起來醜陋而且維護成本相當高。
于是他們決定為每個字段增加一個額外資訊來作為一個字段的唯一辨別——tag,雖然增加記憶體和帶寬,但是可以容許這些備援,換取易用性。
1 unsigned short version;
2 unsigned short cmd;
3 unsigned char gender;
4 unsigned int birthday;
5 char name[8];
有了tag之後,每個字段都有唯一辨別,雙方通過tag即可知道第幾個字段代表的是什麼。于是我們就可以自由地增加字段了,隻要保證tag不修改即可。注意一般不删除字段,因為删除字段後tag可能會不小心被複用了,進而類型不比對導緻解碼失敗。
4、強擴充性的TLV
後來他們發現,name使用8個位元組不夠用,最大長度可能會達到100個位元組。如果每次都按照100個位元組這種固定長度打包,太浪費流量了。
于是他們決定使用**<Tag,Length,Value>**三元組編碼,簡稱TLV編碼。其中Value字段是可以嵌套的。
TLV具備了很好可擴充性,但是由于其增加了2個額外的備援資訊 Tag 和 Length,特别是如果協定大部分是基本資料類型int ,short, byte,會浪費幾倍存儲空間。另外Value具體是什麼含義,需要通信雙方事先得到描述文檔,即TLV不具備結構化和自解釋特性。
5、自解釋性的TTLV
TT[L]V是 <Tag,Type,Length,Value> 四元組編碼,其中,當type是定長的基本資料類型如int, short, long, byte時,因為其長度是已知的,是以L不需要。
于是我們可以定義一些type值如下:
類型 Type值 類型描述
bool 1 布爾值
int8 2 帶符号的一個字元
uint8 3 不帶符号的一個字元
int16 4 16位有符号整型
uint16 5 16位無符号整型
int32 6 32位有符号整型
uint32 7 32位無符号整型
… … …
string 12 字元串或二進制序列
struct 13 自定義的結構,嵌套使用
list 14 有序清單
map 15 無序清單
改完後,不光可以随心所欲地增删字段,還可以在一定程度上修改資料類型,例如把 unsigned short cmd 改成 int cmd,可以無縫相容。因為 unsigned short 和 int 都是屬于整型數的範疇,是以實際資料傳輸存儲中,無論定義的是 unsigned short 還是 int,都是按照實際的資料大小選擇type值進行編碼的。
6、跨語言特性
有一天來了一個新的同僚C,他寫了一個新的服務,需要和A通信,但是C使用的是java編碼,沒有無符号類型,導緻負數解析失敗。
為了解決這個問題,A重新規劃一下協定類型,剝離語言特性,定義一些共性,對使用類型做了強制性限制。雖然帶來了限制,但是帶來通用性、簡潔性和跨語言性,大家表示都很贊同,于是有了一個新的類型(type)規範。
int16 3 16位有符号整型
int32 4 32位有符号整型
7、代碼自動化——IDL語言的産生
後來A和B又發現了新的煩惱,就是每搞一套新的協定,都要從頭編解碼,調試,雖然TLV很簡單,但是寫編解碼是一個毫無技術含量的枯燥體力活,一個非常明顯的問題是,由于大量copy/past,不管是對新手還是老手,非常容易犯錯,一犯錯,定位排錯非常耗時。于是A想到使用工具自動生成代碼。
IDL(Interface Description Language),是一種描述語言,也是一個中間語言,IDL的一個使命就是規範和限制,就像前面提到,規範使用類型,提供跨語言特性。通過工具分析 idl 檔案,生成各種語言代碼:
Gencpp.exe sample.idl 輸出 sample.cpp sample.h
Genphp.exe sample.idl 輸出 sample.php
Genjava.exe sample.idl 輸出 sample.java
于是後續約定協定,其實就變成了使用中間語言來書寫sample.idl 協定檔案,然後使用Gencpp.exe等工具,生成對應語言的編解碼代碼。
二、JCE協定
1、什麼是JCE協定
JCE是一種二進制、支援字段動态增加、代碼自動生成、跨平台的通信、資料傳輸協定。
JCE是一種類C++語言的IDL,用于生成具體的服務接口檔案。
對于結構定義,可以支援擴充字段,即可以增加字段而不影響以前結構的解析。
協定的作用是為了能在網絡傳輸中起到封裝資料的作用,是雙方對傳輸資料的一種約定、規則,或者說通信語言。有了共同的語言之後,一方就可以将資料按照協定進行序列化,打包成位元組流,底層調用send或者sendto函數發出去,而接收方隻需按照約定好的規則對位元組流進行反序列化,轉換成自己讀得懂的資料結構。
2、JCE資料編碼
JCE結構體的定義如下:
struct Test
0 require string s;
1 optional int i = 23;
key[Test, s, i];
說明:
第一列數字表示該字段的唯一辨別(Tag),無論結構增減字段,該字段的值都不變,必須和相應的字段對應;
Tag的值必須要 >= 0 且 <= 255(1 byte);
require 表示該字段必選,如果解碼發現該Tag缺失,則解碼失敗;
optional表示該字段可選,如果解碼發現該Tag缺失,則忽略;
對于optional字段,可以有一個預設值;
Key表示結構的小于比較符号,預設時Struct是沒有小于操作的,如果定義了key,則生成小于比較符。
key[Stuct, member…]:
Struct:表示結構的名稱
Member:表示該結構的成員變量,可以有多個;
生成的小于比較操作符,按照key中成員變量定義的順序進行優先 < 比較。
2.1、 基本結構
JCE使用TTLV進行編碼,編碼資料由 頭資訊 和 實際資料 兩個部分組成。
其中頭資訊包括以下幾個部分:
Type Tag 1 Tag 2
4 bits 4 bits 1 byte
Type表示資料存儲類型,用4個二進制位表示,取值範圍是0~15,用來辨別該資料的類型。不同類型的資料,其後緊跟着的實際資料的長度和格式都是不一樣的,詳見後面的類型表。
Tag由Tag 1和Tag 2一起表示,取值範圍是0~255,用來區分不同的字段(編碼之後的資料中,隻會存儲字段對應的tag,而不會傳輸字段名,通信雙方都有JCE檔案,通過tag可以找到對應的字段名)。其中 Tag 2 是可選的,當Tag的值不超過14時,隻需要用 Tag 1 就可以表示;當Tag的值超過14而小于256時,Tag 1 固定為15,而用 Tag 2 表示Tag的值。Tag不允許大于255。
2.2、編碼類型表
注意,這裡的類型與jce檔案定義的類型是兩個不同的概念,這裡的類型隻是辨別資料存儲的類型,而不是資料定義的類型。比如字段age資料定義的類型是 int,當它實際的值是1時,資料存儲的類型是 1個位元組整型資料。
取值 類型 備注
0 int1 緊跟1個位元組整型資料
1 int2 緊跟2個位元組整型資料
2 int4 緊跟4個位元組整型資料
3 int8 緊跟8個位元組整型資料
4 float 緊跟4個位元組浮點型資料
5 double 緊跟8個位元組浮點型資料
6 String1 緊跟1個位元組長度,再跟内容
7 String4 緊跟4個位元組長度,再跟内容
8 Map 緊跟一個整型資料表示Map的大小,再跟[key, value]對清單
9 List 緊跟一個整型資料表示List的大小,再跟元素清單
10 自定義結構開始 自定義結構開始标志
11 自定義結構結束 自定義結構結束标志,Tag為0
12 數字0 表示數字0,後面不跟資料
13 SimpleList 簡單清單(目前用在byte數組),緊跟一個類型字段(目前隻支援byte),緊跟一個整型資料表示長度,再跟byte資料
14 - -
15 - -
2.3、各類型較長的描述
2.3.1 基本類型(包括int1、int2、int4、int8、float、double)
頭資訊後緊跟數值資料。char、bool也被看作整型。所有的整型資料之間不做區分,也就是說一個short的值可以指派給一個int。由于長度固定,是以不需要長度資訊。
2.3.2 數字0
頭資訊後不跟資料,表示數值0。所有基本類型的0值都可以這樣來表示。
這是考慮到數字0出現的機率比較大,是以單獨提一個類型,以節省空間。
(即,隻有頭資訊,沒有實際資料,解析為數字0)
2.3.3 字元串(包括String1、String4)
String1跟一個位元組的長度(該長度資料不包括頭資訊)(字元的長度小于等于255),接着緊跟内容。
String4跟四個位元組的長度(該長度資料不包括頭資訊)(字元的長度大于255,注意最大長度為2^32 - 1),接着緊跟内容。
2.3.4 Map
緊跟一個整形資料(包括頭資訊)表示Map的大小,然後緊跟[Key資料(Tag為0),Value資料(Tag為1)]對清單。
在序列化的時候将key和value分開進行存放,先存放其長度,接着存放所有的key,其中key存放的tag為0,value存放的tag為1:
map的tag
map的type
map的size
key的tag(0)
key的type
key1
key2
...
keyN
value的tag(1)
value的type
value1
value2
valueN
9
10
11
12
13
14
15
2.3.5 List
緊跟一個整形資料(包括頭資訊)表示List的大小,然後緊跟元素清單(Tag為0)
2.3.6 自定義結構開始
自定義結構開始标志,後面緊跟字段資料,字段按照tag升序順序排列
2.3.7 自定義結構結束
自定義結構結束标志,Tag為0
2.4、對象持久化
對于自定義結構的持久化,由開始标志與結束标志來辨別。
比如如下結構定義:
struct TestInfo
1 require int i = 34;
2 optional string s = “abc”;
struct TestInfo2
1 require TestInfo t;
2 require int a = 12345;
其中,預設的TestInfo2結構編碼後結果為:
3、JCE資料解碼
JCE解碼解碼過程:
擷取頭部資訊,讀取第一個位元組的高4位作為tag值,如果大于等于15,則讀取下一個位元組的值作為真實的tag值;讀取第一個位元組的低四位作為type值
将指針偏移到資料部分
擷取資料真實大小
讀取資料部分成功後傳回其值
三、如何通信
1、背景與背景如何通信
以TAF架構為例,背景之間的通信是直接通過TCP、UDP socket進行傳輸的,而傳輸的二進制資料,則使用應用層協定JCE進行編碼。
TAF架構層統一使用RequestPacket 和 ResponsePacket 作為請求包結構和響應包結構,這些結構通過 JCE 定義,網絡傳輸時,首先使用 JCE 協定将結構編碼成二進制流,然後在前面加上整個資料包的長度(表示長度的位元組大小+二進制資料大小),組成最終的資料包。
其中請求、響應包結構中的 sBuffer 成員變量,則是業務自己定義的請求、響應包結構體JCE序列化後的二進制資料。
舉個例子,假設有一個接口協定定義如下:
int echo(const EchoRequest & req, out EchoResponse & rsp);
則發送請求時資料打包的步驟:
将 EchoRequest JCE序列号成二進制資料 vtIn;
将 vtIn 指派給 RequestPacket 的 sBuffer 成員變量;
填充 RequestPacket 的其他成員變量資訊(比如 requestId 等);
将 RequestPacket JCE序列号成二進制資料 vtBody;
計算 vtBody 的大小 bodySize(假設是1024byte);
bodySize自身的大小(一個int32,4byte) + bodySize(1024byte) = 整個資料包的大小作為header(一個值為1028的int32整數);
header + vtBody 作為最終的資料,通過socket發送到目标ip:port上
2、終端與背景如何通信
背景服務之間的通信,是通過JCE協定實作資料的打包和解包的。假設服務A調用服務B的某個接口,那麼A填充請求資料包,序列化後通過B的本地代理将發送請求給B。
如果終端也采用這種方式,則需要接入所有目标服務的本地代理,不友善功能的擴充。
實際上,終端和目标背景服務之間有一層代理服務(即接入層),它内部維護了指令字和目标服務代理的映射關系。
終端通過HTTP協定把資料發給接入層(對外暴露統一的域名供用戶端通路),HTTP資料的body,是一個JCE結構體序列化之後加密、壓縮得到的資料,接入層收到請求,首先對body解壓縮、解密,然後反序列化,得到一個請求結構,結構中包含請求的指令字、以及各指令字協定的JCE序列化資料。
接入層從映射表中找到對應的背景服務後,代替終端去和背景服務通信,得到結果後再把結果以HTTP回包的形式傳回給終端。
為了實作統一接入,接入層跟背景服務通信的協定必須統一:
xxx(const BusinessRequestHead &head, const vector<char> &requestData, vector<char> &responseData);
requestData 和 responseData 是終端與背景服務約定好的 JCE協定結構的序列化資料,接入層隻需要透傳,無需了解。
這樣一來,接入層就可以實作對RPC參數的統一打包解包:tag 1 2 3 分别是 head, requestData,responseData,tag 0 則為ret。