Spring Boot 2.x实战之基于WebSocket构建交互式的应用程序

img

WebSocket简介

WebSocekt协议诞生于2008年,在2011年成为国际标准,目前所有的浏览器都已经支持WebSocket协议了。WebSocket协议属于服务器推送技术的一种,它最大的特点就是可以实现服务端和客户端的双向通信。

HTTP VS WebSocket

WebSocket协议和HTTP协议一样,也是在TCP协议层之上的应用层协议,刚接触WebSocket协议的人通常会有个疑问:都有HTTP这个应用层协议了,为啥还要再搞出一个WebSocket协议?

事实上,HTTP是无状态的,并且只能支持单向通信,即都是浏览器向服务端发送HTTP请求,然后得到一个响应。

在实际应用中,还有另外一种场景:服务端有数据更新的时候,希望客户端能够及时感知到。针对这种场景,在WebSocket出现之前,只能采用长轮询的方案(如下图所示)——客户端通过一个AJAX请求和定时器进行轮询查询;这样的方案在服务端数据变化比较频繁的时候,是比较适合使用的,但是当服务端数据变化不频繁的时候,就会面临一个两难的选择:轮询间隔时间短了,会增加服务端的负担,轮询得时间间隔长了,又不能及时感知到服务端数据的变化。

Long polling via AJAX is incredibly inefficient for some applications.

WebSocket协议的出现就是为了解决上面这个两难问题的,可以弥补长轮询方案的不足,使用WebSocket协议的方案的时序图如下图所示,浏览器和服务端之间会建立一个WebSocket连接,基于这个连接客户端可以给服务端发送信息,服务端也可以给客户端发送信息,如果服务端的数据变更不频繁,或者客户端对数据感知的时效性要求不高,就比较适合使用WebSocket这种方案。

WebSockets are more efficient than long polling for server sent updates.

Spring Boot实战

下面我们会使用Spring Boot中的Websocket支持组件,实现一个交互式的Web应用程序,演示下WebSocket协议的工作过程。

服务端

  1. 新建一个Spring Boot工程,在pom中加入下面的依赖;
    <?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 https://maven.apache.org/xsd/maven-4.0.0.xsd">
       <modelVersion>4.0.0</modelVersion>
       <parent>
           <groupId>org.springframework.boot</groupId>
           <artifactId>spring-boot-starter-parent</artifactId>
           <version>2.2.2.RELEASE</version>
           <relativePath/> <!-- lookup parent from repository -->
       </parent>
       <groupId>online.javaadu.springboot</groupId>
       <artifactId>websocketdemo</artifactId>
       <version>0.0.1-SNAPSHOT</version>
       <name>websocketdemo</name>
       <description>Demo project for Spring Boot</description>
    
       <properties>
           <java.version>1.8</java.version>
       </properties>
    
       <dependencies>
           <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>org.springframework.boot</groupId>
               <artifactId>spring-boot-starter-test</artifactId>
               <scope>test</scope>
               <exclusions>
                   <exclusion>
                       <groupId>org.junit.vintage</groupId>
                       <artifactId>junit-vintage-engine</artifactId>
                   </exclusion>
               </exclusions>
           </dependency>
       </dependencies>
    
       <build>
           <plugins>
               <plugin>
                   <groupId>org.springframework.boot</groupId>
                   <artifactId>spring-boot-maven-plugin</artifactId>
               </plugin>
           </plugins>
       </build>
    
    </project>
    

    Spring Boot Maven插件提供了很多方便的特性:

  • 将classpath下的Jar包打包成一个独立的可以运行的Jar包,方便应用的交付和部署;
  • 搜索所有的类,将含有main方法的那个类标记为运行入口类;
  • 内置了一个依赖解析器,可以自动选择跟当前的Spring Boot版本适配的版本号
  1. 创建请求和响应的消息类,分别是:HelloMessage.java和Greeting.java,代码如下所示:

    //HelloMessage.java
    package online.javaadu.springboot.websocketdemo;
    
    public class HelloMessage {
       private String name;
    
       public HelloMessage() {
       }
    
       public HelloMessage(String name) {
           this.name = name;
       }
    
       public String getName() {
           return name;
       }
    
       public void setName(String name) {
           this.name = name;
       }
    }
    
    //Greeting.java
    package online.javaadu.springboot.websocketdemo;
    
    public class Greeting {
       private String content;
    
       public Greeting() {
       }
    
       public Greeting(String content) {
           this.content = content;
       }
    
       public String getContent() {
           return content;
       }
    
    }
    
  2. 创建基于ws协议的消息处理Controller类,在Spring中,我们计划使用STOMP协议来演示ws协议的应用,STOMP协议并不是为websocket设计的,它属于消息队列的一种协议,和AMQP、 JMS是一个层面的东西,只不过由于它的简单性恰巧可以用于定义websocket的消息体格式。
    package online.javaadu.springboot.websocketdemo;
    
    import org.springframework.messaging.handler.annotation.MessageMapping;
    import org.springframework.messaging.handler.annotation.SendTo;
    import org.springframework.stereotype.Controller;
    import org.springframework.web.util.HtmlUtils;
    
    @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注解的作用类似于Spring MVC中的@RequestMapping,表示greeting()方法可以处理发往“/hello”的消息;

  • greeting()方法的参数是HelloMessage类,消息的内容会被转换成HelloMessage对象,这个方法的内部会先休眠1秒——模拟服务端的处理过程,然后给客户端返回一个消息响应对象(Greeting对象);

  • @SendTo注解表示:消息的响应使用的是广播模式,greeting()方法返回的消息,会发送给所有订阅了“/topic/greetings”的订阅者
  1. 接下来增加Spring配置,开启WebSocket和STOMP消息的支持,具体的WebSocketConfig代码如下所示:

    package online.javaadu.springboot.websocketdemo;
    
    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 WebSocketConfig implements WebSocketMessageBrokerConfigurer {
    
       @Override
       public void configureMessageBroker(MessageBrokerRegistry config) {
           config.enableSimpleBroker("/topic");
           config.setApplicationDestinationPrefixes("/app");
       }
    
       @Override
       public void registerStompEndpoints(StompEndpointRegistry registry) {
           registry.addEndpoint("/gs-guide-websocket").withSockJS();
       }
    
    }
    
  • @Configuration注解表示WebSocketConfig是一个Spring配置类;
  • @EnableWebSocketMessageBroker注解用来启动Spring的WebSocket消息处理代理;
  • configureMessageBroker()方法用来配置消息代理的一些特性,通过调用enableSiampleBroker()方法,启动一个基于内存的消息代理,该消息代理会将打招呼的消息广播给那些订阅了“/topic”卡头的客户端;
  • setApplicationDestinationPrefixes()方法用来跟@MessageMapping注解一起起作用,这个注解指定的是服务端接口的前缀,整体下来,greeting()方法对应的请求路径是”/app/hello”。
  • registerStompEndpoints()方法用来注册”/gs-guide-websocket”端点,使得SockJS客户端在不支持WebSocket协议的时候,可以选择其他的传输协议连接服务端,例如:xhr-streaming、xhr-polling等等。

