目录
1背景
2 导致的问题
3 解决方式
4 选择编码方式
4.1 选择方式
4.2 过程分析
5 具体实现
5.1定义协议
5.2 编码处理
6 总结
7 参考
数据以二进制发送,在服务端处理收发会出现异常,主要是一数据传输被分割,二数据被缓存。数据传输被分割体现两方面,一是滑动窗口影响数据收发能力,发送和接收方会动态调整;二是当传输大于MSS和MTU数据时需要数据分片。数据缓存,主要是收发方存在数据缓冲区(TCP层面),批量发送和确认,提升效率。
书写两个实例文件来运行查看见下。
服务端: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)
我们期望的是打印这种发一条解析得到一条。
要解决问题需要对发送消息编码,接收方解码。
常规编码有三种方式:
定长(fix length):发送方以某种固定字节长度发送,接收方以固定长度解析数据,但这接收方总会有粘包,发送方不友好某条大消息需要手动拆分多条发送,不足的需要补足长度,接收方需要处理补足协议。
特殊限制符(delimiter based):发送方以\r\n(或其他特殊界定符)作为定界符发送数据,接收方获取后以\r\n(或其他特殊界定符)作为分隔符解析数据。
长度编码(length field based frame decoder):发送方在每次发送时加上包长度,接收方获取后按照包长度读取解析数据,这种规范体现比较集中。
以下按照长度编码处理。
缓冲区大小固定,数据包在业务层读取到有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的部分字节序列,第二次读才读完剩下的内容。实际上每次读取会从前到后覆盖字节序列,能覆盖多少是多少(这也是上面提到后面内容重复问题)。
具体解决方式,未完待续。。。
---------------------------------------------------------------------------------------------------------------------------------
每次发送数据时,我们会定义一个固定包头长度,这里我们默认包头长度为16字节,包含如下信息,目前我们只用到它前4个字节,其余的在工程管理服务封装时有用。
4bytes PacketLen 包长度,在数据流传输过程中,先写入整个包的长度,方便整个包的数据读取。
2bytes HeaderLen 头长度,在处理数据时,会先解析头部,可以知道具体业务操作。
2bytes VersionLen 协议版本号,主要用于上行和下行数据包按版本号进行解析。
4bytes OperationLen 业务操作码,可以按操作码进行分发数据包到具体业务当中。
4bytes SequenceLen 序列号,数据包的唯一标记,可以做具体业务处理,或者数据包去重。
具体实现方式有多种,一次性处理,或者固定空间每次处理。采用一次性处理是把读取到的数据放在一个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))
}
}
结果:
采用包头协议方式处理粘包问题注意要点,1 协议定义;2 编码大小端问题;3 每次读取数据判定条件,即判定包头信息完整,判定包体信息完整;4移动数据位置。
书籍:《TCP/IP详解》
博文:
一文带你搞定TCP滑动窗口
【协议森林】详解TCP之滑动窗口
参见:阿里Java一面:熟悉TCP粘包、拆包?说说粘包、拆包产生原因 - 知乎
go语言下tcp粘包分包的简单处理 - SegmentFault 思否
go语言下tcp粘包分包的简单处理 - SegmentFault 思否
golang Endian字节序 - johnhjwsosd的个人空间 - OSCHINA - 中文开源技术交流社区