一、實現思路
-
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