
🔥个人主页:爱和冰阔乐
📚专栏传送门:《数据结构与算法》 、C++、 《Linux操作系统》
🐶学习方向:C++方向学习爱好者
⭐人生格言:得知坦然 ,失之淡然

🏠博主简介

文章目录
- 前言
- [一、JSON 消息也需要边界](#一、JSON 消息也需要边界)
- 二、按长度提取完整消息
-
- [2.1 Protocol.hpp](#2.1 Protocol.hpp)
- [2.2 为什么必须保留 buffer](#2.2 为什么必须保留 buffer)
-
- [沿着一条消息看 buffer 怎么变](#沿着一条消息看 buffer 怎么变)
- [2.3 客户端不能读到任意字节就返回成功](#2.3 客户端不能读到任意字节就返回成功)
- [2.4 为什么要限制长度](#2.4 为什么要限制长度)
- [三、计算业务不处理 Socket](#三、计算业务不处理 Socket)
- 四、服务器接到连接后交给协议层
- 五、客户端发送请求并等待应答
-
- [5.1 对象创建与简单工厂](#5.1 对象创建与简单工厂)
- 六、把各层串起来
-
- [6.1 文件与编译关系](#6.1 文件与编译关系)
- [6.2 从编译到发出一次请求](#6.2 从编译到发出一次请求)
- [6.3 出问题时先分清停在哪一层](#6.3 出问题时先分清停在哪一层)
- [6.4 拆包函数与实际 TCP 收发分别检查](#6.4 拆包函数与实际 TCP 收发分别检查)
-
- [先明确检查 Decode 的输入](#先明确检查 Decode 的输入)
- [再通过真正的 Socket 发请求](#再通过真正的 Socket 发请求)
- 总结
前言
JSON 解决了字段怎样表示,但还没有解决一次 read 到底读了多少条消息。这里继续沿用长度报头,把发送、拆包与计算过程连起来。
下面的网络计算器已在 Ubuntu、Linux 5.15.0-181-generic、GCC 11.4.0、JsonCpp 1.9.5-3 上编译运行,使用 C++17。实际发出 10+20 和除零等请求,分别收到结果 30、状态 0,以及状态 1 的应答;一条报文分次写入、两条报文一起写入也完成了往返。另对 Encode 生成的报文逐个切分位置检查 Decode,确认半包不丢失、完整消息能依次取出。后面把这些现象对应到具体代码。
一、JSON 消息也需要边界
JSON 串通过 TCP 传递时,一次读取可能只得到半条消息,也可能得到一条、多条,甚至一条半。read/recv 返回多少字节,与应用一次发送了几条请求没有固定对应关系。
接收时机、已到达的数据量和本次缓冲区大小等,都会影响读取结果。不需要等缓冲区快满,才会遇到半条消息。
多条消息一次读到,通常称为"粘包";一条消息分多次读到,常称为"半包"。这些都是字节流的正常表现。接收端要做的是累计字节,按双方的协议找到边界,不能拿到什么就立即当作一条 JSON 解析。
所以我们进一步定制协议。以客户端为例,我们可以规定客户端发来的请求不能只包含json串,还需要在json串头部包含表示整个json有效载荷的长度字段。为了能够正确处理请求,我们要求在长度和json串中间使用\r\n进行区分。为了有更好的可读性,要求在json串结尾也要带\r\n。
所以完整报文示例如下:
text
26\r\n{"x":10,"y":20,"oper":"+"}\r\n
这里的 \r\n 表示回车、换行两个实际字节,不是把反斜线和字母原样发出去。
长度字段使用十进制文本。先找到它后面的 CRLF,就知道长度写完了;再按这个长度取正文,最后检查结尾的 CRLF。**长度只统计正文,不包含外层分隔符。**请求和应答使用相同的封包规则。
为什么把网络计算器拆成 Socket、协议和业务几层?因为它们处理的问题不同:Socket 管字节收发,协议层管边界和字段,计算模块管运算结果。
这里借用分层思路理解软件结构,并不意味着 C++ 类与 OSI 层一一对应,也不是说操作系统只能支持一种应用协议。不同业务可以复用相同的网络收发代码。
文件保存也有类似问题。连续写入多个对象的字段后,读取方同样需要知道每条记录从哪里开始、到哪里结束。可以先序列化,再把长度和正文一起写入,读取时按相反顺序恢复。
不过,直接保存链表对象的指针并没有意义;应该保存实际字段与关系。文件和 Socket 都能读写字节,并不意味着它们的断开、错误和等待行为完全相同。
二、按长度提取完整消息
这里的 length 统计实际正文的字节数,不是中文字符数,也不包括外层的两个 CRLF。示例里的加号写成 JSON 字符串时,紧凑正文 {"x":10,"y":20,"oper":"+"} 实际是 26 个 ASCII 字节;实际项目采用整数 oper,FastWriter 还可能带末尾换行,因此不要手填长度,一律用 body.size()。

接收缓存可能只有长度的一半,也可能已经有五条半消息。处理顺序如下:
- 找到长度后的 CRLF;找不到则继续读。
- 校验长度只包含数字,并检查上限。
- 计算整帧长度:长度字段字符数 + 2 + 正文长度 + 2。
- 字节不够时保留缓存,等待后续数据。
- 字节足够时检查末尾 CRLF,提取一条正文。
- 只移除这一帧,然后继续检查剩余缓存。
**报文不完整和报文不合法不能混为一谈。**前者继续等待,后者不能无限等待。本例用 false 表示还没收齐,用异常报告格式错误,由连接处理入口捕获并结束本次服务。
2.1 Protocol.hpp
这个头文件依赖前两篇的 Socket.hpp 和 Message.hpp。**Encode/Decode 只处理外层长度与正文,不判断正文是不是合法计算请求。**GetRequest 把拆包、反序列化和业务调用连接起来;GetResponse 则供客户端取出一条应答。
两个 while 各有用途:外层继续接收字节,内层尽量处理缓存里已经完整的消息。内层停止,不一定是连接结束,也可能只是剩下了半条消息。
cpp
#pragma once
#include "Socket.hpp"
#include "Message.hpp"
#include <functional>
#include <stdexcept>
#include <utility>
using namespace SocketModule;
using func_t = std::function<Response(Request &)>;
class Protocol
{
public:
Protocol() = default;
explicit Protocol(func_t handler) : _func(std::move(handler)) {}
std::string Encode(const std::string &body)
{
if (body.size() > max_body) throw std::length_error("frame too large");
return std::to_string(body.size()) + "\r\n" + body + "\r\n";
}
bool Decode(std::string &buffer, std::string *body)
{
auto end = buffer.find("\r\n");
if (end == std::string::npos) {
if (buffer.size() > 11) throw std::runtime_error("invalid header");
return false;
}
if (end == 0 || end > 10) throw std::runtime_error("invalid length");
std::size_t length = 0;
for (std::size_t index = 0; index < end; ++index) {
char digit = buffer[index];
if (digit < '0' || digit > '9') throw std::runtime_error("invalid length");
std::size_t value = static_cast<std::size_t>(digit - '0');
if (length > (max_body - value) / 10)
throw std::runtime_error("frame too large");
length = length * 10 + value;
}
std::size_t total = end + 2 + length + 2;
if (buffer.size() < total) return false;
if (buffer.compare(end + 2 + length, 2, "\r\n") != 0)
throw std::runtime_error("invalid frame ending");
*body = buffer.substr(end + 2, length);
buffer.erase(0, total);
return true;
}
void GetRequest(std::shared_ptr<Socket> &sock, InetAddr &client)
{
std::string buffer;
try {
while (sock->Recv(&buffer) > 0) {
std::string body;
while (Decode(buffer, &body)) {
Request request;
if (!request.Deserialize(body)) {
LOG(LogLevel::WARNING) << client.StringAddr() << " invalid request";
return;
}
Response response = _func(request);
if (sock->Send(Encode(response.Serialize())) < 0) return;
}
}
} catch (const std::exception &error) {
LOG(LogLevel::WARNING) << client.StringAddr() << " " << error.what();
}
}
bool GetResponse(std::shared_ptr<Socket> &client,
std::string &buffer, Response *response)
{
try {
while (true) {
std::string body;
// 先处理已有缓存,半包继续读,成功时只取一条应答。
if (Decode(buffer, &body)) return response->Deserialize(body);
if (client->Recv(&buffer) <= 0) return false;
}
} catch (const std::exception &error) {
std::cerr << "protocol error: " << error.what() << '\n';
return false;
}
}
std::string BuildRequestString(int left, int right, char operation)
{
Request request(left, right, operation);
return Encode(request.Serialize());
}
private:
static constexpr std::size_t max_body = 1024 * 1024;
func_t _func;
};
2.2 为什么必须保留 buffer
**buffer 属于一条连接,必须跨多次 Recv 存活。**把它放在循环内部,每轮重新定义,刚保存的半包就会丢失;把所有客户端共用一份 buffer,则会把不同连接的数据混到一起。
完整消息取出后,erase 只删除对应的前缀。后面剩下的一条半、半个长度字段甚至单独一个回车,都必须留到下一轮继续处理。
沿着一条消息看 buffer 怎么变
还是看上面的 26 字节示例。如果先读到 26\r,报头的 CRLF 尚未完整,Decode 返回 false,缓存原样保留。后续补齐 \n 和一部分正文,长度虽然知道了,但整帧字节数仍不够,也不能提前解析 JSON。
**整帧长度 = 长度字段自身的字节数 + 2 + 正文字节数 + 2。**这个示例是 2+2+26+2,共 32 字节。只有缓存至少包含这些字节,并且帧尾正确,才能提取正文。
如果一次收到了两帧,第一轮只移除前 32 字节,第二轮从剩下的报头继续判断。不要直接清空 buffer,否则第二帧会一起丢掉。这里是在推演解析过程,网络实际分几次返回由运行时情况决定。
2.3 客户端不能读到任意字节就返回成功
客户端的 GetResponse 先检查缓存是否已经包含完整应答;只有 Decode 成功且反序列化成功,才返回 true。如果这次只收到半个长度字段,就继续 Recv。
每次只取一条应答,剩余内容留在缓存里。否则在一个循环中不断覆盖同一个 Response,调用方最后只能看到最后一条结果。
2.4 为什么要限制长度
长度来自对端,不能直接信任。负数、非数字、超长数字、错误结尾都应拒绝。这里限制正文为 1 MiB,只是本例的资源上限,不是 TCP 或 JSON 的规定。计算器的消息很小,足够使用。
TCP 保证有序字节流,不负责划分应用请求。Decode 成功后得到的是完整正文,Deserialize 成功后才得到可使用的业务对象。

三、计算业务不处理 Socket
下面保存为 NetCal.hpp。**Cal 只接收 Request 并返回 Response,不操作 Socket。**它不知道客户端 IP,也不负责收发,所以调整计算规则时,不需要改动拆包循环。
cpp
#pragma once
#include "Protocol.hpp"
#include <limits>
#include <cstdint>
class Cal
{
public:
Response Execute(Request &request)
{
std::int64_t left = request.X();
std::int64_t right = request.Y();
std::int64_t result = 0;
switch (request.Oper()) {
case '+': result = left + right; break;
case '-': result = left - right; break;
case '*': result = left * right; break;
case '/':
if (right == 0) return Response(0, 1);
result = left / right;
break;
case '%':
if (right == 0) return Response(0, 2);
result = left % right;
break;
default: return Response(0, 3);
}
if (result < std::numeric_limits<int>::min() ||
result > std::numeric_limits<int>::max()) return Response(0, 4);
return Response(static_cast<int>(result), 0);
}
};
这个版本保留原有的 0~3 状态码,额外用 4 表示超出 int 范围。客户端应先看状态码,再使用结果;通信双方增加状态码时也需要同步理解。
四、服务器接到连接后交给协议层
下面保存为 TcpServer.hpp。服务器继续使用多进程版本:主进程接收连接,处理连接的进程调用 Protocol,Protocol 再调用 Cal。
cpp
#include "Socket.hpp"
#include <iostream>
#include <memory>
#include <sys/wait.h>
#include <functional>
using namespace SocketModule;
using namespace LogModule;
using ioservice_t = std::function<void(std::shared_ptr<Socket> &sock, InetAddr &client)>;
class TcpServer
{
public:
TcpServer(uint16_t port, ioservice_t service) : _port(port),
_listensockptr(std::make_unique<TcpSocket>()),
_isrunning(false),
_service(service)
{
_listensockptr->BuildTcpSocketMethod(_port);
}
void Start()
{
_isrunning = true;
while(_isrunning)
{
InetAddr client;
auto sock = _listensockptr->Accept(&client);
if(sock == nullptr)
{
continue;
}
LOG(LogLevel::DEBUG) << "accept success ..."<<client.StringAddr();
pid_t id = fork();
if(id < 0)
{
LOG(LogLevel::FATAL) << "fork error ...";
sock->Close();
continue;
}
else if(id == 0)
{
_listensockptr->Close();
pid_t worker = fork();
if (worker < 0) _exit(FORK_ERR);
if (worker > 0) _exit(OK);
_service(sock, client);
sock->Close();
_exit(OK);
}
else
{
sock->Close();
while (::waitpid(id, nullptr, 0) < 0 && errno == EINTR) {}
}
}
_isrunning = false;
}
~TcpServer() {}
private:
uint16_t _port;
std::unique_ptr<Socket> _listensockptr;
bool _isrunning;
ioservice_t _service;
};
这里仍要区分监听 fd 与通信 fd:**处理连接的进程不再接新连接,父进程也不参与这一条连接的业务读写。**因此前者关闭继承的监听端,后者关闭自己那份通信 fd。
为什么父进程 waitpid 后还能继续接收其他连接?它等待的是第一次 fork 出来的子进程;这个子进程完成第二次 fork 就退出,真正服务的是孙子进程。孙子进程之后由相应的收割进程接管,不由这里的 waitpid 等待。
第二次 fork 失败也必须退出,不能当成孙子进程分支继续运行。这个例子保留多进程结构来说明调用关系,不额外展开线程池或事件循环。
五、客户端发送请求并等待应答
下面保存为 TcpClient.cc。它每次发送一个计算请求,再等待对应应答;先把这一来一回看清楚。
cpp
#include "Socket.hpp"
#include "Common.hpp"
#include "Protocol.hpp"
#include <iostream>
#include <string>
#include <memory>
using namespace SocketModule;
void Usage(std::string proc)
{
std::cerr << "Usage: " << proc << " server_ip server_port" << std::endl;
}
void GetDataFromStdin(int *x, int *y, char *oper)
{
std::cout << "Please Enter x: ";
std::cin >> *x;
std::cout << "Please Enter y: ";
std::cin >> *y;
std::cout << "Please Enter oper: ";
std::cin >> *oper;
}
int main(int argc, char *argv[])
{
if (argc != 3)
{
Usage(argv[0]);
exit(USAGE_ERR);
}
std::string server_ip = argv[1];
uint16_t server_port = 0;
if (!ParsePort(argv[2], &server_port)) {
std::cerr << "port must be an integer in [1, 65535]\n";
return USAGE_ERR;
}
std::shared_ptr<Socket> client = std::make_shared<TcpSocket>();
client->BuildTcpClientSocketMethod();
if (client->Connect(server_ip, server_port) != 0)
{
std::cerr << "connect error" << std::endl;
exit(CONNECT_ERR);
}
std::unique_ptr<Protocol> protocol = std::make_unique<Protocol>();
std::string resp_buffer;
while (true)
{
int x, y;
char oper;
GetDataFromStdin(&x, &y, &oper);
if (!std::cin) break;
std::string req_str = protocol->BuildRequestString(x, y, oper);
if (client->Send(req_str) < 0) break;
Response resp;
bool res = protocol->GetResponse(client, resp_buffer, &resp);
if(res == false)
break;
resp.ShowResult();
}
client->Close();
return 0;
}
resp_buffer 放在循环外,这是为了保留尚未消费的字节。读取输入失败时结束循环;发送失败或应答解析失败时也不继续展示一个未初始化的结果。
5.1 对象创建与简单工厂
Request、Response 也可以由 Factory 统一创建,例如用 make_shared 返回请求或应答对象。工厂只负责创建,不负责发送,也不改变对象的序列化格式。当前代码直接构造对象,若创建步骤增多,再集中到工厂中即可。
六、把各层串起来
主程序先创建计算对象,再把计算方法交给 Protocol;最后把 GetRequest 交给 TcpServer。这里的回调是在明确对象生命周期后使用的,不能让回调引用已销毁的局部对象。
cpp
#include "NetCal.hpp"
#include "Protocol.hpp"
#include "TcpServer.hpp"
#include <memory>
#include "Log.hpp"
#include "Daemon.hpp"
void Usage(std::string proc)
{
std::cerr << "Usage: " << proc << " port" << std::endl;
}
int main(int argc, char *argv[])
{
if (argc != 2)
{
Usage(argv[0]);
exit(USAGE_ERR);
}
uint16_t port = 0;
if (!ParsePort(argv[1], &port)) {
std::cerr << "port must be an integer in [1, 65535]\n";
return USAGE_ERR;
}
Daemon(0, 0);
Enable_File_Log_Strategy();
std::unique_ptr<Cal> cal = std::make_unique<Cal>();
std::unique_ptr<Protocol> protocol = std::make_unique<Protocol>([&cal](Request &req)->Response{
return cal->Execute(req);
});
std::unique_ptr<TcpServer> tsvr = std::make_unique<TcpServer>(port,
[&protocol](std::shared_ptr<Socket> &sock, InetAddr &client){
protocol->GetRequest(sock, client);
});
tsvr->Start();
return 0;
}
**先校验端口,再进行守护进程化。**如果在标准错误已重定向之后才发现参数错误,用户可能只看到终端返回,却不知道服务为什么没启动。
Daemon 的具体实现在后面的守护进程篇。第一次看收发流程时,可以暂时注释 Daemon(0, 0) 和 Enable_File_Log_Strategy() 两行,让服务及日志留在当前终端;理解收发后再恢复后台运行。
端口解析放在 Common.hpp,服务端与客户端共用。这里拒绝空字符串、非数字、0 和大于 65535 的数,避免直接窄化转换后得到另一个端口:
cpp
inline bool ParsePort(const std::string &text, uint16_t *port)
{
if (text.empty()) return false;
unsigned int value = 0;
for (char digit : text) {
if (digit < '0' || digit > '9') return false;
value = value * 10 + static_cast<unsigned int>(digit - '0');
if (value > 65535) return false;
}
if (value == 0) return false;
*port = static_cast<uint16_t>(value);
return true;
}
6.1 文件与编译关系
text
main.cc / TcpClient.cc
├── Socket.hpp → InetAddr.hpp、Common.hpp、Log.hpp
├── Protocol.hpp → Message.hpp
├── NetCal.hpp
└── Daemon.hpp
makefile
.PHONY: all clean
all: ServerNetCald client_netcal
ServerNetCald: main.cc $(wildcard *.hpp)
g++ -std=c++17 -Wall -Wextra -pthread main.cc -ljsoncpp -o $@
client_netcal: TcpClient.cc $(wildcard *.hpp)
g++ -std=c++17 -Wall -Wextra -pthread TcpClient.cc -ljsoncpp -o $@
clean:
rm -f ServerNetCald client_netcal
本例以 Linux 常见的 32 位 int 为计算参数范围,int64_t 用于承接加减乘的中间结果。
库依赖放在使用它的源文件或目标文件后面。文件日志默认写入 /var/log/my.log,需要具备对应写权限;普通账号运行时应改成自己可写的绝对路径,不要把没有日志误判为没有启动。
实际头文件路径以安装环境为准,本文沿用 <jsoncpp/json/json.h>。
6.2 从编译到发出一次请求
确认当前目录中已经放齐上面的头文件,以及项目原有的 Common.hpp、InetAddr.hpp、Log.hpp、Mutex.hpp。只复制 Protocol.hpp,不会自动得到这些依赖。日志策略通过基类指针管理派生对象,LogStrategy 的析构函数应声明为 virtual ~LogStrategy() = default;。
下面沿用前台观察方式。先在一个 Linux 终端中编译并启动服务器:
bash
make
./ServerNetCald 8080
再开一个终端,在同一台机器上连接:
bash
./client_netcal 127.0.0.1 8080
程序会依次提示输入 x、y 和 oper,可以分别输入 10、20、+。这对应 Request(10, 20, '+');按计算逻辑应得到 result 为 30、code 为 0 的应答。换成 10、0、/,则应进入除零分支,返回 code 为 1,不能把此时的 result 当作正常结果。
这是两次请求应经过的代码路径,不要求 JSON 字段的打印顺序与截图逐字相同。跨机器运行时,把 127.0.0.1 换成服务器的实际地址,同时检查端口是否允许访问;127.0.0.1 始终指向当前机器自身。
6.3 出问题时先分清停在哪一层
- 编译失败:先检查头文件是否放齐、JsonCpp 开发包及链接参数,Makefile 命令行前要使用 Tab。
- 连接失败:先确认服务器仍在运行、端口与地址一致,不要立刻修改 JSON 格式。
- 连接成功但没有结果:先看发送返回值与拆包条件。长度多算一个字节,就可能一直等待尚未到达的正文。
- 收到错误状态码:说明已经进入业务应答流程,按 code 检查运算符、除零或数值范围。
正文使用 FastWriter 的实际输出长度,不能把演示中手写的 26 复制进封包函数。长度必须由 Serialize 返回的字符串计算,收发两端不能各算一套。
6.4 拆包函数与实际 TCP 收发分别检查
服务器与客户端均按前面的编译命令构建,编译完成后再开始收发。
观察期间,服务器暂时以前台方式运行,监听地址限定为 127.0.0.1;也就是不调用主程序中的 Daemon 和文件日志切换,并把地址封装中的 INADDR_ANY 改为回环地址。协议、计算业务和 Socket 收发函数保持相同实现。
先明确检查 Decode 的输入
拿 Encode 实际生成的一帧报文,在每个非端点字节位置分别切成前后两段。每次先把前段交给 Decode,检查它返回 false,而且没有丢弃缓存;随后补上后段,再检查提取的正文与发送前完全相同,缓存中也没有多余字节。
对两帧相连的情况,第一次 Decode 后,缓存应完整留下第二帧;第二次再取出它。实际检查输出为:
text
PASS all two-part frame splits retain incomplete data
PASS joined frames: first decode retains second frame
PASS joined frames: second decode consumes remainder
PASS reject non-decimal frame length
PASS reject body larger than 1 MiB
这里分别覆盖了不完整输入、连续多帧、非法长度和长度上限。**半包是继续等待的理由,非法报头不是。**不能因为 Decode 都没给出正文,就把这两类情况归到同一个分支。
再通过真正的 Socket 发请求
启动文中的 ServerNetCald,用回环连接发送请求,收到应答后按同样的长度规则读取完整正文。实际结果如下:
text
PASS TCP 10 + 20 => {'code': 0, 'result': 30}
PASS TCP 10 / 0 => {'code': 1, 'result': 0}
PASS TCP 10 % 0 => {'code': 2, 'result': 0}
PASS TCP 10 ? 20 => {'code': 3, 'result': 0}
PASS TCP request sent in two writes
PASS two framed requests sent together, two responses received
这些字典形式是检查程序解析应答后显示的字段,并不是直接发送在网络上的 Python 表达式。网络正文仍是 Request/Response 约定的 JSON。
最后再用项目里的 client_netcal 发出 10+20 请求,实际得到 计算结果是: 30[0],确认交互式客户端也走通了一来一回。
**网络收发检查与拆包函数检查不能相互代替。**发送时调用两次 send,并不能强制对端调用两次 recv;所以前面先直接构造每一种分段输入检查 Decode,随后再用真实连接检查整个服务流程。
这次验证没有人为限制内核发送缓冲区,也没有证明循环发送中的每一轮都出现过短写;不能把普通连通测试说成已经覆盖了所有网络故障。跨机器运行、慢连接和超时处理,仍需结合后续服务设计考虑。
总结
一次请求依次经过:构造字段、序列化、加长度报头、发送、累计接收、提取完整帧、反序列化和计算。应答沿反方向执行相同过程。拆包时留下半包,读取时处理短读,发送时处理短写,才不会把简单回显时暂时没遇到的问题带进计算器。
配套项目:网络计算器源码。
往期博客分享
【Linux网络】TCP 线程池服务器怎么写?从 accept、任务队列到连接调度与 fd 生命周期