秋雨De blog

  • 首页
  • 留言板
  • 关于
  • rss
秋雨De blog
一个技术小白的个人博客
  1. 首页
  2. 未分类
  3. 正文

手写 C++ Web 服务器:WebSocket 协议实现

2026年6月9日 105点热度 0人点赞 0条评论

从实习开始我就接触 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:告诉服务器,我想升级成 WebSocket
  • Connection: Upgrade:这条连接需要做协议升级
  • Sec-WebSocket-Key:一个随机生成的 Base64 字符串,用来验证服务器是否真正支持 WebSocket
  • Sec-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

计算步骤:

  1. 把客户端传来的 Sec-WebSocket-Key 拼上这个 Magic 字符串
  2. 对拼接结果做 SHA1,得到 20 字节的摘要
  3. 对这 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 抽象到同一个接口,以及如何用模板实现零开销的多态。

本作品采用 知识共享署名 4.0 国际许可协议 进行许可
标签: 暂无
最后更新:2026年6月9日

fallrain

种一棵树最好的时间是十年前,其次是现在。

点赞
< 上一篇

文章评论

razz evil exclaim smile redface biggrin eek confused idea lol mad twisted rolleyes wink cool arrow neutral cry mrgreen drooling persevering
取消回复

fallrain

种一棵树最好的时间是十年前,其次是现在。

文章目录
  • 一、WebSocket 与 HTTP 的关系
  • 二、握手:协议升级的详细过程
    • 2.1 客户端发送 Upgrade 请求
    • 2.2 服务器验证请求合法性
    • 2.3 计算 Sec-WebSocket-Accept
    • 2.4 返回 101,TCP 连接"变身"
  • 三、帧格式:WebSocket 数据包的二进制结构
    • 3.1 帧头结构
    • 3.2 Payload Length 的三种情况
    • 3.3 掩码(Mask)机制
  • 四、帧的分发:根据 opcode 处理不同类型的消息
  • 五、发送帧:把数据打包成 WebSocket 帧格式
  • 六、连接管理:websocket_manager 的设计
  • 七、框架与业务的边界:控制权是怎么交出去的
  • 八、完整的异步读取循环
  • 九、小结
友情连接
猫饭范文泉博客迎風别葉CODING手艺人ScarSu博友圈
归档
  • 2026 年 6 月
  • 2026 年 3 月
  • 2025 年 11 月
  • 2025 年 5 月
  • 2025 年 4 月
  • 2025 年 3 月
  • 2024 年 12 月
  • 2024 年 10 月
  • 2024 年 5 月
  • 2023 年 2 月
  • 2022 年 11 月
  • 2022 年 3 月
  • 2021 年 12 月
  • 2021 年 8 月
  • 2021 年 5 月
  • 2021 年 4 月
  • 2021 年 3 月
  • 2020 年 12 月
  • 2020 年 11 月
  • 2020 年 8 月
  • 2020 年 5 月
  • 2019 年 12 月
  • 2019 年 3 月

吉ICP备18007356号

吉公网安备22020302000184号

Theme Kratos Made By Seaton Jiang

COPYRIGHT © 2026 秋雨De blog ALL RIGHTS RESERVED