Nodejs 發送 TCP 消息的正確姿勢

最近使用 NODE-RED 跟 TCP 打交道。NODE-RED 裏內建了一個節點叫“tcp-out”,看文檔呢使用這個節點可以很方便的把 payload 用 TCP 協議發送出去,但是事實上事情沒有這麼簡單。其實當我第一次看到這個節點用法的時候我就覺得會有問題,果不其然。既然節點有問題,那麼就乾脆寫代碼吧,反正 NODE-RED 支持自定義 javascript function 。於是就花了點時間研究了下用 Nodejs 來發送 TCP 消息。

問題

上面說了使用內建的節點“tcp-out”發送 TCP 消息會有問題。那麼到底是什麼問題呢?
“tcp-out” 節點只是簡單的把 payload 字符串轉成了 buffer 然後發送了出去。其實如果自己做測試,發送一個消息然後服務端接受一個消息一點問題都沒有的。但是稍微有一些 socket 編程經驗的人都知道,這麼做在生產環境是有問題的。因爲在真實的生產環境下,服務端都是會定義消息的結構的。比如我們這次對接的服務端就要求每個消息頭部都需要帶4字節的包頭,來標識整個消息的長度。所以我們直接發送的消息服務端校驗包頭不通過會直接丟棄。
那麼爲什麼要這麼做呢?

粘包?

服務端這麼做的原因是 TCP 服務端接收消息有可能出現“粘包”的問題。這時候肯定有同學會出來說了:TCP 是流式協議,根本沒有包的概念怎麼可能粘包呢?是的 ,這說的沒錯。本質上 TCP 作爲流式協議根本不可能出現粘包的問題。但是如果從應用層開發者的角度來看,TCP 服務端在接受消息的時候確確實實會出現多個消息同時收到,或者收到1.x個消息的問題。站在應用層開發者的角度看,就是幾個包(消息)黏在了一起。所以也沒必要去咬文嚼字,畢竟大家多數都是應用層開發玩家。
那麼爲什麼會有以上問題?讓我們先回顧一下 OSI 網絡模型:
TCP位於傳輸層(第四層),傳輸的單位叫 Segment(段);
下面是 IP 協議位於網絡層,傳輸的單位叫 Packet (包);
下面是 Datalink 數據鏈路層,單位是 frame (幀);
好了知道了以上知識,我們可以知道 TCP 是已 segment 單位來傳輸的。但是 segment 是有最大值限制的。在 TCP 協議中有個叫 MSS(Max Segment Size) 的東西。一般來說 MSS = MTU - 40 = 1460 字節。爲什麼是一般來說,因爲 TCP 協議太複雜了。看上面又引入了一個 MTU 的概念,這裏就不展開來說了,有興趣大家可以自己研究一下 TCP,會大開眼界的。
好了,既然 segment 有最大值限制,那麼很顯然當我們一次發送的消息長度超過 MSS ,那麼消息就會被拆分成多個 segment 來發送。既然有拆分那麼顯然就有合併。TCP 協議有個 TCP_NODELAY 算法,當傳輸大量長度短的數據的時候有可能會觸發 TCP_NODELAY 算法。TCP_NODELAY 算法就會嘗試把多個短消息合併成一個 segment 來發送。
那麼如何解決上述問題呢?方法就是上面說的 ,在每個消息的開始的地方放一個固定長度的頭部用來表示整個消息的長度。

服務端收到消息後,先截取4個字節的長度,讀取裏面的值獲得整個消息的長度。然後 payload 長度 = 整個長度-4。然後使用這個長度截取對應的長度的數據。這樣就得到了一個完整的消息。如果後面的長度不夠了就等下一個消息到達後補齊對應長度的數據。如此循環以上操作,服務端就能解決這個問題了。

使用 Nodejs 發送 TCP 報文(消息)

好了上面鋪墊了這麼多 ,總算要開始寫代碼了。
如果你打開 Google 搜索 "nodejs 發送 tcp" 你會得到很多代碼示例。但是大多數代碼都是 demo 級別的。也就是都是簡單的把所有的消息當做 payload 發送到服務端,然後服務端打印一下而已。這也是我寫這篇文章的初衷,科普一下一個真正的 TCP 報文(消息)該怎麼發送。
就以上面的結構爲例:頭部固定4字節表示整個消息的長度(4 + length(payload))。

const payloadString = 'hello , world .';
const headerLength = 4;
let socket = net.createConnection({ port: 8888, host: '127.0.0.1' });
socket.on('connect', () => {
  console.log('start send data .');
  let messageBuff = Buffer.from(payloadString);
  let messageLength = messageBuff.length;
  let contentLength = Buffer.allocUnsafe(4);
  contentLength.writeUInt32BE(headerLength + messageLength);
  socket.write(contentLength);
  console.log('send header done');
  socket.write(messageBuff);
  console.log('send payload done');
  
  console.log('send data done .');
});

其實代碼也沒幾行。簡單說一下就是,在發送 payload 之前,需要先分配一個 4 字節長度的 buffer,然後寫入整個消息的長度,發送出去,緊接着發送真正的 payload 。這樣就完成了一次 TCP 報文消息的發送。

總結

雖然題目叫 Nodejs 發送消息,但是代碼卻是寥寥幾行。本文多數文字都是在描述 TCP 協議相關的東西。TCP是個偉大(複雜)的協議,要理解它不是件容易的事情,光是鏈接建立,鏈接關閉的過程都非常複雜。更別說它那些算法了(NODELAY,窗口算法,擁堵避免算法等等)。但是有時間的話還是可以花點時間研究下,這對於我們這些應用層開發者來說也是一件非常有意義的事。當你瞭解了 TCP 協議後,很多以前似懂非懂的問題都豁然開朗了。比如到底有沒有粘包問題,應用層爲什麼要定義數據結構,同一個連接服務端會有併發問題嗎?

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章