深入浅出:在 Spring Boot 中构建实时应用 - 全面掌握 WebSocket
前言:为什么需要 WebSocket?
在传统的 Web 应用中,通信模式主要是 HTTP 请求-响应。客户端(通常是浏览器)发起一个请求,服务器处理后返回一个响应,然后连接关闭。这种模式对于获取网页内容、提交表单等操作非常有效。
然而,随着 Web 应用的复杂化,我们越来越多地需要实时、双向、持续的通信能力。例如:
- 在线聊天室: 用户发送消息,所有在线用户能立即看到。
- 实时通知: 新邮件、好友请求、系统告警需要即时推送给用户。
- 股票行情/数据仪表盘: 价格、状态需要秒级甚至毫秒级更新。
- 在线游戏: 玩家状态、游戏事件需要实时同步。
- 协作编辑: 多人同时编辑文档,彼此的修改需要实时可见。
如果使用传统的 HTTP 轮询(Polling)或长轮询(Long Polling)来实现这些功能,会带来巨大的服务器压力、延迟高、效率低下。WebSocket 协议的出现,正是为了解决这些问题。
WebSocket 是什么?
WebSocket 是一种在单个 TCP 连接上进行全双工(full-duplex)通信的协议。它允许服务器主动向客户端推送数据,而无需客户端先发起请求。一旦建立连接,客户端和服务器就可以像打电话一样,随时向对方发送消息,实现真正的实时双向通信。
Spring Boot 如何简化 WebSocket 开发?
Spring Boot 提供了强大的 spring-boot-starter-websocket
模块,它基于 Spring Framework 的 WebSocket 支持,极大地简化了在 Spring 应用中集成 WebSocket 的过程。它不仅支持原始的 WebSocket API,还集成了 STOMP(Simple Text Oriented Messaging Protocol)协议,使得消息的发布/订阅、点对点通信、用户特定消息等复杂场景变得异常简单。
第一部分:准备工作
-
创建 Spring Boot 项目
使用 Spring Initializr (https://start.spring.io/) 创建一个新的项目。确保添加以下依赖:- Spring Web (
spring-boot-starter-web
) - Spring WebSocket (
spring-boot-starter-websocket
) - (可选) Thymeleaf (
spring-boot-starter-thymeleaf
) - 用于创建简单的 HTML 前端页面进行演示。 - (可选) Lombok - 简化 Java 代码(如
@Data
,@AllArgsConstructor
)。
pom.xml
(Maven) 相关依赖示例:<dependencies><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</artifactId></dependency><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-websocket</artifactId></dependency><!-- 可选:用于模板渲染 --><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-thymeleaf</artifactId></dependency><!-- 可选:简化代码 --><dependency><groupId>org.projectlombok</groupId><artifactId>lombok</artifactId><scope>provided</scope></dependency> </dependencies>
- Spring Web (
-
项目结构
一个典型的结构可能如下:src/ ├── main/ │ ├── java/ │ │ └── com/example/websocketdemo/ │ │ ├── WebSocketConfig.java │ │ ├── WebSocketController.java │ │ ├── model/ │ │ │ └── Message.java │ │ └── WebSocketDemoApplication.java │ └── resources/ │ ├── static/ │ │ └── js/ │ │ └── app.js │ └── templates/ │ └── index.html └── test/└── ...
第二部分:配置 WebSocket (WebSocketConfig)
这是启用和配置 WebSocket 功能的核心步骤。我们需要创建一个配置类。
package com.example.websocketdemo;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;/*** WebSocket 配置类* @EnableWebSocketMessageBroker 注解启用 STOMP 消息代理功能。*/
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {/*** 配置消息代理(Message Broker)* 消息代理负责处理来自客户端的消息,并将消息广播给订阅了特定目的地的客户端。** @param config MessageBrokerRegistry*/@Overridepublic void configureMessageBroker(MessageBrokerRegistry config) {// 1. 启用一个简单的内存消息代理,用于处理以 "/topic" 或 "/queue" 开头的消息。// - "/topic" 通常用于**发布/订阅**模式(一对多广播)。// - "/queue" 通常用于**点对点**模式(一对一,但多个订阅者时会负载均衡)。config.enableSimpleBroker("/topic", "/queue");// 2. 定义应用目的地前缀。// 所有以 "/app" 开头的 STOMP 消息都会被路由到带有 @MessageMapping 注解的控制器方法中。// 例如:客户端发送到 "/app/hello" 的消息会被 @MessageMapping("/hello") 的方法处理。config.setApplicationDestinationPrefixes("/app");// (可选) 设置用户目的地前缀 (用于用户特定消息)// config.setUserDestinationPrefix("/user");}/*** 注册 STOMP 协议的 WebSocket 端点。* 客户端通过这些端点与服务器建立 WebSocket 连接。** @param registry StompEndpointRegistry*/@Overridepublic void registerStompEndpoints(StompEndpointRegistry registry) {// 1. 注册一个名为 "/ws" 的端点。// 客户端将连接到 "ws://<server>:<port>/ws" (HTTP) 或 "wss://<server>:<port>/ws" (HTTPS)。registry.addEndpoint("/ws")// 2. 启用 SockJS 作为后备机制。// SockJS 是一个 JavaScript 库,它在浏览器不支持原生 WebSocket 时,// 会尝试使用其他技术(如轮询)来模拟 WebSocket 行为,提高兼容性。// 客户端连接时,如果使用 SockJS,URL 会是 "/ws/sockjs/info" 等。.withSockJS();// (可选) 可以注册多个端点// registry.addEndpoint("/another-endpoint").withSockJS();}
}
关键点解析:
@EnableWebSocketMessageBroker
: 这个注解是开启 Spring WebSocket 支持的关键,它启用了 STOMP 消息代理。configureMessageBroker
:enableSimpleBroker(...)
: 启用一个简单的内存消息代理。对于生产环境,你可能需要集成更强大的消息代理,如 RabbitMQ 或 Redis(通过@EnableStompBrokerRelay
配置),以实现集群部署和消息持久化。setApplicationDestinationPrefixes(...)
: 定义了应用处理消息的前缀。/app
是约定俗成的前缀。
registerStompEndpoints
:addEndpoint("/ws")
: 定义了 WebSocket 连接的实际路径。.withSockJS()
: 强烈建议启用,以确保在老旧浏览器或网络环境下的兼容性。
第三部分:定义消息模型 (Message.java)
创建一个简单的 POJO 类来表示我们要发送和接收的消息。
package com.example.websocketdemo.model;import lombok.Data;
import lombok.AllArgsConstructor;/*** 消息实体类*/
@Data
@AllArgsConstructor
public class Message {private String content; // 消息内容private String sender; // 发送者private long timestamp; // 时间戳// 无参构造函数(JSON 反序列化需要)public Message() {}// (可选) 可以添加更多字段,如消息类型、接收者等
}
第四部分:创建 WebSocket 控制器 (WebSocketController.java)
这个控制器负责处理来自客户端的消息(通过 @MessageMapping
)以及向客户端发送消息(通过 SimpMessagingTemplate
)。
package com.example.websocketdemo;import com.example.websocketdemo.model.Message;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.messaging.handler.annotation.MessageMapping;
import org.springframework.messaging.handler.annotation.Payload;
import org.springframework.messaging.handler.annotation.SendTo;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.stereotype.Controller;
import org.springframework.web.util.HtmlUtils;import java.time.Instant;/*** WebSocket 消息处理控制器*/
@Controller // 使用 @Controller 而不是 @RestController,因为通常不直接返回 HTTP 响应
public class WebSocketController {// SimpMessagingTemplate 用于从服务器端任意位置向客户端发送消息@Autowiredprivate SimpMessagingTemplate messagingTemplate;/*** 处理客户端发送到 "/app/hello" 的消息。* 此方法将处理消息,并将处理后的结果广播给所有订阅了 "/topic/greetings" 的客户端。** @param message 客户端发送的原始消息 (Message 对象)* @return 处理后的消息 (Message 对象) - 这个返回值会被 @SendTo 指定的目的地接收* @throws Exception*/@MessageMapping("/hello") // 监听目的地 "/app/hello"@SendTo("/topic/greetings") // 将方法返回值发送到 "/topic/greetings"public Message greeting(@Payload Message message) throws Exception {// 模拟一些处理延迟Thread.sleep(1000);// 返回一个处理后的消息,包含原内容、发送者和当前时间戳return new Message("Hello, " + HtmlUtils.htmlEscape(message.getSender()) + "!","Server",Instant.now().toEpochMilli());}/*** 处理客户端发送到 "/app/chat" 的消息。* 这个方法展示了如何使用 SimpMessagingTemplate 进行更灵活的消息发送。* 它不会返回值给 @SendTo,而是直接使用 messagingTemplate 发送消息。** @param message 客户端发送的聊天消息*/@MessageMapping("/chat")public void handleChatMessage(@Payload Message message) {// 可以在这里进行消息验证、存储到数据库等操作// ...// 使用 SimpMessagingTemplate 将消息广播给所有订阅了 "/topic/chat" 的客户端messagingTemplate.convertAndSend("/topic/chat", message);// (示例) 向特定用户发送消息 (需要配置用户目的地前缀)// messagingTemplate.convertAndSendToUser("username", "/queue/private", privateMessage);}/*** (可选) 示例:从服务器内部其他地方(如定时任务、服务)触发消息发送*/// @Scheduled(fixedRate = 5000)// public void sendServerTime() {// Message timeMessage = new Message("Server Time: " + Instant.now(), "System", Instant.now().toEpochMilli());// messagingTemplate.convertAndSend("/topic/greetings", timeMessage);// }
}
关键点解析:
@Controller
: 标记为控制器。@MessageMapping("/hello")
: 将方法映射到 STOMP 消息的目的地/app/hello
。客户端发送到/app/hello
的消息会触发此方法。@Payload
: 明确指定参数是从消息体(Payload)中提取并反序列化为Message
对象的。@SendTo("/topic/greetings")
: 指定该方法的返回值应该发送到/topic/greetings
这个目的地。所有订阅了此目的地的客户端都会收到此消息。SimpMessagingTemplate
: 这是一个强大的工具,允许你在代码的任何地方(而不仅限于@MessageMapping
方法)发送消息。convertAndSend(destination, payload)
方法会将payload
对象序列化(通常是 JSON)并发送到指定的destination
。convertAndSendToUser(user, destination, payload)
用于向特定用户发送消息(需要配置用户目的地前缀和用户识别机制)。
第五部分:创建前端页面 (index.html)
使用 Thymeleaf 创建一个简单的 HTML 页面来测试我们的 WebSocket 功能。
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org"><head><meta charset="UTF-8" /><title>Spring Boot WebSocket Demo</title><!-- 引入 SockJS 客户端库 (如果配置了 withSockJS) --><script src="https://cdn.jsdelivr.net/npm/sockjs-client@1/dist/sockjs.min.js"></script><!-- 引入 STOMP 客户端库 --><script src="https://cdn.jsdelivr.net/npm/@stomp/stompjs@6.1.0/bundles/stomp.umd.min.js"></script><!-- (可选) Bootstrap 用于美化 --><linkhref="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css"rel="stylesheet"/></head><body><div class="container mt-5"><h1>WebSocket Chat & Greeting Demo</h1><div class="row"><div class="col-md-6"><h3>Send Greeting</h3><form id="greetingForm"><div class="mb-3"><label for="greetingSender" class="form-label">Your Name:</label><inputtype="text"class="form-control"id="greetingSender"placeholder="Enter your name"required/></div><button type="submit" class="btn btn-primary">Send Greeting</button></form></div><div class="col-md-6"><h3>Chat Room</h3><form id="chatForm"><div class="mb-3"><label for="chatSender" class="form-label">Nickname:</label><inputtype="text"class="form-control"id="chatSender"placeholder="Enter nickname"required/></div><div class="mb-3"><label for="chatMessage" class="form-label">Message:</label><textareaclass="form-control"id="chatMessage"rows="3"placeholder="Type your message..."required></textarea></div><button type="submit" class="btn btn-success">Send Message</button></form></div></div><div class="row mt-4"><div class="col-md-6"><h3>Greetings Received</h3><ul id="greetingList" class="list-group"></ul></div><div class="col-md-6"><h3>Chat Messages</h3><ul id="chatList" class="list-group"></ul></div></div></div><!-- 引入自定义 JavaScript --><script th:src="@{/js/app.js}"></script></body>
</html>
第六部分:编写前端 JavaScript (app.js)
这是前端与 WebSocket 交互的核心逻辑。
// 定义全局变量
let stompClient = null
let connected = false// 页面加载完成后执行
document.addEventListener("DOMContentLoaded", function () {connect()
})// 连接到 WebSocket 服务器
function connect() {// 1. 创建 SockJS 实例,连接到后端配置的端点 "/ws"// 如果后端没有配置 withSockJS,则使用 new WebSocket("ws://localhost:8080/ws");const socket = new SockJS("/ws") // 注意:路径是相对于当前页面的,这里假设在根路径// 2. 使用 SockJS 实例创建 STOMP 客户端stompClient = Stomp.over(socket)// 3. 连接到 STOMP 代理stompClient.connect({},function (frame) {console.log("Connected: " + frame)connected = true// 更新 UI 状态 (可选)// document.getElementById('connectionStatus').innerHTML = 'Connected';// 4. 订阅目的地 "/topic/greetings"// 当服务器向 "/topic/greetings" 发送消息时,onGreetingReceived 函数会被调用stompClient.subscribe("/topic/greetings", onGreetingReceived)// 5. 订阅目的地 "/topic/chat"stompClient.subscribe("/topic/chat", onChatMessageReceived)},function (error) {console.error("Connection error: " + error)connected = false// 重连逻辑 (可选)// setTimeout(function() { connect(); }, 5000);})
}// 处理从 "/topic/greetings" 接收到的消息
function onGreetingReceived(payload) {const message = JSON.parse(payload.body)const greetingList = document.getElementById("greetingList")const item = document.createElement("li")item.textContent = `[${new Date(message.timestamp).toLocaleTimeString()}] ${message.sender}: ${message.content}`item.className = "list-group-item list-group-item-info"greetingList.appendChild(item)// 自动滚动到底部greetingList.scrollTop = greetingList.scrollHeight
}// 处理从 "/topic/chat" 接收到的消息
function onChatMessageReceived(payload) {const message = JSON.parse(payload.body)const chatList = document.getElementById("chatList")const item = document.createElement("li")item.textContent = `[${new Date(message.timestamp).toLocaleTimeString()}] ${message.sender}: ${message.content}`item.className = "list-group-item"chatList.appendChild(item)chatList.scrollTop = chatList.scrollHeight
}// 处理 "Send Greeting" 表单提交
document.getElementById("greetingForm").addEventListener("submit", function (event) {event.preventDefault() // 阻止表单默认提交行为const senderInput = document.getElementById("greetingSender")const sender = senderInput.value.trim()if (sender && connected) {// 发送消息到目的地 "/app/hello"// 消息体是一个 JSON 字符串stompClient.send("/app/hello",{},JSON.stringify({ sender: sender, content: "Greeting Request" }))senderInput.value = "" // 清空输入框} else if (!connected) {alert("WebSocket not connected!")}})// 处理 "Send Message" 表单提交
document.getElementById("chatForm").addEventListener("submit", function (event) {event.preventDefault()const senderInput = document.getElementById("chatSender")const messageInput = document.getElementById("chatMessage")const sender = senderInput.value.trim()const content = messageInput.value.trim()if (sender && content && connected) {// 发送消息到目的地 "/app/chat"const chatMessage = {sender: sender,content: content,timestamp: new Date().getTime(), // 客户端时间戳,服务器会用自己的}stompClient.send("/app/chat", {}, JSON.stringify(chatMessage))// 清空输入框messageInput.value = ""// (可选) 立即将消息显示在本地聊天列表(回显),服务器广播后会再次收到// onChatMessageReceived({body: JSON.stringify(chatMessage)});} else if (!connected) {alert("WebSocket not connected!")}})// (可选) 断开连接函数
function disconnect() {if (stompClient) {stompClient.disconnect()connected = falseconsole.log("Disconnected")// 更新 UI 状态// document.getElementById('connectionStatus').innerHTML = 'Disconnected';}
}// 页面卸载时断开连接
window.addEventListener("beforeunload", function () {disconnect()
})
关键点解析:
SockJS('/ws')
: 创建 SockJS 连接,路径必须与后端WebSocketConfig
中addEndpoint("/ws")
一致。Stomp.over(socket)
: 使用 SockJS 连接创建 STOMP 客户端。stompClient.connect(headers, connectCallback, errorCallback)
: 连接到 STOMP 代理。headers
通常为空对象{}
。stompClient.subscribe(destination, callback)
: 订阅一个目的地。callback
函数接收一个payload
参数,其body
属性是服务器发送的原始消息字符串(通常是 JSON)。stompClient.send(destination, headers, body)
: 向指定目的地发送消息。body
是消息内容(字符串)。JSON.parse(payload.body)
: 将接收到的 JSON 字符串解析成 JavaScript 对象。JSON.stringify(object)
: 将 JavaScript 对象序列化成 JSON 字符串发送。
第七部分:运行与测试
- 启动应用: 运行
WebSocketDemoApplication.java
的main
方法。 - 访问页面: 打开浏览器,访问
http://localhost:8080
(或你配置的端口和路径)。 - 观察控制台: 打开浏览器的开发者工具(F12),查看 Network 和 Console 标签页。你应该能看到 SockJS 或 WebSocket 连接建立成功 (
CONNECTED
帧)。 - 测试功能:
- 在 “Send Greeting” 区域输入名字并点击 “Send Greeting”。稍等 1 秒,你会在 “Greetings Received” 列表中看到服务器返回的 “Hello, [你的名字]!” 消息。
- 在 “Chat Room” 区域输入昵称和消息,点击 “Send Message”。消息会立即出现在 “Chat Messages” 列表中(因为服务器广播回所有客户端,包括发送者)。
- 打开多个浏览器标签页或窗口访问同一个页面。在一个窗口发送消息,其他所有窗口都会实时收到更新!这就是 WebSocket 的魔力。
第八部分:高级主题与最佳实践
-
用户认证与授权 (Security):
- 通常需要将 WebSocket 连接与用户的登录会话关联。可以在
WebSocketConfig
的registerStompEndpoints
中添加拦截器,或者在HttpSessionHandshakeInterceptor
中将用户信息存入WebSocketSession
的属性。 - 使用 Spring Security 保护
/ws
端点,确保只有认证用户才能连接。 - 在
@MessageMapping
方法上使用@PreAuthorize
进行细粒度权限控制。 - 使用
messagingTemplate.convertAndSendToUser(username, destination, payload)
向特定用户发送私有消息。需要配置setUserDestinationPrefix("/user")
。
- 通常需要将 WebSocket 连接与用户的登录会话关联。可以在
-
消息代理 (Message Broker):
-
Simple Broker: 适用于单机部署的简单应用。在集群环境下,不同实例间的客户端无法互相通信。
-
STOMP Broker Relay (推荐生产环境): 配置 Spring Boot 应用连接到外部的、功能更强大的 STOMP 消息代理(如 RabbitMQ, ActiveMQ, Redis)。
// 在 WebSocketConfig 中 @Override public void configureMessageBroker(MessageBrokerRegistry config) {// 配置应用目的地前缀config.setApplicationDestinationPrefixes("/app");// 配置用户目的地前缀config.setUserDestinationPrefix("/user");// 启用 STOMP 代理中继,连接到外部的 Brokerconfig.enableStompBrokerRelay("/topic", "/queue").setRelayHost("localhost") // 外部 Broker 的主机.setRelayPort(61613) // STOMP 端口 (RabbitMQ 默认 61613).setClientLogin("guest") // Broker 用户名.setClientPasscode("guest"); // Broker 密码 }
- 优势: 支持集群、消息持久化、更复杂的消息路由、高可用性。
-
-
异常处理:
- 可以使用
@ControllerAdvice
和@MessageExceptionHandler
注解来处理@MessageMapping
方法中抛出的异常,并向客户端发送错误消息。 - 处理连接断开 (
WebSocketSession
关闭) 的逻辑。
- 可以使用
-
性能与监控:
- 监控连接数、消息吞吐量。
- 考虑消息大小和频率,避免网络拥塞。
- 对于高并发场景,优化线程池配置。
-
前端库选择:
@stomp/stompjs
是目前最流行和维护良好的 STOMP 客户端库。sockjs-client
是 SockJS 的官方库。
第九部分:总结
通过本文的详细步骤,我们成功地在 Spring Boot 应用中集成并实现了 WebSocket 功能。我们学习了:
- 核心概念: WebSocket 协议、STOMP、消息代理、发布/订阅模式。
- 配置: 使用
@EnableWebSocketMessageBroker
和WebSocketMessageBrokerConfigurer
进行配置。 - 后端开发: 使用
@MessageMapping
,@SendTo
,SimpMessagingTemplate
处理消息和发送消息。 - 前端开发: 使用
sockjs-client
和@stomp/stompjs
库建立连接、订阅、发送消息。 - 高级主题: 安全、消息代理、异常处理。
Spring Boot 的 WebSocket 支持使得构建实时 Web 应用变得相对简单和高效。掌握这些知识,你就可以为你的应用添加强大的实时交互能力了。
下一步:
- 尝试集成 Spring Security 进行用户认证。
- 将简单消息代理替换为 RabbitMQ 或 Redis。
- 实现更复杂的聊天功能,如群组、在线状态、消息历史记录。
- 探索 WebSocket 在游戏、协作工具等领域的应用。
参考资料:
- Spring Framework 官方文档 - WebSocket Support: https://docs.spring.io/spring-framework/docs/current/reference/html/web.html#websocket
- Spring Boot 官方文档 - WebSocket: https://docs.spring.io/spring-boot/docs/current/reference/htmlsingle/#messaging.websocket
- STOMP Protocol: https://stomp.github.io/
- SockJS: https://github.com/sockjs/sockjs-client
- @stomp/stompjs: https://github.com/stomp-js/stompjs
希望这篇详尽的指南能帮助你顺利在 Spring Boot 项目中应用 WebSocket!