基於Nodejs的Tcp封包和解包

蘇格團隊發表於2019-03-03
  • 蘇格團隊
  • 作者:Jonny

我們知道,TCP是面向連線流傳輸的,其採用Nagle演算法,在緩衝區對上層資料進行了處理。避免觸發自動分片機制和網路上大量小資料包的同時也造成了粘包(小包合併)和半包(大包拆分)問題,導致資料沒有訊息保護邊界,接收端接收到一次資料無法判斷是否是一個完整資料包。那有什麼方案可以解決這問題呢?


1、粘包問題解決方案及對比

很簡單,既然訊息沒有邊界,那我們在訊息往下傳之前給它加一個邊界識別就好了。

  1. 傳送固定長度的訊息
  2. 使用特殊標記來區分訊息間隔
  3. 把訊息的尺寸與訊息一塊傳送

第一種方案不夠靈活;第二種有風險,如果資料內剛好有該特殊字元會出問題;第三種方案雖然要增加對訊息頭的解析,不過相對而言還是要安全一些。

2、分包與拆包

既然使用第三種方案,就必然涉及到封包和拆包的問題。

首先肯定需要定義資料包的結構,這類似Http包一樣,有包頭和包體。包頭其實上是個大小固定的結構體,其中有個結構體成員變數表示包體的長度,其他的結構體成員可根據需要自己定義。根據包頭長度固定以及包頭中含有包體長度的變數就能正確的拆分出一個完整的資料包。包體則存放資料內容。

image

在傳送端,需要進行封包。封包就是給一段資料加上包頭,這樣一來資料包就分為包頭和包體兩部分內容了。

在接受端,則需要進行拆包。主要流程如下:

  1. 為每一個連線動態分配一個緩衝區,同時把此緩衝區和SOCKET關聯.
  2. 當接收到資料時首先把此段資料存放在緩衝區中.
  3. 判斷快取區中的資料長度是否夠一個包頭的長度,如不夠,則不進行拆包操作.
  4. 根據包頭資料解析出裡面代表包體長度的變數.
  5. 判斷快取區中除包頭外的資料長度是否夠一個包體的長度,如不夠,則不進行拆包操作.
  6. 取出整個資料包.這裡的”取”的意思是不光從緩衝區中拷貝出資料包,而且要把此資料包從快取區中刪除掉.刪除的辦法就是把此包後面的資料移動到緩衝區的起始地址.

其中對於緩衝區的設計,主要由倆種:

  1. 採用動態變化的緩衝區暫存,根據資料大小調整緩衝區大小。這個方案有個缺點,為了避免緩衝區不斷增長,每次解析出一個完整包後需要將緩衝區殘留的資料拷貝到緩衝區首部,這增加了系統負載。
  2. 採用環形緩衝區,定義兩個指標,分別指向有效資料的頭和尾.在存放資料和刪除資料時只是進行頭尾指標的移動
image
image

3、網路位元組序和本機位元組序

定義了訊息結構之後,傳送端和接收端還需要統一位元組序。我們知道,不同機器的本機位元組序不同,絕大多數X86機器都是小端位元組序,然後還是由少數機器是大端儲存的。因此在資料流進行傳輸時,必須先統一位元組序。一般約定在傳輸時採用網路位元組序(大端),統一用unicode編碼。

image

4、程式碼實現

瞭解以上知識之後,我們現在之後要做什麼了。傳送端按定義的協議規則封包,接受端把接收到的buffer放入緩衝區,當緩衝區內有完整包時開始拆包。封包拆包過程需要注意,讀寫超過一個位元組的資料時需要按大端位元組序讀取。下面看node的程式碼實現(只提供核心實現片段):

1)傳送端封包:

    let head = new Buffer(4);
    let jsonStr = JSON.stringify(json);
    let body = new Buffer(jsonStr);
    //超過一位元組的大端寫入
    head.writeInt32BE(body.byteLength, 0);
    let buffer = Buffer.concat([head, body]);
複製程式碼

2)接收端收到buffer入緩衝區:

let dataReadStart = 0; //新資料的起始位置
let dataLength = buffer.length; // 要拷貝資料的長度
let availableLen = _bufferLength - _dataLen; // 緩衝區剩餘可用空間

