前言
为什么需要 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)协议,使得消息的发布/订阅、点对点通信、用户特定消息等复杂场景变得异常简单。
第一部分:准备工作
1.创建 spring boot 项目
使用 spring initializr (https://start.spring.io/) 创建一个新的项目。确保添加以下依赖:
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 (
spring-boot-starter-web
) - spring websocket (
spring-boot-starter-websocket
) - (可选) thymeleaf (
spring-boot-starter-thymeleaf
) - 用于创建简单的 html 前端页面进行演示。 - (可选) lombok - 简化 java 代码(如
@data
,@allargsconstructor
)。
2.项目结构
一个典型的结构可能如下:
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 */ @override public 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 */ @override public 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 用于从服务器端任意位置向客户端发送消息 @autowired private 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 用于美化 --> <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="external nofollow" 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> <input type="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> <input type="text" class="form-control" id="chatsender" placeholder="enter nickname" required /> </div> <div class="mb-3"> <label for="chatmessage" class="form-label">message:</label> <textarea class="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 = false console.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 的魔力。
第八部分:高级主题与最佳实践
1.用户认证与授权 (security):
- 通常需要将 websocket 连接与用户的登录会话关联。可以在
websocketconfig
的registerstompendpoints
中添加拦截器,或者在httpsessionhandshakeinterceptor
中将用户信息存入websocketsession
的属性。 - 使用 spring security 保护
/ws
端点,确保只有认证用户才能连接。 - 在
@messagemapping
方法上使用@preauthorize
进行细粒度权限控制。 - 使用
messagingtemplate.convertandsendtouser(username, destination, payload)
向特定用户发送私有消息。需要配置setuserdestinationprefix("/user")
。
2.消息代理 (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 代理中继,连接到外部的 broker config.enablestompbrokerrelay("/topic", "/queue") .setrelayhost("localhost") // 外部 broker 的主机 .setrelayport(61613) // stomp 端口 (rabbitmq 默认 61613) .setclientlogin("guest") // broker 用户名 .setclientpasscode("guest"); // broker 密码 }
优势: 支持集群、消息持久化、更复杂的消息路由、高可用性。
3.异常处理:
- 可以使用
@controlleradvice
和@messageexceptionhandler
注解来处理@messagemapping
方法中抛出的异常,并向客户端发送错误消息。 - 处理连接断开 (
websocketsession
关闭) 的逻辑。
4.性能与监控:
- 监控连接数、消息吞吐量。
- 考虑消息大小和频率,避免网络拥塞。
- 对于高并发场景,优化线程池配置。
5.前端库选择:
@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 在游戏、协作工具等领域的应用。
以上就是深入浅出springboot websocket构建实时应用全面指南的详细内容,更多关于springboot websocket构建实时应用的资料请关注代码网其它相关文章!
发表评论