在 SpringBoot 中使用 STOMP 基于 WebSocket 建立 BS 双向通信

作者 | 李增光

杏仁后端工程师。「只有变秃,才能变强!」

Websocket

HTTP、WebSocket 等应用层协议,都是基于 TCP 协议来传输数据的。

HTTP 不足在于它与服务器的全双工通信依靠轮询实现,对于需要从服务器主动发送数据的情境,会给服务器资源造成很大的浪费,WebSocket 是针对 HTTP 在这种情况下的补充。

对于 WebSocket 来说,它必须依赖 HTTP 协议进行一次握手,握手成功后,数据就直接从 TCP 通道传输,与 HTTP 无关了。

WebSocket 是一个完整的应用层协议,包含一套标准的 API。

WebSocket 请求头分析

Request URL: ws://localhost:8080/his-websocket/533/1giglbas/websocket
Request Method: GET
Status Code: 101

Accept-Encoding: gzip, deflate, br
Accept-Language: zh-CN,zh;q=0.9,en;q=0.8
Cache-Control: no-cache
Connection: Upgrade
Cookie: Idea-e2c8f53c=ddd6f37a-65a0-4101-94f1-8864d9c71c68; sidebarStatus=0; JSESSIONID=03F59B3EE783F1CFEF2072D05835FA36; XSRF-TOKEN=50348e10-af01-441a-bb53-017ae18d0e09; SESSION=1cfa5aa3-57ec-44bb-ada7-47deb95c67b2
Host: localhost:8080
Origin: http://localhost:8080
Pragma: no-cache
Sec-WebSocket-Extensions: permessage-deflate; client_max_window_bits
Sec-WebSocket-Key: D+ar5ktXfJ5mPzgvSIXZ/A==
Sec-WebSocket-Protocol: chat, superchat
Sec-WebSocket-Version: 13
Upgrade: websocket
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/76.0.3809.132 Safari/537.36

可以发现,这段类似 HTTP 协议的握手请求中,多了几个东西。

Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Extensions: permessage-deflate; client_max_window_bits
Sec-WebSocket-Key: D+ar5ktXfJ5mPzgvSIXZ/A==
Sec-WebSocket-Protocol: chat, superchat
Sec-WebSocket-Version: 13

这个就是 Websocket 的核心了,告诉 Tomcat、Nginx 等服务器:注意啦,我发起的是 Websocket 协议,快点帮我找到对应的服务器处理。

Upgrade:HTTP 协议提供了一种特殊的机制,这一机制允许将一个已建立的连接升级成新的、不相容的协议。这里表示要升级协议为 Websocket。

Sec-WebSocket-Key:是一个 Base64 encode 的值,这个是浏览器随机生成的,告诉服务器:不要忽悠我,我要验证你是不是真的是 Websocket 助理。

Sec-WebSocket-Version:是告诉服务器所使用的 Websocket Draft(协议版本),在最初的时候,Websocket 协议还在 Draft 阶段,各种奇奇怪怪的协议都有,而且还有很多奇奇怪怪不同的东西,什么 Firefox 和 Chrome 用的不是一个版本之类的,当初 Websocket 协议太多可是一个大难题。不过现在还好,已经定下来啦~大家都使用的一个东西。

Sec_WebSocket-Protocol:是一个用户定义的字符串,用来区分同 URL 下,不同的服务所需要的协议,标识了客户端支持的子协议的列表。

Sec-WebSocket-Extensions:是客户端用来与服务端协商扩展协议的字段,permessage-deflate 表示协商是否使用传输数据压缩,clientmaxwindow_bits 表示采用 LZ77 压缩算法时,滑动窗口相关的 SIZE 大小。

然后服务器会返回下列东西,表示已经接受到请求。

Connection: upgrade
Date: Wed, 25 Sep 2019 09:20:06 GMT
Sec-WebSocket-Accept: 1bISo8QakTaeaNEatm9g1yFMGaY=
Sec-WebSocket-Extensions: permessage-deflate;client_max_window_bits=15
Upgrade: websocket

