简介:这份资源面向具备一定C++基础、希望深入理解网络编程与实时通信的开发者,提供一套基于libevent事件驱动模型实现的WebSocket服务器源码。内容围绕HTTP Upgrade握手展开,涵盖Sec-WebSocket-Key校验与Accept值生成、帧的编解码、掩码处理、读写事件回调注册,以及心跳与超时管理等关键环节,帮助读者打通从协议握手到双向通信的完整链路。压缩包共20个文件,以8个h头文件与7个cpp源文件为主体,辅以Makefile构建脚本、README说明文档及演示页面,整体约16KB,结构紧凑、模块划分清晰,便于按连接、帧处理、工具函数等层次逐一研读。目前已有616人学习下载,适合作为理解WebSocket协议细节与libevent并发I/O实践的参考样例,也可在此基础上扩展多线程、TLS加密等进阶能力。
1. WebSocket 服务器:为什么握手之后还要自己管帧
很多人第一次写 WebSocket 服务端,会以为升级完 HTTP 就万事大吉,结果上线后连接莫名其妙断、大包发不出去、心跳一开就掉线。问题不在协议本身,而在于握手只是入场券,真正的工作量全在握手之后:帧怎么切、掩码怎么校验、分片怎么拼、控制帧怎么插队。用 C++ 和 libevent 自己搭一套,等于把这些细节全部摊在桌面上,没有框架替你兜底。搞懂这套机制,你才能判断一个连接到底是网络断了、协议错了,还是自己的缓冲区写炸了。这份资源给的正是一个不依赖重型框架、把 WebSocket 帧层和事件循环直接摊开讲的服务器实现,适合想看清底层链路的开发者。
2. 握手升级:HTTP 到 WebSocket 的那一次协议切换
2.1 升级请求到底校验了什么
WebSocket 连接的起点是一个普通的 HTTP GET,但它带了三个关键头:Upgrade: websocket、Connection: Upgrade、Sec-WebSocket-Key。服务端要做的不是简单回 101,而是把客户端给的 Key 拼上一个固定 GUID,做一次 SHA-1,再 Base64,回写到Sec-WebSocket-Accept。这个设计的目的很朴素:确认对面真的懂 WebSocket,而不是某个缓存代理把请求转发了过来。
常见的实现思路是,在 libevent 的 HTTP 回调里先判断 method 和路径,再逐个头比对。这里有个容易忽略的点:头字段名大小写不敏感,值里的Upgrade也可能带空格,直接字符串相等比较会翻车。稳妥做法是先 trim 再转小写比较。
// 握手校验的核心逻辑(示意) bool validateHandshake(const HttpRequest& req, std::string& acceptKey) { // 1. 必须是 GET if (req.method != "GET") return false; // 2. Upgrade 头必须存在且为 websocket(大小写不敏感) std::string upgrade = toLower(trim(req.getHeader("Upgrade"))); if (upgrade != "websocket") return false; // 3. Connection 头可能包含多个 token,如 "keep-alive, Upgrade" std::string conn = toLower(req.getHeader("Connection")); if (conn.find("upgrade") == std::string::npos) return false; // 4. 版本必须是 13 if (req.getHeader("Sec-WebSocket-Version") != "13") return false; // 5. 计算 Accept std::string key = req.getHeader("Sec-WebSocket-Key"); if (key.empty()) return false; acceptKey = base64Encode(sha1(key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11")); return true; }这段代码里最值得说的是第 3 步。Connection头允许逗号分隔多个值,浏览器通常发Upgrade,但经过某些中间层后可能变成keep-alive, Upgrade。用find而不是相等比较,就是为了兼容这种拼接。第 5 步的 GUID 是协议写死的,不是随机盐,它的作用只是让 Key 的哈希结果不可预测,防止普通 HTTP 缓存误判。
2.2 101 响应之后,连接就不再是 HTTP 了
回完 101,socket 上跑的就是纯 WebSocket 帧,不能再按 HTTP 报文解析。很多实现在这里出错:libevent 的evhttp还挂着,后续数据被当成 HTTP 请求体处理,直接解析失败。正确做法是在发送 101 响应后,把底层 bufferevent 从 HTTP 层摘出来,交给自定义的帧解析器。
这里涉及 libevent 的一个使用细节:evhttp_connection和bufferevent是两层抽象,握手阶段用 HTTP 层方便,升级后要拿到裸 bufferevent 做字节流读写。常见的实现思路是,在 HTTP 请求回调里调用evhttp_send_reply发 101,然后在回调返回前把连接标记为已升级,后续的 read 回调走自己的帧解析函数,而不是继续喂给 evhttp。
提示:101 响应必须带
Upgrade: websocket和Connection: Upgrade两个头,缺一个浏览器就会判定握手失败,控制台只报一句笼统的握手错误,排查时容易误以为是网络问题。
3. 帧解析:一个字节一个字节地把数据拆出来
3.1 帧头两个字节里藏了多少信息
WebSocket 帧头最短 2 字节,最长 14 字节,信息密度很高。第一个字节的高 4 位是 opcode,低 4 位里最高位是 FIN,表示是不是分片的最后一帧。第二个字节最高位是 MASK,表示载荷是否被掩码,低 7 位是载荷长度。长度字段有三个档位:0 到 125 直接表示,126 表示后面跟 2 字节长度,127 表示后面跟 8 字节长度。
这个变长设计是为了省带宽:小消息只多花 2 字节头,大消息才扩展。但解析时必须严格按档位读,不能一律读 8 字节,否则字节流会错位,后面全乱。我一般会这么理解:帧解析器本质上是一个状态机,先读 2 字节定长度档位,再读扩展长度,再读掩码键,最后读载荷。
// 帧头解析(示意,省略边界检查) struct FrameHeader { bool fin; uint8_t opcode; bool masked; uint64_t payloadLen; uint8_t maskKey[4]; }; bool parseFrameHeader(const uint8_t* buf, size_t len, FrameHeader& h, size_t& headerSize) { if (len < 2) return false; // 不够最小头 h.fin = buf[0] & 0x80; h.opcode = buf[0] & 0x0F; h.masked = buf[1] & 0x80; uint8_t len7 = buf[1] & 0x7F; size_t offset = 2; if (len7 < 126) { h.payloadLen = len7; } else if (len7 == 126) { if (len < offset + 2) return false; h.payloadLen = (buf[offset] << 8) | buf[offset + 1]; offset += 2; } else { if (len < offset + 8) return false; h.payloadLen = 0; for (int i = 0; i < 8; ++i) h.payloadLen = (h.payloadLen << 8) | buf[offset + i]; offset += 8; } if (h.masked) { if (len < offset + 4) return false; memcpy(h.maskKey, buf + offset, 4); offset += 4; } headerSize = offset; return true; }这段代码的关键在len7的分支。126 和 127 不是长度值本身,而是“接下来用几字节表示长度”的标记。127 档位读 8 字节时要注意字节序,协议规定是大端,所以循环里是左移累加。掩码键只有客户端发来的帧才有,服务端发出去的帧不能掩码,这是协议强制规定,违反的话浏览器会直接断开。
3.2 掩码不是加密,但必须逐字节异或
客户端发来的载荷全部用 4 字节掩码键做异或,规则是第 i 个字节异或maskKey[i % 4]。这个机制常被误解为安全措施,其实它的设计动机是防止中间代理缓存污染:让攻击者无法构造出可被缓存层误认为 HTTP 响应的字节序列。对服务端来说,解掩码是纯计算,没有密钥管理问题。
实现上要注意,掩码是作用在完整载荷上的,分片帧的每一片各自带自己的掩码键,不能拿第一片的键去解后面的片。另外,解掩码可以原地做,省一次拷贝,但前提是缓冲区可写。常见的实现思路是在读满一帧后,直接在接收缓冲区上异或,然后把指针往后推。
3.3 分片与控制帧:谁可以插队
WebSocket 允许一个大消息拆成多个帧,第一帧 opcode 是 1(文本)或 2(二进制),后续帧 opcode 是 0(continuation),最后一帧 FIN 置 1。解析器需要维护一个“当前正在拼接的消息”状态,把 continuation 帧的载荷追加进去,直到 FIN 才交付给上层。
控制帧(opcode 8 关闭、9 ping、10 pong)是另一套规则:它们不能分片,FIN 必须为 1,载荷不超过 125 字节,而且可以插在分片消息中间。这意味着解析器不能假设“读到 FIN 才处理控制帧”,每读到一个完整帧就要判断 opcode。ping 必须回 pong,pong 可以忽略,close 要回一个 close 再关连接。这套规则看起来琐碎,但它是心跳和优雅关闭的基础,漏掉任何一个,连接管理就会出玄学问题。
4. 事件循环:libevent 怎么把连接管起来
4.1 bufferevent 的读写水位线
libevent 的bufferevent把 socket 读写抽象成两个缓冲区,读缓冲区有数据就触发 read 回调,写缓冲区低于水位线就触发 write 回调。WebSocket 服务器正好利用这个模型:read 回调里做帧解析,write 回调里继续灌待发数据。
水位线的设置直接影响吞吐和内存。读水位线设太小,回调触发频繁,CPU 空转;设太大,单连接内存占用高。常见的实现思路是按预期消息大小设一个中间值,比如 4KB 到 16KB,然后在回调里循环解析直到缓冲区不够一帧为止。写侧则要关注“背压”:如果对端读得慢,写缓冲区会涨,这时候不能无限追加,要么暂停读,要么丢弃,否则内存会被慢连接拖爆。
// 读回调里的解析循环(示意) void onRead(bufferevent* bev, void* ctx) { evbuffer* input = bufferevent_get_input(bev); while (true) { size_t len = evbuffer_get_length(input); FrameHeader h; size_t headerSize = 0; // 先窥探头部,不消费 unsigned char* head = evbuffer_pullup(input, len); if (!parseFrameHeader(head, len, h, headerSize)) break; // 头不全,等下次 if (len < headerSize + h.payloadLen) break; // 载荷不全,等下次 // 消费整帧 evbuffer_drain(input, headerSize); std::vector<uint8_t> payload(h.payloadLen); evbuffer_remove(input, payload.data(), h.payloadLen); if (h.masked) unmask(payload, h.maskKey); handleFrame(h, payload); // 分片拼接、控制帧处理 } }这段循环的核心是“不够就 break,等下一次回调”。evbuffer_pullup把缓冲区拉成连续内存方便窥探,但它可能触发拷贝,大缓冲区下要慎用。更省的做法是先用evbuffer_copyout拷头部几个字节判断,确认整帧到齐再整体取出。handleFrame里要做分片状态维护,不能每帧独立处理。
4.2 定时器与心跳:连接假死的解药
TCP 连接在没有数据时,中间设备可能悄悄断掉而不通知两端,表现就是连接还在、发消息没反应。WebSocket 的心跳就是解药:服务端定期发 ping,等 pong,超时没回就主动关。libevent 里用event_new加EV_PERSIST做周期定时器,每个连接挂一个,或者全局一个定时器扫所有连接。
全局扫描更省资源,但要注意扫描频率和超时阈值的配合。频率太高浪费 CPU,太低发现慢。常见的实现思路是 30 秒发一次 ping,连续两次没 pong 就判定死亡。这里有个坑:pong 的载荷要和 ping 一致,用来匹配是哪次心跳的回应,否则并发心跳下分不清。
4.3 关闭握手:为什么不能直接 close
WebSocket 的关闭是双向的:一方发 close 帧,另一方回 close 帧,然后才关 TCP。直接closesocket 会导致对端收到 RST,正在传输的数据丢失,日志里就是一堆“连接被重置”。正确流程是收到 close 后,把待发数据发完,回一个 close,再关 bufferevent。
close 帧的载荷可以带状态码,1000 表示正常关闭,1001 表示离开,1011 表示服务端错误。这些码不是装饰,客户端库会据此决定是否重连。实现时要注意,发完 close 后不能立刻释放连接对象,要等写缓冲区排空,否则 close 帧本身可能发不出去。
5. 避坑与排查:那些让连接莫名断掉的细节
5.1 现象:浏览器连上就断,服务端无日志
原因通常是握手响应头不全,或者Sec-WebSocket-Accept算错。浏览器对握手失败不给细节,只在控制台报一句通用错误。排查方法是抓包看 101 响应的头,逐项比对,尤其是 Accept 值,GUID 拼错一个字符就全错。
5.2 现象:小消息正常,大消息收不全
原因是帧解析没有处理 126/127 长度档位,或者读循环在载荷不全时错误地消费了缓冲区。表现是后续帧全部错位,解析出乱码 opcode。解决是严格按“头不全 break、载荷不全 break”的原则,消费动作只在整帧到齐后做。
5.3 现象:心跳开着,连接还是假死
原因是 ping 发出去了但没校验 pong 的载荷,或者超时定时器没重置。另一个常见原因是把 ping 当普通数据帧处理,没走控制帧分支。解决是给每次 ping 带唯一载荷,收到 pong 比对后重置计时,连续超时才关。
5.4 现象:服务端内存持续上涨
原因是慢连接导致写缓冲区堆积,或者分片消息一直不 FIN,拼接缓冲区无限增长。解决是给单连接设写缓冲上限和分片消息总大小上限,超限直接 close 并记日志。这类问题在压测时最容易暴露,平时低流量看不出来。
5.5 现象:关闭时对端报连接重置
原因是收到 close 后直接关 socket,没等写缓冲排空。解决是把关闭拆成两步:先标记 closing,发 close 帧,等写回调确认排空后再真正释放。这一步在 libevent 里可以用bufferevent_flush配合回调做。
6. 设计取舍:为什么不用现成框架而自己管帧
自己实现 WebSocket 帧层的最大代价是要处理所有边界:半包、粘包、分片、控制帧插队、掩码、关闭握手。这些逻辑写错任何一处,表现都是“偶发断连”,排查成本极高。那为什么还要这么做?因为一旦你把这层摊开,连接的生命周期就完全可控:你能精确知道每个字节从哪来、到哪去,能在帧层做限流、做审计、做自定义扩展,而不被框架的黑匣子挡住。
代价和收益的权衡可以用一张表说清:
| 维度 | 自管帧层 | 重型框架 |
|---|---|---|
| 可控性 | 每个字节可干预 | 受限于框架 API |
| 排查成本 | 初期高,后期低 | 初期低,深层问题高 |
| 内存模型 | 自己定水位线 | 框架默认值 |
| 扩展协议 | 可直接改帧处理 | 需看框架是否开放钩子 |
我一般会这么理解:如果你的服务只需要收发文本消息,框架足够;如果要在帧层做背压、做多路复用、做和现有 libevent 事件循环的深度整合,自己管帧反而是长期成本更低的选择。验证方法也简单:写一个压测客户端,故意发半帧、发超大帧、发分片中间插 ping,看服务端是否按预期响应而不是崩溃。这套用例跑通,帧层基本就稳了。
从那以后我每次接 WebSocket 相关的活,都会先把“半包、分片、控制帧插队”这三类用例写成自动化测试跑一遍,再谈业务逻辑。希望帮到你。
本文还有配套的精品资源,点击获取