Spring Boot整合WebSocket

在HTTP协议中,所有的请求都是由客户端发起的,由服务端进行响应,服务端无法向客户端推送消息,但是在一些需要即时通信的应用中,又不可避免地需要服务端向客户端推送消息,传统的解决方案主要有如下几种。

1. 为什么需要WebSocket

  1. 轮询

轮询是最简单的一种解决方案,所谓轮询,就是客户端在固定的时间间隔下不停地向服务端发送请求,查看服务端是否有最新的数据,若服务端有最新的数据,则返回给客户端,若服务端没有,则返回一个空的JSON或者XML文档。轮询对开发人员而言实现方便,但是弊端也很明显:客户端每次都要新建HTTP请求,服务端要处理大量的无效请求,在高并发场景下会严重拖慢服务端的运行效率,同时服务端的资源被极大的浪费了,因此这种方式并不可取。

  1. 长轮询

长轮询是传统轮询的升级版,当聪明的工程师看到轮询所存在的问题后,就开始解决问题,于是有了长轮询。不同于传统轮询,在长轮询中,服务端不是每次都会立即响应客户端的请求,只有在服务端有最新数据的时候才会立即响应客户端的请求,否则服务端会持有这个请求而不返回,直到有新数据时才返回。这种方式可以在一定程度上节省网络资源和服务器资源,但是也存在一些问题,例如:

  • 如果浏览器在服务器响应之前有新数据要发送,就只能创建-一个新的并发请求,或者先尝试断掉当前请求,再创建新的请求。
  • TCP和HTTP规范中都有连接超时一说,所以所谓的长轮询并不能一直持续, 服务端和客户端的连接需要定期的连接和关闭再连接,这又增大了程序员的工作量,当然也有一些技术能够延长每次连接的时间,但毕竟是非主流解决方案。
  1. Applet和Flash

Applet和Flash都已经是明日黄花,不过在这两个技术存在的岁月里,除了可以让我们的HTML页面更加绚丽之外,还可以解决消息推送问题。开发者可以使用Applet和Flash来模拟全双工通信,通过创建一一个只有 1个像素点大小的透明的Applet或者Flash,然后将之内嵌在网页中,再从Applet或者Flash的代码中创建一一个Socket连接进行双向通信。这种连接方式消除了HTTP协议中的诸多限制,当服务器有消息发送到客户端的时候,开发者可以在Applet或者Flash中调用JavaScript函数将数据显示在页面上,当浏览器有数据要发送给服务器时也一样,通过Applet或者Flash来传递。这种方式真正地实现了全双工通信,不过也有问题,说明如下:

  • 浏览器必须能够运行Java或者Flash。
  • 无论是Applet还是Flash 都存在安全问题。
  • 随着HTML 5标准被各浏览器厂商广泛支持,Flash 下架已经被提上日程( Adobe宣布2020年正式停止支持Flash)。

其实,传统的解决方案不止这三种,但是无论哪种解决方案都有自身的缺陷,于是有了WebSocket。

2. WebSocket简介

WebSocket是一种在单个TCP连接.上进行全双工通信的协议,已被W3C定为标准。使用WebSocket可以使得客户端和服务器之间的数据交换变得更加简单,它允许服务端主动向客户端推送数据。在WebSocket协议中,浏览器和服务器只需要完成一次握手,两者之间就可以直接创建持久性的连接,并进行双向数据传输。

