【网络编程】WebSocket 实现简易Web多人聊天室

一、实现思路

  • 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的几个回调函数:

  1. onopen
  • 这个回调函数在连接服务器成功时触发,我们在连接成功的时候,绑定上发送按钮的点击事件
js 复制代码
// 连接成功
websocket.onopen = function () {
	console.log("连接服务器成功");
	document.getElementById("sendBtn").onclick = function () {
		var msg = document.getElementById("sendMsg").value;
		if (msg) {
			websocket.send(msg);
		}
	};
};
  1. onmessage
  • 这个回调函数在接收到服务端的消息后触发,这里是JSON格式,我们服务端定义了datakey,对应的消息就是data后面的value
js 复制代码
websocket.onmessage = function (e) {
	var mes = JSON.parse(e.data);
	showMessage(mes.data, mes.type);
};
  1. 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序列化的typeleave

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序列化的typemessage,发送的时候需要加上用户名,这样前端显示才有用户名:

  • 这里是通过修改message_ptrpayload的形式来发送消息的,因此我们调用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

两个用户加入房间、聊天、离开的效果有不同的颜色,如下所示

更多资料:https://github.com/0voice

相关推荐
易ლ拉罐6 分钟前
【计算机网络】Socket网络编程
服务器·网络·计算机网络
止观止30 分钟前
Remix框架:高性能React全栈开发实战
前端·react.js·前端框架·remix
萌萌哒草头将军1 小时前
🚀🚀🚀 深入探索 Node.js v22.18.0 新特性;默认支持运行 ts 文件了!
前端·typescript·node.js
安心不心安1 小时前
React ahooks——副作用类hooks之useThrottleFn
前端·javascript·react.js
朝朝又沐沐1 小时前
算法竞赛阶段二-数据结构(40)数据结构栈的STL
开发语言·数据结构·c++·算法
秋田君1 小时前
Vue3 + WebSocket网页接入弹窗客服功能的完整实现
前端·javascript·websocket·网络协议·学习
浪里行舟1 小时前
一网打尽 Promise 组合技:race vs any, all vs allSettled,再也不迷糊!
前端·javascript·vue.js
jxy pro max2 小时前
Corrosion2靶机练习笔记
服务器·网络·笔记