golang 解決 TCP 粘包問題

什麼是 TCP 粘包問題以及爲什麼會產生 TCP 粘包,本文不加討論。本文使用 golang 的 bufio.Scanner 來實現自定義協議解包。

協議數據包定義

本文模擬一個日誌服務器,該服務器接收客戶端傳到的數據包並顯示出來

type Package struct {
 Version        [2]byte // 協議版本,暫定V1
 Length         int16   // 數據部分長度
 Timestamp      int64   // 時間戳
 HostnameLength int16   // 主機名長度
 Hostname       []byte  // 主機名
 TagLength      int16   // 標籤長度
 Tag            []byte  // 標籤
 Msg            []byte  // 日誌數據
}

協議定義部分沒有什麼好講的,根據具體的業務邏輯定義即可。

數據打包

由於 TCP 協議是語言無關的協議,所以直接把協議數據包結構體發送到 TCP 連接中也是不可能的,只能發送字節流數據,所以需要自己實現數據編碼。所幸 golang 提供了 binary 來幫助我們實現網絡字節編碼。

func (p *Package) Pack(writer io.Writer) error {
 var err error
 err = binary.Write(writer, binary.BigEndian, &p.Version)
 err = binary.Write(writer, binary.BigEndian, &p.Length)
 err = binary.Write(writer, binary.BigEndian, &p.Timestamp)
 err = binary.Write(writer, binary.BigEndian, &p.HostnameLength)
 err = binary.Write(writer, binary.BigEndian, &p.Hostname)
 err = binary.Write(writer, binary.BigEndian, &p.TagLength)
 err = binary.Write(writer, binary.BigEndian, &p.Tag)
 err = binary.Write(writer, binary.BigEndian, &p.Msg)
 return err
}

Pack 方法的輸出目標爲 io.Writer,有利於接口擴展,只要實現了該接口即可編碼數據寫入。binary.BigEndian 是字節序,本文暫時不討論,有需要的讀者可以自行查找資料研究。

數據解包

解包需要將 TCP 數據包解析到結構體中,接下來會講爲什麼需要添加幾個數據無關的長度字段。

func (p *Package) Unpack(reader io.Reader) error {
 var err error
 err = binary.Read(reader, binary.BigEndian, &p.Version)
 err = binary.Read(reader, binary.BigEndian, &p.Length)
 err = binary.Read(reader, binary.BigEndian, &p.Timestamp)
 err = binary.Read(reader, binary.BigEndian, &p.HostnameLength)
 p.Hostname = make([]byte, p.HostnameLength)
 err = binary.Read(reader, binary.BigEndian, &p.Hostname)
 err = binary.Read(reader, binary.BigEndian, &p.TagLength)
 p.Tag = make([]byte, p.TagLength)
 err = binary.Read(reader, binary.BigEndian, &p.Tag)
 p.Msg = make([]byte, p.Length-8-2-p.HostnameLength-2-p.TagLength)
 err = binary.Read(reader, binary.BigEndian, &p.Msg)
 return err
}

由於主機名、標籤這種數據是不固定長度的,所以需要兩個字節來標識數據長度,否則讀取的時候只知道一個總的數據長度是無法區分主機名、標籤名、日誌數據的。

數據包的粘包問題解決

上文只是解決了編碼/解碼問題,前提是收到的數據包沒有產生粘包問題,解決粘包就是要正確分割字節流中的數據。一般有以下做法:

  1. 定長分隔(每個數據包最大爲該長度) 缺點是數據不足時會浪費傳輸資源
  2. 特定字符分隔(如\r\n) 缺點是如果正文中有\r\n就會導致問題
  3. 在數據包中添加長度字段(本文采用的)

golang 提供了 bufio.Scanner 來解決粘包問題。

scanner := bufio.NewScanner(reader) // reader爲實現了io.Reader接口的對象,如net.Conn
scanner.Split(func(data []byte, atEOF bool) (advance int, token []byte, err error) {
 if !atEOF && data[0] == 'V' { // 由於我們定義的數據包頭最開始爲兩個字節的版本號,所以只有以V開頭的數據包才處理
   if len(data) > 4 { // 如果收到的數據>4個字節(2字節版本號+2字節數據包長度)
     length := int16(0)
     binary.Read(bytes.NewReader(data[2:4]), binary.BigEndian, &length) // 讀取數據包第3-4字節(int16)=>數據部分長度
     if int(length)+4 <= len(data) { // 如果讀取到的數據正文長度+2字節版本號+2字節數據長度不超過讀到的數據(實際上就是成功完整的解析出了一個包)
       return int(length) + 4, data[:int(length)+4], nil
     }
   }
 }
 return
})
// 打印接收到的數據包
for scanner.Scan() {
 scannedPack := new(Package)
 scannedPack.Unpack(bytes.NewReader(scanner.Bytes()))
 log.Println(scannedPack)
}

本文的核心就在於 scanner.Split 方法,該方法用來解析 TCP 數據包

完整源碼