WebSocket使用了HTTP/1.1的协议升级特性,一个WebSocket请求首先使用非正常的HTTP请求以特定的模式访问一个URL,这个URL有两种模式,分别是ws和wss,对应HTTP协议中的HTTP和HTTPS,在请求头中有一个Connection:Upgrade字段,表示客户端想要对协议进行升级,另外还有一个Upgrade:websocket字段,表示客户端想要将请求协议升级为WebSocket协议。这两个字段共同告诉服务器要将连接升级为WebSocket这样一种全双工协议,如果服务端同意协议升级,那么在握手完成之后,文本消息或者其他二进制消息就可以同时在两个方向上进行发送,而不需要关闭和重建连接。此时的客户端和服务端关系是对等的,它们可以互相向对方主动发送消息。和传统的解决方案相比,WebSocket主要有如下特点:

  • WebSocket使用时需要先创建连接,这使得WebSocket成为一种有状态的协议,在之后的通信过程中可以省略部分状态信息( 例如身份认证等)。
  • WebSocket连接在端口80 (ws)或者443 (wss)上创建,与HTTP使用的端口相同,这样,基本上所有的防火墙都不会阻止WebSocket连接。
  • WebSocket使用HTTP协议进行握手,因此它可以自然而然地集成到网络浏览器和HTTP服务器中,而不需要额外的成本。
  • 心跳消息(ping 和pong)将被反复的发送,进而保持WebSocket连接一直处于活跃状态。
  • 使用该协议,当消息启动或者到达的时候,服务端和客户端都可以知道。
  • WebSocket连接关闭时将发送一个特殊的关闭消息。
  • WebSocket支持跨域,可以避免Ajax的限制。
  • HTTP规范要求浏览器将并发连接数限制为每个主机名两个连接,但是当我们使用WebSocket的时候,当握手完成之后,该限制就不存在了,因为此时的连接已经不再是HTTP连接了。
  • WebSocket协议支持扩展,用户可以扩展协议,实现部分自定义的子协议。
  • 更好的二进制支持以及更好的压缩效果。

WebSocket既然具有这么多优势,使用场景当然也是非常广泛的,例如:

  • 在线股票网站。
  • 即时聊天。
  • 多人在线游戏。
  • 应用集群通信。
  • 系统性能实时监控。

在了解了这么多WebSocket的基本信息后,接下来看看在Spring Boot中如何使用WebSocket。

3. Spring Boot整合WebSocket

Spring Boot 对WebSocket 提供了非常友好的支持,可以方便开发者在项目中快速集成WebSocket功能,实现单聊或者群聊。

3.1 消息群发

3.1.1 创建项目

首先创建一个Spring Boot项目,添加如下依赖:

<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.1.2</version>
</dependency>
<dependency>
    <groupId>org.webjars</groupId>
    <artifactId>stomp-websocket</artifactId>
    <version>2.3.4</version>
</dependency>
<dependency>
    <groupId>org.webjars</groupId>
    <artifactId>jquery</artifactId>
    <version>3.6.0</version>
</dependency>
<dependency>
    <groupId>org.projectlombok</groupId>
    <artifactId>lombok</artifactId>
    <optional>true</optional>
</dependency>

spring-bot-starter-websocket依赖是Web Socket相关依赖,其他的都是前端库,使用jar包的形式对这些前端库进行统一管理, 使用webjar添加到项目中的前端库,在Spring Boot项目中已经默认添加了静态资源过滤,因此可以直接使用。

3.1.2 配置WebSocket

Spring框架提供了基于WebSocket的STOMP支持,STOMP是一个简单的可互操作的协议,

通常被用于通过中间服务器在客户端之间进行异步消息传递。WebSocket 配置如下:

@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {

    @Override
    public void configureMessageBroker(MessageBrokerRegistry config) {
        // 设置消息代理的前缀,如果消息的前缀为"/topic",就会将消息转发给消息代理(broker)
        // 再由消息代理广播给当前连接的客户端
        config.enableSimpleBroker("/topic");
        // 下面方法可以配置一个或多个前缀,通过这些前缀过滤出需要被注解方法处理的消息。
        // 例如这里表示前缀为"/app"的destination可以通过@MessageMapping注解的方法处理
        // 而其他 destination(例如"/topic""/queue")将被直接交给 broker 处理
        config.setApplicationDestinationPrefixes("/app");
    }

    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        // 定义一个前缀为"/chart"的endpoint,并开启 sockjs 支持。
        // sockjs 可以解决浏览器对WebSocket的兼容性问题,客户端将通过这里配置的URL建立WebSocket连接
        registry.addEndpoint("/chat").withSockJS();
    }
}

