2.从零开始学习SpringBoot整合WebSocket与STOMP协议:实用案例带你轻松入门!
在我们的日常工作中,我们可能会遇到需要实现双向通讯的场景。为了解决这个问题,常见的实现方案包括短轮询、长轮询、SSE和WebSocket等几种方式。本文将重点介绍如何通过整合WebSocket和STOMP协议来实现双向通讯的方案, 并给出一个应用实例,带你轻松掌握如何基于SpringBoot搭建一个在线聊天系统
I. 基础知识点
1. 相关概念释疑
再开始之前,简单介绍一下文中会出现的几个术语的
WebSocket: WebSocket是一种在单个TCP连接上进行全双工通信的协议。它使得客户端和服务器之间能够实时地进行双向数据传输,而不需要频繁地建立和关闭连接。这种机制可以提高数据传输的效率和性能。
STOMP: Simple Text Oriented Messaging Protocol 是一种简单而灵活的消息传递协议,它是在HTTP之上实现的。STOMP协议定义了一套简单的命令和帧格式,用于在客户端和服务器之间进行消息传递。通过使用STOMP协议,我们可以实现客户端和服务器之间的双向通讯。
注意:stomp协议属于websocket的子协议
2. WebSocket整合STOMP
WebSocket整合STOMP协议实现双向通讯的方案,主要步骤如下:
建立WebSocket连接:客户端通过JavaScript的WebSocket API与服务器建立连接。
发送STOMP帧:在WebSocket连接建立后,客户端和服务器就可以通过发送STOMP帧来进行通信了。STOMP帧是STOMP协议的基本单位,它定义了消息的各种操作,如订阅、发布等。
处理STOMP帧:服务器收到STOMP帧后,会根据帧的类型进行相应的处理。例如,如果收到一个SUBSCRIBE帧,服务器就会为客户端创建一个订阅;如果收到一个SEND帧,服务器就会将消息发送给指定的目的地。
关闭WebSocket连接:当客户端和服务器都完成了通信后,可以通过调用WebSocket API的close方法来关闭连接。
3. SpringBoot整合STOMP流程
SpringBoot对websocket提供了非常友好的封装,对于业务开放而言,可以简单迅速的搭建一个基于STOMP协议的websocket应用工程

上图展示了STOMP的基本工作流程。
步骤1:初始化
服务端:
- 定义接收建立WebSocket连接的端点EndPoint。
 - 配置消息代理Broker,用于前端订阅(subscribe),后端向该Broker写入消息后,订阅了该Broker的前端都会收到相应的消息。
 - 配置路由转发规则,将用户发送的信息转发给相应的处理器(类似于
RequestMappingHandlerMapping与@RequestMapping注解,在WebSocket中使用的是Destination+@MessageMapping)。 
客户端:
- 与EndPoint建立连接。
 - 订阅Broker,注册消息回调。
 
步骤2:通讯
服务端:
- 主动向Broker写入消息:使用
simpMessagingTemplate。 - 消息应答:使用
@SendTo注解。 
客户端:
- 发送消息:调用send(xxxx)方法。
 - 消息应答:触发订阅的回调函数。
 
II. 实例演示
1. 项目配置
首先搭建一个标准的SpringBoot项目工程,相关版本以及依赖如下
本项目借助SpringBoot 2.2.1.RELEASE + maven 3.5.3 + IDEA进行开发
核心依赖 spring-boot-starter-websocket, 其中模板渲染引擎thymeleaf主要是集成前端页面
<dependencies>
    <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>
