【Linux网络编程】04 · 应用层自定义协议与序列化(Jsoncpp + 长度前缀彻底解决粘包)
摘要:本文围绕"网络版计算器"这一经典案例,把应用层协议是怎么被设计出来的 这件事从头掰开讲:为什么 TCP 一定要自己定协议、为什么"报文必须自带边界"、
Encode/Decode这对函数到底在干什么、Jsoncpp 如何完成结构化数据与字符串的互转、send/recv的真实语义(它们其实是拷贝函数而不是收发函数)、以及 TCP 为什么天然支持全双工。最后横向对比 JSON / Protobuf / MessagePack 等序列化方案,并给出面试高频追问。关键词:应用层协议、序列化、反序列化、Jsoncpp、粘包、拆包、Encode/Decode、全双工、模板方法模式、工厂模式
目录
- 一、引言:从"能通"到"能用"
- 二、应用层:我们写的程序就在这一层
- 三、再谈"协议":协议就是双方约定好的结构化数据
- 四、序列化与反序列化
- [4.1 关键:两层序列化(Serialize 与 Encode 的分工)](#4.1 关键:两层序列化(Serialize 与 Encode 的分工))
- [4.2 为什么收发双方各持有一份 Request / Response](#4.2 为什么收发双方各持有一份 Request / Response)
- [五、重新理解 read/write/recv/send:它们其实是拷贝函数](#五、重新理解 read/write/recv/send:它们其实是拷贝函数)
- [六、为什么 TCP 支持全双工](#六、为什么 TCP 支持全双工)
- [七、Socket 封装:模板方法模式小试牛刀](#七、Socket 封装:模板方法模式小试牛刀)
- [八、定制协议:Request / Response 的报文格式](#八、定制协议:Request / Response 的报文格式)
- [九、Jsoncpp 落地:序列化到底怎么写](#九、Jsoncpp 落地:序列化到底怎么写)
- [9.5 Json::Value 到底是个什么容器](#9.5 Json::Value 到底是个什么容器)
- [9.6 输出型参数 vs 输入型参数](#9.6 输出型参数 vs 输入型参数)
- [十、Encode / Decode:给字节流加上边界(核心)](#十、Encode / Decode:给字节流加上边界(核心))
- 十一、流式数据的完整处理流程
- 十二、深入理解:为什么选择文本协议而不是直接发结构体
- 十三、再深入:这个自定义协议还有哪些可以改进的地方
- 十四、序列化方案横向对比
- 十五、高频面试题
- [附录:Jsoncpp 完整用法手册](#附录:Jsoncpp 完整用法手册)
一、引言:从"能通"到"能用"
前面几篇我们已经能用 socket 把字符串从一台主机发到另一台主机了,但那只是回显(echo):客户端发什么,服务器原样吐回来。
回显式的代码有一个致命特点:它不需要理解数据 。服务器收到 "hello" 也不知道这是什么意思,原样发回去就完事了。而真实的业务场景不是这样的:
- 客户端要告诉服务器"帮我算 10+20";
- 服务器要告诉客户端"结果是 30,或者你的除数是 0,出错了"。
这就引出了本篇的三件大事:
- 约定报文格式(应用层协议)
- 把结构化数据变成字符串 / 再把字符串还原成结构化数据(序列化和反序列化)
- 在 TCP 这条没有边界的字节流里,把一个个报文切出来(解决粘包)
二、应用层:我们写的程序就在这一层
在 TCP/IP 五层模型里,应用层是最上面的一层:
┌──────────────────────────────────────┐
│ 应用层 ← 我们写的网络程序就在这里 │ HTTP / FTP / SMTP / 我们自己定的协议
├──────────────────────────────────────┤
│ 传输层 │ TCP / UDP (由内核实现)
├──────────────────────────────────────┤
│ 网络层 │ IP (由内核实现)
├──────────────────────────────────────┤ socket API 就夹在这儿
│ 数据链路层 │ 以太网 / 驱动
├──────────────────────────────────────┤
│ 物理层 │ 光缆 / 网线 / 无线电磁波
└──────────────────────────────────────┘
⭐ 下四层由操作系统内核帮我们做完了 ,我们调用
socket / bind / listen / accept / connect / send / recv这些 API,本质上是在借用内核的传输层能力。我们写的那些解决实际问题的程序(网盘、聊天室、游戏服务器、RPC 框架)通通跑在应用层。
一个重要推论:TCP/UDP 只负责把"字节"搬过去,它完全不关心你字节里装的什么。 那么"这段字节代表什么意思"就必须由我们自己在应用层约定 ------ 这就是应用层协议存在的唯一理由。
三、再谈"协议":协议就是双方约定好的结构化数据
3.1 痛点:socket API 只会收发字符串
socket 的读写接口(read/write/recv/send)传的都是 void*/char 缓冲区,也就是一串没有结构的字节 。但实际业务要传的往往是结构化数据:一个订单、一条聊天消息、一个计算任务......
📌 协议(protocol)本质上就是一种"约定":约定好这段字节流里,第几个字节到第几个字节是什么含义、用什么符号分隔、值用什么格式表示。
一句话:协议 = 双方约定好的结构化数据。
3.2 以"网络版计算器"为例
需求:客户端把两个加数发给服务器,服务器算完把结果返回。
约定方案一:纯字符串约定
- 客户端发送一个形如
"1+2"的字符串; - 字符串中有两个操作数,都是整型;
- 两个数字之间有一个字符是运算符 ,运算符只能是
+; - 数字和运算符之间没有空格;
- ......
cpp
// 服务端朴素写法
recv(sockfd, buf, sizeof(buf), 0); // buf = "1+2"
// 自己去找 '+' 的位置,拆成左操作数和右操作数
✅ 优点:简单、直观、肉眼可读(用 telnet/nc 就能调试)。
❌ 缺点:扩展性极差 。哪天要加个乘法、加个浮点数、加个"错误码"字段,就得重写一遍字符串解析逻辑,而且很容易写出 atoi 满天飞、sscanf 到处硬编码的垃圾代码。
约定方案二:结构体 + 序列化(本篇采用)
-
定义一个结构体 来表示需要交互的信息:
cppclass Request { int _data_x; int _data_y; char _oper; }; // 请求 class Response { int _result; int _code; }; // 应答 -
发送时 把这个结构体按照某种规则转换成字符串;
-
接收时 再按照相同规则把字符串还原成结构体;
-
这个"结构 ↔ 字符串"的转换过程,就叫 序列化(Serialize) 和 反序列化(Deserialize)。
3.3 到底什么是"应用层协议"
⭐ 无论采用方案一、方案二还是什么别的方案,只要保证:一端发送时构造的数据,在另一端能够被正确地解析,就是 OK 的。这种约定,就是应用层协议。
也就是说,协议没有标准答案。你完全可以这样约定:
"protocol_code\r\nlen\r\nx op y\r\n"
也可以那样约定。只要收发两端对得上就行。为了让大家真正理解协议的细节,我们才"手搓"一遍协议定制的全过程。
| 环节 | 做法 | 目的 |
|---|---|---|
| 协议格式 | 采用方案二(结构体) | 体现协议定制的细节与字段的自描述能力 |
| 序列化 | 直接用现成轮子 ------ Jsoncpp 库 | 避免把时间浪费在字符串切割上,同时也贴近工程实践 |
| 传输层 | 对 socket 做字节流读取处理 | 直面 TCP 的粘包问题 |
四、序列化与反序列化
发送方 接收方
┌───────────────┐ ┌───────────────┐
│ 结构化数据 │ │ 字符串/字节流 │
│ Request 对象 │ │ "...json..." │
└───────┬───────┘ └───────┬───────┘
│ Serialize() 序列化 │ Deserialize() 反序列化
▼ ▲
┌───────────────┐ 网络/磁盘传输 ┌────────┴──────┐
│ 字符串/字节流 │ ───────────────────────▶ │ 结构化数据 │
└───────────────┘ └──────────────┘
| 概念 | 定义 | 场景 |
|---|---|---|
| 序列化 (Serialize) | 把内存中的结构化对象 转换成可传输的字符串/字节序列 | 发送前、写文件前、存 Redis 前 |
| 反序列化 (Deserialize) | 把字符串/字节序列还原成内存中的结构化对象 | 收到数据后、读文件后、从 Redis 读出后 |
💡 为什么不能直接把结构体内存 memcpy 出去? 这是初学者的经典疑问,答案见 第十二节(涉及内存对齐、大小端、版本兼容三大坑)。
4.1 关键:"序列化"在本项目里其实做了两层
初学时最容易混淆的一点:Serialize() 和 Encode() 好像都在做"变成字符串",它俩到底什么关系?
⭐ 它们的分工不同、层次不同:
层次 负责的函数 处理的对象 产出 第一层(向内:业务的表示) Serialize()/Deserialize()业务对象 ↔ 消息体字符串 {"datax":10,"datay":20,"oper":43}第二层(向外:网络的表示) Encode()/Decode()消息体字符串 ↔ 完整网络报文 36\r\n{"datax":10,...}\r\n两次处理都属于"序列化",只是粒度不同。 业务层构建出字符串之后,还需要经过第二次加工,才能变成真正能上线的报文。
为什么要分两层?因为它们解决的问题完全不同:
Request/Response这两个结构只描述"消息里有什么字段" ,它并不规定这些字段怎么变成字节流发到网络上;- 而 TCP 是字节流,
Encode负责的事情是给已经有内容的消息体套上一层"能在线上被识别出来的壳"。
💡 一句话串起来 :
Serialize决定"消息长什么样",Encode决定"这条消息在线上从哪里开始、到哪里结束"。两者叠起来,才是完整的应用层协议。
4.2 为什么收发双方要各持有一份 Request / Response
⭐ 序列化和反序列化要求双方约定好完全相同的字段名。 所以服务端与客户端的代码里都有一份
Request和Response(即Protocol.hpp是两边共有的头文件)。
它们的使用方向恰好相反,务必记牢这张表:
| 角色 | 对 Request |
对 Response |
|---|---|---|
| 客户端 | Serialize() 发送 |
Deserialize() 接收 |
| 服务端 | Deserialize() 接收 |
Serialize() 发送 |
客户端 ── Serialize(Request) ──▶ 网络 ──▶ Deserialize(Request) ── 服务端
◀─ Deserialize(Response) ── 网络 ◀── Serialize(Response) ──
如果两边字段名不一致会怎样? 举个真实的坑:一边写 root["datax"],另一边读 root["x"]。JSON 解析不会报错(因为 root["x"] 在 Jsoncpp 里会自动创建一个空的新元素 ,见附录 A.5),但取出来的是 0 ------ 发出去也白搭,数据永远是默认值。这就是注释里说的"必须约定同一套字段,否则发了也白搭"。
五、重新理解 read/write/recv/send:它们其实是拷贝函数
这是本篇最重要的认知升级之一。
5.1 数据到底怎么流动的
【主机 A】客户端进程 【主机 B】服务器进程
用户空间 用户空间
┌───────────┐ ┌───────────┐
│ 应用层缓冲 │ │ 应用层缓冲 │
│ "1+2" │ │ │
└─────┬─────┘ └─────▲─────┘
│ send()/write() recv()/read()│
│ ★把用户数据"拷贝"进内核★ ★把内核数据"拷贝"到用户★│
──────┼──────────────── 用户态/内核态分界 ───────────────┼──────
▼ │
┌───────────┐ ┌─────┴─────┐
│ 发送缓冲区 │ │ 接收缓冲区 │
└─────┬─────┘ └─────▲─────┘
│ (TCP 决定何时发、发多少、出错怎么办) │
└──────────────────▶ 网络 ───────────────────────┘
⭐ 关键结论:
send/recv本质上是"拷贝函数",而不是"收发函数"!
send()只负责把用户空间的字节拷贝 到内核的发送缓冲区,然后就返回了;recv()只负责把内核接收缓冲区 里的字节拷贝到用户空间,缓冲区空了就阻塞等你;- 数据什么时候真正发出去、一次发多少、丢包了怎么重传、拥塞了怎么降速 ------ 全是 TCP 协议自己说了算。
正因为 TCP 全权接管了"何时发、发多少、出错了怎么办"这套控制逻辑,所以 TCP 才叫 传输控制协议(Transmission Control Protocol)。
5.2 这个认知带来的直接推论
send()返回成功 ≠ 对方收到了数据。 它只代表数据被拷进了你的发送缓冲区。对方进程崩溃与否、网络是否通畅,此刻都不知道。- 你可能要循环调用
recv()才能拿全一个业务包。 因为对方那一次业务逻辑可能很大,而 TCP 分片由不得应用层控制。 - 这就是为什么必须自己处理"报文边界"。 TCP 只保证字节流的顺序,不保留"消息"的边界。
六、为什么 TCP 支持全双工
6.1 全双工、半双工、单工
| 术语 | 含义 | 生活类比 |
|---|---|---|
| 单工 | 只能单向传输 | 广播、电视 |
| 半双工 | 双方都能收能发,但同一时刻只能一方发 | 对讲机(要按着说话键) |
| 全双工 | 双方可以同时收发 | 电话、TCP 连接 |
6.2 TCP 全双工的物理来源
⭐ 在任意一台主机上,一个 TCP 连接同时拥有
发送缓冲区和接收缓冲区。两者互相独立、互不干扰。所以在内核层面,"发消息"和"收消息"是两条完全独立的通道,可以同时进行 ------ 这就是全双工。
这一点要和我们熟悉的**管道(pipe)**做个对比:
| 对象 | fd 数量 | 缓冲区 | 是否全双工 |
|---|---|---|---|
| pipe(匿名管道) | 需要 2 个 fd(fd[0] 读、fd[1] 写) |
一个单向缓冲区 | ❌ 单向,双向通信要开两根管道 |
| TCP socket | 1 个 sockfd | 独立的收发两个缓冲区 | ✅ 全双工 |
💡 这也就是为什么一个 TCP
sockfd既能用来read又能用来write的原因:同一个 fd 背后挂着两个方向相反的缓冲区,你读就读接收缓冲区,你写就写发送缓冲区,互不打架。
同一个 sockfd
┌──────────┴──────────┐
▼ ▼
┌─────────┐ ┌─────────┐
│ 接收缓冲区│ │ 发送缓冲区│
│ recv ← │ │ → send │
└─────────┘ └─────────┘
两者独立 → 可同时工作 → 全双工
七、Socket 封装:模板方法模式小试牛刀
项目文件结构:
Calculate.hpp Makefile Socket.hpp TcpServer.hpp
Daemon.hpp Protocol.hpp TcpClientMain.cc TcpServerMain.cc
为了把注意力集中在协议本身,服务器采用简单的自定义线程处理(不为每个连接 fork 多进程) ,客户端也不用读用户输入,直接自动构造请求和服务器来回通信,省掉一堆没营养的 IO 代码。
7.1 抽象基类 Socket:定义"骨架"
cpp
#pragma once
#include <iostream>
#include <string>
#include <cstring>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
#define Convert(addrptr) ((struct sockaddr *)addrptr)
namespace Net_Work
{
const static int defaultsockfd = -1;
const int backlog = 5;
enum
{
SocketError = 1,
BindError,
ListenError,
};
// 封装一个基类,Socket接口类
// 设计模式:模版方法类
class Socket
{
public:
virtual ~Socket() {}
virtual void CreateSocketOrDie() = 0;
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;
virtual void SetSockFd(int sockfd) = 0;
virtual void CloseSocket() = 0;
virtual bool Recv(std::string *buffer, int size) = 0;
virtual void Send(std::string &send_str) = 0;
// TODO
public:
void BuildListenSocketMethod(uint16_t port, int backlog)
{
CreateSocketOrDie();
BindSocketOrDie(port);
ListenSocketOrDie(backlog);
}
bool BuildConnectSocketMethod(std::string &serverip, uint16_t serverport)
{
CreateSocketOrDie();
return ConnectServer(serverip, serverport);
}
void BuildNormalSocketMethod(int sockfd)
{
SetSockFd(sockfd);
}
};
}
7.2 什么是模板方法模式(Template Method)
⭐ 模板方法模式 :在一个抽象父类中定义好算法的执行骨架(步骤顺序) ,而把其中某些具体步骤延迟到子类实现。
在本例中:
- "模板"(骨架) =
BuildListenSocketMethod():先 create → 再 bind → 再 listen ,这个顺序是所有监听类套接字都必须遵守的、不可变的流程,所以写在父类里; - "可变步骤" =
CreateSocketOrDie()等纯虚函数:TCP 要SOCK_STREAM,将来如果加 UDP 子类就要SOCK_DGRAM,各不相同,所以留给子类override。
带来的好处:
- 上层代码只需要调
BuildListenSocketMethod(port, backlog)一行,不用再关心"监听套接字怎么搭起来"这种样板细节; - 将来新增
UdpSocket子类,只需继承并实现纯虚函数,上层完全不用改(对修改封闭、对扩展开放 = 开闭原则)。
💡 注意函数后缀的
OrDie命名习惯:表示"这一步失败就直接让进程退出(exit())",不做错误处理。这是许多开源项目(如陈硕 muduo、Google 内部代码规范)常见的约定,用于把"不可能失败的场景"的代码写短。
7.3 具体实现 TcpSocket
cpp
class TcpSocket : public Socket
{
public:
TcpSocket(int sockfd = defaultsockfd) : _sockfd(sockfd) {}
~TcpSocket() {}
void CreateSocketOrDie() override
{
_sockfd = ::socket(AF_INET, SOCK_STREAM, 0);
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;
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);
if (n < 0)
exit(ListenError);
}
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);
Socket *s = new TcpSocket(newsockfd);
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;
server.sin_addr.s_addr = inet_addr(serverip.c_str());
server.sin_port = htons(serverport);
int n = ::connect(_sockfd, Convert(&server), sizeof(server));
if (n == 0)
return true;
else
return false;
}
int GetSockFd() override { return _sockfd; }
void SetSockFd(int sockfd) override { _sockfd = sockfd; }
void CloseSocket() override
{
if (_sockfd > defaultsockfd)
::close(_sockfd);
}
bool Recv(std::string *buffer, int size) override
{
char inbuffer[size];
ssize_t n = recv(_sockfd, inbuffer, size - 1, 0);
if (n > 0)
{
inbuffer[n] = 0;
*buffer += inbuffer; // 故意拼接的
return true;
}
else if (n == 0)
return false;
else
return false;
}
void Send(std::string &send_str) override
{
// 多路转接我们统一再说
send(_sockfd, send_str.c_str(), send_str.size(), 0);
}
private:
int _sockfd;
};
} // namespace Net_Work
7.4 这里值得留意的两个细节
① AcceptConnection 为什么要返回一个新的 Socket*?
因为 accept() 返回的是一个全新的 fd (用于和这个特定客户端通信),而监听 socket 还要继续 accept 别人。所以这里用新 fd new 出一个 TcpSocket 对象 返回给上层,让上层拿它去和客户端收发数据。注意它返回的是父类指针 Socket*,上层面向抽象编程,不依赖具体实现。
② Recv 里为什么要 *buffer += inbuffer(拼接)?
这是我们处理 TCP 字节流的第一处伏笔。recv 每次读到的不一定是一个完整报文 ,可能只有半个。所以这里先把读到的内容追加到调用者的缓冲区尾部 ,由上层再去循环解析,直到凑出完整报文为止。这就是所谓的用户态缓冲区。
⚠️ 代码小坑提醒(面试加分项) :
char inbuffer[size];是 VLA(变长数组) ,属于 C99 特性、并非标准 C++ ,只是 GCC 扩展。工程写法建议改成std::vector<char> inbuffer(size);或std::string。另外
*buffer += inbuffer;依赖inbuffer[n] = 0补的'\0',如果收到的报文中含有'\0'二进制数据就会被截断 ------ 这也是为什么本协议最终选择了纯文本的 JSON,而不是二进制结构体。
八、定制协议:Request / Response 的报文格式
8.1 基本结构
⭐ 定制结构化字段本身,就是在定协议。
cpp
class Request
{
private:
// _data_x _oper _data_y
// 报文的自描述字段
// "len\r\nx op y\r\n" : \r\n不属于报文的一部分,约定
int _data_x; // 第一个参数
int _data_y; // 第二个参数
char _oper; // + - * / %
};
class Response
{
private:
// "len\r\n_result _code\r\n"
int _result; // 运算结果
int _code; // 运算状态
};
为什么要设计一个 _code(状态码)字段?
因为网络请求是有可能失败的,光有一个返回值不足以表达全部语义:
_code 取值建议 |
含义 | _result 的含义 |
|---|---|---|
0 |
成功 | 真正的计算结果 |
1 |
除数为 0 | 无意义(置 0) |
2 |
模 0(非法取模) | 无意义 |
3 |
运算符非法 | 无意义 |
4 |
结果溢出 | 无意义 |
💡 这其实就是 HTTP 状态码的思想:用"状态码 + 数据载荷"的组合来描述一次请求的结果,而不是只返回一个值。
8.2 期望的完整报文格式
┌──────────┬────────┬───────────────────────────┬────────┐
│ len │ \r\n │ {"datax":1,...} │ \r\n │
│ (报文字节数)│ (分隔符)│ (报文正文 / payload) │ (分隔符) │
└──────────┴────────┴───────────────────────────┴────────┘
▲ 自描述头部 ▲ 自描述正文 ▲ 结束边界
一个真实的例子(假设正文是 x op y 形式):
9\r\n
10 + 20\r\n
📌
\r\n不属于报文正文的一部分 ,它只是我们约定的分隔符/边界。这是 HTTP、FTP、SMTP、Redis(RESP) 等众多文本协议的通用做法。注:课件里还有一个
protocol_code的可选扩展版协议头 ------"protocol_code\r\nlen\r\nx op y\r\n"。加了一个"协议编号"字段后,同一个端口上就可以同时跑多套协议 (比如1表示计算器、2表示聊天消息、3表示文件传输),收到报文后先读protocol_code,再分派给对应的处理函数。这正是 RPC 框架里的常见设计。
九、Jsoncpp 落地:序列化到底怎么写
9.1 安装
⚠️ Jsoncpp 是第三方库,不是系统自带的 ,必须手动安装到本地才能
#include <jsoncpp/json/json.h>。它就是 C++ 版的 JSON 序列化/反序列化库。
bash
# Ubuntu / Debian
sudo apt-get install libjsoncpp-dev
# CentOS / RockyLinux
sudo yum install jsoncpp-devel
编译时注意链接库:g++ -o server *.cc -ljsoncpp -lpthread
装完后两个常见检查点:
bash
ls /usr/include/jsoncpp/json/ # 头文件是否到位
ls /usr/lib/x86_64-linux-gnu/libjsoncpp* # 库文件是否到位
9.2 Protocol.hpp 全貌
📌 下面这份代码附上了学习过程中总结的行内注释,基本把你会在阅读/二开时产生的疑问都答了。原版注释里有一句特别值得单独拎出来:
"每段 JSON 字符串是自己构建的,所以一定有边界;但一旦把这些字符串都传入传输层,就无法分辨它们了。"
这正是
Encode存在的全部理由 ------ 序列化(此处指第二层的 Encode)的本质,就是给每一段 JSON 消息封装出明确的边界,每次找到边界就能实现分包。
cpp
#pragma once
#include <iostream>
#include <memory>
#include <jsoncpp/json/json.h>
namespace Protocol
{
// 问题:
// 1. 结构化数据的序列和反序列化
// 2. 还要解决用户区分报文边界 ------ 数据包粘报问题
const std::string ProtSep = " ";
const std::string LineBreakSep = "\r\n";
// ==================== 一、加报头 / 去报头 ====================
// "len\r\nx op y\r\n" ← 期望的最终报文格式
// Encode 的过程就是"拼接":
// 最终报文 = 协议头 (Header) + 业务数据 (JSON字符串)
// 这样解决 TCP 粘包问题
// 注意:Request/Response 结构只表示"消息里有什么字段",
// 但不规定"这些字段怎么变成字节流发到网络上"------那是这里的事
std::string Encode(const std::string &message)
{
std::string len = std::to_string(message.size());
std::string package = len + LineBreakSep + message + LineBreakSep;
return package;
}
// 解包:message 是输出型参数(函数内部写结果,调用方取值)
// 最终应用层报文的格式完全由自己定义;当然也可以采用互联网上其他标准协议,
// 只要收发双方一致就行
bool Decode(std::string &package, std::string *message)
{
auto pos = package.find(LineBreakSep);
if (pos == std::string::npos)
return false;
std::string lens = package.substr(0, pos); // substr(pos,len):从 pos 开始,截取 len 个字符
int messagelen = std::stoi(lens);
int total = lens.size() + messagelen + 2 * LineBreakSep.size();
if (package.size() < total)
return false;
// 至少 package 内部一定有一个完整的报文了!
*message = package.substr(pos + LineBreakSep.size(), messagelen);
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) {}
void Debug()
{
std::cout << "_data_x: " << _data_x << std::endl;
std::cout << "_data_y: " << _data_y << std::endl;
std::cout << "_oper: " << _oper << std::endl;
}
void Inc() { _data_x++; _data_y++; }
// Json::Value 就像一个通用的"键值对容器",底层其实是一个 std::map
// ↓
// 你往里面塞什么 key-value 由你决定(这就是协议设计)
// ↓
// 但通信双方必须约定好同一套字段,否则发了也白搭
// 序列化:结构化数据 -> 字符串
// out 为输出型参数:函数内部把序列化好的 JSON 字符串写进 out 指向的对象,
// 调用方通过它拿到结果
bool Serialize(std::string *out)
{
Json::Value root;
// 库的能力:能往 root 里塞任意 key-value
root["datax"] = _data_x;
root["datay"] = _data_y;
root["oper"] = _oper;
Json::FastWriter writer; // 旧版接口,紧凑输出(无多余空格换行)
// JSON 的本质:把一个内存中的哈希映射(字典/Map 对象),
// 序列化(扁平化)成了一个普通的 std::string
// 例:{"datax":10,"datay":20,"oper":43}
*out = writer.write(root);
return true;
}
// 反序列化:字符串 -> 结构化数据。in 是输入型参数
bool Deserialize(std::string &in)
{
Json::Value root;
Json::Reader reader;
// parse(解析):判断这个字符串能否被 Json::Value 解析,返回布尔值;
// 只有 true 才进行安全的字段取值(防止对残缺报文乱取值)
bool res = reader.parse(in, root);
if (res)
{
_data_x = root["datax"].asInt(); // 靠 "datax" 这个 key 找到值,赋给 _data_x
_data_y = root["datay"].asInt(); // 靠 "datay" 找到值,赋给 _data_y
_oper = root["oper"].asInt(); // 靠 "oper" 找到值,赋给 _oper
}
return res;
}
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; // 运算状态
};
// ==================== 四、工厂 ====================
// ==================== 四、工厂 ====================
// 对象工厂:封装 Request 和 Response 的创建
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);
}
};
} // namespace Protocol
9.3 为什么要用 Factory(工厂模式)
工厂模式 的核心价值:把对象的创建过程封装起来,尤其是当创建逻辑比较复杂(要初始化多个字段、要做资源分配、可能返回多种派生类型)时,调用方只需要
BuildXxx()一句话拿到shared_ptr。
这里搭配 std::shared_ptr 使用还有个额外好处:自动管理生命周期 。请求/应答对象会在网络层、业务层之间传递,用裸指针容易内存泄漏或悬空,交给 shared_ptr 就省心了。
9.4 序列化后长什么样?
json
{"datax":10,"datay":20,"oper":43}
💡 注意
oper是char,塞进 JSON 后被当成了整数 43 (字符'+'的 ASCII 码)。反序列化时我们用root["oper"].asInt()取出来再隐式转回char,收发两端是一致的,所以没毛病。但这也提醒我们:JSON 的类型系统很简单(只有 null/bool/number/string/array/object),自定义类型要先达成转换约定。
9.5 Json::Value 到底是个什么容器?
⭐
Json::Value可以理解为一个"万能键值对容器",底层就是一个 map(哈希/有序映射)。
Json::Value root; // 一个空的 map<string, Value>
│
├─ root["datax"] = 10; // 塞入 key="datax", value=10
├─ root["datay"] = 20; // 塞入 key="datay", value=20
└─ root["oper"] = 43; // 塞入 key="oper", value=43
│
▼ write()
{"datax":10,"datay":20,"oper":43} // 一个扁平的 std::string
由此得出两个重要认知:
① JSON 的本质 = 把 map 扁平化成字符串
内存里的 map 是有结构的(树/哈希表),而网络上只能传一维字节。所谓序列化,就是把这个键值对结构按约定写法"拍扁"成一个 std::string ;反序列化则是反向把这串字符还原成一张 map。所以 JSON 天生只能表达"对象/数组/字符串/数字/布尔/null"这几种结构,复杂的自定义类型必须先降维。
② 塞什么 key 完全是"协议设计",必须两端对齐
| 环节 | 谁决定 | 风险 |
|---|---|---|
字段名(datax/result/code) |
协议设计者(你) | 两端不一致 → 取值全是 0,且不报错 |
| 字段类型(int/string) | 序列化时决定 | 类型不匹配时 asInt()/asString() 会做隐式转换或抛异常 |
⚠️ Jsoncpp 的一个隐蔽坑 :
root["typo_key"]当键不存在时,不会报错、也不会返回空,而是自动新建一个 null 元素 ,此时.asInt()得到0。所以"字段名写错"在本项目里表现为静默地得到 0 ,非常难查。想要严格检查,请先用isMember("datax")判断,或使用at()(键不存在会抛异常)。
9.6 顺带复习:输出型参数 vs 输入型参数
本项目大量使用指针/引用传参,代码注释里特别标了这一点:
| 写法 | 语义 | 本项目中的例子 |
|---|---|---|
std::string *out |
输出型参数:函数往里写结果,调用方事后读取 | Serialize(std::string *out) |
const std::string &in / std::string &in |
输入型参数:函数从这里读数据 | Deserialize(std::string &in) |
std::string &package |
输入输出型参数 :既读又改(Decode 会 erase 掉已消费的报文) |
Decode(std::string &package, ...) |
std::string *message |
输出型参数:切出来的报文正文 | Decode(..., std::string *message) |
💡
Decode用引用接收package正是因为它需要就地删除已处理的那一段 (package.erase(0, total)),剩下的半包留着下次继续用 ------ 这正是"流式处理"的精髓。
十、Encode / Decode:给字节流加上边界(核心)
10.1 为什么需要它们
前面铺垫了那么多,真正的难点来了。TCP 是面向字节流 的,没有消息边界:
发送方调用 3 次 send: [AAAA] [BBBB] [CCCC]
│
接收方的接收缓冲区里可能是: [AA] [AABBB] [BCC] [CCC] ← 完全无序、无从对应!
这就是著名的 粘包 / 拆包 问题。Encode/Decode 的任务就是人为地、显式地给报文加上边界。
10.2 Encode:加长度前缀(Length-Prefixed Frame)
cpp
std::string Encode(const std::string &message)
{
std::string len = std::to_string(message.size());
std::string package = len + LineBreakSep + message + LineBreakSep;
return package;
}
输入 "10 + 20" → 输出 "9\r\n10 + 20\r\n"
⭐ 记住这个分解式:
最终报文 = 协议头(Header) + 业务数据(JSON字符串)
len\r\n + {"datax":10,"datay":20,"oper":43} + \r\n ───────────── ────────────────────────────────── ──── 协议头(长度 + 分隔) 业务数据(由 Serialize 产出) 结束分隔
Encode干的事情本质上就是"字符串拼接" ,没有调用任何底层 socket API ------ 它纯粹是在应用层做字符串加工,这也再次印证了:协议 ≈ 字符串处理(课件原话:"很多工作都是在做字符串处理!")。
💡 为什么非得包这一层?(这段很关键)每段 JSON 字符串是自己构建出来的,所以在内存里它当然是有边界的(
std::string自带 size);但一旦把这些字符串依次丢给传输层,它们就被首尾相连地灌进同一条 TCP 字节流,到时候就再也分辨不出谁是谁了。所以第二层序列化(Encode)的本质,就是为每一段 JSON 消息封装出明确边界 ------ 每次找到边界,就能实现分包。
另外,这个格式还带了一点冗余性设计:
- 头部
len\r\n:告诉你正文有多长 → 用于精确切分; - 尾部
\r\n:作为一个额外的边界标记 → 用于确认一个报文确实收完了。
实际上只靠
len也能切(这是纯长度前缀法),只靠\r\n也能切(这是分隔符法,但当正文中出现\r\n时会误判)。两者结合更健壮,也更贴近 HTTP 等成熟文本协议的习惯。
10.3 Decode:从缓冲区里"切"出一个完整报文
cpp
bool Decode(std::string &package, std::string *message)
{
// 除了解包,我还想判断报文的完整性,能否正确处理具有"边界"的报文
auto pos = package.find(LineBreakSep); // ① 找第一个 \r\n
if (pos == std::string::npos)
return false; // 连长度行都没收全,继续等
std::string lens = package.substr(0, pos); // ② 取出长度字符串
int messagelen = std::stoi(lens); // 转成数字(正文长度)
int total = lens.size() + messagelen + 2 * LineBreakSep.size(); // ③ 计算整个报文总长
if (package.size() < total)
return false; // 还不够一个完整报文,继续等
// 至少 package 内部一定有一个完整的报文了!
*message = package.substr(pos + LineBreakSep.size(), messagelen); // ④ 摘出正文
package.erase(0, total); // ⑤ 从缓冲区头部删掉已处理的报文
return true;
}
10.4 逐帧推演:缓冲区里那些"半包"的情况
课件里非常形象地列出了缓冲区内容的所有中间状态,这里整理成表:
缓冲区 package 当前内容 |
find(\r\n) |
len |
需要总长 | 结果 |
|---|---|---|---|---|
"l" |
npos | --- | --- | ❌ 返回 false,继续 recv |
"len" |
npos | --- | --- | ❌ 继续 recv |
"len\r\n" |
pos=3 | 解析失败 | --- | ❌ 继续 recv |
"len\r\nx" |
pos | len | total > 现长 | ❌ 继续 recv |
"len\r\nx op" |
pos | len | total > 现长 | ❌ 继续 recv |
"len\r\nx op y" |
pos | len | total > 现长 | ❌ 只差一个边界符!继续 recv |
"len\r\nx op y\r\n" |
pos | len | total == 现长 | ✅ 切出一个完整报文! |
"len\r\nx op y\r\n""len" |
pos | len | total | ✅ 切出第1个;残留 "len" |
"len\r\nx op y\r\n""len\r\nx op y\r\n" |
pos | len | total | ✅ 切出第1个;残留第2个(粘包被正确拆解) |
⭐ 核心思想:
Decode不只是"解包",它还同时回答了一个问题 ------ "你给的这段数据里,现在有没有至少一个完整报文?"
- 返回
false:还不够 ,调用方继续recv往尾部追加;- 返回
true:成功取出一个完整报文,并且已经把它从缓冲区头部删掉了,剩余部分留着下次再处理。
10.5 UDP 为什么不用这套?
| 特性 | TCP | UDP |
|---|---|---|
| 传输单位 | 字节流,无边界 | 数据报,天然带边界 |
| 一次发送对应 | 可能多次接收、也可能合并 | 一次 sendto 必然对应一次 recvfrom |
| 是否需要 Encode/Decode | 必须 | recvfrom 拿到的就是一个完整报文,不需要切包 |
| 是否需要处理粘包 | ✅ 需要 | ❌ 不需要 |
💡 UDP 自带长度字段(8 字节首部里就有 length),内核会帮你把一个个数据报切好。代价是 UDP 不可靠。这就是"可靠性带来的复杂度债务"。
十一、流式数据的完整处理流程
有了上面的铺垫,服务端的处理循环就水到渠成了:
cpp
void HandlerRequest(Socket *sock)
{
std::string inbuffer; // ★ 用户态缓冲区,跨 recv 调用持久存在
std::string clientip;
uint16_t clientport;
// ...
while (true)
{
// ---------- 1. 读 ----------
if (!sock->Recv(&inbuffer, 1024)) // 内部是 *buffer += inbuffer(拼接)
break; // 对端关闭或出错
// ---------- 2. 解包(可能要循环,因为可能粘了多个包)----------
std::string message;
while (Decode(inbuffer, &message)) // ★ 只要还切得出完整报文就继续切
{
// ---------- 3. 反序列化 ----------
auto req = factory.BuildRequest();
if (!req->Deserialize(message))
continue; // 报文坏了,丢弃这一个,继续下一个
// ---------- 4. 业务处理 ----------
auto resp = cal.Calculator(req); // 真正的业务逻辑:计算 + 设置 _code
// ---------- 5. 序列化 ----------
std::string resp_str;
resp->Serialize(&resp_str);
// ---------- 6. 加报头 ----------
resp_str = Encode(resp_str);
// ---------- 7. 发送 ----------
sock->Send(resp_str);
}
}
sock->CloseSocket();
delete sock;
}
⭐ 新手最常见的两个错误写法,务必对照自查:
- 把
inbuffer定义在while循环内部 ------ 那每次循环半个包残留就丢了,永远解不出完整报文。它必须定义在循环外面。- 只调用一次
Decode------ 如果一次 recv 收到了 3 个包粘在一起,只 Decode 一次就丢 2 个。必须while(Decode(...))。一句话总结整个处置策略:先拷到用户缓冲区 → 再看够不够一个包 → 够了才解析。 网络 IO 和业务处理彻底解耦。
十二、深入理解:为什么选择文本协议而不是直接发结构体
很多同学会问:既然 Request 本身就是个结构体,直接 send(sockfd, &req, sizeof(req), 0) 不行吗?绝大多数情况下:不行。 原因有三大坑:
坑 1:内存对齐规则可能不同
cpp
struct Request { int x; char oper; };
sizeof(Request) == ? // 32位 gcc 默认 4 字节对齐 → 8,不是 5!
客户端和服务端如果是不同的编译器/编译选项/语言 (-fpack-struct、#pragma pack、Java/Go 写的客户端),结构体在内存里的排布就不一样,你 sizeof 出来的字节数不同,解析必然错位。
坑 2:大小端(字节序)问题
cpp
int x = 1;
// x86/x64(小端)内存: 01 00 00 00
// 网络字节序(大端): 00 00 00 01
如果直接把内存里的 int 发出去,跨平台必然读反 。二进制协议就必须到处写 htonl/ntohl,漏一个就出玄学 bug。
⭐ 而 JSON 是文本协议 :
"{"datax":10}"里的'1','0'是两个 ASCII 字符,无论在什么机器上、什么大小端、什么编译器下,含义都完全一致。文本天然免疫字节序与内存对齐问题。
坑 3:版本兼容性与可读性
| 诉求 | 二进制结构体 | JSON |
|---|---|---|
| 加一个新字段 | ❌ 老客户端全崩(布局变了) | ✅ 老字段照读,新字段忽略即可 |
| 抓包能看懂吗 | ❌ 一堆十六进制 | ✅ nc/Wireshark 肉眼可读 |
| 跨语言(C++/Java/Python) | ❌ 要各自实现布局 | ✅ 所有语言都有 JSON 库 |
💡 结论 :牺牲一点点性能(文本解析比 memcpy 慢、体积更大),换来可读性、可调试性、跨语言性和向后兼容性。对绝大多数业务而言这笔交易是血赚的。这也是 HTTP/1.x、Redis RESP、绝大部分 RPC 框架选择文本协议的原因。
十三、再深入:这个自定义协议还有哪些可以改进的地方
课件里的实现作为教学版本非常清晰,但站在工程的角度,它还留下了几个可以打磨的点:
| 问题 | 现状 | 改进建议 |
|---|---|---|
std::stoi 可能抛异常 |
收到 "abc\r\n..." 就直接抛 std::invalid_argument,进程崩溃 |
用 strtol + 检查 endptr,或 try/catch,或先 isdigit 校验 |
FastWriter 已废弃 |
新版 jsoncpp 中 Json::FastWriter/Reader 被标记 deprecated |
改用推荐的 Json::StreamWriterBuilder + Json::CharReaderBuilder |
| 没有最大值保护 | messagelen 可能被伪造成巨大值,导致内存耗尽 |
加一个 MAX_PACKAGE_SIZE 上限,超过就断开连接并计入黑名单 |
| 报文无校验字段 | 网络传输中数据被篡改无法察觉 | 加一个 CRC32 / MD5 校验和字段 |
Protocol.hpp 里还留着未使用的 ProtSep = " " |
这是约定"正文内部字段分隔符"时的遗留痕迹 | 既然正文用 JSON 自描述,ProtSep 可以删掉;若正文是 "x op y" 这种空白分隔形式,则它会被使用并在两边保持一致 |
Recv 用 += 拼接二进制不安全 |
遇 '\0' 会截断 |
二进制协议请改用 std::string::append(ptr, len) 指定长度追加 |
| 单条 Buffer + 多条请求 | 目前一个连接串行处理 | 可加线程池/IO多路复用进一步提升并发 |
📌 关于
protocol_code的扩展 :在len之前再加一个协议编号字段,就演化成"protocol_code\r\nlen\r\nx op y\r\n"。服务端读到protocol_code后查一张"协议号 → 处理函数"的分发表即可支持多套协议共存 ------ 这就是 RPC 框架协议分派的最小雏形。 所以"今天定义了几组协议?可以同时存在多个协议吗?"答案是:可以同时存在多组,靠编号区分即可。
十四、序列化方案横向对比
| 方案 | 格式 | 可读性 | 体积 | 速度 | IDL/schema | 典型应用 |
|---|---|---|---|---|---|---|
| JSON (Jsoncpp) | 文本 | ✅ 极好 | 大 | 中 | ❌ 无(弱类型) | Web API、配置文件、本篇 |
| XML | 文本 | ✅ 好(但冗长) | 最大 | 慢 | ✅ XSD | 早期 WebService、SOAP |
| Protobuf | 二进制 | ❌ 差 | 最小 | 最快 | ✅ .proto 强 schema |
gRPC、微服务内部通信 |
| MessagePack | 二进制 | ❌ 差 | 小 | 快 | 半(兼容 JSON 模型) | Redis、游戏、IoT |
| FlatBuffers | 二进制 | ❌ 差 | 小 | 极快(零拷贝) | ✅ .fbs |
游戏、高性能移动端 |
| BSON | 二进制 | 中 | 中 | 中 | ❌ | MongoDB |
选型建议:
- 需要给人看 / 调试方便 / 对外接口 → JSON
- 内部高性能 RPC / 带宽敏感 → Protobuf
- 极致性能、避免反序列化开销 → FlatBuffers
十五、高频面试题
Q1:什么是粘包?为什么 TCP 有而 UDP 没有?
TCP 面向字节流,只保证字节顺序、不保留消息边界,多个 send 的数据可能在接收缓冲区中粘在一起或被拆开。UDP 面向数据报,一次
sendto对应一个完整的recvfrom,内核自带长度,天然有边界。
Q2:解决粘包的方案有哪几种?
① 定长包 :每个包固定 N 字节,不够则补位(简单但浪费);② 分隔符法 :用
\r\n等特殊字符结尾(正文不能含分隔符,需转义);③ 长度前缀法 (本文方案):包头写明正文长度,最常用、最可靠;④ 更高层的协议自动处理(HTTP 用Content-Length/chunked)。
Q3:send() 成功返回就一定代表对方收到数据了吗?
不是。
send()只是把数据从用户空间拷贝 到内核发送缓冲区。真正何时发、发多少、要不要重传,由 TCP 自己决定。要保证对方"处理完",需要应用层自己做应用层 ACK。
Q4:为什么一个 TCP sockfd 既能读又能写?
因为这个 fd 对应着内核中互相独立的两个缓冲区 (接收缓冲区 + 发送缓冲区)。这是 TCP 全双工的物理基础;对比 pipe 只有单向缓冲区,所以必须开两个 fd。
Q5:可以直接把结构体 send 出去吗?
强烈不建议。会遇到内存对齐不一致、大小端不一致、版本扩展不兼容三大问题(详见第十二节)。应通过序列化转换成跨平台一致的格式(如 JSON/Protobuf)。
Q6:为什么 Response 里要有 _code 状态码,直接返回结果不行吗?
不行。请求可能因为业务原因失败(除零、非法运算符、权限不足),只靠返回值无法区分"结果是 0"和"计算失败"。状态码 + 载荷的模式是所有成熟协议的通用做法(如 HTTP 200/404/500)。
Q7:Serialize() 和 Encode() 都在"变字符串",区别是什么?
它们是两个层次:
Serialize解决业务对象 ↔ 消息体字符串 (让数据可表达),Encode解决消息体字符串 ↔ 完整网络报文 (让数据可切分)。两者都属于广义的序列化。之所以要分开,是因为Request/Response结构体只描述"有哪些字段",并不规定这些字段怎么变成字节流上线。一句话:Serialize 决定消息长什么样,Encode 决定这条消息从哪儿开始、到哪儿结束。
Q8:如果客户端和服务端的 JSON 字段名写不一致,会立刻报错吗?
不会报错,但更危险。 Jsoncpp 的
operator[]在键不存在时会自动创建一个 null 元素 ,asInt()拿到的是 0 。所以现象是"数据永远是默认值,程序却一切正常"。排查手段:先用root.isMember("key")检查,或改用at()(键不存在会抛异常)。
附录:Jsoncpp 完整用法手册
A.1 简介
Jsoncpp 是一个用于处理 JSON 数据的 C++ 库,提供将 JSON 序列化为字符串以及从字符串反序列化为 C++ 数据结构的能力。它开源且被广泛使用。
特性:
- 简单易用:直观的 API。
- 高性能:性能经过优化,能高效处理大量 JSON 数据。
- 全面支持:支持 JSON 标准全部数据类型 ------ 对象、数组、字符串、数字、布尔值和 null。
- 错误处理:解析 JSON 时提供详细的错误信息和出错位置,方便调试。
A.2 安装
bash
# Ubuntu
sudo apt-get install libjsoncpp-dev
# CentOS
sudo yum install jsoncpp-devel
A.3 序列化:三种写法
方式一:Json::Value::toStyledString()(最简)
cpp
#include <iostream>
#include <string>
#include <jsoncpp/json/json.h>
int main()
{
Json::Value root;
root["name"] = "joe";
root["sex"] = "男";
std::string s = root.toStyledString();
std::cout << s << std::endl;
return 0;
}
输出(带缩进美化):
json
{
"name" : "joe",
"sex" : "男"
}
方式二:Json::StreamWriter(可定制缩进/换行)
cpp
#include <iostream>
#include <string>
#include <sstream>
#include <memory>
#include <jsoncpp/json/json.h>
int main()
{
Json::Value root;
root["name"] = "joe";
root["sex"] = "男";
Json::StreamWriterBuilder wbuilder; // StreamWriter 的工厂
std::unique_ptr<Json::StreamWriter> writer(wbuilder.newStreamWriter());
std::stringstream ss;
writer->write(root, &ss);
std::cout << ss.str() << std::endl;
return 0;
}
想要压缩成一行的风格,可以设置
wbuilder["indentation"] = "";
方式三:Json::FastWriter(无多余空白,最快)
cpp
Json::FastWriter writer;
std::string s = writer.write(root);
std::cout << s << std::endl;
输出:
json
{"name":"joe","sex":"男"}
📌 与之对应的是
Json::StyledWriter(同toStyledString,美化输出)。注意:新版 Jsoncpp 中FastWriter/StyledWriter已被标记为 deprecated ,推荐改用StreamWriterBuilder。
A.4 反序列化
cpp
#include <iostream>
#include <string>
#include <jsoncpp/json/json.h>
int main()
{
std::string json_string = "{\"name\":\"张三\", \"age\":30, \"city\":\"北京\"}";
Json::Reader reader;
Json::Value root;
bool parsingSuccessful = reader.parse(json_string, root);
if (!parsingSuccessful)
{
// 解析失败,输出错误信息
std::cout << "Failed to parse JSON: "
<< reader.getFormattedErrorMessages() << std::endl;
return 1;
}
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;
}
输出:
Name: 张三
Age: 30
City: 北京
Json::CharReader的派生类也可用于更精细地控制解析过程,但通常Json::Reader::parse()或Json::parseFromStream已经足够。
总结:
toStyledString、StreamWriter、FastWriter提供了不同序列化选项,按需选取;Json::Reader和parseFromStream是主要反序列化工具,错误处理机制强大;- 序列化和反序列化时务必处理所有可能的错误情况,并校验输入输出的有效性。
A.5 Json::Value 常用 API 清单
① 构造函数
| API | 说明 |
|---|---|
Json::Value() |
默认构造,创建空的 Value 对象 |
Json::Value(ValueType type, bool allocated = false) |
按给定类型创建(nullValue/intValue/stringValue 等) |
② 访问元素
| API | 说明 |
|---|---|
Json::Value& operator[](const char* key) |
按键访问对象元素;键不存在则新建 |
Json::Value& operator[](const std::string& key) |
同上,参数为 std::string |
Json::Value& operator[](ArrayIndex index) |
按索引访问数组元素;越界则新建 |
Json::Value& at(const char* key) |
按键访问;键不存在则抛异常 |
Json::Value& at(const std::string& key) |
同上,参数为 std::string |
③ 类型检查
| API | 说明 |
|---|---|
bool isNull() |
是否为 null |
bool isBool() |
是否为布尔类型 |
bool isInt() |
是否为整数类型 |
bool isInt64() |
是否为 64 位整数 |
bool isUInt() |
是否为无符号整数 |
bool isUInt64() |
是否为 64 位无符号整数 |
bool isIntegral() |
是否为整数或可转为整数的浮点数 |
bool isDouble() |
是否为双精度浮点数 |
bool isNumeric() |
是否为数字(整数或浮点数) |
bool isString() |
是否为字符串 |
bool isArray() |
是否为数组 |
bool isObject() |
是否为对象(键值对集合) |
④ 赋值与类型转换
| API | 说明 |
|---|---|
Json::Value& operator=(bool/int/unsigned int/Int64/UInt64/double/const char*/const std::string&) |
把对应类型的值赋给 Value |
bool asBool() |
转为布尔类型(如可行) |
int asInt() |
转为 int |
Int64 asInt64() |
转为 64 位整数 |
unsigned int asUInt() |
转为无符号整数 |
UInt64 asUInt64() |
转为 64 位无符号整数 |
double asDouble() |
转为双精度浮点 |
std::string asString() |
转为字符串 |
⑤ 数组与对象操作
| API | 说明 |
|---|---|
size_t size() |
返回数组/对象中的元素数量 |
bool empty() |
是否为空 |
void resize(ArrayIndex newSize) |
调整数组大小 |
void clear() |
删除所有元素 |
void append(const Json::Value& value) |
在数组末尾追加元素 |
Json::Value& operator[](key, const Json::Value& defaultValue = Json::nullValue) |
插入或访问对象元素,键不存在时使用默认值 |
总结
回顾整条知识链路:
- 应用层 是我们写业务的地方,TCP 只搬字节 → 所以必须自定义协议;
- 协议 = 约定好的结构化数据 ,用
Request/Response结构体来表达; - 结构体 ↔ 字符串互转 = 序列化 / 反序列化 ,本例用 Jsoncpp (
Json::Value本质是一个 k-v 容器,JSON 就是把它扁平化成字符串); - 序列化分为两层 :
Serialize/Deserialize负责业务对象 ↔ 消息体字符串 ,Encode/Decode负责消息体字符串 ↔ 完整网络报文;结构体只定字段,报文格式由 Encode 定; - TCP 是面向字节流 的,
send/recv本质是把数据往内核缓冲区里拷 ,"何时发、发多少"由 TCP 控制 → JSON 一进传输层就再也分不出彼此,必须自己加报文边界; Encode干的活就是拼接 :最终报文 = 协议头(len\r\n) + 业务数据(JSON) + \r\n;Decode则是先看够不够一个完整报文,够才切,并把已消费的部分 erase 掉;- 服务端处理循环 = recv 拼接到用户缓冲区 → while(Decode) → Deserialize → 业务 → Serialize → Encode → send;注意客户端与服务端对两个类的用法方向相反(客户端用 Request 发、Response 收;服务端反之);
- 同一个 sockfd 既能读又能写,因为它背后是收发两个独立缓冲区 → TCP 全双工。
打油诗收尾:
字节流里无边界,全靠长度来切线;文本协议免字节序,一纸 JSON 走天下。