系统工作台待办实时提醒,取代五分钟刷新一次,判断有没有新的待办,利用 WebSocket 实现
一、实现步骤

二、前端代码


三、后端代码





四、效果展示

五、AI 啰嗦(直接忽略)
你好!作为一名 Java 程序员,将传统的"轮询"(Polling)模式升级为基于 WebSocket 的"实时推送"(Push)模式,是提升用户体验和系统性能的最佳实践。
轮询不仅浪费带宽和服务器资源,还会导致消息延迟(最长可达5分钟)。而 WebSocket 建立的是全双工通信,服务器一旦有新待办,可以毫秒级主动推送到前端。
下面我将基于 Spring Boot 和 Spring WebSocket 提供一套完整的实现方案。
🛠️ 核心架构思路
- 连接建立:前端页面加载时,建立与后端的 WebSocket 长连接。
- 用户绑定 :连接建立后,将
WebSocket Session与当前的用户ID进行绑定(通常使用Map<UserId, Session>或 Redis)。 - 事件触发:当业务层(Service)产生新的待办事项时,触发一个事件。
- 消息推送:后端监听该事件,查找对应用户的 Session,主动发送 JSON 消息。
- 前端接收 :前端
onmessage回调收到消息,通过浏览器的通知 API 或 DOM 操作更新界面。
第一步:引入 Maven 依赖
我们需要 spring-boot-starter-websocket。
xml
<dependencies>
<!-- Web 基础依赖 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- WebSocket 依赖 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
<!-- Lombok (可选,简化代码) -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
</dependencies>
第二步:配置 WebSocket
创建一个配置类,开启 WebSocket 支持,并注册处理器。
java
import org.springframework.context.annotation.Configuration;
import org.springframework.web.socket.config.annotation.EnableWebSocket;
import org.springframework.web.socket.config.annotation.WebSocketConfigurer;
import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry;
@Configuration
@EnableWebSocket
public class WebSocketConfig implements WebSocketConfigurer {
private final TodoWebSocketHandler todoWebSocketHandler;
// 注入自定义的 Handler
public WebSocketConfig(TodoWebSocketHandler todoWebSocketHandler) {
this.todoWebSocketHandler = todoWebSocketHandler;
}
@Override
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
// 注册处理器的路径,前端连接 ws://host:port/ws/todo
// setAllowedOrigins("*") 允许跨域,生产环境建议指定具体域名
registry.addHandler(todoWebSocketHandler, "/ws/todo")
.setAllowedOrigins("*");
}
}
第三步:实现 WebSocket Handler (核心逻辑)
这是处理连接、断开和消息转发的核心类。我们需要维护一个 Map 来存储在线用户和他们的会话。
注意 :在生产环境中,如果服务是多实例部署(集群) ,不能只使用本地
ConcurrentHashMap,必须使用 Redis Pub/Sub 来广播消息。这里演示单机版逻辑。
java
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.web.socket.CloseStatus;
import org.springframework.web.socket.TextMessage;
import org.springframework.web.socket.WebSocketSession;
import org.springframework.web.socket.handler.TextWebSocketHandler;
import java.io.IOException;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
@Slf4j
@Component
public class TodoWebSocketHandler extends TextWebSocketHandler {
// 存储在线用户的 Session,Key 为 userId,Value 为 Session
// 使用 ConcurrentHashMap 保证线程安全
private static final Map<String, WebSocketSession> USER_SESSION_MAP = new ConcurrentHashMap<>();
/**
* 连接建立成功后
*/
@Override
public void afterConnectionEstablished(WebSocketSession session) throws Exception {
// 获取用户ID,通常从 URL 参数或 HTTP 握手头中获取
// 例如:ws://localhost:8080/ws/todo?userId=1001
String userId = (String) session.getAttributes().get("userId");
if (userId != null) {
USER_SESSION_MAP.put(userId, session);
log.info("用户 [{}] 建立了 WebSocket 连接,当前在线人数: {}", userId, USER_SESSION_MAP.size());
}
}
/**
* 接收到客户端消息(通常客户端不需要发什么,主要是心跳检测)
*/
@Override
protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
log.info("收到客户端消息: {}", message.getPayload());
// 可以处理心跳包
}
/**
* 连接断开后
*/
@Override
public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
String userId = (String) session.getAttributes().get("userId");
if (userId != null) {
USER_SESSION_MAP.remove(userId);
log.info("用户 [{}] 断开了连接,当前在线人数: {}", userId, USER_SESSION_MAP.size());
}
}
/**
* 处理异常
*/
@Override
public void handleTransportError(WebSocketSession session, Throwable exception) throws Exception {
log.error("WebSocket 传输异常", exception);
}
/**
* 业务层调用此方法,向指定用户推送消息
*/
public void sendMessageToUser(String userId, String message) {
WebSocketSession session = USER_SESSION_MAP.get(userId);
if (session != null && session.isOpen()) {
try {
session.sendMessage(new TextMessage(message));
log.info("成功推送消息给用户 [{}]: {}", userId, message);
} catch (IOException e) {
log.error("发送消息失败", e);
}
} else {
log.warn("用户 [{}] 不在线,消息推送失败", userId);
}
}
}
第四步:业务触发与拦截器
我们需要在连接建立前把 userId 放入 Session 属性中。这通常通过 HandshakeInterceptor 实现,或者在 Controller 中处理。这里简单演示如何在业务层触发。
假设你有一个 TodoService,当新增待办时:
java
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.stereotype.Service;
import java.util.HashMap;
import java.util.Map;
@Service
public class TodoService {
private final TodoWebSocketHandler webSocketHandler;
private final ObjectMapper objectMapper = new ObjectMapper();
public TodoService(TodoWebSocketHandler webSocketHandler) {
this.webSocketHandler = webSocketHandler;
}
/**
* 模拟新增待办事项的业务方法
*/
public void createTodo(String assigneeUserId, String todoTitle) {
// 1. 保存待办到数据库...
System.out.println("保存待办: " + todoTitle);
// 2. 构造推送消息
Map<String, Object> msg = new HashMap<>();
msg.put("type", "NEW_TODO");
msg.put("title", todoTitle);
msg.put("time", System.currentTimeMillis());
try {
// 3. 调用 WebSocket Handler 推送
String jsonMsg = objectMapper.writeValueAsString(msg);
webSocketHandler.sendMessageToUser(assigneeUserId, jsonMsg);
} catch (Exception e) {
e.printStackTrace();
}
}
}
第五步:前端实现 (HTML + JavaScript)
前端不再需要 setInterval,而是监听 WebSocket 消息。
html
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<title>待办实时提醒</title>
</head>
<body>
<h1>我的工作台</h1>
<div id="status">连接状态:未连接</div>
<ul id="todo-list">
<!-- 待办列表 -->
</ul>
<script>
// 假设当前登录用户 ID 为 1001
const userId = '1001';
// 建立 WebSocket 连接
const socket = new WebSocket(`ws://localhost:8080/ws/todo?userId=${userId}`);
// 连接打开
socket.onopen = function () {
document.getElementById('status').innerText = '连接状态:实时连接中...';
document.getElementById('status').style.color = 'green';
// 请求浏览器通知权限
if (Notification.permission !== "granted") {
Notification.requestPermission();
}
};
// 接收消息
socket.onmessage = function (event) {
const data = JSON.parse(event.data);
if (data.type === 'NEW_TODO') {
// 1. 更新页面 DOM
const list = document.getElementById('todo-list');
const li = document.createElement('li');
li.innerText = `[新] ${data.title}`;
list.prepend(li);
// 2. 浏览器弹窗提醒 (可选)
if (Notification.permission === "granted") {
new Notification("新待办通知", {
body: `你有新的待办任务:${data.title}`,
icon: "/path/to/your/icon.png"
});
}
}
};
// 连接关闭
socket.onclose = function () {
document.getElementById('status').innerText = '连接状态:已断开,5秒后重试...';
document.getElementById('status').style.color = 'red';
// 简单的断线重连机制
setTimeout(() => {
window.location.reload();
}, 5000);
};
// 连接错误
socket.onerror = function (err) {
console.error('WebSocket 错误', err);
};
</script>
</body>
</html>
💡 生产环境的关键点
在实际的企业级开发中,你还需要考虑以下几点:
-
集群问题(Redis Pub/Sub):
- 如果后端部署了多台服务器(例如 Nginx 负载均衡),用户的连接可能连在 Server A,但业务请求打到了 Server B。
- 解决方案 :Server B 收到业务请求后,不能直接查本地 Map,而是将消息发布到 Redis Channel。所有 Server 订阅该 Channel,Server A 收到 Redis 消息后,发现是发给自己的用户,再通过本地 Session 推送。
-
心跳检测:
- 网络波动可能导致连接"假死"。前端需要每隔 30-60 秒发送一个
ping消息,后端收到后回复pong,否则后端主动断开连接释放资源。
- 网络波动可能导致连接"假死"。前端需要每隔 30-60 秒发送一个
-
安全性:
- 握手认证 :不要在 URL 参数里明文传
userId。应该在建立 WebSocket 连接前,通过 HTTP Header 传递 Token(JWT),在HandshakeInterceptor中解析 Token 验证用户身份。
- 握手认证 :不要在 URL 参数里明文传
-
消息可靠性:
- WebSocket 是"尽力而为"的协议。如果用户断网了,消息就丢了。对于极其重要的待办,建议配合数据库的"未读状态"字段,用户上线后通过 HTTP 接口拉取一次未读列表作为兜底。
通过这套方案,你的系统将彻底告别"5分钟刷新",实现真正的实时响应。