在实际开发中我们经常需要处理用户交互的即时性例如在社交、直播或在线协作场景中实时感知到“谁正在看我”或“谁关注了我”是一个典型需求。虽然输入材料“当你突然看我的时候”更像一个感性的描述但从技术实现角度它可以映射为一个实时在线状态感知与事件通知系统。这类系统需要解决的核心问题是如何低延迟、高可靠地让一个用户观察者的行为如进入页面、聚焦窗口被另一个特定用户被观察者实时感知。本文将围绕构建这样一个最小化的实时感知系统展开。我们将使用 WebSocket 作为双向通信协议Spring Boot 作为后端框架Vue 3 作为前端框架实现一个可运行的示例。文章将不仅包含连接建立和消息发送还会深入探讨连接状态管理、心跳机制、用户映射、事件定义以及生产环境需要考虑的扩展性问题。通过本文你将掌握从零搭建一个具备基础实时感知能力服务的关键步骤、常见陷阱以及优化方向。1. 理解实时感知系统的核心机制与设计取舍在开始编码之前必须理解我们要构建的系统与传统请求-响应模式如 HTTP API的本质区别。实时感知的核心是服务器需要主动向客户端推送数据并且这种推送是基于某个特定事件例如用户A打开了用户B的主页触发的。1.1 为什么选择 WebSocket 而非轮询或 Server-Sent Events实现服务器推送主要有几种技术短轮询、长轮询、Server-Sent Events (SSE) 和 WebSocket。短轮询客户端定期如每秒向服务器发送 HTTP 请求询问“有更新吗”。简单但效率极低会产生大量无效请求延迟等于轮询间隔。长轮询客户端发起请求服务器持有连接直到有数据或超时。比短轮询好但每次通信仍需重建 HTTP 连接开销较大。Server-Sent Events (SSE)允许服务器通过一个持久的 HTTP 连接向客户端单向推送数据。非常适合服务器向客户端发送通知流但不支持客户端向服务器的双向通信。对于“看”这个动作需要客户端先上报因此 SSE 不完全适用。WebSocket在单个 TCP 连接上提供全双工通信通道。连接建立后客户端和服务器可以随时相互发送消息延迟极低开销小。它是实现双向实时交互的理想选择。因此对于“当你突然看我的时候”这种需要客户端观察者主动上报事件并由服务器实时转发给另一客户端被观察者的场景WebSocket 是目前最合适的基础协议。1.2 系统架构与数据流设计一个最小化的系统包含以下组件和数据流客户端 (观察者)打开目标页面建立 WebSocket 连接并发送一个“开始观看”事件。WebSocket 服务器维护所有活跃连接并建立用户 ID 与 WebSocket 会话的映射关系。当收到“开始观看”事件时解析出观察者和被观察者 ID并向被观察者的连接推送“被观看”通知。客户端 (被观察者)保持 WebSocket 连接监听服务器推送的消息。收到“被观看”通知后更新 UI例如弹出提示。关键设计点在于如何管理用户 - 连接的映射。一个用户可能从多个设备登录因此通常是一对多的关系。在我们的最小示例中为简化处理我们假设一个用户只有一个活跃连接。2. 环境准备与项目结构搭建我们将创建一个 Spring Boot 后端和一个 Vue 3 前端。确保你的开发环境满足以下要求组件要求说明JDK17 或更高版本Spring Boot 3.x 需要 JDK 17Node.js18.x 或更高版本用于运行 Vue 开发服务器和构建Maven3.6 或 Gradle用于管理 Spring Boot 项目依赖IDEIntelliJ IDEA, VS Code 等任意你熟悉的开发环境2.1 创建 Spring Boot 后端项目使用 Spring Initializr 或 IDE 的创建向导生成一个项目依赖选择Spring Web提供基础的 Web 能力。Spring Boot DevTools可选开发时热加载。Lombok可选简化 POJO 代码。生成后在pom.xml中手动添加spring-boot-starter-websocket依赖dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-websocket/artifactId /dependency项目基础结构如下realtime-notification-backend ├── src/main/java/com/example/demo │ ├── config/ # 配置类目录 │ ├── controller/ # HTTP控制器可选用于测试 │ ├── dto/ # 数据传输对象 │ ├── handler/ # WebSocket消息处理器 │ └── DemoApplication.java # 主启动类 ├── src/main/resources │ └── application.properties └── pom.xml2.2 创建 Vue 3 前端项目使用 Vue CLI 或 Vite 创建项目。这里使用 Vitenpm create vuelatest realtime-notification-frontend # 按照提示选择项目特性确保包含 TypeScript 和 Router可选 cd realtime-notification-frontend npm install此外我们需要安装 WebSocket 客户端库。虽然浏览器原生支持WebSocketAPI但使用vue-use-webSocket或SockJS-clientStompJS可以更方便地集成到 Vue 的响应式系统中。为了更接近原生并保持示例简洁我们将使用原生 API 并配合 Vue 的 Composition API 进行封装。安装一个轻量级的工具库可选用于处理 JSONnpm install axios # 用于可能的HTTP API调用前端项目结构realtime-notification-frontend ├── src/ │ ├── components/ # Vue组件 │ │ └── NotificationDisplay.vue │ ├── composables/ # 组合式函数 │ │ └── useWebSocket.ts │ ├── views/ # 页面视图 │ │ ├── HomeView.vue # 被观察者页面 │ │ └── ViewerPage.vue # 观察者页面 │ ├── App.vue │ └── main.ts ├── index.html ├── package.json └── vite.config.ts3. 实现后端 WebSocket 服务后端需要完成三件事启用 WebSocket 支持、处理连接生命周期、路由并处理消息。3.1 启用 WebSocket 配置创建一个配置类WebSocketConfig启用基于 STOMP 子协议的 WebSocket 支持。STOMP 是一个简单的消息协议为 WebSocket 提供了消息格式和目的地的约定比直接处理原始 WebSocket 帧更方便。package com.example.demo.config; import org.springframework.context.annotation.Configuration; import org.springframework.messaging.simp.config.MessageBrokerRegistry; import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker; import org.springframework.web.socket.config.annotation.StompEndpointRegistry; import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer; Configuration EnableWebSocketMessageBroker // 启用WebSocket消息代理 public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { Override public void registerStompEndpoints(StompEndpointRegistry registry) { // 定义客户端连接WebSocket的端点允许跨域用于前端开发 registry.addEndpoint(/ws-notification) .setAllowedOriginPatterns(*) // 生产环境应指定具体域名 .withSockJS(); // 启用SockJS回退选项以应对不支持WebSocket的浏览器 } Override public void configureMessageBroker(MessageBrokerRegistry registry) { // 启用一个简单的内存消息代理将消息路由到以/topic为前缀的目的地用于广播 registry.enableSimpleBroker(/topic); // 设置应用程序自身消息的目的地前缀客户端发送消息到以/app为前缀的地址 registry.setApplicationDestinationPrefixes(/app); } }3.2 定义消息模型和事件类型我们需要定义客户端和服务器之间传递的消息格式。通常使用 JSON。package com.example.demo.dto; import lombok.Data; import java.time.Instant; Data public class NotificationMessage { // 事件类型例如VIEW_START, VIEW_END, HEARTBEAT private String eventType; // 触发事件的用户ID观察者 private String fromUserId; // 事件目标用户ID被观察者 private String toUserId; // 事件发生时间戳 private Instant timestamp; // 可选附加数据如观看的页面、设备信息等 private Object payload; }3.3 实现消息控制器创建一个控制器来处理客户端发送的特定应用消息。当观察者发送“开始观看”事件时控制器负责将其转发给被观察者。package com.example.demo.controller; import com.example.demo.dto.NotificationMessage; import org.springframework.messaging.handler.annotation.MessageMapping; import org.springframework.messaging.handler.annotation.SendTo; import org.springframework.messaging.simp.SimpMessagingTemplate; import org.springframework.stereotype.Controller; import java.time.Instant; Controller public class NotificationController { private final SimpMessagingTemplate messagingTemplate; public NotificationController(SimpMessagingTemplate messagingTemplate) { this.messagingTemplate messagingTemplate; } /** * 处理客户端发送到 /app/view 的消息。 * 客户端消息体应是一个 NotificationMessage其中 eventTypeVIEW_START, toUserId 指定被观看者。 * param message 客户端发送的消息 */ MessageMapping(/view) // 对应 /app/view public void handleViewEvent(NotificationMessage message) { // 设置或补充时间戳 message.setTimestamp(Instant.now()); // 构造目标地址/topic/notifications/{userId} String destination String.format(/topic/notifications/%s, message.getToUserId()); // 使用 messagingTemplate 直接发送到指定目的地 messagingTemplate.convertAndSend(destination, message); // 注意这里没有返回值因为我们不是广播给所有订阅者而是点对点发送。 } }关键解释MessageMapping(“/view”)监听发送到/app/view目的地的消息。SimpMessagingTemplateSpring 提供的用于发送消息的模板类。convertAndSend(destination, message)将消息对象转换为 JSON通过配置的 MessageConverter并发送到指定的destination。目的地格式/topic/notifications/{userId}是一种约定允许前端订阅特定用户的通知频道。3.4 维护用户-会话映射进阶上面的简单实现有一个问题它依赖于消息体中的toUserId来路由但无法验证fromUserId的真实性也无法处理用户离线的情况。一个更健壮的系统需要在用户连接时将其用户 ID 与 WebSocket 会话绑定。我们可以实现一个WebSocketEventListener来监听连接和断开事件package com.example.demo.handler; import org.springframework.context.event.EventListener; import org.springframework.messaging.simp.SimpMessageHeaderAccessor; import org.springframework.stereotype.Component; import org.springframework.web.socket.messaging.SessionConnectedEvent; import org.springframework.web.socket.messaging.SessionDisconnectEvent; import java.util.concurrent.ConcurrentHashMap; Component public class WebSocketSessionHandler { // 存储 userId - sessionId 的映射。实际生产环境可能用Redis。 private final ConcurrentHashMapString, String userSessionMap new ConcurrentHashMap(); EventListener public void handleWebSocketConnectListener(SessionConnectedEvent event) { SimpMessageHeaderAccessor headers SimpMessageHeaderAccessor.wrap(event.getMessage()); // 假设连接建立时客户端通过连接参数传递了userId String userId headers.getFirstNativeHeader(userId); String sessionId headers.getSessionId(); if (userId ! null sessionId ! null) { userSessionMap.put(userId, sessionId); System.out.println(用户连接: userId , session: sessionId); } } EventListener public void handleWebSocketDisconnectListener(SessionDisconnectEvent event) { SimpMessageHeaderAccessor headers SimpMessageHeaderAccessor.wrap(event.getMessage()); String sessionId headers.getSessionId(); // 遍历map移除对应sessionId的条目 userSessionMap.entrySet().removeIf(entry - entry.getValue().equals(sessionId)); System.out.println(用户断开连接session: sessionId); } public String getSessionIdByUserId(String userId) { return userSessionMap.get(userId); } }然后在NotificationController中注入WebSocketSessionHandler在转发消息前可以先检查被观察者是否在线getSessionIdByUserId是否返回非空再进行精准发送使用convertAndSendToUser方法。这涉及到更复杂的用户认证和会话管理本例为简化暂不展开。4. 实现前端 WebSocket 客户端前端需要实现建立连接、订阅个人通知频道、发送“观看”事件、接收并显示通知。4.1 封装 WebSocket 工具函数创建一个组合式函数useWebSocket.ts管理连接状态和消息收发。// src/composables/useWebSocket.ts import { ref, onUnmounted } from vue; interface NotificationMessage { eventType: string; fromUserId: string; toUserId: string; timestamp: string; payload?: any; } export function useWebSocket(userId: string) { const socket refWebSocket | null(null); const isConnected ref(false); const notifications refNotificationMessage[]([]); const stompClient refany(null); // 如果使用STOMP这里会是Stomp.Client类型 // 使用SockJS和STOMP的连接方式推荐与后端SockJS兼容 const connectWithStomp () { const socketUrl http://localhost:8080/ws-notification; const socket new SockJS(socketUrl); const stomp Stomp.over(socket); stomp.connect({ userId: userId }, () { console.log(WebSocket connected via STOMP); isConnected.value true; stompClient.value stomp; // 订阅个人通知频道 const subscription stomp.subscribe(/topic/notifications/${userId}, (message) { const notification: NotificationMessage JSON.parse(message.body); console.log(收到通知:, notification); notifications.value.unshift(notification); // 添加到列表开头 // 可以在这里触发UI提示如播放声音、显示Toast showNotificationAlert(notification); }); // 存储subscription以便断开时取消订阅 }, (error: any) { console.error(STOMP连接失败:, error); isConnected.value false; }); }; // 发送“开始观看”事件 const sendViewEvent (targetUserId: string) { if (!stompClient.value || !isConnected.value) { console.error(WebSocket未连接); return; } const message: NotificationMessage { eventType: VIEW_START, fromUserId: userId, toUserId: targetUserId, timestamp: new Date().toISOString(), payload: { page: window.location.pathname } }; stompClient.value.send(/app/view, {}, JSON.stringify(message)); console.log(发送观看事件:, message); }; // 断开连接 const disconnect () { if (stompClient.value) { stompClient.value.disconnect(); } isConnected.value false; socket.value null; stompClient.value null; console.log(WebSocket disconnected); }; // 简单的通知提示 const showNotificationAlert (notification: NotificationMessage) { if (notification.eventType VIEW_START) { alert(用户 ${notification.fromUserId} 开始观看你); } // 可以替换为更优雅的UI组件 }; // 组件卸载时自动断开连接 onUnmounted(() { disconnect(); }); return { isConnected, notifications, connect: connectWithStomp, disconnect, sendViewEvent }; }注意上述代码使用了SockJS和Stomp。需要在index.html中引入相关库或通过 npm 安装npm install sockjs-client stomp/stompjs并在useWebSocket.ts中导入import SockJS from sockjs-client; import { Client } from stomp/stompjs; // 使用新的Client API上述示例是兼容旧版的写法实际建议使用新版API。4.2 创建被观察者主页被观察者页面需要连接 WebSocket 并等待通知。!-- src/views/HomeView.vue -- template div classhome h1我的主页被观察者/h1 p当前用户ID: strong{{ currentUserId }}/strong/p p连接状态: span :classconnectionClass{{ isConnected ? 已连接 : 未连接 }}/span/p button clickconnectWebSocket :disabledisConnected连接WebSocket/button button clickdisconnectWebSocket :disabled!isConnected断开连接/button hr h3实时通知列表/h3 ul v-ifnotifications.length 0 li v-for(note, index) in notifications :keyindex [{{ formatTime(note.timestamp) }}] 用户 strong{{ note.fromUserId }}/strong {{ getEventText(note.eventType) }} /li /ul p v-else暂无通知。/p /div /template script setup langts import { ref, computed } from vue; import { useWebSocket } from /composables/useWebSocket; // 假设当前登录用户ID是固定的实际应从登录状态获取 const currentUserId ref(user_observed_123); const { isConnected, notifications, connect, disconnect } useWebSocket(currentUserId.value); const connectionClass computed(() isConnected.value ? connected : disconnected); const connectWebSocket () { connect(); }; const disconnectWebSocket () { disconnect(); }; const formatTime (isoString: string) { return new Date(isoString).toLocaleTimeString(); }; const getEventText (eventType: string) { const map: Recordstring, string { VIEW_START: 开始观看你, VIEW_END: 停止观看你, }; return map[eventType] || eventType; }; /script style scoped .connected { color: green; } .disconnected { color: red; } /style4.3 创建观察者页面观察者页面需要能够输入被观察者的 ID 并发送“观看”事件。!-- src/views/ViewerPage.vue -- template div classviewer h1观察者页面/h1 p当前观察者ID: strong{{ viewerUserId }}/strong/p div label fortargetUserId输入被观察者ID: /label input idtargetUserId v-modeltargetUserId placeholder例如: user_observed_123 / button clicksendViewEvent :disabled!isConnected || !targetUserId发送“开始观看”事件/button /div p连接状态: span :classconnectionClass{{ isConnected ? 已连接 : 未连接 }}/span/p button clickconnectWebSocket :disabledisConnected连接WebSocket/button /div /template script setup langts import { ref, computed } from vue; import { useWebSocket } from /composables/useWebSocket; const viewerUserId ref(user_viewer_456); const targetUserId ref(); const { isConnected, connect, sendViewEvent: sendView } useWebSocket(viewerUserId.value); const connectionClass computed(() isConnected.value ? connected : disconnected); const connectWebSocket () { connect(); }; const sendViewEvent () { if (targetUserId.value) { sendView(targetUserId.value); targetUserId.value ; // 发送后清空 } }; /script5. 运行验证与测试5.1 启动后端服务确保在application.properties中配置了服务器端口默认 8080。运行DemoApplication主类。观察控制台日志看到 Tomcat 启动在 8080 端口并且 WebSocket 端点已注册。5.2 启动前端服务进入前端项目目录运行npm run dev。Vite 通常会启动在http://localhost:5173或其他端口。5.3 测试流程打开两个浏览器窗口或匿名窗口模拟两个用户。窗口 A访问http://localhost:5173被观察者主页。点击“连接 WebSocket”。控制台应显示连接成功。窗口 B访问http://localhost:5173/viewer观察者页面。点击“连接 WebSocket”。在窗口 B的输入框中输入窗口 A 中显示的用户 ID如user_observed_123点击“发送‘开始观看’事件”。观察窗口 A应该立即弹出一个提示框显示“用户 user_viewer_456 开始观看你”同时通知列表会更新这条记录。检查后端控制台应该能看到连接日志和消息处理日志。预期结果被观察者能近乎实时地收到观察者“观看”行为的通知。这模拟了“当你突然看我的时候”这一交互的底层技术实现。6. 常见问题排查与优化在实际部署和开发中你会遇到各种问题。以下是典型问题及其排查路径。6.1 连接建立失败问题现象可能原因检查方式处理建议前端控制台报WebSocket connection to ‘ws://...‘ failed1. 后端服务未启动。2. 后端 WebSocket 端点路径错误。3. 跨域问题前端与后端域名/端口不同。4. 网络策略如防火墙阻止。1. 确认后端应用日志是否正常启动。2. 检查前端连接 URL 是否与后端registerStompEndpoints注册的路径一致。3. 查看浏览器开发者工具 Network 面板看 WebSocket 连接请求的响应状态码。4. 检查后端 CORS 配置setAllowedOriginPatterns。1. 启动后端服务。2. 修正连接 URL。3. 开发阶段可暂时设置setAllowedOriginPatterns(“*”)生产环境必须指定具体前端域名。4. 检查服务器安全组和防火墙设置。连接使用 SockJS 时一直停留在Opening状态SockJS 在尝试多种传输协议WebSocket, xhr-streaming等。查看浏览器 Network 面板会看到一系列info、xhr请求。这通常是正常的降级过程。如果最终连接成功则无需处理。如果失败检查后端是否支持 SockJS添加了.withSockJS()。6.2 消息发送成功但接收不到问题现象可能原因检查方式处理建议前端发送消息后后端控制器收到并处理了但目标客户端没反应。1. 目标客户端未订阅正确的目的地。2. 消息目的地路径拼写错误。3. 后端消息转换器未正确序列化/反序列化 JSON。1. 在后端handleViewEvent方法内打印日志确认destination字符串。2. 在前端订阅代码处打印日志确认订阅的路径。3. 检查浏览器开发者工具 WebSocket 帧内容看服务器推送的消息格式是否正确。1. 确保订阅路径/topic/notifications/{userId}与发送路径完全匹配。2. 使用 Spring Boot 默认的 Jackson 库通常能自动处理 JSON。检查对象是否有正确的 getter 方法或 LombokData。3. 可以在前端 STOMP 客户端启用调试模式查看所有帧。6.3 连接不稳定经常断开问题现象可能原因检查方式处理建议连接一段时间后自动断开前端触发重连。1. 网络波动。2. 代理服务器如 Nginx超时设置过短。3. 缺乏心跳机制连接被中间设备清理。1. 查看后端日志是否有断开事件。2. 检查 Nginx 等代理的proxy_read_timeout,proxy_send_timeout配置通常需要设置得较长如 1小时。3. 检查 STOMP 客户端和服务端是否配置了心跳。1. 在 STOMP 配置中启用心跳。后端registry.enableSimpleBroker(...)后可链式调用.setHeartbeatValue(...)。2. 前端 STOMP 客户端连接时配置heartbeatIn/Out。3. 在前端实现自动重连逻辑。6.4 生产环境扩展性问题上述示例使用内存消息代理和内存 Session 映射仅适用于单机开发。生产环境需要考虑分布式扩展当有多台后端服务器时内存中的 Session 映射和订阅信息无法共享。需要使用外部消息代理如 RabbitMQ, Redis, Kafka作为 STOMP broker并集成 Spring Session 和 Redis 来管理分布式 WebSocket 会话。用户认证与授权连接建立时不能信任客户端传递的userId。应集成 Spring Security在握手阶段进行身份验证如基于 JWT并将认证信息绑定到 WebSocket 会话。连接数管理与限流防止单个用户建立过多连接耗尽资源。可以在握手拦截器中进行限制。消息持久化对于重要的通知如果用户离线需要存储起来待其上线后推送。这需要引入消息队列和离线存储。前端重连与状态恢复网络中断后前端应自动重连并重新订阅之前的频道。需要更完善的状态管理。7. 最佳实践与扩展方向7.1 连接管理最佳实践始终启用心跳在服务器和客户端配置心跳保持连接活跃及时发现死连接。// 后端配置示例 Override public void configureMessageBroker(MessageBrokerRegistry registry) { registry.enableSimpleBroker(/topic) .setHeartbeatValue(new long[]{10000, 10000}); // 发送间隔接收间隔毫秒 }实现前端自动重连在连接断开时使用指数退避策略进行重连。连接状态可视化在 UI 上清晰显示连接状态连接中、已连接、断开、重连中。7.2 消息设计最佳实践定义清晰的事件类型枚举如VIEW_START,VIEW_END,LIKE,COMMENT等避免魔法字符串。消息体版本化考虑在消息中加入版本字段便于后续兼容性升级。添加消息去重与幂等性对于可能重复发送的消息如网络重试在客户端或服务器端根据唯一 ID 进行去重处理。7.3 扩展功能方向“正在看”列表被观察者可以实时查看当前有哪些人在看自己。这需要服务器维护一个“观看关系”的集合并在连接断开时清理。观看时长统计结合VIEW_START和VIEW_END事件计算观察者的观看时长。匿名观看观察者可以不暴露自己的真实 ID由服务器生成临时标识。富媒体通知通知可以包含头像、观看的页面截图需前端配合等信息。离线消息集成 APNsiOS、FCMAndroid或 Web Push在用户关闭网页时仍能送达通知。构建“当你突然看我的时候”这样的实时感知功能技术核心在于稳定、高效的 WebSocket 连接管理和精准的消息路由。从最小原型到生产系统需要逐步解决认证、扩展、可靠性和用户体验等问题。本文提供的示例是一个完整的起点你可以在此基础上根据实际业务需求引入更强大的消息中间件、完善安全机制并优化前端交互最终打造出体验流畅的实时交互功能。
基于WebSocket与Spring Boot构建实时在线状态感知系统
在实际开发中我们经常需要处理用户交互的即时性例如在社交、直播或在线协作场景中实时感知到“谁正在看我”或“谁关注了我”是一个典型需求。虽然输入材料“当你突然看我的时候”更像一个感性的描述但从技术实现角度它可以映射为一个实时在线状态感知与事件通知系统。这类系统需要解决的核心问题是如何低延迟、高可靠地让一个用户观察者的行为如进入页面、聚焦窗口被另一个特定用户被观察者实时感知。本文将围绕构建这样一个最小化的实时感知系统展开。我们将使用 WebSocket 作为双向通信协议Spring Boot 作为后端框架Vue 3 作为前端框架实现一个可运行的示例。文章将不仅包含连接建立和消息发送还会深入探讨连接状态管理、心跳机制、用户映射、事件定义以及生产环境需要考虑的扩展性问题。通过本文你将掌握从零搭建一个具备基础实时感知能力服务的关键步骤、常见陷阱以及优化方向。1. 理解实时感知系统的核心机制与设计取舍在开始编码之前必须理解我们要构建的系统与传统请求-响应模式如 HTTP API的本质区别。实时感知的核心是服务器需要主动向客户端推送数据并且这种推送是基于某个特定事件例如用户A打开了用户B的主页触发的。1.1 为什么选择 WebSocket 而非轮询或 Server-Sent Events实现服务器推送主要有几种技术短轮询、长轮询、Server-Sent Events (SSE) 和 WebSocket。短轮询客户端定期如每秒向服务器发送 HTTP 请求询问“有更新吗”。简单但效率极低会产生大量无效请求延迟等于轮询间隔。长轮询客户端发起请求服务器持有连接直到有数据或超时。比短轮询好但每次通信仍需重建 HTTP 连接开销较大。Server-Sent Events (SSE)允许服务器通过一个持久的 HTTP 连接向客户端单向推送数据。非常适合服务器向客户端发送通知流但不支持客户端向服务器的双向通信。对于“看”这个动作需要客户端先上报因此 SSE 不完全适用。WebSocket在单个 TCP 连接上提供全双工通信通道。连接建立后客户端和服务器可以随时相互发送消息延迟极低开销小。它是实现双向实时交互的理想选择。因此对于“当你突然看我的时候”这种需要客户端观察者主动上报事件并由服务器实时转发给另一客户端被观察者的场景WebSocket 是目前最合适的基础协议。1.2 系统架构与数据流设计一个最小化的系统包含以下组件和数据流客户端 (观察者)打开目标页面建立 WebSocket 连接并发送一个“开始观看”事件。WebSocket 服务器维护所有活跃连接并建立用户 ID 与 WebSocket 会话的映射关系。当收到“开始观看”事件时解析出观察者和被观察者 ID并向被观察者的连接推送“被观看”通知。客户端 (被观察者)保持 WebSocket 连接监听服务器推送的消息。收到“被观看”通知后更新 UI例如弹出提示。关键设计点在于如何管理用户 - 连接的映射。一个用户可能从多个设备登录因此通常是一对多的关系。在我们的最小示例中为简化处理我们假设一个用户只有一个活跃连接。2. 环境准备与项目结构搭建我们将创建一个 Spring Boot 后端和一个 Vue 3 前端。确保你的开发环境满足以下要求组件要求说明JDK17 或更高版本Spring Boot 3.x 需要 JDK 17Node.js18.x 或更高版本用于运行 Vue 开发服务器和构建Maven3.6 或 Gradle用于管理 Spring Boot 项目依赖IDEIntelliJ IDEA, VS Code 等任意你熟悉的开发环境2.1 创建 Spring Boot 后端项目使用 Spring Initializr 或 IDE 的创建向导生成一个项目依赖选择Spring Web提供基础的 Web 能力。Spring Boot DevTools可选开发时热加载。Lombok可选简化 POJO 代码。生成后在pom.xml中手动添加spring-boot-starter-websocket依赖dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-websocket/artifactId /dependency项目基础结构如下realtime-notification-backend ├── src/main/java/com/example/demo │ ├── config/ # 配置类目录 │ ├── controller/ # HTTP控制器可选用于测试 │ ├── dto/ # 数据传输对象 │ ├── handler/ # WebSocket消息处理器 │ └── DemoApplication.java # 主启动类 ├── src/main/resources │ └── application.properties └── pom.xml2.2 创建 Vue 3 前端项目使用 Vue CLI 或 Vite 创建项目。这里使用 Vitenpm create vuelatest realtime-notification-frontend # 按照提示选择项目特性确保包含 TypeScript 和 Router可选 cd realtime-notification-frontend npm install此外我们需要安装 WebSocket 客户端库。虽然浏览器原生支持WebSocketAPI但使用vue-use-webSocket或SockJS-clientStompJS可以更方便地集成到 Vue 的响应式系统中。为了更接近原生并保持示例简洁我们将使用原生 API 并配合 Vue 的 Composition API 进行封装。安装一个轻量级的工具库可选用于处理 JSONnpm install axios # 用于可能的HTTP API调用前端项目结构realtime-notification-frontend ├── src/ │ ├── components/ # Vue组件 │ │ └── NotificationDisplay.vue │ ├── composables/ # 组合式函数 │ │ └── useWebSocket.ts │ ├── views/ # 页面视图 │ │ ├── HomeView.vue # 被观察者页面 │ │ └── ViewerPage.vue # 观察者页面 │ ├── App.vue │ └── main.ts ├── index.html ├── package.json └── vite.config.ts3. 实现后端 WebSocket 服务后端需要完成三件事启用 WebSocket 支持、处理连接生命周期、路由并处理消息。3.1 启用 WebSocket 配置创建一个配置类WebSocketConfig启用基于 STOMP 子协议的 WebSocket 支持。STOMP 是一个简单的消息协议为 WebSocket 提供了消息格式和目的地的约定比直接处理原始 WebSocket 帧更方便。package com.example.demo.config; import org.springframework.context.annotation.Configuration; import org.springframework.messaging.simp.config.MessageBrokerRegistry; import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker; import org.springframework.web.socket.config.annotation.StompEndpointRegistry; import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer; Configuration EnableWebSocketMessageBroker // 启用WebSocket消息代理 public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { Override public void registerStompEndpoints(StompEndpointRegistry registry) { // 定义客户端连接WebSocket的端点允许跨域用于前端开发 registry.addEndpoint(/ws-notification) .setAllowedOriginPatterns(*) // 生产环境应指定具体域名 .withSockJS(); // 启用SockJS回退选项以应对不支持WebSocket的浏览器 } Override public void configureMessageBroker(MessageBrokerRegistry registry) { // 启用一个简单的内存消息代理将消息路由到以/topic为前缀的目的地用于广播 registry.enableSimpleBroker(/topic); // 设置应用程序自身消息的目的地前缀客户端发送消息到以/app为前缀的地址 registry.setApplicationDestinationPrefixes(/app); } }3.2 定义消息模型和事件类型我们需要定义客户端和服务器之间传递的消息格式。通常使用 JSON。package com.example.demo.dto; import lombok.Data; import java.time.Instant; Data public class NotificationMessage { // 事件类型例如VIEW_START, VIEW_END, HEARTBEAT private String eventType; // 触发事件的用户ID观察者 private String fromUserId; // 事件目标用户ID被观察者 private String toUserId; // 事件发生时间戳 private Instant timestamp; // 可选附加数据如观看的页面、设备信息等 private Object payload; }3.3 实现消息控制器创建一个控制器来处理客户端发送的特定应用消息。当观察者发送“开始观看”事件时控制器负责将其转发给被观察者。package com.example.demo.controller; import com.example.demo.dto.NotificationMessage; import org.springframework.messaging.handler.annotation.MessageMapping; import org.springframework.messaging.handler.annotation.SendTo; import org.springframework.messaging.simp.SimpMessagingTemplate; import org.springframework.stereotype.Controller; import java.time.Instant; Controller public class NotificationController { private final SimpMessagingTemplate messagingTemplate; public NotificationController(SimpMessagingTemplate messagingTemplate) { this.messagingTemplate messagingTemplate; } /** * 处理客户端发送到 /app/view 的消息。 * 客户端消息体应是一个 NotificationMessage其中 eventTypeVIEW_START, toUserId 指定被观看者。 * param message 客户端发送的消息 */ MessageMapping(/view) // 对应 /app/view public void handleViewEvent(NotificationMessage message) { // 设置或补充时间戳 message.setTimestamp(Instant.now()); // 构造目标地址/topic/notifications/{userId} String destination String.format(/topic/notifications/%s, message.getToUserId()); // 使用 messagingTemplate 直接发送到指定目的地 messagingTemplate.convertAndSend(destination, message); // 注意这里没有返回值因为我们不是广播给所有订阅者而是点对点发送。 } }关键解释MessageMapping(“/view”)监听发送到/app/view目的地的消息。SimpMessagingTemplateSpring 提供的用于发送消息的模板类。convertAndSend(destination, message)将消息对象转换为 JSON通过配置的 MessageConverter并发送到指定的destination。目的地格式/topic/notifications/{userId}是一种约定允许前端订阅特定用户的通知频道。3.4 维护用户-会话映射进阶上面的简单实现有一个问题它依赖于消息体中的toUserId来路由但无法验证fromUserId的真实性也无法处理用户离线的情况。一个更健壮的系统需要在用户连接时将其用户 ID 与 WebSocket 会话绑定。我们可以实现一个WebSocketEventListener来监听连接和断开事件package com.example.demo.handler; import org.springframework.context.event.EventListener; import org.springframework.messaging.simp.SimpMessageHeaderAccessor; import org.springframework.stereotype.Component; import org.springframework.web.socket.messaging.SessionConnectedEvent; import org.springframework.web.socket.messaging.SessionDisconnectEvent; import java.util.concurrent.ConcurrentHashMap; Component public class WebSocketSessionHandler { // 存储 userId - sessionId 的映射。实际生产环境可能用Redis。 private final ConcurrentHashMapString, String userSessionMap new ConcurrentHashMap(); EventListener public void handleWebSocketConnectListener(SessionConnectedEvent event) { SimpMessageHeaderAccessor headers SimpMessageHeaderAccessor.wrap(event.getMessage()); // 假设连接建立时客户端通过连接参数传递了userId String userId headers.getFirstNativeHeader(userId); String sessionId headers.getSessionId(); if (userId ! null sessionId ! null) { userSessionMap.put(userId, sessionId); System.out.println(用户连接: userId , session: sessionId); } } EventListener public void handleWebSocketDisconnectListener(SessionDisconnectEvent event) { SimpMessageHeaderAccessor headers SimpMessageHeaderAccessor.wrap(event.getMessage()); String sessionId headers.getSessionId(); // 遍历map移除对应sessionId的条目 userSessionMap.entrySet().removeIf(entry - entry.getValue().equals(sessionId)); System.out.println(用户断开连接session: sessionId); } public String getSessionIdByUserId(String userId) { return userSessionMap.get(userId); } }然后在NotificationController中注入WebSocketSessionHandler在转发消息前可以先检查被观察者是否在线getSessionIdByUserId是否返回非空再进行精准发送使用convertAndSendToUser方法。这涉及到更复杂的用户认证和会话管理本例为简化暂不展开。4. 实现前端 WebSocket 客户端前端需要实现建立连接、订阅个人通知频道、发送“观看”事件、接收并显示通知。4.1 封装 WebSocket 工具函数创建一个组合式函数useWebSocket.ts管理连接状态和消息收发。// src/composables/useWebSocket.ts import { ref, onUnmounted } from vue; interface NotificationMessage { eventType: string; fromUserId: string; toUserId: string; timestamp: string; payload?: any; } export function useWebSocket(userId: string) { const socket refWebSocket | null(null); const isConnected ref(false); const notifications refNotificationMessage[]([]); const stompClient refany(null); // 如果使用STOMP这里会是Stomp.Client类型 // 使用SockJS和STOMP的连接方式推荐与后端SockJS兼容 const connectWithStomp () { const socketUrl http://localhost:8080/ws-notification; const socket new SockJS(socketUrl); const stomp Stomp.over(socket); stomp.connect({ userId: userId }, () { console.log(WebSocket connected via STOMP); isConnected.value true; stompClient.value stomp; // 订阅个人通知频道 const subscription stomp.subscribe(/topic/notifications/${userId}, (message) { const notification: NotificationMessage JSON.parse(message.body); console.log(收到通知:, notification); notifications.value.unshift(notification); // 添加到列表开头 // 可以在这里触发UI提示如播放声音、显示Toast showNotificationAlert(notification); }); // 存储subscription以便断开时取消订阅 }, (error: any) { console.error(STOMP连接失败:, error); isConnected.value false; }); }; // 发送“开始观看”事件 const sendViewEvent (targetUserId: string) { if (!stompClient.value || !isConnected.value) { console.error(WebSocket未连接); return; } const message: NotificationMessage { eventType: VIEW_START, fromUserId: userId, toUserId: targetUserId, timestamp: new Date().toISOString(), payload: { page: window.location.pathname } }; stompClient.value.send(/app/view, {}, JSON.stringify(message)); console.log(发送观看事件:, message); }; // 断开连接 const disconnect () { if (stompClient.value) { stompClient.value.disconnect(); } isConnected.value false; socket.value null; stompClient.value null; console.log(WebSocket disconnected); }; // 简单的通知提示 const showNotificationAlert (notification: NotificationMessage) { if (notification.eventType VIEW_START) { alert(用户 ${notification.fromUserId} 开始观看你); } // 可以替换为更优雅的UI组件 }; // 组件卸载时自动断开连接 onUnmounted(() { disconnect(); }); return { isConnected, notifications, connect: connectWithStomp, disconnect, sendViewEvent }; }注意上述代码使用了SockJS和Stomp。需要在index.html中引入相关库或通过 npm 安装npm install sockjs-client stomp/stompjs并在useWebSocket.ts中导入import SockJS from sockjs-client; import { Client } from stomp/stompjs; // 使用新的Client API上述示例是兼容旧版的写法实际建议使用新版API。4.2 创建被观察者主页被观察者页面需要连接 WebSocket 并等待通知。!-- src/views/HomeView.vue -- template div classhome h1我的主页被观察者/h1 p当前用户ID: strong{{ currentUserId }}/strong/p p连接状态: span :classconnectionClass{{ isConnected ? 已连接 : 未连接 }}/span/p button clickconnectWebSocket :disabledisConnected连接WebSocket/button button clickdisconnectWebSocket :disabled!isConnected断开连接/button hr h3实时通知列表/h3 ul v-ifnotifications.length 0 li v-for(note, index) in notifications :keyindex [{{ formatTime(note.timestamp) }}] 用户 strong{{ note.fromUserId }}/strong {{ getEventText(note.eventType) }} /li /ul p v-else暂无通知。/p /div /template script setup langts import { ref, computed } from vue; import { useWebSocket } from /composables/useWebSocket; // 假设当前登录用户ID是固定的实际应从登录状态获取 const currentUserId ref(user_observed_123); const { isConnected, notifications, connect, disconnect } useWebSocket(currentUserId.value); const connectionClass computed(() isConnected.value ? connected : disconnected); const connectWebSocket () { connect(); }; const disconnectWebSocket () { disconnect(); }; const formatTime (isoString: string) { return new Date(isoString).toLocaleTimeString(); }; const getEventText (eventType: string) { const map: Recordstring, string { VIEW_START: 开始观看你, VIEW_END: 停止观看你, }; return map[eventType] || eventType; }; /script style scoped .connected { color: green; } .disconnected { color: red; } /style4.3 创建观察者页面观察者页面需要能够输入被观察者的 ID 并发送“观看”事件。!-- src/views/ViewerPage.vue -- template div classviewer h1观察者页面/h1 p当前观察者ID: strong{{ viewerUserId }}/strong/p div label fortargetUserId输入被观察者ID: /label input idtargetUserId v-modeltargetUserId placeholder例如: user_observed_123 / button clicksendViewEvent :disabled!isConnected || !targetUserId发送“开始观看”事件/button /div p连接状态: span :classconnectionClass{{ isConnected ? 已连接 : 未连接 }}/span/p button clickconnectWebSocket :disabledisConnected连接WebSocket/button /div /template script setup langts import { ref, computed } from vue; import { useWebSocket } from /composables/useWebSocket; const viewerUserId ref(user_viewer_456); const targetUserId ref(); const { isConnected, connect, sendViewEvent: sendView } useWebSocket(viewerUserId.value); const connectionClass computed(() isConnected.value ? connected : disconnected); const connectWebSocket () { connect(); }; const sendViewEvent () { if (targetUserId.value) { sendView(targetUserId.value); targetUserId.value ; // 发送后清空 } }; /script5. 运行验证与测试5.1 启动后端服务确保在application.properties中配置了服务器端口默认 8080。运行DemoApplication主类。观察控制台日志看到 Tomcat 启动在 8080 端口并且 WebSocket 端点已注册。5.2 启动前端服务进入前端项目目录运行npm run dev。Vite 通常会启动在http://localhost:5173或其他端口。5.3 测试流程打开两个浏览器窗口或匿名窗口模拟两个用户。窗口 A访问http://localhost:5173被观察者主页。点击“连接 WebSocket”。控制台应显示连接成功。窗口 B访问http://localhost:5173/viewer观察者页面。点击“连接 WebSocket”。在窗口 B的输入框中输入窗口 A 中显示的用户 ID如user_observed_123点击“发送‘开始观看’事件”。观察窗口 A应该立即弹出一个提示框显示“用户 user_viewer_456 开始观看你”同时通知列表会更新这条记录。检查后端控制台应该能看到连接日志和消息处理日志。预期结果被观察者能近乎实时地收到观察者“观看”行为的通知。这模拟了“当你突然看我的时候”这一交互的底层技术实现。6. 常见问题排查与优化在实际部署和开发中你会遇到各种问题。以下是典型问题及其排查路径。6.1 连接建立失败问题现象可能原因检查方式处理建议前端控制台报WebSocket connection to ‘ws://...‘ failed1. 后端服务未启动。2. 后端 WebSocket 端点路径错误。3. 跨域问题前端与后端域名/端口不同。4. 网络策略如防火墙阻止。1. 确认后端应用日志是否正常启动。2. 检查前端连接 URL 是否与后端registerStompEndpoints注册的路径一致。3. 查看浏览器开发者工具 Network 面板看 WebSocket 连接请求的响应状态码。4. 检查后端 CORS 配置setAllowedOriginPatterns。1. 启动后端服务。2. 修正连接 URL。3. 开发阶段可暂时设置setAllowedOriginPatterns(“*”)生产环境必须指定具体前端域名。4. 检查服务器安全组和防火墙设置。连接使用 SockJS 时一直停留在Opening状态SockJS 在尝试多种传输协议WebSocket, xhr-streaming等。查看浏览器 Network 面板会看到一系列info、xhr请求。这通常是正常的降级过程。如果最终连接成功则无需处理。如果失败检查后端是否支持 SockJS添加了.withSockJS()。6.2 消息发送成功但接收不到问题现象可能原因检查方式处理建议前端发送消息后后端控制器收到并处理了但目标客户端没反应。1. 目标客户端未订阅正确的目的地。2. 消息目的地路径拼写错误。3. 后端消息转换器未正确序列化/反序列化 JSON。1. 在后端handleViewEvent方法内打印日志确认destination字符串。2. 在前端订阅代码处打印日志确认订阅的路径。3. 检查浏览器开发者工具 WebSocket 帧内容看服务器推送的消息格式是否正确。1. 确保订阅路径/topic/notifications/{userId}与发送路径完全匹配。2. 使用 Spring Boot 默认的 Jackson 库通常能自动处理 JSON。检查对象是否有正确的 getter 方法或 LombokData。3. 可以在前端 STOMP 客户端启用调试模式查看所有帧。6.3 连接不稳定经常断开问题现象可能原因检查方式处理建议连接一段时间后自动断开前端触发重连。1. 网络波动。2. 代理服务器如 Nginx超时设置过短。3. 缺乏心跳机制连接被中间设备清理。1. 查看后端日志是否有断开事件。2. 检查 Nginx 等代理的proxy_read_timeout,proxy_send_timeout配置通常需要设置得较长如 1小时。3. 检查 STOMP 客户端和服务端是否配置了心跳。1. 在 STOMP 配置中启用心跳。后端registry.enableSimpleBroker(...)后可链式调用.setHeartbeatValue(...)。2. 前端 STOMP 客户端连接时配置heartbeatIn/Out。3. 在前端实现自动重连逻辑。6.4 生产环境扩展性问题上述示例使用内存消息代理和内存 Session 映射仅适用于单机开发。生产环境需要考虑分布式扩展当有多台后端服务器时内存中的 Session 映射和订阅信息无法共享。需要使用外部消息代理如 RabbitMQ, Redis, Kafka作为 STOMP broker并集成 Spring Session 和 Redis 来管理分布式 WebSocket 会话。用户认证与授权连接建立时不能信任客户端传递的userId。应集成 Spring Security在握手阶段进行身份验证如基于 JWT并将认证信息绑定到 WebSocket 会话。连接数管理与限流防止单个用户建立过多连接耗尽资源。可以在握手拦截器中进行限制。消息持久化对于重要的通知如果用户离线需要存储起来待其上线后推送。这需要引入消息队列和离线存储。前端重连与状态恢复网络中断后前端应自动重连并重新订阅之前的频道。需要更完善的状态管理。7. 最佳实践与扩展方向7.1 连接管理最佳实践始终启用心跳在服务器和客户端配置心跳保持连接活跃及时发现死连接。// 后端配置示例 Override public void configureMessageBroker(MessageBrokerRegistry registry) { registry.enableSimpleBroker(/topic) .setHeartbeatValue(new long[]{10000, 10000}); // 发送间隔接收间隔毫秒 }实现前端自动重连在连接断开时使用指数退避策略进行重连。连接状态可视化在 UI 上清晰显示连接状态连接中、已连接、断开、重连中。7.2 消息设计最佳实践定义清晰的事件类型枚举如VIEW_START,VIEW_END,LIKE,COMMENT等避免魔法字符串。消息体版本化考虑在消息中加入版本字段便于后续兼容性升级。添加消息去重与幂等性对于可能重复发送的消息如网络重试在客户端或服务器端根据唯一 ID 进行去重处理。7.3 扩展功能方向“正在看”列表被观察者可以实时查看当前有哪些人在看自己。这需要服务器维护一个“观看关系”的集合并在连接断开时清理。观看时长统计结合VIEW_START和VIEW_END事件计算观察者的观看时长。匿名观看观察者可以不暴露自己的真实 ID由服务器生成临时标识。富媒体通知通知可以包含头像、观看的页面截图需前端配合等信息。离线消息集成 APNsiOS、FCMAndroid或 Web Push在用户关闭网页时仍能送达通知。构建“当你突然看我的时候”这样的实时感知功能技术核心在于稳定、高效的 WebSocket 连接管理和精准的消息路由。从最小原型到生产系统需要逐步解决认证、扩展、可靠性和用户体验等问题。本文提供的示例是一个完整的起点你可以在此基础上根据实际业务需求引入更强大的消息中间件、完善安全机制并优化前端交互最终打造出体验流畅的实时交互功能。