网络编程—应用层自定义协议
前言在学习网络编程的时候很多人都会有这样的困惑我们天天在用HTTP、TCP、UDP这些协议但是协议到底是什么为什么说TCP是面向字节流的粘包问题到底是怎么回事序列化和反序列化又在网络通信中扮演什么角色这篇文章我会带着大家从零开始亲手实现一个自定义的应用层协议——网络版计算器。在这个过程中我们会彻底搞懂上面所有的问题。本文不会堆砌晦涩的理论而是通过可运行的代码一步步拆解协议设计的每一个细节。一、应用层与协议1.1 什么是应用层我们程序员写的一个个解决实际问题、满足日常需求的网络程序本质上都是在应用层工作的。传输层及以下的协议栈由操作系统内核实现而应用层协议则是我们程序员自己定义的游戏规则。很多同学觉得协议是很高大上的东西其实不然。协议本质上就是一种约定。就像两个人打电话你说中文我说中文我们能互相听懂这就是我们之间的协议。1.2 网络计算器假设我们要实现一个服务器版的加法器客户端把两个数字发给服务器服务器计算后把结果返回给客户端。就这么一个简单的需求我们该怎么设计通信格式方案一字符串拼接客户端发送形如 12 的字符串规定两个操作数都是整数中间只有一个运算符只能是数字和运算符之间没有空格这个方案很简单但是扩展性极差。如果我想支持减法、乘法、除法呢如果我想传浮点数呢如果我想增加错误码呢字符串解析会变得异常痛苦。方案二结构体序列化定义结构体来表示交互信息发送时把结构体按规则转成字符串接收时再按同样规则转回来。这个转换过程就是我们常说的序列化和反序列化。无论采用哪种方案只要保证一端发送的数据另一端能正确解析这就是一个合法的应用层协议。为了深入理解协议的本质我们选择方案二来实现并且使用成熟的jsoncpp库来帮我们完成序列化工作。二、序列化与反序列化2.1 序列化可能有同学会问我直接把结构体memcpy到缓冲区发过去不行吗为什么还要转成字符串这里有几个关键问题大小端问题不同主机的字节序可能不同内存对齐结构体在不同编译器下的对齐方式可能不同指针问题如果结构体里有指针直接发送指针地址毫无意义跨语言C的结构体Java/Python根本不认识序列化的本质就是把内存中的结构化数据转换成可以独立于平台和语言的字节流格式让不同机器、不同语言写的程序能够互相通信。2.2 方案对比工业界有很多成熟的序列化方案我们来做个简单对比方案可读性数据体积序列化速度跨语言典型应用场景JSON✅ 极好 大 中等✅ 极好Web API、配置文件、调试友好场景XML✅ 好 很大 慢✅ 极好传统企业应用、Web ServiceProtobuf❌ 二进制不可读 极小 极快✅ 好微服务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序列化的三种方式// 方式1toStyledString - 带格式化适合调试 Json::Value root; root[name] zhangsan; root[age] 20; std::string s root.toStyledString(); // 输出: // { // age : 20, // name : zhangsan // } // 方式2FastWriter - 无格式化速度快适合网络传输 Json::FastWriter writer; std::string s writer.write(root); // 输出: {age:20,name:zhangsan} // 方式3StreamWriter - 可高度定制 Json::StreamWriterBuilder wbuilder; std::unique_ptrJson::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\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版本的Socketclass 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; // n0表示对端关闭连接 } 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_ptrRequest BuildRequest() { return std::make_sharedRequest(); } std::shared_ptrRequest BuildRequest(int x, int y, char op) { return std::make_sharedRequest(x, y, op); } std::shared_ptrResponse BuildResponse() { return std::make_sharedResponse(); } std::shared_ptrResponse BuildResponse(int result, int code) { return std::make_sharedResponse(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编写最后写个简单的MakefileCXXg CXXFLAGS-stdc11 -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序列化等等。