应用层协议基础_序列化与反序列化

一、起点:此前写的所有代码,都是应用层代码

网络协议栈是分层的,最上面那一层叫应用层。这一层有一个别的层没有的特点:它不由标准规定,而由写代码的人自己定。

到目前为止写过的所有程序------翻译、远程命令执行、聊天------都在应用层。它们调用的 socket / bind / listen / accept / connect / read / write 全部是操作系统提供的系统调用,属于传输层及以下的能力。

层 归谁管 典型内容
应用层 ★ 写代码的人 HTTP、自定义协议、序列化格式
传输层 内核 TCP / UDP
网络层 内核 IP
数据链路层 驱动 以太网 / Wi-Fi
物理层 硬件 双绞线 / 光纤

由此可以定位一个常见困惑:「报文长什么样」这件事,内核既不知道、也无权决定。它只负责把字节搬到对面,搬到之后这串字节是什么意思,全靠两端自己约定。


二、协议的第一层含义:一份约定

在写具体协议之前,先看一个每天都在用的例子。

2.1 一条聊天消息其实不是一个字符串

在 QQ 或微信里发一句话,屏幕上看到的是「一句话」,但发出去的从来不止这一个人写下的内容。一条消息至少包含三部分:

部分 实际类型 说明
头像 图片的路径 一个字符串,不是图片本身
昵称 字符串 发消息的人叫什么
消息内容 字符串 正文

三个字符串凑在一起,才构成一条完整的报文。

2.2 分三次发会出什么事

如果偷懒,把三个字符串分成三次 write 发出去,接收端会收到三个字符串------但它不知道哪个配哪个。

关键在于:接收端在接收消息的同时,可能还在收别人的消息。于是完全可能出现「A 的头像 + B 的昵称 + C 的内容」这种拼接,界面直接乱掉。

2.3 所以要打包成一个

解决办法很自然:把三个字符串用特殊字符串起来,拼成一个大字符串。

复制代码
头像路径 | 昵称 | 消息内容

分隔符可以是空格、换行、\3 这类不会出现在正文里的字符------用哪个不重要,两端用同一个才重要。

打包带来三个好处:

好处 原因
保证报文完整性 三个字段要么一起到,要么一起不到,不会被别人插进来
方便网络传送 一次 write 就够了,不用管三次调用的对应关系
方便上层处理 收到之后按同一个分隔符拆开,字段各归各位

2.4 这就是结构化数据

回到打包之前的状态:三个字符串一起放在内存里,它们其实是一个结构体的三个字段。

cpp 复制代码
struct message
{
    std::string path;       // 头像
    std::string nick_name;  // 昵称
    std::string msg;        // 正文
};

所以完整链条是:

复制代码
struct message  ──序列化──▶  一串字节  ──网络──▶  一串字节  ──反序列化──▶  struct message

💡 一个足够清晰的表述:所谓协议定制,本质就是在定制双方都能认识的、符合通信与业务需要的结构化数据;而所谓结构化数据,落到代码里就是 struct 或者 class。


三、★★ 序列化与反序列化

3.1 两个动作

动作 方向 目的
序列化 结构化数据 → 一串字节 把信息由多变一,方便网络发送
反序列化 一串字节 → 结构化数据 把信息由一变多,方便上层处理

「由多变一」的「多」指的是多个字段,「一」指的是一个可在网络上投递的整体。

3.2 为什么要「由多变一」

网络只认字节,不认结构体和对象。write 吐出去的是一串连续的字节,它没有能力表达「这里是一个结构体、那里是另一个结构体」。

所以字段必须先被拍平成一维的字节序列:字段按约定顺序排好,中间用约定好的方式划出边界。

3.3 为什么要「由一变多」

反过来的理由同样直接:上层要拿昵称就取昵称、要拿内容就取内容。如果对面递上来的是一整块字节,业务代码就得自己数偏移量,这显然不可接受。

拆成结构体之后,业务代码面对的是字节流 ,而不是一串需要心算的位置。

