网络编程经验总结 TCP拆包粘包常见解决方案

从简单通信协议开始

最近工作中又需要处理协议解析,我对协议解析和网络抓包其实还是小有研究,17年刚毕业的时候,就用Netty手写过SMPP协议的对接。(其实做协议解析是一个很枯燥的工作,如果协议解析可以像antlr那样子写grammar自动解析应该会很酷?)本文总结一下协议在tcp下编码拆包粘包的三种解决方案。

网上有一些人对拆包粘包的说法不是很认可,但是我觉得这个术语还是挺形象的。

首先,让我们来设计一个简单地通信协议,Sorry,客户端一直对服务器发送I am Sorry,服务端回复That's ok。如下图所示

image-20210704104926698

让我们来写个demo程序实现这个协议

服务端

package main

import (
    "fmt"
    "net"
)

func main() {
    listen, err := net.Listen("tcp", "localhost:1997")
    if err != nil {
        panic(err)
    }
    defer listen.Close()
    for {
        conn, err := listen.Accept()
        if err != nil {
            panic(err)
        }
        go handleRequest(conn)
    }
}

// handle incoming requests
func handleRequest(conn net.Conn) {
    // make a buffer to hold incoming data
    buf := make([]byte, 1024)
    // Read the incoming connection into the buffer
    reqLen, err := conn.Read(buf)
    if err != nil {
        fmt.Println("error reading: ", err.Error())
    }
    if reqLen != 10 {
        fmt.Println("invalid request size ", reqLen)
    }
    _, err = conn.Write([]byte("That's ok"))
    if err != nil {
        fmt.Println("error sending: ", err.Error())
    }
}

客户端

use std::io::{Read, Write};
use std::net::TcpStream;
use std::str::from_utf8;

fn main() {
    match TcpStream::connect("localhost:1997") {
        Ok(mut stream) => {
            println!("success connect to 1997");
            let msg = b"I am Sorry";
            let expect_resp = b"That's ok";
            stream.write(msg);
            println!("Send hello, awaiting reply");
            // use 9 byte buffer
            let mut data = [0 as u8; 9];
            match stream.read_exact(&mut data) {
                Ok(_) => {
                    if &data == expect_resp {
                        println!("Reply is ok")
                    } else {
                        let text = from_utf8(&data).unwrap();
                        println!("Unexpected reply: {}", text);
                    }
                },
                Err(e) => {
                    println!("Failed to receive data: {}", e);
                }
            }
        }
        Err(e) => {
            println!("Failed to connect: {}", e)
        }
    }

}

注意上面在服务端的实现中,我们校验了请求体的大小。

运行成功,我们在Wireshark上可以看到

image-20210704115955993

目标端口为1997,这是客户端发出的报文。当然也能看到响应的报文

image-20210704120027704

那么,如果客户端是个十分礼貌的人,他如果连续发送10个I am Sorry呢?我们将代码修改为

            for _ in 0..10 {
                stream.write(msg);
            }

服务端报错了,服务端收到了一个请求,大小为100。并不是新手预期的10个大小为10的消息,

image-20210704120639637

那么实际在网络中是如何传输的呢?一定是1个大小为100的消息吗?答案是否定的。在我的这次测试中,在TCP层,分成了两组消息,第一个大小为10,包含一个I am Sorry

image-20210704120759769

另一个大小为90,包含9个

image-20210704120818834

揭秘时刻

TCP协议

TCPUDP不同,它是一个基于流的协议,TCP并不识别你定义的协议规则,只负责将这些报文打包发送,它可以基于TCP_NODELAYNagle算法等,任意的对你的报文进行切分发送。有两个典型的场景:第一个像上文中的例子,两个及以上的包在一个TCP数据包发送了,有个很形象的名字叫粘包。还有一个,因为报文过大,拆分成两个TCP报文发送,这叫拆包。

应用层读取

常见API,应用层读取也不保证单次操作一定仅仅读取一个tcp数据包,会根据你提供的buffer大小,尽量提供数据。你读取到的可能是上一个TCP包的末尾和下一个TCP包的开头部分。

总结

TCP是基于流的协议,并非基于报文。TCP提供了保序的语义保证,这要求应用程序,尤其是接收者,需要能够从报文流中提取出协议信息,TCP决不保证读取到的报文恰好是发送者一次write写入的报文,即使能在测试环境通过case,那也只不过是你运气好而已。

