上一篇我们把 socket 编程的套路摸了个遍:怎么建监听、怎么 accept、怎么往缓冲区里读、怎么把数据发出去。但不知道你有没有闪过一丝不安——我们发出去的东西,本质上就是一串"字节",甚至可以说是一串"字符"。收发双方是痛快了,可这串字节到底代表什么意思,谁也没认真约定过。

这一篇,我们就要解决这个"意思"的问题。这就是应用层协议的世界。

先说清楚我们到底站在哪一层。TCP/IP 四层模型里,链路层、网络层、传输层的活基本被操作系统和内核包办了;而我们程序员写的一个个真正解决实际问题、满足日常需求的网络程序,几乎全都工作在应用层。这一层没有哪条铁律规定"你必须怎么组织数据",全靠收发双方自己商量。商量的结果,就是协议。

应用层:我们写的网络程序都在这一层

在往下讲之前,把这一层的地基夯实一点。你写完一个网络服务器,跑起来之后它在网络栈里的位置大概是这样的:

  • 物理层 + 链路层:把比特变成电信号/光信号在网线上传送,处理 MAC 地址、网卡收发。这份脏活由硬件和驱动完成;
  • 网络层:负责数据包的路由和转发,IP 地址就在这一层,它的产物是"数据报";
  • 传输层:端到端的可靠传输,TCP 就在这一层,它的产物是"段";
  • 应用层:你写的代码。HTTP、FTP、DNS、你自定义的一切,都在这一层。

所以你在自己程序里 make 出来的"报文",内核只当它是传输层要搬运的普通字节流——它既不确定你的第一个字节是数字还是字母,也不关心你是要发一个加法运算还是要传一张图片。"这些字节该怎么解读",是应用层自己说了算的事。 这份自由既是机遇也是负担:没有人规定你必须怎么组织数据,所以一切都得你来自定义。

这也正是"再谈协议"的由头。

到底什么是协议

协议这个词,日常生活中也有。两国首脑见面要谈"协议",其实就是"双方商量好、都认可的约定"。网络协议也是这个意思,只不过约定的对象是"数据长什么样、怎么填、怎么解析"。

一句话:协议就是双方约定好的结构化数据。 只要一端发送时构造数据的规则,与另一端解析数据时使用的规则完全一致,那么这两端就共享同一个协议,数据就能被正确理解。

这就带出一个特别重要的推论:协议的定义是双向的、对称的,而且没有任何"官方指定答案"。 只要你能保证"发"和"收"对齐,你用任何格式都会被大家接受。下面我们就用一个具体的例子,把这句话在一次实际的收发里坐实。

再谈协议:一份约定的两种打开方式

来看一个最经典的入门场景——网络版计算器。我们要在一台主机上跑一个服务器,在另一台主机上跑一个客户端。客户端把两个加数发给服务器,服务器算好结果再发回来。就这么简单。

问题是:这两个数,用什么格式送给服务器?

约定方案一:人类可读的字符串

我们约定,客户端发送一个形如 "1+2" 的字符串,并且:

  • 这个字符串里有两个操作数,都是整数;
  • 两个数字之间有一个字符是运算符,本版本运算符只能是 +;
  • 数字和运算符之间没有空格;
  • 为了防止"负数、多位数、运算符撞上"这类歧义,再多定几条细则……

这样服务器收到后,就逐字符解析这个字符串,把 "1+2" 拆成 1、+、2,算得 3,再按约定把 "3" 这个字符串发回去。

这个方案看起来没什么问题,但你能明显感到它"脆":规则全是文字约定,解析要靠一堆字符串处理代码,一旦出现 "+-1+-2" 这种边缘情况,规则立刻需要打补丁。更麻烦的是,当我们要传输的数据不是"两个整数加一个符号",而是"用户名、密码、头像、一组好友、登录时间"这种结构复杂的数据时,纯字符串方案的解析工作会指数级膨胀。

约定方案二:先定义结构体,再转成字符串

我们换个思路:不直接传字符串,而是先在程序里定义一个结构体,用来表示要交互的信息:

  • 定义结构体(比如"请求"里有加数 x、加数 y、运算符 oper 三个字段);
  • 发送数据时,把这个结构体按照一套规则"翻译"成一个字符串,再送给 socket;
  • 接收数据时,再按照同一套规则把这个字符串"翻译"回结构体;
  • 这个"翻译"过程,就是我们接下来要浓墨重彩讲的序列化和反序列化。

对比一下你就发现,方案一和方案二本质是同一件事的两种姿势:都是在"内存里的数据"和"线上传输的字节"之间做翻译,只不过方案一直接拿字符串打交道,方案二先用结构体建模、再翻译成字符串。无论采用哪种,只要保证一端发送时构造的数据,在另一端能被正确解析,就是一段好用的应用层协议。

而为了真正理解协议内部的这些门道,我们决定手把手把方案二完整实现一遍——从 Socket 封装,到结构体定义,到序列化/反序列化,再到最头疼的"粘包",一整套走下来,你对"协议到底是怎么设计的"就彻底通了。

序列化和反序列化:协议的心脏

在动手写代码前,得先把这两个词彻底讲清。它们是应用层协议里出镜率最高、却常被误会的一对概念。

序列化(Serialize)

序列化是指:把一个"进程内存里的结构化数据"(结构体、对象、类实例)转换成一串连续的、可以放到网络上传输、或者存到文件里的字节序列。形象点说,内存里的数据本来是一块有着明确字段布局的"立体模型",而网络只能传"一字排开的字节流",序列化就是把这个立体模型"压扁拍平"成一条线。

为什么要这样做?因为结构体里的字段在内存里是靠"偏移量 + 类型"来定位的,这种布局只有运行中的同一个程序才懂。而数据要在两台主机、甚至两种语言之间传输,对端不可能共享你的内存布局。所以我们必须先把它翻译成"纯粹由字节构成、不带任何内存假设"的形式,对端拿到后再按自己的规则重建。

反序列化(Deserialize)

反序列化就是序列化的逆操作:把网络上传来的那一串连续字节,按照约定的规则重建回对应类型的结构化数据。发送端的结构体对象,在接收端被"复活"成一个字段值相同的结构体对象。

打个比方:序列化像是把一卡车货先装箱贴上清单发出去,反序列化像收货方拿到箱子和清单、照着清单把货再一样样摆回仓库的原位置。 只有"装箱规则"和"摆货规则"完全一致,货才不会摆错位。这也是协议里"对称"二字的全部含义。

序列化的三种"打开方式"

既然要翻译,用什么格式翻译?本篇文章重点覆盖三种主流选择,各有适用场景,我们后面都会展开有完整代码的实现:

  1. 直接用 C 结构体翻译(二进制序列化):把结构体的内存字节原样发出去。速度快、体积小,但坑极多(字节对齐、端序),对跨语言/跨平台不友好,适合限定场景的网络内部使用;
  2. 用 JSON 翻译(文本序列化):把结构体翻译成 {"datax":1,"oper":37,"datay":2} 这种带字段名的文本。用现成库(我们讲 jsoncpp)处理,跨语言友好、可读性强,是当前互联网应用层的主流;
  3. 自描述方案:在报文里额外携带"这是什么类型、占多长"这样的元信息,让接收方拿到手就能自己理解这是一份什么样的数据,而不必被"固定死在第几种协议"。

这三个方案不是互斥的,成熟的协议往往组合使用。我们逐个实现。

重新理解 read / write / recv / send:TCP 为什么是全双工

在正式写代码之前,还有一个底层认知需要补齐,否则后面讲"为什么读不到完整报文"时会卡壳。这就是 socket 为什么会变成"全双工"。

回忆一下:TCP 连接建立后,你在任何一个 sockfd 上,既可以用 recv 读,也可以用 send 写,而且是"边读边写"互不阻塞。为什么同一个 socket 既能读又能写?因为在内核里,一个 TCP 连接同时拥有两个缓冲区:发送缓冲区(send buffer)和接收缓冲区(recv buffer),它们彼此独立。

  • send 并不是直接把数据"怼"到对端,而是把数据拷贝进本端的发送缓冲区,之后"什么实际发出去、一次发多少、发错了怎么办、要不要重传",由 TCP 协议自己控制——这就是为什么 TCP 全名叫传输控制协议(Transmission Control Protocol)。对端收到后,先落进它自己那端的接收缓冲区;
  • 你的 recv 也不是直接到对端去"取",而是从本端的接收缓冲区里拿数据。

因为"发"走发送缓冲区、"收"走接收缓冲区,两者是两块不同的内存、两套独立的队列,所以可以同时进行——这就是 TCP 全双工的底层原因:收发路径互不依赖,各自有独立的缓冲区。 这也顺带解释了为什么一个 sockfd 就同时兼了"读"和"写"两个角色:它背后挂着的,本来就是两套各自独立的内核数据结构。

而"两个缓冲区彼此独立"这个特性,又直接引出我们后面要花大篇幅解决的粘包问题:接收缓冲区的数据不是一条条"有界"的,而是源源不断堆在一起的连续字节流,你得自己负责"切分"。先记下这个伏笔。

开始实现:先看整体代码结构

好,理论和底层都铺垫完了,现在动手。下面是我们这一整套的工程文件结构:

