一、为什么我会在Stomp上栽跟头

前一阵子做个实时订单提醒功能,后端用的是Spring Boot,本来想着WebSocket也就是个长连接,写起来应该不难。结果真正动手才发现,Spring Boot里对上WebSocket,走的最顺的路其实是STOMP协议。STOMP这东西一开始看着挺唬人,什么“面向消息的简单文本协议”,听着就像个高深玩意儿。实际踩了一圈坑之后,发现只要把几个核心点摸透了,它就是一头温顺的小毛驴。这篇文章就跟你聊聊我踩过的那些坑,以及最后是怎么把配置理顺的。

二、先搞明白WebSocket和Stomp是啥关系

严格说WebSocket和STOMP不是同一个层次的东西。WebSocket是一条管道,它解决了“浏览器和服务器之间能一直聊天”的问题,但是没规定聊天的格式。张三发一句话,你发过来的是“我饿了”三个字,他发过来的是“{"msg":"我饿了"}”,服务器收到后还得猜这是什么意思。STOMP就是用来定规矩的:你说“我要订阅哪个频道”、“我要发到哪个地址”、“消息体长啥样”,都写在一条文本里。Spring Boot集成WebSocket时,STOMP更像是给WebSocket加了一层层高速公路上的路牌,让消息能精准地跑到该去的地方。

我最初犯的错就是把两者当成一个东西,结果配置时一会儿以为该在WebSocketConfig里写路径,一会儿又去改STOMP的注解,绕了好大一圈。你要记住:WebSocket负责“连接”,STOMP负责“消息路由”,Spring Boot里你用@EnableWebSocketMessageBroker这个注解,就是告诉Spring,我们不仅要开WebSocket,还要把STOMP那套也跑起来。

三、先把最基础的配置搭起来

技术栈:Spring Boot 2.7.5 + Spring WebSocket + STOMP(Java)。

先加上依赖,我习惯用Maven,只加一个starter就能同时引入WebSocket和STOMP相关的东西。

<!-- Spring Boot WebSocket 依赖,里面已经包含 STOMP 相关类 -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-websocket</artifactId>
</dependency>

然后写一个配置类。配置类是整个Stomp的“总闸门”,大部分坑都是从这里开始的。

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;

// 标注这是一个Spring配置类
@Configuration
// 这个注解一打开,WebSocket + STOMP 就都启用了
@EnableWebSocketMessageBroker
public class WebSocketStompConfig implements WebSocketMessageBrokerConfigurer {

    /**
     * 注册STOMP端点,也就是客户端最初握手用的地址。
     * 这里的 /ws 就像门卫,客户端要先在这里报到一下。
     */
    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        registry.addEndpoint("/ws")           // 握手端点,前端连接时用 ws://域名/ws
                .setAllowedOriginPatterns("*") // 允许跨域,开发时先放开
                .withSockJS();                 // 启用SockJS,纯WebSocket连不上时自动降级
    }

    /**
     * 配置消息代理,说白了就是定义消息从哪来、到哪去。
     */
    @Override
    public void configureMessageBroker(MessageBrokerRegistry config) {
        // 客户端发给服务端的消息,统一以 /app 开头
        config.setApplicationDestinationPrefixes("/app");
        // 服务端推送给客户端的消息,前缀是 /topic 或 /queue
        // 这里用的是内置的“简单消息代理”,直接跑在Spring进程里
        config.enableSimpleBroker("/topic", "/queue");
        // 点对点消息的前缀,默认就是 /user,一般不用改
        config.setUserDestinationPrefix("/user");
    }
}

这个配置看起来简单,但里面每个变量的含义都有可能让你翻车。下面我按踩坑顺序一条条说。

四、第一个坑:握手端点路径和消息路径混为一谈

刚接触Stomp时,我总以为客户端连了/ws之后,就万事大吉,随便发消息就完事。其实/ws只管“第一次见面”,相当于两个人握手认识一下;后面发消息都得走/app前缀,推消息走/topic或者/queue。我把/ws当成消息地址,结果前端发消息到/ws/chat,后端根本收不到。