Sec-WebSocket-Accept: 这个则是经过服务器确认,并且加密过后的 Sec-WebSocket-Key。服务器:好啦好啦,知道啦,给你看我的 ID CARD 来证明行了吧,如果服务端没有返回此字段,客户端会抛出“ Error during WebSocket handshake ”错误,并关闭连接。

客户端通过验证服务端返回的 Sec-WebSocket-Accept 的值,来确定两件事情:

  1. 服务端是否理解 WebSocket 协议,如果服务端不理解,那么它就不会返回正确的 Sec-WebSocket-Accept,则建立WebSocket连接失败。

  2. 服务端返回的 Response 是对于客户端的此次请求的,而不是之前的缓存。主要是防止有些缓存服务器返回缓存的 Response。

至此,客户端与服务端的 WebSocket 连接就已经建立成功。此时的TCP连接不会释放。客户端和服务端可以互相通信了。

只需建立一次 Request/Response 消息对,之后都是 TCP 连接,避免了需要多次建立 Request/Response 消息对而产生的冗余头部信息。节省了大量流量和服务器资源。因此被广泛应用于线上 WEB 游戏和线上聊天室的开发。

STOMP 与 WebSocket

WebSocket 发送是以帧为单位的。而 WebSocket 协议上并没有规定其消息发送的详细格式。那就意味着每个使用 WebSocket 的开发者,都需要自己在服务端和客户端定义一套规则,来传输信息。那么,有没有已经造好的轮子呢?答案肯定是有的。这就是 STOMP

STOMP 即 Simple (or Streaming) Text Orientated Messaging Protocol,简单(流)文本定向消息协议,它提供了一个可互操作的连接格式,允许 STOMP 客户端与任意 STOMP 消息代理(Broker)进行交互。

STOMP 协议可以建立在 WebSocket 之上,也可以建立在其他应用层协议之上。并不是为 WS 所设计的, 它其实是消息队列的一种协议, 和 AMQP,JMS 是平级的。只不过由于它的简单性恰巧可以用于定义 WS 的消息体格式。目前很多服务端消息队列都已经支持了 STOMP,比如 RabbitMQ,Apache ActiveMQ 等。很多语言也都有 STOMP 协议的客户端解析库,像 JAVA 的 Gozirra,C 的 libstomp,Python 的 pyactivemq,JavaScript 的 stomp.js 等等。

浏览器提供了不同的 WebSocket 的协议,一些老的浏览器不支持 WebSocket 的脚本或者使用别的名字。默认下,stomp.js会使用浏览器原生的WebSocket class去创建 WebSocket。但是利用Stomp.over(ws)这个方法可以使用其他类型的 WebSockets。

STOMP 帧结构

STOMP 是一种基于帧的协议,一帧有一个命令

一个 STOMP 帧由三部分组成:命令,Header(头信息),Body(消息体)

  • 命令使用 UTF-8 编码格式,命令有 SEND、SUBSCRIBE、MESSAGE、CONNECT、CONNECTED 等。

  • Header 也使用 UTF-8 编码格式,它类似 HTTP的 Header,有 content-length,content-type 等。

  • Body 可以是二进制也可以是文本。注意 Body 与 Header 间通过一个空行(EOL)来分隔。