客户端

前面准备好了服务端代码,接下来我们需要开发一个JS客户端,用来跟服务端进行双向通信。

  1. 创建客户端界面,这里非常简单,就一个index.html文件,在这个文件的开头,引入了SOCKJSSTOMP两个JS库,用来跟服务端进行通信。
    <!DOCTYPE html>
    <html>
    <head>
       <title>Hello WebSocket</title>
       <link href="/webjars/bootstrap/css/bootstrap.min.css" rel="stylesheet">
       <link href="/main.css" rel="stylesheet">
       <script src="/webjars/jquery/3.1.0/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...">
                   </div>
                   <button id="send" class="btn btn-default" type="submit">Send</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>
    
  2. 编写客户端逻辑,创建一个JS文件——app.js,代码如下所示,其中最需要关注的是connect()方法和sendName()方法。

  • connect()方法利用SOCKJSstomp.js访问服务端的”/gs-guide-websocket”端点来建立连接,如果建连成功,客户单就会订阅主题“/topic/greetings”——服务端会将返回的消息广播发送到该主题;如果该主题收到了消息,那么客户端会调用showGreeting()方法,在浏览器上渲染出它收到的消息内容;
  • sendName()方法用来接收用户的输入,并利用STOMP客户端将用户输入的内容发送到服务端——”/app/hello”,相应地,该请求会被服务端的GreetingController.greeting()方法处理。

    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() {
       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);
           });
       });
    }
    
    function disconnect() {
       if (stompClient !== null) {
           stompClient.disconnect();
       }
       setConnected(false);
       console.log("Disconnected");
    }
    
    function sendName() {
       stompClient.send("/app/hello", {}, JSON.stringify({'name': $("#name").val()}));
    }
    
    function showGreeting(message) {
       $("#greetings").append("<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(); });
    });
    

    最后,前后端的代码架构如下图所示

    image-20191221225248197

运行演示

  1. 通过WebsocketdemoApplication.main()启动应用程序,启动过程如下:

    image-20191221225316969

  2. 打开两个浏览器页面,都访问localhost:8080,分别点击Connect按钮,将这两个客户端都跟服务端建立连接,如下图所示:image-20191221225541497

  3. 在任意一个客户端中发送消息,延迟一会,可以看到两个客户端页面都看到了服务端返回的消息——这就是前文说的广播模式。021844B0-24C8-42D1-AFB4-5D539A8E82DF

总结

本文首先介绍了WebSocket的定义,然后比较了HTTP协议和WebSocket协议的异同点,并且针对实际应用中的服务端-客户端数据同步的场景进行了不同方案的分析和讨论,分别讨论了长轮询方案和基于WebSocket协议的方案的优劣点;最后利用Spring Boot提供的组件,实现了一个简单的交互应用,用来展示WebSocket协议的实际应用和效果。

参考资料

  1. http://www.ruanyifeng.com/blog/2017/05/websocket.html
  2. https://www.fullstackpython.com/websockets.html
  3. https://spring.io/guides/gs/messaging-stomp-websocket/
  4. 《Spring Boot实战》

Spring Boot 2.x系列

  1. Spring Boot 2.x实战之StateMachine
  2. Spring Boot 2.x实战之定时任务调度

本号专注于后端技术、JVM问题排查和优化、Java面试题、个人成长和自我管理等主题,为读者提供一线开发者的工作和成长经验,期待你能在这里有所收获。
javaadu

阿杜

蚂蚁金服、CRUD研究者

发表评论

电子邮件地址不会被公开。 必填项已用*标注