diff --git a/README.md b/README.md index 73c47f9e025f9d961f915e3e8346e4924f54dc10..c8d98c5d498386240f8993e30758a4007f7d323c 100644 --- a/README.md +++ b/README.md @@ -110,6 +110,7 @@ It contains an event-driven behavior tree that can realize sequential, branching | mqtt | MQTT Client | | coroutine | coroutine function | | http | Implemented HTTP Server and Client, middleware, and SSE (Server-Sent Events) modules on the basis of network | +| websocket | WebSocket Server & Client modules based on HTTP middleware, RFC 6455 & RFC 7692 (permessage-deflate). Features: split Text/Binary callbacks with rvalue refs, fragmented send with configurable chunk size, fragmented receive with buffered decompression | | alarm | Realized 4 commonly used alarm clocks: CRON alarm clock, single alarm clock, weekly cycle alarm clock, weekday alarm clock | | flow | Contains multi-level state machine and behavior tree to solve the problem of action flow in asynchronous mode | | crypto | Implemented the commonly used AES and MD5 encryption and decryption calculations | diff --git a/README_CN.md b/README_CN.md index 9f06b47b42250602cd6841731293b7ad77d12339..0ca5d9ea8dbea0172667c6406c698e69a053b8bf 100644 --- a/README_CN.md +++ b/README_CN.md @@ -112,7 +112,7 @@ trace模块能记录被标记的函数每次执行的时间点与时长,可导 | mqtt | MQTT客户端库 | | | coroutine | 协程库 | 众所周知,异步框架不方便处理顺序性业务,协程弥补之 | | http | HTTP库 | 在network的基础上实现了HTTP的Server与Client、中间件、SSE(服务端推送事件)模块 | -| websocket | WebSocket库 | 在http的基础上实现了WebSocket的Server与Client模块,遵循RFC 6455 | +| websocket | WebSocket库 | 在http的基础上实现了WebSocket的Server与Client模块,遵循RFC 6455与RFC 7692(permessage-deflate压缩)。支持:Text/Binary分类型回调(右值引用)、分片发送(可配置分片大小)、分片接收完整后统一解压再回调 | | alarm | 闹钟库 | 实现了4种常用的闹钟:CRON闹钟、单次闹钟、星期循环闹钟、工作日闹钟 | | flow | 流程库| 含多层级状态机与行为树,解决异步模式下动行流程问题 | | crypto | 加密工具库 | 实现了常用的AES、MD5运算 | diff --git a/documents/modules/websocket.md b/documents/modules/websocket.md index 8989b2edc057aa0f6f2627712be40989d39d9c0c..3fd3abe4a4b26db57282cfb60a672bb7f44b345d 100644 --- a/documents/modules/websocket.md +++ b/documents/modules/websocket.md @@ -18,9 +18,10 @@ On the client side, C++ programs may need to connect to WebSocket servers to rec #include //! WebSocket frame definition #include //! Frame parser (incremental) #include //! Frame builder (server/masked) +#include //! Compression (RFC 7692) #include //! WebSocket server #include //! WebSocket connection (internal) -#include //! WebSocket client +#include //! WebSocket client ``` ## Core Classes and Interfaces @@ -38,8 +39,9 @@ WsServer runs on top of an HTTP server as a middleware. It detects WebSocket upg | `cleanup()` | Cleanup (inverse of initialize) | | `state()` | Get current state (None/Inited/Running) | | `send(client, text)` | Send text frame to a client | +| `send(client, str)` | Send text frame to a client (const char* version, no std::string construction) | | `send(client, data, len)` | Send binary frame to a client (raw pointer version) | -| `sendBinary(client, data)` | Send binary frame to a client (vector version) | +| `send(client, data)` | Send binary frame to a client (vector version) | | `close(client, code, reason)` | Close a client connection (sends Close frame) | | `ping(client, data)` | Send Ping frame to a client | | `pong(client, data)` | Send Pong frame to a client | @@ -50,8 +52,11 @@ WsServer runs on top of an HTTP server as a middleware. It detects WebSocket upg | `getContext(client)` | Get context data for a client connection | | `setConnectedCallback(cb)` | Set callback: new client connected | | `setDisconnectedCallback(cb)` | Set callback: client disconnected | -| `setMessageCallback(cb)` | Set callback: client sent a message | +| `setTextMessageCallback(cb)` | Set callback: received complete text message (rvalue ref, after buffered decompression) | +| `setBinaryMessageCallback(cb)` | Set callback: received complete binary message (rvalue ref, after buffered decompression) | | `setErrorCallback(cb)` | Set callback: client connection error | +| `setCompressionEnable(enable)` | Enable/disable compression support (must call before initialize) | +| `setFragmentSize(size)` | Set max fragment size for sending (default 65535, 0=no fragmentation; must call before initialize) | | `IsWsUpgradeRequest(req)` | Static: check if an HTTP request is a valid WebSocket upgrade | | `ComputeWsAcceptKey(key)` | Static: compute Sec-WebSocket-Accept value | @@ -78,25 +83,29 @@ using ConnToken = cabinet::Token; ConnectedCallback = std::function; DisconnectedCallback = std::function; -MessageCallback = std::function; +TextMessageCallback = std::function; +BinaryMessageCallback = std::function &&)>; ErrorCallback = std::function; ``` -### Client — WebSocket Client +> **Note:** `TextMessageCallback` and `BinaryMessageCallback` use rvalue references for efficiency. Fragmented messages are buffered internally and only delivered to the callback after the complete message is received and decompressed. This means the callback never receives partial fragments — only complete, decompressed messages. -The Client class connects to a WebSocket server via TcpConnector, performs the HTTP Upgrade handshake, and then enters WebSocket frame communication mode. All client-to-server frames are masked per RFC 6455. It supports auto-reconnect with configurable delay strategies. +### WsClient — WebSocket Client + +The WsClient class connects to a WebSocket server via TcpConnector, performs the HTTP Upgrade handshake, and then enters WebSocket frame communication mode. All client-to-server frames are masked per RFC 6455. It supports auto-reconnect with configurable delay strategies. Fragmented messages are buffered and only delivered after complete receipt and decompression. | Method | Description | |------|------| -| `Client(loop)` | Constructor | +| `WsClient(loop)` | Constructor | | `initialize(server_addr, url_path)` | Initialize: set target server address and URL path | | `start()` | Start connecting to server | | `stop()` | Stop/disconnect | | `cleanup()` | Cleanup (inverse of initialize) | | `state()` | Get current state | | `send(text)` | Send text frame | +| `send(str)` | Send text frame (const char* version, no std::string construction) | | `send(data, len)` | Send binary frame (raw pointer version) | -| `sendBinary(data)` | Send binary frame (vector version) | +| `send(data)` | Send binary frame (vector version) | | `close(code, reason)` | Send Close frame and disconnect | | `ping(data)` | Send Ping frame | | `pong(data)` | Send Pong frame | @@ -106,10 +115,13 @@ The Client class connects to a WebSocket server via TcpConnector, performs the H | `getContext()` | Get context data | | `setConnectedCallback(cb)` | Set callback: connected to server | | `setDisconnectedCallback(cb)` | Set callback: disconnected from server | -| `setMessageCallback(cb)` | Set callback: received a message | +| `setTextMessageCallback(cb)` | Set callback: received complete text message (rvalue ref) | +| `setBinaryMessageCallback(cb)` | Set callback: received complete binary message (rvalue ref) | | `setErrorCallback(cb)` | Set callback: connection error | | `setAutoReconnect(enable)` | Enable/disable auto-reconnect (default: enabled) | | `setReconnectDelayCalcFunc(func)` | Set custom reconnect delay calculation | +| `setCompressionPrefer(enable)` | Enable/disable compression preference (must call before initialize) | +| `setFragmentSize(size)` | Set max fragment size for sending (default 65535, 0=no fragmentation; must call before initialize) | **State enum:** @@ -136,6 +148,7 @@ struct WsFrame { OpCode opcode; //! Frame opcode bool fin = true; //! Is this the final frame? + bool rsv1 = false; //! RSV1 bit (true for compressed frame, RFC 7692) std::string payload; //! Payload data bool isControlFrame() const; //! Close/Ping/Pong are control frames @@ -205,8 +218,8 @@ class ChatRoom { ws_srv_.setDisconnectedCallback([this](const WsServer::ConnToken &token) { onDisconnected(token); }); - ws_srv_.setMessageCallback([this](const WsServer::ConnToken &token, const WsFrame &frame) { - onMessage(token, frame); + ws_srv_.setTextMessageCallback([this](const WsServer::ConnToken &token, std::string &&text) { + onTextMessage(token, std::move(text)); }); return true; @@ -217,18 +230,15 @@ class ChatRoom { void cleanup() { ws_srv_.cleanup(); } private: - void onMessage(const WsServer::ConnToken &token, const WsFrame &frame) + void onTextMessage(const WsServer::ConnToken &token, std::string &&text) { - if (frame.opcode != WsFrame::OpCode::kText) - return; - //! First message is the username auto it = conn_to_name_.find(token); if (it == conn_to_name_.end()) { - conn_to_name_[token] = frame.payload; - broadcast(frame.payload + " online"); + conn_to_name_[token] = text; + broadcast(text + " online"); } else { - broadcast(it->second + ": " + frame.payload); + broadcast(it->second + ": " + text); } } @@ -280,7 +290,7 @@ int main() > Full example in `examples/websocket/echo_bin/` -Demonstrates binary WebSocket frame handling. The server echoes binary data back to the client and periodically pushes statistics frames (4-byte header "STAT" + JSON payload) using `sendBinary()` with `vector`. +Demonstrates binary WebSocket frame handling. The server echoes binary data back to the client and periodically pushes statistics frames (4-byte header "STAT" + JSON payload) using `send()` with `vector`. ```cpp class EchoService { @@ -295,8 +305,12 @@ class EchoService { if (!ws_srv_.initialize(http_srv, url_path)) return false; - ws_srv_.setMessageCallback([this](const WsServer::ConnToken &token, const WsFrame &frame) { - onMessage(token, frame); + ws_srv_.setBinaryMessageCallback([this](const WsServer::ConnToken &token, std::vector &&data) { + onBinaryMessage(token, std::move(data)); + }); + ws_srv_.setTextMessageCallback([this](const WsServer::ConnToken &token, std::string &&text) { + //! This service only accepts binary frames + ws_srv_.send(token, "This service only accepts binary frames"); }); //! Timer: push stats every 5 seconds @@ -307,14 +321,10 @@ class EchoService { } private: - void onMessage(const WsServer::ConnToken &token, const WsFrame &frame) + void onBinaryMessage(const WsServer::ConnToken &token, std::vector &&data) { - if (frame.opcode == WsFrame::OpCode::kBinary) { - //! Echo binary data back - ws_srv_.send(token, frame.payload.data(), frame.payload.size()); - } else if (frame.opcode == WsFrame::OpCode::kText) { - ws_srv_.send(token, "This service only accepts binary frames"); - } + //! Echo binary data back + ws_srv_.send(token, data); } void onStatTimer() @@ -325,7 +335,7 @@ class EchoService { stat_data.insert(stat_data.end(), json.begin(), json.end()); for (const auto &token : conns_) - ws_srv_.sendBinary(token, stat_data); + ws_srv_.send(token, stat_data); } }; ``` @@ -337,13 +347,13 @@ class EchoService { Demonstrates a WebSocket client connecting to a chat server, reading from stdin, and sending/receiving messages. ```cpp -#include +#include int main() { auto sp_loop = Loop::New(); - Client ws_client(sp_loop); + WsClient ws_client(sp_loop); ws_client.initialize(SockAddr::FromString("127.0.0.1:8080"), "/ws/chat-1"); ws_client.setConnectedCallback([&] { @@ -352,10 +362,8 @@ int main() sp_stdin_event->enable(); }); - ws_client.setMessageCallback([&](const WsFrame &frame) { - if (frame.opcode == WsFrame::OpCode::kText) { - std::cout << frame.payload << std::endl; - } + ws_client.setTextMessageCallback([&](std::string &&text) { + std::cout << text << std::endl; }); //! Custom reconnect delay: exponential backoff @@ -393,7 +401,7 @@ ws_srv.setConnectedCallback([](const WsServer::ConnToken &token) { ws_srv.setContext(token, session, [](void *p) { delete static_cast(p); }); }); -ws_srv.setMessageCallback([](const WsServer::ConnToken &token, const WsFrame &frame) { +ws_srv.setTextMessageCallback([](const WsServer::ConnToken &token, std::string &&text) { //! Retrieve the session auto session = static_cast(ws_srv.getContext(token)); if (session != nullptr) { @@ -414,6 +422,68 @@ ws_client.setReconnectDelayCalcFunc([](int fail_count) { ws_client.setAutoReconnect(false); ``` +## Compression (RFC 7692 permessage-deflate) + +The websocket module supports the `permessage-deflate` compression extension defined in RFC 7692. When enabled, WebSocket text and binary frames are compressed using DEFLATE (zlib), significantly reducing bandwidth for repetitive or large messages. + +### How it works + +1. **Server side**: Call `setCompressionEnable(true)` before `initialize()`. If a client requests `permessage-deflate` in its handshake (`Sec-WebSocket-Extensions: permessage-deflate`), the server agrees by responding with the same header. Otherwise, compression is not used. + +2. **Client side**: Call `setCompressionPrefer(true)` before `initialize()`. The client requests `permessage-deflate` in its handshake. If the server agrees, frames are compressed/decompressed; if the server declines, communication proceeds without compression. + +3. **Frame format**: Compressed data frames set the RSV1 bit in the first frame header. Control frames (Close/Ping/Pong) are never compressed. + +4. **Implementation**: Uses raw DEFLATE with 4-byte tail stripping (RFC 7692 Section 7.2.2). Each message is independently compressed (no_context_takeover mode), simplifying implementation and ensuring compatibility. + +### WsCompressionConfig — Compression Configuration + +```cpp +#include + +struct WsCompressionConfig { + bool enabled = false; //! Whether compression is enabled + bool no_context_takeover = true; //! Don't retain zlib context across messages + int max_window_bits = 15; //! Maximum window bits (8~15) +}; +``` + +### Server: Enable Compression + +```cpp +WsServer ws_srv(sp_loop); +ws_srv.setCompressionEnable(true); //! Allow compression (before initialize) +ws_srv.initialize(&http_srv, "/ws/chat"); +``` + +### Client: Prefer Compression + +```cpp +WsClient ws_client(sp_loop); +ws_client.setCompressionPrefer(true); //! Request compression (before initialize) +ws_client.initialize(SockAddr::FromString("127.0.0.1:8080"), "/ws/chat"); +``` + +### Mixed Server (Some Routes Compressed, Some Not) + +```cpp +//! Chat room with compression +WsServer ws_srv_compressed(sp_loop); +ws_srv_compressed.setCompressionEnable(true); +ws_srv_compressed.initialize(&http_srv, "/ws/chat"); + +//! Echo service without compression +WsServer ws_srv_plain(sp_loop); +ws_srv_plain.initialize(&http_srv, "/ws/echo"); +``` + +### Important: Compression Negotiation + +- Compression is **optional** and negotiated per connection during the HTTP Upgrade handshake. +- If either side does not support or declines compression, frames are sent uncompressed — no impact on functionality. +- The `rsv1` field on `WsFrame` indicates whether a received frame was compressed. After decompression, `rsv1` is cleared, so user callbacks receive the original payload data transparently. +- Compression fails gracefully: if compression or decompression fails, the system falls back to uncompressed mode or reports an error. + ## Common Scenarios 1. **Real-time push**: Mount WsServer on HTTP server, push live data to browser clients @@ -423,6 +493,7 @@ ws_client.setAutoReconnect(false); 5. **Server-to-client heartbeat**: Server sends Ping frames, client auto-replies Pong 6. **Client auto-reconnect**: Client reconnects with exponential backoff after disconnection 7. **Mixed HTTP + WebSocket**: HTTP serves REST APIs and static pages; WebSocket handles real-time communication +8. **Compressed communication**: Enable permessage-deflate to reduce bandwidth for text/binary data ## Important Notes @@ -437,6 +508,11 @@ ws_client.setAutoReconnect(false); 9. **Lifecycle order**: Must follow initialize → start → stop → cleanup for both WsServer and Client. 10. **Thread safety**: All callbacks run in the Loop thread. Cross-thread operations must use `runInLoop()`. 11. **Context data**: `setContext()/getContext()` on WsServer delegates to the underlying TcpConnection. Context data is accessible in callbacks but becomes `nullptr` after the connection is destroyed. +12. **Compression**: Call `setCompressionEnable(true)` on WsServer or `setCompressionPrefer(true)` on WsClient **before** `initialize()`. Compression is negotiated per connection; if the other side doesn't support it, frames are sent uncompressed without impact. +13. **Compression fallback**: If compression/decompression fails, the system logs a warning and falls back to sending the frame uncompressed. Decompression failure causes an error callback. +14. **Fragmented receive**: Both WsServer and WsClient buffer fragmented messages internally. Only after the complete message is received (all fragments, fin=true) does it decompress (if needed) and deliver the message to the callback. The callback never receives partial fragments. +15. **Fragmented send**: When sending data larger than `fragment_size`, it is automatically split into WebSocket fragments. The first fragment carries the original opcode and rsv1 (if compressed); continuation fragments use opcode kContinue. Call `setFragmentSize(size)` before `initialize()` to configure the fragment size (default 65535, set to 0 to disable fragmentation). +16. **send(const char\*)**: Both WsServer and WsClient provide a `send(const char *str)` overload that sends text without constructing a temporary `std::string`. It correctly uses kText opcode. ## Related Modules diff --git a/documents/modules/websocket_CN.md b/documents/modules/websocket_CN.md index e30f7c8ccd0c58e5ca38a4426434629a100d173d..0f04b39729f7215244fc7718a6d6585aebe504c6 100644 --- a/documents/modules/websocket_CN.md +++ b/documents/modules/websocket_CN.md @@ -18,9 +18,10 @@ websocket 模块提供了 WebSocket 服务器与客户端实现,遵循 RFC 645 #include //! WebSocket 帧定义 #include //! 帧解析器(增量式) #include //! 帧构建器(服务端/掩码) +#include //! 压缩(RFC 7692) #include //! WebSocket 服务端 #include //! WebSocket 连接(内部类) -#include //! WebSocket 客户端 +#include //! WebSocket 客户端 ``` ## 核心类与接口 @@ -38,8 +39,9 @@ WsServer 运行在 HTTP 服务器之上,作为中间件存在。它检测 WebS | `cleanup()` | 清理(与 initialize 逆操作) | | `state()` | 获取当前状态 (None/Inited/Running) | | `send(client, text)` | 向指定客户端发送文本帧 | +| `send(client, str)` | 向指定客户端发送文本帧(const char* 版本,不构造 std::string) | | `send(client, data, len)` | 向指定客户端发送二进制帧(原始指针版本) | -| `sendBinary(client, data)` | 向指定客户端发送二进制帧(vector 版本) | +| `send(client, data)` | 向指定客户端发送二进制帧(vector 版本) | | `close(client, code, reason)` | 关闭指定客户端连接(发送 Close 帧) | | `ping(client, data)` | 向指定客户端发送 Ping 帧 | | `pong(client, data)` | 向指定客户端发送 Pong 帧 | @@ -50,8 +52,11 @@ WsServer 运行在 HTTP 服务器之上,作为中间件存在。它检测 WebS | `getContext(client)` | 获取客户端连接的上下文数据 | | `setConnectedCallback(cb)` | 设置回调:新客户端连接 | | `setDisconnectedCallback(cb)` | 设置回调:客户端断开 | -| `setMessageCallback(cb)` | 设置回调:客户端发送消息 | +| `setTextMessageCallback(cb)` | 设置回调:收到完整文本消息(右值引用,分片数据缓存后统一解压再回调) | +| `setBinaryMessageCallback(cb)` | 设置回调:收到完整二进制消息(右值引用,分片数据缓存后统一解压再回调) | | `setErrorCallback(cb)` | 设置回调:客户端连接出错 | +| `setCompressionEnable(enable)` | 启用/禁用压缩支持(必须在 initialize 之前调用) | +| `setFragmentSize(size)` | 设置发送分片大小(默认65535,0=不分片;必须在 initialize 之前调用) | | `IsWsUpgradeRequest(req)` | 静态方法:检查 HTTP 请求是否为有效的 WebSocket 升级请求 | | `ComputeWsAcceptKey(key)` | 静态方法:计算 Sec-WebSocket-Accept 响应值 | @@ -76,27 +81,31 @@ WsServer 运行在 HTTP 服务器之上,作为中间件存在。它检测 WebS ```cpp using ConnToken = cabinet::Token; -ConnectedCallback = std::function; -DisconnectedCallback = std::function; -MessageCallback = std::function; -ErrorCallback = std::function; +ConnectedCallback = std::function; +DisconnectedCallback = std::function; +TextMessageCallback = std::function; +BinaryMessageCallback = std::function &&)>; +ErrorCallback = std::function; ``` -### Client — WebSocket 客户端 +> **注意:** `TextMessageCallback` 和 `BinaryMessageCallback` 使用右值引用提升效率。分片消息在内部缓存,只有接收完整(fin=true)并解压后才回调业务层,回调中永远不会收到部分分片数据。 -Client 类通过 TcpConnector 建立 TCP 连接,发送 HTTP Upgrade 握手请求,验证 101 响应后进入 WebSocket 帧通信模式。所有客户端帧必须掩码(RFC 6455)。支持自动重连与可配置的重连延迟策略。 +### WsClient — WebSocket 客户端 + +WsClient 类通过 TcpConnector 建立 TCP 连接,发送 HTTP Upgrade 握手请求,验证 101 响应后进入 WebSocket 帧通信模式。所有客户端帧必须掩码(RFC 6455)。支持自动重连与可配置的重连延迟策略。分片消息在内部缓存,接收完整后再解压回调。 | 方法 | 说明 | |------|------| -| `Client(loop)` | 构造 | +| `WsClient(loop)` | 构造 | | `initialize(server_addr, url_path)` | 初始化:设置目标服务器地址与 URL 路径 | | `start()` | 开始连接服务器 | | `stop()` | 停止/断开连接 | | `cleanup()` | 清理(与 initialize 逆操作) | | `state()` | 获取当前状态 | | `send(text)` | 发送文本帧 | +| `send(str)` | 发送文本帧(const char* 版本,不构造 std::string) | | `send(data, len)` | 发送二进制帧(原始指针版本) | -| `sendBinary(data)` | 发送二进制帧(vector 版本) | +| `send(data)` | 发送二进制帧(vector 版本) | | `close(code, reason)` | 发送 Close 帧并断开连接 | | `ping(data)` | 发送 Ping 帧 | | `pong(data)` | 发送 Pong 帧 | @@ -106,10 +115,13 @@ Client 类通过 TcpConnector 建立 TCP 连接,发送 HTTP Upgrade 握手请 | `getContext()` | 获取上下文数据 | | `setConnectedCallback(cb)` | 设置回调:连接成功 | | `setDisconnectedCallback(cb)` | 设置回调:连接断开 | -| `setMessageCallback(cb)` | 设置回调:收到消息 | +| `setTextMessageCallback(cb)` | 设置回调:收到完整文本消息(右值引用) | +| `setBinaryMessageCallback(cb)` | 设置回调:收到完整二进制消息(右值引用) | | `setErrorCallback(cb)` | 设置回调:连接出错 | | `setAutoReconnect(enable)` | 启用/禁用自动重连(默认启用) | | `setReconnectDelayCalcFunc(func)` | 设置自定义重连延迟计算函数 | +| `setCompressionPrefer(enable)` | 启用/禁用压缩偏好(必须在 initialize 之前调用) | +| `setFragmentSize(size)` | 设置发送分片大小(默认65535,0=不分片;必须在 initialize 之前调用) | **State 状态枚举:** @@ -136,6 +148,7 @@ struct WsFrame { OpCode opcode; //! 帧操作码 bool fin = true; //! 是否为最后一帧 + bool rsv1 = false; //! RSV1 位(压缩帧首帧为 true,RFC 7692) std::string payload; //! 负载数据 bool isControlFrame() const; //! Close/Ping/Pong 为控制帧 @@ -205,8 +218,8 @@ class ChatRoom { ws_srv_.setDisconnectedCallback([this](const WsServer::ConnToken &token) { onDisconnected(token); }); - ws_srv_.setMessageCallback([this](const WsServer::ConnToken &token, const WsFrame &frame) { - onMessage(token, frame); + ws_srv_.setTextMessageCallback([this](const WsServer::ConnToken &token, std::string &&text) { + onTextMessage(token, std::move(text)); }); return true; @@ -217,18 +230,15 @@ class ChatRoom { void cleanup() { ws_srv_.cleanup(); } private: - void onMessage(const WsServer::ConnToken &token, const WsFrame &frame) + void onTextMessage(const WsServer::ConnToken &token, std::string &&text) { - if (frame.opcode != WsFrame::OpCode::kText) - return; - //! 第一条消息作为用户名 auto it = conn_to_name_.find(token); if (it == conn_to_name_.end()) { - conn_to_name_[token] = frame.payload; - broadcast(frame.payload + " 上线"); + conn_to_name_[token] = text; + broadcast(text + " 上线"); } else { - broadcast(it->second + ": " + frame.payload); + broadcast(it->second + ": " + text); } } @@ -280,7 +290,7 @@ int main() > 完整示例见 `examples/websocket/echo_bin/` -演示二进制 WebSocket 帧的处理。服务器将收到的二进制数据原样回传(echo),并每 5 秒通过 `sendBinary()` 的 `vector` 版本向所有客户端推送统计帧(4字节头"STAT" + JSON字符串)。 +演示二进制 WebSocket 帧的处理。服务器将收到的二进制数据原样回传(echo),并每 5 秒通过 `send()` 的 `vector` 版本向所有客户端推送统计帧(4字节头"STAT" + JSON字符串)。 ```cpp class EchoService { @@ -295,8 +305,12 @@ class EchoService { if (!ws_srv_.initialize(http_srv, url_path)) return false; - ws_srv_.setMessageCallback([this](const WsServer::ConnToken &token, const WsFrame &frame) { - onMessage(token, frame); + ws_srv_.setBinaryMessageCallback([this](const WsServer::ConnToken &token, std::vector &&data) { + onBinaryMessage(token, std::move(data)); + }); + ws_srv_.setTextMessageCallback([this](const WsServer::ConnToken &token, std::string &&text) { + //! 此服务仅接收二进制帧 + ws_srv_.send(token, "此服务仅接收二进制帧"); }); //! 定时器:每 5 秒推送统计帧 @@ -307,14 +321,10 @@ class EchoService { } private: - void onMessage(const WsServer::ConnToken &token, const WsFrame &frame) + void onBinaryMessage(const WsServer::ConnToken &token, std::vector &&data) { - if (frame.opcode == WsFrame::OpCode::kBinary) { - //! 二进制帧:原样回传 - ws_srv_.send(token, frame.payload.data(), frame.payload.size()); - } else if (frame.opcode == WsFrame::OpCode::kText) { - ws_srv_.send(token, "此服务仅接收二进制帧"); - } + //! 二进制帧:原样回传 + ws_srv_.send(token, data); } void onStatTimer() @@ -325,7 +335,7 @@ class EchoService { stat_data.insert(stat_data.end(), json.begin(), json.end()); for (const auto &token : conns_) - ws_srv_.sendBinary(token, stat_data); + ws_srv_.send(token, stat_data); } }; ``` @@ -337,13 +347,13 @@ class EchoService { 演示 WebSocket 客户端连接到聊天服务器,从标准输入读取文本发送,并接收服务器推送的消息。 ```cpp -#include +#include int main() { auto sp_loop = Loop::New(); - Client ws_client(sp_loop); + WsClient ws_client(sp_loop); ws_client.initialize(SockAddr::FromString("127.0.0.1:8080"), "/ws/chat-1"); ws_client.setConnectedCallback([&] { @@ -352,10 +362,8 @@ int main() sp_stdin_event->enable(); }); - ws_client.setMessageCallback([&](const WsFrame &frame) { - if (frame.opcode == WsFrame::OpCode::kText) { - std::cout << frame.payload << std::endl; - } + ws_client.setTextMessageCallback([&](std::string &&text) { + std::cout << text << std::endl; }); //! 设置二次退避重连策略 @@ -393,7 +401,7 @@ ws_srv.setConnectedCallback([](const WsServer::ConnToken &token) { ws_srv.setContext(token, session, [](void *p) { delete static_cast(p); }); }); -ws_srv.setMessageCallback([](const WsServer::ConnToken &token, const WsFrame &frame) { +ws_srv.setTextMessageCallback([](const WsServer::ConnToken &token, std::string &&text) { //! 获取会话数据 auto session = static_cast(ws_srv.getContext(token)); if (session != nullptr) { @@ -414,6 +422,68 @@ ws_client.setReconnectDelayCalcFunc([](int fail_count) { ws_client.setAutoReconnect(false); ``` +## 压缩(RFC 7692 permessage-deflate) + +websocket 模块支持 RFC 7692 定义的 `permessage-deflate` 压缩扩展。启用后,WebSocket 文本帧和二进制帧使用 DEFLATE (zlib) 压缩,显著减少带宽占用,尤其适用于重复性或大数据量的消息。 + +### 工作原理 + +1. **服务端**:在 `initialize()` 之前调用 `setCompressionEnable(true)`。若客户端在握手中请求了 `permessage-deflate`(通过 `Sec-WebSocket-Extensions: permessage-deflate` 头部),服务端在 101 响应中同意压缩。否则不使用压缩。 + +2. **客户端**:在 `initialize()` 之前调用 `setCompressionPrefer(true)`。客户端在握手中请求压缩扩展。若服务端同意,帧将压缩/解压;若服务端拒绝,通信继续不压缩。 + +3. **帧格式**:压缩数据帧的首帧设置 RSV1 位。控制帧(Close/Ping/Pong)永远不压缩。 + +4. **实现方式**:使用 raw DEFLATE,按 RFC 7692 Section 7.2.2 规则去除 4 字节尾部。每条消息独立压缩(no_context_takeover 模式),简化实现并确保兼容性。 + +### WsCompressionConfig — 压缩配置 + +```cpp +#include + +struct WsCompressionConfig { + bool enabled = false; //!< 是否启用压缩 + bool no_context_takeover = true; //!< 不跨消息保留 zlib 上下文 + int max_window_bits = 15; //!< 最大窗口位数 (8~15) +}; +``` + +### 服务端:启用压缩 + +```cpp +WsServer ws_srv(sp_loop); +ws_srv.setCompressionEnable(true); //! 允许压缩(在 initialize 之前调用) +ws_srv.initialize(&http_srv, "/ws/chat"); +``` + +### 客户端:偏好压缩 + +```cpp +WsClient ws_client(sp_loop); +ws_client.setCompressionPrefer(true); //! 请求压缩(在 initialize 之前调用) +ws_client.initialize(SockAddr::FromString("127.0.0.1:8080"), "/ws/chat"); +``` + +### 混合服务(部分路由压缩,部分不压缩) + +```cpp +//! 聊天室启用压缩 +WsServer ws_srv_compressed(sp_loop); +ws_srv_compressed.setCompressionEnable(true); +ws_srv_compressed.initialize(&http_srv, "/ws/chat"); + +//! Echo 服务不压缩 +WsServer ws_srv_plain(sp_loop); +ws_srv_plain.initialize(&http_srv, "/ws/echo"); +``` + +### 重要:压缩协商 + +- 压缩是**可选的**,在 HTTP Upgrade 握手阶段按连接协商。 +- 若任一方不支持或拒绝压缩,帧将不压缩发送——对功能无任何影响。 +- `WsFrame` 的 `rsv1` 字段标识接收到的帧是否被压缩。解压后 `rsv1` 被清除,用户回调中收到的 payload 是原始数据,透明无感。 +- 压缩失败时优雅回退:若压缩或解压失败,系统回退到不压缩模式或报告错误。 + ## 常见场景 1. **实时推送**:将 WsServer 挂载到 HTTP 服务器上,向浏览器客户端推送实时数据 @@ -423,6 +493,7 @@ ws_client.setAutoReconnect(false); 5. **服务端心跳**:服务端发送 Ping 帧,客户端自动回复 Pong 6. **客户端自动重连**:断线后按指数退避策略自动重连 7. **HTTP + WebSocket 混合**:HTTP 提供 REST API 和静态页面;WebSocket 处理实时通信 +8. **压缩通信**:启用 permessage-deflate 减少文本/二进制数据的带宽占用 ## 注意事项 @@ -437,6 +508,11 @@ ws_client.setAutoReconnect(false); 9. **生命周期顺序**:WsServer 和 Client 均须遵循 initialize → start → stop → cleanup 顺序。 10. **线程安全**:所有回调在 Loop 线程中执行,跨线程操作须通过 `runInLoop()` 回到主线程。 11. **上下文数据**:WsServer 的 `setContext()/getContext()` 委托给底层 TcpConnection。在回调中可访问上下文数据,但连接断开后 `getContext()` 返回 `nullptr`。 +12. **压缩**:在 WsServer 上调用 `setCompressionEnable(true)` 或在 WsClient 上调用 `setCompressionPrefer(true)` **必须在 `initialize()` 之前**。压缩按连接协商;若对方不支持,帧将不压缩发送,不影响功能。 +13. **压缩回退**:若压缩/解压失败,系统打印警告并回退到不压缩发送。解压失败会触发错误回调。 +14. **分片接收**:WsServer 和 WsClient 在内部缓存分片数据。只有接收完整消息(所有分片、fin=true)并解压后才回调业务层,回调中永远不会收到部分分片数据。 +15. **分片发送**:当发送数据大于 `fragment_size` 时,自动分片发送。首帧携带原始 opcode 与 rsv1(如压缩),后续帧为 kContinue。调用 `setFragmentSize(size)` 配置分片大小(默认65535,设为0禁用分片),必须在 `initialize()` 之前。 +16. **send(const char\*)**:WsServer 和 WsClient 都提供 `send(const char *str)` 重载,发送文本帧时不构造 std::string 中间对象,正确使用 kText opcode。 ## 相关模块 diff --git a/examples/websocket/chat_client/Makefile b/examples/websocket/chat_client/Makefile index 207f57706c802abcdd96b747da783da74a2849d1..ab9013cac30f55b72b0093c04ad54056efade29d 100644 --- a/examples/websocket/chat_client/Makefile +++ b/examples/websocket/chat_client/Makefile @@ -34,6 +34,6 @@ LDFLAGS += \ -ltbox_log \ -ltbox_util \ -ltbox_base \ - -lpthread -ldl + -lpthread -lz -ldl include $(TOP_DIR)/mk/exe_common.mk diff --git a/examples/websocket/chat_client/chat_client.cpp b/examples/websocket/chat_client/chat_client.cpp index 802cbe048baf01f59e483505ed81d5dcf631664e..1e66cf41eaffbe610aa0957e793c00b10220c82a 100644 --- a/examples/websocket/chat_client/chat_client.cpp +++ b/examples/websocket/chat_client/chat_client.cpp @@ -84,6 +84,12 @@ int main(int argc, char **argv) return 0; } + //! 启用压缩(RFC 7692 permessage-deflate) + ws_client.setCompressionPrefer(true); + ws_client.setFragmentSize(65535); + ws_client.setPingInterval(10); + ws_client.setPingTimeout(2); + //! 设置回调 ws_client.setConnectedCallback([&] { LogInfo("connected to %s%s", server_addr.c_str(), url_path.c_str()); @@ -112,10 +118,12 @@ int main(int argc, char **argv) std::cout << "== 已断开连接 ==" << std::endl; }); - ws_client.setMessageCallback([&](const WsFrame &frame) { - if (frame.opcode == WsFrame::OpCode::kText) { - std::cout << frame.payload << std::endl; - } + ws_client.setTextMessageCallback([&](std::string &&text) { + std::cout << text << std::endl; + }); + + ws_client.setBinaryMessageCallback([&](std::vector &&data) { + //! 此示例不处理二进制帧 }); ws_client.setErrorCallback([&] { diff --git a/examples/websocket/chat_server/Makefile b/examples/websocket/chat_server/Makefile index d5af6e0f687b2e8225757f463154566d0f338561..396c14790ca4b6722a206e74db95f3188b68aa40 100644 --- a/examples/websocket/chat_server/Makefile +++ b/examples/websocket/chat_server/Makefile @@ -34,6 +34,6 @@ LDFLAGS += \ -ltbox_log \ -ltbox_util \ -ltbox_base \ - -lpthread -ldl + -lpthread -lz -ldl include $(TOP_DIR)/mk/exe_common.mk diff --git a/examples/websocket/chat_server/chat_server.cpp b/examples/websocket/chat_server/chat_server.cpp index 8999765e187f26008420d2b1bf66b0f54104c211..1eb03693cd2c76bab10a6d9b25dcff1d0d9dec67 100644 --- a/examples/websocket/chat_server/chat_server.cpp +++ b/examples/websocket/chat_server/chat_server.cpp @@ -52,15 +52,14 @@ class ChatRoom { if (!ws_srv_.initialize(http_srv, url_path)) return false; - ws_srv_.setConnectedCallback([this](const WsServer::ConnToken &token) { - onConnected(token); - }); - ws_srv_.setDisconnectedCallback([this](const WsServer::ConnToken &token) { - onDisconnected(token); - }); - ws_srv_.setMessageCallback([this](const WsServer::ConnToken &token, const WsFrame &frame) { - onMessage(token, frame); - }); + using namespace std::placeholders; + ws_srv_.setConnectedCallback(std::bind(&ChatRoom::onConnected, this, _1)); + ws_srv_.setDisconnectedCallback(std::bind(&ChatRoom::onDisconnected, this, _1)); + ws_srv_.setTextMessageCallback(std::bind(&ChatRoom::onTextMessage, this, _1, _2)); + ws_srv_.setCompressionEnable(true); + ws_srv_.setFragmentSize(256); + ws_srv_.setPingInterval(10); + ws_srv_.setPingTimeout(2); LogInfo("chat room '%s' mounted at %s", name_.c_str(), url_path.c_str()); return true; @@ -101,19 +100,17 @@ class ChatRoom { } //! 收到消息:第一条为用户名(登录),后续为聊天消息 - void onMessage(const WsServer::ConnToken &token, const WsFrame &frame) + void onTextMessage(const WsServer::ConnToken &token, std::string &&text) { - if (frame.opcode != WsFrame::OpCode::kText) - return; - auto it = conn_to_name_.find(token); if (it == conn_to_name_.end()) { //! 第一条消息作为用户名 - conn_to_name_[token] = frame.payload; - LogInfo("[%s] user '%s' online", name_.c_str(), frame.payload.c_str()); - broadcast(frame.payload + " 上线"); + conn_to_name_[token] = text; + LogInfo("[%s] user '%s' online", name_.c_str(), text.c_str()); + broadcast(text + " 上线"); } else { - broadcast(it->second + ": " + frame.payload); + LogInfo("[%s] user: %s", it->second.c_str(), text.c_str()); + broadcast(it->second + ": " + text); } } diff --git a/examples/websocket/echo_bin/Makefile b/examples/websocket/echo_bin/Makefile index 408dd5d7c12fda7d519331e2f89661f9772751f4..9fa1c2f391adf59047e6119645851262dd793b4c 100644 --- a/examples/websocket/echo_bin/Makefile +++ b/examples/websocket/echo_bin/Makefile @@ -34,6 +34,6 @@ LDFLAGS += \ -ltbox_log \ -ltbox_util \ -ltbox_base \ - -lpthread -ldl + -lpthread -lz -ldl include $(TOP_DIR)/mk/exe_common.mk diff --git a/examples/websocket/echo_bin/echo_bin.cpp b/examples/websocket/echo_bin/echo_bin.cpp index 5b084f58c5279e02626d067c28dfc8148d7dafb7..a3628d7188f22dd112d757eb410bcc7e964d65af 100644 --- a/examples/websocket/echo_bin/echo_bin.cpp +++ b/examples/websocket/echo_bin/echo_bin.cpp @@ -28,10 +28,12 @@ * * 演示要点: * - WsServer::send() 的 void* + len 版本:发送原始二进制 - * - WsServer::sendBinary() 的 vector 版本:发送 vector 二进制 + * - WsServer::send() 的 vector 版本:发送 vector 二进制 + * - WsServer::send() 的 const char* 版本:发送文本字符串 * - WsFrame::OpCode::kBinary:区分文本帧与二进制帧 * - event::TimerEvent:定时推送统计数据 * - WsServer 的 start()/stop() 生命周期 + * - WsServer::setFragmentSize():可配置分片大小 */ #include @@ -41,6 +43,7 @@ #include #include #include +#include #include #include @@ -81,15 +84,15 @@ class EchoService { return false; //! 设置回调 - ws_srv_.setConnectedCallback([this](const WsServer::ConnToken &token) { - onConnected(token); - }); - ws_srv_.setDisconnectedCallback([this](const WsServer::ConnToken &token) { - onDisconnected(token); - }); - ws_srv_.setMessageCallback([this](const WsServer::ConnToken &token, const WsFrame &frame) { - onMessage(token, frame); - }); + using namespace std::placeholders; + ws_srv_.setConnectedCallback(std::bind(&EchoService::onConnected, this, _1)); + ws_srv_.setDisconnectedCallback(std::bind(&EchoService::onDisconnected, this, _1)); + ws_srv_.setTextMessageCallback(std::bind(&EchoService::onTextMessage, this, _1, _2)); + ws_srv_.setBinaryMessageCallback(std::bind(&EchoService::onBinaryMessage, this, _1, _2)); + ws_srv_.setCompressionEnable(true); + ws_srv_.setFragmentSize(256); + ws_srv_.setPingInterval(10); + ws_srv_.setPingTimeout(2); //! 初始化定时器:每 5 秒推送统计帧 stat_timer_->initialize(std::chrono::milliseconds(5000), Event::Mode::kPersist); @@ -136,26 +139,26 @@ class EchoService { } //! 收到消息:区分文本帧与二进制帧 - void onMessage(const WsServer::ConnToken &token, const WsFrame &frame) + void onBinaryMessage(const WsServer::ConnToken &token, std::vector &&data) { - if (frame.opcode == WsFrame::OpCode::kBinary) { - //! 二进制帧:echo 回传原数据 - //! 演示 WsServer::send() 的 void* + len 版本 - ws_srv_.send(token, frame.payload.data(), frame.payload.size()); - - //! 更新统计 - recv_frames_++; - recv_bytes_ += frame.payload.size(); - sent_frames_++; - sent_bytes_ += frame.payload.size(); - - } else if (frame.opcode == WsFrame::OpCode::kText) { - //! 文本帧:回复提示,仅接收二进制数据 - ws_srv_.send(token, "此服务仅接收二进制帧,请发送 ArrayBuffer"); - - } else { - //! 其他帧(Ping/Pong/Close 等):忽略 - } + auto hex_str = util::string::RawDataToHexStr(data.data(), data.size()); + LogTrace("hex: %s", hex_str.c_str()); + + //! 二进制帧:echo 回传原数据 + //! 演示 WsServer::send() 的 void* + len 版本 + //! 更新统计 + recv_frames_++; + recv_bytes_ += data.size(); + sent_frames_++; + sent_bytes_ += data.size(); + ws_srv_.send(token, data); + } + + void onTextMessage(const WsServer::ConnToken &token, std::string &&text) + { + LogTrace("text: %s", text.c_str()); + + ws_srv_.send(token, "此服务仅接收二进制帧,请发送 ArrayBuffer"); } //! 定时器回调:构建统计帧,推送给所有客户端 @@ -170,7 +173,7 @@ class EchoService { "\"clients\":" + std::to_string(conns_.size()) + "}"; - //! 演示 WsServer::sendBinary() 的 vector 版本 + //! 演示 WsServer::send() 的 vector 版本 //! 格式:4字节头 "STAT" + JSON 字符串字节 std::vector stat_data; stat_data.reserve(4 + json.size()); @@ -179,7 +182,7 @@ class EchoService { //! 向所有客户端推送统计帧 for (const auto &token : conns_) - ws_srv_.sendBinary(token, stat_data); + ws_srv_.send(token, stat_data); } private: diff --git a/examples/websocket/echo_bin/html_text.cpp b/examples/websocket/echo_bin/html_text.cpp index b101ab3408b968ef87336839af24aba72109ec01..0704f26c18bac29145ccb2495caeb06e023bab8d 100644 --- a/examples/websocket/echo_bin/html_text.cpp +++ b/examples/websocket/echo_bin/html_text.cpp @@ -194,15 +194,39 @@ function connect() { document.querySelectorAll('.btn-group button').forEach(function(b) { b.disabled = false; }); }; - ws.onclose = function() { + //! Close 代码描述映射(RFC 6455 Section 7.4) + function closeCodeDesc(code) { + var desc = { + 1000: '正常关闭', + 1001: '终端离开', + 1002: '协议错误', + 1003: '不支持的数据类型', + 1005: '无状态码(保留)', + 1006: '异常关闭(连接意外断开)', + 1007: '无效帧负载数据', + 1008: '策略违规', + 1009: '消息过大', + 1010: '缺少必要扩展', + 1011: '内部服务器错误', + 1012: '服务重启', + 1013: '稍后重试', + 1015: 'TLS握手失败' + }; + return desc[code] || ('未知代码: ' + code); + } + + ws.onclose = function(e) { + //! CloseEvent 包含 code 和 reason,这是诊断问题的关键信息 + //! code 1010 = 缺少必要扩展;1006 = 异常关闭;1007 = 帧数据无效 + addLog('✗ 连接断开,代码: ' + e.code + ' (' + closeCodeDesc(e.code) + '),原因: "' + e.reason + '",wasClean: ' + e.wasClean, 'warn'); statusEl.textContent = '已断开'; statusEl.className = 'disconnected'; - addLog('✗ 连接断开', 'warn'); document.querySelectorAll('.btn-group button').forEach(function(b) { b.disabled = true; }); }; - ws.onerror = function() { - addLog('✗ 连接出错', 'warn'); + ws.onerror = function(e) { + //! onerror 事件本身不携带太多信息,记录事件类型和 readyState + addLog('✗ 连接出错,事件类型: ' + e.type + ',readyState: ' + ws.readyState, 'warn'); }; ws.onmessage = function(e) { diff --git a/modules/websocket/CMakeLists.txt b/modules/websocket/CMakeLists.txt index 47eebfa1e9f65274820362c37493010de416fadc..c4508491ba556b19b48cf0ea38cb9c6316114fcf 100644 --- a/modules/websocket/CMakeLists.txt +++ b/modules/websocket/CMakeLists.txt @@ -32,6 +32,7 @@ set(TBOX_LIBRARY_NAME tbox_websocket) set(TBOX_WEBSOCKET_SOURCES ws_frame_parser.cpp ws_frame_builder.cpp + ws_compressor.cpp server/ws_connection.cpp server/ws_server_impl.cpp client/ws_client.cpp @@ -40,11 +41,16 @@ set(TBOX_WEBSOCKET_SOURCES set(TBOX_WEBSOCKET_TEST_SOURCES server/ws_server_impl_test.cpp ws_frame_parser_test.cpp - ws_frame_builder_test.cpp) + ws_frame_builder_test.cpp + ws_compressor_test.cpp) + +find_package(ZLIB REQUIRED) add_library(${TBOX_LIBRARY_NAME} ${TBOX_BUILD_LIB_TYPE} ${TBOX_WEBSOCKET_SOURCES}) add_library(tbox::${TBOX_LIBRARY_NAME} ALIAS ${TBOX_LIBRARY_NAME}) +target_link_libraries(${TBOX_LIBRARY_NAME} ZLIB::ZLIB) + set_target_properties( ${TBOX_LIBRARY_NAME} PROPERTIES VERSION ${TBOX_WEBSOCKET_VERSION} @@ -71,6 +77,7 @@ install( install( FILES ws_frame.h + ws_compressor.h DESTINATION ${CMAKE_INSTALL_INCLUDEDIR}/tbox/websocket ) diff --git a/modules/websocket/Makefile b/modules/websocket/Makefile index 07e04a642d62f6524faf497895b889306dc5f31f..8e207b6aea2b96c8f72845eec31cb3416449234b 100644 --- a/modules/websocket/Makefile +++ b/modules/websocket/Makefile @@ -26,13 +26,13 @@ LIB_VERSION_Y = 0 LIB_VERSION_Z = 1 HEAD_FILES = \ - ws_frame.h \ server/ws_server.h \ client/ws_client.h \ CPP_SRC_FILES = \ ws_frame_parser.cpp \ ws_frame_builder.cpp \ + ws_compressor.cpp \ server/ws_connection.cpp \ server/ws_server_impl.cpp \ client/ws_client.cpp \ @@ -45,8 +45,9 @@ TEST_CPP_SRC_FILES = \ server/ws_server_impl_test.cpp \ ws_frame_parser_test.cpp \ ws_frame_builder_test.cpp \ + ws_compressor_test.cpp \ -TEST_LDFLAGS := $(LDFLAGS) -ltbox_crypto -ltbox_http -ltbox_network -ltbox_log -ltbox_eventx -ltbox_event -ltbox_util -ltbox_base -ldl +TEST_LDFLAGS := $(LDFLAGS) -ltbox_crypto -ltbox_http -ltbox_network -ltbox_log -ltbox_eventx -ltbox_event -ltbox_util -ltbox_base -lz -ldl ENABLE_SHARED_LIB = no diff --git a/modules/websocket/client/ws_client.cpp b/modules/websocket/client/ws_client.cpp index b22c4bf36b03ec382323c98f955a1790a8c51019..7869bbbb168d32bab4dc008499d3a2e5a2ac9a49 100644 --- a/modules/websocket/client/ws_client.cpp +++ b/modules/websocket/client/ws_client.cpp @@ -71,9 +71,14 @@ void WsClient::setDisconnectedCallback(const DisconnectedCallback &cb) impl_->setDisconnectedCallback(cb); } -void WsClient::setMessageCallback(const MessageCallback &cb) +void WsClient::setTextMessageCallback(const TextMessageCallback &cb) { - impl_->setMessageCallback(cb); + impl_->setTextMessageCallback(cb); +} + +void WsClient::setBinaryMessageCallback(const BinaryMessageCallback &cb) +{ + impl_->setBinaryMessageCallback(cb); } void WsClient::setErrorCallback(const ErrorCallback &cb) @@ -91,6 +96,26 @@ void WsClient::setReconnectDelayCalcFunc(const ReconnectDelayCalc &func) impl_->setReconnectDelayCalcFunc(func); } +void WsClient::setCompressionPrefer(bool enable) +{ + impl_->setCompressionPrefer(enable); +} + +void WsClient::setFragmentSize(size_t size) +{ + impl_->setFragmentSize(size); +} + +void WsClient::setPingInterval(int seconds) +{ + impl_->setPingInterval(seconds); +} + +void WsClient::setPingTimeout(int seconds) +{ + impl_->setPingTimeout(seconds); +} + void WsClient::setTlsConfig(const network::TlsConfig &config) { impl_->setTlsConfig(config); @@ -101,14 +126,19 @@ bool WsClient::send(const std::string &text) return impl_->send(text); } +bool WsClient::send(const char *str) +{ + return impl_->send(str); +} + bool WsClient::send(const void *data, size_t len) { return impl_->send(data, len); } -bool WsClient::sendBinary(const std::vector &data) +bool WsClient::send(const std::vector &data) { - return impl_->sendBinary(data); + return impl_->send(data); } bool WsClient::close(uint16_t code, const std::string &reason) diff --git a/modules/websocket/client/ws_client.h b/modules/websocket/client/ws_client.h index 69cb89ef059585f9639b5e54b2c0d69884cc8a8b..4173f8fdc1a95788afb3fb45f5ebd9b61c4bb58f 100644 --- a/modules/websocket/client/ws_client.h +++ b/modules/websocket/client/ws_client.h @@ -20,13 +20,14 @@ #ifndef TBOX_WS_CLIENT_H_20260615 #define TBOX_WS_CLIENT_H_20260615 +#include +#include + #include #include #include #include -#include "../ws_frame.h" - namespace tbox { namespace websocket { namespace client { @@ -35,8 +36,12 @@ namespace client { //! 通过 TcpConnector 建立 TCP 连接,发送 HTTP Upgrade 握手 //! 握手成功后进入 WebSocket 帧通信模式(客户端帧必须掩码) //! 断连后支持自动重连(默认开启),重连延迟策略委托给 TcpConnector +//! 分片消息接收完整后统一解压再回调,使用右值引用提升效率 class WsClient { public: + //! 默认分片发送的最大帧 payload 大小 + static constexpr size_t kDefaultFragmentSize = 65535; + explicit WsClient(event::Loop *wp_loop); ~WsClient(); @@ -57,18 +62,20 @@ class WsClient { State state() const; public: - //! 设置回调 - using ConnectedCallback = std::function; - using DisconnectedCallback = std::function; - using MessageCallback = std::function; - using ErrorCallback = std::function; + //! 设置回调(分片消息接收完整后统一解压再回调,使用右值引用提升效率) + using ConnectedCallback = std::function; + using DisconnectedCallback = std::function; + using TextMessageCallback = std::function; + using BinaryMessageCallback = std::function &&)>; + using ErrorCallback = std::function; //! 重连延迟策略(与 TcpClient 一致,委托给 TcpConnector) using ReconnectDelayCalc = std::function; void setConnectedCallback(const ConnectedCallback &cb); void setDisconnectedCallback(const DisconnectedCallback &cb); - void setMessageCallback(const MessageCallback &cb); + void setTextMessageCallback(const TextMessageCallback &cb); + void setBinaryMessageCallback(const BinaryMessageCallback &cb); void setErrorCallback(const ErrorCallback &cb); //! 是否启用自动重连(默认开启) @@ -76,6 +83,23 @@ class WsClient { //! 设置自定义重连延迟策略(委托给底层 TcpConnector) void setReconnectDelayCalcFunc(const ReconnectDelayCalc &func); + //! 设置是否尽可能使用压缩(必须在 initialize 之前调用) + //! 启用后,将在握手请求中请求 permessage-deflate 扩展 + void setCompressionPrefer(bool enable); + + //! 设置分片大小(仅影响发送,接收时自动组装;必须在 initialize 之前调用) + //! 默认为 kDefaultFragmentSize (65535) + //! 值为 0 表示不分片(所有数据单帧发送) + void setFragmentSize(size_t size); + + //! 设置 Ping 发送间隔(秒),0=不自动 Ping(默认;必须在 initialize 之前调用) + //! 启用后,每隔指定秒数向服务器发送 Ping 帧 + void setPingInterval(int seconds); + + //! 设置 Pong 超时时间(秒),0=不检测超时(默认;必须在 initialize 之前调用) + //! 发送 Ping 后若在此时间内未收到 Pong,则判定连接断开并关闭 + void setPingTimeout(int seconds); + //! 设置 TLS 配置(必须在 initialize() 之前调用) //! 需要 network_tls 模块支持,未链接时调用无效 void setTlsConfig(const network::TlsConfig &config); @@ -83,9 +107,12 @@ class WsClient { public: //! 发送文本帧 bool send(const std::string &text); + //! 发送文本帧(const char* 版本,方便直接传字符串字面量) + bool send(const char *str); //! 发送二进制帧 bool send(const void *data, size_t len); - bool sendBinary(const std::vector &data); + //! 发送二进制帧(vector 版本) + bool send(const std::vector &data); //! 发送关闭帧并关闭连接 bool close(uint16_t code = 1000, const std::string &reason = ""); diff --git a/modules/websocket/client/ws_client_impl.cpp b/modules/websocket/client/ws_client_impl.cpp index 85589d21fe4dee1728d7d21a33eee2e807f3c641..a40596b4b107aff143f05e66776fcfdd764f31cc 100644 --- a/modules/websocket/client/ws_client_impl.cpp +++ b/modules/websocket/client/ws_client_impl.cpp @@ -28,6 +28,7 @@ #include #include #include +#include #include "../ws_frame_parser.h" #include "../ws_frame_builder.h" @@ -42,6 +43,9 @@ namespace tbox { namespace websocket { namespace client { +//! ODR-used static constexpr 成员须在类外定义(C++11) +constexpr size_t WsClient::Impl::kDefaultFragmentSize; + using namespace std::placeholders; //! === 静态辅助方法 === @@ -144,6 +148,10 @@ bool WsClient::Impl::start() is_closing_ = false; frame_parser_.reset(); + //! 清理分片缓存 + fragment_buffer_.clear(); + is_fragmenting_ = false; + //! 开始 TCP 连接(TcpConnector 内部处理重连延迟) sp_connector_->start(); state_ = WsClient::State::kConnecting; @@ -185,12 +193,20 @@ void WsClient::Impl::cleanup() CHECK_DELETE_RESET_OBJ(sp_connector_); CHECK_DELETE_RESET_OBJ(sp_factory_); + CHECK_DELETE_RESET_OBJ(sp_ping_timer_); + CHECK_DELETE_RESET_OBJ(sp_pong_timer_); + connected_cb_ = nullptr; disconnected_cb_ = nullptr; - message_cb_ = nullptr; + text_message_cb_ = nullptr; + binary_message_cb_ = nullptr; error_cb_ = nullptr; reconnect_enabled_ = true; + //! 清理分片缓存 + fragment_buffer_.clear(); + is_fragmenting_ = false; + state_ = WsClient::State::kNone; } @@ -221,6 +237,17 @@ void WsClient::Impl::onTcpDisconnected() RECORD_SCOPE(); LogInfo("ws client disconnected"); + //! 清理分片缓存 + fragment_buffer_.clear(); + is_fragmenting_ = false; + + //! 禁用心跳定时器 + if (sp_ping_timer_ != nullptr) + sp_ping_timer_->disable(); + if (sp_pong_timer_ != nullptr) + sp_pong_timer_->disable(); + is_pong_pending_ = false; + //! 通知用户 if (disconnected_cb_) { RECORD_SCOPE(); @@ -262,7 +289,16 @@ void WsClient::Impl::sendHandshakeRequest() "Upgrade: websocket\r\n" + "Connection: Upgrade\r\n" + "Sec-WebSocket-Key: " + sec_ws_key_ + "\r\n" + - "Sec-WebSocket-Version: 13\r\n\r\n"; + "Sec-WebSocket-Version: 13\r\n"; + + //! RFC 7692:若 prefer_compression_=true,请求压缩扩展 + //! 必须声明 client_no_context_takeover 和 server_no_context_takeover + //! 与我们的实现一致(每条消息独立压缩) + if (prefer_compression_) { + request += "Sec-WebSocket-Extensions: permessage-deflate; client_no_context_takeover; server_no_context_takeover\r\n"; + } + + request += "\r\n"; LogDbg("ws client handshake request sent"); sp_tcp_conn_->send(request.data(), request.size()); @@ -328,6 +364,21 @@ bool WsClient::Impl::parseHandshakeResponse(network::Buffer &buff) //! 握手成功!消耗响应头,切换到帧通信模式 buff.hasRead(header_len); LogInfo("ws client handshake success"); + + //! RFC 7692:检查压缩协商结果 + //! 若客户端请求了压缩且服务器同意了 permessage-deflate + if (prefer_compression_ && + header.find("Sec-WebSocket-Extensions: permessage-deflate") != std::string::npos) { + //! 服务器同意压缩 + compression_config_.enabled = true; + compression_config_.no_context_takeover = true; + compression_config_.max_window_bits = 15; + LogInfo("ws client compression agreed: permessage-deflate"); + } else { + //! 服务器不同意压缩,或客户端未请求 + compression_config_.enabled = false; + } + onHandshakeSuccess(); return true; } @@ -337,6 +388,33 @@ void WsClient::Impl::onHandshakeSuccess() state_ = WsClient::State::kConnected; frame_parser_.reset(); + //! 初始化压缩器 + if (compression_config_.enabled) { + if (!compressor_.initialize(compression_config_)) { + LogErr("WsClient compressor init fail, fallback to no compression"); + compression_config_.enabled = false; + } + } + + //! 初始化 Ping/Pong 心跳定时器 + //! 每次连接(含重连)都重新创建定时器 + CHECK_DELETE_RESET_OBJ(sp_ping_timer_); + CHECK_DELETE_RESET_OBJ(sp_pong_timer_); + is_pong_pending_ = false; + + if (ping_interval_ > 0) { + sp_ping_timer_ = wp_loop_->newTimerEvent(); + sp_ping_timer_->initialize(std::chrono::seconds(ping_interval_), event::Event::Mode::kPersist); + sp_ping_timer_->setCallback(std::bind(&WsClient::Impl::onPingTimerFired, this)); + sp_ping_timer_->enable(); + + if (ping_timeout_ > 0) { + sp_pong_timer_ = wp_loop_->newTimerEvent(); + sp_pong_timer_->initialize(std::chrono::seconds(ping_timeout_), event::Event::Mode::kOneshot); + sp_pong_timer_->setCallback(std::bind(&WsClient::Impl::onPongTimeoutFired, this)); + } + } + //! 通知用户 if (connected_cb_) { RECORD_SCOPE(); @@ -368,57 +446,196 @@ void WsClient::Impl::onHandshakeFail() //! === 帧通信阶段 === +//! 将完整数据交付给业务层 +//! data 为解压后的完整数据(若不需要解压则为原始 payload) +//! opcode 为消息类型(kText 或 kBinary) +void WsClient::Impl::deliverMessage(WsFrame::OpCode opcode, std::string &data) +{ + if (opcode == WsFrame::OpCode::kText) { + if (text_message_cb_) { + ++cb_level_; + text_message_cb_(std::move(data)); + --cb_level_; + } + } else if (opcode == WsFrame::OpCode::kBinary) { + //! 将 std::string 转换为 std::vector + std::vector vec(data.begin(), data.end()); + if (binary_message_cb_) { + ++cb_level_; + binary_message_cb_(std::move(vec)); + --cb_level_; + } + } +} + void WsClient::Impl::onWsFrameReceived(network::Buffer &buff) { - //! 与 server::WsConnection 的帧解析逻辑相同 + //! 与 server::WsConnection 的帧解析逻辑相同:分片数据先缓存,接收完整后统一解压再回调 while (buff.readableSize() > 0) { size_t consumed = frame_parser_.parse(buff.readableBegin(), buff.readableSize()); +#if 1 + auto hex_str = util::string::RawDataToHexStr(buff.readableBegin(), buff.readableSize()); + LogTrace("hex: %s, consumed:%u", hex_str.c_str(), consumed); +#endif buff.hasRead(consumed); if (frame_parser_.state() == WsFrameParser::State::kFinished) { WsFrame *frame = frame_parser_.getFrame(); if (frame != nullptr) { - switch (frame->opcode) { - case WsFrame::OpCode::kText: - case WsFrame::OpCode::kBinary: - case WsFrame::OpCode::kContinue: - if (message_cb_) - message_cb_(*frame); - break; - - case WsFrame::OpCode::kClose: - //! 收到关闭帧,自动回复关闭帧(掩码) - if (!is_closing_) { - auto close_frame = WsFrameBuilder::BuildMaskedCloseFrame(frame->closeCode(), frame->closeReason()); - sp_tcp_conn_->send(close_frame.data(), close_frame.size()); - is_closing_ = true; - } - buff.hasReadAll(); + //! ===== 控制帧处理(Close/Ping/Pong 不受分片状态影响) ===== + if (frame->isControlFrame()) { + switch (frame->opcode) { + case WsFrame::OpCode::kClose: + //! 收到关闭帧,自动回复关闭帧(掩码) + if (!is_closing_) { + auto close_frame = WsFrameBuilder::BuildMaskedCloseFrame(frame->closeCode(), frame->closeReason()); + sp_tcp_conn_->send(close_frame.data(), close_frame.size()); + is_closing_ = true; + } + buff.hasReadAll(); + //! 清理分片缓存 + fragment_buffer_.clear(); + is_fragmenting_ = false; + delete frame; + //! 等待 TCP 断开,由 onTcpDisconnected 通知用户并自动重连 + return; + + case WsFrame::OpCode::kPing: + //! 自动回复 Pong(掩码) + pong(frame->payload); + break; + + case WsFrame::OpCode::kPong: + //! 心跳:收到 Pong,取消超时定时器 + if (is_pong_pending_) { + is_pong_pending_ = false; + if (sp_pong_timer_ != nullptr) + sp_pong_timer_->disable(); + } + break; + + default: + LogNotice("ws client unknown control opcode: 0x%02x", static_cast(frame->opcode)); + delete frame; + buff.hasReadAll(); + fragment_buffer_.clear(); + is_fragmenting_ = false; + onError(); + return; + } + delete frame; + continue; //! 控制帧处理完毕,继续解析下一个帧 + } + + //! ===== 数据帧处理(TEXT / BINARY / CONTINUE) ===== + //! 核心逻辑:分片数据先缓存,接收完整后统一解压再回调 + //! 原因:压缩数据不能逐片解压,必须拼接完整后才能解压 + + if (frame->opcode == WsFrame::OpCode::kText || + frame->opcode == WsFrame::OpCode::kBinary) { + //! 新消息的首帧 + if (is_fragmenting_) { + //! 正在接收分片消息时又收到新消息首帧,协议违规 + LogNotice("ws client protocol error: new data frame while fragmenting"); delete frame; - //! 等待 TCP 断开,由 onTcpDisconnected 通知用户并自动重连 + buff.hasReadAll(); + fragment_buffer_.clear(); + is_fragmenting_ = false; + onError(); return; + } + + if (frame->fin) { + //! 单帧完整消息(无分片) + bool is_need_decompress = frame->rsv1 && compression_config_.enabled; + if (is_need_decompress) { + std::string decompressed = compressor_.decompress(frame->payload); + if (!decompressed.empty()) { + frame->payload = std::move(decompressed); + } else { + //! 解压失败 + LogNotice("ws client decompress fail"); + delete frame; + buff.hasReadAll(); + onError(); + return; + } + } - case WsFrame::OpCode::kPing: - //! 自动回复 Pong(掩码) - pong(frame->payload); - break; + //! 交付完整消息给业务层 + deliverMessage(frame->opcode, frame->payload); + delete frame; - case WsFrame::OpCode::kPong: - //! 收到 Pong,不做特殊处理 - break; + } else { + //! 分片消息的首帧(fin=false) + //! 记录原始 opcode 和是否需要解压,缓存 payload + is_fragmenting_ = true; + fragment_opcode_ = frame->opcode; + fragment_need_decompress_ = frame->rsv1 && compression_config_.enabled; + fragment_buffer_ = std::move(frame->payload); + delete frame; + } - default: - LogNotice("ws client unknown opcode: 0x%02x", static_cast(frame->opcode)); + } else if (frame->opcode == WsFrame::OpCode::kContinue) { + //! 分片消息的后续帧 + if (!is_fragmenting_) { + //! 没有首帧却收到续帧,协议违规 + LogNotice("ws client protocol error: continue frame without fragment start"); delete frame; buff.hasReadAll(); onError(); return; + } + + //! 将本片 payload 追加到缓存区 + fragment_buffer_.append(frame->payload); + + if (frame->fin) { + //! 最后一帧(fin=true),消息完整 + //! 对完整数据统一解压,然后回调业务层 + if (fragment_need_decompress_) { + std::string decompressed = compressor_.decompress(fragment_buffer_); + if (!decompressed.empty()) { + fragment_buffer_ = std::move(decompressed); + } else { + //! 解压失败 + LogNotice("ws client decompress fail"); + delete frame; + buff.hasReadAll(); + fragment_buffer_.clear(); + is_fragmenting_ = false; + onError(); + return; + } + } + + //! 交付完整消息给业务层 + deliverMessage(fragment_opcode_, fragment_buffer_); + + //! 重置分片状态 + fragment_buffer_.clear(); + is_fragmenting_ = false; + } + //! fin=false: 继续缓存,不回调 + + delete frame; + + } else { + //! 未知数据帧 opcode + LogNotice("ws client unknown opcode: 0x%02x", static_cast(frame->opcode)); + delete frame; + buff.hasReadAll(); + fragment_buffer_.clear(); + is_fragmenting_ = false; + onError(); + return; } - delete frame; } } else if (frame_parser_.state() == WsFrameParser::State::kError) { LogNotice("ws client frame parse error"); buff.hasReadAll(); + fragment_buffer_.clear(); + is_fragmenting_ = false; onError(); return; } else { @@ -457,6 +674,10 @@ void WsClient::Impl::onTcpReceived(network::Buffer &buff) void WsClient::Impl::onError() { + //! 清理分片缓存 + fragment_buffer_.clear(); + is_fragmenting_ = false; + //! 出错后断开连接,由 onTcpDisconnected 处理重连 if (sp_tcp_conn_ != nullptr) sp_tcp_conn_->disconnect(); @@ -466,25 +687,22 @@ void WsClient::Impl::onError() bool WsClient::Impl::send(const std::string &text) { - if (is_closing_ || sp_tcp_conn_ == nullptr || state_ != WsClient::State::kConnected) - return false; + return sendData(WsFrame::OpCode::kText, text.data(), text.size()); +} - auto frame = WsFrameBuilder::BuildMaskedTextFrame(text); - return sp_tcp_conn_->send(frame.data(), frame.size()); +bool WsClient::Impl::send(const char *str) +{ + return sendData(WsFrame::OpCode::kText, str, strlen(str)); } bool WsClient::Impl::send(const void *data, size_t len) { - if (is_closing_ || sp_tcp_conn_ == nullptr || state_ != WsClient::State::kConnected) - return false; - - auto frame = WsFrameBuilder::BuildMaskedBinaryFrame(data, len); - return sp_tcp_conn_->send(frame.data(), frame.size()); + return sendData(WsFrame::OpCode::kBinary, data, len); } -bool WsClient::Impl::sendBinary(const std::vector &data) +bool WsClient::Impl::send(const std::vector &data) { - return send(data.data(), data.size()); + return sendData(WsFrame::OpCode::kBinary, data.data(), data.size()); } bool WsClient::Impl::close(uint16_t code, const std::string &reason) @@ -533,6 +751,72 @@ bool WsClient::Impl::sendMaskedFrame(WsFrame::OpCode opcode, bool fin, const voi return sp_tcp_conn_->send(frame.data(), frame.size()); } +//! 统一发送数据:前置检查 → 压缩(如需要) → sendFragmented +//! opcode 为 kText 或 kBinary +bool WsClient::Impl::sendData(WsFrame::OpCode opcode, const void *data_ptr, size_t data_len) +{ + if (is_closing_ || sp_tcp_conn_ == nullptr || state_ != WsClient::State::kConnected) + return false; + + //! 压缩协商达成时,压缩数据 + if (compression_config_.enabled && compressor_.isInitialized()) { + std::string compressed = compressor_.compress(data_ptr, data_len); + if (!compressed.empty()) { + return sendFragmented(opcode, compressed.data(), compressed.size(), true); + } + //! 压缩失败,回退到不压缩 + LogNotice("ws client compress fail, fallback to uncompressed"); + } + + return sendFragmented(opcode, data_ptr, data_len, false); +} + +//! 分片发送 payload(客户端版本,掩码) +//! 若 payload 大小超过 fragment_size_,则分片发送: +//! - 首帧:原始 opcode,fin=false,rsv1=is_compressed(掩码) +//! - 中间帧:kContinue,fin=false(掩码) +//! - 末帧:kContinue,fin=true(掩码) +//! 若 payload 大小不超过 fragment_size_,则单帧发送 +bool WsClient::Impl::sendFragmented(WsFrame::OpCode opcode, const void *payload, size_t payload_len, bool is_compressed) +{ + if (sp_tcp_conn_ == nullptr) + return false; + + //! 单帧即可发送(fragment_size_ 为 0 时表示不分片) + if (fragment_size_ == 0 || payload_len <= fragment_size_) { + auto frame = WsFrameBuilder::BuildMaskedFrame(opcode, true, payload, payload_len, nullptr, is_compressed); + return sp_tcp_conn_->send(frame.data(), frame.size()); + } + + //! 分片发送 + const uint8_t *data = static_cast(payload); + size_t offset = 0; + + //! 首帧:原始 opcode,fin=false,rsv1=is_compressed(掩码) + size_t first_chunk = fragment_size_; + auto frame = WsFrameBuilder::BuildMaskedFrame(opcode, false, data, first_chunk, nullptr, is_compressed); + if (!sp_tcp_conn_->send(frame.data(), frame.size())) + return false; + + offset += first_chunk; + + //! 中间帧与末帧:opcode=kContinue,rsv1=false(掩码) + while (offset < payload_len) { + size_t remaining = payload_len - offset; + size_t chunk_size = std::min(remaining, fragment_size_); + bool is_last = (offset + chunk_size == payload_len); + + auto cont_frame = WsFrameBuilder::BuildMaskedFrame(WsFrame::OpCode::kContinue, is_last, + data + offset, chunk_size, nullptr, false); + if (!sp_tcp_conn_->send(cont_frame.data(), cont_frame.size())) + return false; + + offset += chunk_size; + } + + return true; +} + bool WsClient::Impl::isExpired() const { return sp_tcp_conn_ == nullptr || sp_tcp_conn_->isExpired(); @@ -558,6 +842,33 @@ void* WsClient::Impl::getContext() const return nullptr; } +//! Ping 定时器触发:发送 Ping,启动 Pong 超时检测 +void WsClient::Impl::onPingTimerFired() +{ + if (is_closing_ || sp_tcp_conn_ == nullptr || state_ != WsClient::State::kConnected) + return; + + //! 发送 Ping 帧 + ping(""); + + //! 如果有超时检测,标记等待 Pong 并启动超时定时器 + if (ping_timeout_ > 0 && sp_pong_timer_ != nullptr) { + is_pong_pending_ = true; + sp_pong_timer_->enable(); + } +} + +//! Pong 超时触发:未收到 Pong 回复,判定连接已断开 +void WsClient::Impl::onPongTimeoutFired() +{ + if (is_closing_) + return; + + LogNotice("ws client pong timeout, closing connection"); + is_pong_pending_ = false; + close(1006, "pong timeout"); +} + } } } diff --git a/modules/websocket/client/ws_client_impl.h b/modules/websocket/client/ws_client_impl.h index 2b4ece478efb139af6db50bc634c6af4b4ee14bf..526579a47682abf8b6f38f81ef8cf83fbab289d1 100644 --- a/modules/websocket/client/ws_client_impl.h +++ b/modules/websocket/client/ws_client_impl.h @@ -21,6 +21,7 @@ #define TBOX_WS_CLIENT_IMPL_H_20260615 #include +#include #include #include #include @@ -31,6 +32,7 @@ #include "ws_client.h" #include "../ws_frame.h" #include "../ws_frame_parser.h" +#include "../ws_compressor.h" namespace tbox { namespace websocket { @@ -38,8 +40,12 @@ namespace client { //! WsClient::Impl 实现完整的 WebSocket 客户端 //! 流程:TcpConnector 建立 TCP → 发送 HTTP Upgrade → 验证 101 → 帧通信 +//! 发送大数据时先压缩再分片发送,避免单帧过大 class WsClient::Impl { public: + //! 默认分片发送的最大帧 payload 大小(64KB) + //! 选择 65535 是因为:不超过 16-bit payload length 编码范围,避免 64-bit 编码开销 + static constexpr size_t kDefaultFragmentSize = 65535; Impl(WsClient *wp_parent, event::Loop *wp_loop); ~Impl(); @@ -54,16 +60,22 @@ class WsClient::Impl { public: void setConnectedCallback(const WsClient::ConnectedCallback &cb) { connected_cb_ = cb; } void setDisconnectedCallback(const WsClient::DisconnectedCallback &cb) { disconnected_cb_ = cb; } - void setMessageCallback(const WsClient::MessageCallback &cb) { message_cb_ = cb; } + void setTextMessageCallback(const WsClient::TextMessageCallback &cb) { text_message_cb_ = cb; } + void setBinaryMessageCallback(const WsClient::BinaryMessageCallback &cb) { binary_message_cb_ = cb; } void setErrorCallback(const WsClient::ErrorCallback &cb) { error_cb_ = cb; } void setAutoReconnect(bool enable) { reconnect_enabled_ = enable; } void setReconnectDelayCalcFunc(const WsClient::ReconnectDelayCalc &func); + void setCompressionPrefer(bool enable) { prefer_compression_ = enable; } + void setFragmentSize(size_t size) { fragment_size_ = size; } + void setPingInterval(int seconds) { ping_interval_ = seconds; } + void setPingTimeout(int seconds) { ping_timeout_ = seconds; } void setTlsConfig(const network::TlsConfig &config); public: bool send(const std::string &text); + bool send(const char *str); bool send(const void *data, size_t len); - bool sendBinary(const std::vector &data); + bool send(const std::vector &data); bool close(uint16_t code, const std::string &reason); bool ping(const std::string &data); bool pong(const std::string &data); @@ -106,9 +118,28 @@ class WsClient::Impl { //! 发送 WebSocket 帧(客户端,掩码) bool sendMaskedFrame(WsFrame::OpCode opcode, bool fin, const void *payload, size_t payload_len); + //! 分片发送 payload(客户端,掩码,内部使用) + //! opcode: 首帧 opcode(kText 或 kBinary) + //! payload/payload_len: 完整的 payload 数据(可能为压缩后数据) + //! is_compressed: 是否为压缩数据(首帧设置 rsv1=true) + bool sendFragmented(WsFrame::OpCode opcode, const void *payload, size_t payload_len, bool is_compressed); + + //! 统一发送数据(内部使用) + //! opcode: kText 或 kBinary + //! data_ptr/data_len: 原始数据指针与长度 + //! 流程:前置检查 → 压缩(如需要) → sendFragmented + bool sendData(WsFrame::OpCode opcode, const void *data_ptr, size_t data_len); + + //! 将完整消息交付给业务层(opcode 为 kText 或 kBinary) + void deliverMessage(WsFrame::OpCode opcode, std::string &data); + //! 出错处理 void onError(); + //! Ping/Pong 心跳定时器回调 + void onPingTimerFired(); + void onPongTimeoutFired(); + private: WsClient *wp_parent_; event::Loop *wp_loop_; @@ -126,16 +157,39 @@ class WsClient::Impl { //! 帧解析器 WsFrameParser frame_parser_; + //! 压缩相关 + bool prefer_compression_ = false; + WsCompressionConfig compression_config_; //! 握手成功后确认的压缩配置 + WsCompressor compressor_; + + //! 分片发送的最大帧 payload 大小(可配置,默认 kDefaultFragmentSize) + size_t fragment_size_ = WsClient::kDefaultFragmentSize; + + //! Ping/Pong 心跳参数 + int ping_interval_ = 0; + int ping_timeout_ = 0; + event::TimerEvent *sp_ping_timer_ = nullptr; + event::TimerEvent *sp_pong_timer_ = nullptr; + bool is_pong_pending_ = false; + WsClient::State state_ = WsClient::State::kNone; - WsClient::ConnectedCallback connected_cb_; - WsClient::DisconnectedCallback disconnected_cb_; - WsClient::MessageCallback message_cb_; - WsClient::ErrorCallback error_cb_; + WsClient::ConnectedCallback connected_cb_; + WsClient::DisconnectedCallback disconnected_cb_; + WsClient::TextMessageCallback text_message_cb_; + WsClient::BinaryMessageCallback binary_message_cb_; + WsClient::ErrorCallback error_cb_; bool is_closing_ = false; bool reconnect_enabled_ = true; int cb_level_ = 0; + + //! 分片组装相关 + //! 只有接收完整数据帧(fin=true)并进行解压后,才回调业务层 + bool is_fragmenting_ = false; //!< 是否正在接收分片消息 + WsFrame::OpCode fragment_opcode_; //!< 分片消息的原始 opcode(kText 或 kBinary) + bool fragment_need_decompress_ = false; //!< 分片消息是否需要解压 + std::string fragment_buffer_; //!< 分片数据的缓存区 }; } diff --git a/modules/websocket/server/ws_connection.cpp b/modules/websocket/server/ws_connection.cpp index 92dd2b3025039ffefc851fe4a7def542f40f7d4a..eb8f99e3364f05335c8a0562d0a535f4e159b8cc 100644 --- a/modules/websocket/server/ws_connection.cpp +++ b/modules/websocket/server/ws_connection.cpp @@ -21,6 +21,7 @@ #include #include +#include #include "../ws_frame_parser.h" #include "../ws_frame_builder.h" @@ -29,26 +30,64 @@ namespace tbox { namespace websocket { namespace server { +//! ODR-used static constexpr 成员须在类外定义(C++11) +constexpr size_t WsConnection::kDefaultFragmentSize; + using namespace std::placeholders; -WsConnection::WsConnection(event::Loop *wp_loop, network::TcpConnection *tcp_conn, const std::string &url) +WsConnection::WsConnection(event::Loop *wp_loop, + network::TcpConnection *tcp_conn, + const std::string &url, + const WsCompressionConfig &compress_config, + size_t fragment_size, + int ping_interval, + int ping_timeout) : wp_loop_(wp_loop) , sp_tcp_conn_(tcp_conn) , url_(url) + , compression_config_(compress_config) + , fragment_size_(fragment_size) + , ping_interval_(ping_interval) + , ping_timeout_(ping_timeout) { TBOX_ASSERT(wp_loop != nullptr); TBOX_ASSERT(tcp_conn != nullptr); + //! 初始化压缩器 + if (compression_config_.enabled) { + if (!compressor_.initialize(compression_config_)) { + LogErr("WsConnection compressor init fail"); + } + } + //! 设置 TcpConnection 的回调 sp_tcp_conn_->setReceiveCallback(std::bind(&WsConnection::onTcpReceived, this, _1), 0); sp_tcp_conn_->setDisconnectedCallback(std::bind(&WsConnection::onTcpDisconnected, this)); sp_tcp_conn_->setSendCompleteCallback(std::bind(&WsConnection::onTcpSendCompleted, this)); + + //! 初始化 Ping/Pong 心跳定时器 + if (ping_interval_ > 0) { + sp_ping_timer_ = wp_loop_->newTimerEvent(); + sp_ping_timer_->initialize(std::chrono::seconds(ping_interval_), event::Event::Mode::kPersist); + sp_ping_timer_->setCallback(std::bind(&WsConnection::onPingTimerFired, this)); + sp_ping_timer_->enable(); + + if (ping_timeout_ > 0) { + sp_pong_timer_ = wp_loop_->newTimerEvent(); + sp_pong_timer_->initialize(std::chrono::seconds(ping_timeout_), event::Event::Mode::kOneshot); + sp_pong_timer_->setCallback(std::bind(&WsConnection::onPongTimeoutFired, this)); + } + } } WsConnection::~WsConnection() { TBOX_ASSERT(cb_level_ == 0); + //! 清理心跳定时器 + CHECK_DELETE_RESET_OBJ(sp_ping_timer_); + CHECK_DELETE_RESET_OBJ(sp_pong_timer_); + if (sp_tcp_conn_ == nullptr) return; @@ -66,25 +105,22 @@ WsConnection::~WsConnection() bool WsConnection::send(const std::string &text) { - if (is_closing_ || sp_tcp_conn_ == nullptr) - return false; + return sendData(WsFrame::OpCode::kText, text.data(), text.size()); +} - auto frame = WsFrameBuilder::BuildTextFrame(text); - return sp_tcp_conn_->send(frame.data(), frame.size()); +bool WsConnection::send(const char *str) +{ + return sendData(WsFrame::OpCode::kText, str, strlen(str)); } bool WsConnection::send(const void *data, size_t len) { - if (is_closing_ || sp_tcp_conn_ == nullptr) - return false; - - auto frame = WsFrameBuilder::BuildBinaryFrame(data, len); - return sp_tcp_conn_->send(frame.data(), frame.size()); + return sendData(WsFrame::OpCode::kBinary, data, len); } -bool WsConnection::sendBinary(const std::vector &data) +bool WsConnection::send(const std::vector &data) { - return send(data.data(), data.size()); + return sendData(WsFrame::OpCode::kBinary, data.data(), data.size()); } bool WsConnection::close(uint16_t code, const std::string &reason) @@ -164,64 +200,234 @@ bool WsConnection::sendFrame(WsFrame::OpCode opcode, bool fin, return sp_tcp_conn_->send(frame.data(), frame.size()); } +//! 统一发送数据:前置检查 → 压缩(如需要) → sendFragmented +//! opcode 为 kText 或 kBinary +bool WsConnection::sendData(WsFrame::OpCode opcode, const void *data_ptr, size_t data_len) +{ + if (is_closing_ || sp_tcp_conn_ == nullptr) + return false; + + //! 压缩协商达成时,压缩数据 + if (compression_config_.enabled && compressor_.isInitialized()) { + std::string compressed = compressor_.compress(data_ptr, data_len); + if (!compressed.empty()) { + return sendFragmented(opcode, compressed.data(), compressed.size(), true); + } + //! 压缩失败,回退到不压缩 + LogNotice("ws compress fail, fallback to uncompressed"); + } + + return sendFragmented(opcode, data_ptr, data_len, false); +} + +//! 分片发送 payload +//! 若 payload 大小超过 fragment_size_,则分片发送: +//! - 首帧:原始 opcode,fin=false,rsv1=is_compressed +//! - 中间帧:kContinue,fin=false +//! - 末帧:kContinue,fin=true +//! 若 payload 大小不超过 fragment_size_,则单帧发送 +bool WsConnection::sendFragmented(WsFrame::OpCode opcode, const void *payload, size_t payload_len, bool is_compressed) +{ + if (sp_tcp_conn_ == nullptr) + return false; + + //! 单帧即可发送(fragment_size_ 为 0 时表示不分片) + if (fragment_size_ == 0 || payload_len <= fragment_size_) { + auto frame = WsFrameBuilder::BuildFrame(opcode, true, payload, payload_len, is_compressed); + return sp_tcp_conn_->send(frame.data(), frame.size()); + } + + //! 分片发送 + const uint8_t *data = static_cast(payload); + size_t offset = 0; + + //! 首帧:原始 opcode,fin=false,rsv1=is_compressed + size_t first_chunk = fragment_size_; + auto frame = WsFrameBuilder::BuildFrame(opcode, false, data, first_chunk, is_compressed); + if (!sp_tcp_conn_->send(frame.data(), frame.size())) + return false; + + offset += first_chunk; + + //! 中间帧与末帧:opcode=kContinue,rsv1=false + while (offset < payload_len) { + size_t remaining = payload_len - offset; + size_t chunk_size = std::min(remaining, fragment_size_); + bool is_last = (offset + chunk_size == payload_len); + + auto cont_frame = WsFrameBuilder::BuildFrame(WsFrame::OpCode::kContinue, is_last, + data + offset, chunk_size, false); + if (!sp_tcp_conn_->send(cont_frame.data(), cont_frame.size())) + return false; + + offset += chunk_size; + } + + return true; +} + +//! 将完整数据交付给业务层 +//! data 为解压后的完整数据(若不需要解压则为原始 payload) +//! opcode 为消息类型(kText 或 kBinary) +void WsConnection::deliverMessage(WsFrame::OpCode opcode, std::string &data) +{ + if (opcode == WsFrame::OpCode::kText) { + if (text_message_cb_) { + ++cb_level_; + text_message_cb_(std::move(data)); + --cb_level_; + } + } else if (opcode == WsFrame::OpCode::kBinary) { + //! 将 std::string 转换为 std::vector + std::vector vec(data.begin(), data.end()); + if (binary_message_cb_) { + ++cb_level_; + binary_message_cb_(std::move(vec)); + --cb_level_; + } + } +} + void WsConnection::onTcpReceived(network::Buffer &buff) { //! 从缓冲区中逐步解析 WebSocket 帧 while (buff.readableSize() > 0) { size_t consumed = frame_parser_.parse(buff.readableBegin(), buff.readableSize()); +#if 1 + auto hex_str = util::string::RawDataToHexStr(buff.readableBegin(), buff.readableSize()); + LogTrace("hex: %s, consumed:%u", hex_str.c_str(), consumed); +#endif buff.hasRead(consumed); if (frame_parser_.state() == WsFrameParser::State::kFinished) { WsFrame *frame = frame_parser_.getFrame(); if (frame != nullptr) { - switch (frame->opcode) { - case WsFrame::OpCode::kText: - case WsFrame::OpCode::kBinary: - case WsFrame::OpCode::kContinue: - if (message_cb_) { - ++cb_level_; - message_cb_(*frame); - --cb_level_; - } - break; - - case WsFrame::OpCode::kClose: - //! 收到关闭帧,自动回复关闭帧 - if (!is_closing_) { - auto close_frame = WsFrameBuilder::BuildCloseFrame(frame->closeCode(), frame->closeReason()); - sp_tcp_conn_->send(close_frame.data(), close_frame.size()); - is_closing_ = true; - } - //! 不再处理后续数据 - buff.hasReadAll(); + //! ===== 控制帧处理(Close/Ping/Pong 不受分片状态影响) ===== + if (frame->isControlFrame()) { + switch (frame->opcode) { + case WsFrame::OpCode::kClose: + //! 收到关闭帧,自动回复关闭帧 + if (!is_closing_) { + auto close_frame = WsFrameBuilder::BuildCloseFrame(frame->closeCode(), frame->closeReason()); + sp_tcp_conn_->send(close_frame.data(), close_frame.size()); + is_closing_ = true; + } + //! 不再处理后续数据 + buff.hasReadAll(); + //! 清理分片缓存 + fragment_buffer_.clear(); + is_fragmenting_ = false; + delete frame; + if (close_cb_) { + ++cb_level_; + close_cb_(); + --cb_level_; + } + return; + + case WsFrame::OpCode::kPing: + //! 自动回复 Pong + pong(frame->payload); + if (ping_cb_) { + ++cb_level_; + ping_cb_(frame->payload); + --cb_level_; + } + break; + + case WsFrame::OpCode::kPong: + //! 心跳:收到 Pong,取消超时定时器 + if (is_pong_pending_) { + is_pong_pending_ = false; + if (sp_pong_timer_ != nullptr) + sp_pong_timer_->disable(); + } + if (pong_cb_) { + ++cb_level_; + pong_cb_(frame->payload); + --cb_level_; + } + break; + + default: + LogNotice("unknown ws control opcode: 0x%02x", static_cast(frame->opcode)); + delete frame; + buff.hasReadAll(); + fragment_buffer_.clear(); + is_fragmenting_ = false; + if (error_cb_) { + ++cb_level_; + error_cb_(); + --cb_level_; + } + return; + } + delete frame; + continue; //! 控制帧处理完毕,继续解析下一个帧 + } + + //! ===== 数据帧处理(TEXT / BINARY / CONTINUE) ===== + //! 核心逻辑:分片数据先缓存,接收完整后统一解压再回调 + //! 原因:压缩数据不能逐片解压,必须拼接完整后才能解压 + + if (frame->opcode == WsFrame::OpCode::kText || + frame->opcode == WsFrame::OpCode::kBinary) { + //! 新消息的首帧 + if (is_fragmenting_) { + //! 正在接收分片消息时又收到新消息首帧,协议违规 + LogNotice("ws protocol error: new data frame while fragmenting"); delete frame; - if (close_cb_) { + buff.hasReadAll(); + fragment_buffer_.clear(); + is_fragmenting_ = false; + if (error_cb_) { ++cb_level_; - close_cb_(); + error_cb_(); --cb_level_; } return; - - case WsFrame::OpCode::kPing: - //! 自动回复 Pong - pong(frame->payload); - if (ping_cb_) { - ++cb_level_; - ping_cb_(frame->payload); - --cb_level_; + } + + if (frame->fin) { + //! 单帧完整消息(无分片) + bool is_need_decompress = frame->rsv1 && compression_config_.enabled; + if (is_need_decompress) { + std::string decompressed = compressor_.decompress(frame->payload); + if (!decompressed.empty()) { + frame->payload = std::move(decompressed); + } else { + //! 解压失败 + LogNotice("ws decompress fail"); + delete frame; + buff.hasReadAll(); + if (error_cb_) { + ++cb_level_; + error_cb_(); + --cb_level_; + } + return; + } } - break; - case WsFrame::OpCode::kPong: - if (pong_cb_) { - ++cb_level_; - pong_cb_(frame->payload); - --cb_level_; - } - break; + //! 交付完整消息给业务层 + deliverMessage(frame->opcode, frame->payload); + delete frame; + + } else { + //! 分片消息的首帧(fin=false) + //! 记录原始 opcode 和是否需要解压,缓存 payload + is_fragmenting_ = true; + fragment_opcode_ = frame->opcode; + fragment_need_decompress_ = frame->rsv1 && compression_config_.enabled; + fragment_buffer_ = std::move(frame->payload); + delete frame; + } - default: - LogNotice("unknown ws opcode: 0x%02x", static_cast(frame->opcode)); + } else if (frame->opcode == WsFrame::OpCode::kContinue) { + //! 分片消息的后续帧 + if (!is_fragmenting_) { + //! 没有首帧却收到续帧,协议违规 + LogNotice("ws protocol error: continue frame without fragment start"); delete frame; buff.hasReadAll(); if (error_cb_) { @@ -230,12 +436,65 @@ void WsConnection::onTcpReceived(network::Buffer &buff) --cb_level_; } return; + } + + //! 将本片 payload 追加到缓存区 + fragment_buffer_.append(frame->payload); + + if (frame->fin) { + //! 最后一帧(fin=true),消息完整 + //! 对完整数据统一解压,然后回调业务层 + if (fragment_need_decompress_) { + std::string decompressed = compressor_.decompress(fragment_buffer_); + if (!decompressed.empty()) { + fragment_buffer_ = std::move(decompressed); + } else { + //! 解压失败 + LogNotice("ws decompress fail"); + delete frame; + buff.hasReadAll(); + fragment_buffer_.clear(); + is_fragmenting_ = false; + if (error_cb_) { + ++cb_level_; + error_cb_(); + --cb_level_; + } + return; + } + } + + //! 交付完整消息给业务层 + deliverMessage(fragment_opcode_, fragment_buffer_); + + //! 重置分片状态 + fragment_buffer_.clear(); + is_fragmenting_ = false; + } + //! fin=false: 继续缓存,不回调 + + delete frame; + + } else { + //! 未知数据帧 opcode + LogNotice("unknown ws opcode: 0x%02x", static_cast(frame->opcode)); + delete frame; + buff.hasReadAll(); + fragment_buffer_.clear(); + is_fragmenting_ = false; + if (error_cb_) { + ++cb_level_; + error_cb_(); + --cb_level_; + } + return; } - delete frame; } } else if (frame_parser_.state() == WsFrameParser::State::kError) { LogNotice("ws frame parse error"); buff.hasReadAll(); + fragment_buffer_.clear(); + is_fragmenting_ = false; if (error_cb_) { ++cb_level_; error_cb_(); @@ -253,6 +512,17 @@ void WsConnection::onTcpDisconnected() { LogInfo("ws disconnected"); + //! 清理分片缓存 + fragment_buffer_.clear(); + is_fragmenting_ = false; + + //! 禁用心跳定时器 + if (sp_ping_timer_ != nullptr) + sp_ping_timer_->disable(); + if (sp_pong_timer_ != nullptr) + sp_pong_timer_->disable(); + is_pong_pending_ = false; + if (close_cb_) { ++cb_level_; close_cb_(); @@ -277,6 +547,81 @@ void WsConnection::onTcpSendCompleted() } } +void WsConnection::setPingInterval(int seconds) +{ + ping_interval_ = seconds; + + if (sp_ping_timer_ != nullptr) { + if (seconds > 0) { + sp_ping_timer_->initialize(std::chrono::seconds(seconds), event::Event::Mode::kPersist); + sp_ping_timer_->enable(); + } else { + sp_ping_timer_->disable(); + is_pong_pending_ = false; + if (sp_pong_timer_ != nullptr) + sp_pong_timer_->disable(); + } + } else if (seconds > 0) { + //! 之前没有创建过定时器,现在需要创建 + sp_ping_timer_ = wp_loop_->newTimerEvent(); + sp_ping_timer_->initialize(std::chrono::seconds(seconds), event::Event::Mode::kPersist); + sp_ping_timer_->setCallback(std::bind(&WsConnection::onPingTimerFired, this)); + sp_ping_timer_->enable(); + + if (ping_timeout_ > 0) { + sp_pong_timer_ = wp_loop_->newTimerEvent(); + sp_pong_timer_->initialize(std::chrono::seconds(ping_timeout_), event::Event::Mode::kOneshot); + sp_pong_timer_->setCallback(std::bind(&WsConnection::onPongTimeoutFired, this)); + } + } +} + +void WsConnection::setPingTimeout(int seconds) +{ + ping_timeout_ = seconds; + + if (sp_pong_timer_ != nullptr) { + if (seconds > 0) { + sp_pong_timer_->initialize(std::chrono::seconds(seconds), event::Event::Mode::kOneshot); + } else { + sp_pong_timer_->disable(); + CHECK_DELETE_RESET_OBJ(sp_pong_timer_); + } + } else if (seconds > 0 && sp_ping_timer_ != nullptr) { + //! ping 已启用但 pong_timer 未创建,现在创建 + sp_pong_timer_ = wp_loop_->newTimerEvent(); + sp_pong_timer_->initialize(std::chrono::seconds(seconds), event::Event::Mode::kOneshot); + sp_pong_timer_->setCallback(std::bind(&WsConnection::onPongTimeoutFired, this)); + } +} + +//! Ping 定时器触发:发送 Ping,启动 Pong 超时检测 +void WsConnection::onPingTimerFired() +{ + if (is_closing_ || sp_tcp_conn_ == nullptr) + return; + + //! 发送 Ping 帧 + ping(); + + //! 如果有超时检测,标记等待 Pong 并启动超时定时器 + if (ping_timeout_ > 0 && sp_pong_timer_ != nullptr) { + is_pong_pending_ = true; + sp_pong_timer_->enable(); + } +} + +//! Pong 超时触发:未收到 Pong 回复,判定连接已断开 +void WsConnection::onPongTimeoutFired() +{ + if (is_closing_) + return; + + LogNotice("ws pong timeout, closing connection"); + is_pong_pending_ = false; + close(); +} + } } } diff --git a/modules/websocket/server/ws_connection.h b/modules/websocket/server/ws_connection.h index 430d81e308dc018fed860d378da8bb56dc708167..dce51497e33f410116d09c20e77796f1959d9f01 100644 --- a/modules/websocket/server/ws_connection.h +++ b/modules/websocket/server/ws_connection.h @@ -20,11 +20,16 @@ #ifndef TBOX_WS_CONNECTION_H_20260612 #define TBOX_WS_CONNECTION_H_20260612 +#include +#include + #include +#include #include #include "../ws_frame.h" #include "../ws_frame_parser.h" +#include "../ws_compressor.h" namespace tbox { namespace websocket { @@ -33,14 +38,21 @@ namespace server { //! WebSocket 连接 //! 包装从 HTTP 升级后分离出来的 TcpConnection,解析/构建 WebSocket 帧 //! 生命期由 WsServer 通过 Cabinet 管理,用户通过 ConnToken 访问 +//! 支持分片消息的完整接收:缓存分片数据,接收完整后再解压并回调 +//! 发送大数据时先压缩再分片发送,避免单帧过大 class WsConnection { public: + //! 默认分片发送的最大帧 payload 大小(64KB) + //! 选择 65535 是因为:不超过 16-bit payload length 编码范围,避免 64-bit 编码开销 + static constexpr size_t kDefaultFragmentSize = 65535; + //! 内部回调:WsServer::Impl 绑定 ConnToken,不传递 WsConnection* - using CloseCallback = std::function; - using MessageCallback = std::function; - using ErrorCallback = std::function; - using PingCallback = std::function; - using PongCallback = std::function; + using CloseCallback = std::function; + using TextMessageCallback = std::function; + using BinaryMessageCallback = std::function &&)>; + using ErrorCallback = std::function; + using PingCallback = std::function; + using PongCallback = std::function; using SendCompleteCallback = std::function; ~WsConnection(); @@ -50,19 +62,22 @@ class WsConnection { public: //! 设置回调(由 WsServer::Impl 调用,绑定 ConnToken) - void setCloseCallback(const CloseCallback &cb) { close_cb_ = cb; } - void setMessageCallback(const MessageCallback &cb) { message_cb_ = cb; } - void setErrorCallback(const ErrorCallback &cb) { error_cb_ = cb; } - void setPingCallback(const PingCallback &cb) { ping_cb_ = cb; } - void setPongCallback(const PongCallback &cb) { pong_cb_ = cb; } + void setCloseCallback(const CloseCallback &cb) { close_cb_ = cb; } + void setTextMessageCallback(const TextMessageCallback &cb) { text_message_cb_ = cb; } + void setBinaryMessageCallback(const BinaryMessageCallback &cb) { binary_message_cb_ = cb; } + void setErrorCallback(const ErrorCallback &cb) { error_cb_ = cb; } + void setPingCallback(const PingCallback &cb) { ping_cb_ = cb; } + void setPongCallback(const PongCallback &cb) { pong_cb_ = cb; } void setSendCompleteCallback(const SendCompleteCallback &cb) { send_complete_cb_ = cb; } public: //! 发送文本帧 bool send(const std::string &text); + //! 发送文本帧(const char* 版本,方便直接传字符串字面量) + bool send(const char *str); //! 发送二进制帧 bool send(const void *data, size_t len); - bool sendBinary(const std::vector &data); + bool send(const std::vector &data); //! 发送关闭帧并关闭连接 bool close(uint16_t code = 1000, const std::string &reason = ""); @@ -86,9 +101,22 @@ class WsConnection { void setContext(void *context, ContextDeleter &&deleter = nullptr); void* getContext() const; + //! 设置/获取分片大小(仅影响发送,接收时自动组装) + void setFragmentSize(size_t size) { fragment_size_ = size; } + size_t fragmentSize() const { return fragment_size_; } + + //! 设置 Ping/Pong 心跳参数 + void setPingInterval(int seconds); + void setPingTimeout(int seconds); + private: //! 仅由 WsServer 创建(生命期由 Cabinet 管理) - WsConnection(event::Loop *wp_loop, network::TcpConnection *tcp_conn, const std::string &url); + //! compress_config 为握手时协商的压缩配置 + //! fragment_size 为分片发送的最大帧 payload 大小 + //! ping_interval/ping_timeout 为心跳参数(0=不自动 Ping/不检测超时) + WsConnection(event::Loop *wp_loop, network::TcpConnection *tcp_conn, const std::string &url, + const WsCompressionConfig &compress_config, size_t fragment_size, + int ping_interval, int ping_timeout); void onTcpReceived(network::Buffer &buff); void onTcpDisconnected(); @@ -97,6 +125,25 @@ class WsConnection { //! 发送 WebSocket 帧(内部使用) bool sendFrame(WsFrame::OpCode opcode, bool fin, const void *payload, size_t payload_len); + //! 分片发送 payload(内部使用) + //! opcode: 首帧 opcode(kText 或 kBinary) + //! payload/payload_len: 完整的 payload 数据(可能为压缩后数据) + //! is_compressed: 是否为压缩数据(首帧设置 rsv1=true) + bool sendFragmented(WsFrame::OpCode opcode, const void *payload, size_t payload_len, bool is_compressed); + + //! 统一发送数据(内部使用) + //! opcode: kText 或 kBinary + //! data_ptr/data_len: 原始数据指针与长度 + //! 流程:前置检查 → 压缩(如需要) → sendFragmented + bool sendData(WsFrame::OpCode opcode, const void *data_ptr, size_t data_len); + + //! 将完整消息交付给业务层(opcode 为 kText 或 kBinary) + void deliverMessage(WsFrame::OpCode opcode, std::string &data); + + //! Ping/Pong 心跳定时器回调 + void onPingTimerFired(); + void onPongTimeoutFired(); + private: event::Loop *wp_loop_; network::TcpConnection *sp_tcp_conn_; @@ -104,15 +151,37 @@ class WsConnection { WsFrameParser frame_parser_; - CloseCallback close_cb_; - MessageCallback message_cb_; - ErrorCallback error_cb_; - PingCallback ping_cb_; - PongCallback pong_cb_; + //! 压缩相关 + WsCompressionConfig compression_config_; + WsCompressor compressor_; + + //! 分片发送的最大帧 payload 大小(可配置,默认 kDefaultFragmentSize) + size_t fragment_size_ = kDefaultFragmentSize; + + CloseCallback close_cb_; + TextMessageCallback text_message_cb_; + BinaryMessageCallback binary_message_cb_; + ErrorCallback error_cb_; + PingCallback ping_cb_; + PongCallback pong_cb_; SendCompleteCallback send_complete_cb_; bool is_closing_ = false; + //! Ping/Pong 心跳相关 + int ping_interval_ = 0; //! Ping 发送间隔(秒,0=不自动 Ping) + int ping_timeout_ = 0; //! Pong 超时时间(秒,0=不检测超时) + event::TimerEvent *sp_ping_timer_ = nullptr; //! Ping 定时器(周期触发) + event::TimerEvent *sp_pong_timer_ = nullptr; //! Pong 超时定时器(单次触发) + bool is_pong_pending_ = false; //! 发送 Ping 后是否在等待 Pong 回复 + + //! 分片组装相关 + //! 只有接收完整数据帧(fin=true)并进行解压后,才回调业务层 + bool is_fragmenting_ = false; //!< 是否正在接收分片消息 + WsFrame::OpCode fragment_opcode_; //!< 分片消息的原始 opcode(kText 或 kBinary) + bool fragment_need_decompress_ = false; //!< 分片消息是否需要解压 + std::string fragment_buffer_; //!< 分片数据的缓存区 + int cb_level_ = 0; friend class WsServer; diff --git a/modules/websocket/server/ws_server.h b/modules/websocket/server/ws_server.h index ce71e445f119290c4e9294c7252b8f2078a19bd2..b121163da9945af31de06458730ba0bc89937693 100644 --- a/modules/websocket/server/ws_server.h +++ b/modules/websocket/server/ws_server.h @@ -20,13 +20,14 @@ #ifndef TBOX_WS_SERVER_H_20260612 #define TBOX_WS_SERVER_H_20260612 +#include +#include + #include #include #include #include -#include "../ws_frame.h" - namespace tbox { namespace http { namespace server { @@ -42,10 +43,14 @@ namespace server { //! 支持指定 URL 路径(前缀匹配),实现多个 WebSocket 服务挂载于同一 HTTP 服务器 //! 升级后接管 TcpConnection,提供 WebSocket 通信功能 //! 通过 Cabinet 管理 WsConnection 生命期,用户通过 ConnToken 操作连接 +//! 分片消息接收完整后统一解压再回调,使用右值引用提升效率 class WsServer { public: using ConnToken = cabinet::Token; + //! 默认分片发送的最大帧 payload 大小 + static constexpr size_t kDefaultFragmentSize = 65535; + explicit WsServer(event::Loop *wp_loop); ~WsServer(); @@ -59,6 +64,24 @@ class WsServer { //! - url_path_ 不以 '/' 结尾:全量匹配,如 "/api" 仅匹配 "/api" //! - url_path_ 为空字符串:匹配所有 WebSocket 升级请求 bool initialize(http::server::Server *http_server, const std::string &url_path = ""); + + //! 设置是否允许压缩(必须在 initialize 之前调用) + //! 启用后,若客户端请求 permessage-deflate,将在握手响应中同意压缩 + void setCompressionEnable(bool enable); + + //! 设置分片大小(仅影响发送,接收时自动组装;必须在 initialize 之前调用) + //! 默认为 kDefaultFragmentSize (65535) + //! 值为 0 表示不分片(所有数据单帧发送) + void setFragmentSize(size_t size); + + //! 设置 Ping 发送间隔(秒),0=不自动 Ping(默认;必须在 initialize 之前调用) + //! 启用后,每隔指定秒数向客户端发送 Ping 帧 + void setPingInterval(int seconds); + + //! 设置 Pong 超时时间(秒),0=不检测超时(默认;必须在 initialize 之前调用) + //! 发送 Ping 后若在此时间内未收到 Pong,则判定连接断开并关闭 + void setPingTimeout(int seconds); + bool start(); void stop(); void cleanup(); @@ -68,23 +91,27 @@ class WsServer { public: //! 设置回调(所有回调均使用 ConnToken,不暴露 WsConnection 指针) - using ConnectedCallback = std::function; - using DisconnectedCallback = std::function; - using MessageCallback = std::function; - using ErrorCallback = std::function; + using ConnectedCallback = std::function; + using DisconnectedCallback = std::function; + using TextMessageCallback = std::function; + using BinaryMessageCallback = std::function &&)>; + using ErrorCallback = std::function; void setConnectedCallback(const ConnectedCallback &cb); void setDisconnectedCallback(const DisconnectedCallback &cb); - void setMessageCallback(const MessageCallback &cb); + void setTextMessageCallback(const TextMessageCallback &cb); + void setBinaryMessageCallback(const BinaryMessageCallback &cb); void setErrorCallback(const ErrorCallback &cb); public: //! 向指定客户端发送文本数据 bool send(const ConnToken &client, const std::string &text); + //! 向指定客户端发送文本数据(const char* 版本,方便直接传字符串字面量) + bool send(const ConnToken &client, const char *str); //! 向指定客户端发送二进制数据 bool send(const ConnToken &client, const void *data, size_t len); //! 向指定客户端发送二进制数据(vector 版本) - bool sendBinary(const ConnToken &client, const std::vector &data); + bool send(const ConnToken &client, const std::vector &data); //! 关闭指定客户端连接(发送 Close 帧) bool close(const ConnToken &client, uint16_t code = 1000, const std::string &reason = ""); diff --git a/modules/websocket/server/ws_server_impl.cpp b/modules/websocket/server/ws_server_impl.cpp index 38ba7f8ee559763c0a997039716b3f0f46fdc3dc..71fd72c13d543ae4c909521cc9851e879cab2212 100644 --- a/modules/websocket/server/ws_server_impl.cpp +++ b/modules/websocket/server/ws_server_impl.cpp @@ -89,7 +89,8 @@ void WsServer::Impl::stop() //! 清除 WsConnection 内部回调,防止断开时回调到 Impl ws_conns_.foreach([](WsConnection *conn) { conn->setCloseCallback(nullptr); - conn->setMessageCallback(nullptr); + conn->setTextMessageCallback(nullptr); + conn->setBinaryMessageCallback(nullptr); conn->setErrorCallback(nullptr); }); @@ -115,6 +116,127 @@ void WsServer::Impl::cleanup() state_ = WsServer::State::kNone; } +//! === permessage-deflate 扩展协商解析 === + +//! 客户端 Sec-WebSocket-Extensions 头部中 permessage-deflate 扩展的解析结果 +//! RFC 7692 Section 4.1: 扩展参数定义 +struct WsExtOfferParams { + bool found = false; //!< 是否找到 permessage-deflate 扩展 + bool server_no_context_takeover = false; //!< 服务器不保持压缩上下文 + bool client_no_context_takeover = false; //!< 客户端不保持压缩上下文 + bool server_max_window_bits_present = false; //!< 是否包含 server_max_window_bits + int server_max_window_bits = 15; //!< 服务器滑动窗口位数(默认15) + bool client_max_window_bits_present = false; //!< 是否包含 client_max_window_bits + int client_max_window_bits = 0; //!< 客户端滑动窗口位数,0=不带值(支持8~15) +}; + +//! 解析 Sec-WebSocket-Extensions 头部中的 permessage-deflate 扩展参数 +//! 格式示例: "permessage-deflate; client_max_window_bits; server_max_window_bits=15" +//! 多个扩展以逗号分隔: "permessage-deflate; client_max_window_bits, x-other-ext" +static WsExtOfferParams ParseWsExtOffer(const std::string &ext_header) +{ + WsExtOfferParams params; + + //! 找到 permessage-deflate 扩展的起始位置(需完整匹配,非子串) + static const std::string kExtName = "permessage-deflate"; + size_t pos = 0; + while (pos < ext_header.size()) { + size_t found_pos = ext_header.find(kExtName, pos); + if (found_pos == std::string::npos) + break; + + //! 前面应为逗号、空格或字符串开头;后面应为分号、逗号、空格或结尾 + bool valid_prefix = (found_pos == 0) || + (ext_header[found_pos - 1] == ',') || + (ext_header[found_pos - 1] == ' '); + size_t name_end = found_pos + kExtName.size(); + bool valid_suffix = (name_end >= ext_header.size()) || + (ext_header[name_end] == ';') || + (ext_header[name_end] == ',') || + (ext_header[name_end] == ' '); + if (valid_prefix && valid_suffix) { + pos = found_pos; + break; + } + pos = name_end; + } + + if (pos >= ext_header.size()) + return params; + + params.found = true; + + //! 确定参数区域:扩展名之后到下一个扩展(逗号)或字符串结尾 + size_t param_start = pos + kExtName.size(); + size_t comma_pos = ext_header.find(',', param_start); + size_t param_end = (comma_pos != std::string::npos) ? comma_pos : ext_header.size(); + + //! 在参数区域内逐个解析分号分隔的参数 + std::string section = ext_header.substr(param_start, param_end - param_start); + size_t search_pos = 0; + while (search_pos < section.size()) { + size_t semi_pos = section.find(';', search_pos); + if (semi_pos == std::string::npos) + break; + + //! 提取参数文本(跳过分号和空格) + size_t text_start = semi_pos + 1; + while (text_start < section.size() && section[text_start] == ' ') + text_start++; + + //! 找到参数结束位置(下一个分号或区域结尾) + size_t text_end = section.find(';', text_start); + if (text_end == std::string::npos) + text_end = section.size(); + + //! 去掉尾部空格 + while (text_end > text_start && section[text_end - 1] == ' ') + text_end--; + + std::string param_text = section.substr(text_start, text_end - text_start); + if (param_text.empty()) { + search_pos = text_end; + continue; + } + + //! 解析参数名=值 + size_t eq_pos = param_text.find('='); + std::string param_name = (eq_pos != std::string::npos) + ? param_text.substr(0, eq_pos) : param_text; + std::string param_value = (eq_pos != std::string::npos) + ? param_text.substr(eq_pos + 1) : ""; + + //! 去掉参数名尾部空格和参数值首尾空格 + while (!param_name.empty() && param_name.back() == ' ') + param_name.pop_back(); + while (!param_value.empty() && param_value.front() == ' ') + param_value.erase(param_value.begin()); + while (!param_value.empty() && param_value.back() == ' ') + param_value.pop_back(); + + //! 匹配已知参数(RFC 7692 Section 4.1) + if (param_name == "server_no_context_takeover") { + params.server_no_context_takeover = true; + } else if (param_name == "client_no_context_takeover") { + params.client_no_context_takeover = true; + } else if (param_name == "server_max_window_bits") { + params.server_max_window_bits_present = true; + if (!param_value.empty()) + params.server_max_window_bits = std::stoi(param_value); + } else if (param_name == "client_max_window_bits") { + params.client_max_window_bits_present = true; + if (!param_value.empty()) + params.client_max_window_bits = std::stoi(param_value); + else + params.client_max_window_bits = 0; //!< 不带值,表示客户端支持 8~15 + } + + search_pos = text_end; + } + + return params; +} + //! === Middleware 接口实现 === void WsServer::Impl::handle(http::server::ContextSptr sp_ctx, const http::server::NextFunc &next) @@ -168,8 +290,68 @@ void WsServer::Impl::handle(http::server::ContextSptr sp_ctx, const http::server if (key_iter != req.headers.end()) res.headers["Sec-WebSocket-Accept"] = ComputeWsAcceptKey(key_iter->second); + //! RFC 7692:压缩扩展协商 + //! 若 server 允许压缩且客户端请求了 permessage-deflate,同意压缩 + bool compression_agreed = false; + if (compression_config_.enabled) { + auto ext_iter = req.headers.find("Sec-WebSocket-Extensions"); + if (ext_iter != req.headers.end()) { + //! 解析客户端的 permessage-deflate 扩展参数 + WsExtOfferParams offer_params = ParseWsExtOffer(ext_iter->second); + std::string resp_value; + if (offer_params.found) { + //! 构建响应参数: + //! 1) server_no_context_takeover: 服务器每条消息独立压缩,必须声明 + //! 2) client_no_context_takeover: 要求客户端每条消息独立压缩 + //! 3) client_max_window_bits: 若客户端 offered,RFC 7692 MUST 包含 + //! 否则 Chrome 等浏览器会关闭连接(RFC 7692 Section 4.3) + //! 4) server_max_window_bits: 若客户端 offered,可选包含(MAY) + resp_value = "permessage-deflate; server_no_context_takeover; client_no_context_takeover"; + + //! RFC 7692 Section 4.2.2: + //! "If a server received an extension offer containing the client_max_window_bits + //! parameter, the server MUST include the client_max_window_bits parameter + //! in the corresponding extension response." + if (offer_params.client_max_window_bits_present) { + //! 不带值(client_max_window_bits=0)表示客户端支持 8~15 + //! 带值时须 ≤ 客户端 offered 值 + //! 响应值同时须 ≤ 服务器 max_window_bits + int respond_bits = (offer_params.client_max_window_bits == 0) + ? compression_config_.max_window_bits + : std::min(offer_params.client_max_window_bits, compression_config_.max_window_bits); + if (respond_bits < 8) respond_bits = 8; + if (respond_bits > 15) respond_bits = 15; + resp_value += "; client_max_window_bits=" + std::to_string(respond_bits); + } + + //! RFC 7692 Section 4.2.2: + //! server_max_window_bits 为 MAY,非 MUST + //! 此处显式声明,方便客户端明确知道服务器使用的窗口位数 + if (offer_params.server_max_window_bits_present) { + //! 响应值须 ≤ 客户端 offered 值,同时须 ≤ 服务器 max_window_bits + int respond_bits = std::min(offer_params.server_max_window_bits, compression_config_.max_window_bits); + if (respond_bits < 8) respond_bits = 8; + if (respond_bits > 15) respond_bits = 15; + resp_value += "; server_max_window_bits=" + std::to_string(respond_bits); + } + + compression_agreed = true; + LogDbg("ws compression agreed: %s", resp_value.c_str()); + } + if (!resp_value.empty()) + res.headers["Sec-WebSocket-Extensions"] = resp_value; + } + } + //! 注册升级回调:HTTP 服务器发送 101 响应后,将 TcpConnection 交给 WsServer - res.upgrade_cb = std::bind(&WsServer::Impl::onWsUpgrade, this, _1, req.url.path); + //! 同时传递压缩协商结果 + WsCompressionConfig conn_compress_config; + if (compression_agreed) { + conn_compress_config.enabled = true; + conn_compress_config.no_context_takeover = compression_config_.no_context_takeover; + conn_compress_config.max_window_bits = compression_config_.max_window_bits; + } + res.upgrade_cb = std::bind(&WsServer::Impl::onWsUpgrade, this, _1, req.url.path, conn_compress_config); //! 升级请求已处理,不再调用 next() return; @@ -181,21 +363,22 @@ void WsServer::Impl::handle(http::server::ContextSptr sp_ctx, const http::server //! === 升级与连接管理 === -void WsServer::Impl::onWsUpgrade(network::TcpConnection *tcp_conn, const std::string &url_path) +void WsServer::Impl::onWsUpgrade(network::TcpConnection *tcp_conn, const std::string &url_path, + const WsCompressionConfig &compress_config) { RECORD_SCOPE(); LogDbg("ws upgrade: new connection from %s", tcp_conn->peerAddr().toString().c_str()); //! 创建 WsConnection,并存入 Cabinet(直接 alloc 并存入指针) - //! 传入升级时的 URL 路径,供用户后续通过 getUrl() 查询 - //! 注意:这里需要获取升级请求的 URL,但 onWsUpgrade 只拿到 TcpConnection - //! URL 已在 handle() 中记录到 upgrade_cb 的绑定参数中 - WsConnection *ws_conn = new WsConnection(wp_loop_, tcp_conn, url_path); + //! 传入升级时的 URL 路径、压缩配置、分片大小、心跳参数 + WsConnection *ws_conn = new WsConnection(wp_loop_, tcp_conn, url_path, compress_config, + fragment_size_, ping_interval_, ping_timeout_); ConnToken ws_token = ws_conns_.alloc(ws_conn); //! 设置 WsConnection 的回调(bind 捕获 ConnToken,不传递 WsConnection*) ws_conn->setCloseCallback(std::bind(&WsServer::Impl::onWsDisconnected, this, ws_token)); - ws_conn->setMessageCallback(std::bind(&WsServer::Impl::onWsMessage, this, ws_token, _1)); + ws_conn->setTextMessageCallback(std::bind(&WsServer::Impl::onWsTextMessage, this, ws_token, _1)); + ws_conn->setBinaryMessageCallback(std::bind(&WsServer::Impl::onWsBinaryMessage, this, ws_token, _1)); ws_conn->setErrorCallback(std::bind(&WsServer::Impl::onWsError, this, ws_token)); //! 通知用户(传递 ConnToken) @@ -227,11 +410,20 @@ void WsServer::Impl::onWsDisconnected(const ConnToken &client) "WsServer::onWsDisconnected, delete ws_conn"); } -void WsServer::Impl::onWsMessage(const ConnToken &client, const WsFrame &frame) +void WsServer::Impl::onWsTextMessage(const ConnToken &client, std::string &&data) +{ + if (text_message_cb_) { + ++cb_level_; + text_message_cb_(client, std::move(data)); + --cb_level_; + } +} + +void WsServer::Impl::onWsBinaryMessage(const ConnToken &client, std::vector &&data) { - if (message_cb_) { + if (binary_message_cb_) { ++cb_level_; - message_cb_(client, frame); + binary_message_cb_(client, std::move(data)); --cb_level_; } } @@ -260,6 +452,14 @@ bool WsServer::Impl::send(const ConnToken &client, const std::string &text) return false; } +bool WsServer::Impl::send(const ConnToken &client, const char *str) +{ + auto ws_conn = ws_conns_.at(client); + if (ws_conn != nullptr) + return ws_conn->send(str); + return false; +} + bool WsServer::Impl::send(const ConnToken &client, const void *data, size_t len) { auto ws_conn = ws_conns_.at(client); @@ -268,11 +468,11 @@ bool WsServer::Impl::send(const ConnToken &client, const void *data, size_t len) return false; } -bool WsServer::Impl::sendBinary(const ConnToken &client, const std::vector &data) +bool WsServer::Impl::send(const ConnToken &client, const std::vector &data) { auto ws_conn = ws_conns_.at(client); if (ws_conn != nullptr) - return ws_conn->sendBinary(data); + return ws_conn->send(data); return false; } @@ -385,7 +585,10 @@ std::string WsServer::Impl::ComputeWsAcceptKey(const std::string &sec_ws_key) return util::base64::Encode(digest, 20); } -//! === WsServer 外部接口 === +void WsServer::Impl::setCompressionEnable(bool enable) +{ + compression_config_.enabled = enable; +} WsServer::WsServer(event::Loop *wp_loop) : impl_(new Impl(this, wp_loop)) @@ -398,6 +601,26 @@ WsServer::~WsServer() CHECK_DELETE_RESET_OBJ(impl_); } +void WsServer::setCompressionEnable(bool enable) +{ + impl_->setCompressionEnable(enable); +} + +void WsServer::setFragmentSize(size_t size) +{ + impl_->setFragmentSize(size); +} + +void WsServer::setPingInterval(int seconds) +{ + impl_->setPingInterval(seconds); +} + +void WsServer::setPingTimeout(int seconds) +{ + impl_->setPingTimeout(seconds); +} + bool WsServer::initialize(http::server::Server *http_server, const std::string &url_path) { TBOX_ASSERT(http_server != nullptr); @@ -434,9 +657,14 @@ void WsServer::setDisconnectedCallback(const DisconnectedCallback &cb) impl_->setDisconnectedCallback(cb); } -void WsServer::setMessageCallback(const MessageCallback &cb) +void WsServer::setTextMessageCallback(const TextMessageCallback &cb) { - impl_->setMessageCallback(cb); + impl_->setTextMessageCallback(cb); +} + +void WsServer::setBinaryMessageCallback(const BinaryMessageCallback &cb) +{ + impl_->setBinaryMessageCallback(cb); } void WsServer::setErrorCallback(const ErrorCallback &cb) @@ -449,14 +677,19 @@ bool WsServer::send(const ConnToken &client, const std::string &text) return impl_->send(client, text); } +bool WsServer::send(const ConnToken &client, const char *str) +{ + return impl_->send(client, str); +} + bool WsServer::send(const ConnToken &client, const void *data, size_t len) { return impl_->send(client, data, len); } -bool WsServer::sendBinary(const ConnToken &client, const std::vector &data) +bool WsServer::send(const ConnToken &client, const std::vector &data) { - return impl_->sendBinary(client, data); + return impl_->send(client, data); } bool WsServer::close(const ConnToken &client, uint16_t code, const std::string &reason) diff --git a/modules/websocket/server/ws_server_impl.h b/modules/websocket/server/ws_server_impl.h index 5ba079067adb04e6da25efe62846d87ddcbfa7a7..9576a2969fc6953a84a3762cbc5572cae34c58df 100644 --- a/modules/websocket/server/ws_server_impl.h +++ b/modules/websocket/server/ws_server_impl.h @@ -33,6 +33,7 @@ #include "ws_server.h" #include "ws_connection.h" +#include "../ws_compressor.h" namespace tbox { namespace websocket { @@ -55,16 +56,28 @@ class WsServer::Impl : public http::server::Middleware { WsServer::State state() const { return state_; } public: - void setConnectedCallback(const WsServer::ConnectedCallback &cb) { connected_cb_ = cb; } + void setConnectedCallback(const WsServer::ConnectedCallback &cb) { connected_cb_ = cb; } void setDisconnectedCallback(const WsServer::DisconnectedCallback &cb) { disconnected_cb_ = cb; } - void setMessageCallback(const WsServer::MessageCallback &cb) { message_cb_ = cb; } - void setErrorCallback(const WsServer::ErrorCallback &cb) { error_cb_ = cb; } + void setTextMessageCallback(const WsServer::TextMessageCallback &cb) { text_message_cb_ = cb; } + void setBinaryMessageCallback(const WsServer::BinaryMessageCallback &cb) { binary_message_cb_ = cb; } + void setErrorCallback(const WsServer::ErrorCallback &cb) { error_cb_ = cb; } + + //! 压缩配置 + void setCompressionEnable(bool enable); + + //! 分片大小配置 + void setFragmentSize(size_t size) { fragment_size_ = size; } + + //! Ping/Pong 心跳配置 + void setPingInterval(int seconds) { ping_interval_ = seconds; } + void setPingTimeout(int seconds) { ping_timeout_ = seconds; } public: //! 通过 ConnToken 操作连接(转发到 WsConnection) bool send(const ConnToken &client, const std::string &text); + bool send(const ConnToken &client, const char *str); bool send(const ConnToken &client, const void *data, size_t len); - bool sendBinary(const ConnToken &client, const std::vector &data); + bool send(const ConnToken &client, const std::vector &data); bool close(const ConnToken &client, uint16_t code, const std::string &reason); bool ping(const ConnToken &client, const std::string &data); bool pong(const ConnToken &client, const std::string &data); @@ -87,13 +100,17 @@ class WsServer::Impl : public http::server::Middleware { private: //! 当 HTTP 服务器发送 101 响应后回调此函数 - void onWsUpgrade(network::TcpConnection *tcp_conn, const std::string &url_path); + void onWsUpgrade(network::TcpConnection *tcp_conn, const std::string &url_path, + const WsCompressionConfig &compress_config); //! 当 WsConnection 断开时回调(参数为 ConnToken) void onWsDisconnected(const ConnToken &client); - //! 当 WsConnection 收到消息时回调 - void onWsMessage(const ConnToken &client, const WsFrame &frame); + //! 当 WsConnection 收到完整文本消息时回调 + void onWsTextMessage(const ConnToken &client, std::string &&data); + + //! 当 WsConnection 收到完整二进制消息时回调 + void onWsBinaryMessage(const ConnToken &client, std::vector &&data); //! 当 WsConnection 出错时回调 void onWsError(const ConnToken &client); @@ -112,6 +129,16 @@ class WsServer::Impl : public http::server::Middleware { //! 中间件 token(由 HTTP Server 的 use() 返回,用于 unuse() 反注册) http::server::MiddlewareToken mw_token_; + //! 压缩配置 + WsCompressionConfig compression_config_; + + //! 分片发送的最大帧 payload 大小(可配置,默认 kDefaultFragmentSize) + size_t fragment_size_ = WsServer::kDefaultFragmentSize; + + //! Ping/Pong 心跳参数 + int ping_interval_ = 0; + int ping_timeout_ = 0; + //! WsConnection 容器(生命期管理) cabinet::Cabinet ws_conns_; @@ -119,7 +146,8 @@ class WsServer::Impl : public http::server::Middleware { WsServer::ConnectedCallback connected_cb_; WsServer::DisconnectedCallback disconnected_cb_; - WsServer::MessageCallback message_cb_; + WsServer::TextMessageCallback text_message_cb_; + WsServer::BinaryMessageCallback binary_message_cb_; WsServer::ErrorCallback error_cb_; int cb_level_ = 0; diff --git a/modules/websocket/ws_compressor.cpp b/modules/websocket/ws_compressor.cpp new file mode 100644 index 0000000000000000000000000000000000000000..8f4699fbf82fec2c0ad691604ef3e5b9f433dc2a --- /dev/null +++ b/modules/websocket/ws_compressor.cpp @@ -0,0 +1,198 @@ +/* + * .============. + * // M A K E / \ + * // C++ DEV / \ + * // E A S Y / \/ \ + * ++ ----------. \/\ . + * \\ \ \ /\ / + * \\ \ \ / + * \\ \ \ / + * -============' + * + * Copyright (c) 2026 Hevake and contributors, all rights reserved. + * + * This file is part of cpp-tbox (https://github.com/cpp-main/cpp-tbox) + * Use of this source code is governed by MIT license that can be found + * in the LICENSE file in the root of the project source tree. All contributing + * project authors may be found in the CONTRIBUTORS.md file in the root + * of the source tree. + */ +#include "ws_compressor.h" + +#include +#include + +#include + +namespace tbox { +namespace websocket { + +//! RFC 7692 要求去除的 DEFLATE 尾部:0x00 0x00 0xFF 0xFF +static const uint8_t kDeflateTail[4] = {0x00, 0x00, 0xFF, 0xFF}; + +WsCompressor::WsCompressor() +{ } + +WsCompressor::~WsCompressor() +{ + reset(); +} + +bool WsCompressor::initialize(const WsCompressionConfig &config) +{ + if (!config.isValid()) { + LogErr("invalid compression config"); + return false; + } + + config_ = config; + initialized_ = true; + return true; +} + +void WsCompressor::reset() +{ + //! no_context_takeover 模式不需要持久化 zlib 上下文 + //! 每次调用 compress/decompress 时各自初始化并结束 zlib stream + initialized_ = false; +} + +//! === compress === + +std::string WsCompressor::compress(const std::string &data) +{ + return compress(data.data(), data.size()); +} + +std::string WsCompressor::compress(const void *data_ptr, size_t data_size) +{ + if (!initialized_ || !config_.enabled) + return ""; + + //! no_context_takeover:每次消息独立压缩 + z_stream strm; + memset(&strm, 0, sizeof(strm)); + + //! 初始化 deflate,使用 raw deflate(不写 zlib/gzip 头) + //! window_bits 取负值表示 raw deflate,值的绝对值为窗口位数 + int ret = deflateInit2(&strm, Z_DEFAULT_COMPRESSION, Z_DEFLATED, + -config_.max_window_bits, 8, Z_DEFAULT_STRATEGY); + if (ret != Z_OK) { + LogErr("deflateInit2 fail, ret=%d", ret); + return ""; + } + + //! 设置输入数据 + strm.next_in = reinterpret_cast(const_cast(data_ptr)); + strm.avail_in = static_cast(data_size); + + //! 输出缓冲区:压缩后可能比原始数据更大(如随机数据),预留足够空间 + //! 注意:deflateBound 不包含 Z_SYNC_FLUSH 的空存储块开销(5字节) + //! 实测 deflateBound 比 Z_SYNC_FLUSH 完整输出少约3字节,须额外预留 + size_t max_out = deflateBound(&strm, static_cast(data_size)) + 6; + std::string output; + output.resize(max_out); + + strm.next_out = reinterpret_cast(&output[0]); + strm.avail_out = static_cast(max_out); + + //! 执行压缩 + ret = deflate(&strm, Z_SYNC_FLUSH); + if (ret != Z_OK) { + LogErr("deflate fail, ret=%d", ret); + deflateEnd(&strm); + return ""; + } + + //! 计算实际输出大小 + size_t out_len = max_out - strm.avail_out; + + //! 去掉 4 字节尾部 0x00 0x00 0xFF 0xFF(RFC 7692 Section 7.2.2) + //! Z_SYNC_FLUSH 在末尾追加空存储块:头部(0x00) + LEN(0x00 0x00) + NLEN(0xFF 0xFF) + //! 此处仅去掉 LEN+NLEN 共4字节,保留头部字节0x00——与浏览器实现一致 + //! 解压时追加回这4字节即可还原完整空存储块 + if (out_len >= 4 && + memcmp(reinterpret_cast(&output[out_len - 4]), kDeflateTail, 4) == 0) { + out_len -= 4; + } + + deflateEnd(&strm); + + output.resize(out_len); + return output; +} + +//! === decompress === + +std::string WsCompressor::decompress(const std::string &data) +{ + return decompress(data.data(), data.size()); +} + +std::string WsCompressor::decompress(const void *data_ptr, size_t data_size) +{ + if (!initialized_ || !config_.enabled) + return ""; + + //! no_context_takeover:每次消息独立解压 + z_stream strm; + memset(&strm, 0, sizeof(strm)); + + //! 初始化 inflate,使用 raw inflate(不读 zlib/gzip 头) + //! window_bits 取负值表示 raw inflate + int ret = inflateInit2(&strm, -config_.max_window_bits); + if (ret != Z_OK) { + LogErr("inflateInit2 fail, ret=%d", ret); + return ""; + } + + //! RFC 7692 Section 7.2.2:解压前须在数据末尾加回 4 字节尾部 + //! 将原始数据 + 4字节尾部拼入一个临时缓冲区,避免修改原始数据 + size_t input_len = data_size + 4; + std::string input; + input.resize(input_len); + memcpy(&input[0], data_ptr, data_size); + memcpy(&input[data_size], kDeflateTail, 4); + + //! 输出缓冲区:预估解压后大小 + //! 解压后通常比压缩数据大,预估为输入的 4 倍,不够时动态扩容 + std::string output; + size_t out_capacity = input_len * 4; + if (out_capacity < 256) + out_capacity = 256; + output.resize(out_capacity); + + strm.next_in = reinterpret_cast(&input[0]); + strm.avail_in = static_cast(input_len); + + size_t total_out = 0; + + do { + strm.next_out = reinterpret_cast(&output[total_out]); + strm.avail_out = static_cast(out_capacity - total_out); + + ret = inflate(&strm, Z_SYNC_FLUSH); + + if (ret == Z_STREAM_ERROR || ret == Z_DATA_ERROR || ret == Z_MEM_ERROR) { + LogErr("inflate fail, ret=%d", ret); + inflateEnd(&strm); + return ""; + } + + total_out = strm.total_out; + + //! 输出缓冲区不够大时扩容 + if (strm.avail_out == 0 && ret != Z_STREAM_END) { + out_capacity *= 2; + output.resize(out_capacity); + } + } while (ret != Z_STREAM_END && strm.avail_in > 0); + + inflateEnd(&strm); + + output.resize(total_out); + return output; +} + +} +} diff --git a/modules/websocket/ws_compressor.h b/modules/websocket/ws_compressor.h new file mode 100644 index 0000000000000000000000000000000000000000..d2c7acdf95ee775ef9a54d8b35cecc30ebf59b49 --- /dev/null +++ b/modules/websocket/ws_compressor.h @@ -0,0 +1,91 @@ +/* + * .============. + * // M A K E / \ + * // C++ DEV / \ + * // E A S Y / \/ \ + * ++ ----------. \/\ . + * \\ \ \ /\ / + * \\ \ \ / + * \\ \ \ / + * -============' + * + * Copyright (c) 2026 Hevake and contributors, all rights reserved. + * + * This file is part of cpp-tbox (https://github.com/cpp-main/cpp-tbox) + * Use of this source code is governed by MIT license that can be found + * in the LICENSE file in the root of the project source tree. All contributing + * project authors may be found in the CONTRIBUTORS.md file in the root + * of the source tree. + */ +#ifndef TBOX_WS_COMPRESSOR_H_20260708 +#define TBOX_WS_COMPRESSOR_H_20260708 + +#include +#include + +namespace tbox { +namespace websocket { + +//! WebSocket 压缩配置(RFC 7692 permessage-deflate) +struct WsCompressionConfig { + bool enabled = false; //!< 是否启用压缩 + bool no_context_takeover = true; //!< 是否不保留压缩上下文(每次消息独立) + int max_window_bits = 15; //!< 最大窗口位数 (8~15) + + //! 检查配置是否有效 + bool isValid() const { + if (!enabled) + return true; + if (max_window_bits < 8 || max_window_bits > 15) + return false; + return true; + } +}; + +//! WebSocket 帧压缩/解压缩器(RFC 7692 permessage-deflate) +//! +//! RFC 7692 关键规则: +//! - 使用 DEFLATE (zlib),压缩后数据须去掉 4 字节尾 0x00 0x00 0xFF 0xFF +//! - 解压前须将 4 字节尾加回 +//! - 控制帧(Close/Ping/Pong)永远不压缩 +//! - no_context_takeover=true 时,每条消息独立压缩/解压(不跨消息保持 zlib 上下文) +class WsCompressor { + public: + WsCompressor(); + ~WsCompressor(); + + //! 初始化压缩器(须在使用前调用) + bool initialize(const WsCompressionConfig &config); + + //! 重置压缩器内部状态 + void reset(); + + //! 压缩数据(去掉 4 字节尾 0x00 0x00 0xFF 0xFF) + //! 成功返回压缩后数据,失败返回空字符串 + //! 控制帧不应调用此方法 + std::string compress(const std::string &data); + //! 直接接受原始指针与长度,避免二进制数据构造 std::string 的额外拷贝 + std::string compress(const void *data_ptr, size_t data_size); + + //! 解压数据(先加回 4 字节尾再解压) + //! 成功返回解压后数据,失败返回空字符串 + //! 仅对 RSV1=1 的数据帧调用此方法 + std::string decompress(const std::string &data); + //! 直接接受原始指针与长度,避免二进制数据构造 std::string 的额外拷贝 + std::string decompress(const void *data_ptr, size_t data_size); + + //! 是否已初始化 + bool isInitialized() const { return initialized_; } + + //! 获取配置 + const WsCompressionConfig& config() const { return config_; } + + private: + WsCompressionConfig config_; + bool initialized_ = false; +}; + +} +} + +#endif //TBOX_WS_COMPRESSOR_H_20260708 diff --git a/modules/websocket/ws_compressor_test.cpp b/modules/websocket/ws_compressor_test.cpp new file mode 100644 index 0000000000000000000000000000000000000000..0807f63a13542876bddfbabf57cc4fce47d4a19b --- /dev/null +++ b/modules/websocket/ws_compressor_test.cpp @@ -0,0 +1,296 @@ +/* + * .============. + * // M A K E / \ + * // C++ DEV / \ + * // E A S Y / \/ \ + * ++ ----------. \/\ . + * \\ \ \ /\ / + * \\ \ \ / + * \\ \ \ / + * -============' + * + * Copyright (c) 2026 Hevake and contributors, all rights reserved. + * + * This file is part of cpp-tbox (https://github.com/cpp-main/cpp-tbox) + * Use of this source code is governed by MIT license that can be found + * in the LICENSE file in the root of the project source tree. All contributing + * project authors may be found in the CONTRIBUTORS.md file in the root + * of the source tree. + */ +#include + +#include "ws_compressor.h" +#include "ws_frame_builder.h" +#include "ws_frame_parser.h" + +namespace tbox { +namespace websocket { + +//! === WsCompressor 测试 === + +TEST(WsCompressor, CompressDecompressRoundTrip) +{ + WsCompressionConfig config; + config.enabled = true; + + WsCompressor compressor; + ASSERT_TRUE(compressor.initialize(config)); + + //! 压缩 → 解压 → 验证 + std::string original = "Hello, WebSocket permessage-deflate! This is a test message."; + std::string compressed = compressor.compress(original); + ASSERT_FALSE(compressed.empty()); + + //! 压缩后应该比原数据短(对重复性文本) + EXPECT_LT(compressed.size(), original.size()); + + std::string decompressed = compressor.decompress(compressed); + EXPECT_EQ(original, decompressed); +} + +TEST(WsCompressor, CompressDecompressLargeData) +{ + WsCompressionConfig config; + config.enabled = true; + + WsCompressor compressor; + ASSERT_TRUE(compressor.initialize(config)); + + //! 大数据测试 + std::string original(10000, 'A'); + std::string compressed = compressor.compress(original); + ASSERT_FALSE(compressed.empty()); + EXPECT_LT(compressed.size(), original.size()); + + std::string decompressed = compressor.decompress(compressed); + EXPECT_EQ(original, decompressed); +} + +TEST(WsCompressor, CompressDecompressEmptyData) +{ + WsCompressionConfig config; + config.enabled = true; + + WsCompressor compressor; + ASSERT_TRUE(compressor.initialize(config)); + + //! 空数据:压缩应该返回空(或不压缩的数据) + std::string original = ""; + std::string compressed = compressor.compress(original); + //! 空数据压缩后可能有少量数据(zlib 头信息) + //! 但解压后应恢复为空 + if (!compressed.empty()) { + std::string decompressed = compressor.decompress(compressed); + EXPECT_EQ(original, decompressed); + } +} + +TEST(WsCompressor, NoContextTakeover) +{ + WsCompressionConfig config; + config.enabled = true; + config.no_context_takeover = true; + + WsCompressor compressor; + ASSERT_TRUE(compressor.initialize(config)); + + //! 连续压缩多条不同消息,每条独立 + std::string msg1 = "First message with some repeated words words words"; + std::string msg2 = "Second message with different content xyz xyz xyz"; + std::string msg3 = "Third message 1234567890"; + + std::string c1 = compressor.compress(msg1); + std::string c2 = compressor.compress(msg2); + std::string c3 = compressor.compress(msg3); + + ASSERT_FALSE(c1.empty()); + ASSERT_FALSE(c2.empty()); + ASSERT_FALSE(c3.empty()); + + EXPECT_EQ(msg1, compressor.decompress(c1)); + EXPECT_EQ(msg2, compressor.decompress(c2)); + EXPECT_EQ(msg3, compressor.decompress(c3)); +} + +TEST(WsCompressor, CompressDecompressRecompressSymmetry) +{ + //! 验证:compress(data) → decompress → compress 应产生相同结果 + //! 这是 echo 服务器的核心场景:收到客户端压缩数据 → 解压 → 再压缩发回 + //! 如果 compress 输出不一致,客户端将无法正确解压服务端的回传数据 + + WsCompressionConfig config; + config.enabled = true; + config.no_context_takeover = true; + config.max_window_bits = 15; + + WsCompressor compressor; + ASSERT_TRUE(compressor.initialize(config)); + + //! 测试多种数据长度,特别覆盖 16、256、1024 等典型 WebSocket 帧大小 + std::vector test_sizes = {16, 32, 64, 128, 256, 512, 1024}; + + for (size_t size : test_sizes) { + //! 生成随机二进制数据(模拟浏览器发送的随机 payload) + std::string original(size, '\0'); + for (size_t i = 0; i < size; i++) + original[i] = static_cast(rand() % 256); + + //! 第1步:压缩原始数据,得 data1 + std::string data1 = compressor.compress(original); + ASSERT_FALSE(data1.empty()) << "compress failed for size=" << size; + + //! 第2步:解压 data1,还原原始数据 + std::string decompressed = compressor.decompress(data1); + ASSERT_EQ(original.size(), decompressed.size()) << "decompress size mismatch for size=" << size; + ASSERT_EQ(original, decompressed) << "decompress content mismatch for size=" << size; + + //! 第3步:将解压后的数据再次压缩,得 data2 + std::string data2 = compressor.compress(decompressed); + ASSERT_FALSE(data2.empty()) << "recompress failed for size=" << size; + + //! 第4步:data1 与 data2 应完全一致(相同输入 + 相同参数 = 相同输出) + EXPECT_EQ(data1.size(), data2.size()) + << "compressed size mismatch: data1=" << data1.size() << ", data2=" << data2.size() + << " for original size=" << size; + EXPECT_EQ(data1, data2) + << "compressed content mismatch for original size=" << size; + } +} + +TEST(WsCompressor, DisabledCompression) +{ + WsCompressionConfig config; + config.enabled = false; + + WsCompressor compressor; + ASSERT_TRUE(compressor.initialize(config)); + + //! 禁用时 compress/decompress 返回空 + std::string data = "test data"; + EXPECT_EQ("", compressor.compress(data)); + EXPECT_EQ("", compressor.decompress(data)); +} + +//! === RSV1 帧解析测试 === + +TEST(WsFrameParser, Rsv1CompressedTextFrame) +{ + //! RSV1=1 的文本帧(压缩帧首帧) + //! 第1字节: FIN=1, RSV1=1, opcode=0x01(text) = 0xC1 + //! 第2字节: MASK=0, len=5 = 0x05 + //! Payload: 5 字节压缩数据 + uint8_t data[] = {0xC1, 0x05, 0x48, 0x65, 0x6C, 0x6C, 0x6F}; + WsFrameParser parser; + size_t consumed = parser.parse(data, sizeof(data)); + + EXPECT_EQ(sizeof(data), consumed); + EXPECT_EQ(WsFrameParser::State::kFinished, parser.state()); + + WsFrame *frame = parser.getFrame(); + ASSERT_NE(frame, nullptr); + EXPECT_TRUE(frame->fin); + EXPECT_TRUE(frame->rsv1); //! RSV1 应为 true + EXPECT_EQ(WsFrame::OpCode::kText, frame->opcode); + delete frame; +} + +TEST(WsFrameParser, Rsv2Rejected) +{ + //! RSV2=1 的帧应报错(目前不支持 RSV2) + //! 第1字节: FIN=1, RSV2=1, opcode=0x01 = 0xA1 + uint8_t data[] = {0xA1, 0x05, 'H', 'e', 'l', 'l', 'o'}; + WsFrameParser parser; + parser.parse(data, sizeof(data)); + + EXPECT_EQ(WsFrameParser::State::kError, parser.state()); +} + +//! === RSV1 帧构建测试 === + +TEST(WsFrameBuilder, CompressedTextFrameServer) +{ + //! 服务端压缩帧:RSV1=1,不掩码 + auto frame = WsFrameBuilder::BuildFrame(WsFrame::OpCode::kText, true, "Hello", 5, true); + EXPECT_EQ(0xC1, frame[0]); //! FIN + RSV1 + text opcode +} + +TEST(WsFrameBuilder, CompressedTextFrameClient) +{ + //! 客户端压缩帧:RSV1=1,掩码 + uint8_t mask_key[4] = {0x37, 0xfa, 0x21, 0x3d}; + auto frame = WsFrameBuilder::BuildMaskedFrame(WsFrame::OpCode::kText, true, "Hello", 5, mask_key, true); + //! 第1字节: FIN + RSV1 + text opcode = 0xC1 + EXPECT_EQ(0xC1, frame[0]); + //! 第2字节: MASK=1 + len=5 = 0x85 + EXPECT_EQ(0x85, frame[1]); +} + +//! === 压缩帧 roundtrip 测试 === + +TEST(WsCompressor, CompressedFrameRoundTripServer) +{ + //! 模拟服务端发送压缩帧 → 客户端解析并解压 + WsCompressionConfig config; + config.enabled = true; + + WsCompressor compressor; + ASSERT_TRUE(compressor.initialize(config)); + + std::string original = "Hello WebSocket compression!"; + std::string compressed = compressor.compress(original); + ASSERT_FALSE(compressed.empty()); + + //! 服务端构建 RSV1=1 的帧(不掩码) + auto frame = WsFrameBuilder::BuildFrame(WsFrame::OpCode::kText, true, compressed.data(), compressed.size(), true); + + //! 客户端解析帧 + WsFrameParser parser; + parser.parse(frame.data(), frame.size()); + EXPECT_EQ(WsFrameParser::State::kFinished, parser.state()); + + WsFrame *parsed = parser.getFrame(); + ASSERT_NE(parsed, nullptr); + EXPECT_TRUE(parsed->rsv1); + EXPECT_EQ(WsFrame::OpCode::kText, parsed->opcode); + + //! 解压 payload + std::string decompressed = compressor.decompress(parsed->payload); + EXPECT_EQ(original, decompressed); + delete parsed; +} + +TEST(WsCompressor, CompressedFrameRoundTripClient) +{ + //! 模拟客户端发送压缩帧 → 服务端解析并解压 + WsCompressionConfig config; + config.enabled = true; + + WsCompressor compressor; + ASSERT_TRUE(compressor.initialize(config)); + + std::string original = "Client compressed message!"; + std::string compressed = compressor.compress(original); + ASSERT_FALSE(compressed.empty()); + + //! 客户端构建 RSV1=1 的掩码帧 + auto frame = WsFrameBuilder::BuildMaskedFrame(WsFrame::OpCode::kText, true, + compressed.data(), compressed.size(), + nullptr, true); + //! 服务端解析帧 + WsFrameParser parser; + parser.parse(frame.data(), frame.size()); + EXPECT_EQ(WsFrameParser::State::kFinished, parser.state()); + + WsFrame *parsed = parser.getFrame(); + ASSERT_NE(parsed, nullptr); + EXPECT_TRUE(parsed->rsv1); + EXPECT_EQ(WsFrame::OpCode::kText, parsed->opcode); + + //! 解压 payload + std::string decompressed = compressor.decompress(parsed->payload); + EXPECT_EQ(original, decompressed); + delete parsed; +} + +} +} diff --git a/modules/websocket/ws_frame.h b/modules/websocket/ws_frame.h index 3ef01140de3544589dd65fea26c289c0818154c6..12d2df6dc4fd782620fbf755c6d6458c6c025fe8 100644 --- a/modules/websocket/ws_frame.h +++ b/modules/websocket/ws_frame.h @@ -40,6 +40,7 @@ struct WsFrame { OpCode opcode = OpCode::kContinue; bool fin = true; //!< 是否为最后一帧 + bool rsv1 = false; //!< RSV1 位(压缩帧首帧为 true,RFC 7692) std::string payload; //!< 负载数据 //! 是否为控制帧(Close/Ping/Pong) diff --git a/modules/websocket/ws_frame_builder.cpp b/modules/websocket/ws_frame_builder.cpp index 6226450f7b4b626cf44f26f415b33dfd2d62d5c0..3b48388725894fd741c959df9ffa4e357c28fbe9 100644 --- a/modules/websocket/ws_frame_builder.cpp +++ b/modules/websocket/ws_frame_builder.cpp @@ -64,19 +64,22 @@ std::vector WsFrameBuilder::BuildPongFrame(const std::string &data) return BuildFrame(WsFrame::OpCode::kPong, true, data.data(), data.size()); } -std::vector WsFrameBuilder::BuildFrame(WsFrame::OpCode opcode, bool fin, const void *payload_ptr, size_t payload_len) +std::vector WsFrameBuilder::BuildFrame(WsFrame::OpCode opcode, bool fin, const void *payload_ptr, size_t payload_len, bool rsv1) { std::vector frame; - //! 第1字节:FIN + RSV1-3(0) + Opcode + //! 第1字节:FIN + RSV1(permessage-deflate) + RSV2-3(0) + Opcode uint8_t byte0 = static_cast(opcode); if (fin) byte0 |= 0x80; + if (rsv1) + byte0 |= 0x40; frame.push_back(byte0); //! 第2字节:MASK=0(服务端不掩码) + Payload length //! 服务端发送的帧不使用掩码(RFC 6455 Section 5.3) - if (payload_len < 125) { + //! RFC 6455 Section 5.2:payload_len 0~125 用 7-bit,126~65535 用 16-bit,>65535 用 64-bit + if (payload_len <= 125) { frame.push_back(static_cast(payload_len)); } else if (payload_len <= 65535) { frame.push_back(126); @@ -137,7 +140,7 @@ std::vector WsFrameBuilder::BuildMaskedPongFrame(const std::string &dat std::vector WsFrameBuilder::BuildMaskedFrame(WsFrame::OpCode opcode, bool fin, const void *payload_ptr, size_t payload_len, - const uint8_t *mask_key) + const uint8_t *mask_key, bool rsv1) { std::vector frame; @@ -153,14 +156,17 @@ std::vector WsFrameBuilder::BuildMaskedFrame(WsFrame::OpCode opcode, bo mk[3] = static_cast(rand() & 0xFF); } - //! 第1字节:FIN + RSV1-3(0) + Opcode + //! 第1字节:FIN + RSV1(permessage-deflate) + RSV2-3(0) + Opcode uint8_t byte0 = static_cast(opcode); if (fin) byte0 |= 0x80; + if (rsv1) + byte0 |= 0x40; frame.push_back(byte0); //! 第2字节:MASK=1(客户端必须掩码) + Payload length - if (payload_len < 125) { + //! RFC 6455 Section 5.2:payload_len 0~125 用 7-bit,126~65535 用 16-bit,>65535 用 64-bit + if (payload_len <= 125) { frame.push_back(static_cast(0x80 | payload_len)); } else if (payload_len <= 65535) { frame.push_back(0x80 | 126); diff --git a/modules/websocket/ws_frame_builder.h b/modules/websocket/ws_frame_builder.h index 69fd76136d1fe41aa218d62b0d6d630ce782ec94..720dfa5d9b7bafef09c2c4235148dffb4b20df35 100644 --- a/modules/websocket/ws_frame_builder.h +++ b/modules/websocket/ws_frame_builder.h @@ -51,7 +51,10 @@ class WsFrameBuilder { static std::vector BuildPongFrame(const std::string &data = ""); //! 通用帧构建(服务端,不掩码) - static std::vector BuildFrame(WsFrame::OpCode opcode, bool fin, const void *payload, size_t payload_len); + //! rsv1 为 true 时设置 RSV1 位(用于 permessage-deflate 压缩帧) + static std::vector BuildFrame(WsFrame::OpCode opcode, bool fin, + const void *payload, size_t payload_len, + bool rsv1 = false); //! === 客户端帧(掩码) === //! RFC 6455 Section 5.3:客户端发送的帧必须使用掩码 @@ -74,9 +77,11 @@ class WsFrameBuilder { //! 通用帧构建(客户端,掩码) //! mask_key 为 4 字节掩码密钥,若为 nullptr 则自动随机生成 + //! rsv1 为 true 时设置 RSV1 位(用于 permessage-deflate 压缩帧) static std::vector BuildMaskedFrame(WsFrame::OpCode opcode, bool fin, const void *payload, size_t payload_len, - const uint8_t *mask_key = nullptr); + const uint8_t *mask_key = nullptr, + bool rsv1 = false); }; } diff --git a/modules/websocket/ws_frame_parser.cpp b/modules/websocket/ws_frame_parser.cpp index 4ce6cab1df0fe73a0af035c66dad9c1bcbe9950d..69c8c3c6ed13f423695e050c14e9959e9a705467 100644 --- a/modules/websocket/ws_frame_parser.cpp +++ b/modules/websocket/ws_frame_parser.cpp @@ -46,14 +46,16 @@ size_t WsFrameParser::parse(const void *data_ptr, size_t data_size) case State::kInit: { //! 第1字节:FIN + RSV1-3 + Opcode fin_ = (p[0] >> 7) & 1; - opcode_ = p[0] & 0x0F; + rsv1_ = (p[0] >> 6) & 1; - //! 检查:RSV1-3 必须为0(除非扩展协商) - if ((p[0] & 0x70) != 0) { + //! 检查:RSV2/RSV3 必须为0(目前仅支持 RSV1 用于 permessage-deflate) + if ((p[0] & 0x30) != 0) { state_ = State::kError; return consumed; } + opcode_ = p[0] & 0x0F; + ++p; --remaining; ++consumed; state_ = State::kHeader2Bytes; break; @@ -61,15 +63,27 @@ size_t WsFrameParser::parse(const void *data_ptr, size_t data_size) case State::kHeader2Bytes: { //! 第2字节:MASK + Payload length (7 bits) + //! RFC 6455 Section 5.2:len7 0~125 为 7-bit 长度,126 为 16-bit,127 为 64-bit masked_ = (p[0] >> 7) & 1; uint8_t len7 = p[0] & 0x7F; - if (len7 < 125) { + if (len7 <= 125) { payload_len_ = len7; ++p; --remaining; ++consumed; - state_ = masked_ ? State::kMaskKey : State::kPayload; payload_.clear(); payload_received_ = 0; + + if (payload_len_ == 0 && !masked_) { + //! 无负载,也无mask,创建 WsFrame + sp_frame_ = new WsFrame; + sp_frame_->fin = fin_; + sp_frame_->rsv1 = rsv1_; + sp_frame_->opcode = static_cast(opcode_); + sp_frame_->payload = std::move(payload_); + state_ = State::kFinished; + return consumed; + } + state_ = masked_ ? State::kMaskKey : State::kPayload; } else if (len7 == 126) { payload_len_ = 0; //! 待读取16位长度 ++p; --remaining; ++consumed; @@ -91,8 +105,8 @@ size_t WsFrameParser::parse(const void *data_ptr, size_t data_size) | static_cast(p[1]); p += 2; remaining -= 2; consumed += 2; - //! 16位长度必须 >= 125 - if (payload_len_ < 125) { + //! RFC 6455 Section 5.2:16位扩展长度必须 >= 126 + if (payload_len_ <= 125) { state_ = State::kError; return consumed; } @@ -141,6 +155,18 @@ size_t WsFrameParser::parse(const void *data_ptr, size_t data_size) memcpy(mask_key_, p, 4); p += 4; remaining -= 4; consumed += 4; + + if (payload_len_ == 0) { + //! 无负载,创建 WsFrame + sp_frame_ = new WsFrame; + sp_frame_->fin = fin_; + sp_frame_->rsv1 = rsv1_; + sp_frame_->opcode = static_cast(opcode_); + sp_frame_->payload = std::move(payload_); + state_ = State::kFinished; + return consumed; + } + state_ = State::kPayload; break; } @@ -167,6 +193,7 @@ size_t WsFrameParser::parse(const void *data_ptr, size_t data_size) //! 帧完整,创建 WsFrame sp_frame_ = new WsFrame; sp_frame_->fin = fin_; + sp_frame_->rsv1 = rsv1_; sp_frame_->opcode = static_cast(opcode_); sp_frame_->payload = std::move(payload_); state_ = State::kFinished; diff --git a/modules/websocket/ws_frame_parser.h b/modules/websocket/ws_frame_parser.h index f99dcf8f0d002c43275ac6fdf227c0afed8bf564..710ce6799f87423688ec780f34678c449a30d820 100644 --- a/modules/websocket/ws_frame_parser.h +++ b/modules/websocket/ws_frame_parser.h @@ -62,6 +62,7 @@ class WsFrameParser { //! 当前帧的头部信息 bool fin_; + bool rsv1_; //!< RSV1 位(permessage-deflate 压缩帧标记) uint8_t opcode_; bool masked_; uint64_t payload_len_; diff --git a/version.mk b/version.mk index 1c300acd7f08b379842d4898892b289fd71846c7..de4d8f8ec652d554c56707e805ccaac19b427c00 100644 --- a/version.mk +++ b/version.mk @@ -21,4 +21,4 @@ # TBOX版本号 TBOX_VERSION_MAJOR := 1 TBOX_VERSION_MINOR := 15 -TBOX_VERSION_REVISION := 5 +TBOX_VERSION_REVISION := 6