Calculate.hpp    Protocol.hpp    Socket.hpp
Daemon.hpp       Makefile
TcpServerMain.cc TcpClientMain.cc
  • Socket.hpp:把 socket 的建、绑、听、连、收、发封装起来,屏蔽底层细节;
  • Calculate.hpp:真正的"业务逻辑"——拿到两个加数和运算符,算出结果;
  • Protocol.hpp:本篇文章的主角。定义 Request/Response 结构体、序列化/反序列化、以及解决粘包的 Encode/Decode;
  • TcpServerMain.cc:服务器入口;
  • TcpClientMain.cc:客户端入口。

为了把时间花在真正有干货的协议设计上,我们不打算写一堆"用户交互输入"的界面代码,直接让客户端循环发送、服务器循环响应,把通信链路跑通即可。

先看第一块:把 socket 包装成一个类,让我们后面写服务器和客户端时只调取干净的方法。

Socket 封装:用模板方法把底层细节藏起来

socket.hpp 的完整实现如下。整段代码严格遵守 C++11 及以上标准,可在 Linux 下直接编译。我用了设计模式里的模板方法模式:把"创建→绑定→监听"这类固定流程写进基类的非虚方法里,把"每一步具体怎么实现"留给子类去覆写虚函数。这样流程框架稳定,细节又可替换。

// socket.hpp
#pragma once                                // 防止头文件被重复包含
#include <iostream>                          // C++ 标准输入输出流(便于调试打印)
#include <string>                            // std::string
#include <cstring>                           // memset、strerror
#include <sys/types.h>                       // socket 相关的底层类型
#include <sys/socket.h>                      // socket/bind/listen/accept/connect/recv/send/close
#include <netinet/in.h>                      // struct sockaddr_in
#include <arpa/inet.h>                       // htons/htonl/ntohs/ntohl/inet_addr/inet_ntoa
#include <unistd.h>                          // close
 
// 把"socket 协议族地址结构"通用地转成 sockaddr* 的简化宏
// sockaddr_in 和 sockaddr 内存布局兼容,传递前需要强转类型
#define Convert(addrptr) ((struct sockaddr *)addrptr)
 
namespace Net_Work
{
    const int defaultsockfd = -1;            // 表示"无效 fd"的默认值
    const int backlog = 5;                   // listen 的等待连接队列长度
 
    enum { SocketError = 1, BindError, ListenError };  // 各类失败对应的退出码
 
    // 基类:Socket 接口。定义"连招"式的流程骨架,具体动作交给子类实现
    class Socket
    {
    public:
        virtual ~Socket() {}                              // 虚析构,保证多态删除正确
 
        // ---- 纯虚函数:由派生类实现的具体原子操作 ----
        virtual void CreateSocketOrDie() = 0;             // 创建一个 socket
        virtual void BindSocketOrDie(uint16_t port) = 0;  // 绑定端口
        virtual void ListenSocketOrDie(int backlog) = 0;  // 进入监听
        virtual Socket *AcceptConnection(std::string *peerip, uint16_t *peerport) = 0; // 接受连接
        virtual bool ConnectServer(std::string &serverip, uint16_t serverport) = 0;   // 主动连接
        virtual int GetSockFd() = 0;                      // 拿到底层 fd
        virtual void SetSockFd(int sockfd) = 0;           // 设置底层 fd
        virtual void CloseSocket() = 0;                   // 关闭 socket
        virtual bool Recv(std::string *buffer, int size) = 0; // 读取一次数据
        virtual void Send(std::string &send_str) = 0;     // 发送字符串
 
    public:
        // 模板方法:把"监听端"的三板斧串起来,子类只需各自实现
        void BuildListenSocketMethod(uint16_t port, int backlog)
        {
            CreateSocketOrDie();     // 第 1 步:创建
            BindSocketOrDie(port);   // 第 2 步:绑定端口
            ListenSocketOrDie(backlog); // 第 3 步:监听
        }
 
        // 模板方法:把"客户端"的建连两步串起来
        bool BuildConnectSocketMethod(std::string &serverip, uint16_t serverport)
        {
            CreateSocketOrDie();                      // 第 1 步:创建
            return ConnectServer(serverip, serverport); // 第 2 步:连接
        }
 
        // 模板方法:accept 返回的已经是一个已建立的连接 fd,直接"接管"即可
        void BuildNormalSocketMethod(int sockfd)
        {
            SetSockFd(sockfd);
        }
    };
 
    // 派生类:基于 TCP 的具体实现
    class TcpSocket : public Socket
    {
    public:
        TcpSocket(int sockfd = defaultsockfd)
            : _sockfd(sockfd)
        {
        }
 
        ~TcpSocket()
        {
            CloseSocket();          // 析构时顺手关掉 fd,防止泄漏
        }
 
        // 创建一个 TCP 流式 socket
        void CreateSocketOrDie() override
        {
            _sockfd = ::socket(AF_INET, SOCK_STREAM, 0); // AF_INET:IPv4,SOCK_STREAM:可靠字节流
            if (_sockfd < 0)                             // 创建失败
                exit(SocketError);
        }
 
        // 绑定一个监听端口
        void BindSocketOrDie(uint16_t port) override
        {
            struct sockaddr_in local;                   // 本地地址结构
            memset(&local, 0, sizeof(local));           // 清零,避免脏数据
            local.sin_family = AF_INET;                 // IPv4 协议族
            local.sin_addr.s_addr = INADDR_ANY;         // 绑定所有网卡地址
            local.sin_port = htons(port);               // 端口转成网络字节序
            int n = ::bind(_sockfd, Convert(&local), sizeof(local));
            if (n < 0)                                  // 绑定失败
                exit(BindError);
        }
 
        // 进入监听状态
        void ListenSocketOrDie(int backlog) override
        {
            int n = ::listen(_sockfd, backlog);          // backlog 指定连接队列长度
            if (n < 0)                                   // 监听失败
                exit(ListenError);
        }
 
        // 接受一个连接,把对端 ip/port 填出来,并返回一个专门和它通信的新 socket
        Socket *AcceptConnection(std::string *peerip, uint16_t *peerport) override
        {
            struct sockaddr_in peer;                    // 对端地址
            socklen_t len = sizeof(peer);               // 地址结构长度(输入输出参数)
            int newsockfd = ::accept(_sockfd, Convert(&peer), &len);
            if (newsockfd < 0)
                return nullptr;                         // 失败返回空指针
            *peerport = ntohs(peer.sin_port);           // 对端端口转回主机字节序
            *peerip = inet_ntoa(peer.sin_addr);         // 对端 IP 转成可读字符串
            Socket *s = new TcpSocket(newsockfd);       // 用它的 fd 包一个新对象
            return s;
        }
 
        // 作为客户端,主动连接服务器
        bool ConnectServer(std::string &serverip, uint16_t serverport) override
        {
            struct sockaddr_in server;                  // 服务器地址结构
            memset(&server, 0, sizeof(server));
            server.sin_family = AF_INET;                // IPv4
            server.sin_addr.s_addr = inet_addr(serverip.c_str()); // 文本 IP 转二进制
            server.sin_port = htons(serverport);        // 端口网络字节序
            int n = ::connect(_sockfd, Convert(&server), sizeof(server));
            return n == 0;                              // n==0 表示连接成功
        }
 
        int GetSockFd() override { return _sockfd; }
        void SetSockFd(int sockfd) override { _sockfd = sockfd; }
 
        void CloseSocket() override
        {
            if (_sockfd > defaultsockfd)                // fd 有效才关闭
                ::close(_sockfd);
        }
 
        // 读取一次数据,追加到 buffer 尾部。关键点:可能要读多次才积累成完整报文
        bool Recv(std::string *buffer, int size) override
        {
            char inbuffer[size];                        // 临时缓冲区(VLA,需编译器支持)
            ssize_t n = recv(_sockfd, inbuffer, size - 1, 0); // 读最多 size-1 字节
            if (n > 0)
            {
                inbuffer[n] = 0;                        // 手动补字符串结束符
                *buffer += inbuffer;                    // 故意"拼接"——留到协议层去切分
                return true;
            }
            else
            {
                return false;                           // n==0 对端关闭;n<0 出错
            }
        }
 
        void Send(std::string &send_str) override
        {
            // 简单的发送:这里不处理"一次没发完"的情况(完整版本可加循环发送)
            send(_sockfd, send_str.c_str(), send_str.size(), 0);
        }
 
    private:
        int _sockfd;                                    // 底层的文件描述符
    };
}

我特意在 Recv 里写了句注释"故意拼接",因为它是理解后面一切的关键。recv 一次能读到多少,只取决于此刻接收缓冲区里堆了多少字节,跟"你逻辑上一个报文多长"毫无关系。所以 Recv 只负责"把这一次读到的原始字节存进一个累加字符串",至于里面是半个报文、一个报文还是十个报文,全交给协议层去切分。缓冲区累积的想法,就是下文粘包解法的伏笔。

补充说明:可变长数组(VLA)

上面 char inbuffer[size]; 里 size 是运行时变量,这叫变长数组。它在 C99 里是标准特性,在 C++ 里虽属编译器扩展、不保证所有编译器支持,但 g++ 在 Linux 上默认可用。更稳妥的写法是用 std::vector<char>,但课堂示例为了直观用了 VLA。如果你想追求最严格的可移植性,可以改成 std::string inbuffer(size, '\0');,效果等价且更地道——读者可以自己替换试试。

定制协议:定义我们的 Request 和 Response

