Golang 處理 TCP“粘包” 問題

  1. 什麼是粘包?

“粘包” 這個說法已經被詬病很久了,既然坊間流傳這個說法咱們就沿用吧,關於這個問題比較準確的解釋可以參考下面幾點:

  1. TCP 是流傳輸協議, 是一種面向連接的、可靠的、基於字節流的傳輸層通信協議

  2. TCP 沒有包的概念,它只負責傳輸字節序列,UDP 是面向數據報的協議,所以不存在拆包粘包問題

  3. 應該由應用層來維護消息和消息的邊界,即需要一個應用層協議,比如 HTTP

所以,本質上這是一個沒有正確使用 TCP 協議的而產生的問題,有網友說了一句非常形象的話:“打開家裏的水龍頭, 看着自來水往下流, 然後你告訴我, 看, 自來水粘在一起了, 不是有病?”

  1. 如何解決粘包?

通常來說,一般有下面幾種方式:

  1. 消息長度固定,提前確定包長度,讀取的時候也安固定長度讀取,適合定長消息包。

  2. 使用特殊的字符或字符串作爲消息的邊界,例如 HTTP 協議的 headers 以 “\r\n” 爲字段的分隔符

  3. 自定義協議,將消息分爲消息頭和消息體,消息頭中包含表示消息總長度

3.Golang 實戰

首先,來看一個存在粘包問題的例子:

一、Server 端:

package main

import (
    "log"
    "net"
    "strings"
)

func main() {
    listen, err := net.Listen("tcp""127.0.0.1:8888")
    if err != nil {
        panic(err)
    }
    defer listen.Close()

    for {
        conn, err := listen.Accept()
        if err != nil {
            panic(err)
        }
        for {
            data := make([]byte, 10)

            _, err := conn.Read(data)

            if err != nil {
                log.Printf("%s\n", err.Error())
                break
            }

            receive := string(data)
            log.Printf("receive msg: %s\n", receive)

            send := []byte(strings.ToUpper(receive))
            _, err = conn.Write(send)
            if err != nil {
                log.Printf("send msg failed, error: %s\n", err.Error())
            }

            log.Printf("send msg: %s\n", receive)
        }
    }
}

簡單說一下這段代碼,有點 socket 編程的基礎的話應該很容易理解,基本上都是 Listen -> Accept -> Read 這個套路。

有些人一下子就看出來這個服務有點 “問題”,它是同步阻塞的,也就意味着這個服務同一時間只能處理一個連接請求,其實解決這個問題也很簡單,得益於 Go 協程的強大,我們只需要開啓一個協程單獨處理每一個連接就行了。不過這不是今天的主題,有興趣的童鞋可以自行研究。

二、Client 端:

這個服務的功能特別簡單,客戶端輸入什麼我就返回什麼,客戶端的話,這裏我使用 telnet 來演示:

jwang@jwang:~$ telnet 127.0.0.1 8888
Trying 127.0.0.1...
Connected to 127.0.0.1.
Escape character is '^]'.
111111
111111
123456
123456

當你按回車鍵的時候 telnet 會在消息後面自動追加”\r\n“換行符併發送消息!

從代碼裏面可以看到,在接受消息的時候我們每次讀取 10 個字節的內容輸出並返回,如果輸入的消息小於等於 8(減去換行符)個字符的時候沒有問題,但是當我們在 telnet 裏面輸入大於 10 個字符的內容的時候,這些數據的時候會被強行拆開處理。

當然這裏有人說了,可不可以一次讀多點,然而讀多少都會存在這個問題,而且 TCP 會有緩存區,不一定能夠及時把消息發出去,像 Nagle 優化算法會將多次間隔較小、數據量小的數據,合併成一個大的數據塊,然後進行封包,還是會存在問題。

如果我們把這個內容看作是一個業務消息,這個業務消息就被拆分放到下個消息裏面處理,必然會產生問題,這就是 “粘包” 問題的由來。說到底,還是用的人的問題,沒有確定好數據邊界,如果簡單粗暴的讀取固定長度的內容,必然會出現問題。

  1. 邊界符解決粘包問題

前面說過這個問題,我們可以通過定義一個邊界符號解決粘包問題,比如說在上面的例子裏面 telnet 會自動在每一條消息後面追加 “\r\n” 符號,我們恰好可以利用這點來區分消息。

  1. 定義一個 buffer 來臨時存放消息

  2. 從 conn 裏面讀取固定字節大小內容,判斷當前內容裏面有沒有分隔符

  3. 如果沒有找到分隔符,把當前內容追加到 buffer 裏面,然後重複第 2 步

  4. 如果找到分隔符,把當前內容裏面分隔符之前的內容追加到 buffer 後輸出

  5. 然後重置 buffer,把分隔符之後的內容追加到 buff,重複第 2 步

不過 Go 裏面提供了一個非常好用的 buffer 庫,爲我們節省了很多操作

我們可以使用 bufio 庫裏面的 NewReader 把 conn 包裝一下,然後使用 ReadSlice 方法讀取內容,該方法會一直讀直到遇到分隔符,非常簡單實用。

一、Server 端:

package main

import (
    "bufio"
    "fmt"
    "net"
)

