WebSocket 教程:websocketpp 写 echo 服务端

📚 系列:C++ 工程实践 第 07 篇 ⏱ 阅读约 10 分钟
📌 一句话总结

本文用「同端口双协议 → 握手 → 六步骨架 → 浏览器验证」四步讲透 WebSocket,读完后你能:① 说清 101 握手是怎么来的;② 用 websocketpp 跑通一个 8888 端口的 echo 服务;③ 不写 C++ 客户端,用浏览器 30 行 JS 验证双向通信。

文章目录

    • [1. WebSocket 和 HTTP 到底差在哪:同一个 8888 端口,两种协议](#1. WebSocket 和 HTTP 到底差在哪:同一个 8888 端口,两种协议)
    • [2. 握手:服务端怎么知道你要「升级」](#2. 握手:服务端怎么知道你要「升级」)
    • [3. 环境与 makefile:header-only 也要装 Boost](#3. 环境与 makefile:header-only 也要装 Boost)
    • [4. server.cc 逐段:两行 typedef 与六步 main](#4. server.cc 逐段:两行 typedef 与六步 main)
    • [5. 回调生命周期:hdl 是 weak_ptr,别拿它当连接 ID](#5. 回调生命周期:hdl 是 weak_ptr,别拿它当连接 ID)
    • [6. 消息与帧:get_payload() 拿到什么,send() 怎么回](#6. 消息与帧:get_payload() 拿到什么,send() 怎么回)
    • [7. 跑起来:浏览器验证 echo,一张表排掉 8 个坑](#7. 跑起来:浏览器验证 echo,一张表排掉 8 个坑)
    • 附录:完整代码整合
      • [A.1 目录结构](#A.1 目录结构)
      • [A.2 server.cc(完整版)](#A.2 server.cc(完整版))
      • [A.3 makefile](#A.3 makefile)
      • [A.4 webroot/index.html](#A.4 webroot/index.html)
      • [A.5 编译与启动](#A.5 编译与启动)
      • [A.6 验证步骤与预期输出](#A.6 验证步骤与预期输出)
      • [A.7 常见错误对照](#A.7 常见错误对照)

1. WebSocket 和 HTTP 到底差在哪:同一个 8888 端口,两种协议

上一篇我们用 cpp-httplib 做了个一问一答的 HTTP 服务:浏览器请求一次,服务端答一次,连接就断了------它没法「服务端主动推给你」。WebSocket 补的就是这段:一次 HTTP 请求把连接「升级」成 ws://,之后双方可以随时互发消息;而且同一条 TCP 连接在升级前是 HTTP、升级后是 WebSocket。

对比项 HTTP 长轮询 WebSocket
连接状态 请求-响应后关闭 反复建连 一条长连接
谁能先发 客户端 客户端(假推送) 双方都可以
服务端主动推 不支持 靠挂起请求绕 原生支持
单条消息开销 完整头部 完整头部 2~14 字节帧头

本文的 server.cc 最能说明问题:同一个 8888 端口,既回 HTTP 欢迎页,也接 ws:// 长连接 。set_http_handler 处理的是「没走升级的普通 HTTP 请求」,set_open_handler 处理的是「升级成功后的 WebSocket 连接」------两个入口,一个端口。
ℹ️ 为什么一定要有 HTTP 入口

WebSocket 的握手本身就是一次 HTTP 请求。服务端要能解析这个请求、区分「普通 GET」和「Upgrade 请求」,所以 websocketpp 默认带 HTTP 处理能力;不注册 set_http_handler 也能跑,但浏览器直接访问 8888 时会看到空白响应,排查起来很别扭。

2. 握手:服务端怎么知道你要「升级」

客户端发的是一次带 Connection: Upgrade / Upgrade: websocket / Sec-WebSocket-Key 的 HTTP GET,服务端回 101 Switching Protocols + 由 key 算出的 Sec-WebSocket-Accept。 这之后同一条连接就只说 WebSocket 帧,不再说 HTTP。
websocketpp 浏览器 websocketpp 浏览器 #mermaid-svg-D7BfE0bMbvqZCdhx{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-D7BfE0bMbvqZCdhx .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-D7BfE0bMbvqZCdhx .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-D7BfE0bMbvqZCdhx .error-icon{fill:#552222;}#mermaid-svg-D7BfE0bMbvqZCdhx .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-D7BfE0bMbvqZCdhx .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-D7BfE0bMbvqZCdhx .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-D7BfE0bMbvqZCdhx .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-D7BfE0bMbvqZCdhx .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-D7BfE0bMbvqZCdhx .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-D7BfE0bMbvqZCdhx .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-D7BfE0bMbvqZCdhx .marker{fill:#333333;stroke:#333333;}#mermaid-svg-D7BfE0bMbvqZCdhx .marker.cross{stroke:#333333;}#mermaid-svg-D7BfE0bMbvqZCdhx svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-D7BfE0bMbvqZCdhx p{margin:0;}#mermaid-svg-D7BfE0bMbvqZCdhx .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-D7BfE0bMbvqZCdhx text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-D7BfE0bMbvqZCdhx .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-D7BfE0bMbvqZCdhx .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-D7BfE0bMbvqZCdhx .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-D7BfE0bMbvqZCdhx .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-D7BfE0bMbvqZCdhx #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-D7BfE0bMbvqZCdhx .sequenceNumber{fill:white;}#mermaid-svg-D7BfE0bMbvqZCdhx #sequencenumber{fill:#333;}#mermaid-svg-D7BfE0bMbvqZCdhx #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-D7BfE0bMbvqZCdhx .messageText{fill:#333;stroke:none;}#mermaid-svg-D7BfE0bMbvqZCdhx .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-D7BfE0bMbvqZCdhx .labelText,#mermaid-svg-D7BfE0bMbvqZCdhx .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-D7BfE0bMbvqZCdhx .loopText,#mermaid-svg-D7BfE0bMbvqZCdhx .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-D7BfE0bMbvqZCdhx .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-D7BfE0bMbvqZCdhx .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-D7BfE0bMbvqZCdhx .noteText,#mermaid-svg-D7BfE0bMbvqZCdhx .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-D7BfE0bMbvqZCdhx .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-D7BfE0bMbvqZCdhx .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-D7BfE0bMbvqZCdhx .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-D7BfE0bMbvqZCdhx .actorPopupMenu{position:absolute;}#mermaid-svg-D7BfE0bMbvqZCdhx .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-D7BfE0bMbvqZCdhx .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-D7BfE0bMbvqZCdhx .actor-man circle,#mermaid-svg-D7BfE0bMbvqZCdhx line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-D7BfE0bMbvqZCdhx :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} GET Upgrade 请求 101 Switching Protocols text 帧 hello text 帧 hello

关键请求头长这样:

http 复制代码
GET / HTTP/1.1
Host: 127.0.0.1:8888
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13

Sec-WebSocket-Accept 的算法一行就能说清:把 Sec-WebSocket-Key 拼上固定 GUID 258EAFA5-E914-47DA-95CA-C5AB0DC85B11,做 SHA1,再 base64。websocketpp 会自动算,读者不需要手写,但知道它怎么来,看到 101 就不会心虚。
⚠️ curl 测不了 WebSocket

curl http://127.0.0.1:8888 只能拿到欢迎页(HTTP 200),它不会完成升级。要看握手和帧,用浏览器 DevTools 的 Network → WS 面板,或者第 7 节的 JS 测试页。

3. 环境与 makefile:header-only 也要装 Boost

websocketpp 是纯头文件库,但底层用 Boost.Asio,真正要装的是 Boost;makefile 只要 -std=c++17、-lpthread -lboost_system。

环境 版本
系统 Ubuntu 22.04
编译器 g++ 11(C++17)
websocketpp 0.8.2(header-only)
Boost 1.74(libboost-system-dev)

安装与构建:

bash 复制代码
sudo apt install libwebsocketpp-dev libboost-system-dev
# 或者把 websocketpp clone 到 third_party/ 下

参考代码的 makefile:

makefile 复制代码
server : server.cc
	g++ -std=c++17 $^ -o $@ -lpthread -lboost_system

clean:
	rm -rf server

两个细节:#include <websocketpp/...> 用的是尖括号,装到系统路径不用加 -I,clone 到工程目录就必须加 -Ithird_party/websocketpp;rm -rf 在 Windows/MinGW 下不可用,换 WSL 或改成 del server.exe。

4. server.cc 逐段:两行 typedef 与六步 main

server.cc 只有两块------前半段是让回调能被注册的准备,后半段 main 是六步:实例化 → init_asio → 注册回调 → listen(8888) → start_accept → run。

先看准备部分:

cpp 复制代码
#include <websocketpp/config/asio_no_tls.hpp>
#include <websocketpp/server.hpp>

typedef websocketpp::server<websocketpp::config::asio> websocketsvr;
typedef websocketsvr::message_ptr message_ptr;

using websocketpp::lib::placeholders::_1;
using websocketpp::lib::placeholders::_2;
using websocketpp::lib::bind;

注意一个反直觉的点:头文件叫 asio_no_tls,类型名却写 websocketpp::config::asio 。写成 config::asio_no_tls 编译不过;要上 TLS,才换成 config/asio.hpp + config::asio_tls 并链接 -lssl -lcrypto,本文不展开。

_1、_2 是 bind 的参数钩子,必须走 websocketpp::lib:: 这一层别名,它让代码在 Boost.Asio 和独立 Asio 两种构建下都能编;混用 std::placeholders 会换来一屏模板报错。

再看 main 的六步:

cpp 复制代码
int main() {
    websocketsvr server;                                        // ① 实例化
    server.set_access_channels(websocketpp::log::alevel::none); // ② 关日志
    server.init_asio();                                         // ③ 初始化 asio

    server.set_http_handler(bind(&OnHttp, &server, ::_1));
    server.set_open_handler(bind(&OnOpen, &server, ::_1));
    server.set_close_handler(bind(&OnClose, &server, ::_1));
    server.set_message_handler(bind(&OnMessage, &server, ::_1, ::_2));

    server.listen(8888);                                        // ④ 占端口
    server.start_accept();                                      // ⑤ 开始接收连接
    server.run();                                               // ⑥ 事件循环,阻塞
    return 0;
}

每一步删掉会发生什么:

操作 漏写的后果
init_asio() 后续注册/监听没有事件循环,直接异常或无效
listen(8888) 没有监听端口,start_accept 无处可挂
start_accept() 端口在监听,但谁都连不上(全篇最高频的坑)
不注册 handler 连接能建立,但收不到消息、看不到关闭
run() main 直接结束,服务瞬间退出

🚫 最容易漏的一行

start_accept() 漏写时,程序不报错、ss -lntp 也能看到 8888 在监听,但浏览器和客户端全部连接失败。排查时先确认这一行在不在。

5. 回调生命周期:hdl 是 weak_ptr,别拿它当连接 ID

一条连接的生命是 OnOpen → OnMessage × N → OnClose 或 OnFail;五个回调里的 connection_hdl 只是个弱引用,要拿连接对象必须 get_con_from_hdl(hdl)。
#mermaid-svg-oQ4kMKAc8NDsiNNu{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-oQ4kMKAc8NDsiNNu .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-oQ4kMKAc8NDsiNNu .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-oQ4kMKAc8NDsiNNu .error-icon{fill:#552222;}#mermaid-svg-oQ4kMKAc8NDsiNNu .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-oQ4kMKAc8NDsiNNu .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-oQ4kMKAc8NDsiNNu .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-oQ4kMKAc8NDsiNNu .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-oQ4kMKAc8NDsiNNu .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-oQ4kMKAc8NDsiNNu .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-oQ4kMKAc8NDsiNNu .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-oQ4kMKAc8NDsiNNu .marker{fill:#333333;stroke:#333333;}#mermaid-svg-oQ4kMKAc8NDsiNNu .marker.cross{stroke:#333333;}#mermaid-svg-oQ4kMKAc8NDsiNNu svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-oQ4kMKAc8NDsiNNu p{margin:0;}#mermaid-svg-oQ4kMKAc8NDsiNNu defs #statediagram-barbEnd{fill:#333333;stroke:#333333;}#mermaid-svg-oQ4kMKAc8NDsiNNu g.stateGroup text{fill:#9370DB;stroke:none;font-size:10px;}#mermaid-svg-oQ4kMKAc8NDsiNNu g.stateGroup text{fill:#333;stroke:none;font-size:10px;}#mermaid-svg-oQ4kMKAc8NDsiNNu g.stateGroup .state-title{font-weight:bolder;fill:#131300;}#mermaid-svg-oQ4kMKAc8NDsiNNu g.stateGroup rect{fill:#ECECFF;stroke:#9370DB;}#mermaid-svg-oQ4kMKAc8NDsiNNu g.stateGroup line{stroke:#333333;stroke-width:1;}#mermaid-svg-oQ4kMKAc8NDsiNNu .transition{stroke:#333333;stroke-width:1;fill:none;}#mermaid-svg-oQ4kMKAc8NDsiNNu .stateGroup .composit{fill:white;border-bottom:1px;}#mermaid-svg-oQ4kMKAc8NDsiNNu .stateGroup .alt-composit{fill:#e0e0e0;border-bottom:1px;}#mermaid-svg-oQ4kMKAc8NDsiNNu .state-note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-oQ4kMKAc8NDsiNNu .state-note text{fill:black;stroke:none;font-size:10px;}#mermaid-svg-oQ4kMKAc8NDsiNNu .stateLabel .box{stroke:none;stroke-width:0;fill:#ECECFF;opacity:0.5;}#mermaid-svg-oQ4kMKAc8NDsiNNu .edgeLabel .label rect{fill:#ECECFF;opacity:0.5;}#mermaid-svg-oQ4kMKAc8NDsiNNu .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-oQ4kMKAc8NDsiNNu .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-oQ4kMKAc8NDsiNNu .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-oQ4kMKAc8NDsiNNu .edgeLabel .label text{fill:#333;}#mermaid-svg-oQ4kMKAc8NDsiNNu .label div .edgeLabel{color:#333;}#mermaid-svg-oQ4kMKAc8NDsiNNu .stateLabel text{fill:#131300;font-size:10px;font-weight:bold;}#mermaid-svg-oQ4kMKAc8NDsiNNu .node circle.state-start{fill:#333333;stroke:#333333;}#mermaid-svg-oQ4kMKAc8NDsiNNu .node .fork-join{fill:#333333;stroke:#333333;}#mermaid-svg-oQ4kMKAc8NDsiNNu .node circle.state-end{fill:#9370DB;stroke:white;stroke-width:1.5;}#mermaid-svg-oQ4kMKAc8NDsiNNu .end-state-inner{fill:white;stroke-width:1.5;}#mermaid-svg-oQ4kMKAc8NDsiNNu .node rect{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-oQ4kMKAc8NDsiNNu .node polygon{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-oQ4kMKAc8NDsiNNu #statediagram-barbEnd{fill:#333333;}#mermaid-svg-oQ4kMKAc8NDsiNNu .statediagram-cluster rect{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-oQ4kMKAc8NDsiNNu .cluster-label,#mermaid-svg-oQ4kMKAc8NDsiNNu .nodeLabel{color:#131300;}#mermaid-svg-oQ4kMKAc8NDsiNNu .statediagram-cluster rect.outer{rx:5px;ry:5px;}#mermaid-svg-oQ4kMKAc8NDsiNNu .statediagram-state .divider{stroke:#9370DB;}#mermaid-svg-oQ4kMKAc8NDsiNNu .statediagram-state .title-state{rx:5px;ry:5px;}#mermaid-svg-oQ4kMKAc8NDsiNNu .statediagram-cluster.statediagram-cluster .inner{fill:white;}#mermaid-svg-oQ4kMKAc8NDsiNNu .statediagram-cluster.statediagram-cluster-alt .inner{fill:#f0f0f0;}#mermaid-svg-oQ4kMKAc8NDsiNNu .statediagram-cluster .inner{rx:0;ry:0;}#mermaid-svg-oQ4kMKAc8NDsiNNu .statediagram-state rect.basic{rx:5px;ry:5px;}#mermaid-svg-oQ4kMKAc8NDsiNNu .statediagram-state rect.divider{stroke-dasharray:10,10;fill:#f0f0f0;}#mermaid-svg-oQ4kMKAc8NDsiNNu .note-edge{stroke-dasharray:5;}#mermaid-svg-oQ4kMKAc8NDsiNNu .statediagram-note rect{fill:#fff5ad;stroke:#aaaa33;stroke-width:1px;rx:0;ry:0;}#mermaid-svg-oQ4kMKAc8NDsiNNu .statediagram-note rect{fill:#fff5ad;stroke:#aaaa33;stroke-width:1px;rx:0;ry:0;}#mermaid-svg-oQ4kMKAc8NDsiNNu .statediagram-note text{fill:black;}#mermaid-svg-oQ4kMKAc8NDsiNNu .statediagram-note .nodeLabel{color:black;}#mermaid-svg-oQ4kMKAc8NDsiNNu .statediagram .edgeLabel{color:red;}#mermaid-svg-oQ4kMKAc8NDsiNNu #dependencyStart,#mermaid-svg-oQ4kMKAc8NDsiNNu #dependencyEnd{fill:#333333;stroke:#333333;stroke-width:1;}#mermaid-svg-oQ4kMKAc8NDsiNNu .statediagramTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-oQ4kMKAc8NDsiNNu :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 握手成功
收到消息
继续收发
正常关闭
异常断开
Open
Message
Close
Fail

回调 触发时机 常见坑
OnOpen 握手成功、连接建立 可以在这里主动 send 欢迎消息
OnMessage 收到文本/二进制帧 核心逻辑,见第 6 节
OnClose 客户端正常关闭 只打「连接关闭」排不了错,要取关闭码
OnFail 传输层异常 参考代码定义了但没注册,是死代码
OnHttp 普通 HTTP 请求 缺 Content-Type 时浏览器可能乱码

两个可以直接抄的修法:

cpp 复制代码
// ① 把 OnFail 真正注册上,否则「连接异常」永远不会打印
server.set_fail_handler(bind(&OnFail, &server, ::_1));

// ② 关闭时打印远端关闭码与原因,排查才有信息量
void OnClose(websocketsvr *server, websocketpp::connection_hdl hdl) {
    auto con = server->get_con_from_hdl(hdl);
    std::cout << "连接关闭 code=" << con->get_remote_close_code()
              << " reason=" << con->get_remote_close_reason() << std::endl;
}

hdl 是 weak_ptr,不能直接当 map 的 key 去存业务状态。要按连接存数据,用 std::map<websocketpp::connection_hdl, State, std::owner_less<websocketpp::connection_hdl>>,或者先 get_con_from_hdl(hdl) 拿到 connection_ptr(具体容器写法随版本有差异,这一条标待实测)。

6. 消息与帧:get_payload() 拿到什么,send() 怎么回

OnMessage 里 msg->get_payload() 就是一段 std::string,第二个参数 websocketpp::frame::opcode::text 决定你回的是文本帧;echo 就两行------原样 send 回去。

cpp 复制代码
void OnMessage(websocketsvr *server, websocketpp::connection_hdl hdl,
               message_ptr msg) {
    std::cout << "收到消息: " << msg->get_payload() << std::endl;
    server->send(hdl, msg->get_payload(), websocketpp::frame::opcode::text);
}

参考代码里 cout << "收到消息" << msg->get_payload() 中间没有分隔符,实际输出是「收到消息hello」;上面加了冒号和空格,贴日志时别被格式差异误导。

WebSocket 帧头只有几个字段,理解它们能解释很多怪问题:

字段 作用 规则
FIN 是否消息最后一帧 分片消息靠它判断结束
opcode 帧类型 1 文本 / 2 二进制 / 8 关闭 / 9 ping / 10 pong
MASK 是否掩码 客户端发必须掩码,服务端发必须不掩码
payload len 负载长度 7 / 16 / 64 位三档

✅ 业务里只看 text 和 binary

ping/pong 由 websocketpp 自动应答,close 由连接生命周期处理;业务代码通常只判断 msg->get_opcode() 是 text 还是 binary,其余交给库。

7. 跑起来:浏览器验证 echo,一张表排掉 8 个坑

验证 echo 不需要 C++ 客户端 ------浏览器里 new WebSocket("ws://127.0.0.1:8888") 就行。编译并启动:

bash 复制代码
make
./server

把测试页塞进 OnHttp 的欢迎页里,打开 http://127.0.0.1:8888 就能测:

html 复制代码
<!doctype html>
<html><head><meta charset="utf-8"><title>websocketpp echo</title></head>
<body>
<h1>websocketpp echo 测试页</h1>
<p id="log">连接中...</p>
<script>
  const ws = new WebSocket("ws://127.0.0.1:8888");
  const log = (s) => { document.getElementById("log").textContent = s; console.log(s); };
  ws.onopen    = () => { log("已连接"); ws.send("hello websocket"); };
  ws.onmessage = (e) => log("收到回显: " + e.data);
  ws.onclose   = (e) => log("连接关闭 code=" + e.code);
  ws.onerror   = () => log("连接失败:检查 ws:// 地址与端口");
</script>
</body></html>

配套的 C++ 侧只需改两行(顺手补上 Content-Type):

cpp 复制代码
con->append_header("Content-Type", "text/html; charset=utf-8");
con->set_body(html);
con->set_status(websocketpp::http::status_code::ok);

服务端控制台的预期输出:

text 复制代码
处理http请求
连接成功
收到消息: hello websocket
连接关闭 code=1000 reason=

没有浏览器或想在服务器上测,可以用 Python 或 wscat:

python 复制代码
# pip install websockets(10/11/12 的 API 有差异,以实测为准)
import asyncio, websockets

async def main():
    async with websockets.connect("ws://127.0.0.1:8888") as ws:
        await ws.send("hello websocket")
        print(await asyncio.wait_for(ws.recv(), timeout=3))

asyncio.run(main())
bash 复制代码
npx wscat -c ws://127.0.0.1:8888
# > hello websocket
# < hello websocket

常见问题一张表:

坑 现象 修法
漏写 start_accept() 端口在监听,但连不上 补上这一步
类型写成 config::asio_no_tls 编译报错 类型名是 config::asio
找不到 boost_system 链接或头文件报错 装 libboost-system-dev;clone 的要加 -I
用 http:// 连 WS 浏览器报错 地址必须是 ws://
localhost 解析到 ::1 连接被拒 先试 127.0.0.1
8888 被 Jupyter 占用 启动/连接失败 查端口占用,换端口并同步改 JS
关掉 alevel::none 后没日志 看不到任何输出 调回 alevel::all 排查
回调里 send 抛异常 进程直接退出 try/catch 或先判连接状态

📝 小结

① 同端口双协议:HTTP 请求走 `set_http_handler`,升级请求走 `set_open_handler`。

② 六步骨架:实例化 → `init_asio` → 注册回调 → `listen` → `start_accept` → `run`,一步都不能少。

③ 回调顺序:Open → Message×N → Close 或 Fail;`hdl` 是弱引用,用 `get_con_from_hdl` 取连接。
📦 项目源码

本文示例所在的 C++ 微服务脚手架(点播系统):https://github.com/syagain-zsx/cpp-microservice-scaffold-vod-system
🔜 下一篇

《Websocket 使用实践:把回声服务改成业务消息通道》------从单文件 demo 到项目里能用的连接管理。关注专栏不迷路。
🔗 参考

1 websocketpp GitHub:https://github.com/zaphoyd/websocketpp

2 RFC 6455(WebSocket 协议):https://datatracker.ietf.org/doc/html/rfc6455

3 Boost.Asio 文档:https://www.boost.org/doc/libs/release/doc/html/boost_asio.html

附录:完整代码整合

前面七节把每段代码拆开讲过,这一段只做一件事:把零散的片段合成一份复制即可编译运行的最小工程 。三个文件放进同一目录,make && ./server 就能跑;环境与第 3 节一致(Ubuntu 22.04 + g++ 11 + websocketpp 0.8.2 + Boost 1.74),前提是依赖已装好。

A.1 目录结构

text 复制代码
websocketpp-demo/
├── server.cc            # 完整服务端:HTTP 欢迎页 + WebSocket echo
├── makefile             # 编译 + clean,命令行用真 tab
└── webroot/
    └── index.html       # 浏览器测试页,与 OnHttp 内嵌的是同一份

server.cc 和 makefile 放在同一层,webroot/ 只装页面;服务端不读它,原因见 A.4。

A.2 server.cc(完整版)

在参考代码上只动了三处:OnClose 打印远端关闭码、OnHttp 补 Content-Type 并内嵌测试页、注册 set_fail_handler;其余 typedef、回调和 main 的六步保持原样。

📄 server.cc · 完整版,可直接 make

cpp 复制代码
#include <iostream>
#include <websocketpp/config/asio_no_tls.hpp>
#include <websocketpp/server.hpp>

using namespace std;

typedef websocketpp::server<websocketpp::config::asio> websocketsvr;
typedef websocketsvr::message_ptr message_ptr;

using websocketpp::lib::placeholders::_1;
using websocketpp::lib::placeholders::_2;
using websocketpp::lib::bind;

// 浏览器测试页:直接内嵌,省掉静态文件服务
static const char *kIndexHtml = R"html(<!doctype html>
<html>
<head><meta charset="utf-8"><title>websocketpp echo</title></head>
<body>
<h1>websocketpp echo 测试页</h1>
<p id="log">连接中...</p>
<script>
  const ws = new WebSocket("ws://127.0.0.1:8888");
  const log = (s) => { document.getElementById("log").textContent = s; console.log(s); };
  ws.onopen    = () => { log("已连接"); ws.send("hello websocket"); };
  ws.onmessage = (e) => log("收到回显: " + e.data);
  ws.onclose   = (e) => log("连接关闭 code=" + e.code);
  ws.onerror   = () => log("连接失败:检查 ws:// 地址与端口");
</script>
</body>
</html>
)html";

// websocket连接成功的回调函数
void OnOpen(websocketsvr *server, websocketpp::connection_hdl hdl) {
    cout << "连接成功" << endl;
}

// 连接关闭:打印远端关闭码,1000 表示正常关闭
void OnClose(websocketsvr *server, websocketpp::connection_hdl hdl) {
    websocketsvr::connection_ptr con = server->get_con_from_hdl(hdl);
    cout << "连接关闭 code=" << static_cast<int>(con->get_remote_close_code())
         << " reason=" << con->get_remote_close_reason() << endl;
}

// websocket连接收到消息的回调函数
void OnMessage(websocketsvr *server, websocketpp::connection_hdl hdl, message_ptr msg) {
    cout << "收到消息: " << msg->get_payload() << endl;
    // 收到消息将相同的消息发回给websocket客户端
    server->send(hdl, msg->get_payload(), websocketpp::frame::opcode::text);
}

// websocket连接异常的回调函数
void OnFail(websocketsvr *server, websocketpp::connection_hdl hdl) {
    cout << "连接异常" << endl;
}

// 处理http请求的回调函数:返回带 UTF-8 声明的测试页
void OnHttp(websocketsvr *server, websocketpp::connection_hdl hdl) {
    cout << "处理http请求" << endl;
    websocketsvr::connection_ptr con = server->get_con_from_hdl(hdl);
    con->append_header("Content-Type", "text/html; charset=utf-8");
    con->set_body(kIndexHtml);
    con->set_status(websocketpp::http::status_code::ok);
}

int main() {
    // 使用websocketpp库创建服务器
    websocketsvr server;
    // 设置websocketpp库的日志级别:all 打印全部,none 什么都不打印
    server.set_access_channels(websocketpp::log::alevel::none);
    /*初始化asio*/
    server.init_asio();

    // 注册http请求的处理函数
    server.set_http_handler(bind(&OnHttp, &server, ::_1));
    // 注册websocket请求的处理函数
    server.set_open_handler(bind(&OnOpen, &server, ::_1));
    server.set_close_handler(bind(&OnClose, &server, ::_1));
    // 注册异常处理函数:参考代码定义了 OnFail 却没注册,这里补上
    server.set_fail_handler(bind(&OnFail, &server, ::_1));
    server.set_message_handler(bind(&OnMessage, &server, ::_1, ::_2));

    // 监听8888端口
    server.listen(8888);
    // 开始接收tcp连接
    server.start_accept();
    // 开始运行服务器(阻塞)
    server.run();
    return 0;
}

ℹ️ 三处补全对应正文哪一节

OnClose 取关闭码的写法见第 5 节;OnHttp 的 Content-Type 是第 5 节表格里那条坑;set_fail_handler 也在第 5 节------参考代码定义了 OnFail 却没注册,补上这行「连接异常」才会真的打印。测试页用 C++17 的原始字符串 R"html(...)html" 包起来,省掉逐行 ss << 和引号转义;set_body 接收 std::string,传 const char * 会自动转换。

A.3 makefile

比参考 makefile 多了 -I.、all 和 .PHONY,编译器与参数抽成变量,换工具链只改变量。

📄 makefile · 缩进必须是真 tab,空格会报 missing separator

makefile 复制代码
# websocketpp echo 服务:make 编译,make clean 清理
CXX      ?= g++
# 头文件在系统路径时 -I. 够用;clone 到 third_party/ 时换成 -Ithird_party/websocketpp
CXXFLAGS ?= -std=c++17 -O2 -Wall -I.
LDFLAGS  ?= -lpthread -lboost_system

all: server

server: server.cc
	$(CXX) $(CXXFLAGS) $^ -o $@ $(LDFLAGS)

clean:
	rm -rf server

.PHONY: all clean

⚠️ 两个跨平台细节

-I 指到哪:系统装了 libwebsocketpp-dev 时,#include <websocketpp/...> 不用 -I;把仓库 clone 到 third_party/websocketpp 后要写成 -Ithird_party/websocketpp(该目录下才是 websocketpp/ 头文件夹)。另外 clean 里的 rm -rf 在 Windows/MinGW 下没有,换 WSL 或改成 del server.exe。

A.4 webroot/index.html

内容和 A.2 里的 kIndexHtml 一字不差,服务端并不读这个文件 ------它存在是为了两个场景:不用启动服务端,直接用 file:// 打开验证 ws://127.0.0.1:8888 通不通;以及以后想把页面改成真正的静态资源时,有现成文件可用。

📄 webroot/index.html · 与内嵌版本同一份页面

html 复制代码
<!doctype html>
<html>
<head><meta charset="utf-8"><title>websocketpp echo</title></head>
<body>
<h1>websocketpp echo 测试页</h1>
<p id="log">连接中...</p>
<script>
  const ws = new WebSocket("ws://127.0.0.1:8888");
  const log = (s) => { document.getElementById("log").textContent = s; console.log(s); };
  ws.onopen    = () => { log("已连接"); ws.send("hello websocket"); };
  ws.onmessage = (e) => log("收到回显: " + e.data);
  ws.onclose   = (e) => log("连接关闭 code=" + e.code);
  ws.onerror   = () => log("连接失败:检查 ws:// 地址与端口");
</script>
</body>
</html>

这个文件还有个排查用途:file:// 打开能连上、http://127.0.0.1:8888 打开却连不上,问题就在 OnHttp 返回的内容,不在 WebSocket 服务本身。

A.5 编译与启动

两条命令,编译完在前台跑,日志直接打在终端。

bash 复制代码
make clean && make     # 从干净状态编译,首次十几秒(模板 + Boost.Asio 的代价)
./server               # 前台启动,Ctrl+C 退出

A.6 验证步骤与预期输出

三件事逐条确认:响应头对不对、页面有没有回显、关闭码有没有打出来。

  1. 另开一个终端执行 curl -i http://127.0.0.1:8888 | head -5,响应头里应有 Content-Type: text/html; charset=utf-8,缺了它中文可能乱码。
  2. 浏览器打开 http://127.0.0.1:8888,页面从「连接中...」变成「收到回显: hello websocket」;F12 打开 DevTools,Network → WS 里能看到 101 Switching Protocols 和两条 text 帧。
  3. 关掉标签页,终端应打印 连接关闭 code=1000 reason=,1000 就是正常关闭。
  4. Ctrl+C 结束服务端。

终端预期输出(与第 7 节一致):

text 复制代码
$ make clean && make
rm -rf server
g++ -std=c++17 -O2 -Wall -I. server.cc -o server -lpthread -lboost_system

$ ./server
处理http请求
连接成功
收到消息: hello websocket
连接关闭 code=1000 reason=

看到两次 处理http请求 是浏览器顺带请求了 /favicon.ico,属正常现象,不是回调注册错了。

A.7 常见错误对照

报错 / 现象 原因 修法
websocketpp/server.hpp: No such file or directory 头文件没装,或 clone 了却没加 -I 装 libwebsocketpp-dev,或改 -Ithird_party/websocketpp
asio.hpp: No such file or directory 只装了 websocketpp,没装 Boost sudo apt install libboost-system-dev
cannot find -lboost_system Boost 库没装或版本路径不同 装 libboost-system-dev;Boost ≥ 1.69 可先去掉 -lboost_system 试
makefile: *** missing separator 命令行用空格缩进 换成真 tab
端口在监听但浏览器连不上 漏了 start_accept() 补上,第 4 节最容易漏的一行
页面中文乱码 响应缺 Content-Type 或 charset 补 append_header("Content-Type", "text/html; charset=utf-8")

与正文示例的差异就三处 :OnClose 从「只打一行字」改成打印远端关闭码;OnHttp 加了 Content-Type 并把测试页内嵌进来(正文第 7 节只给了片段和两行 C++);main 补注册了 set_fail_handler。其余代码与正文里的参考代码完全一致,可以对照着看。

相关推荐
无足鸟ICT1 小时前
【RHCA+】_etc_yum.repos.d_文件名.repo
linux·运维·centos
骑着蜗牛撵大象3271 小时前
字节序、TLV 到 netlink:Linux 网络消息的编解码全链路
linux·网络·php·etl·序列化·字节序·tlv
大陈子编程1 小时前
C++ CSP-J 与 CSP-S 题单:洛谷 140 题知识点全覆盖
c++
tianyuanwo1 小时前
RPM Spec中Name: %{?scl_prefix}xxx中增加宏定义的设计原理
linux·服务器·rpm·scl
漫步人生走在路上2 小时前
外贸SEO中的品牌词与产品词策略:大卖家和小卖家的不同打法
大数据·javascript·网络·数据库·人工智能·搜索引擎
张小姐的猫2 小时前
【MySQL】数据类型
android·linux·数据库·c++·mysql·adb·php
讳疾忌医丶2 小时前
编译器眼中的 C++ 循环:从指针别名、IEEE 754 结合律到 SIMD 语义证明
开发语言·c++
金色熊族2 小时前
ebpf程序开发(一)--ubuntu20.04
linux·ebpf
‎ദ്ദിᵔ.˛.ᵔ₎2 小时前
Linux 线程控制
linux