正确逻辑是这样的:如果服务端有一个方法用@MessageMapping("/chat"),那客户端发的地址应该是/app/chat。前面那个/app就是你在配置里写的setApplicationDestinationPrefixes("/app")。没有这个前缀,消息不会进到你的Controller里。

五、第二个坑:订阅地址和发送地址没对齐

前端经常出现这种情况:服务端用@SendTo("/topic/messages")把结果推出去,前端却在/queue/messages上等消息,等半天等了个寂寞。/topic是“广播”,所有人都能收到;/queue是“点对点”,一般是给某个人或者某个小组收的。

我客户端的代码是Java,也犯过这种错。服务端往/topic发,我订阅/queue,后来改成/topic就好了。下面是一段稍微完整的Controller示例,你看完就能明白消息是怎么转的。

import org.springframework.messaging.handler.annotation.MessageMapping;
import org.springframework.messaging.handler.annotation.SendTo;
import org.springframework.stereotype.Controller;

// 控制器,SpringMVC里那种@Controller在这里也能用
@Controller
public class ChatController {

    /**
     * 当客户端发消息到 /app/chat 时,这个方法会被调用。
     * 方法返回值会被Spring自动包装,然后发到 @SendTo 指定的地址。
     */
    @MessageMapping("/chat")
    @SendTo("/topic/messages")
    public ChatMessage handleChat(ChatMessage input) {
        System.out.println("收到来自 " + input.getSender() + " 的消息: " + input.getContent());

        // 做点业务处理,比如存库、调接口等
        ChatMessage result = new ChatMessage();
        result.setSender("服务端");
        result.setContent("我收到了你发来的:" + input.getContent());
        return result;
    }
}

对应的消息实体类长这样:

public class ChatMessage {
    // 谁发的
    private String sender;
    // 内容
    private String content;

    // getter/setter不能少,Spring要用它来转JSON
    public String getSender() {
        return sender;
    }

    public void setSender(String sender) {
        this.sender = sender;
    }

    public String getContent() {
        return content;
    }

    public void setContent(String content) {
        this.content = content;
    }
}

这样,前端订阅/topic/messages,然后往/app/chat发一条JSON,就能收到一条来自服务端的问候。注意这里@SendTo里的地址,必须和前端订阅的地址完全一致,差一个字母都收不到。

六、第三个坑:@MessageMapping 和 @SendTo 的转发规则没吃透

Spring里实现消息转发有好几种姿势。除了上面那种用注解“自动转”的,还有一种是用SimpMessagingTemplate手动发。比如同一个方法里,既要广播给所有人,又要单独给某个用户发私信,光靠@SendTo就不够用了。

我一开始只会在注解里写死目标地址,导致一个需求折腾半天。后来发现Spring早就给你准备了一把“瑞士军刀”,叫SimpMessagingTemplate。你只管把它注入进来,想往哪发就往哪发。

import org.springframework.messaging.handler.annotation.MessageMapping;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.stereotype.Controller;

@Controller
public class MsgController {

    // 注入Spring帮我们准备好的消息发送模板
    private final SimpMessagingTemplate messagingTemplate;

    // 构造器注入,比@Autowired更推荐
    public MsgController(SimpMessagingTemplate messagingTemplate) {
        this.messagingTemplate = messagingTemplate;
    }

    /**
     * 客户端发消息到 /app/check,这里只做转发演示。
     * 这个方法会同时往 /topic/notice 和 /user/当前用户/queue/private 各发一条
     */
    @MessageMapping("/check")
    public void check(String message) {
        // 广播给所有订阅了 /topic/notice 的人
        messagingTemplate.convertAndSend("/topic/notice", "大家注意:" + message);

        // 给当前登录用户发一条私密通知,后面会单独说用户消息
        // 注意:这里要知道“当前用户是谁”,通常从Principal里取
        // 示例就先不写具体用户了
        messagingTemplate.convertAndSend("/queue/private", "你有一条私信:" + message);
    }
}