func main() {
    listen, err := net.Listen("tcp""127.0.0.1:8888")
    if err != nil {
        panic(err)
    }
    defer listen.Close()

    for {
        conn, err := listen.Accept()
        if err != nil {
            panic(err)
        }
        reader := bufio.NewReader(conn)
        for {
            slice, err := reader.ReadSlice('\n')
            if err != nil {
                continue
            }
            fmt.Printf("%s", slice)
        }
    }
}

二、Client 端:

Client 這裏可以直接使用 telnet,也可以自己寫一個,代碼如下:

package main

import (
    "log"
    "net"
    "strconv"
    "testing"
    "time"
)

func Test(t *testing.T) {
    conn, err := net.Dial("tcp""127.0.0.1:8888")
    if err != nil {
        log.Println("dial error:", err)
        return
    }
    defer conn.Close()
    i := 0
    for {
        var err error
        _, err = conn.Write([]byte(strconv.Itoa(i) + " => 77777\n"))
        _, err = conn.Write([]byte(strconv.Itoa(i) + " => 88888\n"))
        _, err = conn.Write([]byte(strconv.Itoa(i) + " => 555555555555555555555555555555555555555555\n"))
        if err != nil {
            panic(err)
        }
        time.Sleep(time.Second * 1)
        _, err = conn.Write([]byte(strconv.Itoa(i) + " => 123456\n"))
        _, err = conn.Write([]byte(strconv.Itoa(i) + " => 123456\n"))
        if err != nil {
            panic(err)
        }
        time.Sleep(time.Second * 1)
        _, err = conn.Write([]byte(strconv.Itoa(i) + " => 9999999\n"))
        _, err = conn.Write([]byte(strconv.Itoa(i) + " => 0000000000000000000000000000000000000000000\n"))
        if err != nil {
            panic(err)
        }
        i++
    }
}

如果要說缺點,這種方式主要存在 2 點,第一點是分隔符的選擇問題,如果需要傳輸的消息包含分隔符,那就需要提前做轉義處理。第二點就是性能問題,如果消息體特別大,每次查找分隔符的位置的話肯定會有一點消耗。

  1. 在頭部放入信息長度

目前應用最廣泛的是在消息的頭部添加數據包長度,接收方根據消息長度進行接收;在一條 TCP 連接上,數據的流式傳輸在接收緩衝區裏是有序的,其主要的問題就是第一個包的包尾與第二個包的包頭共存接收緩衝區,所以根據長度讀取是十分合適的。

一、Server 端:

package main

import (
    "bufio"
    "bytes"
    "encoding/binary"
    "fmt"
    "net"
)

func main() {
    listen, err := net.Listen("tcp""127.0.0.1:8888")
    if err != nil {
        panic(err)
    }
    defer listen.Close()

    for {
        conn, err := listen.Accept()
        if err != nil {
            panic(err)
        }
        reader := bufio.NewReader(conn)
        for {
            //前4個字節表示數據長度
            peek, err := reader.Peek(4)
            if err != nil {
                continue
            }
            buffer := bytes.NewBuffer(peek)
            //讀取數據長度
            var length int32
            err = binary.Read(buffer, binary.BigEndian, &length)
            if err != nil {
                continue
            }
            //Buffered 返回緩存中未讀取的數據的長度,如果緩存區的數據小於總長度,則意味着數據不完整
            if int32(reader.Buffered()) < length+4 {
                continue
            }
            //從緩存區讀取大小爲數據長度的數據
            data := make([]byte, length+4)
            _, err = reader.Read(data)
            if err != nil {
                continue
            }
            fmt.Printf("receive data: %s\n", data[4:])
        }
    }
}

二、Client 端:

需要注意的是發送數據的編碼,這裏使用了 Go 的 binary 庫,先寫入 4 個字節的頭,再寫入消息主體,最後一起發送過去。

package main

import (
    "bytes"
    "encoding/binary"
    "fmt"
    "log"
    "net"
    "testing"
    "time"
)

func Test(t *testing.T) {
    conn, err := net.Dial("tcp""127.0.0.1:8888")
    if err != nil {
        log.Println("dial error:", err)
        return
    }
    defer conn.Close()
    for {
        data, _ := Encode("123456789")
        _, err := conn.Write(data)
        data, _ = Encode("888888888")
        _, err = conn.Write(data)
        time.Sleep(time.Second * 1)
        data, _ = Encode("777777777")
        _, err = conn.Write(data)
        data, _ = Encode("123456789")
        _, err = conn.Write(data)
        time.Sleep(time.Second * 1)
        fmt.Println(err)
    }
}
func Encode(message string) ([]byte, error) {
    // 讀取消息的長度
    var length = int32(len(message))
    var pkg = new(bytes.Buffer)
    // 寫入消息頭
    err := binary.Write(pkg, binary.BigEndian, length)
    if err != nil {
        return nil, err
    }
    // 寫入消息實體
    err = binary.Write(pkg, binary.BigEndian, []byte(message))
    if err != nil {
        return nil, err
    }
    return pkg.Bytes(), nil
}
  1. 總結

世界上本沒有 “粘包”,只不過是少數人沒有正確處理 TCP 數據邊界問題,成熟的應用層協議(http、ssh)都不會存在這個問題。但是如果你使用純 TCP 自定義協議,那就需要自己處理好了。

轉自:

wangbjun.site/2019/coding/golang/golang-tcp-package.html

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/5iJxcjeUh0eeNxW0NKK6mg