业务层要交互的,本质上就是三种信息:客户端发出的请求、服务器返回的响应。我们给它们各定义一个结构体,这三个字段的摆放,就是协议最核心的骨架。

// Protocol.hpp 中的协议骨架(先看结构定义)
class Request   // 客户端的请求
{
private:
    int _data_x;   // 第一个加数
    int _data_y;   // 第二个加数
    char _oper;    // 运算符:+ - * / %
    // 报文的自描述字段:约定一次完整报文的格式为 "len\r\nx op y\r\n"
    // 其中 \r\n 不属于报文的数据部分,只是我们约定的分隔符
};
 
class Response  // 服务器的响应
{
private:
    int _result;   // 运算结果
    int _code;     // 运算状态码(0 表示成功,非 0 表示出错,如除零)
    // 报文格式: "len\r\nresult code\r\n"
};

注意我在注释里反复强调 \r\n:它不属于报文的数据部分,是我们额外约定的"分隔符" ——这是协议设计里非常重要的一种"元数据"思想:报文不但有"数据",还得有描述这份数据的"格式信息"(长度、类型、分隔标记)。这些信息帮接收端从没有边界的字节流里把一条条报文"抠"出来。

好了,骨架有了,接下来要给它填上两个关键能力:

  1. 在内存结构体 与 字符串(线上格式)之间互转 —— 这就是序列化/反序列化;
  2. 在"字符串"与"字节流中的完整报文"之间互转 —— 这就是解决粘包的编解码(Encode/Decode)。

而我们选的序列化方案,是在这套骨架里直接套用现成的成熟库——jsoncpp。上一节说的第一种方案(结构体二进制直发)我会在后面的进阶篇单独补一个完整实现。

Jsoncpp:成熟的序列化方案

在我们自制协议的很多细节之前,先认识一下选定的序列化工具——jsoncpp。

JSON(JavaScript Object Notation)是一种轻量级的、非常流行的数据交换格式,本身是一段人类能读懂的文本,形如 {"name":"joe","age":12}。jsoncpp 是处理 JSON 的一个开源 C++ 库:它能把你程序里的结构数据序列化成 JSON 字符串,也能把 JSON 字符串反序列化回 C++ 结构。它开源、轻量、在本主题的课堂环境里被广泛采用。

安装(二选一,取决于你的发行版):

# Ubuntu / Debian 系
sudo apt-get install libjsoncpp-dev
 
# CentOS / RHEL 系
sudo yum install jsoncpp-devel

Linux 下的头文件路径通常是 /usr/include/jsoncpp/json/json.h,所以代码里要用 #include <jsoncpp/json/json.h>(而不是 <json/json.h>)。编译时要链接它:

g++ -std=c++17 mycode.cc -o myapp -ljsoncpp

极速上手:Json::Value 序列化

jsoncpp 的核心是 Json::Value。它是一个"动态的、能表示任何 JSON 值"的容器:往里面塞键值对、数组、嵌套对象都行。序列化就是把 Json::Value 变成字符串。常见写法有三种,各有用武之地:

// json_serialize_demo.cc —— 三种序列化写法对比
#include <iostream>
#include <string>
#include <jsoncpp/json/json.h>   // jsoncpp 统一头文件,路径见上文说明
 
int main()
{
    Json::Value root;            // 一个用于装 JSON 数据的容器
    root["name"] = "joe";        // 塞入键值对:name -> "joe"
    root["sex"] = "男";          // 中文也能存,JSON 底层是 UTF-8
    root["age"]  = 30;           // 数字类型直接就能塞进去
 
    // 写法 1:toStyledString()。输出带缩进和换行的"美化"格式,方便人读/调试
    std::string styled = root.toStyledString();
    std::cout << "=== toStyledString ===" << std::endl;
    std::cout << styled;
 
    // 写法 2:StreamWriter。自由度最高,可定制缩进/分隔符(这里演示基本用法)
    Json::StreamWriterBuilder wbuilder;              // 一个 Writer 工场
    std::unique_ptr<Json::StreamWriter> writer(wbuilder.newStreamWriter()); // 创一个 Writer
    std::ostringstream ss;                           // 把输出接到字符串流里
    writer->write(root, &ss);                        // 写入流
    std::cout << "=== StreamWriter ===" << std::endl;
    std::cout << ss.str();
 
    // 写法 3:FastWriter。速度最快,紧凑无缩进无多余换行,适合线上传输省带宽
    Json::FastWriter fwriter;
    std::string fast = fwriter.write(root);
    std::cout << "=== FastWriter ===" << std::endl;
    std::cout << fast;
    return 0;
}

运行结果大致是这样(顺序对应三种写法):

{
   "age" : 30,
   "name" : "joe",
   "sex" : "男"
}
{ "age" : 30, "name" : "joe", "sex" : "男" }
{"age":30,"name":"joe","sex":"男"}

可以看到 FastWriter 最紧凑。线上传输我们一般用 FastWriter(省字节),本地调试才用 toStyledString(好阅读)。 这正是序列化在"体积"和"可读性"之间的常见取舍。

反序列化:Json::Reader 把字符串变回结构

反向操作用 Json::Reader。它有两个本篇文章很关心的优点:一是解析能力好,二是解析失败时能给出详细的错误信息,方便定位"到底哪行 JSON 写坏了"。

// json_deserialize_demo.cc —— 反序列化示例
#include <iostream>
#include <string>
#include <jsoncpp/json/json.h>
 
int main()
{
    // 一段 JSON 字符串(注意字符串里的引号要转义)
    std::string json_string = "{\"name\":\"张三\", \"age\":30, \"city\":\"北京\"}";
 
    Json::Reader reader;                 // 解析器
    Json::Value root;                    // 解析出来的值会放进这里
 
    bool ok = reader.parse(json_string, root);  // 核心:把字符串解析进 root
    if (!ok)
    {
        // 解析失败:打印详细错误信息和出错位置,强烈建议每个 parse 都这样处理
        std::cout << "Failed to parse JSON: "
                  << reader.getFormattedErrorMessages() << std::endl;
        return 1;
    }
 
    // 从 root 里按键取值,并用 asXxx() 转成希望的类型
    std::string name = root["name"].asString();
    int age = root["age"].asInt();
    std::string city = root["city"].asString();
 
    std::cout << "Name: " << name << std::endl;
    std::cout << "Age: " << age << std::endl;
    std::cout << "City: " << city << std::endl;
    return 0;
}

Json::Value 的常用"武器库"

实战中你会频繁用到这些 Json::Value 的能力,我按类别列出来便于查阅(详细方法清单藏在 jsoncpp 头文件里,这里只挑高频的):

类型判断(用于"保守起见先看看是什么类型"):isNull()、isInt()、isInt64()、isUInt()、isDouble()、isString()、isArray()、isObject()、isBool()。

取类型(asXxx 系列):asInt()、asInt64()、asUInt()、asUInt64()、asDouble()、asString()、asBool()。这些方法会把值尽力转成你要的类型;但若类型根本不兼容(比如拿字符串当整数转),行为取决于编译版本,所以先 isXxx 判断再 asXxx 取值更稳妥。

访问元素:root["key"] 通过键访问对象成员;root[i] 通过下标访问数组成员。注意一个隐蔽点:root["不存在的键"] 并不会报错,而是自动创建一个空值。所以如果你想区分"键存在但值是空"和"键根本不存在",需要用 root.isMember("key") 先判断。

数组操作:append(value) 往数组尾部加一个元素;size() 取数组/对象的元素个数;empty() 判断是否为空。

我们把 Request/Response 的序列化与反序列化用 jsoncpp 填上,这一节就算闭环了。

把 Request / Response 接入网络:完整 Protocol 设计

下面给出完整的 Protocol.hpp。它集齐了我们前面所有概念:结构体定义、序列化/反序列化(用 jsoncpp)、以及为了解决粘包而设计的 Encode/Decode。还用了一个简单的工厂来帮我们规范地创建 Request/Response 对象。

// Protocol.hpp —— 协议层的完整实现
#pragma once
#include <iostream>                 // 调试打印
#include <memory>                   // std::shared_ptr / make_shared
#include <string>
#include <cassert>                  // assert,防御式检查用
#include <jsoncpp/json/json.h>      // jsoncpp 序列化库
 
namespace Protocol
{
    // 分隔符约定:字段之间的空格、一行报文内部的换行分隔符
    const std::string ProtSep = " ";
    const std::string LineBreakSep = "\r\n";   // 注意是 CRLF 两个字符
 
    // -----------------------------------------------------------
    // 一、报文编解码:解决"粘包"
    // 我们的线上报文格式约定为:
    //            "len\r\n" + 真正的数据 + "\r\n"
    // 即:长度字段 + 分隔符 + 数据 + 结束分隔符
    // \r\n 不属于报文的数据部分,只是我们约定的边界标记。
    // -----------------------------------------------------------
 
    // 编码:给一段"已经是字符串的报文数据"加上"长度前缀 + 分隔符",打包成可上线的报文
    std::string Encode(const std::string &message)
    {
        std::string len = std::to_string(message.size());   // 用十进制文本表示长度
        std::string package = len + LineBreakSep + message + LineBreakSep; // 拼装
        return package;
    }
 