// buffer剩餘空間不足夠儲存本次資料
if (availableLen < dataLength) {
    let newLength = Math.ceil((_dataLen + dataLength) / _bufferLength) * _bufferLength;
    let _tempBuffer = Buffer.alloc(newLength);
    
    // 將舊資料複製到新buffer並且修正相關引數
    if (_writePointer < _readPointer) { // 資料儲存在舊buffer的尾部+頭部的順序
        let dataTailLen = _bufferLength - _readPointer;
        _buffer.copy(_tempBuffer, 0, _readPointer, _readPointer + dataTailLen);
        _buffer.copy(_tempBuffer, dataTailLen, 0, _writePointer);
    } else {  // 資料是按照順序進行的完整儲存
        _buffer.copy(_tempBuffer, 0, _readPointer, _writePointer);
    }
    _bufferLength = newLength;
    _buffer = _tempBuffer;
    _tempBuffer = null;
    _readPointer = 0;
    _writePointer = _dataLen;

    //儲存新到來的buffer
    buffer.copy(_buffer, _writePointer, dataReadStart, dataReadStart + dataLength);
    _dataLen += dataLength;
    _writePointer += dataLength;

} else if (_writePointer + dataLength > _bufferLength) {
// 空間夠用情況下,但是資料會衝破緩衝區尾部,部分存到緩衝區舊資料後,一部分存到緩衝區開始位置
    // 緩衝區尾部剩餘空間的長度
    let bufferTailLength = _bufferLength - _writePointer;

    // 資料尾部位置
    let dataEndPosition = dataReadStart + bufferTailLength;
    buffer.copy(_buffer, _writePointer, dataReadStart, dataEndPosition);

    // data剩餘未拷貝進快取的長度
    let restDataLen = dataLength - bufferTailLength;
    buffer.copy(_buffer, 0, dataEndPosition, dataLength);

    _dataLen = _dataLen + dataLength;
    _writePointer = restDataLen

} else { // 剩餘空間足夠儲存資料,直接拷貝資料到緩衝區
    buffer.copy(_buffer, _writePointer, dataReadStart, dataReadStart + dataLength);
    _dataLen = _dataLen + dataLength;
    _writePointer = _writePointer + dataLength
}
複製程式碼

2)取出緩衝區所有完整資料包(收到的buffer入緩衝區後)

let _dataHeadLen = 4;
timer && clearInterval(timer);
timer = setInterval(()=>{
    // 緩衝區資料不夠解析出包頭
    if (_dataLen < _dataHeadLen) {
        console.log(`資料長度小於包頭規定長度,等待資料......`)
        clearInterval(timer);
    }
    // 解析包頭長度
    // 尾部最後剩餘可讀位元組長度
    let restDataLen = _bufferLength - _readPointer;
    let dataLen = 0;
    let headBuffer = Buffer.alloc(_dataHeadLen);
    // 資料包為分段儲存,不能直接解析出包頭,先拼接
    if (restDataLen < _dataHeadLen) {
        // 取出第一部分頭部位元組
        _buffer.copy(headBuffer, 0, _readPointer, _bufferLength)
        // 取出第二部分頭部位元組
        let unReadHeadLen = _dataHeadLen - restDataLen;
        _buffer.copy(headBuffer, restDataLen, 0, unReadHeadLen)
        dataLen = headBuffer.readUInt32BE(0);
    } else {
        _buffer.copy(headBuffer, 0, _readPointer, _readPointer + _dataHeadLen);
        dataLen = headBuffer.readUInt32BE(0);;
    }

    // 資料長度不夠讀取,直接返回
    if (_dataLen - _dataHeadLen  < dataLen) {
        log.info("緩衝區已有body資料長度小於包頭定義body的長度,等待資料......")
        clearInterval(timer);

    } else { // 資料夠讀,讀取資料包 
        let package = Buffer.alloc(dataLen);
        // 資料是分段儲存,需要分兩次讀取
        if (_bufferLength - _readPointer < dataLen) {
            let firstPartLen = _bufferLength - _readPointer;
            // 讀取第一部分,直接到字元尾部的資料
            _buffer.copy(package, 0, _readPointer, firstPartLen + _readPointer);
            // 讀取第二部分,儲存在開頭的資料
            let secondPartLen = dataLen - firstPartLen;
            _buffer.copy(package, firstPartLen, 0, secondPartLen);
            _readPointer = secondPartLen; //更新可讀起點

        } else { // 直接讀取資料
            _buffer.copy(package, 0, _readPointer, _readPointer + dataLen);
            _readPointer += dataLen; //更新可讀起點
        }

        _dataLen -= readData.length; //更新資料長度
        // 已經讀取完所有資料
        if (_readPointer === _writePointer) {
            clearInterval(timer)
        }

        //開始解包
        callback(package);
          
    }
}, 50);
複製程式碼

4)拆包得到資料

let headBytes = 4;
let head = new Buffer(headBytes);
buffer.copy(head, 0, 0, headBytes);
let dataLen = head.readUInt32BE();
const body = new Buffer(dataLen);
buffer.copy(body, 0, headBytes, headBytes + dataLen)

let content = null;
try {
    const str = body.toString(`utf-8`);
    if(str === ``){
        content = null;
    }else{
        content = JSON.parse(body);
    }
} catch (e) {
    log.error(`head指定body長度有問題`)
}
//傳遞給業務層
callback(content);
複製程式碼

5、總結

從上面我們已經瞭解到了封包解包的一個過程。TCP是可靠傳輸的,同一時間在網路上只會有一個資料包,並且丟包會重傳,因此不用擔心丟包或者資料包亂序問題。UDP有訊息保護邊界,不需要進行拆包解包,然後其是非可靠傳輸,也需要解決其他一些問題,譬如丟包和資料包排序問題。

上面進行資料包結構設計時只是簡單地加了一個包體長度,事實上在業務場景可以自由增加需要的欄位,譬如協議版本,協議型別等等。

相關文章