代码解释:

  • 自定义类WebSocketConfig 继承自WebSocketMessageBrokerConfigurer 进行WebSocket配置,然后通过@EnableWebSocketMessageBroker注解开启WebSocket消息代理。
  • config.enableSimpleBroker(“/topic”)表示设置消息代理的前缀,即如果消息的前缀是“/topic" ,就会将消息转发给消息代理( broker),再由消息代理将消息广播给当前连接的客户端。
  • config.setApplicationDestinationPrefixes(“/app”)表示配置一个或多个前缀,通过这些前缀过滤出需要被注解方法处理的消息。例如,前缀为“/app”的destination可以通过@MessageMapping注解的方法处理,而其他destination(例如“/topic”“/queue”)将被直接交给broker处理。
  • regitry.addEndpoint(“/chat”).withSockJS()则表示定义一个前缀为“/chat” 的endPoint,并开启sockjs支持,sockjs 可以解决浏览器对WebSocket的兼容性问题,客户端将通过这里配置的URL来建立WebSocket连接。
3.1.3 定义Controller

定义一个Controller用来实现对消息的处理,代码如下:

@Controller
public class GreetingController {
    @MessageMapping("/hello")
    @SendTo("/topic/greetings")
    public Message greeting(Message message) throws Exception {
        return message;
    }
}

自定义的Message对象代码如下:

@Data
public class Message {
    private String name;
    private String content;
}

根据第2步的配置,@MessageMapping(“hello”)注解的方法将用来接收“/apphello"路径发送来的消息,在注解方法中对消息进行处理后,再将消息转发到@SendTo定义的路径上,而@SendTo路径是一个前缀为“/topic” 的路径,因此该消息将被交给消息代理broker,再由broker 进行广播。

3.1.4 构建聊天页面

在resources/static目录下创建chat.html 页面作为聊天页面,代码如下:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>群聊</title>
    <script src="/webjars/jquery/jquery.min.js"></script>
    <script src="/webjars/sockjs-client/sockjs.min.js"></script>
    <script src="/webjars/stomp-websocket/stomp.min.js"></script>
    <script>
    var stompClient = null;

    // 根据是否已连接设置页面元素状态
    function setConnected(connected) {
        $("#connect").prop("disabled", connected);
        $("#disconnect").prop("disabled", !connected);
        if (connected) {
            $("#conversation").show();
            $("#chat").show();
        }
        else {
            $("#conversation").hide();
            $("#chat").hide();
        }
        $("#greetings").html("");
    }

    // 建立一个WebSocket连接
    function connect() {
        // 用户名不能为空
        if (!$("#name").val()) {
            return;
        }
        // 首先使用 SockJS 建立连接
        var socket = new SockJS("/chat");
        // 然后创建一个STOMP实例发起连接请求
        stompClient = Stomp.over(socket);
        // 连接成功回调
        stompClient.connect({}, function (frame) {
            // 进行页面设置
            setConnected(true);
            // 订阅服务端发送回来的消息
            stompClient.subscribe('/topic/greetings', function (greeting) {
                // 将服务端发送回来的消息展示出来
                showGreeting(JSON.parse(greeting.body));
            });
        });
    }

    // 断开WebSocket连接
    function disconnect() {
        if (stompClient !== null) {
            stompClient.disconnect();
        }
        setConnected(false);
    }

    // 发送消息
    function sendName() {
        stompClient.send("/app/hello",
            {},
            JSON.stringify({'name': $("#name").val(),'content':$("#content").val()}));
    }

    // 将服务端发送回来的消息展示出来
    function showGreeting(message) {
        $("#greetings")
            .append("<div>" + message.name+":"+message.content + "</div>");
    }

    // 页面加载后进行初始化动作
    $(function () {
        $( "#connect" ).click(function() { connect(); });
        $( "#disconnect" ).click(function() { disconnect(); });
        $( "#send" ).click(function() { sendName(); });
    });
</script>
</head>
<body>
<div>
    <label for="name">请输入用户名:</label>
    <input type="text" id="name" placeholder="用户名">
</div>
<div>
    <button id="connect" type="button">连接</button>
    <button id="disconnect" type="button" disabled="disabled">断开连接</button>
</div>
<div id="chat" style="display: none;">
    <div>
    <label for="name">请输入聊天内容:</label>
    <input type="text" id="content" placeholder="聊天内容">
    </div>
    <button id="send" type="button">发送</button>
    <div id="greetings">
    <div id="conversation" style="display: none">群聊进行中...</div>
    </div>
</div>
</body>
</html>

