WebSocket 适用于需要低延迟、双向实时通信的场景,相比于 HTTP 轮询或长轮询,能显著提升性能和效率。它的主要应用场景有聊天室、实时协作工具(微信文档金山文档)、实时通知提醒(推送后台实时进度、大屏幕展示状态变化等等)。
本文用 Spring Websocket + STOMP 实现一个简单聊天室,并讨论服务的可用性问题等。
原理
一对一聊天的消息的传播路径如下:

一对多聊天(聊天室)的消息传播路径大体一致,只不过是由一个消息发送方发送给多个消息发送方;消息交换和路由也不同(上图中rabbitmq部分)。
STOMP:
STOMP (Simple Text Oriented Messaging Protocol)是一种基于文本的简单消息协议,定义了客户端与消息中间件(RabbitMQ、ActiveMQ)之间的消息规范。它设计简洁,采用类 HTTP 的请求 - 响应模式,通过文本命令(如CONNECT、SEND、SUBSCRIBE等)和 Headers(键值对)实现消息的发送、订阅和接收,底层可基于 TCP、WebSocket 等传输协议工作。
STOMP协议常见的命令如下:
命令 | 作用 |
---|---|
CONNECT | 客户端连接消息代理,携带一些身份信息 |
CONNECTED | 服务端响应连接成功 |
SEND | 客户端发送消息到某个目标地址(消息如何被路由) |
SUBSCRIBE | 客户端定于某个目标地址(Destination,这个概念之后会多次出现),一旦这个目标地址有消息,客户端就会接收到此消息 |
MESSAGE | 服务端推送消息给客户端(订阅了某个目标地址的客户端) |
一则STOMP消息(消息帧)示例,可以看到跟HTTP还是很像的:
bash
SEND # 命令部分,SEND消息是客户端发给代理送消息到某个目标地址
destination:/topic/chat # Header部分,传递元数据键值对。Destination指定消息发送的目标(队列或主题)
content-type:text/plain # Header部分,传递元数据键值对。content-type定义消息体格式
# Header部分和消息体之间用空格分隔
Hello everyone! # 消息体
^@ # 结束符 消息体以空字符(`0x00`,表示为`^@`)结束
STOMP over WebSocket:
WebSocket本身是一种底层的全双工通信协议,仅提供了原始的字节流传输能力,没有定义消息的格式、路由、订阅等高级特性。 STOMP over WebSocket 是指将 STOMP 协议运行在 WebSocket 之上。
消息的传输路径是怎样的?
- 浏览器1通过WebSocket连接上SpringBoot服务
- 使用STOMP协议,发送一条消息到某个目标(Destination)
- SpringBoot服务的消息代理(这里是RabbitMQ集群)收到这条消息
- RabbitMQ知道谁订阅了这个目标,将消息推送给订阅者,比如浏览器2。当然,它不是直接推送的,它要通过Web服务器
- Web服务器把消息推送给了浏览器2
从 RabbitMQ 的视角来看:浏览器不是直接的发布者或订阅者。浏览器是 STOMP 客户端,它连接的是 Spring Boot 服务,而不是直接连接 RabbitMQ。Web服务器(确切的说是Web服务器代表的客户端逻辑)是RabbitMQ的发布者和订阅者。
为什么需要Web服务器集群?
单机连接数是有限的。通过水平扩展,能实现负载均衡,分摊用户连接,提高可用性。
为什么需要RabbitMQ集群?
光有Web服务器集群是不够的的。比如,用户1连接到了服务器1, 用户2连接到了服务器2, 用户1发送的消息被服务器1收到之后,要想办法把它发送给服务器2. 可以通过调用服务器2的接口发送消息,也可以引入消息代理(如RabbitMQ、Redis Pub/Sub)作为消息总线。前者需要一个全局的用户会话状态(服务器1需要知道转发给哪台服务器),自己编码完成消息交换,复杂一些,可以用在特殊场景,。
后端
新建一个SpringBoot工程。Java 17, SpringBoot 3.0.8
添加Websocket依赖
xml
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
<!-- 为 Spring 项目提供与 AMQP 协议通信的能力,并且默认集成了对 RabbitMQ 的官方支持
该启动器包含以下:
1. Spring AMQP 的核心库(spring-amqp)
2. 与 RabbitMQ 通信的客户端封装(spring-rabbit)
3. 自动配置(AutoConfiguration):SpringBoot自动配置 RabbitMQ 的连接工厂、RabbitTemplate、监听容器等
4. 消息监听注解支持,比如 @RabbitListener
-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-amqp</artifactId>
</dependency>
<!-- Netty是异步、高并发的,适合大规模实时通信-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-reactor-netty</artifactId>
</dependency>
添加Websocket配置类
java
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
/*
配置连接端点,即客户端发起websocket连接的请求路径
*/
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/websocket") // 客户端代码:new SockJS("http://" + server + "/websocket");
.addInterceptors(new CustomHandshakeInterceptor()) // 添加自定义握手拦截器
.withSockJS(); // 支持 SockJS 协议. SockJS 是一个 JavaScript 库,提供了 WebSocket 的兼容层。如果浏览器不支持 WebSocket,它会自动降级为HTTP长轮询
}
/*
配置消息代理,即:客户端发送的消息最终由谁来处理/转发
*/
@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
// 客户端发送的、以 /app 开头的消息,将由 Spring 应用程序处理
// 客户端代码:stompClient.send("/app/chat.sendToAll", {}, JSON.stringify(data)); 发送的消息
// 将由 @MessageMapping("/chat.sendToAll")标注的方法处理
registry.setApplicationDestinationPrefixes("/app");
// 配置代理的地址和认证信息
// 户端订阅或发布消息的目标地址(Destination)以这两个前缀开头时,会交给代理处理
// 如果客户端发到 `/topic/xxx`,用 Topic Exchange 去处理,RoutingKey 是 xxx
registry.enableStompBrokerRelay("/topic/", "/queue/")
.setRelayHost("mydevcvm")
.setRelayPort(61613)
.setVirtualHost("/")
.setSystemLogin("admin")
.setSystemPasscode("admin@1991")
.setClientLogin("admin")
.setClientPasscode("admin@1991");
}
}
说明:
enableStompBrokerRelay("/topic/", "/queue/"),它的功能是启用外部的消息代理,默认是RabbitMQ。
不使用 Spring 内嵌的简单内存代理(对应代码是enableSimpleBroker
),而是将消息转发到一个外部的消息代理服务器去处理消息的广播、订阅等. 外部代理有更好的性能,支持持久化和集群. 简单内存代理仅适合开发和测试.
/topic/
和/queue/
是消息的目标地址(Destination)
的前缀
。目标地址
是STOMP中的概念,前缀
是STOMP对目标地址分类的方式。那么,RabbitMQ 是如何处理这些不同前缀的?它背后用的是什么交换机类型?
STOMP 目标地址前缀 | RabbitMQ 使用的交换机类型 | 说明 |
---|---|---|
/topic/ | Topic Exchange(主题交换机) | Spring 将消息发送到 RabbitMQ 中一个默认(或自定义)的 Topic 类型 Exchange,并根据 /topic/xxx 的路径进行路由 key 匹配(通常是直接使用该路径作为 routing key) |
/queue/ | Direct Exchange(直连交换机) | 通常会映射到 Direct Exchange,或者更常见的是:RabbitMQ 会为每个 /queue/xxx 的订阅创建一个 独占的匿名队列,并通过 Direct Exchange 路由到该队列(1对1) |
关键代码在 StompBrokerRelayMessageHandler
可以参考 RabbitMQ STOMP,介绍了前缀和交换机的关系(开启了谷歌翻译):