SimpMessagingTemplate@SendTo灵活得多,因为它可以动态决定目标地址。比如你想根据数据库里查到的用户ID,给指定人发消息,用注解根本做不到。我最终在订单提醒项目里就是用这种方式做的,每次有新订单,就查一下这个订单归属的商家ID,然后convertAndSendToUser给商家推消息。

七、第四个坑:简单消息代理和外部消息代理的选择

Spring Boot内置的“简单消息代理”是很多初学者默认用的,它不需要额外依赖,跑起来就能用。但它有个硬伤:不支持真正的消息持久化,也不适合多实例部署。当你的服务开了多个副本,一个订单进来,用户连的是服务A,你消息却发到服务B上,用户就收不到了。

如果你只是做个演示或者内部小工具,简单代理完全够用。但如果是生产环境,得考虑用外部代理,比如RabbitMQ或者ActiveMQ。换了代理之后,配置会变样子。

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
public class ExternalBrokerConfig implements WebSocketMessageBrokerConfigurer {

    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        registry.addEndpoint("/ws").withSockJS();
    }

    @Override
    public void configureMessageBroker(MessageBrokerRegistry config) {
        // 应用目的地前缀还是 /app
        config.setApplicationDestinationPrefixes("/app");
        // 使用外部STOMP代理,比如RabbitMQ默认端口61613
        config.enableStompBrokerRelay("/topic", "/queue")
                .setRelayHost("localhost")
                .setRelayPort(61613)
                .setClientLogin("guest")
                .setClientPasscode("guest");
    }
}

外部代理的好处是消息不会被进程重启弄丢,也能跨服务实例转发。缺点就是你得另外运维一套中间件。我有个朋友图省事,生产用简单代理,结果每次发版重启,前端就丢一堆实时消息,后来老老实实上了RabbitMQ才消停。

我建议:人少于一百、不会重启服务的小项目用简单代理;一旦涉及多实例、高可用,别犹豫,直接上外部代理。

八、第五个坑:给指定用户发消息时,路径总对不上

点对点消息是WebSocket应用里最常用的场景之一。老板想给某个员工单独发个通知,总不能广播给全公司吧。Spring提供了一套约定,叫“/user/用户名/queue/xxx”,看着很简单,但真要自定义前缀或者搞清原理,容易犯迷糊。

先说结论:如果你用SimpMessagingTemplate.convertAndSendToUser(username, "/queue/notifications", payload),那Spring会自动把地址补成/user/{username}/queue/notifications。前端订阅的时候,要订阅/user/queue/notifications(注意没有用户名)或者/user/{username}/queue/notifications,具体看你前端用的库。这里最坑的就是:服务端写的第二个参数是/queue/notifications,但加上/user前缀后,你会以为前端应该订阅/user/{username}/queue/notifications,其实很多JavaScript库会自动帮你处理,你只要订/user/queue/notifications就等着收消息就行了。

为了让你不踩这个坑,我写一个完整的服务端推送私信的Controller,用Java写,还是在同一个技术栈里。

import org.springframework.messaging.handler.annotation.MessageMapping;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.security.core.Authentication;
import org.springframework.stereotype.Controller;

import java.security.Principal;

@Controller
public class UserNotifierController {

    private final SimpMessagingTemplate messagingTemplate;

    public UserNotifierController(SimpMessagingTemplate messagingTemplate) {
        this.messagingTemplate = messagingTemplate;
    }

    /**
     * 客户端调用 /app/notify 来触发服务端给自己发私信。
     * 注意这里的Principal会自动注入当前登录用户对象。
     */
    @MessageMapping("/notify")
    public void notifyMe(String message, Principal principal) {
        // 从Principal里拿到当前用户名
        String username = principal.getName();

        // 第二个参数 "目的地后缀" 是 /queue/notifications
        // Spring会自动加上 /user/{username} 前缀
        // 最终这条消息会发给 /user/{username}/queue/notifications
        messagingTemplate.convertAndSendToUser(username, "/queue/notifications", "你好," + username + ",收到消息:" + message);
    }
}

