互聯網后台服務的協議設計
1. 基本概念
服務(server):“服務”可以分軟件和硬件兩個類別,本文提到的“服務”都是指軟件,是一種程序。稱之為“服務”的程序一般具備2個特點:
1) 程序啟動后常駐內存,成為守護進程。
2) 能與其他進程通信,接收請求,處理請求並做出回應。
本文中的服務特指基於TCP/IP 協議通過socket進行通信的服務。
為什么互聯網業務需要“服務”這種類型的程序呢?主要有2個原因
1) 有些功能可以通過一個獨立的程序來完成,不用每個程序都寫一套代碼來實現這個功能,這樣有利於程序的解耦和復用
2) 有些功能單機、單進程無法完成,需要通過多台機器、多個程序的協作完成。
比如memcache服務,它常駐內存並監聽TCP端口,接收來自socket的數據包,使客戶端可以以key-value的方式存取數據,是互聯網后台中一種常用的cache服務。
客戶端(client):本文中的客戶端指的是主動發包給服務的程序,或者說是發起請求的程序,相對的,服務就是接收請求並處理的程序。
協議:協議是一種約定,通過約定,不同的進程可以對一段數據產生相同的理解,從而可以相互協作,存在進程間通信的程序就一定需要協議。
我們為什么需要自己設計協議:
通過上面講的我們可以看出,在互聯網后台開發中,稍微復雜一些的業務,服務是必要的,進而協議也是必要的。那么我們是否可以復用已有的協議呢?主要是因為現在已有的協議都沒有能完全match互聯網后台開發的需求,存在這樣或那樣的問題。
協議設計的目標:
解析效率:互聯網業務具有高並發的特點,解析效率決定了使用協議的CPU成本;
編碼長度:信息編碼出來的長度,編碼長度決定了使用協議的網絡帶寬及存儲成本;
易於實現:互聯網業務需要一個輕量級的協議,而不是大而全的,CORBA這種重量級的協議就不太適合,易於實現決定了使用協議的開發成本和學習成本;
可讀性:編碼后的數據的可讀性決定了使用協議的調試及維護成本
兼容性: 互聯網的需求具有靈活多變的特點,協議會經常升級,使用協議的雙方是否可以獨立升級協議、增減協議中的字段是非常重要的。兼容性決定了持續開發時的開發成本,個人覺得這點是互聯網協議中最重要的一個指標。
協議設計需要解決的問題:
1) 序列化/反序列化
2) 判斷包的完整性
只要解決了這2個問題,2個不同機器的進程就能完成通信。
2. 序列化/反序列化:
序列化我們常稱之為編碼,或者打包,反序列化常稱之為解碼,或者解包。常用的序列化/反序列化方式主要有以下幾種:
1) TLV編碼及其變體(后面統稱為TLV編碼):Protobuf/thrift/ASN BER都屬於這種。
TLV編碼基本原理是每個字段打一個二進制包,每個包包含tag、length、value 3個部分:
tag: 一般占用1個字節,表示數據類型,有的編碼方式(Protobuf/thrift)中tag包含字段的id,有的編碼方式(ASN BER)不包含字段的id。包含字段id的序列化方式,id是字段的標志,協議可以靈活的增刪字段,只要保證字段id唯一,就能兼容解析,非常適合互聯網開發。
length:一個整數,表示后面數據塊的長度,Protobuf/thrift的序列化不包含length字段,因為大部分數據類型的長度都可以根據tag中的類型信息可以得到。
value:真正的數據內容。
舉個tag包含id的序列化方式打包解包的例子(只是舉個例子說明原理,實際上Protobuf等協議都做了比較巧妙的實現,比如varint、ZigZag編碼來盡量減少編碼長度):
協議包括2個字段, name字段的id為0,類型為1(string);age字段的id為1,類型為2(unsigned int )
| 字段id |
字段類型 |
字段名 |
| 0 |
string |
name |
| 1 |
unsigned int |
age |
需要傳輸的數據:
name = "xxx"
age = 18
序列化之后大約是
| 字段類型(tag的一部分) |
字段id(tag的一部分) |
字段值(value) |
| 0x01 |
0x00 |
xxx |
| 0x02 |
0x01 |
0x12 |
反序列化的時候,逐步解析字節流,先解析字段類型和字段id,再根據字段類型解析出后面的數據內容,得到了一個id和值的映射關系
0 : "xxx"
1 : 18
根據協議,id=0的字段表示name,id=1的字段表示age,反序列化之后,就知道傳過來的數據是
name = "xxx",age = 18了
如果協議做了升級,增加了1個字段“gender”,刪除一個已經沒有意義的字段age,協議變成
0 string name
2 string gender
需要傳輸的數據:
name = "xxx"
gender = "male"
發送方升級了協議,序列化之后大約是
| 字段類型 |
字段id |
字段值 |
| 0x01 |
0x00 |
xxx |
| 0x01 |
0x02 |
male |
反序列化之后,得到了一個id和值的映射關系
0 : "xxx"
2 : "male"
反序列化的一方由於沒有升級協議,不知道id=2的字段什么意思,直接忽略,沒找到id=1的age字段,那么使用默認值,這樣單方的升級,完全不影響協議的解析,協議是具有兼容性的。
舉個tag不包含id的序列化方式打包解包的例子:
如果tag中沒有字段id,那么字段所在的位置決定字段的含義
協議包括2個字段, 第1個字段name,類型為1(string);第2個字段age類型為2(unsigned int )
| 字段類型 |
字段名 |
| string |
name |
| unsigned int |
age |
需要傳輸的數據:
name = "xxx"
age = 18
序列化之后大約是
| 字段類型 |
字段值 |
| 0x01 |
xxx |
| 0x02 |
0x12 |
反序列化程序解析出第1個字段是字符串xxx,第二個字段是整數18,根據協議,第1個字段是name,第2個字段是age,這時反序列化程序就知道了name是xxx,age是18
但是相比上面有id的序列化方式,這種方式有個明顯的缺陷:一方升級了協議時,另一方很可能需要升級協議才行,協議不具有兼容性。比如協議做了升級,增加了一個字段gender,刪除一個已經沒有意義的字段age,協議變成
string name
string gender
需要傳輸的數據:
name = "xxx"
gender = "male"
發送方升級了協議,序列化之后大約是
| 字段類型 |
字段值 |
| 0x01 |
xxx |
| 0x01 |
male |
這時接收方如果不升級協議就完全無法理解協議的含義
可以看出tag包含ID的序列化方式(Protobuf/thrift)兼容性和靈活性方面優於不包含ID的方式(asn-ber)
TLV編碼的特點是:
解析效率高:主要是因為不需要轉義字符
編碼長度低:主要是因為元數據占用的空間很少
不易於實現:但是有很多開源的工具,根據IDL自動生成代碼,提高開發效率
兼容性高:協議雙方可以獨立升級
可讀性差:二進制協議,肉眼很難識別
2) 文本流編碼:xml/json都屬於這種。
基本原理是把每個字段打一個字符串形式的包,通過鍵值對(key-value)的方式存儲數據,key是字段的名字,用於區分不同的字段(對比上面TLV編碼采用id的方式標志一個字段),特殊字符特別是非文本字符需要做適當轉義,轉義為xml/json的合法字符。xml的解析效率低於json,而編碼長度高於json,json作為序列化的方式一般是優於xml的。
同樣是上面的協議:
序列化的結果大概是
<p><name>xxx</name><age>18</age></p>
或者
{name:xxx,age:18}
文本流編碼的特點是:解析效率低,編碼長度高,易於實現,可擴展性高,可讀性好
3) 固定結構編碼:
基本原理是,協議約定了傳輸字段類型和字段含義,和TLV的方式類似,但是沒有了tag和len,只有value
同樣是上面的協議:
序列化的結果大概是
xxx 0x00 0x12
反序列化的時候,根據協議中約定的字段位置、字段類型和字段含義,逐個解出相應的字段
固定結構編碼如果協議升級了又需要保證兼容性,那么可以在協議中增加一個“版本號”字段,然后根據版本號決定如何序列化和反序列化,這樣可以保證協議的兼容性。但是這樣會導致代碼非常混亂和讓人費解
固定結構編碼解析效率、編碼長度、易於實現、可讀性方面略微優於TLV方式,但是靈活性和兼容性非常差,如果不使用版本號判斷就不能單方增刪字段,不能單方修改字段數據類型,甚至,把協議中的short int字段改成int,反序列化就可能會出錯,因此除了業務邏輯非常固定的場景外不推薦使用。
4) 內存dump:
基本原理是,把內存中的數據直接輸出,不做任何序列化操作。反序列化的時候,直接還原內存。
一般我們聲明c++的結構如下即可
#pragma pack (1) struct { char name[64]; unsigned int age; }; #pragma pack ()
這種方式適合c/c++語言,單機進程間交換數據。這是一種簡單高效的協議,特別適合通過共享內存交換數據的場景。但是不具有通用性,不適合跨越語言和機器,本文不再討論這種編碼方式
如果沒有特別的必要,自己發明一種序列化方式一般是費力不討好的,有重復造輪子的嫌疑,所以我們在成熟序列化方式中選擇一種即可。
綜上,我們可以看出,如果我們想設計一個具有通用性,可以用於分布式環境,適合互聯網后台開發,能傳遞復雜數據,具有很好的靈活性和兼容性的協議,常用的序列化方式是TLV編碼和字符流編碼2種。那么根據不重復造輪子的原則,可選的編碼方式就只有Protobuf、thrift 和 json 3種了。我們對比一下這3種編碼方式。
序列化方式對比
Protobuf/thrift VS json
根據google的測評結果,Protobuf/thrift 效率高於 json, 而可讀性弱於json。解析效率大概比json高1倍。這個具體的倍數關系我沒測試過,存疑,而且不同的程序使用的json庫不一樣,還是應該以實測結果為准。
參考http://code.google.com/p/thrift-protobuf-compare/wiki/Benchmarking
Protobuf VS thrift
Protobuf 效率和編碼長度略有優勢,文檔比thrift豐富
thrift 內建的數據類型更多(有map和set)
thrift官方比Protobuf支持更多的編程語言,並有RPC框架,但是Protobuf有很多第三方的支持,同樣提供了多種語言的支持和RPC框架的實現
參考http://code.google.com/p/protobuf/wiki/ThirdPartyAddOns
參考http://blog.mirthlab.com/2009/06/01/thrift-vs-protocol-bufffers-vs-json/
個人比較傾向於Protobuf,主要是考慮到文檔和第三方支持多,目前使用的更廣泛。
至此,我們就選定了2種序列化方式Protobuf和json,如果並發度非常高,數據量非常大,使用Protobuf,否則使用json.
3. 判斷包的完整性:
一般有兩種方法:
1) 在序列化后的buffer前面增加一個采用固定結構編碼的頭部,頭部長度和結構固定,其中有個字段存儲包總長度。收包時,先接收固定字節數的頭部,解出這個包完整長度,按此長度接收包體。
2) 在序列化后的buffer前面增加一個字符流的頭部,其中有個字段存儲包總長度,根據特殊字符(比如根據\n 或者\0)判斷頭部的完整性。這樣通常比1要麻煩一些,http、memcached和radis采用的是這種方式。收包的時候,先判斷已收到的數據中是否包含結束符,收到結束符后解析包頭,解出這個包完整長度,按此長度接收包體。
至此,我們已經得到了一個協議框架,采用這個協議框架,再根據業務需要約定字段含義,就可以得到一個具體的協議,可以用於把一個機器上的消息,發送到另一個機器,並讓對方完全理解消息的含義。但是如果這就是這個協議框架的全部,那這個協議就太弱了,因為如果一個程序只知道協議框架而不知道協議的字段內容,那它除了可以收包和發包外,做不了任何事情,而在客戶端和服務之間搭建一個代理層,來做容災、監控、統計、路由、認證等等事情是一種常見的架構模式,這樣這些公共的處理邏輯就不用每個服務都做一次了,服務可以專注於業務,而把這些邏輯交由代理層來做。換句話說,我們需要為協議框架增加一個頭部,並約定一些所有業務都可以使用的公共字段。
4. 協議頭部:
那么頭部中可以增加哪些字段呢?這個取決於你希望代理幫你做哪些事情。通常以下字段是可以考慮的:
seq //消息序列號,可以用於排查問題,也可以用於某些IO模型中包的解析
protocol version //協議版本號,可以用於協議的兼容
request useragent //請求者機器環境,包括操作系統、客戶端版本等等信息
request user ip //請求者ip
request user id //請求者id
client ip //客戶端ip
client id //客戶端業務id
server ip //服務ip
server id //服務id
server server cmd //服務命令字
retcode //返回碼
有了這些字段,代理層就能完全監控到服務的訪問情況,並生成報表
5. 我設計的協議:
有了上面的理論,我們就可以真正的設計協議了。我設計的這個協議可以應用於互聯網后台服務的絕大部分場景,協議中把一個包分為3個部分:
包頭的第1部分:固定8字節:協議標志(2字節) 包頭長度(2字節) 包體長度(4字節)
包頭的第2部分:這部分主要是前面第4點提到的公共頭部,包括seq等字段,采用Protobuf序列化,包頭的字段是可以增刪的,即使沒有任何字段,也不影響數據傳遞,但是可能影響你的代理做的工作;
包體:采用Protobuf序列化,具體內容取決於業務。
6. 一些常用的協議:
http協議:http協議是我們最常見的協議,我們是否可以采用http協議作為互聯網后台的協議呢?這個一般是不適當的,主要是考慮到以下2個原因:
1) http協議只是一個框架,沒有指定包體的序列化方式,所以還需要配合其他序列化的方式使用才能傳遞業務邏輯數據。
2) http協議解析效率低,而且比較復雜(不知道有沒有人覺得http協議簡單,其實不是http協議簡單,而是http大家比較熟悉而已)
有些情況下是可以使用http協議的:
1) 對公網用戶api,http協議的穿透性最好,所以最適合;
2) 效率要求沒那么高的場景;
3) 希望提供更多人熟悉的接口,比如新浪微、騰訊博提供的開放接口,就是http的;
memcache的協議:
基本原理是:先發送字符流,以\r\n作為結束標志,字符流中不允許存在特殊字符。
再發送一個數據包,可以包含任何字符,數據包的長度已經在前面的字符流中指定。
memcache的協議並沒有包含業務數據序列化和反序列化的部分,只有包頭和一個buffer,是一種適合於業務邏輯簡單場景下的協議。參考:http://www.ccvita.com/306.html
redis協議:
基本原理是:先發送一個字符串表示參數個數,然后再逐個發送參數,每個參數發送的時候,先發送一個字符串表示參數的數據長度,再發送參數的內容。
redis的協議和memcache類似,但是memcached只能帶一個二進制字段,redis可以帶多個
參考:http://www.redisdoc.com/en/latest/topic/protocol.html