消息体定义
java
@Data
public class ChatMessage {
private Type type;
private String content;
private String sender;
private String receiver;
public enum Type {
CHAT, // 聊天消息
JOIN, // 加入聊天室
LEAVE // 离开聊天室
}
}
// 省略getter setter
Controller
java
@Controller
public class ChatController {
private static final Logger logger = LoggerFactory.getLogger(ChatController.class);
@Autowired
private SimpMessagingTemplate template;
/*
一对一发送消息
*/
@MessageMapping("/chat.sendToOne") // stompClient.send("/app/chat.sendToOne", {}, JSON.stringify(data));
public void sendToOne(@Payload ChatMessage chatMessage, SimpMessageHeaderAccessor headerAccessor) {
String userId = (String) headerAccessor.getSessionAttributes().put("userId", chatMessage.getSender());
template.convertAndSend("/queue/user" + chatMessage.getReceiver(), chatMessage);
logger.info("从 " + userId + " 向 " + chatMessage.getReceiver() + " 发送消息: " + chatMessage.getContent());
}
/*
一对多发送消息(聊天室)
*/
@MessageMapping("/chat.sendToAll") // stompClient.send("/app/chat.sendToAll", {}, JSON.stringify(data));
@SendTo("/topic/chat") // stompClient.subscribe('/topic/chat', function (response) {...})
public ChatMessage sendToAll(@Payload ChatMessage chatMessage, SimpMessageHeaderAccessor headerAccessor) {
return chatMessage;
}
/*
一对多发送消息(聊天室),使用template
*/
@MessageMapping("/chat.sendToAll2")
public void sendToAllByTemplate(@Payload ChatMessage chatMessage, SimpMessageHeaderAccessor headerAccessor) {
template.convertAndSend("/topic/chat", chatMessage); // stompClient.subscribe('/topic/chat', function (response) {...})
}
}
SendTo
声明式消息发送的注解,自动将方法返回值发送到指定目的地, 用于简化广播消息发送。所有订阅了/topic/public的客户端,都将收到chatMessage消息。 SimpMessagingTemplate
编程式消息发送工具类,支持动态目的地和用户定向消息。核心方法包括 convertAndSend(destination, payload)
和convertAndSendToUser(username, destination, payload)
. 订阅了 /queue/user1
的用户将收到 template.convertAndSend("/queue/user" + 1, chatMessage)
发送的消息
握手拦截器
java
public class CustomHandshakeInterceptor implements HandshakeInterceptor {
@Override
public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response,
WebSocketHandler wsHandler, Map<String, Object> attributes) {
// 将HTTP请求参数存入WebSocket会话属性
if (request instanceof ServletServerHttpRequest) {
ServletServerHttpRequest servletRequest = (ServletServerHttpRequest) request;
String userId = servletRequest.getServletRequest().getParameter("userId");
attributes.put("userId", userId);
}
return true;
}
@Override
public void afterHandshake(ServerHttpRequest request, ServerHttpResponse response,
WebSocketHandler wsHandler, Exception exception) {
// 握手后操作
}
}
WebSocket握手阶段(beforeHandshake
方法)从 HTTP 请求中提取"userId"参数,并将其存入WebSocket会话属性(attributes)中,供后续WebSocket通信过程使用.
前端
只是为了验证通信逻辑,前端代码比较简单
html
<!-- index.html --!>
<script id="code">
function sendMessage() {
var msg = $("#msg").val();
var toUserId = $("#userId").val();
var data = {"sender": userId, "receiver": toUserId, "content": msg, "type": 0};
if (toUserId == "") {
stompClient.send("/app/chat.sendToAll", {}, JSON.stringify(data));
} else {
stompClient.send("/app/chat.sendToOne", {}, JSON.stringify(data));
}
}
</script>
userId
和toUserId
是用户的身份标志,代表了消息的发送者、接收者。
javascript
// websocket.js
function connect() {
var server = window.location.host;
userId = GetQueryString("userId");
var socket = new SockJS("http://" + server + "/websocket?userId=" + userId);
stompClient = Stomp.over(socket);
stompClient.connect({}, function (frame) {
writeToScreen("connected: " + frame);
// 订阅一对一消息,所有发送到目标 /topic/chat 都会收到消息
stompClient.subscribe('/topic/chat', function (response) {
writeToScreen(response.body);
});
// 订阅一对一消息,所有发送到目标 /queue/user$userId的都会被$userId接收到
stompClient.subscribe("/queue/user" + userId, function (response) {
writeToScreen(response.body);
});
}, function (error) {
wsCreateHandler && clearTimeout(wsCreateHandler);
wsCreateHandler = setTimeout(function () {
console.log("重连..."); // fixme
connect();
}, 1000);
}
)
}
stompClient = Stomp.over(socket);
是STOMP协议的客户端实现,处理消息的帧格式转换和编码解码。此外他还处理WebSocket的连接建立、心跳检测、自动重连和连接关闭清理。
在 WebSocket 中,心跳机制通常用于检测连接是否存活,防止因长时间无通信而断开。 项目中的WebSocket心跳机制主要由前端Stomp客户端负责实现,具体是stomp.min.js库处理心跳包的发送与接收检测。服务器端(Spring Boot)使用了SockJS, 在连接时会协商心跳参数. 可以用 setHeartbeatTime(10000)
指定心跳间隔。
测试
在Kubernetes中启动3个Web服务器,分别监听32081、32082、32083端口;启动一个rabbitmq实例。开三个浏览器标签分别连接三个客户端。(没有用到ALB

部署事用到的yaml
yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: chat-8080
namespace: chat
spec:
replicas: 1
selector:
matchLabels:
app: chat-8080
template:
metadata:
labels:
app: chat-8080
spec:
containers:
- image: chat:v0.1
name: chat
env:
- name: PORT
value: "8080"
ports:
- containerPort: 8080
protocol: TCP
---
apiVersion: v1
kind: Service
metadata:
name: chat-8080
namespace: chat
spec:
ports:
- port: 8080
name: http
protocol: TCP
targetPort: 8080
nodePort: 32080
selector:
app: chat-8080
type: NodePort
发送方:

接收方:

还需要讨论的、验证的
- ALB