uniapp + ruoyi 实现WebSocket聊天室:从配置到群发消息的完整实战
从零构建跨端即时通讯:基于UniApp与RuoYi的WebSocket聊天室实战
最近在帮一个初创团队搭建内部协作工具时,他们提出了一个看似简单却颇具挑战的需求:要在现有的管理系统中集成一个轻量级的即时聊天功能,要求既能支持移动端也能在PC端使用,并且要能快速集成到他们基于RuoYi开发的后台框架中。经过几轮技术选型,我们最终确定了UniApp + RuoYi + WebSocket的技术栈组合。这个方案不仅满足了跨平台的需求,还能充分利用团队已有的技术积累。
如果你也在寻找一种既能快速上手,又具备良好扩展性的即时通讯实现方案,那么这篇文章正是为你准备的。我将带你从零开始,一步步构建一个完整的WebSocket聊天室,涵盖从后端服务配置到前端消息处理的全过程,特别聚焦于如何在实际项目中处理多用户群发、连接管理等核心问题。
1. 后端架构:构建稳健的WebSocket服务
1.1 环境准备与依赖配置
在开始编写WebSocket服务之前,我们需要确保开发环境已经准备就绪。对于使用RuoYi框架的项目来说,这通常意味着一个基于Spring Boot的Java后端环境。
首先,在项目的pom.xml文件中添加必要的依赖。虽然原始资料中只提到了spring-boot-starter-websocket,但在实际生产环境中,我们还需要考虑更多因素:
<!-- WebSocket核心依赖 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
<!-- 用于JSON序列化,处理消息传输 -->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>2.0.25</version>
</dependency>
<!-- 可选:添加监控和健康检查 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
注意:依赖版本的选择需要根据你的Spring Boot版本进行匹配。如果使用RuoYi的最新版本,建议先查看其父pom中定义的版本号,避免版本冲突。
配置完成后,我们需要创建一个配置类来启用WebSocket支持。这里有一个关键点容易被忽略:WebSocket配置需要与Spring Security的配置协调工作,否则可能会遇到连接被拦截的问题。
1.2 WebSocket服务端核心实现
创建WebSocket服务端时,我们需要考虑几个关键问题:如何管理连接会话、如何处理并发访问、如何设计消息格式。下面是一个经过优化的实现方案:
package com.yourproject.websocket;
import org.springframework.stereotype.Component;
import javax.websocket.*;
import javax.websocket.server.PathParam;
import javax.websocket.server.ServerEndpoint;
import java.io.IOException;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicInteger;
@Component
@ServerEndpoint("/ws/chat/{userId}")
public class ChatWebSocketEndpoint {
// 使用线程安全的Map存储会话
private static final ConcurrentHashMap<String, Session> SESSION_MAP =
new ConcurrentHashMap<>();
// 使用原子类确保在线计数的线程安全
private static final AtomicInteger ONLINE_COUNT = new AtomicInteger(0);
private String currentUserId;
private Session currentSession;
@OnOpen
public void onOpen(Session session, @PathParam("userId") String userId) {
this.currentUserId = userId;
this.currentSession = session;
// 将新连接添加到会话映射中
SESSION_MAP.put(userId, session);
// 原子性增加在线人数
int count = ONLINE_COUNT.incrementAndGet();
// 广播用户上线通知
broadcastSystemMessage(userId + " 已上线", userId);
log.info("用户 {} 连接成功,当前在线人数: {}", userId, count);
}
@OnClose
public void onClose() {
// 从映射中移除会话
SESSION_MAP.remove(currentUserId);
// 原子性减少在线人数
int count = ONLINE_COUNT.decrementAndGet();
// 广播用户下线通知
broadcastSystemMessage(currentUserId + " 已离线", currentUserId);
log.info("用户 {} 断开连接,剩余在线人数: {}", currentUserId, count);
}
@OnMessage
public void onMessage(String message, Session session) {
try {
// 解析消息内容
ChatMessage chatMessage = JSON.parseObject(message, ChatMessage.class);
// 根据消息类型处理
switch (chatMessage.getMessageType()) {
case "TEXT":
handleTextMessage(chatMessage);
break;
case "IMAGE":
handleImageMessage(chatMessage);
break;
case "SYSTEM":
handleSystemMessage(chatMessage);
break;
default:
log.warn("未知的消息类型: {}", chatMessage.getMessageType());
}
} catch (Exception e) {
log.error("消息处理失败: {}", e.getMessage());
sendError(session, "消息格式错误");
}
}
private void handleTextMessage(ChatMessage message) {
// 检查是否为群发消息
if ("ALL".equals(message.getTargetId())) {
// 群发给所有在线用户
broadcastMessage(message);
} else {
// 私聊消息,发送给指定用户
sendToUser(message.getTargetId(), message);
}
}
// 广播消息给所有用户
private void broadcastMessage(ChatMessage message) {
SESSION_MAP.forEach((userId, session) -> {
if (!userId.equals(message.getSenderId())) {
sendMessage(session, message);
}
});
}
// 发送消息给指定用户
private void sendToUser(String targetUserId, ChatMessage message) {
Session targetSession = SESSION_MAP.get(targetUserId);
if (targetSession != null && targetSession.isOpen()) {
sendMessage(targetSession, message);
} else {
// 用户不在线,可以存储到数据库或缓存中
log.info("用户 {} 不在线,消息已存储", targetUserId);
}
}
// 发送消息的通用方法
private void sendMessage(Session session, ChatMessage message) {
try {
session.getBasicRemote().sendText(JSON.toJSONString(message));
} catch (IOException e) {
log.error("发送消息失败: {}", e.getMessage());
}
}
}
这个实现相比基础版本有几个重要改进:
- 使用
ConcurrentHashMap替代CopyOnWriteArraySet,在频繁读写场景下性能更好 - 使用
AtomicInteger管理在线人数,避免并发问题 - 增加了消息类型区分,支持文本、图片、系统消息等多种格式
- 实现了更完善的错误处理机制
1.3 安全配置与连接管理
在RuoYi框架中,Spring Security默认会拦截所有请求,因此我们需要特别配置WebSocket端点的访问权限:
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.authorizeRequests()
// 允许WebSocket连接无需认证
.antMatchers("/ws/**").permitAll()
// 其他配置保持不变
.anyRequest().authenticated()
.and()
.formLogin()
.and()
.csrf().disable();
}
}
提示:在生产环境中,建议为WebSocket连接添加token验证机制,而不是完全开放。可以在连接建立时验证token,确保只有合法用户能够建立连接。
连接管理方面,我们还需要考虑几个实际问题:
心跳机制:WebSocket连接可能会因为网络问题或长时间空闲而断开。实现心跳机制可以保持连接活跃:
// 在WebSocket端点中添加心跳处理
@OnMessage
public void onMessage(String message, Session session) {
if ("HEARTBEAT".equals(message)) {
// 收到心跳包,更新最后活跃时间
updateLastActiveTime(currentUserId);
sendMessage(session, "HEARTBEAT_ACK");
return;
}
// ... 其他消息处理逻辑
}
// 定时检查并清理失效连接
@Scheduled(fixedRate = 30000) // 每30秒执行一次
public void checkConnections() {
long currentTime = System.currentTimeMillis();
SESSION_MAP.entrySet().removeIf(entry -> {
long lastActive = getLastActiveTime(entry.getKey());
if (currentTime - lastActive > 120000) { // 2分钟无活动
try {
entry.getValue().close();
return true;
} catch (IOException e) {
log.error("关闭失效连接失败: {}", e.getMessage());
return false;
}
}
return false;
});
}
连接数限制:为了防止单个用户建立过多连接,我们可以添加连接数限制:
// 在onOpen方法中添加连接数检查
private static final int MAX_CONNECTIONS_PER_USER = 3;
private static final ConcurrentHashMap<String, AtomicInteger> USER_CONNECTION_COUNT =
new ConcurrentHashMap<>();
@OnOpen
public void onOpen(Session session, @PathParam("userId") String userId) {
// 检查用户连接数
int count = USER_CONNECTION_COUNT
.computeIfAbsent(userId, k -> new AtomicInteger(0))
.incrementAndGet();
if (count > MAX_CONNECTIONS_PER_USER) {
try {
session.close(new CloseReason(
CloseReason.CloseCodes.TRY_AGAIN_LATER,
"连接数超过限制"
));
USER_CONNECTION_COUNT.get(userId).decrementAndGet();
return;
} catch (IOException e) {
log.error("关闭连接失败: {}", e.getMessage());
}
}
// ... 其他初始化逻辑
}
2. 前端实现:UniApp中的WebSocket客户端
2.1 UniApp项目配置与基础结构
创建UniApp项目时,我建议使用HBuilderX的最新版本,它提供了更好的开发体验和调试支持。项目结构可以这样组织:
chat-project/
├── pages/
│ ├── index/
│ │ ├── index.vue # 聊天主页面
│ │ └── index.scss
│ └── login/
│ └── login.vue # 登录页面
├── static/
│ ├── icons/ # 图标资源
│ └── images/ # 图片资源
├── common/
│ ├── websocket.js # WebSocket封装
│ └── utils.js # 工具函数
└── manifest.json # 应用配置
在manifest.json中,我们需要配置WebSocket权限:
{
"name": "ChatApp",
"appid": "__UNI__XXXXXX",
"description": "即时通讯应用",
"versionName": "1.0.0",
"versionCode": "100",
"transformPx": false,
"networkTimeout": {
"request": 15000,
"connectSocket": 20000,
"uploadFile": 60000,
"downloadFile": 60000
},
"permission": {
"scope.userLocation": {
"desc": "你的位置信息将用于显示位置相关功能"
}
}
}
2.2 WebSocket连接管理与消息处理
在UniApp中处理WebSocket连接,我推荐创建一个专门的封装类,而不是在每个页面中直接调用原生API。这样做的好处是代码更清晰,也更容易维护。
首先创建common/websocket.js:
class WebSocketManager {
constructor() {
this.socketTask = null
this.isConnected = false
this.reconnectCount = 0
this.maxReconnectCount = 5
this.reconnectTimer = null
this.messageQueue = []
this.listeners = new Map()
this.heartbeatInterval = null
}
// 连接WebSocket
connect(options) {
return new Promise((resolve, reject) => {
const {
url,
userId,
token,
onOpen,
onMessage,
onError,
onClose
} = options
// 构建完整的WebSocket URL
const wsUrl = `${url}/${userId}?token=${token}&platform=uniapp`
// 关闭现有连接
if (this.socketTask) {
this.disconnect()
}
// 创建WebSocket连接
this.socketTask = uni.connectSocket({
url: wsUrl,
success: () => {
console.log('WebSocket连接创建成功')
},
fail: (err) => {
reject(new Error(`连接创建失败: ${err.errMsg}`))
}
})
// 监听连接打开事件
uni.onSocketOpen((res) => {
console.log('WebSocket连接已打开')
this.isConnected = true
this.reconnectCount = 0
// 发送积压的消息
this.flushMessageQueue()
// 开始心跳检测
this.startHeartbeat()
// 调用用户定义的回调
if (typeof onOpen === 'function') {
onOpen(res)
}
resolve(res)
})
// 监听消息事件
uni.onSocketMessage((res) => {
const data = this.parseMessage(res.data)
// 处理心跳响应
if (data.type === 'HEARTBEAT_ACK') {
this.lastHeartbeatAck = Date.now()
return
}
// 调用用户定义的回调
if (typeof onMessage === 'function') {
onMessage(data)
}
// 触发特定类型的监听器
this.triggerListeners(data.type, data)
})
// 监听错误事件
uni.onSocketError((err) => {
console.error('WebSocket连接错误:', err)
this.isConnected = false
this.stopHeartbeat()
if (typeof onError === 'function') {
onError(err)
}
// 尝试重连
this.scheduleReconnect()
})
// 监听关闭事件
uni.onSocketClose((res) => {
console.log('WebSocket连接已关闭:', res)
this.isConnected = false
this.stopHeartbeat()
if (typeof onClose === 'function') {
onClose(res)
}
// 如果不是主动断开,尝试重连
if (res.code !== 1000) {
this.scheduleReconnect()
}
})
})
}
// 发送消息
send(message) {
const messageData = {
type: 'TEXT',
content: message,
timestamp: Date.now(),
platform: 'uniapp'
}
if (this.isConnected && this.socketTask) {
uni.sendSocketMessage({
data: JSON.stringify(messageData),
success: () => {
console.log('消息发送成功:', message)
},
fail: (err) => {
console.error('消息发送失败:', err)
// 加入重试队列
this.messageQueue.push(messageData)
}
})
} else {
// 连接未建立,加入队列
this.messageQueue.push(messageData)
console.log('连接未就绪,消息已加入队列')
}
}
// 发送特定类型的消息
sendMessage(type, data, target = 'ALL') {
const messageData = {
type,
data,
target,
timestamp: Date.now(),
platform: 'uniapp'
}
if (this.isConnected) {
uni.sendSocketMessage({
data: JSON.stringify(messageData)
})
} else {
this.messageQueue.push(messageData)
}
}
// 断开连接
disconnect() {
this.stopHeartbeat()
if (this.socketTask) {
uni.closeSocket({
code: 1000,
reason: '用户主动断开'
})
this.socketTask = null
}
this.isConnected = false
this.reconnectCount = 0
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer)
this.reconnectTimer = null
}
}
// 开始心跳检测
startHeartbeat() {
this.stopHeartbeat()
this.heartbeatInterval = setInterval(() => {
if (this.isConnected) {
this.sendMessage('HEARTBEAT', {})
// 检查上次心跳响应时间
if (this.lastHeartbeatAck) {
const timeSinceLastAck = Date.now() - this.lastHeartbeatAck
if (timeSinceLastAck > 30000) { // 30秒未收到响应
console.warn('心跳超时,尝试重连')
this.disconnect()
this.scheduleReconnect()
}
}
}
}, 15000) // 每15秒发送一次心跳
}
// 停止心跳检测
stopHeartbeat() {
if (this.heartbeatInterval) {
clearInterval(this.heartbeatInterval)
this.heartbeatInterval = null
}
}
// 安排重连
scheduleReconnect() {
if (this.reconnectCount >= this.maxReconnectCount) {
console.error('达到最大重连次数,停止重连')
return
}
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer)
}
this.reconnectCount++
const delay = Math.min(1000 * Math.pow(2, this.reconnectCount), 30000)
this.reconnectTimer = setTimeout(() => {
console.log(`第${this.reconnectCount}次尝试重连...`)
this.connect(this.lastOptions).catch(err => {
console.error('重连失败:', err)
this.scheduleReconnect()
})
}, delay)
}
// 清空消息队列
flushMessageQueue() {
while (this.messageQueue.length > 0) {
const message = this.messageQueue.shift()
this.sendMessage(message.type, message.data, message.target)
}
}
// 解析消息
parseMessage(rawData) {
try {
return JSON.parse(rawData)
} catch (error) {
console.error('消息解析失败:', error)
return {
type: 'RAW',
content: rawData,
timestamp: Date.now()
}
}
}
// 添加消息监听器
addListener(type, callback) {
if (!this.listeners.has(type)) {
this.listeners.set(type, [])
}
this.listeners.get(type).push(callback)
}
// 移除消息监听器
removeListener(type, callback) {
if (this.listeners.has(type)) {
const callbacks = this.listeners.get(type)
const index = callbacks.indexOf(callback)
if (index > -1) {
callbacks.splice(index, 1)
}
}
}
// 触发监听器
triggerListeners(type, data) {
if (this.listeners.has(type)) {
this.listeners.get(type).forEach(callback => {
try {
callback(data)
} catch (error) {
console.error('监听器执行错误:', error)
}
})
}
}
}
// 创建单例实例
const websocketManager = new WebSocketManager()
export default websocketManager
这个WebSocket管理器提供了以下关键功能:
- 自动重连机制:在网络不稳定时自动尝试重新连接
- 消息队列:在连接断开时缓存消息,连接恢复后自动发送
- 心跳检测:保持连接活跃,及时检测连接状态
- 事件监听系统:支持按消息类型注册监听器
- 完整的错误处理:提供详细的错误信息和恢复机制
2.3 聊天界面设计与实现
现在我们来创建聊天主界面。这个界面需要处理消息的发送、接收和显示,同时提供良好的用户体验。
创建pages/index/index.vue:
<template>
<view class="chat-container">
<!-- 顶部导航栏 -->
<view class="chat-header">
<view class="header-left">
<text class="online-count">在线: {{ onlineCount }}人</text>
</view>
<view class="header-center">
<text class="chat-title">群聊({{ currentRoom }})</text>
</view>
<view class="header-right">
<button class="menu-btn" @tap="showMenu">···</button>
</view>
</view>
<!-- 消息列表区域 -->
<scroll-view
class="message-list"
scroll-y
:scroll-top="scrollTop"
scroll-with-animation
@scrolltoupper="loadHistory"
>
<!-- 加载更多提示 -->
<view v-if="loadingHistory" class="load-more">
<text>加载中...</text>
</view>
<!-- 历史消息分割线 -->
<view v-if="showHistoryDivider" class="history-divider">
<text>以上是历史消息</text>
</view>
<!-- 消息列表 -->
<view
v-for="(message, index) in messages"
:key="message.id || index"
:class="['message-item', message.senderId === userId ? 'message-right' : 'message-left']"
>
<!-- 系统消息 -->
<view v-if="message.type === 'SYSTEM'" class="system-message">
<text class="system-text">{{ message.content }}</text>
<text class="system-time">{{ formatTime(message.timestamp) }}</text>
</view>
<!-- 文本消息 -->
<view v-else-if="message.type === 'TEXT'" class="text-message">
<!-- 对方消息 -->
<view v-if="message.senderId !== userId" class="other-message">
<image
v-if="showAvatar"
class="avatar"
:src="getAvatar(message.senderId)"
mode="aspectFill"
/>
<view class="message-content">
<text class="sender-name">{{ getSenderName(message.senderId) }}</text>
<view class="bubble bubble-left">
<text class="message-text">{{ message.content }}</text>
<text class="message-time">{{ formatTime(message.timestamp) }}</text>
</view>
</view>
</view>
<!-- 自己发送的消息 -->
<view v-else class="my-message">
<view class="message-content">
<view class="bubble bubble-right">
<text class="message-text">{{ message.content }}</text>
<text class="message-time">{{ formatTime(message.timestamp) }}</text>
</view>
</view>
<image
v-if="showAvatar"
class="avatar"
:src="getAvatar(userId)"
mode="aspectFill"
/>
</view>
</view>
<!-- 图片消息 -->
<view v-else-if="message.type === 'IMAGE'" class="image-message">
<view :class="['image-container', message.senderId === userId ? 'image-right' : 'image-left']">
<image
class="chat-image"
:src="message.content"
mode="widthFix"
@tap="previewImage(message.content)"
:lazy-load="true"
/>
<text class="image-time">{{ formatTime(message.timestamp) }}</text>
</view>
</view>
</view>
<!-- 新消息提示 -->
<view v-if="hasNewMessage" class="new-message-tip" @tap="scrollToBottom">
<text>有新消息 ↓</text>
</view>
</scroll-view>
<!-- 输入区域 -->
<view class="input-area">
<!-- 功能按钮 -->
<view class="function-buttons">
<button class="func-btn" @tap="toggleEmoji">
<text>😊</text>
</button>
<button class="func-btn" @tap="chooseImage">
<text>📷</text>
</button>
<button class="func-btn" @tap="showMoreFunctions">
<text>+</text>
</button>
</view>
<!-- 文本输入 -->
<view class="text-input-container">
<textarea
v-model="inputText"
class="text-input"
placeholder="输入消息..."
:focus="isInputFocus"
@focus="onInputFocus"
@blur="onInputBlur"
@confirm="sendTextMessage"
:adjust-position="true"
maxlength="500"
auto-height
/>
<text class="text-count">{{ inputText.length }}/500</text>
</view>
<!-- 发送按钮 -->
<button
class="send-btn"
:disabled="!inputText.trim()"
@tap="sendTextMessage"
:class="{ 'send-btn-active': inputText.trim() }"
>
<text>发送</text>
</button>
</view>
<!-- 表情面板 -->
<view v-if="showEmojiPanel" class="emoji-panel">
<scroll-view class="emoji-list" scroll-y>
<view class="emoji-row" v-for="(row, rowIndex) in emojiList" :key="rowIndex">
<text
v-for="emoji in row"
:key="emoji"
class="emoji-item"
@tap="insertEmoji(emoji)"
>
{{ emoji }}
</text>
</view>
</scroll-view>
<button class="emoji-close" @tap="toggleEmoji">
<text>关闭</text>
</button>
</view>
<!-- 更多功能面板 -->
<view v-if="showMorePanel" class="more-panel">
<view class="more-grid">
<view class="more-item" @tap="takePhoto">
<view class="more-icon">📸</view>
<text class="more-text">拍照</text>
</view>
<view class="more-item" @tap="chooseFile">
<view class="more-icon">📎</view>
<text class="more-text">文件</text>
</view>
<view class="more-item" @tap="sendLocation">
<view class="more-icon">📍</view>
<text class="more-text">位置</text>
</view>
<view class="more-item" @tap="startVoiceCall">
<view class="more-icon">📞</view>
<text class="more-text">语音</text>
</view>
</view>
</view>
</view>
</template>
<script>
import websocketManager from '@/common/websocket.js'
import { formatTime, debounce } from '@/common/utils.js'
export default {
data() {
return {
// 用户信息
userId: '',
userName: '',
userAvatar: '',
// 聊天状态
messages: [],
inputText: '',
isInputFocus: false,
showEmojiPanel: false,
showMorePanel: false,
// 滚动控制
scrollTop: 0,
lastScrollTop: 0,
hasNewMessage: false,
// 在线状态
onlineCount: 0,
onlineUsers: [],
currentRoom: '默认群聊',
// 加载状态
loadingHistory: false,
showHistoryDivider: false,
pageSize: 20,
currentPage: 1,
hasMoreHistory: true,
// 配置
showAvatar: true,
emojiList: this.generateEmojiList()
}
},
onLoad(options) {
// 获取用户信息
this.userId = options.userId || 'user_' + Date.now()
this.userName = options.userName || '用户' + this.userId.slice(-4)
// 初始化WebSocket连接
this.initWebSocket()
// 加载历史消息
this.loadHistoryMessages()
// 监听键盘高度变化
this.listenKeyboard()
},
onUnload() {
// 清理资源
this.removeKeyboardListener()
websocketManager.removeListener('USER_LIST', this.handleUserList)
websocketManager.removeListener('SYSTEM', this.handleSystemMessage)
websocketManager.disconnect()
},
methods: {
// 初始化WebSocket连接
initWebSocket() {
const wsUrl = 'ws://your-server.com/ws/chat'
const token = uni.getStorageSync('auth_token')
websocketManager.connect({
url: wsUrl,
userId: this.userId,
token: token,
onOpen: (res) => {
console.log('连接成功')
this.showToast('连接成功')
// 请求在线用户列表
this.requestUserList()
},
onMessage: (data) => {
this.handleIncomingMessage(data)
},
onError: (err) => {
console.error('连接错误:', err)
this.showToast('连接出错,正在重连...')
},
onClose: (res) => {
console.log('连接关闭:', res)
if (res.code !== 1000) {
this.showToast('连接断开,正在重连...')
}
}
})
// 注册消息监听器
websocketManager.addListener('USER_LIST', this.handleUserList)
websocketManager.addListener('SYSTEM', this.handleSystemMessage)
websocketManager.addListener('TEXT', this.handleTextMessage)
websocketManager.addListener('IMAGE', this.handleImageMessage)
},
// 处理收到的消息
handleIncomingMessage(data) {
// 根据消息类型分发处理
switch (data.type) {
case 'TEXT':
this.handleTextMessage(data)
break
case 'IMAGE':
this.handleImageMessage(data)
break
case 'SYSTEM':
this.handleSystemMessage(data)
break
case 'USER_LIST':
this.handleUserList(data)
break
default:
console.warn('未知消息类型:', data.type)
}
},
// 处理文本消息
handleTextMessage(data) {
const message = {
id: 'msg_' + Date.now() + '_' + Math.random().toString(36).substr(2),
type: 'TEXT',
senderId: data.senderId,
senderName: data.senderName || '用户' + data.senderId.slice(-4),
content: data.content,
timestamp: data.timestamp || Date.now(),
status: 'received'
}
this.addMessage(message)
// 如果用户正在查看历史消息,不自动滚动到底部
if (this.isAtBottom()) {
this.scrollToBottom()
} else {
this.hasNewMessage = true
}
},
// 处理系统消息
handleSystemMessage(data) {
const message = {
id: 'sys_' + Date.now(),
type: 'SYSTEM',
content: data.content,
timestamp: data.timestamp || Date.now()
}
this.addMessage(message)
this.showToast(data.content)
},
// 处理用户列表更新
handleUserList(data) {
this.onlineUsers = data.users || []
this.onlineCount = this.onlineUsers.length
},
// 发送文本消息
sendTextMessage() {
if (!this.inputText.trim()) return
const message = {
type: 'TEXT',
content: this.inputText.trim(),
timestamp: Date.now()
}
// 先添加到本地消息列表
const localMessage = {
id: 'local_' + Date.now(),
type: 'TEXT',
senderId: this.userId,
senderName: this.userName,
content: message.content,
timestamp: message.timestamp,
status: 'sending'
}
this.addMessage(localMessage)
this.scrollToBottom()
// 发送到服务器
websocketManager.sendMessage('TEXT', message.content, 'ALL')
// 清空输入框
this.inputText = ''
// 更新消息状态(在实际项目中,应该等待服务器确认)
setTimeout(() => {
const index = this.messages.findIndex(m => m.id === localMessage.id)
if (index !== -1) {
this.$set(this.messages, index, {
...this.messages[index],
status: 'sent'
})
}
}, 1000)
},
// 添加消息到列表
addMessage(message) {
this.messages.push(message)
// 限制消息数量,避免内存问题
if (this.messages.length > 1000) {
this.messages = this.messages.slice(-500)
}
},
// 滚动到底部
scrollToBottom() {
this.$nextTick(() => {
// 使用一个较大的值确保滚动到底部
this.scrollTop = 999999
this.hasNewMessage = false
})
},
// 检查是否在底部
isAtBottom() {
// 这里需要根据实际滚动位置判断
// 简化实现:如果最近没有手动滚动,认为在底部
return Date.now() - this.lastScrollTime > 1000
},
// 加载历史消息
async loadHistoryMessages() {
if (this.loadingHistory || !this.hasMoreHistory) return
this.loadingHistory = true
try {
// 模拟API调用
const mockHistory = this.generateMockHistory()
if (mockHistory.length > 0) {
// 将历史消息插入到开头
this.messages = [...mockHistory, ...this.messages]
this.currentPage++
// 显示历史消息分割线
if (this.currentPage > 1) {
this.showHistoryDivider = true
}
} else {
this.hasMoreHistory = false
}
} catch (error) {
console.error('加载历史消息失败:', error)
this.showToast('加载失败')
} finally {
this.loadingHistory = false
}
},
// 选择图片
async chooseImage() {
try {
const res = await uni.chooseImage({
count: 1,
sizeType: ['compressed'],
sourceType: ['album', 'camera']
})
if (res.tempFilePaths.length > 0) {
const imagePath = res.tempFilePaths[0]
// 先显示本地图片
const localMessage = {
id: 'img_local_' + Date.now(),
type: 'IMAGE',
senderId: this.userId,
content: imagePath,
timestamp: Date.now(),
status: 'uploading'
}
this.addMessage(localMessage)
this.scrollToBottom()
// 模拟上传过程
setTimeout(() => {
const index = this.messages.findIndex(m => m.id === localMessage.id)
if (index !== -1) {
this.$set(this.messages, index, {
...this.messages[index],
status: 'sent'
})
// 发送到服务器(实际项目中需要上传到服务器)
websocketManager.sendMessage('IMAGE', {
url: imagePath,
width: 300,
height: 200
}, 'ALL')
}
}, 1500)
}
} catch (error) {
console.error('选择图片失败:', error)
this.showToast('选择图片失败')
}
},
// 预览图片
previewImage(url) {
uni.previewImage({
urls: [url],
current: 0
})
},
// 插入表情
insertEmoji(emoji) {
this.inputText += emoji
this.isInputFocus = true
},
// 生成表情列表
generateEmojiList() {
const emojis = ['😀', '😃', '😄', '😁', '😆', '😅', '😂', '🤣', '😊', '😇',
'🙂', '🙃', '😉', '😌', '😍', '🥰', '😘', '😗', '😙', '😚',
'😋', '😛', '😝', '😜', '🤪', '🤨', '🧐', '🤓', '😎', '🤩']
const rows = []
for (let i = 0; i < emojis.length; i += 8) {
rows.push(emojis.slice(i, i + 8))
}
return rows
},
// 生成模拟历史消息
generateMockHistory() {
const mockMessages = []
const users = ['user_001', 'user_002', 'user_003', 'user_004']
const contents = [
'大家好!',
'这个聊天室功能很实用',
'有人在线吗?',
'测试消息',
'WebSocket连接很稳定',
'UniApp开发体验不错',
'RuoYi框架集成很方便',
'继续加油!'
]
const now = Date.now()
for (let i = 0; i < 10; i++) {
const userId = users[Math.floor(Math.random() * users.length)]
mockMessages.unshift({
id: 'hist_' + (now - i * 60000),
type: 'TEXT',
senderId: userId,
senderName: '用户' + userId.slice(-3),
content: contents[Math.floor(Math.random() * contents.length)],
timestamp: now - i * 60000,
status: 'sent'
})
}
return mockMessages
},
// 工具方法
formatTime,
getAvatar(userId) {
// 实际项目中应该从用户信息中获取
return `/static/avatars/${userId}.jpg`
},
getSenderName(userId) {
const user = this.onlineUsers.find(u => u.id === userId)
return user ? user.name : '用户' + userId.slice(-4)
},
showToast(title) {
uni.showToast({
title,
icon: 'none',
duration: 2000
})
},
// 请求用户列表
requestUserList() {
websocketManager.sendMessage('GET_USERS', {}, 'SERVER')
},
// 监听键盘
listenKeyboard() {
// UniApp中键盘高度变化监听
uni.onKeyboardHeightChange(res => {
if (res.height > 0) {
// 键盘弹出时滚动到底部
setTimeout(() => {
this.scrollToBottom()
}, 300)
}
})
},
removeKeyboardListener() {
// 清理键盘监听
},
// 其他功能方法
toggleEmoji() {
this.showEmojiPanel = !this.showEmojiPanel
this.showMorePanel = false
if (this.showEmojiPanel) {
this.isInputFocus = false
}
},
showMoreFunctions() {
this.showMorePanel = !this.showMorePanel
this.showEmojiPanel = false
},
onInputFocus() {
this.showEmojiPanel = false
this.showMorePanel = false
},
onInputBlur() {
// 输入框失去焦点时的处理
},
takePhoto() {
// 拍照功能
uni.chooseImage({
sourceType: ['camera'],
success: (res) => {
this.chooseImageSuccess(res)
}
})
},
chooseFile() {
// 选择文件
uni.chooseMessageFile({
count: 1,
type: 'all',
success: (res) => {
console.log('选择的文件:', res)
}
})
},
sendLocation() {
// 发送位置
uni.chooseLocation({
success: (res) => {
console.log('选择的位置:', res)
}
})
},
startVoiceCall() {
// 开始语音通话
this.showToast('语音通话功能开发中...')
}
}
}
</script>
<style scoped>
.chat-container {
display: flex;
flex-direction: column;
height: 100vh;
background-color: #f5f5f5;
}
.chat-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 16px;
background-color: #ffffff;
border-bottom: 1px solid #e0e0e0;
height: 60px;
box-sizing: border-box;
}
.header-center {
flex: 1;
text-align: center;
}
.chat-title {
font-size: 18px;
font-weight: 600;
color: #333333;
}
.online-count {
font-size: 12px;
color: #666666;
}
.menu-btn {
background: none;
border: none;
font-size: 20px;
color: #333333;
padding: 4px 12px;
}
.message-list {
flex: 1;
padding: 12px;
overflow: hidden;
}
.message-item {
margin-bottom: 16px;
}
.system-message {
text-align: center;
margin: 8px 0;
}
.system-text {
font-size: 12px;
color: #999999;
background-color: rgba(0, 0, 0, 0.05);
padding: 4px 12px;
border-radius: 12px;
}
.system-time {
display: block;
font-size: 10px;
color: #cccccc;
margin-top: 2px;
}
.other-message, .my-message {
display: flex;
align-items: flex-start;
max-width: 80%;
}
.other-message {
align-self: flex-start;
}
.my-message {
align-self: flex-end;
flex-direction: row-reverse;
}
.avatar {
width: 40px;
height: 40px;
border-radius: 20px;
margin: 0 8px;
}
.message-content {
display: flex;
flex-direction: column;
max-width: calc(100% - 56px);
}
.sender-name {
font-size: 12px;
color: #666666;
margin-bottom: 4px;
margin-left: 4px;
}
.bubble {
padding: 10px 14px;
border-radius: 18px;
position: relative;
word-break: break-word;
}
.bubble-left {
background-color: #ffffff;
border-top-left-radius: 4px;
}
.bubble-right {
background-color: #95ec69;
border-top-right-radius: 4px;
}
.message-text {
font-size: 16px;
line-height: 1.4;
}
.message-time {
display: block;
font-size: 10px;
color: #999999;
margin-top: 4px;
text-align: right;
}
.input-area {
display: flex;
align-items: flex-end;
padding: 8px 12px;
background-color: #ffffff;
border-top: 1px solid #e0e0e0;
min-height: 60px;
}
.function-buttons {
display: flex;
margin-right: 8px;
}
.func-btn {
background: none;
border: none;
font-size: 24px;
padding: 8px;
margin-right: 4px;
}
.text-input-container {
flex: 1;
position: relative;
background-color: #f8f8f8;
border-radius: 20px;
padding: 8px 12px;
min-height: 40px;
max-height: 120px;
}
.text-input {
width: 100%;
font-size: 16px;
line-height: 1.4;
max-height: 100px;
}
.text-count {
position: absolute;
right: 12px;
bottom: 4px;
font-size: 10px;
color: #cccccc;
}
.send-btn {
background-color: #e0e0e0;
border: none;
border-radius: 20px;
padding: 8px 20px;
margin-left: 8px;
font-size: 16px;
color: #999999;
transition: all 0.3s;
}
.send-btn-active {
background-color: #07c160;
color: #ffffff;
}
.emoji-panel {
position: absolute;
bottom: 60px;
left: 0;
right: 0;
background-color: #ffffff;
border-top: 1px solid #e0e0e0;
height: 200px;
}
.emoji-list {
height: 160px;
padding: 12px;
}
.emoji-row {
display: flex;
justify-content: space-around;
margin-bottom: 12px;
}
.emoji-item {
font-size: 24px;
padding: 8px;
}
.more-panel {
position: absolute;
bottom: 60px;
left: 0;
right: 0;
background-color: #ffffff;
border-top: 1px solid #e0e0e0;
padding: 20px;
}
.more-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 20px;
}
.more-item {
display: flex;
flex-direction: column;
align-items: center;
}
.more-icon {
font-size: 32px;
margin-bottom: 8px;
}
.more-text {
font-size: 12px;
color: #666666;
}
.new-message-tip {
position: absolute;
bottom: 80px;
left: 50%;
transform: translateX(-50%);
background-color: rgba(0, 0, 0, 0.7);
color: white;
padding: 8px 16px;
border-radius: 20px;
font-size: 12px;
z-index: 100;
}
.load-more {
text-align: center;
padding: 12px;
color: #999999;
font-size: 14px;
}
.history-divider {
text-align: center;
padding: 12px;
color: #999999;
font-size: 12px;
position: relative;
}
.history-divider::before,
.history-divider::after {
content: '';
position: absolute;
top: 50%;
width: 30%;
height: 1px;
background-color: #e0e0e0;
}
.history-divider::before {
left: 10%;
}
.history-divider::after {
right: 10%;
}
</style>
这个聊天界面实现了以下功能:
- 完整的消息展示:支持文本、图片、系统消息等多种类型
- 智能滚动:新消息自动滚动到底部,查看历史消息时显示新消息提示
- 丰富的交互:支持表情选择、图片发送、文件传输等功能
- 响应式设计:适配不同屏幕尺寸和设备
- 良好的用户体验:包括消息状态显示、发送反馈、错误处理等
3. 高级功能与优化策略
3.1 消息持久化与历史记录
在实际项目中,消息的持久化存储是必不可少的。我们需要将聊天记录保存到数据库中,以便用户查看历史消息。
后端消息存储实现:
@Service
public class MessageService {
@Autowired
private MessageMapper messageMapper;
// 保存消息到数据库
public void saveMessage(ChatMessage message) {
MessageRecord record = new MessageRecord();
record.setMessageId(generateMessageId());
record.setSenderId(message.getSenderId());
record.setReceiverId(message.getReceiverId());
record.setMessageType(message.getMessageType());
record.setContent(message.getContent());
record.setTimestamp(message.getTimestamp());
record.setStatus(MessageStatus.SENT);
messageMapper.insert(record);
}
// 获取历史消息
public List<ChatMessage> getHistoryMessages(String userId, String targetId,
Long startTime, int limit) {
List<MessageRecord> records = messageMapper.selectHistory(
userId, targetId, startTime, limit
);
return records.stream()
.map(this::convertToChatMessage)
.collect(Collectors.toList());
}
// 消息分页查询
public PageResult<ChatMessage> getMessagesByPage(String userId, String targetId,
int pageNum, int pageSize) {
PageHelper.startPage(pageNum, pageSize);
List<MessageRecord> records = messageMapper.selectByUserAndTarget(userId, targetId);
PageInfo<MessageRecord> pageInfo = new PageInfo<>(records);
List<ChatMessage> messages = records.stream()
.map(this::convertToChatMessage)
.collect(Collectors.toList());
return new PageResult<>(
messages,
pageInfo.getTotal(),
pageInfo.getPageNum(),
pageInfo.getPageSize()
);
}
// 消息状态更新(已读、撤回等)
public void updateMessageStatus(String messageId, MessageStatus status) {
MessageRecord record = new MessageRecord();
record.setMessageId(messageId);
record.setStatus(status);
record.setUpdateTime(new Date());
messageMapper.updateStatus(record);
}
// 生成唯一消息ID
private String generateMessageId() {
return "msg_" + System.currentTimeMillis() + "_" +
UUID.randomUUID().toString().substring(0, 8);
}
private ChatMessage convertToChatMessage(MessageRecord record) {
ChatMessage message = new ChatMessage();
message.setMessageId(record.getMessageId());
message.setSenderId(record.getSenderId());
message.setReceiverId(record.getReceiverId());
message.setMessageType(record.getMessageType());
message.setContent(record.getContent());
message.setTimestamp(record.getTimestamp().getTime());
message.setStatus(record.getStatus());
return message;
}
}
前端历史消息加载优化:
// 在WebSocket管理器中添加历史消息加载功能
class WebSocketManager {
// ... 其他代码 ...
// 加载历史消息
async loadHistoryMessages(params) {
const { userId, targetId, beforeTime, limit = 20 } = params
try {
// 先检查本地缓存
const cachedMessages = this.getCachedMessages(userId, targetId, beforeTime, limit)
if (cachedMessages.length >= limit) {
return cachedMessages
}
// 从服务器加载
const response = await uni.request({
url: `${this.baseUrl}/api/messages/history`,
method: 'GET',
data: {
userId,
targetId,
beforeTime,
limit
},
header: {
'Authorization': `Bearer ${this.token}`
}
})
if (response.statusCode === 200) {
const messages = response.data.data || []
// 缓存消息
this.cacheMessages(userId, targetId, messages)
return messages
} else {
throw new Error('加载历史消息失败')
}
} catch (error) {
console.error('加载历史消息失败:', error)
throw error
}
}
// 本地消息缓存
cacheMessages(userId, targetId, messages) {
const cacheKey = `messages_${userId}_${targetId}`
const cached = uni.getStorageSync(cacheKey) || []
// 合并并去重
const allMessages = [...messages, ...cached]
.filter((msg, index, self) =>
index === self.findIndex(m => m.messageId === msg.messageId)
)
.sort((a, b) => b.timestamp - a.timestamp) // 按时间倒序
.slice(0, 1000) // 最多缓存1000条
uni.setStorageSync(cacheKey, allMessages)
}
getCachedMessages(userId, targetId, beforeTime, limit) {
const cacheKey = `messages_${userId}_${targetId}`
const cached = uni.getStorageSync(cacheKey) || []
return cached
.filter(msg => beforeTime ? msg.timestamp < beforeTime : true)
.slice(0, limit)
}
}
3.2 消息状态同步与确认机制
在即时通讯中,确保消息的可靠传递至关重要。我们需要实现消息状态同步机制:
// 消息状态枚举
public enum MessageStatus {
SENDING, // 发送中
SENT, // 已发送
DELIVERED, // 已送达
READ, // 已读
FAILED // 发送失败
}
// 消息确认服务
@Service
public class MessageAckService {
private static final ConcurrentHashMap<String, MessageDelivery> DELIVERY_MAP =
new ConcurrentHashMap<>();
// 发送消息并等待确认
public void sendWithAck(ChatMessage message, Session session) {
String ackId = generateAckId();
message.setAckId(ackId);
// 保存到待确认队列
DELIVERY_MAP.put(ackId, new MessageDelivery(message, session));
// 发送消息
sendMessage(session, message);
// 启动超时检查
scheduleAckTimeout(ackId);
}
// 处理消息确认
public void handleAck(String ackId) {
MessageDelivery delivery = DELIVERY_MAP.remove(ackId);
if (delivery != null) {
// 更新消息状态为已送达
updateMessageStatus(delivery.getMessage().getMessageId(), MessageStatus.DELIVERED);
// 发送送达回执给发送者
sendDeliveryReceipt(delivery.getSession(), ackId);
}
}
// 处理已读回执
public void handleReadReceipt(String messageId, String readerId) {
// 更新消息状态为已读
updateMessageStatus(messageId, MessageStatus.READ);
// 发送已读回执给发送者
MessageRecord message = getMessageById(messageId);
if (message != null) {
sendReadReceipt(message.getSenderId(), messageId, readerId);
}
}
private void scheduleAckTimeout(String ackId) {
Timer timer = new Timer();
timer.schedule(new TimerTask() {
@Override
public void run() {
MessageDelivery delivery = DELIVERY_MAP.get(ackId);
if (delivery != null) {
// 确认超时,标记为发送失败
updateMessageStatus(delivery.getMessage().getMessageId(), MessageStatus.FAILED);
DELIVERY_MAP.remove(ackId);
// 通知发送者
sendDeliveryFailed(delivery.getSession(), ackId);
}
}
}, 30000); // 30秒超时
}
}
前端消息状态管理:
// 扩展消息管理器,添加状态管理
class MessageStatusManager {
constructor() {
this.pendingMessages = new Map()
this.messageCallbacks = new Map()
}
// 发送消息并等待确认
sendMessageWithAck(message, options = {}) {
return new Promise((resolve, reject) => {
const messageId = message.id || this.generateMessageId()
const ackId = `ack_${messageId}`
// 设置确认ID
message.ackId = ackId
// 保存到待确认队列
this.pendingMessages.set(ackId, {
message,
timestamp: Date.now(),
retryCount: 0,
maxRetries: options.maxRetries || 3,
resolve,
reject
})
// 发送消息
this.sendMessageInternal(message)
// 设置超时检查
this.setupAckTimeout(ackId, options.timeout || 30000)
})
}
// 处理服务器确认
handleServerAck(ackId) {
const pending = this.pendingMessages.get(ackId)
if (pending) {
this.pendingMessages.delete(ackId)
pending.resolve({
success: true,
ackId,
timestamp: Date.now()
})
// 更新本地消息状态
this.updateMessageStatus(pending.message.id, 'delivered')
}
}
// 处理已读回执
handleReadReceipt(messageId, readerId) {
this.updateMessageStatus(messageId, 'read', { readerId })
// 触发已读回调
const callbacks = this.messageCallbacks.get(messageId)
if (callbacks && callbacks.onRead) {
callbacks.onRead(readerId)
}
}
// 发送消息的内部方法
sendMessageInternal(message) {
const pending = this.pendingMessages.get(message.ackId)
websocketManager.sendMessage(message.type, message.content, message.target)
.then(() => {
// 发送成功,更新状态为发送中
this.updateMessageStatus(message.id, 'sending')
})
.catch(error => {
// 发送失败,重试或标记为失败
if (pending && pending.retryCount < pending.maxRetries) {
pending.retryCount++
setTimeout(() => {
this.sendMessageInternal(message)
}, 1000 * pending.retryCount) // 指数退避
} else {
this.updateMessageStatus(message.id, 'failed', { error })
if (pending) {
pending.reject(new Error(`消息发送失败: ${error.message}`))
this.pendingMessages.delete(message.ackId)
}
}
})
}
// 设置消息状态更新回调
onMessageStatusChange(messageId, callbacks) {
this.messageCallbacks.set(messageId, callbacks)
}
// 更新消息状态
updateMessageStatus(messageId, status, extra = {}) {
// 更新本地存储
const messages = this.getLocalMessages()
const index = messages.findIndex(msg => msg.id === messageId)
if (index !== -1) {
messages[index].status = status
messages[index].statusTime = Date.now()
Object.assign(messages[index], extra)
this.saveLocalMessages(messages)
}
// 触发状态变更事件
this.triggerStatusChange(messageId, status, extra)
}
// 生成消息ID
generateMessageId() {
return `msg_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
}
}
3.3 性能优化与监控
连接池管理:
@Component
public class WebSocketConnectionPool {
private static final ConcurrentHashMap<String, SessionWrapper> SESSION_POOL =
new ConcurrentHashMap<>();
// 会话包装类,添加额外信息
private static class SessionWrapper {
private Session session;
private String userId;
private String deviceId;
private long connectTime;
private long lastActiveTime;
private String clientVersion;
// 检查会话是否有效
public boolean isValid() {
return session != null && session.isOpen();
}
// 更新活跃时间
public void updateActiveTime() {
this.lastActiveTime = System.currentTimeMillis();
}
}
// 添加会话
public void addSession(String userId, String deviceId, Session session,
String clientVersion) {
SessionWrapper wrapper = new SessionWrapper();
wrapper.session = session;
wrapper.userId = userId;
wrapper.deviceId = deviceId;
wrapper.connectTime = System.currentTimeMillis();
wrapper.lastActiveTime = System.currentTimeMillis();
wrapper.clientVersion = clientVersion;
String key = generateKey(userId, deviceId);
SESSION_POOL.put(key, wrapper);
log.info("会话添加成功: userId={}, deviceId={}, 当前连接数: {}",
userId, deviceId, SESSION_POOL.size());
}
// 获取会话
public Session getSession(String userId, String deviceId) {
String key = generateKey(userId, deviceId);
SessionWrapper wrapper = SESSION_POOL.get(key);
return wrapper != null && wrapper.isValid() ? wrapper.session : null;
}
// 获取用户的所有会话
public List<Session> getUserSessions(String userId) {
return SESSION_POOL.entrySet().stream()
.filter(entry -> entry.getKey().startsWith(userId + "|"))
.map(entry -> entry.getValue())
.filter(SessionWrapper::isValid)
.map(wrapper -> wrapper.session)
.collect(Collectors.toList());
}
// 移除会话
public void removeSession(String userId, String deviceId) {
String key = generateKey(userId, deviceId);
SessionWrapper wrapper = SESSION_POOL.remove(key);
if (wrapper != null) {
log.info("会话移除成功: userId={}, deviceId={}, 剩余连接数: {}",
userId, deviceId, SESSION_POOL.size());
}
}
// 清理无效会话
@Scheduled(fixedRate = 60000) // 每分钟执行一次
public void cleanupInvalidSessions() {
long now = System.currentTimeMillis();
List<String> toRemove = new ArrayList<>();
SESSION_POOL.forEach((key, wrapper) -> {
// 检查会话是否已关闭
if (!wrapper.isValid()) {
toRemove.add(key);
return;
}
// 检查是否超时(5分钟无活动)
if (now - wrapper.lastActiveTime > 300000) {
try {
wrapper.session.close(new CloseReason(
CloseReason.CloseCodes.GOING_AWAY,
"连接超时"
));
} catch (IOException e) {
log.error("关闭超时会话失败: {}", e.getMessage());
}
toRemove.add(key);
}
});
// 移除无效会话
toRemove.forEach(SESSION_POOL::remove);
if (!toRemove.isEmpty()) {
log.info("清理了 {} 个无效会话,剩余连接数: {}",
toRemove.size(), SESSION_POOL.size());
}
}
// 生成会话键
private String generateKey(String userId, String deviceId) {
return userId + "|" + deviceId;
}
// 获取连接统计信息
public ConnectionStats getConnectionStats() {
ConnectionStats stats = new ConnectionStats();
stats.setTotalConnections(SESSION_POOL.size());
// 按用户分组统计
Map<String, Long> userConnections = SESSION_POOL.values().stream()
.collect(Collectors.groupingBy(
wrapper -> wrapper.userId,
Collectors.counting()
));
stats.setUserConnections(userConnections);
stats.setAvgConnectionsPerUser(
SESSION_POOL.size() / (double) userConnections.size()
);
return stats;
}
}
前端性能监控:
// 性能监控工具
class PerformanceMonitor {
constructor() {
this.metrics = {
connectionTime: 0,
messageLatency: [],
messageLoss: 0,
reconnectCount: 0,
errors: []
}
this.startTime = Date.now()
this.messageSentTimes = new Map()
}
// 记录连接建立时间
recordConnectionTime() {
this.metrics.connectionTime = Date.now() - this.startTime
this.reportMetric('connection_time', this.metrics.connectionTime)
}
// 记录消息发送
recordMessageSend(messageId) {
this.messageSentTimes.set(messageId, Date.now())
}
// 记录消息接收
recordMessageReceive(messageId) {
const sentTime = this.messageSentTimes.get(messageId)
if (sentTime) {
const latency = Date.now() - sentTime
this.metrics.messageLatency.push(latency)
this.messageSentTimes.delete(messageId)
// 报告延迟
this.reportMetric('message_latency', latency)
// 计算平均延迟
if (this.metrics.messageLatency.length % 10 === 0) {
const avgLatency = this.metrics.messageLatency.reduce((a, b) => a + b, 0) /
this.metrics.messageLatency.length
this.reportMetric('avg_message_latency', avgLatency)
}
}
}
// 记录重连
recordReconnect() {
this.metrics.reconnectCount++
this.reportMetric('reconnect_count', this.metrics.reconnectCount)
}
// 记录错误
recordError(error) {
this.metrics.errors.push({
time: Date.now(),
error: error.message,
stack: error.stack
})
// 错误过多时报警
if (this.metrics.errors.length > 10) {
this.alert('错误次数过多,请检查网络连接')
}
}
// 报告指标
reportMetric(name, value) {
// 在实际项目中,这里可以上报到监控系统
console.log(`[Metric] ${name}: ${value}`)
// 也可以存储到本地
this.saveMetricToLocal(name, value)
}
// 获取性能报告
getPerformanceReport() {
const avgLatency = this.metrics.messageLatency.length > 0
? this.metrics.messageLatency.reduce((a, b) => a + b, 0) /
this.metrics.messageLatency.length
: 0
return {
connectionTime: this.metrics.connectionTime,
avgMessageLatency: avgLatency,
totalMessages: this.metrics.messageLatency.length,
reconnectCount: this.metrics.reconnectCount,
errorCount: this.metrics.errors.length,
uptime: Date.now() - this.startTime
}
}
// 保存指标到本地
saveMetricToLocal(name, value) {
const metrics = uni.getStorageSync('websocket_metrics') || {}
if (!metrics[name]) {
metrics[name] = []
}
metrics[name].push({
timestamp: Date.now(),
value
})
// 只保留最近1000条记录
if (metrics[name].length > 1000) {
metrics[name] = metrics[name].slice(-1000)
}
uni.setStorageSync('websocket_metrics', metrics)
}
// 报警
alert(message) {
// 在实际项目中,这里可以触发UI提示或上报到监控系统
console.warn(`[Alert] ${message}`)
// 显示用户提示
uni.showToast({
title: message,
icon: 'none',
duration: 3000
})
}
}
// 在WebSocket管理器中集成性能监控
class WebSocketManager {
constructor() {
// ... 其他初始化代码 ...
this.performanceMonitor = new PerformanceMonitor()
}
connect(options) {
this.performanceMonitor.startTime = Date.now()
return new Promise((resolve, reject) => {
// ... 连接逻辑 ...
uni.onSocketOpen((res) => {
this.performanceMonitor.recordConnectionTime()
// ... 其他逻辑 ...
})
uni.onSocketError((err) => {
this.performanceMonitor.recordError(err)
// ... 其他逻辑 ...
})
// ... 其他事件监听 ...
})
}
sendMessage(type, data, target) {
const messageId = this.generateMessageId()
this.performanceMonitor.recordMessageSend(messageId)
// ... 发送逻辑 ...
}
// 处理接收到的消息
handleIncomingMessage(data) {
if (data.messageId) {
this.performanceMonitor.recordMessageReceive(data.messageId)
}
// ... 其他处理逻辑 ...
}
}
4. 部署与运维实践
4.1 生产环境配置
在生产环境中部署WebSocket服务时,需要考虑以下几个关键方面:
Nginx反向代理配置:
http {
# WebSocket连接需要较长的超时时间
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
upstream websocket_backend {
server 127.0.0.1:8080;
server 127.0.0.1:8081;
# 添加更多后端服务器
}
server {
listen 80;
server_name your-domain.com;
# WebSocket连接
location /ws/ {
proxy_pass http://websocket_backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
# 重要:设置较长的超时时间
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_connect_timeout 75s;
# 启用缓冲
proxy_buffering off;
proxy_buffer_size 4k;
proxy_buffers 8 4k;
# 启用压缩
proxy_set_header Accept-Encoding gzip;
}
# 静态资源
location / {
root /path/to/static/files;
index index.html;
try_files $uri $uri/ /index.html;
}
}
}
Spring Boot应用配置:
# application-prod.yml
server:
port: 8080
compression:
enabled: true
mime-types: text/html,text/xml,text/plain,text/css,text/javascript,application/javascript,application/json
min-response-size: 1024
spring:
# 数据库配置
datasource:
url: jdbc:mysql://localhost:3306/chat_db?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai
username: your_username
password: your_password
hikari:
maximum-pool-size: 20
minimum-idle: 5
connection-timeout: 30000
idle-timeout: 600000
max-lifetime: 1800000
# Redis配置(用于会话共享)
redis:
host: localhost
port: 6379
password: your_redis_password
timeout: 10000
lettuce:
pool:
max-active: 20
max-idle: 10
min-idle: 5
# WebSocket配置
websocket:
max-sessions: 10000
session-timeout: 3600000
message-size-limit: 8192
send-timeout: 5000
send-buffer-size-limit: 512000
# 自定义配置
chat:
websocket:
# 心跳间隔(毫秒)
heartbeat-interval: 30000
更多推荐
所有评论(0)