做Linux网络编程绕不开的一个话题就是应用层自定义协议。TCP在你面前铺开的只是一条可靠的字节流通道,字节流本身没有任何语义,两个进程之间要理解彼此,必须自己定一套对话的规则。这套规则,就是你应用层的协议。很多新手习惯直接往里塞JSON字符串,觉得简单省事,但真正到高并发、多平台、毫秒级交互的场景里,这种“拍脑袋定协议”的做法会带来一堆后续麻烦。我最早做某跨平台监控系统的时候,就在协议设计上栽过跟头,后来踩了不少坑才把一套可用的自定义协议从里到外捋顺。这篇文章我会从一个完整的模拟项目X出发,讲明白为什么需要自定义协议、协议格式怎么设计、在Linux下怎么用socket实现收发、以及最常见的粘包半包和兼容性问题怎么排查。适合刚接触Linux网络编程的同学,也适合写了好几年业务代码但一直没系统整理过协议设计的老手。
1. 项目概述与协议设计思路拆解
1.1 为什么不能只在服务端和客户端之间传JSON
现在做网络通信,很多人第一反应就是“把数据打包成JSON丢过去”。JSON确实有它的好处——可读性强、调试方便、跨语言支持好,但一旦把它当作所有场景的万能解法,问题很快就冒出来。
先说带宽和解析开销。JSON本质上是文本协议,同样的一个整数“12345”在JSON里占5个字符,而二进制定点数只需要4个字节。当我们调用sizeof(struct xxx)去算结构体长度然后直接发过去时,能省一大截流量。对内网小流量服务,这个差异可能无所谓,但如果是物联网传感器上报、游戏同步、日志采集这类高频小包场景,纯文本协议的无效字节会直接变成CPU和带宽的双重浪费。
再说解析的脆弱性。JSON解析器在嵌入式或者资源受限环境里经常要为了鲁棒性做很多额外工作,比如处理字符串转义、处理浮点数精度、处理嵌套对象。而自定义二进制协议可以直接通过memcpy从缓冲区里把整块字段拷出来,一次解析,零内存碎片,效率高出一个数量级。我做过的那个某图像处理Demo就是典型的例子——传图像分片信息时如果走JSON,光序列化和反序列化就占了每帧处理时间的将近30%,改成自定义二进制协议后降到了3%以下。
最关键的是,业务逻辑里很多“状态”和“标记”用文本表达非常别扭。比如一条控制指令的状态码,用int可以用0、1、2、3表示,而JSON要用“pending”“running”“failed”这种字符串,还得大小写兼容,协议双方一旦对不上,线上就出乱子。自定义协议可以完全按照业务语义去定义字段,类型明确、长度可控,这才是为什么Linux网络编程里几乎所有高性能服务最终都会走到“自定义二进制协议”这条路上的根本原因。
1.2 自定义协议的核心设计目标与思路
设计自定义协议,本质上是在做三件事:约定数据的边界、约定数据的含义、约定数据的行为。
- 数据的边界:一段字节流从哪里开始,到哪里结束,如何拆分成一个个完整的消息。这是解决“粘包/拆包”问题的起点。边界规则一般要么靠固定长度,要么靠头部里的长度字段。
- 数据的含义:字段的布局、顺序、类型、字节序,以及每个命令码代表什么操作。服务端和客户端必须对每一个bit的含义达成一致,否则不是乱码就是语义错乱。
- 数据的行为:收到命令后怎么做,错误怎么反馈,版本不兼容时怎么降级。这部分往往是协议文档里最容易被忽略的,但恰恰是线上能稳定跑几年的关键。
我的经验顺序是:先定边界,再定含义,最后定行为。定边界时直接决定采用“长度字段方案”还是“分隔符方案”;定含义时把消息头拆分出来,确保每个包都能独立解析;定行为时考虑消息的确认机制、超时策略、版本号兼容策略。这三步做完,协议的主干就有了,剩下的纯粹是填字段和写代码。
设计思路上还有一条隐形原则:协议要尽量“自描述”。一个消息到达对端之后,光靠这些字节本身,就能判断出它是什么命令、多长、是否完整。这样不仅方便写解析器,也方便做流量录制和回放——出问题时抓一份报文,单看十六进制就能定位问题,而不用猜上下文。我后面设计的协议里加了魔数、版本号、命令字、包长、预留位,就是出于这个目的。
2. 协议格式与数据布局设计
2.1 消息头的字段设计:从一条“通用消息”开始
我最常用的协议模板是这样的:所有消息共用一个固定长度的消息头,消息头后面跟着长度可变的负载区。负载区可以是二进制结构体,也可以是子协议序列化后的数据,但消息头本身永远是固定长度,这样接收方可以先收一个完整的头部,再根据头部里的长度字段收取剩余部分。
以我自己的某跨平台系统为例,消息头我定义为:
| 字段 | 类型 | 长度 | 说明 |
|---|---|---|---|
| magic | uint32_t | 4字节 | 魔数,固定为0x5A5A0001,用于校验是否是本协议的包 |
| version | uint16_t | 2字节 | 协议主版本号,当前为1 |
| cmd | uint16_t | 2字节 | 命令字,如100=心跳、200=业务请求 |
| length | uint32_t | 4字节 | 整个消息的总长度(包含消息头自身) |
| seq | uint32_t | 4字节 | 序列号,用于请求与响应对应 |
| reserved | uint32_t | 4字节 | 预留字段,暂时填0,用于将来扩展标志位 |
这样一个头部是20字节,不算长,但信息量足够丰富。magic解决“这是不是我的包”的问题,version解决“协议能不能解析”的问题,cmd解决“这个包干什么用”的问题,length解决“我要收多少字节才能处理完”的问题,seq解决“响应对应哪一次请求”的问题。这五个字段是自定义协议的基石。
有人可能会问:magic是不是必须的?我的回答是,在局域网自研场景下它可能确实不是必须的,但加上它没有坏处。哪怕只在服务端判断“前4字节不是0x5A5A0001就直接丢弃”,也能挡掉很多因为端口复用或者客户端发错协议导致的脏数据。尤其是同一个服务器上可能同时跑了多个项目的时候,magic就是第一道识别墙。
2.2 负载区与TLV结构的取舍
消息头后面的负载区,我经历过两种方案。第一种最简单直接——把整个业务结构体定义好,直接printf成字节流往里塞。例如一个“上报位置”的命令,负载区就直接是一个struct location { double lat; double lng; },服务端同样定义这个结构体后直接强转解析。这种方案的优点是极小极快,缺点是结构体里如果带了char name[32]这种定长数组,浪费空间;如果带了指针,那就完蛋——指针地址发过去毫无意义。
第二种方案是TLV(Type-Length-Value)结构。每个字段都由“类型标识 + 字段长度 + 字段值”组成,类似HTTP头部的Key-Value形式。TLV的优点是灵活、可扩展性强,增加一个字段不需要改变整个负载区的布局,缺点是每个字段都要额外带类型和长度的开销。
我的结论是:如果是单一功能、字段固定的业务包,直接用结构体;如果是需要跨版本兼容、字段数量可能动态变化的包,用TLV。我在某测试系统里做过一个折中方案——负载区整体依然是一个结构体,但在结构体末尾保留一个“扩展字段”数组,用TLV方式承载未来可能新增的属性。这样既保证了核心字段的高速访问,又保留了扩展空间,算是一个很实用的做法。
字节序的问题必须单独提。x86是小端,网络传输习惯上统一用大端(也叫网络字节序),所以凡是uint16、uint32、uint64这些多字节整型,在封包时都用htons/htonl转换,解析时用ntohs/ntohl还原。有人偷懒不做这一步,在自己两台x86机器上联调一切正常,一旦结构体被放到ARM板子上或者被接收方用其它字节序解析,所有数字都颠倒错乱。这类问题最难排查,因为不是崩溃,纯粹是“数字不对”,我建议所有涉及多字节整型的字段,一律显示转换,不要赌对方的字节序。
2.3 如何保证协议可扩展又不失控
协议不是写一次就不变了,业务加字段、加命令是家常便饭。我把自己的扩展原则列在这里,照着做基本不会失控。
- 版本号只递增,不重写。新增字段时,版本号跟着更新,老版本客户端可以按版本号走兼容逻辑。
- 新增命令永远用新cmd值,不修改旧cmd的含义。比如100是心跳,之后想让心跳带上时间戳,那就把100升级成带时间戳的版本,或者新增101,而不是在100的负载区里塞一个可选字段让老代码强行解析。
- 预留字段保持为0,并且所有端都不得依赖预留字段里的值。将来需要标志位时,把预留字段的某一位定义为新语义,才不会引起歧义。
- 禁止在协议里定义“非定长字符串”,除非它明确携带长度。发送字符串时必须“长度+内容”一起发,不能直接裸发一个不定长的
char*,否则接收方没法知道该拷多少字节。
定好这些规则后,协议才能像滚雪球一样越长大越稳定。很多项目死在“协议失控”上的原因不是设计能力不够,而是没人定规矩,每个人各加各的字段,最后两个版本的包谁也看不明白。
3. Linux下Socket收发核心实现
3.1 建立TCP连接的基本流程与缓冲区语义
Linux下用socket写自定义协议,TCP连接的建立流程大家都熟:服务端socket()创建fd,bind()绑定地址,listen()进入监听,accept()取出连接;客户端socket()创建fd,connect()发起连接。但很多人卡在收发细节上,根本问题在于对“TCP缓冲区”的理解不够。
一个TCP连接由内核维护两个缓冲区——发送缓冲区和接收缓冲区。调用send(fd, buf, len, 0)时,内核并不保证把len个字节一股脑塞进对方的接收缓冲区,它只是尽力复制“能放下”的部分到发送缓冲区,然后返回实际接受的长度。如果缓冲区满了,send可能只发出去一部分字节,甚至返回0或者阻塞。recv也一样,它返回的是“当前可以读到的字节数”,而不是“你请求的字节数”。这就是为什么自定义协议收发代码里一律要循环调用send/recv,直到处理完期望的字节数,或者得出失败结论。
常见的错误写法是:
send(fd, buf, sizeof(buf), 0); recv(fd, recvbuf, sizeof(recvbuf), 0);这种写法在局域网、小包、近距离联调时通常“碰巧能用”,但一旦并发上升、网络变慢、发送缓冲积压,就会出现莫名其妙的消息截断或丢失。我见过好几个线上问题最终定位都是这段代码造成的,所以别给自己留这个雷。
另外,TCP连接默认开启了Nagle算法,小包会被合并后统一发送,给自定义协议带来的最明显后果就是“延迟变高、粘包现象更频繁”。对于实时性要求高的交互业务,可以在连接建立后关掉Nagle,在代码里就是设置TCP_NODELAY为1。代价是每次send都会立即发出,小包多时网络利用率会降低,但对于大部分局域网应用来说,响应速度的收益更大。
3.2 粘包、半包,以及拆包的三种方式
“粘包”在应用层来看,是一个recv调用可能同时读到多个完整消息的数据;“半包”是recv只读到了某条消息的一部分。很多初学者以为TCP有消息边界,这是误解。TCP是字节流,没有边界,应用层必须自己做拆包。
拆包方式通常有三种:
- 固定长度:每个消息长度一样,接收方一次读固定长度,读够了就是一条消息。简单,但浪费带宽,变长数据没法用。
- 分隔符:消息里用特定字符(比如
'\n')作为结束标志,比如很多文本协议这么干。解析简单,但正文里如果出现分隔符要转义,二进制数据几乎没法用。 - 长度字段:在头部放一个字段声明整个包的长度,接收方先读包头,再读负载。这是目前自定义二进制协议的标准解法。
我采用的是第3种。整体逻辑就是:维护一个接收缓冲区,不断把recv到的数据追加进去,然后循环检查“缓冲区里的字节数是否已经足够解析出一个完整包头”,不够就继续recv;够了解析出length,再判断“缓冲区里的总字节数是否大于等于整个包的长度”,不够继续收,够了就切下来处理,把剩余数据往前挪。这个过程听着不复杂,但边界条件很多,漏掉任何一个都会导致内存错乱。
3.3 可复用的循环收包与封包函数
下面给出一个我常用的小型封包/拆包函数实现。它的设计思路不依赖具体业务,可以直接抄进自己的项目里适配。
先定义一个消息结构体,对应前面说过的消息头:
#define PROTO_MAGIC 0x5A5A0001 #define PROTO_MAX_BODY 65536 typedef struct proto_header { uint32_t magic; uint16_t version; uint16_t cmd; uint32_t length; uint32_t seq; uint32_t reserved; } proto_header;封包函数,把头部字段填好并和负载拼到一起:
int proto_pack(uint16_t cmd, uint32_t seq, const void *body, size_t body_len, unsigned char *out_buf, size_t out_cap) { if (body_len > PROTO_MAX_BODY) return -1; size_t total_len = sizeof(proto_header) + body_len; if (out_cap < total_len) return -1; proto_header hdr; hdr.magic = htonl(PROTO_MAGIC); hdr.version = htons(1); hdr.cmd = htons(cmd); hdr.length = htonl((uint32_t)total_len); hdr.seq = htonl(seq); hdr.reserved = 0; memcpy(out_buf, &hdr, sizeof(hdr)); if (body_len > 0 && body != NULL) memcpy(out_buf + sizeof(hdr), body, body_len); return (int)total_len; }拆包循环,配合一个动态追加的接收缓冲区:
// recv_buf 为累积缓冲区,*offset 为当前已用长度 int proto_on_recv(int fd, unsigned char *recv_buf, size_t cap, size_t *offset, proto_handler handler, void *ctx) { ssize_t n = recv(fd, recv_buf + *offset, cap - *offset, 0); if (n <= 0) return (n == 0) ? -1 : -2; // 0 对端关闭,-2 错误 *offset += (size_t)n; while (*offset >= sizeof(proto_header)) { proto_header hdr; memcpy(&hdr, recv_buf, sizeof(hdr)); uint32_t magic = ntohl(hdr.magic); if (magic != PROTO_MAGIC) return -3; // 魔数不对,可能协议错乱 uint32_t total_len = ntohl(hdr.length); if (total_len < sizeof(proto_header) || total_len > cap) return -4; if (*offset < total_len) break; // 还不够,继续收 uint32_t body_len = total_len - sizeof(proto_header); handler(ctx, recv_buf, total_len, body_len); memmove(recv_buf, recv_buf + total_len, *offset - total_len); *offset -= total_len; } return 0; }这段代码里,handler是业务回调,每次收到一个完整包就调用一次。memmove把缓冲区里剩余的数据挪到头部,保证循环不会越界。要注意的是,magic和length校验必须在memcpy头部之后立刻做,因为后续的body_len直接依赖length的值,一个被网络损坏的length字段可能导致程序读取超大内存甚至崩溃。
我对这段代码的体会是:核心不是“怎么收”,而是“校验要前置,循环要严谨”。很多人写完能跑就不管了,结果某天收到一个畸形包就直接宕机。网络环境不可信,任何字段都可能错,所以一个健壮的协议解析器,必须像看门的一样,每个关键字段都要验一验。
4. 完整实操:从零写一个自定义聊天与控制协议
4.1 业务场景与协议规则定义
为了把上面的设计串起来,这里我定义一个简化版的“模拟项目X聊天/控制协议”,场景是内网里的一台服务端和多个客户端之间的消息交互。需求只有两个:客户端能发文本消息给服务端,服务端能对客户端下发一条“重启”指令。
那么协议规则定义如下:
- cmd=100:心跳请求,负载为空
- cmd=200:文本消息上行,负载格式为“length(2字节) + UTF-8文本内容”
- cmd=300:控制指令下发,负载里只含一个uint8类型,1代表重启
心跳、文本、控制三种命令,覆盖了“无负载”“变长负载”“定长负载”三类情况,基本可以当模板套用到大部分需求里。
这里有个细节:文本消息为什么不直接发裸字符串?因为接收方要知道这个字符串到底有多长。我在负载前面加一个2字节的长度字段,用htons/ntohs转换,接收方先把长度读出来,再按长度读取正文。这个“短字符串”模式在自定义协议里是最高频的基础操作。
4.2 服务端完整代码与处理流
服务端核心逻辑是:创建监听socket,接受连接,对每个连接启动一个线程(或者直接在主线程里轮询),在连接循环里调用proto_on_recv,当收到一个完整包时,解析消息头,按cmd分派处理。
我定义的回调函数大致长这样:
static void handle_message(void *ctx, unsigned char *packet, size_t total_len, size_t body_len) { proto_header *hdr = (proto_header *)packet; uint16_t cmd = ntohs(hdr->cmd); uint32_t seq = ntohl(hdr->seq); unsigned char *body = packet + sizeof(proto_header); switch (cmd) { case 100: // 心跳,可以回一个空包或者直接忽略 break; case 200: { // 负载前2字节是文本长度 uint16_t text_len = ntohs(*(uint16_t *)body); if (text_len + 2 > body_len) { fprintf(stderr, "bad text length\n"); break; } char text[512] = {0}; memcpy(text, body + 2, text_len); printf("recv text from client: %s\n", text); break; } case 300: // 控制指令只读1个字节 if (body_len < 1) break; if (body[0] == 1) printf("recv restart cmd\n"); break; default: // 未知命令,建议回一个错误响应 break; } }服务端的主循环比较容易踩坑的点是:accept()返回的客户端fd和监听fd是两回事,很多新手把客户端fd当成监听fd去继续accept,就把连接搞坏了。另外,多线程处理连接时,每个线程要有一块独立的recv_buf,不能共享同一块缓冲区,否则两个连接的数据会互相覆盖。
如果是一个更规范的服务端,我不会用裸线程管理连接,而会用epoll来管理大量的fd,但核心的处理逻辑是完全一样的——epoll只是通知你“这个fd可读了”,真正把缓冲区的字节取出来并且拆包,依然是上面那套逻辑。所以先把单连接拆包吃透,再去套epoll才不会手忙脚乱。
4.3 客户端代码与连接的关闭判断
客户端更简单,建立连接后,随意发送一条文本消息:
int sock = socket(AF_INET, SOCK_STREAM, 0); // connect(...) char text[] = "hello, this is client"; uint16_t text_len = htons((uint16_t)strlen(text)); unsigned char body[512]; memcpy(body, &text_len, 2); memcpy(body + 2, text, strlen(text)); unsigned char sendbuf[1024]; int send_len = proto_pack(200, 1, body, 2 + strlen(text), sendbuf, sizeof(sendbuf)); send(sock, sendbuf, (size_t)send_len, 0);客户端的拆包和服务端是完全一样的,也是拿到消息头、校验、读负载。注意发送方在封包时用proto_pack已经转成了网络字节序,所以整个消息里的长度字段、命令字段都已经是大端了,接收方只用ntoh*还原即可,两边不能有的转了有的没转。
连接关闭的判断要留心:recv返回0表示对端正常关闭,这时候应该把对应的连接清理掉,退出循环。如果返回-1且errno==EINTR,是信号中断导致的,可以重试;如果是ECONNRESET,说明对端已经异常断开,不能再把fd复用。很多项目的“假死连接”问题就是没处理好这个分支,fd一直挂着,既不读也不关,资源慢慢耗尽。
4.4 本地联调与抓包验证
写完代码后,我强烈建议先在127.0.0.1上联调一次,确认基础功能无误后,再用tcpdump或者wireshark抓包验证协议的字节分布是否符合预期。
例如我发送一条文本消息“ab”,查看抓包结果,数据部分大致是这样的:
5a 5a 00 01 00 01 00 c8 00 00 00 1e 00 00 00 01 00 00 00 00 00 02 61 62逐个字段拆开:5a 5a 00 01是magic,00 01是版本号,00 c8是200(文本消息命令),00 00 00 1e是总长度30,也就是20字节头+2字节文本长度+2字节文本内容,00 00 00 01是seq,接着00 02是文本负载长度2,最后的61 62就是ASCII字符串“ab”。看到这个十六进制,即使完全不看代码,也可以确认协议是严格按定义走的。
抓包还有一个好处:能直观地看到粘包现象。如果客户端一口气发了10条文本消息,抓包时接收方很可能一次性读到了好几个完整的包,这正是自定义协议设计时要重点解决的核心问题。如果拆包逻辑写对了,层面应用根本无感知,因为proto_on_recv的循环已经把所有完整包一条一条地送进了处理函数。
5. 常见问题与排查技巧实录
5.1 聯调踩坑:为什么我收到的全是乱码
乱码九成是字节序或者长度不对。现象通常是:字符串前几位对,后面全是乱码,或者一个“数字字段”被解析成了一个肉眼看不懂的巨大数值。排查顺序从外到内:先确认发送端发送前的字节序转换和接收端解析后的字节序还原是否成对出现,再确认负载区长度字段的偏移量是否和定义一致。我之前有一次写解析器,把文本负载长度当成从消息头开始计数,结果每次多读了20字节,修正偏移量后就正常了。
另外一个隐蔽的乱码源是“本机和远端默认字符集不一致”。协议里如果设计了UTF-8文本,那么发送端拼装时必须是UTF-8,接收端也按UTF-8解读。如果一端用了GBK,界面显示自然乱码。这个不属于协议错误,属于字符编码口径不一致,但排查耗时非常久。所以协议文档里建议写上“文本一律UTF-8编码”,能省掉不少沟通成本。
5.2 粘包导致的数据错乱与连续性丢失
粘包本身不是错误,因为拆包逻辑已经按length界定了消息边界。但如果出现“解析出来的第一个包是对的,后面的包全错”这种景象,几乎可以断定memmove之后的*offset更新出了问题。
举例来说,第二包正确的起始位置在缓冲区里的偏移是total_len + 2,如果你错把total_len当成了偏移量,第二个包的头部就会被截断,magic校验自然报错。这种错乱很难从直觉上发现,最好的定位方式是打印每个包的total_len和每次recv的实际字节数,看看有没有“对不齐”的情况。我曾经在调试某系统时加了一行日志,把每次recv的n和拆包后的剩余字节数都打出来,一眼就看出问题出在偏移量少算了4字节。
5.3 半包与超时:连接正常但消息永远不完整
半包最典型的场景是大负载消息,比如客户端发了一个50KB的文本,而recv一次只返回了16KB。如果程序里写的是“必须一次读完整个包”的处理逻辑,就会一直阻塞或者一直空转。
解决半包的办法只有一个:缓冲区要足够大,循环要足够耐心。我见过有人把recv的第三个参数直接写成sizeof(st_req),也就是期望一次性收完整个消息,这在小包场景勉强能碰运气。正确办法是无论包有多大,都让缓冲区持续追加数据,然后用前面说的while(*offset >= total_len)来控制处理时机。对于超时,可以在recv之前用poll或者给fd设置SO_RCVTIMEO,来判断是“半包还没到”还是“对端主动沉默了”。
5.4 多客户端与并发时的缓冲区隔离
多线程处理多连接时,一个很常见的错误是给所有连接共用同一个recv_buf。两个客户端同时发消息,数据互相覆盖,解析出来的消息内容张冠李戴。解决很简单,每个连接都单独分配一块自己的缓冲区,在线程的栈上或者用一个结构体持有。如果采用epoll并发模型,就在每个fd对应的上下文结构体里维护一个动态缓冲区,同样是“一连接一缓冲”的原则。
这个坑容易在联调时被忽略,因为本地自己测,一个连接一个连接地发,永远没冲突,一旦上线多客户端并发,立刻数据错乱。我在某跨平台系统上线前就因为这个问题折腾了一整晚,最后发现是共享缓冲区的锅。
5.5 排查工具与README式协议记录
最后说一个“工具之外的技巧”:协议文档必须跟着代码一起更新。很多团队一开始定了协议,后面加字段时只在代码里改,文档不同步,最后新老同事接手时全靠猜。
我自己的习惯是,在项目仓库里维护一份纯文本的协议说明,格式类似:
消息头 20字节 magic: 4字节 固定 0x5A5A0001 version: 2字节 版本号 cmd: 2字节 命令字 length: 4字节 整个包长度 seq: 4字节 序列号 reserved: 4字节 预留凡是新增命令或字段,必须同步改这份文档,并且把新增内容的日期和负责人也写上,哪怕是小项目,这个习惯也能省去大量“翻代码猜字段”的时间。
排查工具方面,除了tcpdump/wireshark,我还会用nc来做最简单的联调测试,比如nc -l 9000监听一个端口,手工输入一串十六进制,观察服务端的反应,这种方式用来验证一个简单的协议解析器非常快捷。
结束语:一点个人的实战体会
协议设计这件事,看起来不起眼,但它是整个Linux网络程序的地基。地基稳,后续加功能、加并发、加集群都是顺理成章的事;地基不稳,每接入一个新端、每上线一个新版本,都会埋下新的雷。我这些年踩出来的最大教训是:协议要先行,代码再动工;哪怕只是两个人开发的小系统,也要用文字把协议写清楚。与其靠默契和“代码即文档”,不如靠一份随时更新的协议记录,让所有参与者都站在同一个字节序、同一个偏移量、同一个命令字上对话。最后再分享一个小技巧:每次写完拆包代码,先人为构造几个粘包和半包的数据来测试解析器,比直接联调能更快暴露出问题。这套东西你多练几次,就会发现自定义协议真的只是一层窗户纸,捅破之后,网络程序的全貌也就清晰了。