此代码是在官方示例上改造的、包含多群聊、私聊
用户根据uid进行区分 ws://127.0.0.1:8080/ws?uid=123&name=test1,可以加入多个房间,默认广播是main房间
toml
tokio = { version="1.8.1", features=["sync", "time", "rt-multi-thread","macros"] }
tokio-tungstenite = "0.15.0"
tungstenite = "0.14.0"
futures-util = "0.3.15"
env_logger = "0.8.4"
log = "0.4.14"
url = "2.0"
actix-web = "4"
actix-ws = "*"
actix-files="*"
rand="*"
main.rs
//! 多房间 WebSocket 聊天服务器
//! 在浏览器中打开 `http://localhost:8080/` 进行测试
use std::io;
use actix_files::NamedFile;
use actix_web::{App, Error, HttpRequest, HttpResponse, HttpServer, Responder, middleware, web};
use tokio::{task::{spawn, spawn_local}, try_join, };
mod handler;
mod server_simple;
pub use self::server_simple::{ChatServer, ChatServerHandle};
/// 连接ID类型
pub type ConnId = u64;
/// 房间ID类型
pub type RoomId = String;
/// 发送到房间/客户端的消息类型
pub type Msg = String;
/// 首页路由处理函数
async fn index() -> impl Responder {
NamedFile::open_async("./static/index.html").await.unwrap()
}
/// 升级http链接成ws
async fn chat_ws(
req: HttpRequest,
stream: web::Payload,
chat_server: web::Data<ChatServerHandle>,
) -> Result<HttpResponse, Error> {
// 从查询参数中提取 UID 和名称
let query_string = req.query_string();
let params: std::collections::HashMap<String, String> =
url::form_urlencoded::parse(query_string.as_bytes())
.into_owned()
.collect();
let uid = match params.get("uid") {
Some(uid_str) => uid_str.clone(),
None => {
log::warn!("Connection attempt without UID parameter");
return Err(actix_web::error::ErrorBadRequest("UID parameter is required"));
}
};
let name = params.get("name").cloned();
let (res, session, msg_stream) = actix_ws::handle(&req, stream)?;
// 异步任务处理聊天
spawn_local(handler::chat_ws(
(**chat_server).clone(),
session,
msg_stream,
uid,
name,
));
Ok(res)
}
#[tokio::main(flavor = "current_thread")]
async fn main() -> io::Result<()> {
env_logger::init_from_env(env_logger::Env::new().default_filter_or("info"));
log::info!("starting HTTP server at http://localhost:8080");
let chat_server = ChatServer::new();
let server_handle = ChatServerHandle::new(chat_server.clone());
// 这里可以启动任何必要的后台任务
// 对于简化版,我们不需要额外的运行循环
let http_server = HttpServer::new(move || {
App::new()
.app_data(web::Data::new(server_handle.clone()))
// WebSocket UI HTML file
.service(web::resource("/").to(index))
// websocket routes
.service(web::resource("/ws").route(web::get().to(chat_ws)))
// standard middleware
.wrap(middleware::NormalizePath::trim())
.wrap(middleware::Logger::default())
})
.workers(1)
.bind(("127.0.0.1", 8080))?
.run();
http_server.await?;
Ok(())
}
handler.rs
use std::time::{Duration, Instant};
use actix_ws::AggregatedMessage;
use futures_util::StreamExt as _;
use tokio::{time::interval};
use crate::{ChatServerHandle, ConnId};
/// 心跳检测间隔时间
const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(30);
/// 客户端无响应超时时间
const CLIENT_TIMEOUT: Duration = Duration::from_secs(60);
/// 处理从客户端接收到的文本和二进制消息,响应ping消息,并监控
/// 连接健康状况以检测网络问题并释放资源。
pub async fn chat_ws(
chat_server: ChatServerHandle,
mut session: actix_ws::Session,
msg_stream: actix_ws::MessageStream,
uid: String,
initial_name: Option<String>,
) {
let mut name: Option<String> = initial_name.clone();
let mut last_heartbeat = Instant::now();
let mut interval = interval(HEARTBEAT_INTERVAL);
let conn_id = rand::random::<ConnId>();
// 连接服务器并获取消息接收器
log::info!("正在连接服务器,conn_id: {}, uid: {}, name: {:?}", conn_id, uid, initial_name);
let mut msg_rx = chat_server.connect(conn_id, uid.clone(), initial_name.clone()).await;
log::info!("服务器连接成功,开始接收消息");
let mut msg_stream = msg_stream
.max_frame_size(128 * 1024)
.aggregate_continuations()
.max_continuation_size(2 * 1024 * 1024);
let close_reason = loop {
// 开始tokio的select
tokio::select! {
// 等待客户端消息
Some(result) = msg_stream.next() => {
//处理消息
match result {
Ok(msg) => { //处理消息成功
log::info!("接收到消息类型: {:?}", msg);
match msg {
AggregatedMessage::Ping(bytes) => { // ping消息、浏览器协议处理
last_heartbeat = Instant::now();
let _ = session.pong(&bytes).await;
}
AggregatedMessage::Pong(_) => { //pong消息、浏览器协议处理
last_heartbeat = Instant::now();
}
AggregatedMessage::Text(text) => { //文本消息
log::info!("收到文本消息: {}", text);
process_text_msg(&chat_server, &mut session, &text, conn_id, &mut name).await;
log::info!("消息处理完成");
}
AggregatedMessage::Binary(_bin) => { //二进制消息,如果是protobuf的话,会走这
log::warn!("unexpected binary message");
}
AggregatedMessage::Close(reason) => { //关闭
log::info!("收到客户端关闭消息,原因: {:?}", reason);
break reason;
},
}
}
Err(e) => { // 处理消息失败
log::error!("WebSocket消息流错误: {:?}", e);
break None;
}
}
}
// 等待聊天服务器的消息
Some(chat_msg) = msg_rx.recv() => {
// 如果发送消息失败(连接已关闭),则跳出循环
if session.text(chat_msg).await.is_err() {
break None;
}
}
// 定时器
_ = interval.tick() => {
if Instant::now().duration_since(last_heartbeat) > CLIENT_TIMEOUT {
log::info!("客户端心跳超时,断开连接");
break None;
}
if session.ping(b"").await.is_err() {
log::info!("发送心跳ping失败,断开连接");
break None;
}
}
}
};
log::info!("WebSocket连接即将断开,原因: {:?}", close_reason);
// 处理断开消息
chat_server.disconnect(conn_id).await;
// 关闭
let _ = session.close(close_reason).await;
}
//处理文本消息
async fn process_text_msg(
chat_server: &ChatServerHandle,
session: &mut actix_ws::Session,
text: &str,
conn: ConnId,
name: &mut Option<String>,
) {
let msg = text.trim();
// 检查 /<cmd> 类型的消息
if msg.starts_with('/') {
let mut cmd_args = msg.splitn(2, ' ');
// 字符串长度不为0
match cmd_args.next().unwrap() {
"/list" => { //房间列表
let rooms = chat_server.list_rooms().await;
for room in rooms {
if session.text(room).await.is_err() {
break;
}
}
}
"/join" => match cmd_args.next() { // 加入房间
Some(room) => {
chat_server.join_room(conn, room).await;
let _ = session.text(format!("joined {room}")).await;
}
None => {
let _ = session.text("!!! room name is required").await;
}
},
"/pm" => match cmd_args.next() { //私聊
Some(target_and_msg) => {
// 分割目标UID和消息内容,格式为: "target_uid:消息内容"
let parts: Vec<&str> = target_and_msg.splitn(2, ':').collect();
if parts.len() == 2 {
let target_uid = parts[0].trim();
let pm_msg = parts[1].trim();
// 发送私聊消息
let success = chat_server.send_private_message(conn, target_uid, pm_msg).await;
if success {
let _ = session.text(format!("[系统消息] 私聊消息已发送给用户 {}: {}", target_uid, pm_msg)).await;
} else {
let _ = session.text(format!("[系统消息] 发送私聊消息失败,用户 {} 可能不在线", target_uid)).await;
}
} else {
let _ = session.text("[系统消息] 私聊命令格式错误,请使用: /pm target_uid:消息内容").await;
}
}
None => {
let _ = session.text("[系统消息] 请输入目标UID和消息内容,格式: /pm target_uid:消息内容").await;
}
},
_ => {
let _ = session
.text(format!("!!! unknown command: {msg}"))
.await;
}
}
} else {
// 检查是否为房间消息格式: #房间名 消息内容
if msg.starts_with('#') { //某个房间进行广播
let parts: Vec<&str> = msg.splitn(2, ' ').collect();
if parts.len() == 2 {
let room_name = &parts[0][1..]; // 移除 # 符号
let room_msg = parts[1];
// 添加前缀并发送到指定房间
let prefixed_msg = match name {
Some(name) => format!("{name}: {room_msg}"),
None => room_msg.to_string(),
};
log::info!("发送房间消息到: {}", room_name);
chat_server.send_message_to_room(conn, room_name, &prefixed_msg).await;
} else {
// 如果格式不正确,发送错误消息
let _ = session.text("[系统消息] 房间消息格式错误,请使用: #房间名 消息内容").await;
}
} else {
// 普通消息,发送到当前房间
let msg = match name {
Some(name) => format!("{name}: {msg}"),
None => msg.to_owned(),
};
log::info!("发送普通消息: {}", msg);
chat_server.send_message(conn, &msg).await;
}
}
}
server_simple.rs
//! 简化版聊天服务器
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use tokio::sync::RwLock;
use log;
use log::info;
//链接id
pub type ConnId = u64;
//房间名字
pub type RoomId = String;
//消息
pub type Msg = String;
/// 用户信息结构体,整合所有用户相关数据
#[derive(Clone)]
struct UserInfo {
conn_id: ConnId,
name: String,
rooms: HashSet<RoomId>, //可以加入多个房间
sender: tokio::sync::mpsc::UnboundedSender<Msg>,
}
#[derive(Clone)]
pub struct ChatServer {
// 以UID为键的用户信息映射
uid_to_user: Arc<RwLock<HashMap<String, UserInfo>>>,
// 房间到用户集合的映射
room_users: Arc<RwLock<HashMap<RoomId, HashSet<ConnId>>>>,
}
impl ChatServer {
pub fn new() -> Self {
let mut rooms = HashMap::new();
// 初始化一个main房间,全部用户都在一个大群
rooms.insert("main".to_string(), HashSet::new());
//实例化hash表
Self {
uid_to_user: Arc::new(RwLock::new(HashMap::new())),
room_users: Arc::new(RwLock::new(rooms)),
}
}
// 添加连接
pub async fn add_connection(&self, conn_id: ConnId, uid: String, name: Option<String>) -> tokio::sync::mpsc::UnboundedReceiver<Msg> {
info!("添加连接,conn_id: {}, uid: {}, name: {:?}", conn_id, uid, name);
let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
// 设置用户名
let name = name.unwrap_or_else(|| uid.clone());
// 创建用户信息并存储
let user_info = UserInfo {
conn_id,
name,
rooms: HashSet::new(),
sender: tx,
};
//写法1
//self.uid_to_user.write().await.insert(uid.clone(), user_info);
//写法2
RwLock::write(&self.uid_to_user).await.insert(uid.clone(), user_info);
info!("用户信息已存储,uid: {}", uid.clone());
// 让用户加入默认房间
self.join_room_internal(conn_id, "main".to_string()).await;
rx
}
// 移除连接
// 内部辅助方法:通过conn_id查找uid
async fn get_uid_by_conn_id(&self, conn_id: ConnId) -> Option<String> {
let users = RwLock::read(&self.uid_to_user).await;
users.iter().find(|(_, user_info)| user_info.conn_id == conn_id).map(|(uid, _)| uid.clone())
}
// 内部辅助方法:通过conn_id查找用户名
async fn get_username_by_conn_id(&self, conn_id: ConnId) -> Option<String> {
let users = RwLock::read(&self.uid_to_user).await;
users.values().find(|user_info| user_info.conn_id == conn_id).map(|user_info| user_info.name.clone())
}
// 内部辅助方法:通过conn_id查找用户房间列表
async fn get_user_rooms_by_conn_id(&self, conn_id: ConnId) -> Vec<String> {
let users = RwLock::read(&self.uid_to_user).await;
users.values().find(|user_info| user_info.conn_id == conn_id).map(|user_info| user_info.rooms.iter().cloned().collect()).unwrap_or_default()
}
// 离线、同时也退出加入的房间
pub async fn remove_connection(&self, conn_id: ConnId) {
info!("移除连接,conn_id: {}", conn_id);
// 遍历查找对应连接ID的用户
let uid_to_remove = self.get_uid_by_conn_id(conn_id).await;
info!("查找待移除的用户,conn_id: {}, 找到的uid: {:?}", conn_id, uid_to_remove);
if let Some(uid) = uid_to_remove {
// 从所有房间中移除用户
let all_rooms = self.list_rooms().await;
info!("用户 {} 加入了 {} 个房间,开始清理", uid, all_rooms.len());
// 先收集需要从房间中移除的连接ID,避免在持有锁的同时进行修改
let rooms_to_clean = {
let room_users_read = RwLock::read(&self.room_users).await;
all_rooms.into_iter().filter(|room| {
room_users_read.get(room).map_or(false, |users| users.contains(&conn_id))
}).collect::<Vec<_>>()
};
info!("开始清理 {} 个房间", rooms_to_clean.len());
for (i, room) in rooms_to_clean.iter().enumerate() {
info!("处理第 {} 个房间: {}", i, room);
let mut room_users_lock = RwLock::write(&self.room_users).await;
info!("获取房间 {} 的写锁成功", room);
if let Some(room_users) = room_users_lock.get_mut(room) {
info!("找到房间 {} 的用户列表,准备移除用户 {}", room, uid);
room_users.remove(&conn_id);
info!("从房间 {} 移除用户 {} 完成", room, uid);
// 检查房间是否为空,但保留 main 房间不被删除
if room_users.is_empty() && room != "main" {
info!("房间 {} 为空,准备删除", room);
room_users_lock.remove(room);
info!("房间 {} 已删除", room);
}
} else {
info!("房间 {} 不存在于房间列表中", room);
}
drop(room_users_lock); // 明确释放锁
info!("房间 {} 处理完成", room);
}
info!("所有房间清理完成");
info!("准备移除用户基本信息,uid: {}", uid);
RwLock::write(&self.uid_to_user).await.remove(&uid);
info!("用户 {} 信息已移除", uid);
} else {
info!("未找到连接ID {} 对应的用户", conn_id);
}
}
// 加入房间
pub async fn join_room_internal(&self, conn_id: ConnId, room: RoomId) {
// 添加到房间用户映射
RwLock::write(&self.room_users)
.await
.entry(room.clone())
.or_insert_with(HashSet::new)
.insert(conn_id);
// 通过conn_id查找对应的uid
let uid_found = self.get_uid_by_conn_id(conn_id).await;
// 设置用户信息新加入了房间
if let Some(uid) = uid_found {
if let Some(user_info) = RwLock::write(&self.uid_to_user).await.get_mut(&uid) {
user_info.rooms.insert(room);
}
}
}
// 发送消息到房间--遍历房间内的人
pub async fn send_message_to_room(&self, conn_id: ConnId, room: &str, msg: &str) {
info!("准备发送消息,conn_id: {}, room: {}, msg: {}", conn_id, room, msg);
// 通过conn_id查找发送者用户名
let sender_name = self.get_username_by_conn_id(conn_id).await;
if let Some(ref name) = sender_name {
info!("找到发送者名称: {}", name);
}
if let Some(name) = sender_name {
let message = format!("[{}] {}: {}", room, name, msg);
info!("构建消息: {}", message);
if let Some(users) = RwLock::read(&self.room_users).await.get(room) {
info!("房间 {} 有 {} 个用户", room, users.len());
// 复制一份用户连接ID列表,避免在持有读锁的情况下遍历
let user_ids: Vec<ConnId> = users.iter().copied().collect();
for user_conn_id in user_ids {
info!("向用户 {} 发送消息", user_conn_id);
// 通过conn_id查找用户
let target_user_info = {
let users = RwLock::read(&self.uid_to_user).await;
users.values().find(|info| info.conn_id == user_conn_id).cloned()
};
if let Some(user_info) = target_user_info {
info!("找到目标用户,尝试发送消息");
// 如果发送失败(接收端已关闭),忽略错误
if user_info.sender.send(message.clone()).is_err() {
log::warn!("向用户 {} 发送消息失败,连接可能已断开", user_conn_id);
} else {
info!("消息成功发送到用户 {}", user_conn_id);
}
} else {
log::warn!("未找到用户 {} 的信息", user_conn_id);
}
}
} else {
log::warn!("未找到房间 {}", room);
}
} else {
log::warn!("未找到发送者名称,conn_id: {}", conn_id);
}
}
// 发送私聊消息 todo:未判断自己给自己发
pub async fn send_private_message(&self, sender_conn_id: ConnId, target_uid: &str, msg: &str) -> bool {
// 先从uid_to_user中获取目标用户信息
if let Some(target_info) = RwLock::read(&self.uid_to_user).await.get(target_uid) {
// 通过conn_id查找发送者用户名
let sender_name = self.get_user_name(sender_conn_id).await;
if let Some(name) = sender_name {
let message = format!("[私聊][{} -> {}]: {}", name, target_uid, msg);
// 如果发送失败(接收端已关闭),忽略错误
if target_info.sender.send(message).is_err() {
// 发送失败,可能是接收方已断开
return false;
}
return true;
}
}
false
}
// 获取用户姓名
pub async fn get_user_name(&self, conn_id: ConnId) -> Option<String> {
self.get_username_by_conn_id(conn_id).await
}
// 获取用户所在房间列表
pub async fn get_user_rooms(&self, conn_id: ConnId) -> Vec<String> {
self.get_user_rooms_by_conn_id(conn_id).await
}
// 获取房间列表
pub async fn list_rooms(&self) -> Vec<String> {
RwLock::read(&self.room_users)
.await
.keys()
.cloned()
.collect()
}
}
#[derive(Clone)]
pub struct ChatServerHandle {
server: ChatServer,
}
impl ChatServerHandle {
pub fn new(server: ChatServer) -> Self {
Self { server }
}
pub async fn connect(&self, conn_id: ConnId, uid: String, name: Option<String>) -> tokio::sync::mpsc::UnboundedReceiver<Msg> {
self.server.add_connection(conn_id, uid, name).await
}
pub async fn disconnect(&self, conn_id: ConnId) {
self.server.remove_connection(conn_id).await;
}
pub async fn join_room(&self, conn_id: ConnId, room: impl Into<RoomId>) {
self.server.join_room_internal(conn_id, room.into()).await;
}
pub async fn send_message_to_room(&self, conn_id: ConnId, room: &str, msg: &str) {
self.server.send_message_to_room(conn_id, room, msg).await;
}
pub async fn send_message(&self, conn_id: ConnId, msg: &str) {
self.server.send_message_to_room(conn_id, "main", msg).await;
}
pub async fn send_private_message(&self, sender_conn_id: ConnId, target_uid: &str, msg: &str) -> bool {
self.server.send_private_message(sender_conn_id, target_uid, msg).await
}
pub async fn list_rooms(&self) -> Vec<String> {
self.server.list_rooms().await
}
pub async fn get_user_name(&self, conn_id: ConnId) -> Option<String> {
self.server.get_user_name(conn_id).await
}
pub async fn get_user_rooms(&self, conn_id: ConnId) -> Vec<String> {
self.server.get_user_rooms(conn_id).await
}
}
index.html
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>Chat!</title>
<style>
:root {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
font-size: 18px;
}
input[type='text'] {
font-size: inherit;
}
#log {
width: 30em;
height: 20em;
overflow: auto;
margin: 0.5em 0;
border: 1px solid black;
}
#status {
padding: 0 0.2em;
}
#text {
width: 17em;
padding: 0.5em;
}
.msg {
margin: 0;
padding: 0.25em 0.5em;
}
.msg--status {
/* a light yellow */
background-color: #ffffc9;
}
.msg--message {
/* a light blue */
background-color: #d2f4ff;
}
.msg--error {
background-color: pink;
}
</style>
</head>
<body>
<h1>Chat!</h1>
<form id="urlform">
<label for="url">WebSocket Address</label>
<br>
<input style="width: 500px" type="text" id="url" value="ws://127.0.0.1:8080/ws?uid=123&name=testName" />
<input type="submit" value="确定" />
</form>
<div>
<button id="connect" disabled>连接</button>
<span>Status:</span>
<span id="status">disconnected</span>
</div>
<div id="log"></div>
<form id="chatform">
<input type="text" id="text" autocomplete="off" />
<input type="submit" id="send" />
</form>
<hr />
<section>
<h2>Commands</h2>
<table style="border-spacing: 0.5em">
<tr>
<td>
<code>/list</code>
</td>
<td>展示所有房间</td>
</tr>
<tr>
<td>
<code>/join name</code>
</td>
<td>加入房间、如果没有房间,就创建一个,可以加入多个房间</td>
</tr>
<tr>
<td>
<code>/pm 目标用户id:消息内容</code>
</td>
<td>私聊</td>
</tr>
<tr>
<td>
<code>#房间名字 群发的消息内容</code>
</td>
<td>相同房间的用户会进行群发</td>
</tr>
<tr>
<td>
<code>消息内容</code>
</td>
<td>默认会群发main这个房间</td>
</tr>
</table>
</section>
<script>
const $status = document.querySelector('#status')
const $connectButton = document.querySelector('#connect')
const $log = document.querySelector('#log')
const $form = document.querySelector('#chatform')
const $input = document.querySelector('#text')
const $urlInput = document.querySelector('#url')
/** @type {WebSocket | null} */
var socket = null
var websocketUrl = '';
function log(msg, type = 'status') {
$log.innerHTML += `<p class="msg msg--${type}">${msg}</p>`
$log.scrollTop += 1000
}
function connect() {
disconnect()
websocketUrl = $urlInput.value;
log('Connecting...')
socket = new WebSocket(websocketUrl)
socket.onopen = () => {
log('Connected')
updateConnectionStatus()
}
socket.onmessage = ev => {
log('Received: ' + ev.data, 'message')
}
socket.onclose = () => {
log('Disconnected')
socket = null
updateConnectionStatus()
}
}
function disconnect() {
if (socket) {
log('Disconnecting...')
socket.close()
socket = null
updateConnectionStatus()
}
}
function updateConnectionStatus() {
if (socket) {
$status.style.backgroundColor = 'transparent'
$status.style.color = 'green'
$status.textContent = `connected`
$connectButton.innerHTML = 'Disconnect'
$input.focus()
} else {
$status.style.backgroundColor = 'red'
$status.style.color = 'white'
$status.textContent = 'disconnected'
$connectButton.textContent = 'Connect'
}
}
document.querySelector('#urlform').addEventListener('submit', (event) => {
event.preventDefault()
$connectButton.disabled = false
websocketUrl = $urlInput.value
})
$connectButton.addEventListener('click', () => {
if (socket) {
disconnect()
} else {
connect()
}
updateConnectionStatus()
})
$form.addEventListener('submit', ev => {
ev.preventDefault()
const text = $input.value
log('Sending: ' + text)
socket.send(text)
$input.value = ''
$input.focus()
})
updateConnectionStatus()
</script>
</body>
</html>
效果
![ScreenShot_2026-01-14_180355_104]()