    // 解码:从"可能包含多段报文"的字节流里,尝试从头部切出一段完整报文。
    // 若头部凑不齐一条完整报文则返回 false;切成功则返回 true 并把数据填进 message。
    bool Decode(std::string &package, std::string *message)
    {
        // 第 1 步:找第一个 \r\n,它把"长度"和"数据"隔开
        auto pos = package.find(LineBreakSep);
        if (pos == std::string::npos)   // 连第一个分隔符都没找到 => 数据还不全
            return false;
 
        // 第 2 步:长度字段 = 开头到第一个 \r\n 之间那段文本
        std::string lens = package.substr(0, pos);
        int messagelen = std::stoi(lens);        // 把它转成整数(ASCII -> int)
 
        // 第 3 步:算这条完整报文总共占多少字节
        //        = "长度数字本身的字符数" + 数据长度 + 2 个 \r\n 的大小
        int total = lens.size() + messagelen + 2 * LineBreakSep.size();
 
        // 第 4 步:完整性校验 —— 只有积累了够 total 个字节,才可能是一条完整报文
        if ((int)package.size() < total)
            return false;
 
        // 第 5 步:此时 package 头部必定已经有一条完整报文,把它抠出来
        *message = package.substr(pos + LineBreakSep.size(), messagelen);
 
        // 第 6 步:把已消费的 total 字节从缓冲区头部删掉,剩下的留给下一次
        package.erase(0, total);
        return true;
    }
 
    // -----------------------------------------------------------
    // 二、请求与响应:结构的序列化 / 反序列化
    // -----------------------------------------------------------
 
    // 客户端请求
    class Request
    {
    public:
        Request() : _data_x(0), _data_y(0), _oper(0) {}   // 默认构造
        Request(int x, int y, char op) : _data_x(x), _data_y(y), _oper(op) {}
 
        // 序列化:把结构体 -> JSON 字符串
        bool Serialize(std::string *out)
        {
            Json::Value root;
            root["datax"] = _data_x;    // 塞入加数 x
            root["datay"] = _data_y;    // 塞入加数 y
            root["oper"]  = _oper;      // 注意:char 会被 json 当作整型(存的是 ASCII 码)
            Json::FastWriter writer;    // 用最省带宽的写法
            *out = writer.write(root);  // 输出 JSON 字符串
            return true;
        }
 
        // 反序列化:把 JSON 字符串 -> 结构体
        bool Deserialize(std::string &in)
        {
            Json::Value root;
            Json::Reader reader;
            bool res = reader.parse(in, root);   // 解析;失败时 res 为 false
            if (res)
            {
                _data_x = root["datax"].asInt(); // 按键取回
                _data_y = root["datay"].asInt();
                _oper = root["oper"].asInt();    // 取回的是 ASCII 码,拷进 char
            }
            return res;
        }
 
        // 调试:打印结构体内容,方便观察反序列化是否成功
        void Debug()
        {
            std::cout << "_data_x: " << _data_x << std::endl;
            std::cout << "_data_y: " << _data_y << std::endl;
            std::cout << "_oper(int): " << int(_oper) << std::endl;
        }
 
        int GetX() { return _data_x; }
        int GetY() { return _data_y; }
        char GetOper() { return _oper; }
 
    private:
        int _data_x;   // 第一个加数
        int _data_y;   // 第二个加数
        char _oper;    // 运算符
    };
 
    // 服务器响应
    class Response
    {
    public:
        Response() : _result(0), _code(0) {}
        Response(int result, int code) : _result(result), _code(code) {}
 
        bool Serialize(std::string *out)
        {
            Json::Value root;
            root["result"] = _result;   // 运算结果
            root["code"]   = _code;     // 状态码
            Json::FastWriter writer;
            *out = writer.write(root);
            return true;
        }
 
        bool Deserialize(std::string &in)
        {
            Json::Value root;
            Json::Reader reader;
            bool res = reader.parse(in, root);
            if (res)
            {
                _result = root["result"].asInt();
                _code   = root["code"].asInt();
            }
            return res;
        }
 
        void SetResult(int res) { _result = res; }
        void SetCode(int code)   { _code = code; }
        int GetResult() { return _result; }
        int GetCode()   { return _code; }
 
    private:
        int _result;   // 运算结果
        int _code;     // 运算状态(0 成功,非 0 出错)
    };
 
    // 简单的工厂:统一创建对象,未来替换实现时不用改调用方
    class Factory
    {
    public:
        std::shared_ptr<Request> BuildRequest()
        {
            return std::make_shared<Request>();
        }
        std::shared_ptr<Request> BuildRequest(int x, int y, char op)
        {
            return std::make_shared<Request>(x, y, op);
        }
        std::shared_ptr<Response> BuildResponse()
        {
            return std::make_shared<Response>();
        }
        std::shared_ptr<Response> BuildResponse(int result, int code)
        {
            return std::make_shared<Response>(result, code);
        }
    };
}

注意 Deserialize 里 _oper = root["oper"].asInt();。因为 json 把 char 存成整型 ASCII 码,我们取回来时也要当成整形拿,再截断给 char。这在单字节字符(ASCII)下是安全的。

到这里,一个能满足"完整服务器客户端"的协议层就算成型了。但请先别急着跳到服务器——上面那个 Decode 函数是全文最重要也最容易写错的函数。我们得把"为什么要切报文"这件事彻底讲透,否则你只会抄代码、不知道它在解决什么、更不知道它有什么边界没兜住。

字节流、粘包和拆包:为什么 read 一次读不完

现在回到那个困扰无数新手的问题:为什么我 read 一次可能读到的不是一份完整的请求?

要理解它,必须先牢牢记住一个事实:TCP 不是"消息协议",而是"流协议"。 一个 recv 能读出多少字节,只取决于接收缓冲区里此刻真正堆了多少字节;TCP 根本不知道、也不关心"你逻辑上一个请求该有多长"。于是当你连续发多条消息时,接收端缓冲区里的数据是这样堆的:

len\r\nx op y\r\nlen\r\nx op y\r\nlen\r\n x op y...

它们像一条没有刻度的绳索一样连在一起,读的时候你会遭遇两种情况:

  • 粘包:你一次 recv 把两条甚至多条报文一起读出来了。比如一次读了 len\r\nx op y\r\nlen\r\nx op y\r\n,里面其实藏着 2 条完整响应。如果你天真地以为"读一次 = 一条报文",那就解析错了;
  • 拆包(半包):你一次 recv 连一条报文的头都没读全。比如上次只读到了 "le,下次才读到 "n\r\nx op y\r\n"。一条报文被"拦腰截断"散落在多次 recv 里。

所谓粘包/拆包问题,指的就是"流式字节没有边界,导致我们无法从任意两次 recv 的切点上干净地取出一整条报文"这个难题。

请注意一个严谨的表述:粘包/拆包不是 TCP 协议的"bug",而是"流式字节流没有边界"这个特性天然带来的结果。TCP 保证的是"你发的字节顺序、内容不丢、作为可靠字节流到达",它从没承诺"每次读写都是一对一的消息边界"。职责边界是:TCP 负责可靠搬运,应用层负责自己切分。想明白了这点,"为什么 Socket 类里 Recv 要故意把数据拼到一个字符串里"就水落石出了——我们要在应用层维护一个"积累缓冲区",等缓冲区积累到足够判定出一条完整报文时,再去切它。

为什么会出现粘包(从发送端看)

从发送端发力也能产生粘包。假设你连续 send("len\r\n1+2\r\n") 和 send("len\r\n3+4\r\n") 两条数据:

  • 这两条数据可能被 TCP 出于效率考虑(Nagle 算法等)在发送缓冲区里"合并"成一批一起发出去,接收端一次 recv 就同时拿到两条;
  • 也可能每条单独发,但接收端两次 recv 之间时序有差别,导致读者端看到的是"上次的尾巴 + 这次的脑袋"混在一起。

无论成因如何,结果都一样:你无法依赖"一次 recv = 一条消息"。 所以协议层必须提供"把字节流切成一条条报文"的能力,也就是 Encode(切)与 Decode(攒着切)这哥俩要解决的事。

分隔方案的两条底层原则

不管用什么手段,任何 TCP 应用层协议的分包都要守住两条底线:

  1. 不丢数据:切到的每条报文,内容必须和发送端原始报文完全一致,不多一个字节、不少一个字节;
  2. 正确完整:切出来的每条报文必须自洽——不会把"上一条的尾巴"错认成"下一条的头"。

有了这两条质量标准,我们就能评估各种分包手段的优劣了。

粘包问题的三种解决:定长、分隔符、长度前缀

治理粘包,业界公认有三板斧:定长、分隔符、长度前缀。我们先宏观对比,再逐个用代码说话。

方案 A:定长报文

约定:客户端每份报文长度永远等于 N 字节。比如 N = 12,那么每个报文都是 12 字节。接收端只要盯着字节数,每攒够 12 字节就切出一条完整报文,天然不会粘包。

优点:实现最简单,接收端逻辑几乎是"数数"。

缺点非常致命:

  • 不灵活:报文内容超过 N 就必须拆分或失真,内容很短时又白占 N 字节。N 定大了浪费带宽,定小了装不下大报文;
  • 需要处理短内容:不到 N 字节时得用填充(比如补 \0)凑齐,接收端还要会剔除填充,徒增复杂度。

所以定长只在"报文格式极度固定"(比如固定宽度的模拟量采集)的场景有意义;在通用计算器这种内容长短不一的场景,它并不合适。

方案 B:分隔符

约定:在报文之间放一个唯一的特殊标记,比如 \n 或某个自定义字节 ;。接收端"从头扫描,遇到分隔符就切一条",也就是我们 Encode 里用 LineBreakSep(\r\n)干的事。

