TCP粘包切割处理

目录

1背景

2 导致的问题

3 解决方式

4 选择编码方式

4.1 选择方式

4.2 过程分析

5 具体实现

5.1定义协议

5.2 编码处理

6 总结

7 参考


1背景

数据以二进制发送,在服务端处理收发会出现异常,主要是一数据传输被分割,二数据被缓存。数据传输被分割体现两方面,一是滑动窗口影响数据收发能力,发送和接收方会动态调整;二是当传输大于MSS和MTU数据时需要数据分片。数据缓存,主要是收发方存在数据缓冲区(TCP层面),批量发送和确认,提升效率。

2 导致的问题

书写两个实例文件来运行查看见下。

服务端:server/server.go


package main

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

func main() {
	listen, _ := net.Listen("tcp", "127.0.0.1:9000")
	defer listen.Close()
	fmt.Println("start listen 9000")
	for {
		conn, err := listen.Accept()
		if err != nil {
			continue
		}
		go handler(conn)
	}
}

func handler(conn net.Conn) {
	r := bufio.NewReader(conn)
	buffer := make([]byte, 1024)
	for  {
		_, err := r.Read(buffer)
		if err == io.EOF {
			continue
		}
		fmt.Println(string(buffer))
	}
}

客户端:client/client.go

package main

import (
	"fmt"
	"math/rand"
	"net"
	"strings"
	"time"
)

func main() {
	conn, err:=     net.Dial("tcp", "127.0.0.1:9000")
	defer conn.Close()
	if err != nil {
		fmt.Println(err)
		return
	}
	rand.Seed(time.Now().UnixNano())
	for i := 0; i < 1000; i++{
		data := fmt.Sprintf("[这是世界的一部分是不是]:%d", i)
		conn.Write([]byte(data))
	}
}

运行结果:

 打印结果是又两个特点,1数据部分是连在一起的,2一些会出现乱码(这是被缓冲截断导致,当然不包括tcp层面数据截断)见server/server.go

buffer := make([]byte, 1024)

我们期望的是打印这种发一条解析得到一条。

3 解决方式

要解决问题需要对发送消息编码,接收方解码。

常规编码有三种方式:

定长(fix length):发送方以某种固定字节长度发送,接收方以固定长度解析数据,但这接收方总会有粘包,发送方不友好某条大消息需要手动拆分多条发送,不足的需要补足长度,接收方需要处理补足协议。

特殊限制符(delimiter based):发送方以\r\n(或其他特殊界定符)作为定界符发送数据,接收方获取后以\r\n(或其他特殊界定符)作为分隔符解析数据。

长度编码(length field based frame decoder):发送方在每次发送时加上包长度,接收方获取后按照包长度读取解析数据,这种规范体现比较集中。

4 选择编码方式

4.1 选择方式

以下按照长度编码处理。

4.2 过程分析

缓冲区大小固定,数据包在业务层读取到有5种情况,中间部分视为接收方的缓冲区,不同数据长度数据情况不同。 

为了研究在客户端做两次试验,找出其规律。

实验一:发少于1024字节数据

为方便观查,这里只发一次,计算得到39字节,这里先忽略具体Encode,它作用就是在data上增加一个头信息16字节。

	for i := 0; i < 1; i++{
		data := fmt.Sprintf("[这是世界的一部分是不是]:%d", i)
		fmt.Println("总长度:",len(data))
		conn.Write(p.Encode(data))
	}

服务端收到如下,这里也先忽略具体具体实现,注重打印内容。

    buffer := make([]byte, 1024)
    for  {
		time.Sleep(time.Second)
		n, err := r.Read(buffer)
		fmt.Println(n, err)
		if err == io.EOF {
			continue
		}
    }

打印出来55,恰好包头(16字节)和包体(39字节)总字节长度之和,且由于1024是应用缓冲区大小,因此err第一次返回为nil,第二次返回EOF。

 实验二:发超过1024字节的数据,总共1110字节。

客户端代码:

for i := 0; i < 20; i++{
		data := fmt.Sprintf("[这是世界的一部分是不是]:%d", i)
		fmt.Println("总长度:",len(data))
		conn.Write(p.Encode(data))
	}

 服务端代码不变(同实验一),结果见下,第一次打印读取到的1024字节,第二次打印读取到的86字节。

应用端每次会读满1024字节,后面的86字节会放在后续读取。

打印服务端结果如下,在实验一基础上增加打印行fmt.Println(string(buffer))。

for  {
		time.Sleep(time.Second)
		n, err := r.Read(buffer)
		fmt.Println(n, err)
		if err == io.EOF {
			continue
		}
		fmt.Println(string(buffer))
}

可以看出打印出前1024字节,数据被截断了且数据有粘粘乱码,且有重复的。

于是问题变为怎样从一次读取中区分割出数据包(客户端每次编码Write操作算是一次数据包)。

