TCP 粘包拆包的 Golang 实现
Nov 12, 2020 20:45 · 1560 words · 4 minute read
基于 TCP 的应用层协议粘包问题
当应用层使基于 TCP 协议传输数据时,由于 TCP 协议的特性(字节流),可能会将应用层发送的数据拆成多个 TCP 包依次发送,所以应用层从 TCP 缓存中读取的数据,并不一定刚好是完整的应用层封包,很有可能是粘连在一起的一块数据,这时就需要将收到的数据拆分成完整的应用层封包。
Nagle 算法
这是一种通过减少小包发送数量来提高 TCP 带宽利用率的算法,防止因为应用程序将数据递交至套接字的速度缓慢而导致节点传出大量小包。如果某个进程导致传输很多小包,可能会造成不必要的网络拥塞。当有效数据量小于 TCP 包头的数据,就是典型的小包。
如果发送方通过开启 TCP_NODELAY 使用 Nagle 算法来提高网络传输的效率,但是接收方不知道发送方合并了数据包。如果被合并的数据包中没有分界,会导致接收方无法恢复出原数据包。
而且 TCP 是基于“流”的,如果网络传输速度比接收方处理数据的速度还快,接收方从缓冲区中取数据时,缓冲区内有堆积下来的数据包。在 TCP 协议中,接收方将一次性读取缓冲区里的所有数据。如果没有分界,也会导致接收方无法恢复出原数据包。
基于 TCP 的应用层协议设计
所以,既然要基于 TCP 协议来构建自己的应用层协议,就必须要自己定义消息的边界,无论 TCP 协议如何对应用层协议的数据包进程拆分和重组,接收方都能根据协议的规则恢复对应的消息。
最常见的两种解决方案就是基于长度或者终结符。
本文主要讨论基于长度的方案。
基于长度的实现有两种方式,一种是定死长度,所有数据都使用统一的大小,虽然对解析方友好,但是很不灵活;另一种是使用数据帧中的某几个字节来表示负载长度。
实现
我们用 Golang 实现基于 TCP 协议的私有应用层协议的拆包:
func main() {
l, err := net.Listen("tcp", ":8972")
if err != nil {
panic(err)
}
for {
conn, err := l.Accept()
if err != nil {
if ne, ok := err.(net.Error); ok && ne.Temporary() {
log.Printf("accept temp err: %v", ne)
continue
}
log.Printf("accept err: %v", err)
return
}
go handleConn(conn) // TODO
}
}
以上的 TCP 服务器运行时将监听在 9872 端口,我们利用 goroutine 来并发处理每一条建立的连接。
func handleConn(conn net.Conn) {
defer conn.Close()
fmt.Println("new conn from:", conn.RemoteAddr())
localBuf := new(bytes.Buffer)
readBuf := make([]byte, 1024)
for {
n, err := conn.Read(readBuf)
if err != nil {
if err == io.EOF {
fmt.Println("connection closed by client!")
break
}
}
localBuf.Write(readBuf[:n])
// TODO
localBuf.Reset()
}
}
这个协程将从 TCP 连接读取字节流至 readBuf
切片,使用 bytes 包的 Buffer
结构来转存读到的数据会方便一些,我们可以直接使用封装好的方法而不用自己来处理切片了。
要注意的是如果 socket 中的数据量比
readBuf
容量大的话,那么本次循环最多只能读取 1024 字节,剩下的数据得到下一次循环再来读取了。
下面就要来处理数据的拆包了。
Golang 的 bufio 包中提供了 Scanner
来解决这类数据切割的问题:
Scanner provides a convenient interface for reading data such as a file of newline-delimited lines of text. Successive calls to the Scan method will step through the ’tokens’ of a file, skipping the bytes between the tokens. The specification of a token is defined by a split function of type SplitFunc; the default split function breaks the input into lines with line termination stripped. Split functions are defined in this package for scanning a file into lines, bytes, UTF-8-encoded runes, and space-delimited words. The client may instead provide a custom split function.
Scanning stops unrecoverably at EOF, the first I/O error, or a token too large to fit in the buffer. When a scan stops, the reader may have advanced arbitrarily far past the last token. Programs that need more control over error handling or large tokens, or must run sequential scans on a reader, should use bufio.Reader instead.
我们只需要自己实现 SplitFunc
方法即可。
假设基于 TCP 的应用层协议是这样的:
起始位 | 包长度 | 负载数据 |
---|---|---|
2 字节 (0x12 0x34) | 2 字节 | N 字节 |
- 为避免包长和数据中出现用于标记起始位的特定数字,我们通常要定义 2 字节以上的起始位
- 网络传输一般使用大端字节序,所以包长度高位字节在前低位字节在后
func packetSplitFunc(data []byte, atEOF bool) (advance int, token []byte, err error) {
if !atEOF && len(data) > 4 && binary.BigEndian.Uint16(data[:2]) == 0x1234 {
var l int16
binary.Read(bytes.NewReader(data[2:4]), binary.BigEndian, &l)
dataLen := int(l)
if dataLen <= len(data) {
return dataLen, data[:dataLen], nil
}
}
return
}
因为 2 字节的起始位和 2 字节的包长度位存在,单帧数据一定超过 4 字节,而且数据帧以 0x12 和 0x34 为头。随后我们将(包长度)个字节从数据中切割出来。
func handleConn(conn net.Conn) {
defer conn.Close()
fmt.Println("new conn from:", conn.RemoteAddr())
localBuf := new(bytes.Buffer)
readBuf := make([]byte, 1024)
for {
n, err := conn.Read(readBuf)
if err != nil {
if err == io.EOF {
fmt.Println("connection closed by client!")
break
}
}
localBuf.Write(readBuf[:n])
scanner := bufio.NewScanner(localBuf)
scanner.Split(packetSplitFunc) // 自定义的 SplitFunc
for scanner.Scan() {
fmt.Println("recv:", scanner.Bytes())
}
localBuf.Reset()
}
}
这样就简单实现了对应用层协议的拆包。
再给个用于测试的客户端实现:
func main() {
data := []byte{0x12, 0x34, 0x00, 0x07, 0x11, 0x22, 0x33}
buf := bytes.NewBuffer(data)
conn, err := net.DialTimeout("tcp", "localhost:8972", time.Second*30)
if err != nil {
panic(err)
}
defer conn.Close()
_, err = conn.Write(buf.Bytes()[:5])
if err != nil {
log.Fatal(err)
}
_, err = conn.Write(buf.Bytes()[61:])
if err != nil {
log.Fatal(err)
}
}
总结
- TCP 是基于字节流的传输层协议,只有到了上层才有应用协议数据帧的概念
- 应用层协议一定要有消息边界,否则将导致应用程序无法切割粘包