代码解释:

  • connect 方法表示建立一个WebSocket连接,在建立WebSocket连接时,用户必须先输入用户名,然后才能建立连接。
  • 第19~26行首先使用SockJS建立连接,然后创建一个STOMP实例发起连接请求,在连接成功的回调方法中,首先调用setConnected(true);方 法进行页面的设置,然后调用STOMP中的subscribe方法订阅服务端发送回来的消息,并将服务端发送来的消息展示出来(使用showGreeting方法)。
  • 调用STOMP中的disconnect 方法可以断开一个WebSocket连接。
3.1.5 测试

接下来启动Spring Boot 项目进行测试,在浏览器中输入htp:/ocalhost:8080/chat.html,显示结果如图所示。

在这里插入图片描述

用户首先输入用户名,然后单击“连接”按钮,结果如图所示。

在这里插入图片描述

然后换一个浏览器,或者使用Chrome浏览器的多用户(注意不是多窗口),重复刚才的步骤,这样就有两个用户连接上了,接下来就可以开始群聊了(当然也可以有更多的用户连接上来),如图所示。

在这里插入图片描述

3.2 消息点对点发送

在3.1小节中介绍的消息发送使用到了@SendTo注解,该注解将方法处理过的消息转发到broker,再由broker进行消息广播。除了@SendTo注解外, Spring 还提供了SimpMessagingTemplate类来让开发者更加灵活地发送消息。

3.2.1 添加依赖

既然是点对点发送,就应该有用户的概念,因此,首先在项目中加入Spring Security的依赖,代码如下:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
</dependency>
3.2.2 配置Spring Security

对Spring Security进行配置,添加两个用户,同时配置所有地址都认证后才能访问,代码如下:

@Configuration
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
    // 指定密码的加密方式
    @SuppressWarnings("deprecation")
    @Bean
    PasswordEncoder passwordEncoder(){
        // 不对密码进行加密
        return NoOpPasswordEncoder.getInstance();
    }

    // 配置用户及其对应的角色
    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.inMemoryAuthentication()
                .withUser("admin").password("123").roles("ADMIN","USER")
                .and()
                .withUser("suohe").password("123").roles("USER");
    }

    // 配置 URL 访问权限
    @Override
    protected  void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests() // 开启 HttpSecurity 配置
                .anyRequest().authenticated() // 用户访问所有地址都必须登录认证后访问
                .and().formLogin().permitAll(); // 开启表单登录
    }
}

这里就是Spring Security的一个常规配置,相关配置含义可以参考我前面文章。

3.2.3 改造WebSocket配置

接下来对WebSocket配置进行改造,代码如下:

@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {

    @Override
    public void configureMessageBroker(MessageBrokerRegistry config) {
        // 设置消息代理的前缀,如果消息的前缀为"/topic"、"/queue",就会将消息转发给消息代理(broker)
        // 再由消息代理广播给当前连接的客户端
        config.enableSimpleBroker("/topic","/queue");
        // 下面方法可以配置一个或多个前缀,通过这些前缀过滤出需要被注解方法处理的消息。
        // 例如这里表示前缀为"/app"的destination可以通过@MessageMapping注解的方法处理
        // 而其他 destination(例如"/topic""/queue")将被直接交给 broker 处理
        config.setApplicationDestinationPrefixes("/app");
    }

    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        // 定义一个前缀为"/chart"的endpoint,并开启 sockjs 支持。
        // sockjs 可以解决浏览器对WebSocket的兼容性问题,客户端将通过这里配置的URL建立WebSocket连接
        registry.addEndpoint("/chat").withSockJS();
    }
}

这里的修改是在config.enableSimpleBroker(“/topic”);方法的基础 上又增加了一个broker 前缀“/queue”,方便对群发消息和点对点消息进行管理。

3.2.4 配置Controller

对WebSocket的Controller 进行改造,代码如下:

@Controller
public class GreetingController {

    @Autowired
    SimpMessagingTemplate messagingTemplate;

    // 处理来自"/app/hello"路径的消息
    @MessageMapping("/hello")
    @SendTo("/topic/greetings")
    public Message greeting(Message message) throws Exception {
        return message;
    }

    // 处理来自"/app/chat"路径的消息
    @MessageMapping("/chat")
    public void chat(Principal principal, Chat chat) {
        // 获取当前登录用户的用户名
        String from = principal.getName();
        // 将用户设置给chat对象的from属性
        chat.setFrom(from);
        // 再将消息发送出去,发送的目标用户就是 chat 对象的to属性值
        messagingTemplate.convertAndSendToUser(chat.getTo(),
                "/queue/chat", chat);
    }
}