下图反应这这个情况,虚线框是1024缓冲区,包1-包10是每次Write的数据,实线框代表所有数据。第一次读取截取了包7的部分字节序列,第二次读才读完剩下的内容。实际上每次读取会从前到后覆盖字节序列,能覆盖多少是多少(这也是上面提到后面内容重复问题)。

具体解决方式,未完待续。。。

---------------------------------------------------------------------------------------------------------------------------------

5 具体实现

5.1定义协议

每次发送数据时,我们会定义一个固定包头长度,这里我们默认包头长度为16字节,包含如下信息,目前我们只用到它前4个字节,其余的在工程管理服务封装时有用。

4bytes PacketLen 包长度,在数据流传输过程中,先写入整个包的长度,方便整个包的数据读取。
2bytes HeaderLen 头长度,在处理数据时,会先解析头部,可以知道具体业务操作。
2bytes VersionLen 协议版本号,主要用于上行和下行数据包按版本号进行解析。
4bytes OperationLen 业务操作码,可以按操作码进行分发数据包到具体业务当中。
4bytes SequenceLen 序列号,数据包的唯一标记,可以做具体业务处理,或者数据包去重。

5.2 编码处理

具体实现方式有多种,一次性处理,或者固定空间每次处理。采用一次性处理是把读取到的数据放在一个byte数组中,最后一次返回,但每次需要申请新空间,数据聚合占据大量内存空间,产生内存碎片严重,时效性也不好。固定缓存空间则空间固定(不用扩容),每次需要移动不足一个数据包的数据,编码复杂点,这里我采用固定缓存空间方式。这里处理需要分几步。1 从socket层读取数据;2 分割数据并处理。

1从socket层读取数据,因此代码中缓冲区的数据是小于等于1024。

n, err := r.Read(buffer)

2 分割数据并处理,对读取的数据分割,处理分割完整的数据包(可以打印,也可以放在管道里传出去),把剩余不完整的包移动到缓冲区头部,再处理。下图是一次包切割流程。

/** 获取包头信息 **/

// 定义截取的开始位置
start := 0
// 定义头长度
headerLen := 16
// 定义包字节开始位置
packageOffset := 0

// 定义头字节开始位置
headerOffset := packageOffset + 4

// 获取头数据
headerData := buffer[start:start+headerLen]
// 获取包长度大小
packageLen := binary.BigEndian.Uint32(headerData[packageOffset:headerOffset])
// 获取包体长度大小
bodyLen := packageLen - headerLen

/** 切割包体 **/
hs := start+headerLen
bs := hs + int(bodyLen)
body := buffer[hs:bs]

// 处理包体,这里打印
fmt.Println(string(body))

// 移动到下次切割位置(需要跳过packageLen)
start += packageLen

需要循环切割,循环多少次取决,最后的切割是否包含一个完整的包,不是则跳出循环,并把剩余不完整数据移动到最前面。注意这里采用的是大端处理。

1 踢出初始化和常量定义。

2 引入包头信息是否完整的判定(包头信息不完整,包体无法计算),不完整直接跳出循环。

3 包体是否完整,不完整直接跳出循环。

// 定义截取的开始位置
start := 0
// 定义头长度
headerLen := 16
// 定义包字节开始位置
packageOffset := 0
// 定义头字节开始位置
headerOffset := packageOffset + 4
// 读取的结束位置,来自read返回的n值
end := start + n 

for {
    // 没有完整的包头信息
    if end - start < headerLen {
        break
    }
    /** 获取包头信息 **/
    
    // 获取头数据
    headerData := buffer[start:start+headerLen]
    // 获取包长度大小
    packageLen := binary.BigEndian.Uint32(headerData[packageOffset:headerOffset])
    
    // 没有完整的包体信息
    if end - start < int(packageLen) {
        break
    }

    // 获取包体长度大小
    bodyLen := packageLen - headerLen

    /** 切割包体 **/
    bso := start + headerLen
    beo := bso + bodyLen
    body := buffer[bso:beo]

    // 处理包体,这里打印
    fmt.Println(string(body))

    // 移动到下次切割位置(需要跳过packageLen)
    start += packageLen
}
// end > start 意味着截断的数据处理
if end > start {
    // 移动数据
    copy(buffer,buffer[start:end])
    end = end - start
}

最后完整文件如下:

创建协议处理proto/proto.go文件(因为有些数据是常量把它进一步提取出来)。

package proto

import "encoding/binary"
const (
	// 包长度
	PackageLen = 4
	// 头长度
	HeaderLen = 2
	// 协议版本号
	VersionLen = 2
	// 业务操作码
	OperationLen = 4
	// 序列号
	SequenceLen = 4

	// 总长度
	RawHeaderLen = PackageLen + HeaderLen + VersionLen + OperationLen + SequenceLen

	// 偏移位置
	PacketOffset = 0
	HeaderOffset = PacketOffset + PackageLen
	VersionOffset = HeaderOffset + HeaderLen
	OperationOffset = VersionOffset + VersionLen
	SequenceOffset = OperationOffset + OperationLen

	// 最大读缓冲区
	MaxReadBufferSize = 1 << 10
)
type proto struct {
	version int
	operation int
	sequence int
}