如果你想主动给某个不在线的用户发离线通知,可以不用Principal,直接写死用户名调用convertAndSendToUser,效果一样。关键是记住:convertAndSendToUser的第一个参数是用户标识,第二个参数是目标“相对路径”,相对路径必须以/queue开头才不容易和广播混淆。

再补充一个细节:很多项目集成了Spring Security,Principal里存储的就是登录名。但也有项目用自定义拦截器放用户信息,那你就得自己想办法从MessageHeaders里取,这又是另一个坑了。我的建议是尽量用标准Principal,省心。

九、其他容易忽视的小坑

9.1 心跳配置

Stomp自带心跳机制,客户端和服务端会定时互发一个空帧来确认连接还活着。Spring Boot默认心跳是10秒,如果网络环境差,可能因为心跳超时导致连接被断开。但也不能为了省事把心跳停掉,否则服务端不知道连接是否还存活。一般维持默认就行,除非你遇到断线问题,再考虑调大时间。

9.2 跨域问题

WebSocket的跨域和HTTP不太一样。早期浏览器不允许跨域连接,现在有setAllowedOriginPatterns可以控制。我开发时直接写*,生产环境最好写具体的域名,比如https://example.com。不然别人网站也能拿着你的/ws地址连进来,乱发消息。

9.3 消息序列化

Spring默认用Jackson转JSON,你传过来的对象字段名如果跟Java类对不上,会直接报转换异常。比如前端传"senderName",你Java类只有sender,就会解析失败。我建议定义一个专门的消息DTO,字段名前后端定死,别图省事用Map。

9.4 代理前缀冲突

如果你的setApplicationDestinationPrefixes设了/app,同时又用enableSimpleBroker("/app"),那就会冲突。因为/app是发给服务端的,代理是发给客户端的,两边不能共用同一个前缀。我有一回手滑把两者都写成/app,结果日志一直报找不到处理器,查半天才明白。

十、总结一下这趟浑水

应用场景其实非常清晰:只要是需要服务端主动把消息“塞”给客户端的场景,都适合用Stomp+WebSocket。比如订单状态实时提醒、在线聊天、协同编辑、股票行情推送。我以前用前端轮询接口来模拟实时推送,不仅浪费服务器资源,还经常延迟好几秒。换成了WebSocket之后,消息几乎是秒到。

再说说优缺点,帮你判断到底要不要用它。

优点很突出:

  • 服务端能主动发消息,不用客户端一直问“有新消息吗”。
  • Stomp协议标准,客户端各种语言都有现成的库,不用自己设计协议格式。
  • Spring Boot对Stomp支持很成熟,注解配置后基本不用写底层代码。
  • 能区分广播、点对点、带用户标识的私信,满足大部分业务需求。

缺点也不容忽视:

  • 调试比HTTP麻烦。浏览器F12看不到WebSocket的详细报文,很多时候得靠日志。
  • 多实例部署要额外引入RabbitMQ这类外部代理,复杂度一下子上去了。
  • 长连接会占用服务器资源,连接数多了以后,连接管理本身就是个麻烦事。
  • 前端配合使用时要学新概念,对不熟悉Stomp的小伙伴来说,入门成本高。

最后给你一个避坑清单,也是我这次整理的配置要点:

  1. 握手端点只管连接,消息地址要分开记清楚。
  2. /app开头是发给服务端的,/topic/queue开头是服务端发给客户端的。
  3. @SendTo适合固定目标,SimpMessagingTemplate适合动态目标。
  4. 用简单代理时别做多实例,上生产前提前换外部代理。
  5. 给用户发私信,记住“服务端不加用户前缀,前端订阅时加”。
  6. 跨域、心跳、序列化这些小事,等出问题再处理就晚了。

把这些都理顺了,Spring Boot集成WebSocket其实并没有想象中那么吓人。以后我再看到“Stomp协议”这几个字,心里一点不怵了。希望这篇文章能让你少踩几个坑,把实时消息功能顺顺当当做出来。