談談網路程式設計中應用層(基於TCP/UDP)的協議設計
阿新 • • 發佈:2019-02-05
對於初涉網路程式設計的開發人員來說,在通訊協議的設計上一般會有所困惑。一般的網路程式設計書籍上也較少涉及這方面的內容。估計是覺得太簡單了。這塊確實是不難,但如果不瞭解,又很容易出簍子或者繞彎路。下面我就來談談基於TCP/UDP的協議設計。
1、基於TCP的協議設計
TCP是基於流的協議。但大部分網路應用一般會有個更小的處理單元,我們稱之為幀(FRAME)。
考慮到除了學習網路程式設計,沒人做echo server。所以只要服務端不是一次連線只處理一個請求,或者純轉發,就應該採用分幀的設計。
常用做法有兩個:基於長度和基於終結符(Delimiter)。基於長度,就是在幀前先發送幀的長度,一般用固定長度的位元組來發送此長度,比如2個位元組(最大幀長不能大於65535),4個位元組。(ps:我也見過使用可變長度的位元組來發送此長度,比如netty中的ProtobufVarint32FrameDecoder,看程式碼那是相當的蛋疼,我覺得完全是折騰自己,強烈不推薦。)使用基於長度的分幀方式,接受方處理流程一般是這樣:“讀取固定長度的位元組 -> 解析出幀長 -> 讀取幀長位元組 -> 處理幀”。
基於終結符(Delimiter),最典型的應用就是HTTP協議了,使用/r/n/r/n作為終結符。使用基於終結符的分幀方式,接收方的處理流程一般是這樣:“讀資料 -> 在讀取的資料中定位終結符 -> 沒找到,將資料快取 -> 繼續讀資料 -> 定位終結符 -> 找到終結符,將終結符之前的資料作為一幀進行處理”。
使用終結符的方式務必要考慮轉義問題,不然在幀的資料中出現終結符,樂子就大了。
注意不管採用哪種方式,在開發的時候都需要考慮最大幀長的問題。不然如果對方說要傳送4G長度的幀(惡意or程式錯誤),真的去new 4G位元組的快取;或者對方一直髮送資料,沒有終結符。都可能造成程式記憶體耗盡。
一般來說,基於長度的分幀方式。開發更簡單,程式執行效率也更高,使用更廣泛些。基於終結符也不是一無是處:可讀性更好,容易模擬和測試(如用telnet)。下面重點討論基於長度的分幀方式。
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,程式的開發會更簡單。
比較推薦的做法:騷年,用Google Protobuf吧!如果要可讀性好,json相比XML更省頻寬。
2、基於UDP的協議設計
一般來說,UDP的伺服器要比TCP簡單得多(不過如果要實現基於UDP的可靠訊息傳輸,就當我沒說)。而且udp本來就是基於資料包的協議。write/read是可以一一對應的(不考慮丟包),所以不需要有長度欄位/終結符。
但是要注意:為了避免丟包率過高,udp包的長度一般不應該大於1500位元組(大概,為了安全起見,我一般保證小於1K嘿),如果資料量較大,就需要分包了,這是比TCP麻煩的地方。
典型的UDP的協議設計就是:Type-Message。Type長度固定,用於說明訊息型別;Message是訊息體,和tcp的幀體設計同樣即可。
1、基於TCP的協議設計
TCP是基於流的協議。但大部分網路應用一般會有個更小的處理單元,我們稱之為幀(FRAME)。
- 是否分幀
考慮到除了學習網路程式設計,沒人做echo server。所以只要服務端不是一次連線只處理一個請求,或者純轉發,就應該採用分幀的設計。
- 如何分幀
常用做法有兩個:基於長度和基於終結符(Delimiter)。基於長度,就是在幀前先發送幀的長度,一般用固定長度的位元組來發送此長度,比如2個位元組(最大幀長不能大於65535),4個位元組。(ps:我也見過使用可變長度的位元組來發送此長度,比如netty中的ProtobufVarint32FrameDecoder,看程式碼那是相當的蛋疼,我覺得完全是折騰自己,強烈不推薦。)使用基於長度的分幀方式,接受方處理流程一般是這樣:“讀取固定長度的位元組 -> 解析出幀長 -> 讀取幀長位元組 -> 處理幀”。
基於終結符(Delimiter),最典型的應用就是HTTP協議了,使用/r/n/r/n作為終結符。使用基於終結符的分幀方式,接收方的處理流程一般是這樣:“讀資料 -> 在讀取的資料中定位終結符 -> 沒找到,將資料快取 -> 繼續讀資料 -> 定位終結符 -> 找到終結符,將終結符之前的資料作為一幀進行處理”。
使用終結符的方式務必要考慮轉義問題,不然在幀的資料中出現終結符,樂子就大了。
注意不管採用哪種方式,在開發的時候都需要考慮最大幀長的問題。不然如果對方說要傳送4G長度的幀(惡意or程式錯誤),真的去new 4G位元組的快取;或者對方一直髮送資料,沒有終結符。都可能造成程式記憶體耗盡。
一般來說,基於長度的分幀方式。開發更簡單,程式執行效率也更高,使用更廣泛些。基於終結符也不是一無是處:可讀性更好,容易模擬和測試(如用telnet)。下面重點討論基於長度的分幀方式。
- 基於長度的的幀設計(length based frame design)
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,程式的開發會更簡單。
- 幀體的設計
比較推薦的做法:騷年,用Google Protobuf吧!如果要可讀性好,json相比XML更省頻寬。
2、基於UDP的協議設計
一般來說,UDP的伺服器要比TCP簡單得多(不過如果要實現基於UDP的可靠訊息傳輸,就當我沒說)。而且udp本來就是基於資料包的協議。write/read是可以一一對應的(不考慮丟包),所以不需要有長度欄位/終結符。
但是要注意:為了避免丟包率過高,udp包的長度一般不應該大於1500位元組(大概,為了安全起見,我一般保證小於1K嘿),如果資料量較大,就需要分包了,這是比TCP麻煩的地方。
典型的UDP的協議設計就是:Type-Message。Type長度固定,用於說明訊息型別;Message是訊息體,和tcp的幀體設計同樣即可。