优点:实现直观、报文内容不限长度,HTTP/1.1 的报文头就是用 \r\n 分行、再用 \r\n\r\n 隔开头和体的。

缺点同样明显:

  • "转义"难题:万一报文内容里本身就含有这个分隔符呢?比如数据恰好是 "1\r\n2",接收端一扫描到 \r\n 就误以为数据结束了——报文被拦腰切断,后面的数据全乱套。解决方案是要引入"转义"(就像 C 字符串里用 \\n 表示换行一样),报文中出现分隔符时必须转成别的写法、接收端再还原;转义逻辑本身又带来新的解析开销和出错面;
  • 扫描成本:每次读数据都要线性扫描找分隔符,数据量大时开销不小。

在我们的计算器场景里,运算数据里几乎不会出现 \r\n,所以转义的需求不强烈,用分隔符是够用的。但你必须心里有数:一旦报文可能携带任意二进制内容,纯分隔符方案就必须配转义。

方案 C:长度前缀(本课件采用,推荐)

约定:在每份报文的头部,先放一个"报文数据长度"字段,接收端先读这个长度,再按长度读对应数量的字节。这正是我们 Decode 干的事:

  • 先找分隔符拿到 len 字段(这里是文本形式的长度);
  • 再据此算出 total,够了才切,不够就等。

这是目前工程上最主流、最稳妥的方案,也是 HTTP/HTTPS 的报文体、绝大多数 RPC 框架、以及你自己将来设计的协议最常用的手段。它的本质是"让报文自带边界信息"——发送端负责标注"这段读多长",接收端负责"按图索骥"。

一个关键的技术分类:文本长度 vs 二进制长度

你可能注意到,我们课件里的长度字段是十进制文本(std::to_string 生成,如 "13"),后面跟 \r\n 作为分隔。而很多二进制协议直接用固定宽度的二进制整数做长度(如 4 字节网络字节序的整数),后面直接跟数据、不再需要分隔符。两者对比:

  • 文本形式长度 + 分隔符:人可读、好调试,长度字段本身可变长("3" 和 "13" 都是合法的);代价是每报文多出分隔符、且要处理"度数字符串再转整数"的琐碎;
  • 二进制形式长度:长度固定(固定占用 N 字节),接收端直接按字节序读出整数,更紧凑、更快、也更适合紧约束的嵌入式/高性能场景;代价是肉眼不可读、并且必须处理网络字节序。

这两种各有好坏,取决于你的报文是偏"文本友好"还是偏"二进制高效"。我们这一篇的课堂实现走文本路线,而在后面"进阶:二进制协议"一节再补一种最纯粹的二进制长度前缀写法。

长度校验的边界

再看一眼我们的 Decode,它有一行 if ((int)package.size() < total) return false;,这行完整性校验极其关键,它守住了三件事:

  1. 防止"长度字段都被 \r\n 切出来了、但实际数据字节还没攒够"时,你强行去 substr 取出越界的空数据;
  2. 防止恶意/异常对端声明的长度值过大,导致你后续分配异常巨大缓冲区、或读进错误的字节;
  3. 保证 package.size() 与 total 比较时把符号问题处理掉——这里我把 size() 强转成 int 再比较,就是担心无符号 size_t 与有符号 int 混用时出现谁也想不到的比较错误(后面"常见坑"会展开)。

还要说明一个提升点:Decode 每次只切出一条报文。当缓存里同时有 5 条报文时,你应该在一个循环里反复调用 Decode 直到它返回 false,才能把这一次 recv 积累的所有报文都消化掉——这一点我会在服务器处理逻辑里示范,这是"一次读取、切出多条"的标准姿势。

期望的报文格式与流式数据的处理

把前面所有片段拼回到一张图上,我们期望的"线上真容"是这样的:

服务端 / 客户端之间,每一条完整报文的格式:
   "len\r\n"   +   "数据正文"   +   "\r\n"
   ↑ 长度字段     ↑ 报文内容(JSON)   ↑ 结束分隔符
 
例如一条"1+2"请求的报文为:
   "4\r\n{\"datax\":1,\"oper\":43,\"datay\":2}\r\n"

其中 len=4 是指后面数据正文 {"datax":1,...} 的长度(这段 JSON 大约 30 来字节,4 只是占位示意,真实长度取决于生成的 JSON 串)。关键是:收到一坨不透明的缓冲区字节后,我们按"找 \r\n → 拿长度 → 校验 → 按期切分 → 删除已消费前缀"这套流程,就能一次次地挤出完整报文。

那么就引出整个协议层最后一块拼图:流式数据到底应该怎么处理? 答案是三步曲:

  1. 积累:把每次 recv 到的原始字节 += 进同一个字符串缓冲区(Recv 已经做了);
  2. 切分:反复调用 Decode,每次切出一条完整报文,切到缓冲区头部不足一条为止;
  3. 处理:每切出一条,就对其中的 JSON 做反序列化,得到 Request/Response 对象,走业务逻辑。

这三步,我会在下面的服务器与客户端里落成真正的代码。

完整实现:服务器与客户端端到端跑通

业务计算部分先放一个 Calculate.hpp:

// Calculate.hpp —— 业务逻辑:真正的"服务器上帝视角"计算
#pragma once
#include <cstdint>   // 不一定要用,这里只是示意习惯
 
namespace Calc
{
    // 返回计算结果;出错时把错误码填到 code(0 表示成功)
    int Compute(int x, int y, char oper, int *code)
    {
        switch (oper)
        {
        case '+':
            *code = 0;
            return x + y;
        case '-':
            *code = 0;
            return x - y;
        case '*':
            *code = 0;
            return x * y;
        case '/':
            if (y == 0)        // 除零:没法算
            {
                *code = 1;      // 错误码 1 表示除零
                return 0;
            }
            *code = 0;
            return x / y;
        case '%':
            if (y == 0)        // 取模除零同理
            {
                *code = 1;
                return 0;
            }
            *code = 0;
            return x % y;
        default:
            *code = 2;          // 错误码 2 表示非法运算符
            return 0;
        }
    }
}

然后是服务器入口 TcpServerMain.cc,它是"积累 → 切分 → 反序列化 → 算 → 序列化 → 编码 → 发送"这条流水线的完整示范:

// TcpServerMain.cc —— 服务器:监听,接受一个连接,循环处理请求
#include "Socket.hpp"
#include "Protocol.hpp"
#include "Calculate.hpp"
#include <iostream>
#include <string>
 
using namespace Net_Work;
 
int main()
{
    // 1. 创建监听 socket
    TcpSocket listen_sock;
    uint16_t port = 8888;                    // 监听端口
    listen_sock.BuildListenSocketMethod(port, backlog);  // 创建 + 绑定 + 监听
 
    std::cout << "server listening on port " << port << std::endl;
 
    // 2. 接受一个客户端连接(单连接版。想支撑多个客户端,请用多路转接,这里不展开)
    std::string peerip;
    uint16_t peerport = 0;
    // accept 返回的是一个已经能直接收发数据的新 socket
    Socket *newsock = listen_sock.AcceptConnection(&peerip, &peerport);
    if (newsock == nullptr)
    {
        std::cerr << "accept failed" << std::endl;
        return 1;
    }
    std::cout << "client connected: " << peerip << ":" << peerport << std::endl;
 
    // 3. 维护一个"积累缓冲区":解决"一次 recv 读不到完整报文"的核心
    std::string recv_buffer;
 
    // 4. 主循环:循环读 -> 切分 -> 处理
    while (true)
    {
        // 4.1 读一次原始字节,追加进积累缓冲区
        if (newsock->Recv(&recv_buffer, 1024) == false)
        {
            // Recv 返回 false:对端关闭或出错,结束连接
            std::cout << "client closed or error, exit" << std::endl;
            break;
        }
 
        // 4.2 反复 Decode:一次读到的可能在缓冲里积累了多条完整报文
        std::string message;
        while (Protocol::Decode(recv_buffer, &message))
        {
            // 4.2.1 反序列化:JSON 字符串 -> Request 对象
            Protocol::Request req;
            if (!req.Deserialize(message))
            {
                std::cerr << "bad request json, skip" << std::endl;
                continue;            // 对端发来坏数据,跳过这一条,不崩
            }
 
            // 4.2.2 业务计算
            int code = 0;
            int result = Calc::Compute(req.GetX(), req.GetY(), req.GetOper(), &code);
 
            // 4.2.3 构造响应对象并序列化成 JSON 字符串
            Protocol::Response resp(result, code);
            std::string json;
            resp.Serialize(&json);
 
            // 4.2.4 编码:给 JSON 串加上"长度前缀 + 分隔符",变成可上线的一条报文
            std::string package = Protocol::Encode(json);
 
            // 4.2.5 发送回客户端
            newsock->Send(package);
            std::cout << "ans: " << req.GetX() << " " << req.GetOper()
                      << " " << req.GetY() << " = " << result
                      << " (code=" << code << ")" << std::endl;
        }
    }
 
    // 5. 善后
    newsock->CloseSocket();
    listen_sock.CloseSocket();
    return 0;
}

客户端 TcpClientMain.cc,主动连接并发一连串请求,再同步收响应:

// TcpClientMain.cc —— 客户端:连接服务器,发送一批请求,同步收结果
#include "Socket.hpp"
#include "Protocol.hpp"
#include <iostream>
#include <string>
#include <unistd.h>   // usleep
 
