做 Linux 网络编程的人,早晚会在某个夜里被一个问题困住:socket 里源源不断涌进来的字节流,到底该怎么解析成程序里能直接用的结构化数据?随便搜一圈,答案要么是“用 JSON 一把梭”,要么是“上 Protobuf”。但真到了业务场景里,你可能会发现现成方案各有各的别扭,于是自然走向了那条很多服务端开发者都走过的路——在应用层自己定义一套协议,自己做序列化与反序列化。这篇文章不聊虚的,我会把这套东西从设计动机、序列化选型、字节布局、封包拆包到线上排查,完完整整拆开讲一遍,适合正在写 C/C++ 网络服务、想摆脱 HTTP+JSON 依赖,或者准备给嵌入式端设计数据通道的开发者参考。
1. 自定义协议之前,先想清楚值不值
1.1 什么场景逼着你非自定义不可
很多项目一开始都用 HTTP 传数据。HTTP 的优点太直观了——请求头、状态码、Content-Type 全是标准,任何语言都有库,出了问题抓包还能直接看明文。但有几个场景,HTTP 会让人越来越难受。
首先是长连接场景。你要维护一个设备连接池,设备持续上报遥测数据,每条消息就几百字节。HTTP 的每个请求都要携带大量固定头部,意味着传输的有效载荷占比很低。更麻烦的是 HTTP 是文本协议,解析要用状态机逐行处理。高并发下这种开销能被放大到肉眼可见的 CPU 占用飙升。
其次是嵌入式端或弱网场景。这类设备内存小、带宽窄,一个 JSON 字符串里到处是引号和花括号,数据体积比二进制格式大好几倍,序列化和反序列化也要反复做字符串拷贝。你用 4 字节整型表示的字段,JSON 得用十几个字符表达,浪费的每一个 byte 都对应着电费和功耗。
还有一种是高频小消息的 RPC 场景。比如游戏对战服务,每秒钟服务器要往客户端推送几十次状态更新。如果都走 HTTP,连接建立和拆除本身就是巨大开销,而 TCP 长连接配合一个紧凑的二进制帧格式,才是这类服务常见的解法。
所以自定义协议适用的画像一般是这样:长连接、高吞吐、小消息、嵌入式受限环境、或者你对消息格式有强控制需求。反过来,如果只是公司内部的管理接口,偶尔调几次,HTTP+JSON 完全够用。别为了炫技自找麻烦,这个边界得先把住。
1.2 协议设计的两个底层问题
一套应用层自定义协议,拆穿了就解决两个问题。
第一个是消息边界问题。TCP 是流式协议,没有“消息”这个概念,你 send 两次,对端 recv 一次可能就全部收到了,也可能只收到一半。所以你必须自己约定“一条消息从哪里开始、到哪里结束”。业界常见的方案就三种:定长消息、特殊分隔符、长度前缀。定长最简单但浪费带宽;分隔符适合文本协议,但二进制数据里一旦出现分隔符就得转义;长度前缀是二进制协议里最通用、最稳妥的做法,也是我这篇文章后面要讲的方案。
第二个是数据编码问题。结构体、对象、数组这些内存里的数据结构,怎么变成一段连续字节流?这套规则就是序列化。反过来把字节流还原成数据结构,就叫反序列化。
边界和编码是两层独立的事情。你可以用长度前缀定边界,里面负载用 Protobuf 编码;也可以边界和编码都用自研二进制规则。很多初学者把协议和序列化混为一谈,其实协议关心的是“帧怎么切”,序列化关心的是“字段怎么放”。两者分开设计,后面做版本升级和选型替换会轻松很多。
1.3 序列化在协议体系里的位置
一个完整的通信数据通路大概是这样的:业务层产生结构化数据 → 序列化模块把数据变成字节 → 帧模块给字节加上头部(magic、长度、校验等)→ 通过 socket 发送。接收方向则相反,内核收到字节流 → 拆包模块按长度字段切出完整帧 → 校验无误 → 反序列化模块把字节还原成结构化数据 → 交给业务层。
我见过不少团队折腾半天自定义协议,最后负载里直接塞一个 JSON 字符串,理由是“帧格式我们自己定,内容用 JSON 大家读起来方便”。这种折中方案确实存在,也有效。但你得清楚序列化方案的选择会直接影响协议的体积、性能和调试成本,它不是一个可有可无的实现细节。
2. 序列化方案选型:没有最优只有适合
2.1 主流序列化方案横向对比
先把我用过的几种方案放在一张表里,后面逐条说。
| 方案 | 可读性 | 体积 | 编解码性能 | 是否需要 schema | 跨语言支持 | 主要适用场景 |
|---|---|---|---|---|---|---|
| JSON | 很好 | 最大 | 中下 | 不需要 | 极好 | 调试、管理接口、低频接口 |
| XML | 较好 | 大 | 慢 | 弱可选 | 极好 | 遗留系统对接 |
| Protobuf | 差 | 小 | 好 | 必须 | 好 | 高吞吐内部 RPC、长连接 |
| MessagePack | 较好 | 较小 | 好 | 不需要 | 好 | 兼顾可读性与性能 |
| 手写二进制 | 极差 | 最小 | 最好 | 自定义 | 差 | 极致性能、受限嵌入式 |
JSON 最大的优势是人都能读懂,调试期改个字段发个消息就能看结果。但它的体积膨胀很厉害,一个 uint32 变成文本可能占 10 个字节以上,而且解析要逐字符扫,没有类型约束,字段拼错了要到运行时才暴露。
Protobuf 是把结构化数据压成紧凑二进制的成熟方案,它带一个 .proto 文件作为 schema,字段类型、编号、可选还是必选都写在里面,然后代码生成器自动产出序列化代码。体积小、性能好,还天然支持向后兼容的字段编号体系。
MessagePack 本质上是“JSON 的二进制版”,它把 key 和类型用字节表示,数据体积比 JSON 小很多,但读起来依然能还原成 JSON 类似的形态,适合既想要二进制效率、又不想维护 schema 的场景。
手写二进制序列化就是完全自己控制字节布局。优点是可以做到极致——没有框架开销、每个 bit 都花在刀刃上;缺点是容易犯低级错误,比如字节序没转、字段类型改了旧数据解析不了,而且几乎没有调试可读性。
2.2 选型的三条判断标准
第一个看流量敏感度。如果一条消息平均 500 字节,一天发几百万条,带宽费用和耗时就是实打实的成本。第二个看调试诉求。你是在一个需要频繁联调的复杂业务里,还是在设备端和云端之间做固定接口?联调频繁的话,可读性往往比那几十字节的体积更值钱。第三个看跨语言和团队维护成本。Protobuf 要维护 .proto 文件,还得引入编译插件;手写二进制要写很详细的协议文档,不然三个月后没人看得懂。
我的习惯是先用这三条把所有消息过一遍。举个常见例子:一个内网日志上报服务,服务端和客户端都是 C++,消息种类固定且结构稳定,那手写二进制或者 Protobuf 都没问题;一个给前端和第三方对接用的开放 API,负载用 JSON 反而是更负责任的选择;而设备端和云端的长连接命令管道,我大概率会用 Protobuf 做 payload,外面再包一层自定义帧头。
2.3 我的实际选型偏好
我个人的默认方案是:自定义二进制帧头 + Protobuf 负载,这是目前最均衡的组合。帧头自己写,因为要控制 magic、版本、长度、校验这些和传输相关的信息;负载用 Protobuf,因为复杂业务数据结构让它生成代码来序列化,远比手写可靠。
只有在极端受限的单片机环境里,我才会连 Protobuf 都放弃,完全手写字节布局。原因有两个:一是嵌入式环境资源太紧张,生成代码体积大;二是设备端往往不需要复杂的嵌套结构,几十个字段用固定偏移量排布,直接内存访问都比任何序列化框架快。
其实选型最忌讳的是跟风。见过一个团队,因为听别人说 Protobuf 好,强行把调试期极其依赖可读性的消息从 JSON 改成 Protobuf,结果每次联调都要先写工具去转格式,效率掉了三成。方案没有绝对优劣,贴合场景才最重要。
3. 从零设计一个二进制协议:格式、字节序、实现
3.1 消息格式逐字段拆解
手写二进制帧的时候,头部格式是全部的关键。我常用的是一个 10 字节定长头,结构如下。
| 偏移 | 长度 | 字段 | 说明 |
|---|---|---|---|
| 0 | 1 | magic_hi | 固定 0x5A,用于快速定位报文起始 |
| 1 | 1 | magic_lo | 固定 0xA5,与 magic_hi 一起标识协议 |
| 2 | 1 | version | 协议版本号,初始为 1 |
| 3 | 1 | type | 消息类型,比如 1 表示心跳,2 表示数据上报 |
| 4 | 4 | length | 负载区长度,使用网络字节序(大端) |
| 8 | 2 | checksum | 校验值,覆盖头部前 8 字节和整个负载区 |
| 10 | - | payload | 负载区,内容由序列化方案决定 |
magic 是用来做“失步恢复”的。网络环境总有意外,如果对端收到一个错乱的流,不可能指望从中间某个字节开始解析就有意义,所以协议里必须有可以扫描的标记。magic 一般选两个不太常见的字节组合,不要把 0x00 这种频繁出现在二进制数据里的值当 magic,否则误命中率会高。
version 字段很多人会忽略,但它是最便宜的保险。协议迭代时,同一个 socket 上可能混着旧客户端和新客户端,服务端看到 version 就知道该走哪套解析逻辑。type 字段让一个连接可以承载多种消息类型,否则你每一帧都要包一层“这是啥消息”的额外标记。
length 是整个头部设计里最重要的一环,它直接解决消息边界问题。接收端只要先读满 10 字节,从里面取出 length,就知道还需要读多少字节才能凑齐一条完整消息。checksum 则是最后一层防线,它能挡住网络传输损坏和程序逻辑 bug 导致的脏数据。
3.2 字节序、校验与封包实现
写多字节字段时,最常见的一个坑就是字节序。x86 机器内存里整数是小端存储,而网络传输约定一般用大端。如果你的程序直接把 uint32_t 用强转写到字节流里,发到另一台小端机器上没问题,但换到某些嵌入式 ARM 平台或者用网络抓包工具看,解析出来就是错乱的。所以在写入 length 时一定要用 htonl 转成网络字节序,读出来时用 ntohl 转回主机字节序。
另一个坑是结构体强转。千万别图省事直接把一个 struct 用 memcpy 塞进发送缓冲区然后 send。编译器的结构体对齐会插入 padding 字节,32 位和 64 位环境的内存布局还可能不一致,同一份代码换个平台编译,对端就解析不了。规矩做法是逐个字段手工填充到字节数组里。
序列化封包的代码大致如下,这个逻辑我在不同项目里改写过很多次,核心思路没变过。
#include <stdint.h> #include <string.h> #include <arpa/inet.h> #define PROTO_MAGIC1 0x5A #define PROTO_MAGIC2 0xA5 #define PROTO_VERSION 1 #define PROTO_HEADER_LEN 10 #define MAX_PAYLOAD_LEN 65535 // 简单累加校验:对 len 字节求和,再取反 static uint16_t proto_checksum(const uint8_t *data, size_t len) { uint32_t sum = 0; for (size_t i = 0; i < len; i++) sum += data[i]; while (sum >> 16) sum = (sum & 0xFFFF) + (sum >> 16); return (uint16_t)~sum; } // 把一条消息封装成协议帧,返回帧总长度;失败返回 -1 ssize_t proto_serialize(uint8_t version, uint16_t type, const void *payload, uint32_t payload_len, uint8_t *out, size_t out_cap) { if (payload_len > MAX_PAYLOAD_LEN) return -1; if (out_cap < PROTO_HEADER_LEN + payload_len) return -1; out[0] = PROTO_MAGIC1; out[1] = PROTO_MAGIC2; out[2] = version; out[3] = (uint8_t)(type & 0xFF); uint32_t be_len = htonl(payload_len); memcpy(out + 4, &be_len, 4); // 先暂时把校验字段写成 0,再计算覆盖头部+负载的校验值 out[8] = 0; out[9] = 0; if (payload_len > 0) memcpy(out + PROTO_HEADER_LEN, payload, payload_len); uint16_t crc = proto_checksum(out, PROTO_HEADER_LEN + payload_len); out[8] = (uint8_t)(crc >> 8); out[9] = (uint8_t)(crc & 0xFF); return (ssize_t)(PROTO_HEADER_LEN + payload_len); }注意这里有个细节:计算 checksum 时,校验字段本身要先置零。因为校验值还没生成,这个位置是不可能参与运算的。很多新手第一次写会忘记这一点,导致算来算去校验总是不对,还以为是自己长度算错了。
3.3 反序列化和边界检查要点
接收端的反序列化比发送端更要谨慎,因为你面对的是网络上传来的不可信数据。我之前就见过有人写解析函数时,直接按照头里的 length 去 malloc 一块内存然后 memcpy,结果一个异常客户端传了个 length=0xFFFFFFFF,程序当场崩掉。
所以反序列化函数里必须做完整的防御检查:缓冲区不够一个头部长度直接返回“数据不足”;magic 不对返回“非法报文”;length 超过上限返回“超大包”;总帧长度没到齐返回“继续等待”;最后校验 checksum 不对返回“校验失败”。每一层检查都是在给你自己的程序兜底。
对应代码如下。
int proto_deserialize(const uint8_t *buf, size_t buf_len, uint8_t *payload_out, size_t payload_cap, uint8_t *version_out, uint16_t *type_out) { if (buf_len < PROTO_HEADER_LEN) return -1; // 数据不足 if (buf[0] != PROTO_MAGIC1 || buf[1] != PROTO_MAGIC2) return -2; // magic 不匹配 uint32_t be_len; memcpy(&be_len, buf + 4, 4); uint32_t payload_len = ntohl(be_len); if (payload_len > MAX_PAYLOAD_LEN) return -3; // 负载超限 if (buf_len < PROTO_HEADER_LEN + payload_len) return -4; // 半包,仍需等待更多数据 // 为了校验,需要把头部校验字段临时视为 0,重新计算 // 这里直接 malloc 一个临时缓冲,算完就释放 uint8_t *tmp = (uint8_t *)malloc(PROTO_HEADER_LEN + payload_len); if (!tmp) return -5; memcpy(tmp, buf, PROTO_HEADER_LEN + payload_len); tmp[8] = 0; tmp[9] = 0; uint16_t expect = ((uint16_t)buf[8] << 8) | buf[9]; uint16_t calc = proto_checksum(tmp, PROTO_HEADER_LEN + payload_len); free(tmp); if (calc != expect) return -6; // 校验失败 if (version_out) *version_out = buf[2]; if (type_out) *type_out = buf[3]; if (payload_out && payload_cap >= payload_len && payload_len > 0) memcpy(payload_out, buf + PROTO_HEADER_LEN, payload_len); return (int)(PROTO_HEADER_LEN + payload_len); // 返回整帧长度 }这段函数返回值的含义值得多说一句。我设计成“非负数表示消费了多少字节,负数用不同值表示不同错误”,上层拿到返回值以后可以快速归类:-1 和 -4 都是“继续攒数据”,-2、-3、-6 是“这包是坏的,需要丢弃并重新同步”。把错误分类做好,排查问题的难度会降一个数量级。
4. 粘包、半包与收发缓冲的实际处理
4.1 粘包半包到底怎么来的
讲完了序列化,连接收端的重头戏其实才开始:粘包和半包。很多新手以为 send 一次对端 recv 就一定收到同样大小的数据,这是对 TCP 字节流模型最大的误解。
粘包出现的原因有很多。一是 TCP 层把多次 send 的数据合并成一个数据段发给对端,比如你连续发了三个小帧,对端一次 recv 可能把三个帧全读出来了;二是 Nagle 算法为了减少小包会主动把小数据攒在一起发;三是接收方如果读得慢,核心里积压的数据也可能一次吐出来。
半包则是因为一个完整的 frame 在传输中被拆碎了,对端第一次 recv 只拿到前半段,第二次 recv 才拿到后半段。如果程序天真地认为“recv 返回多少字节就是一条完整消息”,那解析必然错乱。
解决办法就是利用我们前面定义的 length 字段做粘包切割。接收端永远不要把 recv 到的数据当成完整帧来解析,而是先丢进一个缓冲区,然后反复尝试从缓冲区里取“一条完整的 frame”。取到一条就处理一条,取不到就继续等网络数据。
4.2 接收缓冲区拆包引擎代码
我用一个简单的线性缓冲区来演示这个拆包引擎。它的核心不复杂:数据追加到缓冲区末尾,然后从头部尝试解析,如果解析返回“数据不够”就停止;如果解析出完整帧,就把这个帧消费掉,并把剩余数据向左移动。
typedef struct { uint8_t *data; size_t cap; size_t len; } recv_buf_t; int recv_buf_init(recv_buf_t *r, size_t cap) { r->data = (uint8_t *)malloc(cap); if (!r->data) return -1; r->cap = cap; r->len = 0; return 0; } // 追加从 socket 读到的数据 int recv_buf_append(recv_buf_t *r, const uint8_t *p, size_t n) { if (r->len + n > r->cap) return -1; // 缓冲区满了,说明需要扩容或者丢弃 memcpy(r->data + r->len, p, n); r->len += n; return 0; } // 尝试从缓冲区头部取出一条完整帧,返回帧总长度;取不到返回 0 ssize_t recv_buf_take(recv_buf_t *r, uint8_t **frame_out) { if (r->len < PROTO_HEADER_LEN) return 0; // 连头部都不够 if (r->data[0] != PROTO_MAGIC1 || r->data[1] != PROTO_MAGIC2) { // 失步状态:向后扫描 magic,找到就丢弃之前的脏数据 size_t pos = 1; while (pos + 1 < r->len && (r->data[pos] != PROTO_MAGIC1 || r->data[pos + 1] != PROTO_MAGIC2)) pos++; if (pos + 1 >= r->len) r->len = 0; // 找不到,全丢 else { memmove(r->data, r->data + pos, r->len - pos); r->len -= pos; } return 0; } uint32_t be_len; memcpy(&be_len, r->data + 4, 4); uint32_t payload_len = ntohl(be_len); if (payload_len > MAX_PAYLOAD_LEN) { // 明显异常的数据,把头部丢掉重新扫描 memmove(r->data, r->data + 2, r->len - 2); r->len -= 2; return 0; } size_t total = PROTO_HEADER_LEN + payload_len; if (r->len < total) return 0; // 半包,继续等待 // 取出一条完整帧,把已消费的数据从缓冲区移除 if (frame_out) *frame_out = r->data; size_t remain = r->len - total; if (remain > 0) memmove(r->data, r->data + total, remain); r->len = remain; return (ssize_t)total; }实际 recv 循环就很清爽了:每次 recv 到的数据先 append 到缓冲区,然后不停 take 出完整帧去处理,直到 take 返回 0 为止。
4.3 缓冲管理和大包保护
线性缓冲区最大的问题是频繁 memmove。如果每取一帧就移动一次剩余数据,当帧很小而数据吞吐很大时,这些内存拷贝会浪费不少 CPU。两个优化方向:一是改成环形缓冲区,避免每次都移动数据,代价是代码复杂度明显上升;二是幂等地在缓冲区头部维护“已读偏移”,定期把已读空间压缩回头部。
我更推荐第二个方案,先用一个已读指针减少大部分拷贝,等已读区域大到一定程度再一次性 memmove。简单、容易验证,对绝大多数项目性能已经够用。
大包保护也必须在缓冲区层面做。比如恶意客户端发一个 length=MAX_PAYLOAD_LEN 的大包声明,然后疯狂传数据,如果缓冲区无脑扩容,内存很容易被打爆。所以我会把 MAX_PAYLOAD_LEN 设成业务上合理的数值,同时给缓冲区设置一个“容量上限=若干倍最大帧长”,达到上限还是凑不出完整帧就直接断开连接。这不是小题大做,TCP 服务被慢速攻击打挂的案例太多了。
5. 线上问题排查与协议演进
5.1 常见问题速查表
把我在实际工作中遇到的高频问题整理成了下面这张表,排查的时候照着对照就能省不少时间。
| 现象 | 可能原因 | 处理办法 |
|---|---|---|
| 对端解析出乱码 | 字节序没转,直接拷贝了主机内存整数 | 用 htonl/ntohl 统一转网络字节序 |
| 总是校验失败 | 校验计算时包含了未置零的校验字段 | 计算前把 checksum 两字节置零 |
| 一次 recv 解析出多条消息 | TCP 粘包,没按 length 切帧 | 必须有拆包缓存逻辑 |
| 程序崩溃或越界 | 没有校验 length 就 memcpy | 解析前检查 length 上限和缓冲区剩余 |
| 同代码跨平台解析不一致 | 结构体对齐、char 符号性差异 | 禁止原生结构体直接传输 |
| 丢帧但没报错 | 半包时把缓冲数据错误覆盖 | 用 append-take 模式而不是按次解析 |
5.2 抓包验证和排查套路
排查协议类问题,最好的工具就是抓包。抓包能直接看到应用层字节流的真实样子,比看代码猜要可靠得多。步骤一般是:先在发送端打点确认封装后的字节内容,再在接收端打点确认 recv 到的原始字节内容,两者对比就能快速定位是发送端封包错了、网络传输坏了,还是接收端解析错了。
我的常用排查套路是这样的:先确认一帧的头部内容和预期是否完全一致——magic 对不对、version 是不是想要的、length 和实际 payload 是否吻合。如果抓包显示一帧末尾的字节和下一帧开头的字节衔接不上,那十有八九是边界判定有问题。如果抓包数据完整但程序一直报校验失败,那就去查校验函数的调用范围是否包含了 checksum 字段本身。十六进制转储看原始字节这一步,比在代码日志里打印解析出错信息要高效得多。
5.3 协议版本兼容与演进策略
协议一旦上线,就进入持续演进的状态。最常见的新增需求是:消息里加一个字段。这看起来简单,但处理不好就是事故。我的教训很深刻:之前有一个内部服务的协议,一开始加了字段直接把负载结构改了,结果旧客户端还在线上跑,新服务端一把旧客户端的消息按新格式解析,立刻出现大量异常数据,回滚都来不及。
正确做法是给协议演进留好空间。头部 version 字段可以告诉接收方“这个包是谁发的、用什么规则解析”。但真正的核心原则是:新版本必须能读懂旧版本的报文。具体落地就是新增字段一律采用可选形式,不要修改已有字段的类型和含义;解析旧版本时缺省字段补默认值,而不是直接报错。
灰度顺序也很重要。在长连接场景里,应当先升级服务端,让服务端同时兼容新旧两种客户端;等所有客户端都升级完毕,再关闭旧版本支持。反过来先升客户端会让服务端面对它看不懂的新协议,直接断连重试,线上体验立刻就炸了。
5.4 几个让我印象深刻的坑
最后分享几个真实踩过的坑。第一个是我早期偷懒直接把 C 结构体强转 char* 发出去,当时局域网内两台相同系统的机器跑得好好的。后来客户那边是 32 位环境,结构体里一个 long 字段宽了一倍,整个帧长度全部错位,两边对联调了整整一天才发现是编译器内存对齐和基本类型宽度不一致导致的。从那以后我再没有用结构体直接传输过数据。
第二个坑是 checksum 把自己算进去,这个前文已经提到了。当时的现象很诡异:抓包里校验值看起来没问题,程序里死活报校验失败。后来一步步加日志才发现,我在发送端序列化时是先把整个缓冲区清零再填数据算好校验的,但校验函数把两字节校验也覆盖进去了,算出来的值怎么可能对得上。这个教训让我养成了一个习惯:任何涉及到校验、加解密的逻辑,先确认参与计算的数据范围,再动手写代码。
第三个坑是半包场景里的缓冲区覆盖。早期我的接收代码很直白,recv 到数据就往固定数组里放,然后拿去解析,如果没凑够一条完整帧,下一次 recv 的数据会把上一次的数据覆盖掉,结果就是完整帧永远凑不出来,连接质量全靠运气。后来老老实实改成 append-take 缓存模型,问题就从根上消失了。
最后一个坑是关于失步恢复的。有一段时间我对端偶尔会收到一些 dirty 数据,解析失败后我直接把整个连接断开重连,结果在弱网环境里连接反复重建,用户体验很差。后来做了 magic 扫描和跳帧恢复,遇到坏数据先尝试在缓冲区里寻找下一帧的位置,找不到才断开重连,稳定性明显提升。
写到这里,我想说点自己的体会。自定义协议和序列化这件事,真正难的地方从来不是写封包拆包的代码,而是你有没有把边界判定、校验保护、版本兼容这些“看不见的层”想清楚。很多人觉得协议设计是件很“硬核”的事,其实它就是一组朴素的约定:告诉对端从哪里开始读、读到哪里结束、读到的东西对不对、以后还能不能变。把这几个问题回答好了,哪怕你用的不是二进制而是一段文本,这套思维方式也完全通用。我在后续项目里越来越深的一个感受是,与其到处抄现成协议,不如先把这套底层逻辑彻底弄明白,遇到任何传输需求都能心里有底。