简介:这份资源面向具备一定C++基础、希望深入理解网络编程与实时通信的开发者,聚焦于用C++结合libevent事件驱动库搭建WebSocket服务器这一典型场景。内容围绕HTTP Upgrade握手流程展开,涵盖Sec-WebSocket-Key校验与Accept值生成、连接建立后的帧编解码、读写事件回调注册,以及心跳与超时管理等长连接维护要点,帮助读者打通从协议细节到工程实现的链路。压缩包共20个文件,约16KB,以8个h头文件与7个cpp源文件为主体,辅以Makefile构建脚本、README说明及演示页面,模块划分清晰,便于按连接、帧处理、工具函数等层次阅读与二次改造。目前已有616人学习下载,适合作为理解WebSocket协议与libevent并发模型的实践参考。
1. 一个 C++ WebSocket 服务器,为什么值得你花一晚上拆开看
手里有个实时推送的需求,第一反应是上 Node 或者 Go,但项目是纯 C++ 技术栈,中间件、内存模型、线程池全是现成的,硬塞一个运行时进去,运维和编译链都得跟着改。这时候一个基于 libevent 的 C++ WebSocket 服务器源码包就很有价值了:它不依赖重型框架,事件循环用 libevent 兜底,协议层自己实现握手和帧解析,编译出来就是一个能独立跑的可执行文件。适合谁?适合已经会写 C++、懂 socket 基本流程、但没亲手撸过 WebSocket 协议帧的从业者。这篇不聊 WebSocket 的历史,只讲这份资源里每个文件干什么、握手怎么算、帧怎么拆、粘包怎么处理,以及我实际编译运行时翻过的车。
2. 拆开源码包:libevent 事件循环与 WebSocket 协议层怎么分工
拿到一个 C++ 网络库源码包,最怕的是一上来就从头读 main。正确的顺序是先看目录结构,判断它把「传输层」和「协议层」切在哪,这决定了你后面改代码时该动哪个文件。这份资源的组织方式比较典型:底层用 libevent 的event_base管所有 fd 的可读可写事件,上层单独一个 WebSocket 会话对象负责握手、帧编解码和业务回调,两者之间靠一个连接上下文结构体串起来。
2.1 目录结构与模块职责
常见做法是把代码分成三块:网络核心(监听、accept、事件注册)、协议处理(HTTP Upgrade 握手、帧解析)、业务示例(echo 或广播)。我拆的时候会先找这几个关键符号:event_base_new、evhttp或裸event_new、websocket_handshake、parse_frame。如果握手和帧解析被塞进同一个几百行的函数里,说明这份代码更适合当学习样本而不是生产底座,改之前要有心理准备。
判断一份 WebSocket 源码能不能直接用,看三个点:一是握手是否校验了Sec-WebSocket-Key并正确拼出Sec-WebSocket-Accept;二是帧解析是否处理了掩码(客户端发来的帧必须带掩码);三是发送大帧时有没有做分片。这三点缺一个,上线就会出玄学问题。
2.2 libevent 事件循环的接入方式
libevent 的价值在于把 epoll/kqueue 的差异抹平,你只需要关心「fd 可读了调哪个回调」。这份资源里,监听 socket 通过event_new绑定一个 accept 回调,新连接进来后为每个 fd 再注册一个读事件,读回调里把字节喂给 WebSocket 解析器。下面是我按这个思路整理的最小接入骨架,参数含义在代码后说明:
// 创建事件循环,后续所有 fd 都挂到这一个 base 上 struct event_base* base = event_base_new(); if (!base) { /* 初始化失败,直接退出,别继续往下走 */ } // 监听 socket:bind + listen 之后拿到 listen_fd // EV_PERSIST 表示持续监听,EV_READ 表示关注可读 struct event* listen_ev = event_new(base, listen_fd, EV_READ | EV_PERSIST, on_accept, base); event_add(listen_ev, nullptr); // 进入事件循环,阻塞直到 event_base_loopbreak 或出错 event_base_dispatch(base);on_accept里要做的事是accept出新 fd、设为非阻塞、为该 fd 创建连接上下文、注册读事件。这里有个容易忽略的点:event_new的最后一个参数是回调的上下文指针,很多人直接传nullptr,结果回调里拿不到连接对象,只能靠全局 map 查 fd,多线程下就是灾难。我一般把连接上下文指针直接传进去,回调第一行就reinterpret_cast回来。
2.3 握手阶段:HTTP Upgrade 的四个必查项
WebSocket 握手本质是一个带特殊头的 HTTP GET。服务端收到请求后要校验四样东西:请求方法是 GET、Upgrade头包含websocket、Connection头包含Upgrade、Sec-WebSocket-Version是 13。校验通过后,把客户端的Sec-WebSocket-Key拼上固定 GUID258EAFA5-E914-47DA-95CA-C5AB0DC85B11,做一次 SHA-1 再 Base64,回写到Sec-WebSocket-Accept。
// key 来自请求头 Sec-WebSocket-Key,guid 是协议固定值 std::string raw = key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"; unsigned char digest[20]; SHA1(reinterpret_cast<const unsigned char*>(raw.data()), raw.size(), digest); std::string accept = base64_encode(digest, 20); // 拼响应,注意结尾必须是两个 \r\n std::string resp = "HTTP/1.1 101 Switching Protocols\r\n" "Upgrade: websocket\r\n" "Connection: Upgrade\r\n" "Sec-WebSocket-Accept: " + accept + "\r\n\r\n"; send(fd, resp.data(), resp.size(), 0);SHA-1 和 Base64 这份资源里通常自带精简实现,别急着换成 OpenSSL,先确认自带的实现输入输出长度对不对。我见过一个版本把 SHA-1 的输出当成十六进制字符串再 Base64,结果 accept 值永远对不上,浏览器直接报握手失败,排查了半天才发现是编码层多转了一次。
2.4 帧解析:掩码、长度字段与粘包
握手完成后,数据以帧为单位传输。一个帧的头部至少 2 字节:第一字节的高 4 位是 opcode(1 文本、2 二进制、8 关闭、9 ping、10 pong),低位是 FIN 和保留位;第二字节最高位是掩码标志 MASK,低 7 位是载荷长度。长度是 126 时要再读 2 字节扩展长度,是 127 时要读 8 字节。客户端发来的帧 MASK 必须为 1,后面跟 4 字节掩码 key,载荷每个字节要和 key 循环异或还原。
// 假设 buf 已累积到足够字节,pos 为当前解析位置 uint8_t b0 = buf[pos]; uint8_t b1 = buf[pos + 1]; bool masked = (b1 & 0x80) != 0; uint64_t len = b1 & 0x7F; size_t offset = pos + 2; if (len == 126) { len = (buf[offset] << 8) | buf[offset + 1]; offset += 2; } else if (len == 127) { /* 读 8 字节大端,注意别用 int 溢出 */ offset += 8; } if (masked) { uint8_t mask[4] = { buf[offset], buf[offset+1], buf[offset+2], buf[offset+3] }; offset += 4; for (uint64_t i = 0; i < len; ++i) payload[i] = buf[offset + i] ^ mask[i % 4]; // 循环异或还原 }这段代码背后有两个血泪经验。第一,TCP 是字节流,一次recv可能只读到半个帧头,也可能读到两个半帧,所以必须有一个 per-connection 的接收缓冲区,攒够一个完整帧再解析,解析完把剩余字节前移,这就是粘包处理。第二,长度字段用uint64_t接,但实际分配内存前要设上限,否则一个恶意帧声明 2GB 长度就能把进程打爆。常见做法是限制单帧最大 1MB 到 16MB,超了直接回 close 帧断开。
3. 编译与跑通:从 CMake 配置到第一个 echo 连接
源码能读和能跑是两回事。这一章按「配环境 → 编译 → 起服务 → 用客户端验证」的顺序走一遍,每一步都给出可抄的命令和参数解释。我默认你在 Linux 下操作,libevent 用系统包管理器装,Windows 下需要自己编 libevent,路径处理会更麻烦,建议先在 Linux 上跑通再考虑跨平台。
3.1 依赖安装与 CMake 关键项
libevent 的开发包必须装,头文件和静态库都要。编译选项里最容易翻车的是 C++ 标准:这份代码如果用了std::string_view或结构化绑定,就得开 C++17,否则一堆编译错误。CMake 里链接 libevent 时,优先用find_package而不是手写-levent,因为不同发行版库名可能是event或event_core。
# Debian/Ubuntu 系安装开发包 sudo apt-get install -y libevent-dev cmake g++ # 编译:显式指定 C++17,开启警告便于发现隐患 mkdir -p build && cd build cmake .. -DCMAKE_CXX_STANDARD=17 -DCMAKE_CXX_FLAGS="-Wall -Wextra" make -j$(nproc)-Wall -Wextra不是摆设,WebSocket 帧解析里大量位运算和类型转换,符号问题、窄化转换都能被警告揪出来。如果make报找不到event2/event.h,说明 libevent 头文件路径没进 include,检查find_package(Libevent REQUIRED)是否成功,失败时手动指定Libevent_INCLUDE_DIR。
3.2 启动参数与端口配置
可执行文件跑起来一般接受端口参数,没给就用默认值。启动后先确认监听成功,用ss -lntp看端口是否处于 LISTEN 状态,这一步能挡掉一半「连不上」的问题。
# 启动服务,指定监听 9001 端口 ./websocket_server 9001 # 另开终端确认端口在监听 ss -lntp | grep 9001如果进程起来了但端口没监听,八成是bind返回失败被忽略了,常见原因是端口被占用或绑定了错误地址。代码里bind之后一定要检查返回值并打印errno,别让失败静默过去。
3.3 用浏览器和脚本双重验证
验证握手最省事的办法是浏览器控制台开一个 WebSocket,看onopen是否触发。但浏览器不方便看原始帧,所以还要配一个脚本客户端,方便构造边界帧。
// 浏览器控制台直接跑,验证握手和 echo const ws = new WebSocket("ws://127.0.0.1:9001"); ws.onopen = () => ws.send("hello"); ws.onmessage = (e) => console.log("recv:", e.data); ws.onerror = (e) => console.error("error", e);onopen不触发时,先看浏览器 Network 面板里那条 101 响应,Sec-WebSocket-Accept对不上就是握手计算错了;101 正常但onmessage收不到,问题就在帧解析或发送路径。脚本客户端建议用 Python 的websocket-client库,能手动发 ping 帧和超长帧,比浏览器可控。
3.4 发送路径:分片与写缓冲
接收解析对了,发送也不能想当然。服务端发帧不需要掩码,但大消息要分片:首帧 opcode 是 1 或 2、FIN 为 0,后续帧 opcode 为 0(continuation)、最后一帧 FIN 为 1。另一个坑是send不一定一次写完,非阻塞 fd 上返回的字节数可能小于请求长度,必须自己维护写缓冲,注册EV_WRITE事件,等可写时继续发。这份资源如果只做了「一次 send 完事」,高并发下就会出现消息截断,表现为客户端偶尔收到半截 JSON,这种问题最难复现,因为本地测试永远不触发。
4. 避坑清单:五个让服务端「看起来正常」却悄悄出错的点
下面这几条都是我在拆这类 C++ WebSocket 源码时真实踩过或见别人踩过的,共同特征是:服务能启动、简单 echo 也通,但一上压力或换个客户端就露馅。每条按现象、原因、解决三段写,照着排查能省不少时间。
现象一:Chrome 能连,某桌面客户端连不上。原因通常是握手响应头大小写或字段顺序被写死,某些客户端对Upgrade的值做严格匹配,服务端返回WebSocket而非websocket就拒绝。解决是把响应头按协议原文拼,值统一小写,别自作聪明加额外头。
现象二:小消息正常,超过 125 字节就断。这是长度字段只处理了 7 位、没处理 126 扩展长度的典型症状。原因在解析时把b1 & 0x7F直接当最终长度,126 和 127 两个特殊值没分支。解决是补上扩展长度读取,并且注意 127 对应 8 字节大端,别用int接。
现象三:并发一上来 CPU 飙高但吞吐不涨。多半是每个连接一个线程,或者事件回调里做了阻塞操作(比如同步写日志、查数据库)。libevent 的模型是单线程事件循环,回调里任何阻塞都会拖住所有连接。解决是把耗时逻辑丢到工作线程池,回调里只做收数据和投递任务。
现象四:客户端断开后服务端 fd 泄漏。现象是跑一段时间后accept返回EMFILE。原因是 close 帧处理或EV_READ回调里没在连接销毁时event_free并close(fd)。解决是给连接上下文加引用计数或统一销毁函数,确保事件、缓冲、fd 三样一起释放。
现象五:ping/pong 不响应,中间层把连接掐了。有些反向代理或负载均衡会定期发 ping,服务端不回 pong 就被判定死连接。原因是 opcode 9 和 10 没处理。解决是在帧解析里对 ping 立即回一个 pong 帧(载荷原样带回),对 pong 忽略即可。
提示:这五条里,第二和第四条最容易在「本地跑通」阶段被掩盖,建议压测时专门构造 200 字节以上的消息和频繁断连的场景。
5. 进阶:把 echo 改成可用的推送服务,以及我怎么验证它
echo 只是验证链路,真正要用得改业务回调。这份资源的价值在于协议层是干净的,你只需要在「收到完整消息」的地方挂自己的分发逻辑。我一般的改法是:解析出一个完整帧后,按 opcode 分流,文本帧交给业务 handler,handler 里根据消息内容决定单播还是广播。广播时遍历连接表逐个入写缓冲,注意不要在遍历过程中删除连接,否则迭代器失效,常见做法是先收集待删列表,遍历完再统一清理。
验证一个 WebSocket 服务端是否真的可靠,我固定跑三步。第一步,用脚本连续发 1000 条 1KB 消息,统计客户端收到的条数和顺序,验证粘包和写缓冲。第二步,模拟 200 个并发连接同时发消息,观察内存曲线是否平稳、fd 数是否回落。第三步,故意在消息中间断开 TCP(不发 close 帧),看服务端是否在超时后回收连接。第三步最能暴露资源泄漏,很多「看起来能用」的实现都栽在这。
| 验证项 | 操作 | 合格标准 |
|---|---|---|
| 粘包 | 连发 1000 条 1KB 消息 | 条数一致、内容不乱序 |
| 并发 | 200 连接同时发 | 内存平稳、无 fd 泄漏 |
| 异常断开 | 直接 kill 客户端进程 | 服务端超时回收连接 |
| 大帧 | 发 1MB 单帧 | 不崩溃、有上限保护 |
还有个小技巧:在帧解析入口加一行长度和 opcode 的日志,只在调试构建里开。线上出问题时,这行日志能直接告诉你收到的是半帧还是完整帧,比抓包快得多。我吃过一次亏,线上偶发消息丢失,抓包看 TCP 层数据都在,最后靠这行日志发现是解析器在缓冲区前移时算错了偏移,把下一帧的头吃掉了。从那以后我每次改帧解析代码,都强制把「单帧、半帧、两帧粘连」三种输入各跑一遍再提交。希望这份拆解能帮你在自己的项目里少走一遍这些弯路,源码包拿去照着改,比从零写协议层省太多时间。
本文还有配套的精品资源,点击获取