using namespace Net_Work;
 
int main()
{
    // 1. 创建客户端 socket 并连上服务器
    TcpSocket client;
    std::string serverip = "127.0.0.1";   // 本地回环,可改成远程 IP
    uint16_t port = 8888;
    if (!client.BuildConnectSocketMethod(serverip, port))
    {
        std::cerr << "connect failed" << std::endl;
        return 1;
    }
    std::cout << "connected to " << serverip << ":" << port << std::endl;
 
    // 2. 构造一批要发送的 (x, y, op)
    struct Task { int x; int y; char op; };
    Task tasks[] = {
        {1, 2, '+'},
        {7, 3, '-'},
        {6, 7, '*'},
        {20, 4, '/'},
        {9, 2, '%'},
        {8, 0, '/'},   // 故意触发一次除零,看服务器的错误码
    };
 
    std::string recv_buffer;    // 客户端侧的积累缓冲区,同样要处理粘包
 
    // 3. 循环发送请求、回收响应
    for (auto &t : tasks)
    {
        // 3.1 构造 Request 并序列化
        Protocol::Request req(t.x, t.y, t.op);
        std::string request_json;
        req.Serialize(&request_json);
 
        // 3.2 编码(加长度前缀 + 分隔符)
        std::string package = Protocol::Encode(request_json);
        client.Send(package);
 
        // 3.3 读响应。注意:一次 Recv 可能读多/读少,必须靠 Decode 循环切
        bool got = false;
        while (!got)
        {
            if (client.Recv(&recv_buffer, 1024) == false)
            {
                std::cerr << "connection closed by server" << std::endl;
                return 1;
            }
            std::string response_json;
            while (Protocol::Decode(recv_buffer, &response_json))
            {
                Protocol::Response resp;
                resp.Deserialize(response_json);
                std::cout << t.x << " " << t.op << " " << t.y
                          << " = " << resp.GetResult()
                          << " (code=" << resp.GetCode() << ")" << std::endl;
                got = true;   // 本轮任务已得到响应
            }
        }
        usleep(200000);   // 稍微停顿看清结果
    }
    return 0;
}

编译:

# 两个入口分别编译;都要链 jsoncpp
g++ -std=c++17 TcpServerMain.cc -o server -ljsoncpp
g++ -std=c++17 TcpClientMain.cc -o client -ljsoncpp
 
# 先开一个终端跑服务器,再开另一个终端跑客户端
./server
./client

运行后你会看到类似这样的输出:

// 服务器
server listening on port 8888
client connected: 127.0.0.1:52345
ans: 1 + 2 = 3 (code=0)
ans: 7 - 3 = 4 (code=0)
ans: 6 * 7 = 42 (code=0)
ans: 20 / 4 = 5 (code=0)
ans: 9 % 2 = 1 (code=0)
ans: 8 / 0 = 0 (code=1)
 
// 客户端
connected to 127.0.0.1:8888
1 + 2 = 3 (code=0)
7 - 3 = 4 (code=0)
6 * 7 = 42 (code=0)
20 / 4 = 5 (code=0)
9 % 2 = 1 (code=0)
8 / 0 = 0 (code=1)

注意 8 / 0 那次:结果被算成 0 但 code=1,客户端依然能正确读出"出错状态码",说明我们的错误通道是可靠的——这正是协议里"状态码字段"存在的意义:结果和状态分开传递,结果不可用时用状态码说明原因。

到这里,一个端到端、能解决粘包、能正确序列化/反序列化的自定义协议已经完整落地。但作为"力求详尽正确"的博客,我们还有几块更深的硬骨头必须啃:二进制协议(包头+长度+负载)、网络字节序、字节对齐、自描述方案。它们每一个都是真实项目里会把你绊倒、而课堂往往一笔带过的地方。下面进入进阶分区。

进阶一:定制并理解"包头 + 长度 + 负载"的二进制协议

前面我们用的是"文本长度 + \r\n 分隔符"这种偏文本的报文。但很多高性能、嵌入式、或对带宽敏感的协议,用的是纯粹的二进制报文,格式是纲领性的:

| 包头(Header) | 长度(Length) | 负载(Payload) |

这里的头部长度字段是指:在报文最前面放一个固定宽度的字段(通常 2~4 字节,用网络字节序存储),它代表"负载区"有多少字节。接收端先读这个长度,再按长度读负载。

与文本版相比,二进制版有两个必须面对的新概念:网络字节序和字节对齐。我们逐个讲透。

网络字节序:整数在两根网线之间的"方言"

先认识一个事实:同样一个整数,在不同 CPU 平台上的内存排列不一样。

  • 内存从低地址到高地址依次放的是"高位字节在前",那叫大端字节序(big-endian),人类读十六进制最习惯,网络协议普遍采用这种;
  • 如果放的是"低位字节在前",叫小端字节序(little-endian),我们日常用的 x86/x64 电脑、手机(ARM 多数为小端)全是小端。

举例:整数 0x01020304,用 int(4 字节)表示时:

大端内存:  01 02 03 04   (高字节在前)
小端内存:  04 03 02 01   (低字节在前)

如果一台小端主机不转换、直接把 0x01020304 按二进制发出去,对端(无论大小端)如果按同样的规则原样读它,会读成 0x04030201——数值整个颠倒,协议就废了。

所以网络字节序的规矩是:穿插在跨主机传输中的所有多字节数值(长度、端口、整数绝对值等),发送端一律转成大端(网络序)后再发,接收端收到后转回自己的本地序再用。 这就是 htonl(host-to-network long)和 ntohl(network-to-host long)这两兄弟的职责。

#include <arpa/inet.h>
 
uint32_t host_val = 1000;              // 我本机(小端)里的值
uint32_t net_val  = htonl(host_val);   // 转成网络字节序(大端)再上网络
uint32_t back   = ntohl(net_val);      // 对端收到网络序后转回本机序
// back 一定等于 1000

类似的还有 htons/ntohs(专用于 16 位的 uint16_t)。凡是"输出到网络上的多字节字段"都记得套一层转换,凡是"从网络收进来的多字节字段"都记得反转换,这是二进制网络编程的铁律。我们前文 Socket.hpp 里 htons(port)、ntohs(peer.sin_port)、htonl 正是这个道理。

字节对齐与填充:结构体直接发送为什么会"串位"

很多人想到"我要发一个二进制报文",第一反应是"把我定义的结构体用 send 直接发出去不就行了?"——这个想法方向没错,但直接裸发会遇到三个大坑,最典型的是字节对齐导致的填充。

C 编译器为了 CPU 读内存更高效,会在结构体成员之间按对齐规则插入填充字节(padding)。例如:

struct S {
    char c;     // 偏移 0
    int  i;     // 编译器会把它对齐到 4 字节边界 => 偏移变成 4,中间空出 3 字节
    char d;     // 偏移 8
};
// sizeof(S) 可能是 12,而不是 1+4+1=6

如果你把这个结构体原封不动发到对端,对端收到的是带了填充的 12 字节——如果对端用同一个结构体解析,看起来"能用";但问题在于填充字节是不可预期的:

  • 它对齐结果依赖编译器、CPU、平台、编译选项,A 机器算出的结构体布局和 B 机器很可能不一致;
  • 一旦你换编译器、换平台,sizeof(S) 变了、偏移更了,同一批字节在不同机器上就会解析出完全不同的字段——结构体直接发送瞬间变成"串位灾难"。

所以二进制序列化的第一条准则是:要么序列化时逐字段手动打包(自己决定字节顺序),要么给结构体去掉填充让它严格按 1 字节对齐。让结构体按 1 字节对齐的手段是 #pragma pack(1)(MSVC)或 __attribute__((packed))(GCC/Clang):

// 1 字节对齐:成员之间不再自动插填充,结构体按声明的顺序紧挨着排
#pragma pack(push, 1)
struct BinaryRequest {
    int32_t x;     // 第一个加数
    int32_t y;     // 第二个加数
    char    oper;  // 运算符
};
#pragma pack(pop)
// 现在 sizeof(BinaryRequest) == 9 (4+4+1),可预期、可控

即便如此,字段内部的端序问题依然在——x 是 int32_t,还是得 htonl/ntohl。二进制协议的标准姿势是"结构体按 1 字节对齐 + 每个整数字段走网络字节序",两者缺一不可。

一个完整的二进制协议实现

下面是一个"包头+长度+负载"的完整自洽示例:先按 1 字节对齐定义包头与负载结构体(长度字段用网络序),给出打包(serialize)与解包(deserialize)函数,并演示端序转换。这段代码不依赖 jsoncpp,可独立编译运行,用来自测。

// binary_protocol_demo.cc —— 二进制协议:包头+长度+负载 + 网络字节序 + 1字节对齐
#include <iostream>
#include <cstring>      // memcpy
#include <cstdint>      // uint8_t/uint32_t
#include <arpa/inet.h>  // htonl/ntohl
#include <string>
 
// 1 字节对齐:去掉编译器的自动填充,让结构体按声明的顺序紧凑排布
#pragma pack(push, 1)
 
// 包头:固定 8 字节。magic 用来校验"这确实是我们的协议",len 是负载字节数
struct PacketHeader {
    uint32_t magic;    // 魔数,比如 0xCAFEBABE,用于快速识别是不是我们的报文
    uint32_t len;      // 负载区长度,用网络字节序存
};
 