package main
import (
 "bufio"
 "bytes"
 "encoding/binary"
 "fmt"
 "io"
 "log"
 "os"
 "time"
)
type Package struct {
 Version        [2]byte // 協議版本
 Length         int16   // 數據部分長度
 Timestamp      int64   // 時間戳
 HostnameLength int16   // 主機名長度
 Hostname       []byte  // 主機名
 TagLength      int16   // Tag長度
 Tag            []byte  // Tag
 Msg            []byte  // 數據部分長度
}
func (p *Package) Pack(writer io.Writer) error {
 var err error
 err = binary.Write(writer, binary.BigEndian, &p.Version)
 err = binary.Write(writer, binary.BigEndian, &p.Length)
 err = binary.Write(writer, binary.BigEndian, &p.Timestamp)
 err = binary.Write(writer, binary.BigEndian, &p.HostnameLength)
 err = binary.Write(writer, binary.BigEndian, &p.Hostname)
 err = binary.Write(writer, binary.BigEndian, &p.TagLength)
 err = binary.Write(writer, binary.BigEndian, &p.Tag)
 err = binary.Write(writer, binary.BigEndian, &p.Msg)
 return err
}
func (p *Package) Unpack(reader io.Reader) error {
 var err error
 err = binary.Read(reader, binary.BigEndian, &p.Version)
 err = binary.Read(reader, binary.BigEndian, &p.Length)
 err = binary.Read(reader, binary.BigEndian, &p.Timestamp)
 err = binary.Read(reader, binary.BigEndian, &p.HostnameLength)
 p.Hostname = make([]byte, p.HostnameLength)
 err = binary.Read(reader, binary.BigEndian, &p.Hostname)
 err = binary.Read(reader, binary.BigEndian, &p.TagLength)
 p.Tag = make([]byte, p.TagLength)
 err = binary.Read(reader, binary.BigEndian, &p.Tag)
 p.Msg = make([]byte, p.Length-8-2-p.HostnameLength-2-p.TagLength)
 err = binary.Read(reader, binary.BigEndian, &p.Msg)
 return err
}
func (p *Package) String() string {
 return fmt.Sprintf("version:%s length:%d timestamp:%d hostname:%s tag:%s msg:%s",
   p.Version,
   p.Length,
   p.Timestamp,
   p.Hostname,
   p.Tag,
   p.Msg,
 )
}
func main() {
 hostname, err := os.Hostname()
 if err != nil {
   log.Fatal(err)
 }
 pack := &Package{
   Version:        [2]byte{'V', '1'},
   Timestamp:      time.Now().Unix(),
   HostnameLength: int16(len(hostname)),
   Hostname:       []byte(hostname),
   TagLength:      4,
   Tag:            []byte("demo"),
   Msg:            []byte(("現在時間是:" + time.Now().Format("2006-01-02 15:04:05"))),
 }
 pack.Length = 8 + 2 + pack.HostnameLength + 2 + pack.TagLength + int16(len(pack.Msg))
 buf := new(bytes.Buffer)
 // 寫入四次,模擬TCP粘包效果
 pack.Pack(buf)
 pack.Pack(buf)
 pack.Pack(buf)
 pack.Pack(buf)
 // scanner
 scanner := bufio.NewScanner(buf)
 scanner.Split(func(data []byte, atEOF bool) (advance int, token []byte, err error) {
   if !atEOF && data[0] == 'V' {
     if len(data) > 4 {
       length := int16(0)
       binary.Read(bytes.NewReader(data[2:4]), binary.BigEndian, &length)
       if int(length)+4 <= len(data) {
         return int(length) + 4, data[:int(length)+4], nil
       }
     }
   }
   return
 })
 for scanner.Scan() {
   scannedPack := new(Package)
   scannedPack.Unpack(bytes.NewReader(scanner.Bytes()))
   log.Println(scannedPack)
 }
 if err := scanner.Err(); err != nil {
   log.Fatal("無效數據包")
 }
}

寫在最後

golang作爲一門強大的網絡編程語言,實現自定義協議是非常重要的,實際上實現自定義協議也不是很難,以下幾個步驟:

  1. 數據包編碼
  2. 數據包解碼
  3. 處理TCP粘包問題
  4. 斷線重連(可以使用心跳實現)(非必須)

轉載|出處:http://t.cn/REC9vb3

技術交流羣:

Golang 技術交流羣 426582602
Python 交流羣 365534424 / 238757010

Golang 公開課(3.9日晚九點)

主題:快速構建 web 應用與 beego 原理

目錄

  • net / http
  • Beego
  • 簡單應用

主講師:蘿蔔

曾任職百度,多年cpp 和 go語言開發經驗,從事自動化運維和基礎架構相關工作。對分佈式系統和高併發有着豐富的經驗。主導過多個大型go語言項目,如部署系統、發佈系統、會員賬號體系等。親身見證了團隊go語言的發展壯大。
參與方式加小助手小單微信(1251743084)進入直播分享羣。

Python 公開課(3.8晚九點)

主題:Python3 從青銅到王者——scrapy 爬蟲實戰

目錄

  • 爬蟲是什麼
  • requests + pyquery 做簡單爬蟲
  • scrapy 是什麼
  • scrapy 核心概念
  • 實戰抓取

主講師:蝸牛

前百度高級工程師,Python 老司機。非典型程序員,畢業之後接觸了編程,從零基礎成長至 BAT 高級工程師,爲部門從零組建開發團隊,帶領完成多個大、中型項目,項目開發經驗豐富並且深 悉初學者學編程的方法,爲人熱情,樂於分析,現任 51Reboot.com 的 Python 課程總監。
參與方式加小助手小月微信(1902433859 )進入直播分享羣。

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