一、实现思路
-
Web
端就是使用html + JavaScript
来实现页面,通过WebSocket
长连接和服务器保持通讯,协议的payload
使用JSON
格式封装 -
服务端使用
C++
配合第三方库WebSocket++
和nlonlohmann
库来实现
二、Web端
2.1 界面显示
首先,使用html
来设计一个简单的静态框架:
- 有一个聊天室的标题
- 然后一个文本框加上一个发送按钮
<body><h1>WebSocket简易聊天室</h1><div id="app"><input id="sendMsg" type="text" /><button id="sendBtn">发送</button></div>
</body>
然后我们还需要显示聊天信息,我们可以利用脚本每次有信息的时候,就把这个信息嵌入到<body>
里面,这个信息本身存放在<div>
里面,如下:
- 加入房间显示蓝色
- 离开房间显示红色
- 聊天信息显示黑色
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
服务器
var websocket = new WebSocket('ws://192.168.217.128:9002');
我们需要实现WebSocket
的几个回调函数:
- onopen
- 这个回调函数在连接服务器成功时触发,我们在连接成功的时候,绑定上发送按钮的点击事件
// 连接成功
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
了
websocket.onmessage = function (e) {var mes = JSON.parse(e.data);showMessage(mes.data, mes.type);
};
- onclose
- 这个回调函数在连接断开的时候触发,在这里我们简单的打印一下即可:
// 连接关闭
websocket.onclose = function (event) {console.log("连接已关闭", "代码:", event.code, "原因:", event.reason);
};
2.3 完整代码
完整的Web代码如下:
<!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
如下:
// 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 endpointserver echo_server;try{// Set logging settings 设置logecho_server.set_access_channels(websocketpp::log::alevel::all);echo_server.clear_access_channels(websocketpp::log::alevel::frame_payload);// Initialize Asio 初始化asioecho_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 9002echo_server.listen(9002);//监听端口// Start the server accept loopecho_server.start_accept();// Start the ASIO io_service run loopecho_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
) -
因此我们可以把它映射到每一个用户名,这样我们就知道发送过来的连接句柄对应是哪个用户了,实际这里我们每次连接都分配了一个用户名:
std::string username = "user:" + std::to_string(++totalUser);
这里我们使用std::map
进行映射,由于websocketpp::connection_hdl
类型不具备运算符<
,因此我们自己写一个仿函数,里面用它内部的weak_ptr
进行比较
struct ConnectionHdlCompare {bool operator()(const websocketpp::connection_hdl& a, const websocketpp::connection_hdl& b) const {// 通过获取底层的弱指针的原始指针进行比较return a.lock() < b.lock();}
};
然后这样定义std::map
:
std::map<websocketpp::connection_hdl, std::string, ConnectionHdlCompare> user_map;
3.3 连接事件
- 每个用户连接的时候,需要分配一个唯一的用户名,然后广播给全部用户,该用户加入房间了,我们使用
JSON
进行序列化,类型是enter
,同时,把这个连接句柄加入全局的链表中
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
,我们只对有效的连接发送消息,无效的连接直接从链表删除这个句柄
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
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
是第一个重载版本
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 完整代码
完整的服务端代码如下:
// 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 设置logecho_server.set_access_channels(websocketpp::log::alevel::all);echo_server.clear_access_channels(websocketpp::log::alevel::frame_payload);// Initialize Asio 初始化asioecho_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 9002echo_server.listen(9002);//监听端口// Start the server accept loopecho_server.start_accept();// Start the ASIO io_service run loopecho_server.run();}catch (websocketpp::exception const &e){std::cout << e.what() << std::endl;}catch (...){std::cout << "other exception" << std::endl;}
}
四、运行结果
编译服务端并启动:
g++ main.cpp -o main -lboost_system -lboost_chrono
./main
在两个浏览器中打开我们的Web
服务端,这里是本地的,所以是
http://127.0.0.1:5500/chatClient.html
两个用户加入房间、聊天、离开的效果有不同的颜色,如下所示
更多资料:https://github.com/0voice