首页 / 资讯中心 / 文章详情

C++服务器和客户端交互的项目实践

C++服务器和客户端交互的项目实践 ★ FEATURED ARTICLE
前言「写一个服务器和客户端」是很多人从语法练习走向工程实践的第一道坎。教程里的版本通常是这样服务端accept之后read一次write一次客户端发一句收一句跑起来一切正常。可一旦消息变长、网络变慢、客户端连续发送程序就开始丢消息、卡死、收到半截字符串。根因不是 C 语法而是对 TCP 的误解。TCP 提供的是字节流不是消息流它保证字节按序到达但不保证一次read对应一次write。发送方写了两条消息接收方可能一次读到一条半发送方写了一条很长的消息接收方可能分三次才读完。所谓「粘包」「半包」不是 TCP 的缺陷而是把流当消息用导致的错误抽象。本文按工程顺序讲先定义带长度前缀的应用层协议再写服务端的监听与连接处理然后写客户端的连接与收发循环最后给出一个本机回显服务的完整示例。示例使用 POSIX socket适用于 LinuxWindows 的 Winsock 在初始化、句柄类型和关闭函数上不同文中会点明差异。所有地址限定为127.0.0.1仅用于本机联调。一、先定协议消息边界必须自己划服务端与客户端交互的第一件事不是socket()而是约定「一条消息到哪里结束」。常见方案有三种方案做法优点缺点定长消息每条消息固定 N 字节解析最简单长度受限、浪费带宽分隔符用换行或特殊字节分隔可读性好、便于调试载荷必须转义分隔符长度前缀先发 4 字节长度再发载荷二进制安全、边界明确需要处理字节序与上限项目里最常用的是长度前缀。约定前 4 字节是网络字节序大端的无符号整数表示后续载荷的字节数载荷本身不做转义。接收方先读满 4 字节解出长度再读满该长度。这样无论 TCP 怎么分段应用层都能还原出完整消息。两个必须做的检查。第一长度上限。如果对端发来0xFFFFFFFF接收方不能直接resize四个 GB否则一个恶意包就能耗尽内存。示例把上限设为 1 MiB超过就断开连接。第二字节序。x86 是小端网络传输约定用大端手工按位移取出四个字节可以完全不依赖平台头文件也不会把「本机序」误当成「网络序」。长度前缀解决了消息边界但没有解决「读满」。recv返回的字节数可能小于请求的字节数必须循环读直到读满或出错。这个循环要正确处理三种返回大于 0 表示读到了一部分等于 0 表示对端有序关闭小于 0 表示出错其中EINTR是被信号打断应当重试而不是当成失败。二、服务端骨架监听、接受、每连接一个线程服务端的系统调用顺序是固定的socket() - setsockopt(SO_REUSEADDR) - bind() - listen() - accept() - recv/send - close()几个容易出错的点SO_REUSEADDR要在bind之前设置。服务端重启时旧连接可能处于TIME_WAIT没有这个选项bind会因地址被占用而失败。它不会让两个进程同时监听同一端口只是放宽了地址复用的限制。bind的地址用htonl(INADDR_LOOPBACK)限定为本机回环避免示例服务意外暴露到局域网。若要对外提供服务应显式配置监听地址并加上访问控制。listen的第二个参数是已完成连接队列的长度提示传SOMAXCONN让内核取实现允许的最大值。它不是并发连接数上限。accept返回的是一个新的已连接套接字原监听套接字继续用于接受后续连接。两者都要关闭但关闭时机不同。并发模型方面示例采用「每连接一个线程」accept成功后把新套接字交给一个std::thread线程里循环收消息、转大写、发回去连接结束就关闭套接字。它直观、易于推演但有明显上限连接数一多线程栈与调度开销就会压垮进程。生产环境通常用epoll事件循环或固定大小的线程池把「等待 I/O」和「处理请求」分离。本文不展开事件循环只把阻塞模型讲完整因为它是理解非阻塞模型的基础。线程的生命周期也要说清楚。示例用detach让连接线程独立运行这意味着主线程退出时不会等待它们进程结束会直接终止所有线程。真实服务需要优雅关闭用一个标志位通知accept退出记录所有连接套接字并逐个关闭再join工作线程。示例为了篇幅省略了这部分但读者不应把detach当成默认正确做法。三、客户端与收发循环客户端比服务端少两步不需要bind和listen直接connect到服务端地址。连接成功后双方就是对等的两个端点收发逻辑完全一样。发送侧要处理「短写」。send返回实际写入的字节数可能小于请求长度尤其是在发送大块数据或内核缓冲区将满时。因此要写一个send_all循环直到全部发完。发送时加MSG_NOSIGNAL可以避免对端已关闭时进程收到SIGPIPE而终止——这是 Linux 的选项其它平台可能需要忽略SIGPIPE或设置SO_NOSIGPIPE具体以平台文档为准。接收侧同理要写recv_all。注意recv返回 0 的含义是「对端正常关闭了写方向」不是「暂时没有数据」。阻塞套接字上没有数据时recv会一直等不会返回 0。把 0 当成「稍后再试」会导致死循环。四、完整示例下面给出服务端与客户端两个源文件。协议为 4 字节大端长度加 UTF-8 文本服务端把收到的文本转成大写后回显。编译时两个文件分别生成可执行文件先启动服务端再启动客户端。// 适用于 C17// 平台Linux / POSIX编译g -stdc17 -pthread server.cpp -o server#include arpa/inet.h#include netinet/in.h#include sys/socket.h#include unistd.h#include cerrno#include cctype#include cstdint#include cstdio#include cstring#include iostream#include string#include threadconstexpr std::uint32_t kMaxFrame 1024 * 1024; // 单条消息上限 1 MiBbool send_all(int fd, const void* data, std::size_t len) {const char* p static_castconst char*(data);std::size_t sent 0;while (sent len) {const ssize_t n ::send(fd, p sent, len - sent, MSG_NOSIGNAL);if (n 0) {if (errno EINTR) continue; // 被信号打断重试return false;}if (n 0) return false;sent static_caststd::size_t(n);}return true;}bool recv_all(int fd, void* data, std::size_t len) {char* p static_castchar*(data);std::size_t got 0;while (got len) {const ssize_t n ::recv(fd, p got, len - got, 0);if (n 0) {if (errno EINTR) continue;return false;}if (n 0) return false; // 对端关闭got static_caststd::size_t(n);}return true;}bool send_frame(int fd, const std::string payload) {if (payload.size() kMaxFrame) return false;const std::uint32_t n static_caststd::uint32_t(payload.size());unsigned char header[4] {static_castunsigned char((n 24) 0xFF),static_castunsigned char((n 16) 0xFF),static_castunsigned char((n 8) 0xFF),static_castunsigned char(n 0xFF)};return send_all(fd, header, sizeof header) send_all(fd, payload.data(), payload.size());}bool recv_frame(int fd, std::string out) {unsigned char header[4];if (!recv_all(fd, header, sizeof header)) return false;const std::uint32_t n (static_caststd::uint32_t(header[0]) 24) |(static_caststd::uint32_t(header[1]) 16) |(static_caststd::uint32_t(header[2]) 8) |static_caststd::uint32_t(header[3]);if (n kMaxFrame) return false; // 拒绝超长消息out.resize(n);if (n 0) return true;return recv_all(fd, out[0], n);}void serve_client(int fd) {std::string msg;while (recv_frame(fd, msg)) {for (char c : msg) {c static_castchar(std::toupper(static_castunsigned char(c)));}if (!send_frame(fd, msg)) break;}::close(fd);}int main() {const int lfd ::socket(AF_INET, SOCK_STREAM, 0);if (lfd 0) {std::perror(socket);return 1;}int yes 1;::setsockopt(lfd, SOL_SOCKET, SO_REUSEADDR, yes, sizeof yes);sockaddr_in addr{};addr.sin_family AF_INET;addr.sin_port htons(9000);addr.sin_addr.s_addr htonl(INADDR_LOOPBACK); // 仅本机if (::bind(lfd, reinterpret_castsockaddr*(addr), sizeof addr) 0) {std::perror(bind);::close(lfd);return 1;}if (::listen(lfd, SOMAXCONN) 0) {std::perror(listen);::close(lfd);return 1;}std::cout listening on 127.0.0.1:9000\n;while (true) {const int cfd ::accept(lfd, nullptr, nullptr);if (cfd 0) {if (errno EINTR) continue;std::perror(accept);break;}std::thread(serve_client, cfd).detach();}::close(lfd);return 0;}// 适用于 C17// 平台Linux / POSIX编译g -stdc17 -pthread client.cpp -o client#include arpa/inet.h#include netinet/in.h#include sys/socket.h#include unistd.h#include cerrno#include cstdint#include cstdio#include iostream#include stringconstexpr std::uint32_t kMaxFrame 1024 * 1024;bool send_all(int fd, const void* data, std::size_t len) {const char* p static_castconst char*(data);std::size_t sent 0;while (sent len) {const ssize_t n ::send(fd, p sent, len - sent, MSG_NOSIGNAL);if (n 0) {if (errno EINTR) continue;return false;}if (n 0) return false;sent static_caststd::size_t(n);}return true;}bool recv_all(int fd, void* data, std::size_t len) {char* p static_castchar*(data);std::size_t got 0;while (got len) {const ssize_t n ::recv(fd, p got, len - got, 0);if (n 0) {if (errno EINTR) continue;return false;}if (n 0) return false;got static_caststd::size_t(n);}return true;}bool send_frame(int fd, const std::string payload) {if (payload.size() kMaxFrame) return false;const std::uint32_t n static_caststd::uint32_t(payload.size());unsigned char header[4] {static_castunsigned char((n 24) 0xFF),static_castunsigned char((n 16) 0xFF),static_castunsigned char((n 8) 0xFF),static_castunsigned char(n 0xFF)};return send_all(fd, header, sizeof header) send_all(fd, payload.data(), payload.size());}bool recv_frame(int fd, std::string out) {unsigned char header[4];if (!recv_all(fd, header, sizeof header)) return false;const std::uint32_t n (static_caststd::uint32_t(header[0]) 24) |(static_caststd::uint32_t(header[1]) 16) |(static_caststd::uint32_t(header[2]) 8) |static_caststd::uint32_t(header[3]);if (n kMaxFrame) return false;out.resize(n);if (n 0) return true;return recv_all(fd, out[0], n);}int main() {const int fd ::socket(AF_INET, SOCK_STREAM, 0);if (fd 0) {std::perror(socket);return 1;}sockaddr_in addr{};addr.sin_family AF_INET;addr.sin_port htons(9000);if (::inet_pton(AF_INET, 127.0.0.1, addr.sin_addr) ! 1) {std::perror(inet_pton);::close(fd);return 1;}if (::connect(fd, reinterpret_castsockaddr*(addr), sizeof addr) 0) {std::perror(connect);::close(fd);return 1;}std::string line;while (std::getline(std::cin, line)) {if (line.empty()) continue;if (!send_frame(fd, line)) break;std::string reply;if (!recv_frame(fd, reply)) break;std::cout server: reply \n;}::close(fd);return 0;}逐点核对两个文件各自定义了收发函数实际项目应抽到头文件里这里重复是为了让每个源文件都能单独阅读。send_all的len为 0 时循环不执行直接返回真send_frame对空载荷也能正确发出 4 字节的头。recv_frame先用resize(n)再取out[0]C17 保证非空std::string的下标 0 可写n 0时提前返回避免对空串取地址。长度按大端拼装接收端按同样顺序还原与主机字节序无关。sockaddr_in用{}零初始化未使用的字段保持为 0sin_addr由inet_pton或htonl填充。reinterpret_castsockaddr*是 POSIX 接口要求的类型转换sockaddr_in与sockaddr的布局兼容由平台保证。服务端把cfd移交给线程后不再使用线程结束时关闭它监听套接字只由主线程关闭。常见坑点❌ 假设一次recv就能收到一条完整消息。✅ TCP 是字节流必须按长度前缀循环读满一次读到的可能是半条也可能是两条。❌ 收到长度字段就直接resize不设上限。✅ 校验长度不超过业务上限超过即断开否则一个包就能耗尽内存。❌ 忽略send的返回值认为写一次就发完了。✅ 循环发送直到全部写完并处理EINTR与返回 0 的情况。❌ 把recv返回 0 当成「暂时没数据」继续重试。✅ 返回 0 表示对端关闭应结束循环并释放连接。❌ 直接按本机字节序发送整数长度。✅ 统一用大端网络字节序发送与接收两侧对称转换。❌ 服务端bind到0.0.0.0做本机测试意外暴露到局域网。✅ 测试时绑定127.0.0.1对外服务另行配置防火墙与鉴权。❌ 每个连接detach后不管进程退出时连接与线程被粗暴终止。✅ 生产环境记录连接、支持优雅关闭并join工作线程detach只适合演示。总结问题现象处理方式消息边界粘包、半包4 字节长度前缀 上限校验短写只发出一部分send_all循环短读只收到一部分recv_all循环信号打断errno EINTR重试而非失败对端关闭recv返回 0结束会话并关闭套接字地址占用重启时bind失败SO_REUSEADDR字节序长度解析错乱统一大端服务器与客户端交互的工程难点几乎都集中在「流」与「消息」的鸿沟上。把长度前缀、循环收发、上限校验、错误分类这四件事写进协议层上层的业务代码就可以像处理本地函数调用一样处理网络请求。之后再考虑并发模型线程每连接适合原型事件循环适合高并发但无论选哪种底层的收发循环与边界检查都不能省。
阅读完成 · 觉得有帮助?
咨询建站