字符串只是字节流的一种特例(可打印、有编码)。真正的协议里完全可能出现二进制字节(比如长度前缀是一个 4 字节整数),那时候它已经不叫字符串了,但仍然是字节流。


四、★★ 能不能直接把结构体发过去

既然两端认识同一个结构体,把它的内存原样 write 出去,对面原样 read 进来,不就行了?

答案:可以,但严重不建议。

4.1 先说「可以」

这个做法在特定场景下确实能跑。一个熟悉的例子:写通讯录程序时,把 struct person 用 fwrite 一个个直接写进文件,读的时候再用 fread 读回来------这样是能工作的。

同样的道理用在网络上:客户端把 struct Request { int x; int y; char op; } 的内存原样发出去,服务端收到之后按同样的结构体解释,也能拿到 x、y 和运算符。

操作系统内部其实就是这么干的:内核里传递的协议数据,全部都是结构体对象,直接传二进制。 原因是它的字节数更少、效率更高------而且内核是同一份 C 代码,两边不会有任何不一致。

4.2 再说「不建议」:两道坎

第一道坎:内存对齐。

结构体为了对齐会插入填充字节,而填充规则由编译器、目标平台、编译选项共同决定:

差异来源 后果
32 位 与 64 位 指针宽度不同,布局随之后移
gcc 与 msvc 默认对齐策略不同
Windows 与 Linux 同一份 struct 的 sizeof 可能不同

客户端在 Windows 上、服务端在 Linux 上,同一个结构体可能一个 12 字节、一个 16 字节。字节流一错位,读出来的 y 就是垃圾值。字段越多,错位越严重。

第二道坎:跨语言。

「两端都能认识这个结构体」这句话成立的前提是两端用同一种语言。

组合 能不能对上
C 对 C 勉强可以
C++ 对 C++ 勉强可以
C++ 对 Python ✖ 对不上
C++ 对 Java ✖ 对不上

Python 的对象布局与 C 的结构体完全不同;Java 的字符串不是 C 的字节数组(它有对象头、有长度字段、编码方式也不一样)。再加上字节序问题(大端 / 小端),只要换一门语言,这份「约定」立刻作废。

4.3 还有一个更根本的理由

即使两端同语言、同平台、同编译器,把结构化数据直接拷进发送缓冲区仍然有问题:类里可能有指针。

cpp 复制代码
struct Bad {
    char *name;   // 指向 new / malloc 出来的空间
    std::vector<int> v;  // 内部还有一层指针
};

把这样的对象按字节拷过去,拷走的只是指针的值(一个地址),而那个地址在对方进程里毫无意义。这就是典型的浅拷贝陷阱:真正该传的数据在指针指向的地方,却没有被传过去。

序列化天生避开了这件事------它按字段逐个编码,字符串就写字符串的内容,容器就写元素个数加元素,永远不会把地址写进去。


五、★ 序列化的真正价值:对软件解耦

「直接发结构体不行」并不能反过来证明「序列化一定好」。序列化的价值需要独立论证,而它的核心只有一条:解耦。

5.1 字段可以随便改

序列化之后,网络上跑的东西统一是字节流。这意味着:

变更 对网络层的影响
给结构体加一个字段 ★ 无。加完重新序列化即可
改一个字段的类型 ★ 无。序列化函数内部处理
调整字段顺序 ★ 无。只要两端同步改序列化逻辑

如果直接发二进制,任何一条变更都会让两端的字节布局错位,必须同时重新编译、同时上线,中间不能有任何版本差。

产品需求变更是常态,而协议的字节布局一旦散落在各处,每次变更都是一次高风险操作。序列化把这份风险收进了一个函数里。

5.2 语言可以随便换

所有语言都支持字符串和字节数组这件事,是跨语言互通的基石。

复制代码
C++ 的 struct  ──序列化──▶  字节流  ──反序列化──▶  Python 的 class

客户端用 C++ 写、服务端用 Python 写,只要两边的序列化与反序列化遵循同一份格式约定,就能通信。这是直接发结构体永远做不到的。

5.3 ★ 结论

从今往后,凡是要做网络协议式的通信,应用层强烈建议使用「序列化 + 反序列化」方案。

