简介:这是一套面向C++开发者的WebSocket编程示例代码,整合Boost、jsoncpp与websocketpp库,提供可直接编译的客户端/服务器Demo,适合需要快速上手WebSocket协议与相关C++库的初中级开发者。压缩包内文件总数达14553个,主体为约14026个hpp头文件,另含226个ipp、157个h头文件及5个cpp源文件,辅以CMake配置与样例文件,包体约154.62MB,可用于搭建完整编译环境和理解项目结构。目前已有1077人下载学习。通过该示例可掌握websocketpp的握手与收发流程,了解Boost库在多线程与网络I/O上的配合方式,以及jsoncpp在JSON序列化/反序列化中的典型用法;CMake构建配置还能帮助读者在不同操作系统上复现一致的编译过程。测试用例与代码组织方式便于本地调试和二次开发,是学习C++ WebSocket通信并快速产出可运行程序的有力参考。
1. 一个能直接编译的 C++ WebSocket Demo:内网推送从轮询改成实时只需一个下午
公司内网有个数据上报服务,C++ 写的,原本靠前端每 3 秒轮询一次拉数据,压力大、延迟高,领导要求改成 WebSocket 实时推送。我翻遍了手头的库,最后落地的方案就是基于 Boost.Beast 写一个最小可用的 WebSocket 服务端和客户端,也就是你现在看到的这份 Demo 源码包。它最实在的地方在于:不是那种只贴片段跑不起来的示例,而是给全了服务端握手、消息收发、客户端连接和心跳的完整代码,并且声明可以直接编译。只要本机装好了 Boost 和编译工具链,按文中的命令就能跑出一个能互相推送消息的 C/S 小程序。适合刚接触 C++ WebSocket 想快速打通链路的开发者,也适合要给老服务加实时通道的人,拿它当骨架改业务非常顺手。
2. 技术选型和依赖准备:为什么用 Boost.Beast 而不是自己撸 Socket
2.1 WebSocket 协议的核心概念:握手、帧、掩码
WebSocket 不是裸的 TCP 长连接,它有自己的一套协议规则。初次接触的开发者经常把 WebSocket 和 TCP Socket 混为一谈,以为就是客户端 connect 上去然后 read/write,实际上中间夹了一个关键的 HTTP Upgrade 握手过程。客户端先发一个带Sec-WebSocket-Key的 HTTP 请求,服务端把 Key 拼上固定的 GUID 做 SHA-1 哈希,base64 之后通过Sec-WebSocket-Accept响应头返回,握手完成之后,这个 TCP 连接才升级成 WebSocket 通道。之后的数据都遵循帧格式:FIN 位表示消息是否结束,opcode 区分文本帧、二进制帧、关闭帧、Ping/Pong,客户端发给服务端的帧还需要做掩码处理,服务端回给客户端的帧不需要掩码。
这套帧协议看起来不复杂,但手写实现容易在边界问题上翻车——比如分片消息的拼接、掩码 key 的提取、UTF-8 校验、超大帧的缓冲区管理。Boost.Beast 把这些细节都封装好了,提供了websocket::stream这个模板类,传入底层 TCP stream 就能得到一个完整的 WebSocket 会话对象,读写接口跟 Asio 风格一致,异步操作也完全复用 boost::asio 的回调模型。这也是我选择 Boost.Beast 而不是自己封装协议的头号原因:它把协议细节变成了黑匣子,你可以专注于业务代码,而不是去调帧格式的位运算。
2.2 Boost 版本选择和依赖组件的取舍
Boost.Beast 对 Boost 版本有一定要求,建议使用 1.70 以上的版本,因为低版本的 Beast 接口差异比较大,像beast::websocket::stream的模板参数和错误处理方式都有变动。我用的是 1.74 版本,编译过程比较顺利,没有碰到接口不兼容的问题。如果你的系统自带的 Boost 版本过低,比如 CentOS 7 默认的 1.53,我一般不会去折腾系统包,而是直接下载新版 Boost 源码,只编译需要的模块,避免全量编译浪费时间。
这个 Demo 依赖的 Boost 组件其实很少。Beast 和 Asio 大部分是 header-only 的实现,真正需要链接成库的只有 boost_system 和 boost_thread。编译方法是用 Boost 自带的 b2 工具,命令行参数大致是这样:
./bootstrap.sh --prefix=/usr/local/boost_1_74_0 ./b2 install --with=system --with=thread --prefix=/usr/local/boost_1_74_0参数说明:--prefix指定 Boost 安装路径,后续编译程序时要通过-I和-L指向这里;--with=system和--with=thread只编译 system 和 thread 两个库。Boost.Thread 在 WebSocket 场景里主要用来支撑多线程下的 io_context 并发,如果你的服务端是单线程事件循环,thread 库其实可以不链接,但为了保险,我还是建议把这两个都编上,因为不同平台的 Boost 内部依赖可能会有差异。
2.3 环境准备清单和版本兼容性验证
在动手编译 Demo 之前,先确认环境里这几样东西是齐的:编译器支持 C++11 或更高标准,gcc 4.9 以上最稳妥;Boost 版本在 1.70 以上;如果用到 CMake,版本不低于 3.10。然后可以写一个极简的测试代码验证 Boost.Beast 头文件能正常引入:
#include <boost/beast/core.hpp> #include <boost/asio/ip/tcp.hpp> #include <iostream> int main() { std::cout << "Boost.Beast header check OK" << std::endl; return 0; }编译命令是g++ check.cpp -I /usr/local/boost_1_74_0 -o check。如果这步能通过,说明头文件路径没问题。接着再用真正的 Demo 源码编译,如果链接阶段报 undefined reference 之类的错误,那多半是库文件路径没指对或者库本身没编出来。环境这关过了,后面跑通就是几分钟的事。
3. 核心模块拆解:服务端消息推送与客户端连接的完整链路
3.1 目录结构与文件职责
这份 Demo 的目录结构非常干净,一共就几个文件,拆开看每个文件的作用非常明确。拿到源码包后我建议先按这个列表过一遍,心里就有数了:
websocket_demo/ ├── CMakeLists.txt # CMake 构建脚本 ├── server.cpp # 服务端入口:监听、握手、消息广播 ├── client.cpp # 客户端入口:连接、发送、接收、心跳 ├── websocket_session.h # 服务端会话类声明 └── websocket_session.cpp # 服务端会话实现server.cpp是服务端的主流程,负责创建 TCP acceptor,接受连接后为每个客户端创建一个websocket_session对象。websocket_session.h和.cpp定义了一个会话类,封装了握手成功之后的所有读写操作,包括消息的异步接收、解析和回显广播。client.cpp则是配套的测试客户端,可以手动输入消息发给服务端,也能收到服务端的推送。这个分层方式和我平时写网络服务的习惯一致:主流程只管连接的生命周期,具体的业务读写全部塞到会话类里,后续要加消息处理逻辑只需要改一个类。
3.2 服务端握手:从 TCP 到 WebSocket 的升级过程
服务端的第一步是建立 TCP 监听,这一步和普通 TCP Server 完全一样,没有什么特殊之处。核心区别在于连接建立之后要做 HTTP Upgrade,把 TCP socket 转换成 WebSocket 流。Demo 里用的是beast::websocket::stream,构造的时候传入 TCP socket,然后调用其accept()方法完成握手。看核心代码:
// 每个新连接创建一个 WebSocket 会话 void do_accept() { acceptor_.async_accept( [this](boost::system::error_code ec, boost::asio::ip::tcp::socket socket) { if (!ec) { // 把 TCP socket 移交到 websocket 会话处理 std::make_shared<websocket_session>(std::move(socket))->start(); } do_accept(); }); }这段代码的作用是循环接受连接,async_accept是异步的,每个新连接进来之后,把 socket 对象 move 到websocket_session的构造函数里,然后调用start()启动会话。注意这里用了std::make_shared创建共享指针,会话对象的生命周期由自身管理,不需要主流程手动删除。start()函数内部调用了ws_.accept(),这个函数会完成 HTTP 握手,解析客户端发来的 Upgrade 请求头,返回 101 状态码完成协议切换。
accept()执行完毕之后,这个连接就彻底变成了 WebSocket 通道,后续的收发必须走websocket::stream的接口,不能再直接用底层的 TCP socket 读写,否则拿到的是一堆带帧头的原始数据。很多从 TCP 转过来的开发者在这步会踩坑,所以提醒一句:握手之后,你的操作对象永远是ws_,而不是原来的 socket。
3.3 消息收发与广播:把一帧数据拆开看
会话建立之后,服务端要做的事情就是异步读消息,收到之后回显给客户端。这里的回显是对单连接,真实项目里通常要改成向所有连接广播。先看单连接收发的实现:
void websocket_session::start() { ws_.async_read( buffer_, [this](boost::system::error_code ec, std::size_t bytes) { if (!ec) { // 把收到的数据拼成字符串交给业务处理 std::string msg = beast::buffers_to_string(buffer_.data()); std::cout << "收到消息: " << msg << std::endl; // 回显:异步写回给客户端 ws_.text(true); ws_.async_write( boost::asio::buffer(msg), [this](boost::system::error_code, std::size_t) { start(); // 继续等待下一条消息 }); } else if (ec != beast::websocket::error::closed) { std::cerr << "读取失败: " << ec.message() << std::endl; } }); }async_read的第一个参数是缓冲区,第二个参数是回调函数。数据读进来之后,缓冲区里存的是一整个完整的 WebSocket 消息——Beast 已经帮我们处理了帧分片拼接的逻辑,不需要自己攒数据。beast::buffers_to_string把缓冲区里的数据转成 std::string,便于业务处理。回显的时候,先调用ws_.text(true)声明这次发送的是文本帧,然后async_write把字符串发回去。
关键点在这里:async_write的回调里再次调用了start(),这样就能循环读取下一条消息。如果你不递归调用,这个会话读完第一条消息后就再也不读新数据了,连接处于假死状态,这是最常见的逻辑遗漏。另外注意错误码的处理,beast::websocket::error::closed表示客户端主动关闭,这个不算异常,但流程上要做区分。
如果要改成广播,常见做法是在服务端维护一个std::set<std::shared_ptr<websocket_session>>集合,收到消息后遍历集合逐个调用async_write。需要注意的是,Beast 不允许对同一个 stream 并发发起多个async_write操作,所以在广播时要保证每个会话在同一时刻只有一个写操作在飞,否则会触发断言错误。我一般会在会话类里加一个写队列,把要发的消息排队,写完一条再发下一条。
3.4 客户端实现:连接、心跳、断线重连
客户端这边的链路是反过来的:先解析 URL,建立 TCP 连接,再发起 HTTP Upgrade 握手,握手成功后正常收发消息。Demo 的客户端逻辑比较简洁,核心代码是这样:
// 建立 TCP 连接 boost::asio::ip::tcp::resolver resolver(ioc); auto results = resolver.resolve(host, port); boost::asio::connect(ws_.next_layer(), results); // 完成 WebSocket 握手 ws_.handshake(host, "/"); // 发送一条测试消息 ws_.write(boost::asio::buffer(std::string("hello websocket"))); // 同步读取服务端回显 beast::flat_buffer buffer; ws_.read(buffer); std::cout << beast::buffers_to_string(buffer.data()) << std::endl;resolver.resolve把域名和端口解析成端点列表,boost::asio::connect完成 TCP 连接。注意ws_.next_layer()返回的是底层的 TCP stream,TCP 连接走的是这个接口。ws_.handshake(host, "/")是客户端主动发起的握手,第一个参数是 Host 头字段,服务端有时候会校验这个值,第二个参数是请求路径。如果是连接云服务商提供的 WebSocket 网关,路径通常是要填的,比如/ws或/v1/chat,具体看服务端路由怎么写。
握手成功后,write和read是同步接口,适合测试和命令行工具。beast::flat_buffer是 Beast 自带的动态增长的缓冲区,不需要手动管理大小。这个客户端的缺点是没有处理断线重连,真实场景里网络抖动是常态,我把重连逻辑写在了二次开发章节里,这里不展开。
3.5 异步回调和共享指针的生命周期管理
Beast 的异步操作回调里,如果你用this捕获,就必须保证会话对象的生命周期长于所有未完成的异步操作。我在这个 Demo 里用的是std::make_shared创建会话对象,然后在每个回调里显式捕获this。这个写法的风险在于:如果异步操作还没完成,而外部已经没有 shared_ptr 引用了,对象一旦析构,回调就悬空,程序会崩溃。
解决这个问题的一种做法是在每个异步操作内部先把自己的 shared_ptr 拷贝一份,让回调持有会话的引用计数。具体来说,可以把start()改成接收一个std::shared_ptr<websocket_session>参数,在 lambda 里捕获这个参数,确保回调执行期间会话对象不会被析构。Demo 里为了简化,代码路径比较短,直接捕获this勉强够用,但你想把它改造成生产级服务端的时候,这一步一定要处理,否则压测到高并发的时候会偶发崩溃,属于典型的内存踩踏问题。
4. 编译运行与避坑排查:Boost 库链接和协议细节里的五个坑
4.1 两条编译路线:g++ 直编和 CMake
先说最快验证的方式,直接用 g++ 编译两个源文件:
g++ -std=c++11 server.cpp websocket_session.cpp \ -I /usr/local/boost_1_74_0/include \ -L /usr/local/boost_1_74_0/lib \ -lboost_system -lboost_thread -lpthread \ -o websocket_server g++ -std=c++11 client.cpp \ -I /usr/local/boost_1_74_0/include \ -L /usr/local/boost_1_74_0/lib \ -lboost_system -lboost_thread -lpthread \ -o websocket_client编译命令里-lboost_system和-lboost_thread是链接 Boost 的动态库,-lpthread是因为 Boost.Thread 底层依赖 POSIX 线程库,在 Linux 上必须加上。如果你的 Boost 是编译成静态库,链接参数会变成libboost_system.a的具体路径,命令写法略有不同。-I和-L的路径要和第二节里 b2 安装时的--prefix保持一致。
CMake 路线更适合工程化。Demo 自带的CMakeLists.txt里最关键的几行是:
find_package(Boost REQUIRED COMPONENTS system thread) include_directories(${Boost_INCLUDE_DIRS}) target_link_libraries(websocket_server ${Boost_LIBRARIES} pthread)find_package会自动寻找 Boost 的安装位置,COMPONENTS指定需要链接的模块。这里常见的坑是:如果你的 Boost 安装路径比较特殊,CMake 默认找不到,需要设置BOOST_ROOT环境变量或者传-DBOOST_ROOT=/usr/local/boost_1_74_0。编译时如果你看到Could not find Boost的报错,优先检查环境变量。
4.2 踩坑记录:编译报错、握手失败、乱码问题
踩坑一:编译时报undefined reference to boost::system::error_code::message()这类错误。原因是 boost_system 库没有链接或者链接顺序不对。g++ 的链接规则是库放在源文件后面,很多开发者习惯把-lboost_system写在源文件前面,这样会导致符号解析失败。解决方法是把链接库参数放到所有源文件之后。
踩坑二:运行时握手失败,客户端 log 显示The WebSocket handshake was rejected。这个大概率是服务端和客户端的协议版本不匹配,或者请求头缺失。常见做法是打印出服务端收到的 HTTP 原始请求,比对手册检查 Upgrade、Connection、Sec-WebSocket-Key 三个头是否完整。如果服务端是用 Nginx 做反向代理,还要检查 Nginx 的 Upgrade 头转发配置。
踩坑三:服务端收到的中文消息变成乱码。原因通常是客户端发送的时候用的是 GBK 编码,而服务端按 UTF-8 解析。WebSocket 协议本身不强制编码,一般约定是文本帧用 UTF-8。解决的办法简单粗暴:所有客户端统一用 UTF-8 发送,服务端收到后如果不确定编码,先按 UTF-8 校验,失败再走兜底方案。
踩坑四:高并发下程序崩溃,报assert(!write_in_progress_)。原因是对同一个 WebSocket stream 发起了并发写,Beast 不允许同时有两个写操作。解决方法是引入写队列,用boost::asio::post把写操作串行化。我在改造章节里有具体代码。
踩坑五:编译时 Boost 头文件版本和库文件版本不一致。这个最容易出现,尤其是系统自带一套 Boost,自己又编译了一套,-I指向新版头文件,而-L却链接到系统旧版库,结果就是各种诡异的运行时崩溃。解决方法是编译前确认-I和-L指向同一个前缀目录,或者干脆全部用系统自带版本,别混用。
5. 二次开发改造:给消息加类型、心跳保活和 WSS 加密
5.1 给消息加一个业务类型字段
Demo 里收的是纯文本字符串,真实业务通常要定义消息格式。我习惯在每条文本消息前加一个 4 字节的 JSON 头,或者干脆直接用 JSON 做信封。改造很简单,在服务端的消息处理函数里先解析出一个 type 字段:
// 假设消息格式是 {"type":"chat","data":"..."} void handle_message(const std::string& msg) { auto json = nlohmann::json::parse(msg); std::string type = json["type"]; if (type == "heartbeat") { // 回复 pong,不广播 ws_.text(true); ws_.async_write(boost::asio::buffer(std::string("{\"type\":\"pong\"}")), callback); } else if (type == "chat") { broadcast(msg); // 向所有会话转发 } }这一点改造能让路由逻辑非常清晰,后续加消息类型不需要改收发框架,只加 else if 分支就行。建议把所有消息类型定义成一个枚举,不要用裸字符串散落在代码里。
5.2 心跳保活与断线重连
WebSocket 连接经过中间代理或负载均衡时,空闲连接容易被回收。解决办法是客户端每 30 秒发一个 Ping 帧,服务端自动回 Pong。Beast 里设置了 ping 之后,协议层的控制帧会自动处理,不需要你手动回。服务端可以加一个超时判断:如果 90 秒内没有收到任何数据帧,就主动关闭该连接:
ws_.async_read( buffer_, [this](boost::system::error_code ec, std::size_t) { if (ec == beast::websocket::error::closed) { // 清理会话 } // 记录 last_active_time = 当前时间 start(); }); // 用 boost::asio::steady_timer 定时检查超时客户端那边要做断线重连,把所有建立连接和握手的逻辑抽成一个connect()函数,在回调里捕获错误码,如果连接失败就 sleep 2 秒后递归调用connect()。我一般还会加一个最大重连次数,防止服务端宕机导致客户端无限循环重连,烧掉 CPU。
5.3 从 ws 到 wss 的安全升级
Demo 跑通之后,如果要部署到公网环境,明文 ws 是不行的。Beast 支持 SSL 包装,把websocket::stream的模板参数从tcp::socket换成ssl::stream<tcp::socket>,然后调用ssl::context加载证书。改造要点是所有的 socket 操作要从next_layer()换成wss.next_layer().next_layer(),握手之前的 TCP 连接要显式走 TLS 握手。证书可以先用自签名的测试一下,生产环境再换正式证书。如果你不想在 C++ 层处理 SSL,另一个常见做法是部署 Nginx 做 TLS 终结,把 wss 转成内网 ws 转发给服务端,这个方案在改造成本上低很多,我第二次做类似项目的时候就走了 Nginx 这条路。
套接字超时、并发写保护、连接清理这三件套,是我每次从 Demo 往生产代码迁移时必须补的功课。那次做监控大屏推送,上线当天半夜服务端就崩了一次,排查下来就是写操作并发导致断言触发,从那以后我每次写 Beast 异步代码,都会强制走一遍「写操作是否串行、回调是否持锁、连接断开是否清理」这三个检查。希望这份拆解能帮你少踩几个同样的坑。
本文还有配套的精品资源,点击获取