像我们上面,读取到100大小的消息。根据协议大小请求固定为10,我们就可以将100消息分割为10条协议报文。如果读取到的大小为96,那就先处理前90个字节,剩下6个字节,待后面4个字节到达之后再合并处理。下一节我们详细介绍一下几种常见方式。

常见TCP协议定义方式

定长编码

就像我们例子中的那样一样,定义一个定长宽度,然后切分

使用Go的gnet库的Server例子

import "github.com/panjf2000/gnet"

type ExampleServer struct {
    *gnet.EventServer
}

func main() {
    codec := gnet.NewFixedLengthFrameCodec(10)
    gnet.Serve(&ExampleServer{}, "tcp://localhost:1998", gnet.WithCodec(codec))
}

基于分隔符

基于分隔符的编码也十分容易理解,双方约定好一个字符,并在正常报文中不出现这个字符(出现则需要转义),比较类似的是以太网的7d7d?这个计算机网络链路层相关的知乎,学太久了,忘记了。

import "github.com/panjf2000/gnet"

type ExampleServer struct {
    *gnet.EventServer
}

func main() {
    codec := gnet.NewDelimiterBasedFrameCodec(0x11)
    gnet.Serve(&ExampleServer{}, "tcp://localhost:1998", gnet.WithCodec(codec))
}

基于固定行数的编码

这个也很简单,协议内容不换行,发送完再发送一个换行符,比较类似的有HTTP的\r\n

package main

import "github.com/panjf2000/gnet"

type ExampleServer struct {
    *gnet.EventServer
}

func main() {
    gnet.Serve(&ExampleServer{}, "tcp://localhost:1998", gnet.WithCodec(&gnet.LineBasedFrameCodec{}))
}

长度编码

长度编码是使用最多的,最流行的一种编码方式。最简单的一种工作方式是,在报文的最开始数个字节(常见为4个字节,足以编码4个G长度,相比之下两个字节仅能存放64K消息),声明报文剩余内容的长度。以Kafka协议举例

image-20210704125652379

Kafka这条消息,在TCP层占据的总长度为87字节,其中前4个字节00 00 00 53声明为83长度,为其余报文的长度。

这一模式还有很多变体,如

  • 声明的长度包括其长度字段本身的长度
  • 长度字段并不是打头的字段
  • 长度字段的长度

等等。这也就是下面解码器,拥有的参数非常多的原因,都是为了适配这些变体

import (
    "encoding/binary"
    "github.com/panjf2000/gnet"
)

type ExampleServer struct {
    *gnet.EventServer
}

func main() {
    encoderConfig := gnet.EncoderConfig{
        ByteOrder:                       binary.BigEndian,
        LengthFieldLength:               4,
        LengthAdjustment:                0,
        LengthIncludesLengthFieldLength: true,
    }
    decoderConfig := gnet.DecoderConfig{
        ByteOrder:           binary.BigEndian,
        LengthFieldOffset:   0,
        LengthFieldLength:   4,
        LengthAdjustment:    -4,
        InitialBytesToStrip: 4,
    }
    codec := gnet.NewLengthFieldBasedFrameCodec(encoderConfig, decoderConfig)
    gnet.Serve(&ExampleServer{}, "tcp://localhost:1998", gnet.WithCodec(codec))
}

事实上,长度字段编码格式是我见过开源代码使用最多的格式,像MQTT、KAFKA、SMPP等都使用这种格式。其中原因,个人觉得在于声明长度之后,buffer申请及释放,可以简化很多,性能最好。

其他网络协议使用的编码方式

MQTT

使用长度字段编码格式

image-20210704131034560

AMQP

AMQP的解析较为麻烦,它根据协议目前的状态,同时使用定长编码和长度字段两种编码方式。这就要求解码器不仅仅要处理报文,还要处理当前协议交互到那个状态了。

定长场景

image-20210704131231757

长度字段模式

image-20210704131317098

代码地址

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 213,335评论 6 492
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,895评论 3 387
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 158,766评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,918评论 1 285
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 66,042评论 6 385
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,169评论 1 291
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,219评论 3 412
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,976评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,393评论 1 304
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,711评论 2 328
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,876评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,562评论 4 336
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,193评论 3 317
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,903评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,142评论 1 267
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,699评论 2 362
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,764评论 2 351

推荐阅读更多精彩内容