func New(version int) *proto {
	return &proto{
		version: version,
	}
}

// Encode编码
func (p *proto)Encode(data string) []byte{
	packageSize := len(data) + RawHeaderLen
	tmp := make([]byte, packageSize)
	// 封装头信息
	binary.BigEndian.PutUint32(tmp[0:4], uint32(packageSize))
	binary.BigEndian.PutUint16(tmp[4:6], uint16(RawHeaderLen))
	binary.BigEndian.PutUint16(tmp[6:8], uint16(p.version))
	binary.BigEndian.PutUint32(tmp[8:12], uint32(p.operation))
	binary.BigEndian.PutUint32(tmp[12:16], uint32(p.sequence))
	copy(tmp[16:], data)
	return tmp
}

// 解码
func (p *proto)Decode()  {

}

服务端文件server/server.go见下。

package main

import (
	"bufio"
	"encoding/binary"
	"fmt"
	"gocamp/test/pkg/proto"
	"io"
	"net"
)

func main() {
	listen, _ := net.Listen("tcp", "127.0.0.1:9000")
	defer listen.Close()
	fmt.Println("start listen 9000")
	for {
		conn, err := listen.Accept()
		if err != nil {
			continue
		}
		go handler(conn)
	}
}

func handler(conn net.Conn) {
	r := bufio.NewReader(conn)
	buffer := make([]byte, proto.MaxReadBufferSize)
	// 定义截取的开始位置
	start := 0
	// 定义一次Read数据结束位置
	end := 0
	// 定义头长度
	headerLen := proto.RawHeaderLen
	// 定义包字节开始位置
	packageOffset := proto.PacketOffset
	// 定义头字节开始位置
	headerOffset := proto.HeaderOffset
	for  {
		// 读取数据
		n, err := r.Read(buffer[end:])
		if n == 0 && err == io.EOF {
			return
		}
		if err == io.EOF {
			continue
		} else if err != nil {
			return
		}
		// 读取的结束位置,来自read返回的n值
		end += n
		// read函数数据读取位置
		for {
			// 没有完整的包头信息
			if end - start < headerLen {
				break
			}
			/** 获取包头信息 **/
			// 获取头数据
			headerData := buffer[start:start+headerLen]
			// 获取包长度大小
			packageLen := int(binary.BigEndian.Uint32(headerData[packageOffset:headerOffset]))
			// 没有完整的包体信息
			if end - start < packageLen {
				// 判定包体大于1024
				if packageLen > proto.MaxReadBufferSize {
					fmt.Printf("one message is too large:%d", proto.MaxReadBufferSize)
					return
				}
				break
			}
			/** 获取包体 **/
			// 获取包体长度大小
			bodyLen := packageLen - headerLen
			/** 切割包体 **/
			bso := start + headerLen
			beo := bso + bodyLen
			body := buffer[bso:beo]

			// 处理包体,这里打印
			fmt.Println(len(body), string(body))

			// 移动到下次切割位置(需要跳过packageLen)
			start += packageLen
		}
		//fmt.Println(end, start)
		// end > start 意味着截断的数据处理
		if end >= start {
			// 移动数据
			copy(buffer,buffer[start:end])
			end = end - start
		}
		start = 0
	}
}

客户端代码:

package main

import (
	"fmt"
	"gocamp/test/pkg/proto"
	"math/rand"
	"net"
	"time"
)

func main() {
	conn, err:=     net.Dial("tcp", "127.0.0.1:9000")
	defer conn.Close()
	if err != nil {
		fmt.Println(err)
		return
	}
	rand.Seed(time.Now().UnixNano())
	p := proto.New(1)
	for i := 0; i < 20; i++{
		data := fmt.Sprintf("[这是世界的一部分是不是]:%d", i)
		fmt.Println("总长度:",len(data))
		conn.Write(p.Encode(data))
	}
}

结果:

6 总结

采用包头协议方式处理粘包问题注意要点,1 协议定义;2 编码大小端问题;3 每次读取数据判定条件,即判定包头信息完整,判定包体信息完整;4移动数据位置。

7 参考

书籍:《TCP/IP详解》

博文:

一文带你搞定TCP滑动窗口

【协议森林】详解TCP之滑动窗口

参见:阿里Java一面:熟悉TCP粘包、拆包?说说粘包、拆包产生原因 - 知乎

go语言下tcp粘包分包的简单处理 - SegmentFault 思否

go语言下tcp粘包分包的简单处理 - SegmentFault 思否

golang Endian字节序 - johnhjwsosd的个人空间 - OSCHINA - 中文开源技术交流社区

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值