代码解释:

  • 群发消息依然使用@SendTo注解来实现,点对点的消息发送则使用SimpMessagingTemplate来实现。

  • 第10~16 行定义了一个新的消息处理接口,@MessageMapping("/chat)注 解表示来自“/app/chat”路径的消息将被chat 方法处理。chat 方法的第一个参数Principal可以用来获取当前登录用户的信息,第二个参数则是客户端发送来的消息。

  • 在chat 方法中,首先获取当前用户的用户名,设置给chat对象的from属性,再将消息发送出去,发送的目标用户就是chat对象的to属性值。

  • 消息发送使用的方法是convertAndSendToUser,该方法内部调用了convertAndSend方法,并对消息路径做了处理,部分源码如下:

    public void convertAndSendToUser(String user, String destination, Object payload) throws MessagingException {
            this.convertAndSendToUser(user, destination, payload, (MessagePostProcessor)null);
        }
     public void convertAndSendToUser(String user, String destination, Object payload) throws MessagingException {
         this.convertAndSendToUser(user, destination, payload, (MessagePostProcessor)null);
     }//这里destinationPrefix 的默认值是“/user”, 也就是说消息的最终发送路径是“/user/用户名/queue/chat"。
    
    
  • chat是一个普通的JavaBean, to 属性表示消息的目标用户,from 表示消息从哪里来,content则是消息的主体内容。

3.2.5 创建在线聊天页面

在resources/static 目录下创chat.html 页面作为在线聊天页面,代码如下:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>单聊</title>
    <script src="/webjars/jquery/jquery.min.js"></script>
    <script src="/webjars/sockjs-client/sockjs.min.js"></script>
    <script src="/webjars/stomp-websocket/stomp.min.js"></script>
    <script>
        var stompClient = null;

        // 建立一个WebSocket连接
        function connect() {
            // 首先使用 SockJS 建立连接
            var socket = new SockJS('/chat');
            // 然后创建一个STOMP实例发起连接请求
            stompClient = Stomp.over(socket);
            // 连接成功回调
            stompClient.connect({}, function (frame) {
                // 订阅服务端发送回来的消息
                stompClient.subscribe('/user/queue/chat', function (chat) {
                    // 将服务端发送回来的消息展示出来
                    showGreeting(JSON.parse(chat.body));
                });
            });
        }

        // 发送消息
        function sendMsg() {
            stompClient.send("/app/chat", {},
                JSON.stringify({'content':$("#content").val(),
                    'to':$("#to").val()}));
        }

        // 将服务端发送回来的消息展示出来
        function showGreeting(message) {
            $("#chatsContent")
                .append("<div>" + message.from+":"+message.content + "</div>");
        }

        // 页面加载后进行初始化动作
        $(function () {
            // 页面加载完毕后自动连接
            connect();
            $( "#send" ).click(function() { sendMsg(); });
        });
    </script>
</head>
<body>
<div id="chat">
    <div id="chatsContent">
    </div>
    <div>
        请输入聊天内容:
        <input type="text" id="content" placeholder="聊天内容">
        目标用户:
        <input type="text" id="to" placeholder="目标用户">
        <button id="send" type="button">发送</button>
    </div>
</div>
</body>
</html>

其中js文件基本与前文的前面js文件内容一致, 差异主要体现在三个地方:

  • 连接成功后,订阅的地址为“/user/queue/chat”, 该地址比服务端配置的地址多了“/user” 前缀,这是因为SimpMessagingTemplate类中自动添加了路径前缀。
  • 聊天消息发送路径为“/app/chat”。
  • 发送的消息内容中有一个to字段,该字段用来描述消息的目标用户。
3.2.6 测试

经过如上几个步骤之后,一个点对点的聊天服务就搭建成功了,接下来直接在浏览器地址栏中输入http://calhost:8080/chat.html,首先会自动跳转到Spring Security的默认登录页面,分别使用一开始配置的两个用户admin/123和suohe/123登录,登录成功后,就可以开始在线聊天了,如图所示。

在这里插入图片描述

Logo

为开发者提供学习成长、分享交流、生态实践、资源工具等服务,帮助开发者快速成长。

更多推荐