Spring Websocket Chatroom

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. 浏览器1通过WebSocket连接上SpringBoot服务
  2. 使用STOMP协议,发送一条消息到某个目标(Destination)
  3. SpringBoot服务的消息代理(这里是RabbitMQ集群)收到这条消息
  4. RabbitMQ知道谁订阅了这个目标,将消息推送给订阅者,比如浏览器2。当然,它不是直接推送的,它要通过Web服务器
  5. 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>

userIdtoUserId是用户的身份标志,代表了消息的发送者、接收者。

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
相关推荐
修一呀23 分钟前
[后端快速搭建]基于 Django+DeepSeek API 快速搭建智能问答后端
后端·python·django
哈基米喜欢哈哈哈27 分钟前
Spring Boot 3.5 新特性
java·spring boot·后端
当无36 分钟前
Mac 使用Docker部署Mysql镜像,并使用DBever客户端连接
后端
野生的午谦36 分钟前
PostgreSQL 部署全记录:Ubuntu从安装到故障排查的完整实践
后端
David爱编程1 小时前
可见性问题的真实案例:为什么线程看不到最新的值?
java·后端
00后程序员1 小时前
移动端网页调试实战,iOS WebKit Debug Proxy 的应用与替代方案
后端
whitepure1 小时前
我如何理解与追求整洁代码
java·后端·代码规范
用户8356290780512 小时前
Java高效读取Excel表格数据教程
java·后端
yinke小琪2 小时前
今天解析一下从代码到架构:Java后端开发的"破局"与"新生"
java·后端·架构
码出极致2 小时前
支付平台资金强一致实践:基于 Seata TCC+DB 模式的余额扣减与渠道支付落地案例
后端·面试