前言
在学习网络编程的时候,很多人都会有这样的困惑:我们天天在用HTTP、TCP、UDP这些协议,但是协议到底是什么?为什么说TCP是面向字节流的?粘包问题到底是怎么回事?序列化和反序列化又在网络通信中扮演什么角色?
这篇文章我会带着大家从零开始,亲手实现一个自定义的应用层协议——网络版计算器。在这个过程中,我们会彻底搞懂上面所有的问题。本文不会堆砌晦涩的理论,而是通过可运行的代码,一步步拆解协议设计的每一个细节。
一、应用层与协议
1.1 什么是应用层?
我们程序员写的一个个解决实际问题、满足日常需求的网络程序,本质上都是在应用层工作的。传输层及以下的协议栈由操作系统内核实现,而应用层协议,则是我们程序员自己定义的"游戏规则"。
很多同学觉得协议是很高大上的东西,其实不然。协议本质上就是一种约定。就像两个人打电话,你说中文我说中文,我们能互相听懂,这就是我们之间的"协议"。
1.2 网络计算器
假设我们要实现一个服务器版的加法器:客户端把两个数字发给服务器,服务器计算后把结果返回给客户端。就这么一个简单的需求,我们该怎么设计通信格式?
方案一:字符串拼接客户端发送形如 "1+2" 的字符串,规定:
两个操作数都是整数
中间只有一个运算符,只能是
+数字和运算符之间没有空格
这个方案很简单,但是扩展性极差。如果我想支持减法、乘法、除法呢?如果我想传浮点数呢?如果我想增加错误码呢?字符串解析会变得异常痛苦。
方案二:结构体+序列化定义结构体来表示交互信息,发送时把结构体按规则转成字符串,接收时再按同样规则转回来。这个转换过程,就是我们常说的序列化和反序列化。
无论采用哪种方案,只要保证一端发送的数据,另一端能正确解析,这就是一个合法的应用层协议。为了深入理解协议的本质,我们选择方案二来实现,并且使用成熟的jsoncpp库来帮我们完成序列化工作。
二、序列化与反序列化
2.1 序列化
可能有同学会问:我直接把结构体memcpy到缓冲区发过去不行吗?为什么还要转成字符串?
这里有几个关键问题:
大小端问题:不同主机的字节序可能不同
内存对齐:结构体在不同编译器下的对齐方式可能不同
指针问题:如果结构体里有指针,直接发送指针地址毫无意义
跨语言:C++的结构体,Java/Python根本不认识
序列化的本质,就是把内存中的结构化数据,转换成可以独立于平台和语言的字节流格式,让不同机器、不同语言写的程序能够互相通信。
2.2 方案对比
工业界有很多成熟的序列化方案,我们来做个简单对比:
方案 | 可读性 | 数据体积 | 序列化速度 | 跨语言 | 典型应用场景 |
|---|---|---|---|---|---|
JSON | ✅ 极好 | 📦 大 | 🚀 中等 | ✅ 极好 | Web API、配置文件、调试友好场景 |
XML | ✅ 好 | 📦 很大 | 🐢 慢 | ✅ 极好 | 传统企业应用、Web Service |
Protobuf | ❌ 二进制不可读 | 📦 极小 | 🚀 极快 | ✅ 好 | 微服务RPC、高性能场景 |
MessagePack | ❌ 二进制 | 📦 小 | 🚀 快 | ✅ 好 | 缓存、消息队列 |
自定义二进制 | ❌ 不可读 | 📦 最小 | 🚀 极快 | ❌ 差 | 极端性能场景 |
性能数据参考:Protobuf的序列化吞吐量大约是JSON的5-6倍,反序列化速度差距更大,数据体积只有JSON的1/3到1/10。但是JSON因为可读性好、调试方便、生态完善,在绝大多数场景下仍然是首选。
对于我们的学习项目来说,JSON是最合适的选择——调试的时候可以直接抓包看到内容,出了问题容易排查。
2.3 Jsoncpp库
Jsoncpp是C++中处理JSON最常用的开源库,Ubuntu下直接用apt安装即可:
sudo apt-get install libjsoncpp-dev序列化的三种方式:
// 方式1:toStyledString - 带格式化,适合调试 Json::Value root; root["name"] = "zhangsan"; root["age"] = 20; std::string s = root.toStyledString(); // 输出: // { // "age" : 20, // "name" : "zhangsan" // } // 方式2:FastWriter - 无格式化,速度快,适合网络传输 Json::FastWriter writer; std::string s = writer.write(root); // 输出: {"age":20,"name":"zhangsan"} // 方式3:StreamWriter - 可高度定制 Json::StreamWriterBuilder wbuilder; std::unique_ptr<Json::StreamWriter> writer(wbuilder.newStreamWriter()); std::stringstream ss; writer->write(root, &ss);反序列化:
std::string json_str = "{\"name\":\"zhangsan\",\"age\":20}"; Json::Reader reader; Json::Value root; bool success = reader.parse(json_str, root); if (success) { std::string name = root["name"].asString(); int age = root["age"].asInt(); }Json::Value是一个万能类型,可以表示int、string、bool、数组、对象等所有JSON支持的类型,使用起来非常方便。
三、TCP字节流
在开始写代码之前,我们必须彻底搞懂TCP的传输特性,否则一定会踩粘包的坑。
3.1 read/write
很多初学者以为调用send()就是把数据发到网络上了,调用recv()就是从网络上收数据了——这是完全错误的理解!
实际上:
send() :只是把数据从应用层缓冲区拷贝到内核的TCP发送缓冲区,就返回了
recv() :只是把数据从内核的TCP接收缓冲区拷贝到应用层缓冲区,就返回了
数据什么时候发、发多少、出错怎么重传,全由TCP协议自己决定
这就是为什么说TCP是传输控制协议——它控制着数据传输的所有细节。
3.2 TCP是全双工的
从图中可以清楚看到:每一个TCP连接,在内核中既有发送缓冲区,又有接收缓冲区。这两个缓冲区是独立的,互不影响。
所以在同一个sockfd上,你可以一边发数据一边收数据,这就是TCP的全双工。这也是为什么一个TCP连接只用一个socket文件描述符就能同时读写的根本原因。
理解了这一点,你就能理解为什么会有粘包问题了——TCP只关心字节流,它根本不知道你发送的"消息"边界在哪里。
四、粘包问题
4.1 粘包
TCP是面向字节流的协议,它没有"包"的概念。发送方连续发送的多个小数据包,可能会被合并成一个TCP段发送出去;接收方如果不及时读取缓冲区,多个数据包的数据会堆在一起。这就是粘包。
举个例子:
发送方连续调用send发送了两个消息:"hello"和"world"
接收方调用recv的时候,可能一次就收到了"helloworld"
接收方根本不知道哪里是第一条消息的结尾,哪里是第二条消息的开头
除了粘包,还有拆包问题:一个大消息被TCP拆成了多个段发送,接收方一次recv只收到了半个消息。
粘包和拆包问题,本质上是同一个问题:接收方无法判断消息的边界。
4.2 四种方案对比
方案 | 实现思路 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
固定长度 | 每条消息固定长度,不足补0 | 实现最简单 | 浪费带宽,不灵活 | 心跳包等定长消息 |
特殊分隔符 | 消息末尾加特殊字符如 | 实现简单 | 消息内容不能包含分隔符 | FTP、SMTP、Redis |
长度字段+内容 | 消息开头用固定字节表示长度 | 灵活高效,无歧义 | 实现稍复杂 | 工业界标准方案 |
应用层协议自描述 | 类似HTTP有头部和body | 扩展性强 | 实现复杂 | HTTP等复杂协议 |
我们今天实现的方案是长度字段+特殊分隔符的变种,既保留了长度字段的高效,又因为分隔符的存在方便调试。
我们约定的报文格式是:
有效载荷长度\r\n有效载荷内容\r\n举个例子,如果要发送的JSON内容长度是25字节,那么最终发出去的报文就是:
25\r\n{"x":1,"y":2,"op":"+"}\r\n4.3 状态机
解码的时候,我们需要处理各种不完整的情况:
连 \r\n 分隔符都没收到,长度都读不全
收到了长度,但是内容还没收全
正好收到一个完整报文
一次收到了多个报文
解码函数的核心逻辑:
bool Decode(std::string &package, std::string *message) { // 1. 找第一个分隔符,确定长度字段 auto pos = package.find(LineBreakSep); if (pos == std::string::npos) return false; // 长度都不完整 std::string lens = package.substr(0, pos); int messagelen = std::stoi(lens); // 2. 计算一个完整报文的总长度:长度字段 + 2个分隔符 + 有效载荷 int total = lens.size() + messagelen + 2 * LineBreakSep.size(); if (package.size() < total) return false; // 内容还没收全 // 3. 提取有效载荷,从缓冲区中移除已经处理的报文 *message = package.substr(pos + LineBreakSep.size(), messagelen); package.erase(0, total); return true; }这个逻辑可以正确处理任何粘包和拆包的情况,哪怕一次recv只收到了一个字节,也不会出问题。
五、项目架构设计
在写代码之前,我们先理清楚整个项目的结构,采用面向对象的思想进行封装:
├── Socket.hpp # Socket基类与TcpSocket实现 ├── Protocol.hpp # 自定义协议、序列化、编解码 ├── TcpServer.hpp # Tcp服务器封装 ├── Daemon.hpp # 守护进程(可选) ├── TcpServerMain.cc # 服务器主程序 ├── TcpClientMain.cc # 客户端主程序 └── Makefile我们采用分层设计:
最底层:Socket封装,屏蔽原始C接口的细节
中间层:协议层,处理序列化、反序列化、编解码
最上层:业务逻辑,也就是计算器的加减乘除
六、核心代码
6.1 面向对象封装
首先我们把原始的socket API封装成面向对象的接口,使用模板方法模式:
// 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; 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); } };然后实现TCP版本的Socket:
class TcpSocket : public Socket { public: TcpSocket(int sockfd = defaultsockfd) : _sockfd(sockfd) {} 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); return new TcpSocket(newsockfd); } 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); return ::connect(_sockfd, Convert(&server), sizeof(server)) == 0; } 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; } return false; // n==0表示对端关闭连接 } void Send(std::string &send_str) override { send(_sockfd, send_str.c_str(), send_str.size(), 0); } // ... 其他接口实现 private: int _sockfd; };这里有个非常关键的细节:Recv函数里是*buffer += inbuffer而不是直接赋值。因为我们需要一个用户态的接收缓冲区,把每次recv到的数据都拼起来,然后再尝试解包——这是处理粘包的标准做法。
6.2 协议层实现
协议层是整个项目的核心,我们定义请求和响应结构体,并且实现序列化和反序列化:
namespace Protocol { const std::string LineBreakSep = "\r\n"; // 先实现前面说过的Encode和Decode函数 std::string Encode(const std::string &message) { std::string len = std::to_string(message.size()); return len + LineBreakSep + message + LineBreakSep; } bool Decode(std::string &package, std::string *message) { // 实现见前文 } // 请求结构体:两个操作数 + 一个运算符 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; root["datay"] = _data_y; root["oper"] = _oper; Json::FastWriter writer; *out = writer.write(root); return true; } // 反序列化:JSON字符串 -> 结构体 bool Deserialize(std::string &in) { Json::Value root; Json::Reader reader; bool res = reader.parse(in, root); if (res) { _data_x = root["datax"].asInt(); _data_y = root["datay"].asInt(); _oper = root["oper"].asInt(); } return res; } // 业务逻辑:计算 Response Execute() { Response resp; switch (_oper) { case '+': resp.SetResult(_data_x + _data_y); break; case '-': resp.SetResult(_data_x - _data_y); break; case '*': resp.SetResult(_data_x * _data_y); break; case '/': if (_data_y == 0) { resp.SetCode(1); // 除零错误 resp.SetResult(0); } else { resp.SetResult(_data_x / _data_y); } break; case '%': if (_data_y == 0) { resp.SetCode(2); // 模零错误 resp.SetResult(0); } else { resp.SetResult(_data_x % _data_y); } break; default: resp.SetCode(3); // 非法运算符 break; } return resp; } 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; }; // 工厂类:统一创建请求和响应对象 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这里我们用了简单的工厂模式,好处是上层代码不需要直接new对象,降低耦合。错误码的设计也很重要:0表示成功,非0表示不同的错误类型,客户端可以根据错误码做不同处理。
6.3 服务器主逻辑
有了Socket封装和协议层,服务器的逻辑就非常清晰了:
// 服务器端处理一个连接的函数 void ServiceIO(Socket *sock) { std::string inbuffer; // 用户态接收缓冲区,这是处理粘包的关键! Factory factory; while (true) { // 1. 收数据 if (!sock->Recv(&inbuffer, 1024)) { break; // 客户端关闭连接 } // 2. 循环解包,处理缓冲区中所有完整的报文 std::string message; while (Decode(inbuffer, &message)) { // 3. 反序列化 auto req = factory.BuildRequest(); if (!req->Deserialize(message)) { continue; // 反序列化失败,丢弃这个包 } // 4. 业务计算 auto resp = req->Execute(); // 5. 序列化响应 std::string resp_str; resp->Serialize(&resp_str); // 6. 编码发送 std::string send_pkg = Encode(resp_str); sock->Send(send_pkg); } } sock->CloseSocket(); delete sock; } int main(int argc, char *argv[]) { if (argc != 2) { std::cerr << "Usage: " << argv[0] << " port" << std::endl; return 1; } uint16_t port = atoi(argv[1]); Socket *listen_sock = new TcpSocket(); listen_sock->BuildListenSocketMethod(port, 5); std::cout << "Server started on port " << port << std::endl; while (true) { std::string client_ip; uint16_t client_port; Socket *client_sock = listen_sock->AcceptConnection(&client_ip, &client_port); if (!client_sock) continue; std::cout << "New connection: " << client_ip << ":" << client_port << std::endl; // 简单起见,每来一个连接创建一个线程处理 pid_t pid = fork(); if (pid == 0) { // 子进程 listen_sock->CloseSocket(); ServiceIO(client_sock); exit(0); } // 父进程关闭不需要的socket client_sock->CloseSocket(); waitpid(-1, nullptr, WNOHANG); // 回收僵尸进程 } }这里最核心的设计就是那个inbuffer——所有从内核读到的数据都先放到这个缓冲区里,然后循环尝试解包。能解出一个完整报文就处理一个,解不出来就继续等下一次recv。这就是工业界处理TCP字节流的标准做法。
6.4 客户端主逻辑
客户端就更简单了:
int main(int argc, char *argv[]) { if (argc != 3) { std::cerr << "Usage: " << argv[0] << " server_ip server_port" << std::endl; return 1; } std::string server_ip = argv[1]; uint16_t server_port = atoi(argv[2]); Socket *sock = new TcpSocket(); if (!sock->BuildConnectSocketMethod(server_ip, server_port)) { std::cerr << "Connect server failed!" << std::endl; return 1; } Factory factory; std::string inbuffer; // 自动发送测试用例 char ops[] = {'+', '-', '*', '/', '%'}; for (int i = 0; i < 5; i++) { int x = rand() % 100; int y = rand() % 100; char op = ops[i]; // 构建请求 auto req = factory.BuildRequest(x, y, op); std::string req_str; req->Serialize(&req_str); std::string send_pkg = Encode(req_str); sock->Send(send_pkg); std::cout << "Send: " << x << op << y << std::endl; // 接收响应 while (true) { if (!sock->Recv(&inbuffer, 1024)) { return 0; } std::string message; if (Decode(inbuffer, &message)) { auto resp = factory.BuildResponse(); resp->Deserialize(message); if (resp->GetCode() == 0) { std::cout << "Result: " << resp->GetResult() << std::endl; } else { std::cout << "Error, code: " << resp->GetCode() << std::endl; } break; } } sleep(1); } sock->CloseSocket(); delete sock; return 0; }6.5 Makefile编写
最后写个简单的Makefile:
CXX=g++ CXXFLAGS=-std=c++11 -Wall LDFLAGS=-ljsoncpp all: server client server: TcpServerMain.cc $(CXX) $(CXXFLAGS) -o server TcpServerMain.cc Protocol.hpp Socket.hpp $(LDFLAGS) client: TcpClientMain.cc $(CXX) $(CXXFLAGS) -o client TcpClientMain.cc Protocol.hpp Socket.hpp $(LDFLAGS) clean: rm -f server client编译运行:
make ./server 8080 ./client 127.0.0.1 8080七、思考:工业级协议还需要什么?
我们今天实现的协议虽然能跑通,但是离工业级使用还有距离。一个成熟的应用层协议,通常还需要考虑这些问题:
魔数:比如0x12345678,用来快速识别是不是合法的协议包,防止乱连端口收到垃圾数据
版本号:协议升级的时候兼容旧版本
消息类型:区分是请求、响应、心跳、还是推送消息
序列号:请求和响应对应,处理超时重传
校验和:检测数据传输过程中是否损坏
压缩:大报文可以选择性压缩
举个例子,工业级的协议头通常长这样:
┌────────┬────────┬────────┬────────┬────────┬────────┐ │ 魔数 │ 版本号 │ 消息类型│ 序列号 │ 长度 │ 校验和 │ │ 4字节 │ 1字节 │ 1字节 │ 4字节 │ 4字节 │ 4字节 │ └────────┴────────┴────────┴────────┴────────┴────────┘大家可以思考一下,这些字段分别解决了什么问题?如果让你给我们的计算器协议加上这些字段,代码该怎么改?
八、总结
通过实现这个简单的网络计算器,我们实际上走完了一个应用层协议设计的完整流程:
理解了协议的本质:协议就是双方约定好的数据格式,能正确解析就是好协议
搞懂了序列化的意义:解决跨平台、跨语言的数据交换问题
看透了TCP字节流:send/recv只是拷贝,真正的传输由内核控制
掌握了粘包问题的解法:用户态缓冲区+长度字段+循环解包
学会了分层设计:Socket层、协议层、业务层各司其职
网络编程其实没有那么神秘,很多东西你亲手写一遍,比看十遍书都管用。这个项目虽然简单,但是包含了网络编程最核心的思想。大家可以在这个基础上继续扩展,比如支持更多运算、增加并发处理、改成Protobuf序列化等等。