一、实现思路
-
Web
端就是使用html + JavaScript
来实现页面,通过WebSocket
长连接和服务器保持通讯,协议的payload
使用JSON
格式封装 -
服务端使用
C++
配合第三方库WebSocket++
和nlonlohmann
库来实现
二、Web端
2.1 界面显示
首先,使用html
来设计一个简单的静态框架:
- 有一个聊天室的标题
- 然后一个文本框加上一个发送按钮
html
<body>
<h1>WebSocket简易聊天室</h1>
<div id="app">
<input id="sendMsg" type="text" />
<button id="sendBtn">发送</button>
</div>
</body>
然后我们还需要显示聊天信息,我们可以利用脚本每次有信息的时候,就把这个信息嵌入到<body>
里面,这个信息本身存放在<div>
里面,如下:
- 加入房间显示蓝色
- 离开房间显示红色
- 聊天信息显示黑色
js
function showMessage(str, type) {
var div = document.createElement("div");
div.innerHTML = str;
if (type == "enter") div.style.color = "blue";
else if (type == "leave") div.style.color = "red";
document.body.appendChild(div);
}
2.2 WebSocket 连接
接下来我们配合JavaScript
脚本,先连接服务端的WebSocket
服务器
js
var websocket = new WebSocket('ws://192.168.217.128:9002');
我们需要实现WebSocket
的几个回调函数:
- onopen
- 这个回调函数在连接服务器成功时触发,我们在连接成功的时候,绑定上发送按钮的点击事件
js
// 连接成功
websocket.onopen = function () {
console.log("连接服务器成功");
document.getElementById("sendBtn").onclick = function () {
var msg = document.getElementById("sendMsg").value;
if (msg) {
websocket.send(msg);
}
};
};
- onmessage
- 这个回调函数在接收到服务端的消息后触发,这里是
JSON
格式,我们服务端定义了data
为key
,对应的消息就是data
后面的value
了
js
websocket.onmessage = function (e) {
var mes = JSON.parse(e.data);
showMessage(mes.data, mes.type);
};
- onclose
- 这个回调函数在连接断开的时候触发,在这里我们简单的打印一下即可:
js
// 连接关闭
websocket.onclose = function (event) {
console.log("连接已关闭", "代码:", event.code, "原因:", event.reason);
};
2.3 完整代码
完整的Web代码如下:
html
<!DOCTYPE html>
<html>
<body>
<h1>WebSocket简易聊天室</h1>
<div id="app">
<input id="sendMsg" type="text" />
<button id="sendBtn">发送</button>
</div>
</body>
<script>
function showMessage(str, type) {
var div = document.createElement("div");
div.innerHTML = str;
if (type == "enter") div.style.color = "blue";
else if (type == "leave") div.style.color = "red";
document.body.appendChild(div);
}
var websocket = new WebSocket('ws://192.168.217.128:9002');
// 连接成功
websocket.onopen = function () {
console.log("连接服务器成功");
document.getElementById("sendBtn").onclick = function () {
var msg = document.getElementById("sendMsg").value;
if (msg) {
websocket.send(msg);
}
};
};
// 接收消息
websocket.onmessage = function (e) {
var mes = JSON.parse(e.data);
showMessage(mes.data, mes.type);
};
// 连接关闭
websocket.onclose = function (event) {
console.log("连接已关闭", "代码:", event.code, "原因:", event.reason);
};
// 错误处理
websocket.onerror = function (error) {
console.error("WebSocket错误:", error);
};
</script>
</html>
三、服务端代码
确保你已经配置好了第三方库,下面我们开始讲解服务端代码:
3.1 echo_server
改造
首先这里我们是使用WebSocket++
这个第三方库的配套示例echo_server.cpp
改造的,因此我们只讲解改造的部分,未修改源代码echo_server.cpp
如下:
cpp
// examples目录是官方的一些例子 本次使用的是echo_server\echo_server.cpp
// 该原程序只支持一对一发送后回复
// 改造后可以通知所有连接上来的客户端。
// 编译 g++ main.cpp -o main -lboost_system -lboost_chrono
#include <websocketpp/config/asio_no_tls.hpp>
#include <websocketpp/server.hpp>
#include <iostream>
#include <list>
#include <functional>
typedef websocketpp::server<websocketpp::config::asio> server;
using websocketpp::lib::bind;
using websocketpp::lib::placeholders::_1;
using websocketpp::lib::placeholders::_2;
// pull out the type of messages sent by our config
typedef server::message_ptr message_ptr;
std::list<websocketpp::connection_hdl> vgdl;
// Define a callback to handle incoming messages
void on_message(server *s, websocketpp::connection_hdl hdl, message_ptr msg)
{
// std::cout << "on_message called with hdl: " << hdl.lock().get()
// << " and message: " << msg->get_payload()
// << std::endl;
// check for a special command to instruct the server to stop listening so
// it can be cleanly exited.
if (msg->get_payload() == "stop-listening")
{
s->stop_listening();
return;
}
for (auto it = vgdl.begin(); it != vgdl.end(); it++)
{
if (it->expired())//移除连接断开的
{
it = vgdl.erase(it);
continue;
}
if (it != vgdl.end())
s->send(*it, msg->get_payload(), msg->get_opcode());
// t.wait();
}
// try {
// s->send(hdl, msg->get_payload()+std::string("aaaaa"), msg->get_opcode());
// } catch (websocketpp::exception const & e) {
// std::cout << "Echo failed because: "
// << "(" << e.what() << ")" << std::endl;
// }
}
//将每个连接存入容器
void on_open(websocketpp::connection_hdl hdl)
{
std::string msg = "link OK";
printf("%s\n", msg.c_str());
// printf("fd %d\n",(int)hdl._M_ptr());
vgdl.push_back(hdl);
}
void on_close(websocketpp::connection_hdl hdl)
{
std::string msg = "close OK";
printf("%s\n", msg.c_str());
}
int main()
{
// Create a server endpoint
server echo_server;
try
{
// Set logging settings 设置log
echo_server.set_access_channels(websocketpp::log::alevel::all);
echo_server.clear_access_channels(websocketpp::log::alevel::frame_payload);
// Initialize Asio 初始化asio
echo_server.init_asio();
// Register our message handler
// 绑定收到消息后的回调
echo_server.set_message_handler(bind(&on_message, &echo_server, ::_1, ::_2));
//当有客户端连接时触发的回调
std::function<void(websocketpp::connection_hdl)> f_open;
f_open = on_open;
echo_server.set_open_handler(websocketpp::open_handler(f_open));
//关闭是触发
std::function<void(websocketpp::connection_hdl)> f_close(on_close);
echo_server.set_close_handler(f_close);
// Listen on port 9002
echo_server.listen(9002);//监听端口
// Start the server accept loop
echo_server.start_accept();
// Start the ASIO io_service run loop
echo_server.run();
}
catch (websocketpp::exception const &e)
{
std::cout << e.what() << std::endl;
}
catch (...)
{
std::cout << "other exception" << std::endl;
}
}
3.2 管理用户名
-
对于每一个连接上来的用户,对应唯一的
connection_hdl
类型的值,是一个用于标识和跟踪 WebSocket 连接的句柄类型,其本质是对连接对象的弱引用(封装了std::weak_ptr
) -
因此我们可以把它映射到每一个用户名,这样我们就知道发送过来的连接句柄对应是哪个用户了,实际这里我们每次连接都分配了一个用户名:
cpp
std::string username = "user:" + std::to_string(++totalUser);
这里我们使用std::map
进行映射,由于websocketpp::connection_hdl
类型不具备运算符<
,因此我们自己写一个仿函数,里面用它内部的weak_ptr
进行比较
cpp
struct ConnectionHdlCompare {
bool operator()(const websocketpp::connection_hdl& a, const websocketpp::connection_hdl& b) const {
// 通过获取底层的弱指针的原始指针进行比较
return a.lock() < b.lock();
}
};
然后这样定义std::map
:
cpp
std::map<websocketpp::connection_hdl, std::string, ConnectionHdlCompare> user_map;
3.3 连接事件
- 每个用户连接的时候,需要分配一个唯一的用户名,然后广播给全部用户,该用户加入房间了,我们使用
JSON
进行序列化,类型是enter
,同时,把这个连接句柄加入全局的链表中
cpp
void on_open(websocketpp::connection_hdl hdl)
{
std::string msg = "link OK";
printf("%s\n", msg.c_str());
vgdl.push_back(hdl);
// 生成唯一用户名
std::string username = "user:" + std::to_string(++totalUser);
user_map[hdl] = username; // 记录连接对应的用户名
// 广播用户加入消息
json j;
j["type"] = "enter";
j["data"] = username + "加入房间";
std::string json_str = j.dump();
std::cout << "json_str = " << json_str << std::endl;
send_msg(&echo_server, json_str);
}
-
广播函数
send_msg
我们重载了两个版本,其中一个版本是字符串std::string
发送,另一个则是使用message_ptr
类型,其内部封装了消息帧的数据,比如payload
负载、opcode
操作码,它本质也是一个weak_ptr
-
创建文本帧如下,我们发送
JSON
,使用的是text
0x0
:延续帧(用于分片传输大消息,当前帧是消息的中间部分);0x1
:文本帧(payload 是 UTF-8 编码的文本数据);0x2
:二进制帧(payload 是任意二进制数据,如图片、protobuf 等);0x8
:关闭帧(通知对方关闭连接,payload 可包含关闭原因);0x9
:Ping 帧(心跳检测,用于确认连接活性);0xA
:Pong 帧(响应 Ping 帧的心跳回复)。
-
遍历的时候,需要判断是否指针失效了,因为
weak_ptr
本身并不能管理对象,需要转换为shared_ptr
来查看,可以调用expire()
函数来看看是否为nullptr
,我们只对有效的连接发送消息,无效的连接直接从链表删除这个句柄
cpp
void send_msg(server *s, message_ptr msg){
for (auto it = vgdl.begin(); it != vgdl.end(); )
{
if (it->expired())
{
it = vgdl.erase(it); // 正确处理迭代器失效:连接断开
}
else
{
try {
s->send(*it, msg->get_payload(), msg->get_opcode());
} catch (websocketpp::exception const & e) {
std::cout << "Broadcast failed because: " << e.what() << std::endl;
}
++it; // 只有在未删除元素时才递增迭代器
}
}
}
void send_msg(server *s,std::string msg){
for (auto it = vgdl.begin(); it != vgdl.end(); )
{
if (it->expired())
{
it = vgdl.erase(it); // 正确处理迭代器失效:连接断开
}
else
{
try {
s->send(*it, msg, websocketpp::frame::opcode::text);
} catch (websocketpp::exception const & e) {
std::cout << "Broadcast failed because: " << e.what() << std::endl;
}
++it; // 只有在未删除元素时才递增迭代器
}
}
}
3.4 关闭事件
在触发关闭连接的回调函数中,我们要删除对应map
里面的用户名,并且我们将这个用户离开房间的消息转发给所有人,JSON
序列化的type
为leave
cpp
void on_close(websocketpp::connection_hdl hdl)
{
for (auto it = vgdl.begin(); it != vgdl.end(); )
{
if (it->expired())
{
it = vgdl.erase(it); // 正确处理迭代器失效:连接断开
}
}
std::string msg = "close OK";
printf("%s\n", msg.c_str());
// 清理用户映射表并广播离开消息
if (user_map.find(hdl) != user_map.end()) {
std::string username = user_map[hdl];
user_map.erase(hdl); // 删除映射记录
json j;
j["type"] = "leave";
j["data"] = username + "离开房间";
std::string json_str = j.dump();
send_msg(&echo_server, json_str);
}
}
3.5 发送消息事件
-
在收到客户端的消息之后,我们需要将这个消息转发给所有人,
JSON
序列化的type
为message
,发送的时候需要加上用户名,这样前端显示才有用户名: -
这里是通过修改
message_ptr
的payload
的形式来发送消息的,因此我们调用send_msg
是第一个重载版本
cpp
void on_message(server *s, websocketpp::connection_hdl hdl, message_ptr msg)
{
std::cout << "on_message called with hdl: " << hdl.lock().get()
<< " and message: " << msg->get_payload()
<< std::endl;
// check for a special command to instruct the server to stop listening so
// it can be cleanly exited.
if (msg->get_payload() == "stop-listening")
{
s->stop_listening();
return;
}
//转发消息
json j;
j["type"] = "message";
j["data"] = user_map[hdl] + "说:" + msg->get_payload();
std::string json_str = j.dump();
msg->set_payload(json_str);
std::cout << "msg = " << msg->get_payload() << std::endl;
send_msg(s, msg);
}
3.6 完整代码
完整的服务端代码如下:
cpp
// examples目录是官方的一些例子 本次使用的是echo_server\echo_server.cpp
// 该原程序只支持一对一发送后回复
// 改造后可以通知所有连接上来的客户端。
// 编译 g++ main.cpp -o main -lboost_system -lboost_chrono
#include <websocketpp/config/asio_no_tls.hpp>
#include <websocketpp/server.hpp>
#include <iostream>
#include <list>
#include <functional>
#include <mutex> // 添加互斥锁头文件
//json解析
#include<nlohmann/json.hpp>
using json = nlohmann::json;
typedef websocketpp::server<websocketpp::config::asio> server;
using websocketpp::lib::bind;
using websocketpp::lib::placeholders::_1;
using websocketpp::lib::placeholders::_2;
// pull out the type of messages sent by our config
typedef server::message_ptr message_ptr;
std::list<websocketpp::connection_hdl> vgdl;
std::mutex vgdl_mutex; // 添加互斥锁保护连接列表
// 自定义比较函数对象
struct ConnectionHdlCompare {
bool operator()(const websocketpp::connection_hdl& a, const websocketpp::connection_hdl& b) const {
// 通过获取底层的弱指针的原始指针进行比较
return a.lock() < b.lock();
}
};
// 使用自定义比较函数对象的连接-用户名映射表
std::map<websocketpp::connection_hdl, std::string, ConnectionHdlCompare> user_map; // 新增:连接-用户名映射表
// Define a callback to handle incoming messages
// Create a server endpoint
server echo_server;
int totalUser = 0;
void send_msg(server *s, message_ptr msg){
for (auto it = vgdl.begin(); it != vgdl.end(); )
{
if (it->expired())
{
it = vgdl.erase(it); // 正确处理迭代器失效:连接断开
}
else
{
try {
s->send(*it, msg->get_payload(), msg->get_opcode());
} catch (websocketpp::exception const & e) {
std::cout << "Broadcast failed because: " << e.what() << std::endl;
}
++it; // 只有在未删除元素时才递增迭代器
}
}
}
void send_msg(server *s,std::string msg){
for (auto it = vgdl.begin(); it != vgdl.end(); )
{
if (it->expired())
{
it = vgdl.erase(it); // 正确处理迭代器失效:连接断开
}
else
{
try {
s->send(*it, msg, websocketpp::frame::opcode::text);
} catch (websocketpp::exception const & e) {
std::cout << "Broadcast failed because: " << e.what() << std::endl;
}
++it; // 只有在未删除元素时才递增迭代器
}
}
}
void on_message(server *s, websocketpp::connection_hdl hdl, message_ptr msg)
{
std::cout << "on_message called with hdl: " << hdl.lock().get()
<< " and message: " << msg->get_payload()
<< std::endl;
// check for a special command to instruct the server to stop listening so
// it can be cleanly exited.
if (msg->get_payload() == "stop-listening")
{
s->stop_listening();
return;
}
//转发消息
json j;
j["type"] = "message";
j["data"] = user_map[hdl] + "说:" + msg->get_payload();
std::string json_str = j.dump();
msg->set_payload(json_str);
std::cout << "msg = " << msg->get_payload() << std::endl;
send_msg(s, msg);
}
//将每个连接存入容器
void on_open(websocketpp::connection_hdl hdl)
{
std::string msg = "link OK";
printf("%s\n", msg.c_str());
vgdl.push_back(hdl);
// 生成唯一用户名
std::string username = "user:" + std::to_string(++totalUser);
user_map[hdl] = username; // 记录连接对应的用户名
// 广播用户加入消息
json j;
j["type"] = "enter";
j["data"] = username + "加入房间";
std::string json_str = j.dump();
std::cout << "json_str = " << json_str << std::endl;
send_msg(&echo_server, json_str);
}
void on_close(websocketpp::connection_hdl hdl)
{
for (auto it = vgdl.begin(); it != vgdl.end(); )
{
if (it->expired())
{
it = vgdl.erase(it); // 正确处理迭代器失效:连接断开
}
}
std::string msg = "close OK";
printf("%s\n", msg.c_str());
// 清理用户映射表并广播离开消息
if (user_map.find(hdl) != user_map.end()) {
std::string username = user_map[hdl];
user_map.erase(hdl); // 删除映射记录
json j;
j["type"] = "leave";
j["data"] = username + "离开房间";
std::string json_str = j.dump();
send_msg(&echo_server, json_str);
}
}
int main()
{
try
{
// Set logging settings 设置log
echo_server.set_access_channels(websocketpp::log::alevel::all);
echo_server.clear_access_channels(websocketpp::log::alevel::frame_payload);
// Initialize Asio 初始化asio
echo_server.init_asio();
// Register our message handler
// 绑定收到消息后的回调
echo_server.set_message_handler(bind(&on_message, &echo_server, ::_1, ::_2));
//当有客户端连接时触发的回调
std::function<void(websocketpp::connection_hdl)> f_open;
f_open = on_open;
echo_server.set_open_handler(websocketpp::open_handler(f_open));
//关闭是触发
std::function<void(websocketpp::connection_hdl)> f_close(on_close);
echo_server.set_close_handler(f_close);
// Listen on port 9002
echo_server.listen(9002);//监听端口
// Start the server accept loop
echo_server.start_accept();
// Start the ASIO io_service run loop
echo_server.run();
}
catch (websocketpp::exception const &e)
{
std::cout << e.what() << std::endl;
}
catch (...)
{
std::cout << "other exception" << std::endl;
}
}
四、运行结果
编译服务端并启动:
shell
g++ main.cpp -o main -lboost_system -lboost_chrono
./main
在两个浏览器中打开我们的Web
服务端,这里是本地的,所以是
http
http://127.0.0.1:5500/chatClient.html
两个用户加入房间、聊天、离开的效果有不同的颜色,如下所示
