對於初涉網絡編程的開發人員來講,在通訊協議的設計上通常會有所困惑。通常的網絡編程書籍上也較少涉及這方面的內容。估計是以爲太簡單了。這塊確實是不難,但若是不瞭解,又很容易出簍子或者繞彎路。下面我就來談談基於TCP/UDP的協議設計。
一、基於TCP的協議設計
TCP是基於流的協議。但大部分網絡應用通常會有個更小的處理單元,咱們稱之爲幀(FRAME)。編程
也就是說,應用層在處理數據的通訊的時候必定要按照Frame爲單位來處理。json
如上所述,大部分網絡應用是須要分幀的。舉IM爲例,用戶登陸是一個幀,用戶發送文本信息是一個幀。少部分應用能夠不須要分幀,好比:echo服務器,接收到什麼直接回復便可;轉發服務器,一樣是接收到數據直接轉給目標機器;更常見的狀況是一個TCP鏈接只發送/處理一個請求以後就直接關閉,這種也就不必分幀了。
考慮到除了學習網絡編程,沒人作echo server。因此只要服務端不是一次鏈接只處理一個請求,或者純轉發,就應該採用分幀的設計。緩存
注意:幀是業務處理的單元,是具體應用Care的,但這不關TCP的事情!初學者每每認爲tcp這端 write一次,tcp那端就會read一次,而後驚呼「粘包」、「丟包」,其實這都是程序處理不當。在這邊推薦一本書籍《TCP/IP協議詳解 卷1》,挺薄的,看完能夠減小不少對TCP的錯誤認識。實際上發送方發送一幀,接收方可能要N次才能讀取完成,並且可能同時讀到下幀的數據。那要怎麼在接收方把一幀數據很少很多的讀取出來呢?
經常使用作法有兩個:基於長度和基於終結符(Delimiter)。基於長度,就是在幀前先發送幀的長度,通常用固定長度的字節來發送此長度,好比2個字節(最大幀長不能大於65535),4個字節。(ps:我也見過使用可變長度的字節來發送此長度,好比netty中的ProtobufVarint32FrameDecoder,看代碼那是至關的蛋疼,我以爲徹底是折騰本身,強烈不推薦。)使用基於長度的分幀方式,接受方處理流程通常是這樣:「讀取固定長度的字節 -> 解析出幀長 -> 讀取幀長字節 -> 處理幀」。
基於終結符(Delimiter),最典型的應用就是HTTP協議了,使用/r/n/r/n做爲終結符。使用基於終結符的分幀方式,接收方的處理流程通常是這樣:「讀數據 -> 在讀取的數據中定位終結符 -> 沒找到,將數據緩存 -> 繼續讀數據 -> 定位終結符 -> 找到終結符,將終結符以前的數據做爲一幀進行處理」。
使用終結符的方式務必要考慮轉義問題,否則在幀的數據中出現終結符,樂子就大了。
注意無論採用哪一種方式,在開發的時候都須要考慮最大幀長的問題。否則若是對方說要發送4G長度的幀(惡意or程序錯誤),真的去new 4G字節的緩存;或者對方一直髮送數據,沒有終結符。均可能形成程序內存耗盡。
通常來講,基於長度的分幀方式。開發更簡單,程序執行效率也更高,使用更普遍些。基於終結符也不是一無可取:可讀性更好,容易模擬和測試(如用telnet)。下面重點討論基於長度的分幀方式。安全
通常來講,咱們會將幀分爲幀頭(frame header,通常是固定長度)和幀體(frame body,通常是可變長度,也有固定長度的)。如上所述,最簡單的幀頭只要一個字段——幀長。但在實際應用中,一個典型的幀頭可能還有如下字段:
a)消息類型(message type):在一個網絡應用中,每每有多種類型的幀。好比對於IM,有登錄/登出/發送消息/……。接收方須要根據幀頭的消息類型字段,解碼出不一樣種類的消息,交給相應處理模塊進行處理。也就是幀的結構是Length-Type-Message,Length-Type能夠視爲幀頭,Message是幀體。消息類型通常也是使用固定長度,好比Length 4個字節,Type 4個字節,那麼幀頭的長度就是8個字節。接收方處理流程:「讀幀頭長度字節數據 - 解碼幀頭得到長度和消息類型 - 讀幀體長度字節數據 - 根據消息類型解碼消息 - 處理消息」。Length-Type-Message結構的幀設計是使用最普遍的,普適性最好也最精簡的設計。
b)請求序列號(serials):這個不是必選項,但我以爲對於非echo式的服務(echo式的服務:老是客戶端發送請求-服務端針對該請求應答,應答保證嚴格按照請求順序),加上這個字段確定不後悔。這樣對於亂序(若是有消息隊列後臺線程池,很正常)的執行結果,纔可以和請求對上號,從而作出正確的處理。通常來講,高性能的服務端要保證響應的嚴格有序,是比較麻煩和影響性能的。
c)版本號(version):不少人這麼用,但我以爲大部分狀況下這不是個好主意。幀頭應該放大部分/所有幀都須要的字段。而版本號可能只有少數包如登陸會用到,因此放到登陸包體裏可能更合適。單獨維護每一個協議的版本工做量會比較大,開發起來會比較繁瑣易錯。至於擔憂解碼失敗,更好的方式是採用相似Protobuf這種能夠向下兼容的編解碼方案。
注意:在幀頭設計時應該要儘量的精簡和通用,由於幀頭長度是每一個幀都須要的額外開銷。若是某個字段(如序列號)只有少數幀會使用到,徹底能夠放在幀體裏去。反之,若是某個字段大部分包都有,卻不定義在包頭,會致使難以統一處理,增長開發工做量。這些須要根據具體業務需求來進行權衡,沒有統一的答案。舉個例子,Length-Type-Message結構適用於大部分狀況,但若是業務要求每一個幀都須要代表操做者,在幀頭增長UID字段變成Length-Type-UID-Message,程序的開發會更簡單。服務器
幀體就是字段的集合,舉個例子,登陸幀體包含用戶名、密碼這兩個字段(只是舉例,現實的登陸包每每複雜得多)。在幀體設計上,你們每每也是八仙過海各顯神通。好比基於XML、json,基於字段Pos(舉登陸包爲例,就先寫/讀用戶名,再寫/讀密碼。這種方式不是太好,很難向下兼容:好比登陸包須要在用戶名和密碼間加一個用戶狀態,若是服務端/客戶端沒有同步升級,就會斯巴達)。我甚至見過狂野得離譜的直接使用C struct的,這種腦殘到爆:兼容性渣不說,類對齊(能夠用pragma pack避免不一致)、byte order、機器字長都會形成麻煩。
比較推薦的作法:騷年,用Google Protobuf吧!若是要可讀性好,json相比XML更省帶寬。
二、基於UDP的協議設計
通常來講,UDP的服務器要比TCP簡單得多(不過若是要實現基於UDP的可靠消息傳輸,就當我沒說)。並且udp原本就是基於數據包的協議。write/read是能夠一一對應的(不考慮丟包),因此不須要有長度字段/終結符。
可是要注意:爲了不丟包率太高,udp包的長度通常不該該大於1500字節(大概,爲了安全起見,我通常保證小於1K嘿),若是數據量較大,就須要分包了,這是比TCP麻煩的地方。
典型的UDP的協議設計就是:Type-Message。Type長度固定,用於說明消息類型;Message是消息體,和tcp的幀體設計一樣便可。網絡