从实习开始我就接触 WebSocket 了。那时候项目用 Spring 自带的支持,照着文档配一配就能跑,ping/pong 心跳、断线重连,大概知道是怎么回事,但从没想过底层是怎么实现的。
后来换到 Netty,再后来接触了 .NET 的 SignalR,框架换了一个又一个,WebSocket 这块始终是拿来就用,从来没往深处想过。但有一个问题一直隐隐在脑子里:HTTP 和 WebSocket 共用同一个端口,一个普通的 HTTP 请求是怎么"变成" WebSocket 长连接的? 网上的技术文章讲这个要么一笔带过,要么只讲概念不讲细节。
趁着写这个框架,我把这块从头实现了一遍。握手、帧解析、连接管理全是手写的,没用任何现成库。实现完之后那个问题才算真正搞清楚了——背后是一次精心设计的协议升级、一套紧凑的二进制帧格式,以及框架层和业务层之间控制权的转移。
本篇是手写 C++ Web 服务器系列第六篇,完整代码:https://github.com/Fall-Rain/asio_web_service
一、WebSocket 与 HTTP 的关系
很多人以为 WebSocket 和 HTTP 是两套完全独立的协议。实际上,WebSocket 连接的建立必须通过一次 HTTP 请求来完成,这一步叫做协议升级(Protocol Upgrade)。升级完成之后,双方才切换到 WebSocket 的帧格式进行通信。
整体流程:
客户端 ──► HTTP Upgrade 请求 ──► 服务器
客户端 ◄── 101 Switching Protocols ◄── 服务器
(握手完成,TCP 连接保持)
客户端 ◄──► WebSocket Frame ◄──► 服务器
也就是说,握手之前走的是 HTTP,握手之后同一条 TCP 连接就"变身"成了 WebSocket 长连接。服务器不需要开额外的端口,复用的就是 8090 那个。
二、握手:协议升级的详细过程
2.1 客户端发送 Upgrade 请求
客户端(浏览器)发来的请求长这样:
GET /chat HTTP/1.1
Host: localhost:8090
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
几个关键字段的含义:
Upgrade: websocket:告诉服务器,我想升级成 WebSocketConnection: Upgrade:这条连接需要做协议升级Sec-WebSocket-Key:一个随机生成的 Base64 字符串,用来验证服务器是否真正支持 WebSocketSec-WebSocket-Version: 13:协议版本,目前固定是 13
2.2 服务器验证请求合法性
框架里,is_websocket_request() 负责校验这次请求是否是合法的 WebSocket 升级请求:
bool connection::is_websocket_request() {
// WebSocket 握手必须是 GET 请求
if (request.method != HttpMethod::GET) {
return false;
}
// 必须包含这四个头
if (request.headers.find("Upgrade") == request.headers.end()
|| request.headers.find("Connection") == request.headers.end()
|| request.headers.find("Sec-WebSocket-Key") == request.headers.end()
|| request.headers.find("Sec-WebSocket-Version") == request.headers.end()) {
return false;
}
auto upgrade = request.headers.find("Upgrade")->second;
boost::to_lower(upgrade);
// Upgrade 头必须是 websocket(忽略大小写)
if (upgrade != "websocket") {
return false;
}
// 版本必须是 13
if (version != "13") {
return false;
}
is_upgrade_to_websocket = true;
return true;
}
这里做了大小写忽略处理(boost::to_lower),因为 HTTP 头的值是大小写不敏感的,有的客户端可能发 WebSocket 而不是 websocket。
2.3 计算 Sec-WebSocket-Accept
验证通过之后,服务器需要返回 101,并带上 Sec-WebSocket-Accept 响应头。这个值是怎么算的?
RFC 6455 规定了一个固定的魔法字符串(Magic GUID):
258EAFA5-E914-47DA-95CA-C5AB0DC85B11
计算步骤:
- 把客户端传来的
Sec-WebSocket-Key拼上这个 Magic 字符串 - 对拼接结果做 SHA1,得到 20 字节的摘要
- 对这 20 字节做 Base64 编码
框架里拆成了两个函数来实现:
// SHA1 计算,调用 OpenSSL
std::string connection::sha1(const std::string &input) {
unsigned char hash[SHA_DIGEST_LENGTH]; // SHA_DIGEST_LENGTH = 20
SHA1(reinterpret_cast<const unsigned char *>(input.c_str()),
input.size(),
hash);
// 注意:返回的是原始二进制字节,不是十六进制字符串
return std::string(reinterpret_cast<char *>(hash), SHA_DIGEST_LENGTH);
}
// Base64 编码,调用 OpenSSL EVP 接口
std::string connection::base64_encode(const std::string &input) {
int len = 4 * ((input.size() + 2) / 3);
std::string output(len, '\0');
int out_len = EVP_EncodeBlock(
reinterpret_cast<unsigned char *>(&output[0]),
reinterpret_cast<const unsigned char *>(input.data()),
input.size());
output.resize(out_len);
return output;
}
然后在握手函数里组合起来:
void connection::websocket_handshake() {
auto secWebsocketKey = request.headers.find("Sec-WebSocket-Key")->second;
response.http_status = HttpStatusCode::SWITCHING_PROTOCOLS; // 101
response.headers["Upgrade"] = "websocket";
response.headers["Connection"] = "Upgrade";
// key + magic → SHA1 → Base64
response.headers["Sec-WebSocket-Accept"] = base64_encode(sha1(secWebsocketKey + magic));
}
其中 magic 就是那个固定的 GUID 字符串,作为类的常量定义。
为什么要这样设计?Sec-WebSocket-Key 每次连接都是随机的,服务器必须用这个 Key 参与计算,才能证明它不是一个普通的 HTTP 服务器随便返回了 101,而是真正理解 WebSocket 协议的服务器。客户端收到响应后会用同样的算法验证这个 Accept 值是否正确。
2.4 返回 101,TCP 连接"变身"
服务器把 101 响应发出去之后,这条 TCP 连接就不再处理 HTTP 了,直接移交给 websocket_connection 接管:
// 在 do_read_body 里,发完 HTTP 响应之后
self->do_write();
if (self->is_upgrade_to_websocket) {
auto ws = std::make_shared<websocket_connection>(
std::move(self->client_socket_), // 把 socket 所有权转移过去
self->request,
self->websocket_handler_,
self->http_session);
ws->start();
}
std::move(self->client_socket_) 这里很关键——socket 的所有权直接转移给了 websocket_connection,connection 对象之后就不会再用这个 socket 了,避免了双方同时操作同一个 socket 的竞争问题。
三、帧格式:WebSocket 数据包的二进制结构
握手完成后,双方不再发 HTTP 报文,改用 WebSocket 帧(Frame) 通信。帧是一个紧凑的二进制格式,我们需要手动按字节解析。
3.1 帧头结构
每一帧的前两个字节是固定的帧头,结构如下:
字节 0:
bit 7 : FIN — 是否是消息的最后一帧(分片时用)
bit 6-4 : RSV1/RSV2/RSV3 — 保留位,通常为 0
bit 3-0 : opcode — 帧类型
字节 1:
bit 7 : MASK — 数据是否有掩码(客户端发的必须有)
bit 6-0 : Payload len — 负载长度(初始值)
opcode 的取值(在 websocket_opcode 枚举里定义):
enum class websocket_opcode : uint8_t {
continuation = 0x0, // 分片续帧
text = 0x1, // 文本帧
binary = 0x2, // 二进制帧
close = 0x8, // 关闭帧
ping = 0x9, // 心跳 ping
pong = 0xA // 心跳 pong
};
3.2 Payload Length 的三种情况
负载长度的编码是 WebSocket 协议里最绕的一个设计,用 7 bit 来表示,但实际长度可以远超 127:
| 初始值 | 实际含义 |
|---|---|
| 0–125 | 就是实际长度,头部不再追加字节 |
| 126 | 后面跟 2 个字节(大端),用这 2 字节表示实际长度(最大 65535) |
| 127 | 后面跟 8 个字节(大端),用这 8 字节表示实际长度 |
代码里处理这个分支:
void websocket_connection::handler_header() {
fin_ = (buffer_[0] & 0x80) >> 7;
opcode_ = static_cast<websocket_opcode>(buffer_[0] & 0x0f);
mask_ = (buffer_[1] & 0x80) >> 7;
payload_length_ = buffer_[1] & 0x7f; // 取低 7 位
if (payload_length_ <= 125) {
read_mask(); // 长度直接确定,去读掩码
} else {
read_extended_length(); // 需要读额外的字节来确定长度
}
}
void websocket_connection::read_extended_length() {
// 126 → 读 2 字节,127 → 读 8 字节
size_t ext_bytes = (payload_length_ == 126) ? 2 : 8;
buffer_.resize(ext_bytes);
boost::asio::async_read(socket_, boost::asio::buffer(buffer_),
[self = shared_from_this(), ext_bytes](boost::system::error_code ec, std::size_t) {
if (ec) return;
self->payload_length_ = 0;
// 大端序拼接
for (size_t i = 0; i < ext_bytes; ++i) {
self->payload_length_ = (self->payload_length_ << 8) | self->buffer_[i];
}
self->read_mask();
});
}
3.3 掩码(Mask)机制
RFC 6455 规定:客户端发给服务器的帧必须加掩码,服务器发给客户端的帧不能加掩码。
掩码由 4 个随机字节组成,紧跟在 Payload Length 之后。解掩码的方式是:把每个字节和掩码循环异或。
void websocket_connection::read_mask() {
if (!mask_) {
// 没有掩码(服务器发给客户端的帧),直接读 payload
read_payload();
return;
}
mask_key_.resize(4);
boost::asio::async_read(socket_, boost::asio::buffer(mask_key_),
[self = shared_from_this()](boost::system::error_code ec, size_t) {
if (ec) return;
self->read_payload();
});
}
void websocket_connection::read_payload() {
payload_.resize(payload_length_);
boost::asio::async_read(socket_, boost::asio::buffer(payload_),
[self = shared_from_this()](boost::system::error_code ec, std::size_t) {
if (ec) return;
// 如果有掩码,逐字节解掩
if (self->mask_) {
for (uint64_t i = 0; i < self->payload_length_; ++i) {
self->payload_[i] ^= self->mask_key_[i % 4];
}
}
self->dispatch(self->opcode_);
self->on_read_frame(); // 继续读下一帧
});
}
为什么要有掩码?主要是为了防止"缓存污染攻击"——恶意页面可能伪造 WebSocket 数据包欺骗代理服务器,加掩码可以确保帧内容在传输层面是随机化的。
四、帧的分发:根据 opcode 处理不同类型的消息
读完 payload 之后,dispatch 函数根据 opcode 决定怎么处理:
void websocket_connection::dispatch(websocket_opcode opcode) {
switch (opcode) {
case websocket_opcode::text: {
// 文本帧:把 payload 转成 string,触发业务回调
std::string message(payload_.begin(), payload_.end());
if (on_message_) {
on_message_(message);
}
break;
}
case websocket_opcode::binary:
// 二进制帧:暂未实现业务处理
break;
case websocket_opcode::ping:
// 收到 ping,立刻回 pong(协议要求)
send_frame(websocket_opcode::pong, payload_);
break;
case websocket_opcode::close:
// 收到关闭帧,回一个关闭帧,触发 on_close 回调
send_close();
if (on_close_) {
on_close_();
}
socket_.close();
break;
default:
break;
}
}
ping/pong 是 WebSocket 的心跳机制,服务器收到 ping 必须立即回 pong,整个过程对业务层透明,框架自动处理。
五、发送帧:把数据打包成 WebSocket 帧格式
发送方向要自己把数据打包成帧格式。服务器发给客户端的帧不需要掩码,所以相对简单:
void websocket_connection::send_frame(websocket_opcode opcode,
const std::vector<uint8_t> &payload) {
std::vector<uint8_t> frame;
// 第一个字节:FIN=1(单帧消息),低 4 位是 opcode
uint8_t fin_opcode = 0x80 | static_cast<uint8_t>(opcode);
frame.push_back(fin_opcode);
// 第二个字节开始:写入 payload 长度
uint64_t len = payload.size();
if (len <= 125) {
frame.push_back(static_cast<uint8_t>(len));
} else if (len <= 0xffff) {
frame.push_back(126);
frame.push_back(len >> 8 & 0xff); // 高字节
frame.push_back(len & 0xff); // 低字节
} else {
frame.push_back(127);
// 8 字节大端序
for (int i = 7; i >= 0; --i) {
frame.push_back((len >> (8 * i)) & 0xFF);
}
}
// 追加 payload 数据(服务器不加掩码)
frame.insert(frame.end(), payload.begin(), payload.end());
// 放入写队列,如果当前没有写操作就立即触发
bool writing = !write_queue_.empty();
write_queue_.push_back(std::move(frame));
if (!writing) {
do_write();
}
}
这里用了写队列(write_queue_,实际上是 std::deque<std::vector<uint8_t>>)而不是直接写入。原因是 Boost.Asio 的 async_write 是异步的,如果上一次写入还没完成又发起了新的写入,会导致数据交错。写队列保证了串行发送:
void websocket_connection::do_write() {
boost::asio::async_write(socket_,
boost::asio::buffer(write_queue_.front()),
[self = shared_from_this()](boost::system::error_code ec, std::size_t) {
if (ec) return;
self->write_queue_.pop_front();
// 队列里还有数据,继续写
if (!self->write_queue_.empty()) {
self->do_write();
}
});
}
关闭帧稍微特殊一点,需要携带 2 字节的状态码(大端序):
void websocket_connection::send_close(uint16_t code) {
std::vector<uint8_t> payload;
payload.push_back((code >> 8) & 0xFF); // 高字节
payload.push_back(code & 0xFF); // 低字节
send_frame(websocket_opcode::close, payload);
}
状态码 1000 表示正常关闭,其他常用的还有 1001(端点离开)、1003(不支持的数据类型)等。
六、连接管理:websocket_manager 的设计
单个连接处理好了,但实际场景下会有很多用户同时在线,需要一个地方统一管理所有 WebSocket 连接。websocket_manager 用单例模式实现:
websocket_manager &websocket_manager::instance() {
static websocket_manager instance_;
return instance_;
}
内部用 unordered_map 以 session_id 为 key 存储连接,但存的不是 shared_ptr 而是 weak_ptr:
std::unordered_map<std::string, std::weak_ptr<websocket_connection>> connections_;
为什么用 weak_ptr?
websocket_connection 对象本身的生命周期由 shared_ptr 管理,通常持有者是 Asio 的异步回调链(shared_from_this())。如果 websocket_manager 也持有 shared_ptr,就会形成额外的引用计数:即使连接断开、Asio 的回调链结束,只要 manager 里还有 shared_ptr,对象就不会销毁,造成内存泄漏。
用 weak_ptr 则完全不影响引用计数,需要使用时调用 .lock() 尝试提升为 shared_ptr,如果提升失败说明对象已经被销毁(连接断了),安全地跳过。
广播实现:
void websocket_manager::broadcast(const std::string &msg, const std::string &id) {
std::lock_guard<std::mutex> lock(mutex_);
for (auto &it : connections_) {
if (it.first != id) { // 排除发送者自己
auto conn = it.second.lock();
if (conn) conn->send_text(msg);
}
}
}
注意这里有一个潜在问题:it.second.lock() 可能返回 nullptr(连接在遍历过程中刚好断开),所以必须先判断 if (conn) 再调用方法,否则会空指针崩溃。
连接的注册和注销在握手回调和关闭回调里完成:
ws->on_handshake([ws](const http_request_struct &req) {
// 检查登录态
auto it = ws->http_session_->find("username");
if (it == ws->http_session_->end()) {
ws->send_close(); // 未登录,拒绝
return;
}
// 注册到 manager
websocket_manager::instance().add(ws->http_request_.session_id, ws);
ws->send_text("欢迎" + it->second + "登录websocket");
});
ws->on_close([ws] {
// 从 manager 移除
websocket_manager::instance().remove(ws->http_request_.session_id);
std::cout << "连接断开" << std::endl;
});
这里还有一个值得注意的设计:握手回调里做了登录态校验,直接复用了 HTTP Session。WebSocket 连接建立时走的是 HTTP 握手,所以 Cookie 和 Session 都是完整传递的,不需要在 WebSocket 层面重新搞一套认证机制。
七、框架与业务的边界:控制权是怎么交出去的
前面讲的握手、帧解析、连接管理,都是框架层的事。但框架不可能知道业务层想对消息做什么——有的连接要做聊天室,有的要做实时推送,有的要做数据同步。框架需要一个机制,在协议处理完成之后,把控制权干净地交给业务层。
这套机制的核心是 websocket_handler:
using websocket_handler = std::function<void(std::shared_ptr<websocket_connection>)>;
就是一个普通的函数对象,参数是 websocket_connection 的 shared_ptr。整个控制权转移的链路是这样的:
第一步:业务层在路由里注册 handler
// main.cpp
routers.ws("/chat", [](const std::shared_ptr<websocket_connection>& ws) {
ws->on_message([ws](const std::string &message) {
ws->send_text(message);
});
ws->on_handshake([ws](const http_request_struct &req) {
// 鉴权、注册到 manager...
});
ws->on_close([ws] {
// 清理...
});
});
这个 lambda 就是 websocket_handler,它被存进路由表:
// routers.cpp
void routers::ws(const std::string& path, websocket_handler handler) {
websocket_routes[path] = std::move(handler);
}
第二步:请求进来,路由匹配,handler 被存到 connection 上
// routers.cpp - handle_request
auto websocket_route = websocket_routes.find(path);
if (websocket_route != websocket_routes.end()) {
session->upgrade_to_websocket(websocket_route->second);
return;
}
upgrade_to_websocket 把 handler 暂存到 connection 对象上,同时完成 HTTP 握手、打上升级标记:
void connection::upgrade_to_websocket(websocket_handler websocket_handler) {
if (!is_websocket_request()) {
throw std::runtime_error("not websocket request");
}
websocket_handshake(); // 准备 101 响应
is_upgrade_to_websocket = true; // 标记需要升级
websocket_handler_ = std::move(websocket_handler); // 暂存 handler
}
第三步:101 响应发完,socket 和 handler 一起移交
// connection.cpp - do_read_body 发完响应之后
if (is_upgrade_to_websocket) {
auto ws = std::make_shared<websocket_connection>(
std::move(client_socket_), // socket 所有权移交
request,
websocket_handler_, // handler 也一起带过去
http_session);
ws->start();
}
第四步:websocket_connection::start() 里,handler 被立即调用
void websocket_connection::start() {
websocket_handler_(shared_from_this()); // 把自身 shared_ptr 传给业务层
if (on_handshake_) {
on_handshake_(http_request_);
}
on_read_frame(); // 开始读帧
}
websocket_handler_(shared_from_this()) 这一行就是控制权转移的那一刻。框架把 websocket_connection 的 shared_ptr 传给业务层的 lambda,业务层在这个 lambda 里注册好 on_message/on_handshake/on_close 三个回调,然后 lambda 返回。
之后框架调 on_handshake_(如果业务层注册了的话),再调 on_read_frame() 进入帧读取循环,后续收到帧时就会触发业务层注册的回调。
这个设计的好处在于:框架完全不知道业务层在做什么,业务层也不需要关心帧是怎么解析的。框架只负责把协议处理好、把消息内容提取出来,然后通过回调告诉业务层"来消息了"。业务层只需要关心消息的内容和业务逻辑,两层之间没有任何耦合。
对比一下如果不这样设计——让框架直接持有业务逻辑,或者让业务层直接操作 socket——前者框架和业务死死绑在一起无法复用,后者业务层要自己处理帧格式完全失去了框架的意义。这种"框架处理协议,回调交给业务"的模式在很多异步框架里都能看到,Node.js 的 EventEmitter、Netty 的 ChannelHandler 都是类似的思路。
八、完整的异步读取循环
最后把帧读取的完整流程串起来看:
on_read_frame() ← 读 2 字节帧头
↓
handler_header() ← 解析 FIN/opcode/mask/payload_len
↓
read_extended_length() ← 如果 len=126/127,读额外 2/8 字节
↓
read_mask() ← 如果有掩码,读 4 字节 mask_key
↓
read_payload() ← 读 payload,解掩,分发
↓
dispatch() ← 根据 opcode 触发回调
↓
on_read_frame() ← 循环,等待下一帧
每一步都是异步的(async_read),当前步骤完成后通过 lambda 回调触发下一步。shared_from_this() 保证了在整个异步链路上,websocket_connection 对象不会被提前销毁。
九、小结
WebSocket 协议实现下来,核心分四层:
握手层:复用 HTTP 做协议升级,关键是 Sec-WebSocket-Accept 的计算——Sec-WebSocket-Key + Magic GUID 经过 SHA1 再 Base64,这套机制保证了服务器的合法性验证。
帧解析层:WebSocket 帧是紧凑的二进制格式,需要按位操作提取 FIN/opcode/mask/length,payload_length 有三档编码(直接值/2字节/8字节),客户端帧强制掩码并用 XOR 解掩。
控制权转移层:websocket_handler 作为桥梁,框架在协议处理完成后把 websocket_connection 的 shared_ptr 交给业务层,业务层注册回调,框架和业务之间完全解耦。
连接管理层:用 weak_ptr 存储连接避免循环引用,写队列保证并发安全,session_id 作为连接标识复用了 HTTP Session 的认证体系。
代码:https://github.com/Fall-Rain/asio_web_service
下一篇打算写 Stream 抽象层的设计,聊聊为什么要把 tcp_stream 和 ssl_stream 抽象到同一个接口,以及如何用模板实现零开销的多态。
文章评论