1. 程式人生 > >談談網路程式設計中應用層(基於TCP/UDP)的協議設計

談談網路程式設計中應用層(基於TCP/UDP)的協議設計

 對於初涉網路程式設計的開發人員來說,在通訊協議的設計上一般會有所困惑。一般的網路程式設計書籍上也較少涉及這方面的內容。估計是覺得太簡單了。這塊確實是不難,但如果不瞭解,又很容易出簍子或者繞彎路。下面我就來談談基於TCP/UDP的協議設計。
    1、基於TCP的協議設計  
    TCP是基於流的協議。但大部分網路應用一般會有個更小的處理單元,我們稱之為幀(FRAME)。
  • 是否分幀
    如上所述,大部分網路應用是需要分幀的。舉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)。下面重點討論基於長度的分幀方式。
  • 基於長度的的幀設計(length based frame design)
   一般來說,我們會將幀分為幀頭(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更省頻寬。
    2、基於UDP的協議設計
   一般來說,UDP的伺服器要比TCP簡單得多(不過如果要實現基於UDP的可靠訊息傳輸,就當我沒說)。而且udp本來就是基於資料包的協議。write/read是可以一一對應的(不考慮丟包),所以不需要有長度欄位/終結符。
   但是要注意:為了避免丟包率過高,udp包的長度一般不應該大於1500位元組(大概,為了安全起見,我一般保證小於1K嘿),如果資料量較大,就需要分包了,這是比TCP麻煩的地方。
   典型的UDP的協議設計就是:Type-Message。Type長度固定,用於說明訊息型別;Message是訊息體,和tcp的幀體設計同樣即可。