// 负载:我们要传输的"请求"数据。打包/解包时对 x、y 做端序转换
struct BinaryRequest {
    int32_t  x;        // 第一个加数
    int32_t  y;        // 第二个加数
    uint8_t  oper;     // 运算符的 ASCII 码
};
 
#pragma pack(pop)
 
// 把一个"包头 + 负载"结构体编码成一串二进制字节(负责加入网络字节序)
std::string Pack(const PacketHeader &hdr, const BinaryRequest &req)
{
    // 组装包头:正确转端序
    PacketHeader nh = hdr;
    nh.magic = hdr.magic;              // 魔数通常直接用常量,也可不转端序(见文末说明)
    nh.len   = htonl(hdr.len);         // 长度转成网络字节序后输出
 
    // 组装负载:把整数转成网络字节序
    BinaryRequest nr = req;
    nr.x = htonl(req.x);
    nr.y = htonl(req.y);
 
    // 按固定字节布局拷贝进一个字节串
    std::string out;
    out.resize(sizeof(PacketHeader) + sizeof(BinaryRequest));
    std::memcpy(&out[0],                 &nh, sizeof(PacketHeader));   // 先放飞包头
    std::memcpy(&out[sizeof(PacketHeader)], &nr, sizeof(BinaryRequest)); // 再放飞负载
    return out;
}
 
// 还原:把一坨字节解成 (包头, 负载),并把端序转回主机序
void Unpack(const std::string &raw, PacketHeader *hdr, BinaryRequest *req)
{
    // 先取包头
    std::memcpy(hdr, &raw[0], sizeof(PacketHeader));
    // 再取负载(包头后面接着)
    std::memcpy(req, &raw[sizeof(PacketHeader)], sizeof(BinaryRequest));
 
    // 把网络字节序还原回主机字节序
    hdr->len = ntohl(hdr->len);   // 长度还原
    req->x   = ntohl(req->x);     // x 还原
    req->y   = ntohl(req->y);     // y 还原
}
 
int main()
{
    // 构造一份请求:6 + 7 = ?
    BinaryRequest req;
    req.x = 6;
    req.y = 7;
    req.oper = '+';
 
    // 手工写一份对应的包头
    PacketHeader hdr;
    hdr.magic = 0xCAFEBABE;                       // 自定义魔数
    hdr.len   = sizeof(BinaryRequest);            // 负载长度 = 9
 
    // 打包成字节,模拟"发到网络"
    std::string wire = Pack(hdr, req);
    std::cout << "wire size = " << wire.size() << " (应= 8+9=" << (sizeof(PacketHeader)+sizeof(BinaryRequest)) << ")" << std::endl;
 
    // 接收端解包
    PacketHeader rh;
    BinaryRequest rr;
    Unpack(wire, &rh, &rr);
 
    std::cout << "magic = " << std::hex << rh.magic << std::dec << std::endl;
    std::cout << "len   = " << rh.len << "  (应= 9)" << std::endl;
    std::cout << "x     = " << rr.x << std::endl;
    std::cout << "y     = " << rr.y << std::endl;
    std::cout << "oper  = " << char(rr.oper) << std::endl;
    return 0;
}

我把 Unpack 里没有同时做"根据 len 判断这条报文是否完整"这一步——这里为了聚焦"打包/解包 + 端序"而省略;真实网络场景中,接收方必须先用 hdr.len 判断"已有字节是否 >= len",再决定能否解析,这正是我们 Decode 里 package.size() < total 那一条的二进制同款。读者可以结合上一节自行补上这段完整性判断(也是很好的练习)。

编译运行:

g++ -std=c++17 binary_protocol_demo.cc -o binproto
./binproto
# 期望输出:x=6  y=7  oper=+   len=9   wire size=17

这段代码把"结构体(二进制)序列化"这条路示范得明明白白:1 字节对齐 + 字段网络字节序 + 长度前缀。相比 JSON 文本方案,它更紧凑(17 字节 vs JSON 的几十上百字节)、更快,代价是肉眼不可读、跨语言要小心端序与对齐、并且改动字段就要同步两端。

一个关于"魔数(magic)怎么放"的小心点

我在 Pack 里 nh.magic = hdr.magic;(没转端序)。这里要澄清一个常见困惑:魔数到底要不要转端序? 魔数是"我们自己定的固定常量",只要发送和接收端看到的那串字节完全一致即可。既然两端都用同一个常量,你转不转端序,规则只要一致就能对得上。业界惯例是:有的协议魔数也统一按网络序存(方便抓包时按大端读),有的干脆不做转换。关键不是转不转,而是"发和收的规则必须一模一样"。为了减少困扰,我倾向建议:既然长度、数值都转了大端,那魔数也顺手转成大端,规则统一、抓包看着也一致。

进阶二:自描述协议——让报文"自己说"自己是啥

走到这,你可能已经完全掌握了"长度前缀"的报文。但它还有个不够聪明的地方:接收端要解析一条报文之前,得先知道"这条报文是什么类型"——是 Request 还是 Response?是登录请求还是消息推送?如果我们只有"长度前缀",那长度只能告诉接收端"读多长",却无法告诉它"读出来怎么解读"。

解决之道,是给报文再添一个"类型/名字"字段,让我们按类型决定怎么解析。这种"数据里额外携带元信息,让接收端无需外挂约定就能自己理解"的能力,就叫自描述(self-describing)。

最典型的自描述方案就是你前面已经用过的 JSON:{"datax":1,"oper":43,"datay":2} 里带着字段名,接收端看到"datax"就知道这是个数字、看到"oper"就知道它是运算符——JSON 天然自描述,因为它把"字段叫什么""值是什么类型"都写在文本里了。

即便不是 JSON,我们也可以为二进制/文本协议主动增加一个"协议类型码"字段。课件里就提到过一种更完备的报文范式:

"protocol_code\r\n" + "len\r\n" + 数据 + "\r\n"

即报文不再只有一层长度前缀,而是"协议码 · 长度 · 数据"三段:接收端先读 protocol_code,就立刻知道自己接下来该用哪套规则解析后续报文。这让我们可以在同一条 TCP 连接上,纯靠一个字段就自由切换多种报文类型,而两端永远对齐。

自描述方案的最大价值是扩展性:新增一种报文类型,只要定义一个新的 protocol_code,两端只要都认识和理解这个码即可,老代码无需改动。这在成熟协议的版本兼容设计里尤其重要——这与"头部长度字段"一同构成了真实网络协议的两大支柱(长度负责划界,类型负责释义)。

到这里,三种序列化路线——结构体直发(二进制)、JSON文本、自描述——以及两种分界手段——定长/分隔/长度前缀、二进制包头——已经全部覆盖并有完整代码。最后,我们把这一路踩过的坑集中复盘一遍,然后进入思考题环节。

常见坑与边界复盘

设计任意应用层协议,下面每一条都是"过来人"用血泪换来的。逐条对照自己的代码,看看都留意到了没有。

坑 1:以为"recv 一次 = 一条报文"

这是粘包问题的头号认知错误。永远要假设 recv 读回来的可能是半条、一条、乃至多条。对策:维护"积累缓冲区",用 Decode 循环切分。

坑 2:长度字段的"符号"与"无符号"陷阱

std::string::size() 返回的是 size_t(unsigned long)。若直接写 if (package.size() < total),而 total 是 int,比较时 total 会被编译器转成无符号数——当 total 因解析出错变成负数时,这个负数转成无符号后会变成一个巨大的正数,比较结果荒谬。我们的 Decode 里专门写成 (int)package.size() < total,就是把两边都压到有符号来做,规避这个经典坑。

坑 3:std::stoi 抛异常

Decode 里 std::stoi(lens) 在 lens 不是合法数字(比如缓冲区里凑巧有个非法的 \r\n 分隔符被当成了长度字段)时会抛 std::invalid_argument 异常,直接让进程崩溃。真实项目里应当用 try/catch 包裹,并把"收到坏长度"当作一条非法报文处理(丢弃或报警)。课堂版为了聚焦主流程没加异常处理,读者务必知道这个隐患。(这也是很好的一个思考题,见文末。)

坑 4:字节对齐填充导致结构体直发"串位"