</dependencies>
2. WebSocket配置
正如前面流程图中介绍的,后端需要先配置一下websocket的相关信息,通过实现配置类WebSocketMessageBrokerConfigurer来定义
@Configuration
@EnableWebSocketMessageBroker
public class StompConfiguration implements WebSocketMessageBrokerConfigurer {
    /**
     * 这里定义的是客户端接收服务端消息的相关信息
     *
     * @param registry
     */
    @Override
    public void configureMessageBroker(MessageBrokerRegistry registry) {
        // 消息代理指定了客户端订阅地址,前端订阅的就是这个路径, 接收后端发送的消息
        // 对应 index.js中的 stompClient.subscribe('/topic/hello'
        registry.enableSimpleBroker("/topic");
        // 表示配置一个或多个前缀,通过这些前缀过滤出需要被注解方法处理的消息。
        // 例如,前缀为 /app 的 destination 可以通过@MessageMapping注解的方法处理,
        // 而其他 destination (例如 /topic /queue)将被直接交给 broker 处理
        registry.setApplicationDestinationPrefixes("/app");
    }
    /**
     * 添加一个服务端点,来接收客户端的连接
     * 即客户端创建ws时,指定的地址, let socket = new WebSocket("ws://ws/hello");
     *
     * @param registry
     */
    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        // Endpoint指定了客户端建立连接时的请求地址
        registry.addEndpoint("/ws/hello").withSockJS();
    }
}
- 定义端点: 
registerStompEndpoints() - 定义客户端与服务端通讯信息: 
configureMessageBroker() 
- 配置消息代理: 
registry.enableSimpleBroker - 配置消息转发: 
registry.setApplicationDestinationPrefixes,转发前缀可以是多个 
上面的配置完毕之后,对于客户端而言
- 建立连接的路径是: 
/ws/hello - 订阅消息的路径是: 
/topic/xxx- 订阅的路径全程,是由 
@SendTo的路径来确定 
 - 订阅的路径全程,是由 
 - 接收前端消息的路劲是: 
/app/xxx- 后面的这个xxx,是由 
@MessageMapping中的路径来补齐 
 - 后面的这个xxx,是由 
 
3. 消息接收应答
@Controller
public class HelloController {
    /**
     * 当接受到客户端发送的消息时, 发送的路径是: /app/hello (这个/app前缀是 StompConfiguration 中的配置的)
     * 将返回结果推送给所有订阅了 /topic/hello 的消费者
     *
     * @param content
     * @return
     */
    @MessageMapping("/hello")
    @SendTo("/topic/hello")
    public String sayHello(String content) {
        return "resp: " + content + " | " + LocalDateTime.now();
    }
}
实现一个非常简单的消息应答,接收客户端朝 /app/hello 发送的消息,然后将结果写回到 /topic/hello 对应的Broker,那么订阅了这个broker的客户端,都会收到这个应答消息了
再写一个定时器,由后端定时的向/topic/hello这个Broker中写入消息,模拟后台主动下发消息的场景
@EnableScheduling
@SpringBootApplication
public class Application {
    @Autowired
    private SimpMessagingTemplate simpMessagingTemplate;
    public static void main(String[] args) {
        SpringApplication.run(Application.class);
    }
    /**
     * 服务端,像订阅了 /topic/hello 的连接端主动发送消息
     *
     * @throws IOException
     */
    @Scheduled(cron = "0/10 * * * * ?")
    public void sc1() throws IOException {
        String rspMsg = Thread.currentThread().getName() + " 自动返回 | sc1:" + LocalDateTime.now();
        // 后端主动给前端发送消息
        simpMessagingTemplate.convertAndSend("/topic/hello", rspMsg);
    }
}
4. 前端示例
一个简单基础的html页面
<!DOCTYPE html>
<html lang="zh-CN" xmlns:th="http://www.thymeleaf.org"
      xmlns:layout="http://www.ultraq.net.nz/web/thymeleaf/layout">
<head>
    <title>Hello WebSocket</title>
    <link th:href="@{/main.css}" rel="stylesheet">
    <link href="/css/bootstrap.min.css" rel="stylesheet" crossorigin="anonymous">
    <script src="/js/jquery.js"></script>
    <script src="/js/sockjs.min.js"></script>
    <script src="/js/stomp.min.js"></script>
    <script src="/index.js"></script>
