
简介面向Windows桌面应用开发者的C WebSocket客户端完整工程源码基于MFC、Boost与websocketpp构建适合需要系统学习WebSocket协议、C网络编程以及GUI端实时通信实现的进阶读者。压缩包38.61MB共318个文件以96个hpp头文件和73个cpp源文件为主体同时包含37个txt文档、33个sconscript构建脚本、Visual Studio工程配置、PEM证书与示例输出等方便直接打开工程浏览、编译和二次开发。目前已有3017人学习下载。源码覆盖连接发起与HTTP Upgrade握手、WebSocket帧解析与构造、异步I/O回调、异常与错误处理、MFC事件驱动界面、内存释放与防泄漏、多线程共享数据保护等关键模块如果采用WSS安全链路还可看到证书验证和加密通信的配套处理。通过研读这份工程能够深入理解websocketpp如何通过回调机制管理连接生命周期Boost如何支撑异步网络操作以及MFC界面层与网络线程如何协同工作是一份适合结合源码逐模块分析的实战型参考资料。 我搜索“C websocket客户端源码”的时候其实想找的是一个能直接扔进项目里用的东西结果翻来翻去要么是封装太重的库要么是只讲理论不给代码的博客。后来我干脆自己写了一个轻量的客户端核心逻辑不依赖任何第三方库纯socket 标准库就能跑Windows和Linux都能编。这篇文章就把这份源码的设计思路、关键代码和踩过的坑完整拆开讲顺便把过程中遇到的一个挺典型的“连接被服务端提前关闭”问题也复盘一遍希望能给正在搞C实时通信的朋友省点时间。1. 为什么在C项目里需要一个自研的WebSocket客户端先别急着看代码得先搞清楚一个问题市面上明明有现成的库为什么还要自己写我当时面临的场景是这样的服务端是用Java写的WebSocket推送服务客户端是一个跑在嵌入式设备上的C程序负责接收实时告警数据。设备性能有限内存只有几十MBCPU主频也不高而且编译工具链比较老很多现代C特性用不了。翻了一圈可用的方案大致情况如下方案依赖适合场景问题Boost.BeastBoost库全家桶服务端、大型项目依赖太重交叉编译费劲libwebsockets自带SSL/事件循环嵌入式LinuxAPI偏底层学习成本高websocketppBoost.Asio跨平台项目异步模型复杂调试头大自研轻量客户端仅标准库 socket资源受限/定制需求需要自己维护协议细节Boost.Beast其实很好但设备上的交叉编译环境太老Boost版本根本带不动。libwebsockets功能全但它的API设计是围绕事件循环的和现有业务线程模型有冲突。最终我决定自研一个。要说清楚的是自研WebSocket客户端这件事复杂度并没有想象中那么高。WebSocket的协议核心就两块握手和帧收发。握手本质是一次HTTP Upgrade请求帧收发无非是处理一个带掩码的二进制格式。只要耐心把RFC 6455里那几页关键部分啃明白整个客户端代码量控制在几百行内完全可行。适合直接参考这份源码的人我总结了几类项目里只是需要简单收发文本或二进制消息不想引入重型依赖需要把WebSocket客户端集成到现有C/C线程模型中库的事件循环反而碍事正在做嵌入式或跨平台开发编译环境受限想彻底搞懂WebSocket协议用源码学习比读文档直观得多当然如果项目功能复杂比如需要自动分片、TLS加密、代理支持还是建议直接用成熟库。自研的边界要明确。2. WebSocket协议里两个最容易写错的地方握手与帧格式协议细节是这类代码最容易翻车的地方。我把RFC 6455里和客户端相关的部分挑出来讲清楚。2.1 握手请求与Sec-WebSocket-Accept校验握手过程就是发起一个HTTP GET请求带上一堆特殊头。关键是Sec-WebSocket-Key这个值不是随便写的必须是16字节随机数做Base64编码的结果。服务端会拿这个Key拼接一个固定的GUID字符串做SHA1哈希再Base64编码得到Sec-WebSocket-Accept返回给客户端。客户端必须要校验这个值否则等于没验证对方是不是真的WebSocket服务端。RFC 6455里有一个标准的测试用例Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ拼接GUID后的字符串: dGhlIHNhbXBsZSBub25jZQ258EAFA5-E914-47DA-95CA-C5AB0DC85B11最终Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbKxOo我在调试握手问题的时候就是先用这个官方测试用例验证本地SHA1和Base64实现是否正确排除算法问题后再排查网络问题。建议你也这么干能省很多排查时间。完整的握手请求长这样GET /ws HTTP/1.1 Host: 192.168.1.100:8080 Upgrade: websocket Connection: Upgrade Sec-WebSocket-Key: 随机生成的16字节Base64 Sec-WebSocket-Version: 13服务端返回101状态码并带Sec-WebSocket-Accept头才算握手成功。2.2 帧格式掩码、长度解析和分片WebSocket的帧格式是二进制的服务端到客户端的帧不带掩码客户端到服务端的帧必须带掩码这是硬性规定不遵守的话服务端会直接断开连接。帧的前两个字节是基础信息第一个字节最高位FIN是否最后一帧低4位是Opcode1是文本2是二进制8是关闭9是Ping10是Pong第二个字节最高位MASK是否掩码低7位是Payload长度长度字段有个分档逻辑这是很多人第一次写会忽略的0-125直接就是长度126后面还有2个字节是大端序的16位长度127后面还有8个字节是大端序的64位长度也就是说当你发送一个长度恰好是126字节的消息时必须在长度字段里写126然后额外追加2字节的16位长度值0x0080。如果直接写成126然后后面不跟长度字节服务端解析就会错位整个连接直接崩掉。这个边界条件我有一次没注意导致发送特定大小消息时概率性断连排查了很久。掩码的计算逻辑不复杂客户端帧必须把长度字段的最高位置1然后附加4字节的随机Masking Key负载数据逐字节和Key[i % 4]做异或。另外如果服务端发来一个大消息被分片了客户端要负责把多个帧拼起来。判断依据就是FIN位Opcode为0x00表示是续帧。为了不让客户端源码过度膨胀我的实现里暂时用std::string做累积缓存把同一个消息的所有分片拼完整后再抛给上层业务。3. 客户端源码的模块划分与核心实现这一节直接看代码。我把源码拆成几个模块Socket封装、握手逻辑、帧编解码、业务接口。3.1 整体类结构#ifndef WEBSOCKET_CLIENT_H #define WEBSOCKET_CLIENT_H #include cstdint #include string #include functional class WebSocketClient { public: using MessageCallback std::functionvoid(const std::string, bool); using CloseCallback std::functionvoid(int, const std::string); using ErrorCallback std::functionvoid(const std::string); WebSocketClient(); ~WebSocketClient(); void setMessageCallback(MessageCallback cb) { messageCb_ std::move(cb); } void setCloseCallback(CloseCallback cb) { closeCb_ std::move(cb); } void setErrorCallback(ErrorCallback cb) { errorCb_ std::move(cb); } bool connect(const std::string host, uint16_t port, const std::string path, uint32_t timeoutMs 5000); void close(uint16_t code 1000, const std::string reason ); bool sendText(const std::string text); bool sendBinary(const void* data, size_t len); bool poll(int timeoutMs); private: bool tcpConnect(const std::string host, uint16_t port, uint32_t timeoutMs); bool handshake(const std::string host, const std::string path); bool sendFrame(uint8_t opcode, const void* payload, size_t len); int recvFrame(std::string payload, uint8_t opcode); bool sendAll(const char* buf, size_t len); int sock_; bool connected_; bool gotClose_; std::string recvBuf_; MessageCallback messageCb_; CloseCallback closeCb_; ErrorCallback errorCb_; }; #endif设计上的几个考虑用回调而不是继承是为了让这个类可以嵌入各种业务框架不必强制继承。poll放在这里意思是调用方可以使用自带的事件循环每次调用poll做一次非阻塞读取满足“不抢线程、不抢循环”的诉求。recvBuf_做粘包处理缓存因为TCP是流式的一次recv可能收到多个WebSocket帧也可能只收到一个帧的一半。3.2 握手实现bool WebSocketClient::handshake(const std::string host, const std::string path) { unsigned char keyBytes[16]; // 随机数生成Linux可用getrandomWindows用rand_s for (int i 0; i 16; i) { keyBytes[i] static_castunsigned char(rand() % 256); } std::string secKey base64Encode(keyBytes, 16); std::string req GET path HTTP/1.1\r\n; req Host: host \r\n; req Upgrade: websocket\r\n; req Connection: Upgrade\r\n; req Sec-WebSocket-Key: secKey \r\n; req Sec-WebSocket-Version: 13\r\n; req \r\n; if (!sendAll(req.data(), req.size())) { return false; } std::string headerBuf; while (headerBuf.find(\r\n\r\n) std::string::npos) { char tmp[512]; int n static_castint(recv(sock_, tmp, sizeof(tmp), 0)); if (n 0) return false; headerBuf.append(tmp, n); if (headerBuf.size() 8192) return false; // 防恶意超大响应头 } std::string responseHeader headerBuf.substr(0, headerBuf.find(\r\n\r\n)); // 校验状态行 if (responseHeader.find(101) std::string::npos) { if (errorCb_) errorCb_(handshake failed, response: responseHeader); return false; } // 校验Sec-WebSocket-Accept std::string expect secKey 258EAFA5-E914-47DA-95CA-C5AB0DC85B11; std::string acceptHash sha1Base64(expect); if (responseHeader.find(acceptHash) std::string::npos) { if (errorCb_) errorCb_(invalid Sec-WebSocket-Accept); return false; } // 剩余数据可能是服务端随后推来的帧要缓存 size_t headerEnd headerBuf.find(\r\n\r\n) 4; if (headerEnd headerBuf.size()) { recvBuf_ headerBuf.substr(headerEnd); } return true; }这里有两个小心思校验完响应头后剩下的recvBuf_不能丢。因为服务端可能在握手响应后立刻推第一个业务消息如果这次recv已经把消息体带回来了你不缓存起来这个帧就丢了。响应头大小限制必须加防止恶意的服务端一直不返回结束符把客户端内存撑爆。3.3 帧发送与接收发送帧时构造掩码的逻辑如下bool WebSocketClient::sendFrame(uint8_t opcode, const void* payload, size_t len) { if (!connected_ || gotClose_) return false; const unsigned char* data static_castconst unsigned char*(payload); std::string frame; frame.push_back(static_castchar(0x80 | opcode)); // FIN opcode if (len 126) { frame.push_back(static_castchar(0x80 | len)); // MASK 短长度 } else if (len 0xFFFF) { frame.push_back(static_castchar(0x80 | 126)); frame.push_back(static_castchar((len 8) 0xFF)); frame.push_back(static_castchar(len 0xFF)); } else { frame.push_back(static_castchar(0x80 | 127)); uint64_t bigLen len; char lenBytes[8]; for (int i 7; i 0; i--) { lenBytes[i] static_castchar(bigLen 0xFF); bigLen 8; } frame.append(lenBytes, 8); } unsigned char maskKey[4]; // 生产环境建议用更安全的随机数来源 maskKey[0] static_castunsigned char(rand() % 256); maskKey[1] static_castunsigned char(rand() % 256); maskKey[2] static_castunsigned char(rand() % 256); maskKey[3] static_castunsigned char(rand() % 256); frame.append(reinterpret_castchar*(maskKey), 4); bool needMask true; for (size_t i 0; i len; i) { char maskedByte static_castchar(data[i] ^ maskKey[i % 4]); frame.push_back(maskedByte); } // 上面这个needMask变量没有实际意义是我隔离需求时留下的废代码 (void)needMask; return sendAll(frame.data(), frame.size()) static_castssize_t(frame.size()); }接收解析时关键是要处理好“读不够”的情况int WebSocketClient::recvFrame(std::string payload, uint8_t opcode) { // 先从缓存取2字节帧头 while (recvBuf_.size() 2) { char tmp[1024]; int n static_castint(recv(sock_, tmp, sizeof(tmp), 0)); if (n 0) return -1; recvBuf_.append(tmp, n); } size_t pos 0; uint8_t b0 static_castuint8_t(recvBuf_[pos]); uint8_t b1 static_castuint8_t(recvBuf_[pos]); opcode b0 0x0F; bool masked (b1 0x80) ! 0; uint64_t payloadLen b1 0x7F; if (payloadLen 126) { while (recvBuf_.size() pos 2) { char tmp[1024]; int n static_castint(recv(sock_, tmp, sizeof(tmp), 0)); if (n 0) return -1; recvBuf_.append(tmp, n); } uint16_t len16 (static_castuint8_t(recvBuf_[pos]) 8) | static_castuint8_t(recvBuf_[pos 1]); payloadLen len16; pos 2; } else if (payloadLen 127) { while (recvBuf_.size() pos 8) { char tmp[1024]; int n static_castint(recv(sock_, tmp, sizeof(tmp), 0)); if (n 0) return -1; recvBuf_.append(tmp, n); } payloadLen 0; for (int i 0; i 8; i) { payloadLen (payloadLen 8) | static_castuint8_t(recvBuf_[pos i]); } pos 8; } if (masked) { while (recvBuf_.size() pos 4) { char tmp[1024]; int n static_castint(recv(sock_, tmp, sizeof(tmp), 0)); if (n 0) return -1; recvBuf_.append(tmp, n); } // 客户端收到的服务端帧实际上不该有MASK这里做个防御性处理 pos 4; } while (recvBuf_.size() pos payloadLen) { char tmp[1024]; int n static_castint(recv(sock_, tmp, sizeof(tmp), 0)); if (n 0) return -1; recvBuf_.append(tmp, n); } payload.assign(recvBuf_.data() pos, payloadLen); recvBuf_.erase(0, pos payloadLen); return static_castint(opcode); }这段代码的读写顺序其实就是边收边push到字符串、再从字符串按位置解析。好处是逻辑简单坏处是拷贝频繁。对嵌入式场景来说消息一般是小数据其实无所谓。如果做高吞吐大消息建议改成环形缓冲区。3.4 poll与关闭流程bool WebSocketClient::poll(int timeoutMs) { if (!connected_ || gotClose_) return false; fd_set rset; FD_ZERO(rset); FD_SET(sock_, rset); timeval tv; tv.tv_sec timeoutMs / 1000; tv.tv_usec (timeoutMs % 1000) * 1000; int ret select(sock_ 1, rset, nullptr, nullptr, tv); if (ret 0) { return (ret 0) ? false : true; // 超时不算错误 } std::string payload; uint8_t opcode 0; int r recvFrame(payload, opcode); if (r 0) { if (errorCb_) errorCb_(recv frame failed or connection reset); handleClose(1006, connection closed unexpectedly); return false; } if (opcode 0x8) { handleClose(1000, payload); return false; } else if (opcode 0x9) { sendFrame(0xA, nullptr, 0); // 自动回Pong } else if (opcode 0x1) { if (messageCb_) messageCb_(payload, true); } else if (opcode 0x2) { if (messageCb_) messageCb_(payload, false); } return true; }关闭时WebSocket的礼仪是主动发一个Close帧然后等服务端回Close帧再关闭TCP。但我见过有些服务端不回Close帧直接断开所以客户端在发送Close帧后就主动close socket不等对方超时兜底避免连接资源挂起。4. 编译、链接与第一次跑通4.1 Linux编译把代码放到ws_client.cpp编译命令很简单g -stdc11 -O2 -Wall -o ws_client ws_client.cpp -lpthread不需要额外的库。如果你在Mac上也一样能编。4.2 Windows编译Windows下要做两件事链接ws2_32库启动WinsockVS工程里在头文件前加#pragma comment(lib, ws2_32.lib)或者在CMake里target_link_libraries(ws_client ws2_32)Winsock初始化#ifdef _WIN32 WSADATA wsaData; WSAStartup(MAKEWORD(2, 2), wsaData); #endif有几个跨平台差异要注意Windows的recv函数签名里第二参数是char*Linux是void*。为省事我在代码里统一强转。select的fd_set在Windows里只支持socket句柄Linux里是普通文件描述符语义一样。Windows下close要用closesocketLinux用close。源码里做了一层宏封装。4.3 验证连通性我习惯用两种方式验证先用在线WebSocket测试服务做握手和数据收发再写一个最简单的Python回显服务import asyncio import websockets async def echo(websocket, path): async for message in websocket: await websocket.send(message) start_server websockets.serve(echo, 0.0.0.0, 8765) asyncio.get_event_loop().run_until_complete(start_server) asyncio.get_event_loop().run_forever()这服务就一个逻辑收到什么回什么。客户端连上去发一句话能收到一模一样的内容说明帧收发没问题。再换二进制消息测确认opcode处理没毛病。最后把服务端停掉观察客户端能否正确感知连接断开。5. 排查实录连接被服务端提前关闭的问题在测试阶段我遇到了一个特别容易让新手懵圈的报错网络热词里都有人搜stream disconnected before completion: websocket closed by server before res。这个报错虽然常出现在服务端或者中间语言客户端里但本质上服务端主动关闭连接的原因是一致的。我先说现象客户端每5秒发一次心跳服务端却经常在几次心跳后主动断开客户端这边recv返回0没收到任何Close帧。5.1 复现流程排查步骤是这样走的在服务端日志里看断开原因发现是“空闲超时”确认客户端确实在发心跳但服务端收没收到抓包看客户端发的心跳帧到达服务端但服务端回包却直接RST了问题出在哪我抓包发现客户端发的Ping帧正常但服务端要求的是“应用层消息”不是Ping帧或者服务端的空闲超时判定只看业务消息不看Ping/Pong控制帧。5.2 根因很多WebSocket服务端框架会把Ping/Pong作为控制帧单独处理如果onPing回调里没有配置自动回Pong连接就会被判定为超时。更常见的情况是服务端设置的“会话空闲时长”是60秒但业务层心跳是90秒一次恰好在两条业务消息之间超过了服务端阈值连接被回收。这个问题的关键词是“空闲超时阈值比心跳周期短”不是代码逻辑错误是配置不对齐。5.3 解决方案我做了三个变化客户端把心跳周期从90秒改成30秒确保任何情况下都远小于服务端60秒的阈值心跳使用Ping帧并监听Pong帧。连续3次Ping无Pong响应就判定连接死掉主动重建发送业务数据时重置本地计时器避免在业务繁忙时仍然傻傻地凑心跳周期发送还有一个隐藏坑如果服务端的内网和客户端之间有公网路由器或NAT设备长时间没有流量中间设备的会话表项会过期把连接默认为死连接。这种场景下30秒一次心跳是合理的因为很多NAT默认超时是60秒。总之心跳周期宁可短一点也不要卡在临界值。5.4 其他导致服务端关闭的常见原因除了超时断连我还遇到过握手路径不对导致服务端返回404直接关闭的。注意看WebSocket URL里的pathJava端如果用ServerEndpoint(/ws/{deviceId})注册建的连接是ws://ip:port/ws/123少一个段路径都不同。还有鉴权失败服务端在握手阶段校验token验证不过会返回403或直接断开。这种情况客户端日志里往往看不到应用层错误只有TCP层异常。所以调试时一定要把握手响应头完整打印出来不要只判断101状态。6. 从自研到落地推送、重连与跨语言对接源码能跑通之后离真正用到业务里还有几步路。6.1 断线重连策略嵌入式场景下网络不稳定是常态重连策略不要写得太暴力。我用的方案是重连间隔指数退避从1秒开始每次翻倍上限30秒每次重连前把旧socket清理干净避免句柄泄漏重连后要重新做握手而不是复用旧连接伪代码大概是int retryDelay 1; while (!stopFlag) { if (client.connect(host, port, path)) { retryDelay 1; client.runLoop(); // 阻塞直到断开 } sleep(retryDelay); retryDelay std::min(retryDelay * 2, 30); }6.2 与Java/SpringBoot服务端对接在不少项目里C端是数据采集端Java端是业务服务端C采集到数据后用WebSocket推到SpringBoot。这种跨语言组合里最要注意的是消息格式约定比如Java端用Jackson反序列化JSONC端拼JSON时字段顺序无所谓但字段类型必须严格匹配0110这种字符串和数字110稍有出入反序列化就会报错。另外Java端如果开启了ServerEndpoint的maxTextMessageBufferSize限制C端发超过1MB的大数据就会被服务端直接断开。遇到这种情况要么改大缓冲要么客户端拆成二进制分片发。6.3 在视频设备接入场景下的应用有些做视频监控行业的朋友问过WebSocket客户端能不能用到GB28181协议里。GB28181本身主要走SIP和RTP但不少平台会把WebSocket作为信令通道来传输设备状态和自定义指令这种情况下C客户端负责在设备端维持长连接接收平台下发的查询指令再返回设备信息。这时WebSocket客户端只需要在本地做一个状态机来映射指令响应不一定非要引入整套GB28181协议栈。只要保证消息可靠送达、连接自动恢复实际效果就很稳定。以我个人经验来说手写这个客户端的过程比直接用库更值因为你被逼着把协议细节抠清楚了后面调什么服务端心里都有底。最后留一个很实在的建议如果你把这段代码用于正式项目至少要做两件额外的事——把随机种子换成更安全的随机源以及给所有外部输入加长度上限检查。协议解析是网络攻击的高发地带不能裸奔。本文还有配套的精品资源点击获取