一、为什么需要STOMP?
WebSocket 协议是一种相当低级的协议。它定义了如何将字节流转换为帧。帧可以包含文本或二进制消息。由于消息本身不提供有关如何路由或处理它的任何其他信息,因此很难在不编写其他代码的情况下实现更复杂的应用程序。幸运的是,WebSocket 规范允许在更高的应用程序级别上使用子协议。
另外,单单使用WebSocket完成群聊、私聊功能时,需要自己管理session信息,通过STOMP协议时,spring已经封装好,开发者只需要关注自己的主题、订阅关系即可。
二、STOMP详解
STOMP 中文为“面向消息的简单文本协议”,STOMP 提供了能够协作的报文格式,以至于 STOMP 客户端可以与任何 STOMP 消息代理(Brokers)进行通信,从而为多语言,多平台和 Brokers 集群提供简单且普遍的消息协作。STOMP 协议可以建立在 WebSocket 之上,也可以建立在其他应用层协议之上。通过 Websocket建立 STOMP 连接,也就是说在 Websocket 连接的基础上再建立 STOMP 连接。最终实现如上图所示,这一点可以在代码中有一个良好的体现。
业界已经有很多优秀的 STOMP 的服务器/客户端的开源实现
STOMP 服务器:ActiveMQ、RabbitMQ、StompServer、… STOMP 客户端库:stomp.js(javascript)Stomp 的特点是客户端的实现很容易,服务端相当于消息队列的 broker 或者是 server,一般不需要我们去实现,所以重点关注一下客户端如何使用
CONNECT
启动与服务器的流或 TCP 连接
SEND
发送消息
SUBSCRIBE
订阅主题
UNSUBSCRIBE
取消订阅
BEGIN
启动事物
COMMIT
提交事物
ABORT
回滚事物
ACK
确认来自订阅的消息的消费
NACK
告诉服务器客户端没有消费该消息
DISCONNECT
断开连接
其实STOMP协议并不是为WS所设计的, 它其实是消息队列的一种协议, 和AMQP,JMS是平级的。 只不过由于它的简单性恰巧可以用于定义WS的消息体格式。 目前很多服务端消息队列都已经支持了STOMP, 比如RabbitMQ, Apache ActiveMQ等。很多语言也都有STOMP协议的客户端解析库,像JAVA的Gozirra,C的libstomp,Python的pyactivemq,JavaScript的stomp.js等等。
三、SpringBoot集成STOMP代码示例
3.1、功能示例
3.2、架构图
3.3、服务端代码
pom文件引入jar
<?xml version="1.0" encoding="UTF-8"?> <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <groupId>org.example</groupId> <artifactId>websocket-demo</artifactId> <version>1.0-SNAPSHOT</version> <parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.3.10.RELEASE</version> <relativePath/> <!-- lookup parent from repository --> </parent> <properties> <java.version>1.8</java.version> </properties> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.apache.commons</groupId> <artifactId>commons-lang3</artifactId> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <version>1.18.12</version> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-websocket</artifactId> </dependency> <dependency> <groupId>org.webjars</groupId> <artifactId>webjars-locator-core</artifactId> </dependency> <dependency> <groupId>org.webjars</groupId> <artifactId>sockjs-client</artifactId> <version>1.0.2</version> </dependency> <dependency> <groupId>org.webjars</groupId> <artifactId>stomp-websocket</artifactId> <version>2.3.3</version> </dependency> <dependency> <groupId>org.webjars</groupId> <artifactId>bootstrap</artifactId> <version>3.3.7</version> </dependency> <dependency> <groupId>org.webjars</groupId> <artifactId>jquery</artifactId> <version>3.1.0</version> </dependency> <dependency> <groupId>com.alibaba</groupId> <artifactId>fastjson</artifactId> <version>1.2.62</version> </dependency> </dependencies> <build> <plugins> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-compiler-plugin</artifactId> <configuration> <source>1.8</source> <target>1.8</target> </configuration> </plugin> </plugins> </build> </project>
WebSocketMessageBroker配置类
@Configuration @EnableWebSocketMessageBroker public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { // 启用一个简单的基于内存的消息代理 @Override public void configureMessageBroker(MessageBrokerRegistry config) { //通过/topic 开头的主题可以进行订阅 config.enableSimpleBroker("/topic"); //send命令时需要带上/app前缀 config.setApplicationDestinationPrefixes("/app"); //修改convertAndSendToUser方法前缀, 稍后解释作用 // config.setUserDestinationPrefix ("/myUserPrefix"); } @Override public void registerStompEndpoints(StompEndpointRegistry registry) { //连接前缀 registry.addEndpoint("/gs-guide-websocket") .setAllowedOrigins("*") // 跨域处理 .withSockJS(); //支持socketJs } }
@EnableWebSocketMessageBroker
注解启用 WebSocket 消息处理,由消息代理支持。
SockJS
有一些浏览器中缺少对 WebSocket 的支持,而 SockJS 是一个浏览器的 JavaScript库,它提供了一个类似于网络的对象,SockJS 提供了一个连贯的,跨浏览器的JavaScriptAPI,它在浏览器和 Web 服务器之间创建了一个低延迟、全双工、跨域通信通道。SockJS 的一大好处在于提供了浏览器兼容性。即优先使用原生WebSocket,如果浏览器不支持 WebSocket,会自动降为轮询的方式。如果你使用 Java 做服务端,同时又恰好使用 Spring Framework 作为框架,那么推荐使用SockJS。
控制器代码
@Slf4j @RestController public class TestController { @Autowired private SimpMessagingTemplate simpMessagingTemplate; @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("/topic/greetings") public Greeting greeting2(HelloMessage message) throws Exception { Thread.sleep(1000); // simulated delay log.info ("Hello, " + HtmlUtils.htmlEscape(message.getName()) + "!"); return new Greeting("Hello, " + HtmlUtils.htmlEscape(message.getName()) + "!"); } @GetMapping ("/hello2") public void greeting3(HelloMessage message) throws Exception { Thread.sleep(1000); // simulated delay simpMessagingTemplate.convertAndSend ("/topic/greetings", new Greeting("Hello, " + HtmlUtils.htmlEscape(message.getName()) + "!")); } @MessageMapping("/sendToUser") public void sendToUser(HelloMessage message) throws Exception { Thread.sleep(1000); // simulated delay log.info ("userId:{},msg:{}",message.getUserId (),message.getName ()); // simpMessagingTemplate.convertAndSendToUser (message.getUserId (),"/sendToUser", // new Greeting("Hello, " + HtmlUtils.htmlEscape(message.getName()) + "!")); // simpMessagingTemplate.convertAndSend ("/user/1/sendToUser", // new Greeting("Hello, " + HtmlUtils.htmlEscape(message.getName()) + "!")); simpMessagingTemplate.convertAndSend ("/topic/user/"+message.getUserId ()+"/sendToUser", new Greeting("Hello, " + HtmlUtils.htmlEscape(message.getName()) + "!")); } }
@MessageMapping
功能与RequestMapping注解类似。send指令发送信息时添加此注解
@SendTo/@SendToUser
将信息输出到该主题。客户端订阅同样的主题后就会收到信息。
在只有指定@MessageMapping
时@MessageMapping == “/topic” + @SendTo
如果想使用rest接口发送消息。可以通过SimpMessagingTemplate
进行发送。
点对点聊天时,可以使用SimpMessagingTemplate.convertAndSendToUser
方法发送。个人意味比注解@SendToUser
更加容易理解,更加方便
convertAndSendToUser
方法和convertAndSend
类似,区别在于convertAndSendToUser
方法会在主题默认添加/user/
为前缀。因此,示例代码中convertAndSend
方法直接传入"/topic/user/"+message.getUserId ()+"/sendToUser"
也是点对点发送。topic
其中是默认前缀。
如果想修改convertAndSendToUser
默认前缀可在配置类进行配置,可在WebSocketConfig
类中查看。
3.4、h5代码
<!DOCTYPE html> <html> <head> <title>Hello WebSocket</title> <link href="/webjars/bootstrap/css/bootstrap.min.css" rel="external nofollow" rel="stylesheet"> <link href="/main.css" rel="external nofollow" rel="stylesheet"> <script type="text/javascript" src="https://cdn.bootcss.com/jquery/3.2.1/jquery.min.js"></script> <script src="/webjars/sockjs-client/sockjs.min.js"></script> <script src="/webjars/stomp-websocket/stomp.min.js"></script> <script src="/app.js"></script> </head> <body> <noscript><h2 style="color: #ff0000">Seems your browser doesn't support Javascript! Websocket relies on Javascript being enabled. Please enable Javascript and reload this page!</h2></noscript> <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 connection:</label> <button id="connect" class="btn btn-default" type="submit">Connect</button> <button id="disconnect" class="btn btn-default" 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">What is your name?</label> <input type="text" id="name" class="form-control" placeholder="Your name here..."> <input type="text" id="userId" class="form-control" placeholder="userId"> </div> <button id="send" class="btn btn-default" type="submit">Send</button> <button id="send2" class="btn btn-default" type="submit">Send2</button> <button id="send3" class="btn btn-default" type="submit">SendToUser</button> </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>
app.js
var stompClient = null; var userId = null; function setConnected(connected) { $("#connect").prop("disabled", connected); $("#disconnect").prop("disabled", !connected); if (connected) { $("#conversation").show(); } else { $("#conversation").hide(); } $("#greetings").html(""); } function connect() { var socket = new SockJS('/gs-guide-websocket'); stompClient = Stomp.over(socket); stompClient.connect({}, function (frame) { setConnected(true); console.log('Connected: ' + frame); stompClient.subscribe('/topic/greetings', function (greeting) { showGreeting(JSON.parse(greeting.body).content); }); //对应controller greeting2方法 注意,这儿有两个topic stompClient.subscribe('/topic/topic/greetings', function (greeting) { showGreeting(JSON.parse(greeting.body).content); }); stompClient.subscribe('/topic/user/'+userId+'/sendToUser', function (greeting) { showGreeting(JSON.parse(greeting.body).content); }); stompClient.subscribe('/user/'+userId+'/sendToUser', function (greeting) { showGreeting(JSON.parse(greeting.body).content); }); }); } function disconnect() { if (stompClient !== null) { stompClient.disconnect(); } setConnected(false); console.log("Disconnected"); } function sendName() { stompClient.send("/app/hello", {}, JSON.stringify({'name': $("#name").val()})); // stompClient.send("/hello", {}, JSON.stringify({'name': $("#name").val()})); } function sendName2() { stompClient.send("/app/topic/greetings", {}, JSON.stringify({'name': $("#name").val()})); // stompClient.send("/topic/greetings", {}, JSON.stringify({'name': $("#name").val()})); } function sendName3() { stompClient.send("/app/sendToUser", {}, JSON.stringify({'userId':$("#userId").val(),'name': $("#name").val()})); } function showGreeting(message) { $("#greetings").append("<tr><td>" + message + "</td></tr>"); } function GetQueryString(name) { var reg = new RegExp("(^|&)" + name + "=([^&]*)(&|$)", "i"); var r = window.location.search.substr(1).match(reg); //获取url中"?"符后的字符串并正则匹配 var context = ""; if (r != null) context = r[2]; reg = null; r = null; return context == null || context == "" || context == "undefined" ? "" : context; } $(function () { userId = GetQueryString("userId"); $("form").on('submit', function (e) { e.preventDefault(); }); $( "#connect" ).click(function() { connect(); }); $( "#disconnect" ).click(function() { disconnect(); }); $( "#send" ).click(function() { sendName(); }); $( "#send2" ).click(function() { sendName2(); }); $( "#send3" ).click(function() { sendName3(); }); });
一些无关紧要的类
public class Greeting { private String content; public Greeting() { } public Greeting(String content) { this.content = content; } public String getContent() { return content; } }
public class HelloMessage { private String userId; private String name; // 省去get/set } Name3(); }); });