来看一个实际的帧例子:

 SEND
 destination:/broker/roomId/1
 content-length:57

 {“type":"ENTER","content":"o7jD64gNifq-wq-C13Q5CRisJx5E"}
  • 第 1 行:表明此帧为 SEND 帧,是 COMMAND 字段。

  • 第 2 行:Header 字段,消息要发送的目的地址,是相对地址。

  • 第 3 行:Header 字段,消息体字符长度。

  • 第 4 行:空行,间隔 Header 与 Body。

  • 第 5 行:消息体,为自定义的 JSON 结构。

STOMP 服务端

STOMP 服务端被设计为客户端可以向其发送消息的一组目标地址。STOMP 协议并没有规定目标地址的格式,它由使用协议的应用自己来定义。例如 /topic/a,/queue/a,queue-a 对于 STOMP 协议来说都是正确的。应用可以自己规定不同的格式以此来表明不同格式代表的含义。比如应用自己可以定义以 /topic 打头的为发布订阅模式,消息会被所有消费者客户端收到,以 /user 开头的为点对点模式,只会被一个消费者客户端收到。

STOMP 客户端

对于 STOMP 协议来说, 客户端会扮演下列两种角色的任意一种:

  • 作为生产者,通过 SEND 帧发送消息到指定的地址。

  • 作为消费者,通过发送 SUBSCRIBE 帧到已知地址来进行消息订阅,而当生产者发送消息到这个订阅地址后,订阅该地址的其他消费者会通过 MESSAGE 帧收到该消息。

实际上,WebSocket 结合 STOMP 相当于构建了一个消息分发队列,客户端可以在上述两个角色间转换,订阅机制保证了一个客户端消息可以通过服务器广播到多个其他客户端,作为生产者,又可以通过服务器来发送点对点消息。

WebSocket 和 STOMP 了解完毕,现在,我们完全可以定义一套自己的 Socket 服务。但是本着不要重复造轮子的原则,Google 一下,就会发现 Spring 已经为我们提供好了一个轮子,如果你使用 SpringBoot,那么使用讲更加方便,只需引入一个依赖即可: spring-boot-starter-websocket,在使用之前,先来了解一下 Spring 中的 WebSocket 架构。

Spring中的 WebSocket 架构

图片来自 spring 官网: https://docs.spring.io/spring-framework/docs/5.0.0.BUILD-SNAPSHOT/spring-framework-reference/html/websocket.html

图中各个组件介绍:

  • 生产者型客户端(左上组件):发送 SEND 命令到某个目的地址(destination)的客户端。

  • 消费者型客户端(左下组件):订阅某个目的地址(destination), 并接收此目的地址所推送过来的消息的客户端。

  • request channel一组用来接收生产者型客户端所推送过来的消息的线程池。

  • response channel一组用来推送消息给消费者型客户端的线程池。

  • broker:消息队列管理者,也可以成为消息代理。它有自己的地址(例如“/topic”),客户端可以向其发送订阅指令,它会记录哪些订阅了这个目的地址(destination)。

  • 应用目的地址(图中的“/app发送到这类目的地址的消息在到达 broker 之前,会先路由到由应用写的某个方法。相当于对进入 broker 的消息进行一次拦截,目的是针对消息做一些业务处理。

  • 非应用目的地址(图中的“/topic,也是消息代理地址)发送到这类目的地址的消息会直接转到 broker。不会被应用拦截。

  • SimpAnnotatonMethod发送到应用目的地址的消息在到达 broker 之前,先路由到的方法。这部分代码是由应用控制的。

消息从生产者发出到消费者消费的流转流程

首先,生产者通过发送一条 SEND 命令消息到某个目的地址(destination),服务端 request channel 接受到这条 SEND 命令消息,如果目的地址是应用目的地址则转到相应的由应用自己写的业务方法做处理(对应图中的 SimpAnnotationMethod),再转到 broker(SimpleBroker)。如果目的地址是非应用目的地址则直接转到 broker。broker 通过 SEND 命令消息来构建 MESSAGE 命令消息,再通过 response channel 推送 MESSAGE 命令消息到所有订阅此目的地址的消费者。废话不多说,下面直接上代码。

Spring 与 WebSocket

让我们以 spring官网上的一个 demo 来看看实际的代码。

创建 Message-handling Controller

在 Spring 中,STOMP 消息会被路由到以 Controller 注解标识的类中。即我们需要定义一个控制器类,并使用 Controller 注解来标识它,然后在其中实现具体的消息处理方法,我们创建一个名为 GreetingController 的类:

Spring Framework 允许@Controller@RestController类同时具有 HTTP 请求处理和 WebSocket 消息处理方法。

@Controller
public class GreetingController {

    @MessageMapping("/hello")
    @SendTo("/topic/greetings")
    public Greeting greeting(HelloMessage message) throws Exception {
        Thread.sleep(1000); // simulated delay
        return new Greeting("Hello, " + HtmlUtils.htmlEscape(message.getName()) + "!");
    }

}
  • 使用 @MessageMapping() 注解来标识所有发送到/hello这个 destination 的消息,都会被路由到这个方法进行处理。

  • 使用 @SendTo() 注解来标识这个方法返回的结果,都会被发送到它指定的 destination,/topic/greetings

  • greeting()方法的作用是,处理所有发到/hello这个 destination 的信息,并将处理的结果,发送到所有订阅了/topic/greetings这个 destination 的客户端。

其中模拟的延时,其本质是为了演示在 WebSocket 中,我们无需考虑超时这样的问题。 客户端与服务端连接建立后,服务端可以根据实际场景,在“任何有需要”的时候“推送”消息到客户端,直到连接释放。

为 Spring 配置 STOMP 消息

The STOMP destination is used for simple prefix-based routing. For example the "/app" prefix could route messages to annotated methods while the "/topic" and "/queue" prefixes could route messages to the broker.

@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {

    @Override
    public void configureMessageBroker(MessageBrokerRegistry config) {
      //启用SimpleBroker,使得订阅到此"topic"前缀的客户端可以收到消息.
        config.enableSimpleBroker("/topic");
      // //将"app"前缀绑定到MessageMapping注解指定的方法上。如"app/hello"被指定用greeting()方法来处理
        config.setApplicationDestinationPrefixes("/app");
    }

    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
      // “/gs-guide-websocket”即为客户端尝试建立连接的地址。
        registry.addEndpoint("/gs-guide-websocket").withSockJS();
    }

}

首先我们定义了一个 Spring 的配置类:WebSocketConfig,并使用 @EnableWebSocketMessageBroker 注解启用 WebSocket 的 broker,即使用 broker 来处理消息。

在该配置类中主要包含两部分内容,一个是消息代理,另一个是 Endpoint,消息代理指定了客户端订阅地址,以及发送消息的路由地址;Endpoint 指定了客户端建立连接时的请求地址。

SimpMessagingTemplate

借助于 SimpMessagingTemplate 我们可以在 任何时机进行消息推送,如下: 

Sending a message to a destination can also be done from anywhere in the application with the help of a messaging template

For example, an HTTP POST handling method can broadcast a message to connected clients, or a service component may periodically broadcast stock quotes.

@Controller
public class GreetingController {

    @Autowired
    private SimpMessagingTemplate template;

    @MessageMapping("/hello")
    @SendTo("/topic/greetings")
    public Greeting greeting(HelloMessage message) throws Exception {
        Thread.sleep(1000); // simulated delay
        return new Greeting("Hello, " + HtmlUtils.htmlEscape(message.getName()) + "!");
    }

    @GetMapping("/say/{word}")
    @ResponseBody
    public void greet(@PathVariable String word) {
        template.convertAndSend("/topic/greetings", new Greeting("Hello, " + HtmlUtils.htmlEscape(word) + "!"));
    }

}

至此,服务端的配置工作就完成了,非常简单。现在,让我们实现一个前端页面,来验证服务的工作情况。

创建前端实现页面

针对 STOMP,前端我们采用 JavaScript 的 stomp 的客户端实现 stomp.js 以及 WebSocket 的实现 SockJS。此处只展示核心代码。

Stomp websocket使用socket实现双工异步通信能力。但是如果直接使用 websocket 协议开发程序比较繁琐,我们可以使用它的子协议 Stomp。

SockJS sockjs 是 websocket 协议的实现,增加了对浏览器不支持 websocket 的时候的兼容支持 SockJS 的支持的传输的协议有 3 类:WebSocket, HTTP Streaming, and HTTP Long Polling。默认使用 websocket,如果浏览器不支持 websocket,则使用后两种的方式。SockJS 使用"Get /info"从服务端获取基本信息。然后客户端会决定使用哪种传输方式。如果浏览器使用 websocket,则使用 websocket。如果不能,则使用 Http Streaming,如果还不行,则最后使用 HTTP Long Polling。

//使用SockJS和stomp.js来打开“gs-guide-websocket”地址的连接,这也是我们使用Spring构建的SockJS服务。
function connect() {
    var socket = new SockJS('/gs-guide-websocket');
    stompClient = Stomp.over(socket);
    stompClient.connect({}, function (frame) {
        //连接成功后的回调方法
        setConnected(true);
        console.log('Connected: ' + frame);
        //订阅/topic/greetings地址,当服务端向此地址发送消息时,客户端即可收到。
        stompClient.subscribe('/topic/greetings', function (greeting) {
            //收到消息时的回调方法,展示欢迎信息。
            showGreeting(JSON.parse(greeting.body).content);
        });
    });
}
//断开连接的方法
function disconnect() {
    if (stompClient !== null) {
        stompClient.disconnect();
    }
    setConnected(false);
    console.log("Disconnected");
}
//将用户输入的名字信息,使用STOMP客户端发送到“/app/hello”地址。它正是我们在GreetingController中定义的greeting()方法所处理的地址.
function sendName() {
    stompClient.send("/app/hello", {}, JSON.stringify({'name': $("#name").val()}));
}

演示

与Security集成

WebSocket 与 Spring Security 集成,也很方便,参见:https://docs.spring.io/spring-security/site/docs/current/reference/htmlsingle/#websocket-authentication。

小结

本文介绍了 WebSocket 的协议,作为对 HTTP 协议的一种补充,WebSocket 可以作为 B/S 架构下的客户端与服务端的双向通信。我们又介绍了如何使用 STOMP 协议在 WebSocket 之上建立一个简单的 B/S 的发布订阅机制,尤其是在 Spring 体系下,使用非常简单,希望对大家有所帮助。

参考资料:

https://spring.io/guides/gs/messaging-stomp-websocket/

https://spring.io/projects/spring-security

全文完


以下文章您可能也会感兴趣:

我们正在招聘 Java 工程师,欢迎有兴趣的同学投递简历到 rd-hr@xingren.com 。

Spring Boot使用STOMP(Simple Messaging over Transport Layer Protocol)和WebSocket技术可以实现服务器与客户端之间的实时双向通信,适合点对点的消息推送。以下是基本步骤: 1. 添加依赖:首先,你需要在你的`pom.xml`文件添加Spring WebSocketStomp的依赖: ```xml <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-websocket</artifactId> </dependency> <dependency> <groupId>org.webjars</groupId> <artifactId>stomp-websocket</artifactId> </dependency> ``` 2. 配置WebSocket:在`Application.java`或配置类开启WebSocket支持,并指定一个处理器: ```java @Configuration @EnableWebSocketMessageBroker public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { @Override public void configureMessageBroker(MessageBrokerRegistry config) { config.enableSimpleBroker("/topic"); config.setApplicationDestinationPrefixes("/app"); } @Override public void registerStompEndpoints(StompEndpointRegistry registry) { registry.addEndpoint("/ws").withSockJS(); } } ``` 这里的`/topic`是STOMP主题的前缀,而`/app`是应用消息前缀。 3. 创建消息处理器:创建一个实现了`TextMessageSender`接口的类,处理发送和接收的消息: ```java @Service public class MyStompService { @Autowired private StompSessionHandlerAdapter sessionHandler; // 发送消息到特定用户 public void sendMessage(String destination, String message, Session session) { session.getAsyncSend().send(destination, new TextMessage(message)); } // 处理接收到的消息 @OnOpen public void onOpen(Session session) { sessionHandler.onOpen(session); } // ...其他事件如@OnMessage, @OnError, @OnClose等 } ``` 4. 客户端连接:在前端通过JavaScript库(如SockJS和Stomp.js)建立WebSocket连接并订阅主题,以便接收服务器发来的消息。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值