网络编程—应用层自定义协议
2026/7/12 1:33:59 网站建设 项目流程

前言

在学习网络编程的时候,很多人都会有这样的困惑:我们天天在用HTTP、TCP、UDP这些协议,但是协议到底是什么?为什么说TCP是面向字节流的?粘包问题到底是怎么回事?序列化和反序列化又在网络通信中扮演什么角色?

这篇文章我会带着大家从零开始,亲手实现一个自定义的应用层协议——网络版计算器。在这个过程中,我们会彻底搞懂上面所有的问题。本文不会堆砌晦涩的理论,而是通过可运行的代码,一步步拆解协议设计的每一个细节。


一、应用层与协议

1.1 什么是应用层?

我们程序员写的一个个解决实际问题、满足日常需求的网络程序,本质上都是在应用层工作的。传输层及以下的协议栈由操作系统内核实现,而应用层协议,则是我们程序员自己定义的"游戏规则"。

很多同学觉得协议是很高大上的东西,其实不然。协议本质上就是一种约定。就像两个人打电话,你说中文我说中文,我们能互相听懂,这就是我们之间的"协议"。

1.2 网络计算器

假设我们要实现一个服务器版的加法器:客户端把两个数字发给服务器,服务器计算后把结果返回给客户端。就这么一个简单的需求,我们该怎么设计通信格式?

方案一:字符串拼接客户端发送形如 "1+2" 的字符串,规定:

  • 两个操作数都是整数

  • 中间只有一个运算符,只能是+

  • 数字和运算符之间没有空格

这个方案很简单,但是扩展性极差。如果我想支持减法、乘法、除法呢?如果我想传浮点数呢?如果我想增加错误码呢?字符串解析会变得异常痛苦。

方案二:结构体+序列化定义结构体来表示交互信息,发送时把结构体按规则转成字符串,接收时再按同样规则转回来。这个转换过程,就是我们常说的序列化反序列化

无论采用哪种方案,只要保证一端发送的数据,另一端能正确解析,这就是一个合法的应用层协议。为了深入理解协议的本质,我们选择方案二来实现,并且使用成熟的jsoncpp库来帮我们完成序列化工作。


二、序列化与反序列化

2.1 序列化

可能有同学会问:我直接把结构体memcpy到缓冲区发过去不行吗?为什么还要转成字符串?

这里有几个关键问题:

  1. 大小端问题:不同主机的字节序可能不同

  2. 内存对齐:结构体在不同编译器下的对齐方式可能不同

  3. 指针问题:如果结构体里有指针,直接发送指针地址毫无意义

  4. 跨语言: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

实现最简单

浪费带宽,不灵活

心跳包等定长消息

特殊分隔符

消息末尾加特殊字符如\r\n

实现简单

消息内容不能包含分隔符

FTP、SMTP、Redis

长度字段+内容

消息开头用固定字节表示长度

灵活高效,无歧义

实现稍复杂

工业界标准方案

应用层协议自描述

类似HTTP有头部和body

扩展性强

实现复杂

HTTP等复杂协议

我们今天实现的方案是长度字段+特殊分隔符的变种,既保留了长度字段的高效,又因为分隔符的存在方便调试。

我们约定的报文格式是:

有效载荷长度\r\n有效载荷内容\r\n

举个例子,如果要发送的JSON内容长度是25字节,那么最终发出去的报文就是:

25\r\n{"x":1,"y":2,"op":"+"}\r\n

4.3 状态机

解码的时候,我们需要处理各种不完整的情况:

  1. 连 \r\n 分隔符都没收到,长度都读不全

  2. 收到了长度,但是内容还没收全

  3. 正好收到一个完整报文

  4. 一次收到了多个报文

解码函数的核心逻辑:

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

七、思考:工业级协议还需要什么?

我们今天实现的协议虽然能跑通,但是离工业级使用还有距离。一个成熟的应用层协议,通常还需要考虑这些问题:

  1. 魔数:比如0x12345678,用来快速识别是不是合法的协议包,防止乱连端口收到垃圾数据

  2. 版本号:协议升级的时候兼容旧版本

  3. 消息类型:区分是请求、响应、心跳、还是推送消息

  4. 序列号:请求和响应对应,处理超时重传

  5. 校验和:检测数据传输过程中是否损坏

  6. 压缩:大报文可以选择性压缩

举个例子,工业级的协议头通常长这样:

┌────────┬────────┬────────┬────────┬────────┬────────┐ │ 魔数 │ 版本号 │ 消息类型│ 序列号 │ 长度 │ 校验和 │ │ 4字节 │ 1字节 │ 1字节 │ 4字节 │ 4字节 │ 4字节 │ └────────┴────────┴────────┴────────┴────────┴────────┘

大家可以思考一下,这些字段分别解决了什么问题?如果让你给我们的计算器协议加上这些字段,代码该怎么改?


八、总结

通过实现这个简单的网络计算器,我们实际上走完了一个应用层协议设计的完整流程:

  1. 理解了协议的本质:协议就是双方约定好的数据格式,能正确解析就是好协议

  2. 搞懂了序列化的意义:解决跨平台、跨语言的数据交换问题

  3. 看透了TCP字节流:send/recv只是拷贝,真正的传输由内核控制

  4. 掌握了粘包问题的解法:用户态缓冲区+长度字段+循环解包

  5. 学会了分层设计:Socket层、协议层、业务层各司其职

网络编程其实没有那么神秘,很多东西你亲手写一遍,比看十遍书都管用。这个项目虽然简单,但是包含了网络编程最核心的思想。大家可以在这个基础上继续扩展,比如支持更多运算、增加并发处理、改成Protobuf序列化等等。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询