直接把结构体对象发过去,除非场景特殊,否则不建议。

💡 一句话记住两条边界:内核内部可以传结构体(同一份 C 代码);应用层不要传结构体(两端什么都可能不一样)。


六、★★ 重新理解 write 与 read

上面讲的是「上层怎么组织数据」。接下来要往下走一层,看清 write 和 read 到底做了什么。这一节的结论会推翻一个很常见的直觉。

6.1 先看 TCP 的内部结构

每一个 TCP 套接字,在内核里对应一对缓冲区:

缓冲区 作用
发送缓冲区 存放本端准备发出去、但还没真正发走的字节
接收缓冲区 存放已经从网络收到、但还没被应用读走的字节

这两个缓冲区都在 OS kernel 里,不在用户空间。应用层能拿到的只有一个 sockfd------一个指向这份内核资源的文件描述符。

6.2 write 没有把数据发到网络上

调用 write(sockfd, buffer, strlen(buffer)) 时发生的事情是:

复制代码
用户空间的一块内存  ──拷贝──▶  TCP 发送缓冲区

就这些。数据没有出网卡。

用户态程序没有权限直接操作网卡,也没有权限决定什么时候把数据推上线。它能做的只有一件事:把数据交给内核,然后返回。

所以 write 的返回值是「成功拷贝了多少字节」,而不是「对方收到了多少」。这两个数字之间没有任何保证关系。

💡 一个准确的类比:寄快递。把包裹交给楼下的驿站、填单子、拿到回执,然后就回家了。包裹什么时候发车、走哪条路线、路上出不出问题,全由快递公司决定------寄件人既不知道也管不着。

6.3 那什么时候发

write 返回之后,这串字节就在发送缓冲区里等。什么时候发、发多少、出错了怎么办,由 TCP 协议自主决定。

这就是 TCP 全名的由来------传输控制协议(Transmission Control Protocol)。「控制」控制的就是这件事:

问题 谁决定
什么时候发 TCP
一次发多少 TCP(受对端接收缓冲区剩余空间、拥塞窗口、MSS 等影响)
出错了怎么办 TCP(超时重传、快重传、拥塞控制)

应用层插不上手,也不需要插手。

6.4 内核把数据送到网络,也是拷贝

继续往下看:TCP 把数据交给网络层,网络层交给链路层,最后交给网卡------每一步都是把数据从一个地方搬到另一个地方。

复制代码
内存  ──拷贝──▶  网卡设备  ──▶  网络

对端则反过来:网卡先收到,交给内核,内核放进接收缓冲区。

6.5 read 也不是从网络读

read(sockfd, buffer, n) 做的事情是:

接收缓冲区状态 read 的行为
空 阻塞,进程被挂起等待
有数据 把数据拷贝到用户空间,返回拷贝的字节数

所以「read 从网络读数据」这个说法在实现层面是不成立的。它读的是本机内核里的接收缓冲区------一个纯粹的内存区域。

6.6 ★ 三条结论

结论一:write / read 的本质不是收发,而是拷贝。

  • write:用户空间 → 内核发送缓冲区
  • read:内核接收缓冲区 → 用户空间

这个认识可以推广:往显示器打印(printf)也是拷贝,往文件里写也是拷贝。整个计算机体系里,所谓「IO」几乎都是「把数据从一个地方拷到另一个地方」。

结论二:主机间通信的本质,是把发送方的发送缓冲区里的数据,拷贝到对端的接收缓冲区。

复制代码
A 的发送缓冲区  ──网络──▶  B 的接收缓冲区

不考虑上层用户的那两层拷贝,网络通信本身就是一个「缓冲区到缓冲区」的搬运过程。

结论三:发送和接收是分离的,所以一个 fd 既能读又能写。

发送缓冲区和接收缓冲区是两个独立的队列,互不干扰。同一个 sockfd 背后同时挂着这两个队列,因此读和写可以同时进行------这就是 TCP 全双工的由来。


七、★ 四组生产者消费者模型

把上面那条「内核往缓冲区写、用户从缓冲区读」的关系单独拎出来看,会发现它和进程间通信里讲过的一个经典结构完全同构:

角色 在缓冲区上的动作
操作系统(内核) 把从网络收到的数据写入接收缓冲区
上层用户(应用) 通过 read 从接收缓冲区读出数据

一个写、一个读,中间隔着一块固定大小的内存------这正是生产者消费者模型。

7.1 阻塞的真正原因

一旦认清了这是个生产者消费者模型,read 的阻塞就不再是「网络慢」这种模糊说法,而是精确的同步行为:

情形 行为 原因
接收缓冲区为空 read 阻塞 消费者没有东西可消费
发送缓冲区写满 write 阻塞 生产者没有空位可放

这与管道的行为完全一致:管道写满时不允许再写,管道为空时读端阻塞。两者都是同一种同步机制在不同对象上的表现。

7.2 为什么是「四组」

一次完整的双向通信里,缓冲区一共有四个:

编号 缓冲区 生产者 消费者
① A 的发送缓冲区 A 的应用(write) A 的内核(TCP 取走发送)
② B 的接收缓冲区 B 的内核(TCP 收到写入) B 的应用(read)
③ B 的发送缓冲区 B 的应用(write) B 的内核(TCP 取走发送)
④ A 的接收缓冲区 A 的内核(TCP 收到写入) A 的应用(read)

所以一个 TCP 连接在结构上就是四组生产者消费者模型,两组负责一个方向,互不干扰。


八、★★ TCP 面向字节流:一次 read 不等于一条消息

8.1 先看 UDP 为什么简单

UDP 是面向数据报 的:sendto 一次,内核就把这一整块做成一个 UDP 报文发出去;对端 recvfrom 一次,就拿到这一个完整的报文。

复制代码
发一个报文  ──▶  收一个报文        (严格 1 : 1)

所以 UDP 里「一次读到一个完整消息」这个假设是成立的,代码写起来简单得多。

8.2 TCP 就不是了

TCP 是面向字节流 的。发送方 write 把字节放进发送缓冲区,此后:

  • 内核可能只把其中一部分发出去(对端接收缓冲区剩余空间不够、拥塞窗口限制);
  • 也可能攒了好几条一起发(Nagle 算法、发送窗口足够大)。

到了接收端,接收缓冲区里就是一条连续的字节流,里面的边界信息已经彻底丢失。

于是接收方的 read 会出现三种情况:

情况 现象
一次读到半条 消息还在路上,只到了一半
一次读到一条半 两条消息黏在一起(即所谓粘包)
一次读到三条 对端发了三次,缓冲区里攒着

发送次数与接收次数之间,没有任何 1:1 的对应关系。

💡 三个熟悉的对照,能立刻感受到这件事:

  1. 管道 :写端一直写、读端一直不读,等读端终于 read 一次,可能把积攒的历史数据全读上来了。
  2. 文件:往文件里写十个字符串很爽,读的时候很难受------因为没有分隔符,不知道该在哪里断开。
  3. 文件里混写类型 :写进去一个 int、一个 double、一个字符串,读的时候必须精确知道每一段多长,否则全乱。

这三种场景的共同点是:它们都是面向字节流的,边界都得自己维护。 当时写管道和文件代码「读起来难受」,根源就是缺一份协议约定。

8.3 半个报文不能反序列化

这是整条链条上最要命的一环:

复制代码
读到半个报文  ──直接反序列化──▶  ✖ 必然出错

反序列化函数按约定的格式去解析字段,如果字节还没到齐,它会把后半段的垃圾数据当成字段内容,解析结果完全错误却不报错。

正确做法只有一个:

复制代码
读到半个  →  先不处理,继续 read  →  攒够一条完整报文  →  再反序列化

「攒够一条」这件事,网络层不会做,内核不会做,只能由应用层自己做。


九、那行 read 的 bug,现在可以正式判定

回到课堂开头那个被标上「其实是有 bug 的」的写法:

c 复制代码
char buffer[1024];
ssize_t n = read(sockfd, buffer, sizeof(buffer) - 1);
buffer[n] = '\0';   // 当作一个完整的字符串处理