</head>
<body>
<div id="main-content" class="container">
    <div class="row">
        <div class="col-md-6">
            <form class="form-inline">
                <div class="form-group">
                    <label for="connect">WebSocket 连接:</label>
                    <button id="connect" class="btn btn-warning" type="submit">Connect</button>
                    <button id="disconnect" class="btn btn-danger" type="submit" disabled="disabled">Disconnect
                    </button>
                </div>
            </form>
        </div>
        <div class="col-md-6">
            <form class="form-inline">
                <div class="form-group">
                    <label for="name">send some message: </label>
                    <input type="text" id="name" class="form-control" placeholder="message here...">
                    <button id="send" class="btn btn-dark" type="submit">Send</button>
                </div>
            </form>
        </div>
    </div>
    <div class="row">
        <div class="col-md-12">
            <table id="conversation" class="table table-striped">
                <thead>
                <tr>
                    <th>Greetings</th>
                </tr>
                </thead>
                <tbody id="greetings">
                </tbody>
            </table>
        </div>
    </div>
</div>
</body>
</html>
核心的js逻辑,实现websocket连接建立,通讯
var stompClient = null;
function setConnected(connected) {
    $("#connect").prop("disabled", connected);
    $("#disconnect").prop("disabled", !connected);
    if (connected) {
        $("#conversation").show();
    } else {
        $("#conversation").hide();
    }
    $("#greetings").html("");
}
function connect() {
    // 与webscoket的EndPoint建立连接
    var socket = new SockJS('/ws/hello');
    stompClient = Stomp.over(socket);
    stompClient.connect({}, function (frame) {
        setConnected(true);
        console.log('Connected: ' + frame);
        stompClient.subscribe('/topic/hello', function (greeting) {
            // 表示这个长连接,订阅了 "/topic/hello" , 这样后端像这个路径转发消息时,我们就可以拿到对应的返回
            console.log("resp: ", greeting.body)
            showGreeting(greeting.body);
        });
    });
}
function disconnect() {
    if (stompClient !== null) {
        stompClient.disconnect();
    }
    setConnected(false);
    console.log("Disconnected");
}
function sendName() {
    // 表示将消息转发到哪个目标,类似与http请求中的path路径,对应的是后端 @MessageMapping 修饰的方法
    stompClient.send("/app/hello", {}, JSON.stringify({'name': $("#name").val()}));
}
function showGreeting(message) {
    $("#greetings").prepend("<tr><td>" + message + "</td></tr>");
}
$(function () {
    $("form").on('submit', function (e) {
        e.preventDefault();
    });
    $("#connect").click(function () {
        connect();
    });
    $("#disconnect").click(function () {
        disconnect();
    });
    $("#send").click(function () {
        sendName();
    });
});
注意上面的实现:
- 建立连接 
connect() 
- 与后端定义的端点建立连接: 
new SockJS('/ws/hello'); - 连接建立成功之后,订阅Broker,接收消息回传: 
stompClient.subscribe('/topic/hello', 消息应答回调) 
- 发送消息
 
- `stompClient.send("/app/hello", xxx)``
 
- 断开连接
 
stompClient.disconnect();
5. 演示
到这里,一个完整的基于SpringBoot整合WebSocket与STOMP协议的示例应用已经搭建完成;接下来我们实际启动一下看看效果

上面的动图展示了前端建立连接后,向后端发送信息并接收后端广播的消息的过程。由于两个客户端订阅的是同一个broker,因此后端发送的消息可以被所有客户端接收到。
这种交互方式类似于群组聊天,当一个人发送消息后,其他人都可以收到。
如果我希望通过websocket实现一个消息通知的功能,其中后端发送的消息既有广播的,也有面向个人的,那么应该如何实现呢?
我们下篇博文将进行详细介绍