直接 send(结构体) 前,先确认结构体是否按 1 字节对齐(#pragma pack(1)/__attribute__((packed))),否则 sizeof 与成员偏移可能随编译器/平台变化,跨主机解析必然出错。

坑 5:多字节整数的端序

结构体里的 int、short、long 等所有多字节数值,跨主机传输必须 hton*/ntoh*。忘了做,数值就会在另一端颠倒。

坑 6:分隔符方案遇到"数据里恰好含分隔符"

用 \r\n 当边界时,若数据正文里恰有 \r\n,会把一条报文拦腰截断。对策:要么对正文做转义(数据里的分隔符换成别的写法,接收端再还原),要么改用"长度前缀"这种与内容无关的划界方式。

坑 7:长度前缀不校验就解析

拿到长度后必须先拿缓冲区累计字节数和它比一比,不够就继续攒、够了再切。少了这步,substr 会取到越界的空串或错误数据。

坑 8:send 可能没发完

send 返回的是"本次实际写入发送缓冲区的字节数",一次未必等于你给的 length。课堂版图省事忽略了这点,要求严格的程序应当循环发送直到发完(while(sent < len)),接收侧同理要处理"recv 返回 <=0 表示对端关闭"。

把这些坑记住,你的协议就基本"抗造"了。

思考题与详解

下面几道题把全文关键点串起来,每题都给了完整答案,建议先自己写一遍再看解析。

思考题 1:为什么说"粘包不是 TCP 的 bug"?那粘包到底是谁造成的?

答案: TCP 的定位是"可靠的字节流传输"——它只保证字节不丢、不重、顺序正确,作为连续的字节序列到达。它从来没有承诺过"一次 send 就对应一次 recv"或"报文有边界"。因此"没有边界"不是缺陷,而是流式传输的本质特性。粘包是应用层的问题:当应用层没有在字节流里约定划界规则时,接收端自然无法分辨"我这一把读到的字节里到底有几条业务报文"。职责划分是——TCP 负责可靠搬运,应用层负责切分。谁在应用层设计协议,谁就要承担提供"边界"的责任(定长、分隔符或长度前缀)。

思考题 2:分别说出"定长 / 分隔符 / 长度前缀"三种分包手段各自的优缺点,并给出各自最合适的场景。

答案:

  • 定长:优点——接收方实现最简单,只需数够 N 字节即一条;缺点——报文过长要分片且不符"一条完整"语义,过短要填充、接收端要剔除填充,带宽浪费,不适用于长度差异大的场景。适用场景: 报文格式固定不变、长度差异小的数据采集(如定长传感器读数)。
  • 分隔符:优点——实现直观、报文长度不限、人类易读(HTTP 头就是 <CRLF> 分开);缺点——数据正文若含分隔符需转义,且每条报文要多付扫描成本。适用场景: 报文内容可控、几乎不会出现分隔符的文本协议(如日志、命令流)。
  • 长度前缀:优点——报文与内容无关的可靠划界,无转义难题,字段固定时很紧凑,最主流;缺点——需要先读长度、判断完整性,逻辑比定长稍多。适用场景: 通用、健壮、待传内容多样的应用层协议(RPC、各类业务报文),也是课件采用、推荐自研协议的默认选择。

思考题 3:为什么 recv 一次可能读不到一条完整报文?"积累缓冲区"在其中扮演什么角色?

答案: 因为 TCP 是流协议,recv 能读到的字节数只取决于接收缓冲区此刻堆了多少,与"一条业务报文多长"无必然关系。可能出现一次只读到 "l(半包的零头),也可能一次读到好几条连在一起(粘包)。积累缓冲区的作用是:把所有 recv 到的原始字节都追加进同一个字符串,供 Decode 反复按"找分隔符 → 取长度 → 校验完整性"来"挤"出完整报文。一套"读 → 积累 → 解码 → 处理 → 循环读"的循环,配合"头部不足一条时继续等"的判定,就完美消解了"读不完整"和"读过头"两个方向的问题。

思考题 4:为什么"直接 send 一个结构体"在跨主机场景几乎必然出错?请至少说出两点原因。

答案:

  1. 字节对齐/填充不同:编译器会按 CPU 的访问偏好给结构体插入填充字节,sizeof 和成员偏移依赖编译器与平台。A 机填充后的布局,与 B 机不完全一致,双方按同一结构体读同一串字节会得到错位字段("串位")。需要 #pragma pack(1) / __attribute__((packed)) 强制 1 字节对齐,才能得到两端一致、可预期的布局。
  2. 端序不同:Linux/x86 是大端还是小端因平台而异;哪怕结构体布局一致,把一个 int 按内存字节直接发出,对端若端序相反会反着读,数值颠倒。需要在发送时对每个多字节字段做 hton*、接收时做 ntoh*(即统一走网络字节序)。

思考题 5:网络字节序到底指什么?为什么长度字段、整数绝对值都最好转网络序?

答案: 网络协议为消除"同一数值在不同 CPU 上内存排列不同(大端/小端)"带来的歧义,统一约定网络传输用 大端字节序(高位字节在前)。你的本机数值要送上网络,先按规则转成大端(htonl/htons),对端收到后再转回自己的本地序(ntohl/ntohs)。凡是会被跨主机解读的多字节数值(长度、端口、报文里的整数)都应遵守这一转换,否则数值在另一端会被反着读、彻底错掉。这也是二进制协议里每出现一个多字节整数都要套一层转换的原因。

思考题 6:为什么我们的 Decode 要把 package.size() 转成 int 再和 total 比较?

答案: size() 返回 size_t(无符号),若 total 是 int,直接比较 package.size() < total 时编译器会把 total 隐式转成 size_t。一旦 total 因为长度字段解析出错而变成一个负数,它转成无符号后会成为一个异常巨大的正数,导致 package.size() < total 恒为真(永远认为"还不够长"),函数永远不切,服务端逐步卡死。强制 (int)package.size() 把两边统一为有符号,负数就不会被"误装成巨无霸",比较结果才靠谱。

思考题 7:Decode 里 std::stoi(lens) 在什么情况下会抛异常?真实项目中该如何处理?

答案: 当 lens(第一个 \r\n 之前的子串)不是合法整数字面量时,std::stoi 会抛 std::invalid_argument;当它代表的数值超出 int 范围时会抛 std::out_of_range。这种情形可能由半包错位、或对端发来恶意/损坏数据触发。真实项目中应:先用 try/catch 捕获异常;异常即视为"本条不是合法报文",稳妥做法是将整段缓存作废/关闭连接,或至少跳过这段不可信数据并记录错误日志,而不能让异常炸掉整个进程。

思考题 8:JSON 序列化相比二进制序列化,有哪些取舍?什么场景用哪个?

答案: JSON 的优点是:自带字段名(自描述)、跨语言/跨平台天然友好(任何语言都有 JSON 库)、人可读可调试、开发效率高,所以 Web 后端、RPC 网关、配置与接口数据这类"讲究兼容与排查方便"的场景是主流。缺点是:体积大(带字段名与花括号)、解析开销高于二进制、明文可能泄露字段语义(若要保密还得加密)、浮点等类型在跨语言时会遇到精度/表示差异。二进制序列化(结构体直发)的取舍正好相反:体积小、速度快、紧凑,适合高性能/嵌入式/带宽受限的内网服务;缺点是跨语言要手动对齐、端序、版本管理,调试困难。现实工作中两者常并存:内网高频接口用二进制,对外/跨语言接口用 JSON,或用 Protobuf、Thrift 这类"编译期生成、兼有两者优点"的序列化框架(可视为本话题的进阶方向)。

思考题 9:如果要支持同一连接上同时传输"登录请求"和"心跳报文"两种不同格式,你会怎么设计报文格式?

答案: 用自描述 + 长度前缀的组合:在报文中增加一个"协议类型码(type)"字段,格式形如 type \r\n len \r\n data \r\n(或二进制版 type(2) + len(4) + data)。接收端先读 type,据 type 决定用哪套结构去解析 data;同时用 len 去判断 data 是否齐全。这样一条 TCP 连接上可自由混传多种协议,新增类型只需注册一个新 type 码并实现对应解析,老类型不受影响——这正是把"长度字段负责划界、类型字段负责释义"两大支柱合用的标准答案。若采用 JSON,则更是天然如此:type 作为一个 JSON 字段,data 按 type 字段去对应子结构。

思考题 10(动手题):把客户端改成"一次发两条请求、一次收两条响应",验证粘包代码是否真的稳。

答案提示: 把客户端里"发→收→发→收"的严格配对改成"一次性把两条 Encode 好的报文 send 出去,然后再循环 Recv+Decode 收两条"。你会发现:即便发送端把两条报文连在一起发出、接收端一次 Decode 读出两条,只要服务器与客户端的"积累缓冲区 + 循环 Decode"逻辑正确,两端依然能精确切出两条独立请求并分别响应——这正是长度前缀划界的威力,也是粘包解法正确性的实证。若你在实现中把"读一次假设只有一条"的错误代码写进去,两条报文就会粘连,客户端只会收到一条响应而卡死——这个对比正好能帮你真正理解 Decode 循环的价值。


写到这里,我们把"应用层自定义协议"这条线从头捋到了尾:从"协议就是双方约定好的结构化数据"的底层认知,到序列化/反序列化把内存结构与线上字节互转;从 Socket 的封装、Request/Response 的定义,到 jsoncpp 现成方案的落地;再重重讲透"TCP 是字节流、粘包/拆包怎么来、定长/分隔/长度前缀三种手段怎么选",最后补上了二进制协议里难啃的网络字节序、字节对齐,以及自描述方案的扩展性。

如果此刻你只想带走三句话,那就是:

  1. 协议 = 约定。只要两端"发"和"收"规则一致,用什么格式都行;但一条好协议,得同时回答"边界在哪(怎么切)"和"含义是什么(怎么解)"两个问题。
  2. 无论文本还是二进制,长度前缀 + 完整性校验,是解决流式字节粘包最稳妥的通用答案。
  3. 一旦跨主机,多字节整数必须走网络字节序,结构体直发必须管住字节对齐——这是两个最容易栽、但记住后一劳永逸的坑。

下一步顺理成章的方向有两个:一是把"单连接"升级成"高并发"——这就进入多路转接(select/poll/epoll)的世界;二是把"自己造轮子"换成熟框架——Protobuf、Thrift、以及真正意义上的 RPC 框架,它们在"序列化 + 划界 + 类型注册"上的工业级设计,值得你带着今天这套知识去审视。我们下一篇见。