假设这个服务器要处理的是翻译请求,那么它隐含了三个假设,每一个都不成立:

假设 为什么错
① 一次 read 读到一整条消息 可能只读到半条,也可能读到一条半
② 一行/一句 = 一条消息 TCP 没有「行」的概念,换行符只是一个普通字节
③ 超长消息被截断 1024 装不下的部分留在缓冲区,下一轮被当成新消息读走

buffer 里读到的不一定是一个完整的单词------可能是半个单词,可能是一个半,甚至可能是五个半。

顺带说明:同样的写法在 UDP 版本里是正确的。因为 UDP 面向数据报,recvfrom 一次就一定拿到一个完整报文。这个 bug 是 TCP 特有的。


十、接下来要自己定的协议:两件事

把前面所有结论收拢,未来的自定义协议必须同时提供两个功能:

功能 要求 为什么
① 结构化字段 + 序列化方案 约定好有哪些字段、什么类型、什么顺序,并提供序列化与反序列化的实现 让消息能过网络,且两端语义一致
② 解决读取不完整 读取时保证拿到的是完整的一条报文 TCP 面向字节流,边界只能自己找

关于第 ② 点有一个明确的取舍:只处理读取侧。

发送侧不需要操心「一条消息该发几次」,因为 write 只是往发送缓冲区拷字节,怎么发由 TCP 决定------应用层控制不了,也不必控制。而读取侧必须自己保证完整性,否则反序列化必然出错。


十一、总结速查表

11.1 概念对照

概念 定义 一句话记忆
应用层协议 双方约定的报文格式 报文长什么样,由写代码的人自己定
结构化数据 struct / class 多个字段打包在一起
序列化 结构化数据 → 字节流 由多变一,为了发送
反序列化 字节流 → 结构化数据 由一变多,为了处理
面向数据报 一次发一个报文,一次收一个报文 UDP,边界天然存在
面向字节流 只有连续字节,没有边界 TCP,边界必须自己找

11.2 read / write 的真实语义

接口 常被误解为 实际是
write 把数据发到网络上 ★ 用户空间 → 内核发送缓冲区
read 从网络读数据 ★ 内核接收缓冲区 → 用户空间
返回值 对方收到了多少 本次拷贝了多少字节
何时发、发多少 由 write 的次数决定 ★ 由 TCP 自主决定

11.3 直接发结构体 vs 序列化

维度 直接发结构体 序列化
内存对齐 ✖ 两端必须完全一致 ✔ 不受影响
跨语言 ✖ 只能同语言 ✔ 任意语言
字段变更 ✖ 两端必须同时重编 ✔ 改完重新序列化即可
含指针 / 容器 ✖ 只能浅拷贝,丢数据 ✔ 按字段逐个编码
效率 ✔ 字节数最少 略差
适用场景 内核内部(同一份 C 代码) ★ 应用层
相关推荐
wuminyu2 小时前
Java字节码学习总结:字节码如何加载、解析和执行
java·linux·c语言·jvm·c++
十年磨一剑~2 小时前
Linux 内核中断处理机制与软硬件协同流程
linux·嵌入式硬件
Ruiery2 小时前
Linux 6.6内核并发与同步(五):mutex 可睡眠锁
linux·运维·服务器
莫晓燚3 小时前
双机部署若依 (RuoYi‑Vue) 传统环境部署---实战
linux·运维·数据库·redis·mysql·nginx·ruoyi
CQU_JIAKE4 小时前
10.30【A】
linux
Zevalin爱灰灰4 小时前
Linux入门第二部分(ARM裸机篇) 第一章 开发环境搭建与了解
linux·arm开发·嵌入式
LHICOD5 小时前
Linux-2
java·linux·后端·jar·vmware·svm·vm
余额瞒着我当琳5 小时前
Linux初识:Linux的历史,计算机的发展脉络,发行版本,选购云服务器,安装Xshell,第一次连接,基础命令指南
linux·运维·服务器
垆边人似月.6 小时前
RDMA 文件传输踩坑(四):从固定延时改造为 READY/ACK 协议
linux·服务器·网络