[WebSocket学习]实现服务端主动推送信息给客户端

866 阅读9分钟

携手创作,共同成长!这是我参与「掘金日新计划 · 8 月更文挑战」的第8天,点击查看活动详情

WebSocket

1. WebSocket介绍

1.1. 什么是websocket

  • WebSocket是HTML5下一种新的协议(websocket协议本质上是一个基于tcp的协议)
  • 它实现了浏览器与服务器全双工通信,能更好的节省服务器资源和带宽并达到实时通讯的目的
  • Websocket是一个持久化的协议

1.2. websocket的原理

websocket约定了一个通信的规范,通过一个握手的机制,客户端和服务器之间能建立一个类似tcp的连接,从而方便它们之间的通信

在websocket出现之前,web交互一般是基于http协议的短连接或者长连接 websocket是一种全新的协议,不属于http无状态协议,协议名为"ws"

image-20220803213231808

1.3. WebSocket与http

1.3.1. 相同点

  1. 都是基于tcp的,都是可靠性传输协议
  2. 都是应用层协议

1.3.2. 不同点

WebSockethttp
双向通信协议,模拟Socket协议,可以双向发送或接受信息HTTP是单向的
需要浏览器和服务器握手进行建立连接的浏览器发起向服务器的连接,服务器预先并不知道这个连接

1.3.3. 联系

WebSocket在建立握手时,数据是通过HTTP传输的。但是建立之后,在真正传输时候是不需要HTTP协议的

1.3.4. 总体过程

  1. 首先,客户端发起http请求,经过3次握手后,建立起TCP连接;http请求里存放WebSocket支持的版本号等信息,如:Upgrade、Connection、WebSocket-Version等;
  2. 然后,服务器收到客户端的握手请求后,同样采用HTTP协议回馈数据;
  3. 最后,客户端收到连接成功的消息后,开始借助于TCP传输信道进行全双工通信。

1.4. websocket解决的问题

1.4.1. http存在的问题

  1. http是一种无状态协议,每当一次会话完成后,服务端都不知道下一次的客户端是谁,需要每次知道对方是谁,才进行相应的响应,因此本身对于实时通讯就是一种极大的障碍
  2. http协议采用一次请求,一次响应,每次请求和响应就携带有大量的header头,对于实时通讯来说,解析请求头也是需要一定的时间,因此,效率也更低下
  3. 最重要的是,需要客户端主动发,服务端被动发,也就是一次请求,一次响应,不能实现主动发送

1.4.2. long poll(长轮询)

对于以上情况就出现了http解决的第一个方法——长轮询

基于http的特性,简单点说,就是客户端发起长轮询,如果服务端的数据没有发生变更,会 hold 住请求,直到服务端的数据发生变化,或者等待一定时间超时才会返回。返回后,客户端又会立即再次发起下一次长轮询 优点是解决了http不能实时更新的弊端,因为这个时间很短,发起请求即处理请求返回响应,实现了“伪·长连接”

长轮询存在的问题:

  1. 推送延迟。服务端数据发生变更后,长轮询结束,立刻返回响应给客户端。
  2. 服务端压力。长轮询的间隔期一般很长,例如 30s、60s,并且服务端 hold 住连接不会消耗太多服务端资源。

1.4.3. Ajax轮询

基于http的特性,简单点说,就是规定每隔一段时间就由客户端发起一次请求,查询有没有新消息,如果有,就返回,如果没有等待相同的时间间隔再次询问

优点是解决了http不能实时更新的弊端,因为这个时间很短,发起请求即处理请求返回响应,把这个过程放大n倍,本质上还是request = response

总的来看,Ajax轮询存在的问题:

  1. 推送延迟。
  2. 服务端压力。配置一般不会发生变化,频繁的轮询会给服务端造成很大的压力。
  3. 推送延迟和服务端压力无法中和。降低轮询的间隔,延迟降低,压力增加;增加轮询的间隔,压力降低,延迟增高

1.4.4. websocket的改进

一旦WebSocket连接建立后,后续数据都以帧序列的形式传输。在客户端断开WebSocket连接或Server端中断连接前,不需要客户端和服务端重新发起连接请求。在海量并发及客户端与服务器交互负载流量大的情况下,极大的节省了网络带宽资源的消耗,有明显的性能优势,且客户端发送和接受消息是在同一个持久连接上发起,实现了“真·长链接”,实时性优势明显。 image-20220803214446561

WebSocket有以下特点:

  1. 是真正的全双工方式,建立连接后客户端与服务器端是完全平等的,可以互相主动请求。而HTTP长连接基于HTTP,是传统的客户端对服务器发起请求的模式。
  2. HTTP长连接中,每次数据交换除了真正的数据部分外,服务器和客户端还要大量交换HTTP header,信息交换效率很低。Websocket协议通过第一个request建立了TCP连接之后,之后交换的数据都不需要发送 HTTP header就能交换数据,这显然和原有的HTTP协议有区别所以它需要对服务器和客户端都进行升级才能实现(主流浏览器都已支持HTML5)

2. 代码实践

2.1. 通过注解构建

由于我们写的是一个SpringBoot项目,所以我们需要引入依赖,并且数据从MySQL数据库中读取,需求就是每隔3秒钟刷新一下前端获取到的数据(这个demo里面是一个在线人数,可以理解为b站实时显示观看视频的人数)

依赖

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
        <version>2.4.0</version>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <version>2.4.0</version>
    </dependency>
    <dependency>
        <groupId>com.alibaba</groupId>
        <artifactId>fastjson</artifactId>
        <version>1.2.73</version>
    </dependency>
    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
        <version>1.18.24</version>
    </dependency>
    <dependency>
        <groupId>mysql</groupId>
        <artifactId>mysql-connector-java</artifactId>
        <version>8.0.15</version>
    </dependency>
    <dependency>
        <groupId>org.mybatis.spring.boot</groupId>
        <artifactId>mybatis-spring-boot-starter</artifactId>
        <version>2.1.3</version>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-jdbc</artifactId>
        <version>2.4.0</version>
    </dependency>
</dependencies>
  1. 引入WebSocket依赖

    <!-- SpringBoot Websocket -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-websocket</artifactId>
        <version>2.4.0</version>
    </dependency>
    
  2. 配置WebSocket

    @Configuration
    public class WebSocketConfig {
    ​
        /**
         * 这个bean的注册,用于扫描带有@ServerEndpoint的注解成为websocket  ,如果你使用外置的tomcat就
         * 不需要该配置文件
         */
        @Bean
        public ServerEndpointExporter serverEndpointExporter() {
            return new ServerEndpointExporter();
        }
    ​
    }
    
  3. 编写业务代码

    由于我自己写的就是通过Mybatis读取数据库中一张表的一个在线数据,比较简单,就不写代码了

  4. WebSocket业务类

    import com.alibaba.fastjson.JSON;
    import com.he.websocket.service.IndexService;
    import lombok.Data;
    import org.slf4j.Logger;
    import org.slf4j.LoggerFactory;
    import org.springframework.scheduling.annotation.EnableScheduling;
    import org.springframework.stereotype.Component;
    ​
    import javax.annotation.PostConstruct;
    import javax.annotation.Resource;
    import javax.websocket.*;
    import javax.websocket.server.ServerEndpoint;
    import java.io.IOException;
    import java.util.concurrent.CopyOnWriteArraySet;
    ​
    ​
    @ServerEndpoint(value = "/webSocket")
    //主要是将目前的类定义成一个websocket服务器端, 注解的值将被用于监听用户连接的终端访问URL地址,客户端可以通过这个URL来连接到WebSocket服务器端
    @Component
    @EnableScheduling// cron定时任务
    @Data
    public class WebSocket {
    ​
        private static final Logger logger = LoggerFactory.getLogger(WebSocket.class);
    ​
        /**
         * 静态变量,用来记录当前在线连接数。应该把它设计成线程安全的。
         */
        private static int onlineCount = 0;
    ​
        /**
         * concurrent包的线程安全Set,用来存放每个客户端对应的MyWebSocket对象。
         */
        private static CopyOnWriteArraySet<WebSocket> webSocketSet = new CopyOnWriteArraySet<>();
    ​
        /**
         * 与某个客户端的连接会话,需要通过它来给客户端发送数据
         */
        private Session session;
    ​
        public static CopyOnWriteArraySet<WebSocket> getWebSocketSet() {
            return webSocketSet;
        }
    ​
        public static void setWebSocketSet(CopyOnWriteArraySet<WebSocket> webSocketSet) {
            WebSocket.webSocketSet = webSocketSet;
        }
    ​
        /**
         * 从数据库查询相关数据信息,可以根据实际业务场景进行修改
         */
        @Resource
        private IndexService indexService;
        private static IndexService indexServiceMapper;
    ​
        @PostConstruct
        public void init() {
            WebSocket.indexServiceMapper = this.indexService;
        }
    ​
        /**
         * 连接建立成功调用的方法
         *
         * @param session 会话
         */
        @OnOpen
        public void onOpen(Session session) throws Exception {
            this.session = session;
            webSocketSet.add(this);
            //查询当前在线人数
            long nowOnline = indexServiceMapper.nowOnline();
            this.sendMessage(JSON.toJSONString(nowOnline));
        }
    ​
        /**
         * 收到客户端消息后调用的方法
         *
         * @param message 客户端发送过来的消息
         */
        @OnMessage
        public void onMessage(String message, Session session) throws IOException {
            logger.info("参数信息:{}", message);
            //群发消息
            for (WebSocket item : webSocketSet) {
                try {
                    item.sendMessage(JSON.toJSONString(message));
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
        }
    ​
        /**
         * 连接关闭调用的方法
         */
        @OnClose
        public void onClose() {
            webSocketSet.remove(this);
            if (session != null) {
                try {
                    session.close();
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
        }
    ​
        /**
         * 发生错误时调用
         *
         * @param session 会话
         * @param error   错误信息
         */
        @OnError
        public void onError(Session session, Throwable error) {
            logger.error("连接异常!");
            error.printStackTrace();
        }
    ​
        /**
         * 发送信息
         *
         * @param message 消息
         */
        public void sendMessage(String message) throws IOException {
            this.session.getBasicRemote().sendText(message);
        }
    ​
        /**
         * 自定义消息推送、可群发、单发
         *
         * @param message 消息
         */
        public static void sendInfo(String message) throws IOException {
            logger.info("信息:{}", message);
            for (WebSocket item : webSocketSet) {
                item.sendMessage(message);
            }
        }
    }
    

    这里有个很重要的点要说一下,我上面的代码是cv别的大佬的,但是我阅读代码的时候很好奇,为什么需要两个IndexService,并且一个还是静态的,我就把static的注释掉,结果发现报错了,为什么呢?

    Spring管理采用单例模式(singleton),而 WebSocket 是多对象的,即每个客户端对应后台的一个 WebSocket 对象,也可以理解成 new 了一个 WebSocket,这样当然是不能获得自动注入的对象了,因为这两者刚好冲突。

    @Autowired 注解注入对象操作是在启动时执行的,而不是在使用时,而 WebSocket 是只有连接使用时才实例化对象,且有多个连接就有多个对象。

    所以我们可以得出结论,这个 Service 根本就没有注入到 WebSocket 当中。

    如何解决?

    1. 就是使用static

      private static OrderService orderService;
      ​
      @Autowired
      public void setOrderService(OrderService service) {
          WebSocketServer.orderService = orderService;
      }
      

      或者上面所写的,再使用@PostConstruct,如果不知道这个注解的,可以自行百度学习一下。

    2. 动态的从 Spring 容器中取出 OrderService

      /**
       * 获取spring容器
       * 当一个类实现了这个接口ApplicationContextAware之后,这个类就可以方便获得ApplicationContext中的所有bean。
       * 换句话说,这个类可以直接获取spring配置文件中所有有引用到的bean对象
       * 前提条件需作为一个普通的bean在spring的配置文件中进行注册 
       */
      public class SpringCtxUtils implements ApplicationContextAware {
      ​
          private static ApplicationContext applicationContext;
      ​
          @Override
          public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
              SpringCtxUtils.applicationContext = applicationContext;
          }
      ​
      ​
          public static <T> T getBean(Class<T> type) {
              try {
                  return applicationContext.getBean(type);
              } catch (NoUniqueBeanDefinitionException e) {   
                  //出现多个,选第一个
                  String beanName = applicationContext.getBeanNamesForType(type)[0];
                  return applicationContext.getBean(beanName, type);
              }
          }
      ​
          public static <T> T getBean(String beanName, Class<T> type) {
              return applicationContext.getBean(beanName, type);
          }
      }
      

      在 WebSocketServer 中调用:

      private OrderService orderService = SpringCtxUtils.getBean(OrderService.class);
      

    几个注解:

    • @ServerEndpoint(value = "/webSocket"):目前的类定义成一个websocket服务器端, 注解的值将被用于监听用户连接的终端访问URL地址,客户端可以通过这个URL来连接到WebSocket服务器端
    • @OnOpen:连接建立成功后第一次调用的方法
    • @OnMessage:前端发送信息后,客户端收到消息后调用的方法
    • @OnClose:前端的网页关闭后调用的方法
    • @OnError:发生错误时调用
  5. 要定时刷新,那定时函数就需要了

    @Slf4j
    @Component
    public class IndexScheduled {
    ​
        @Autowired
        private IndexMapper indexMapper;
    ​
        /**
         * 每3秒执行一次
         */
        @Scheduled(cron = "0/3 * * * * ? ") //我这里暂时不需要运行这条定时任务,所以将注解注释了,朋友们运行时记得放开注释啊
        public void nowOnline() {
            System.err.println("*********   首页定时任务执行   **************");
    ​
            CopyOnWriteArraySet<WebSocket> webSocketSet = WebSocket.getWebSocketSet();
            long nowOnline = indexMapper.getPV();
            webSocketSet.forEach(c -> {
                try {
                    c.sendMessage(JSON.toJSONString(nowOnline));
                } catch (IOException e) {
                    e.printStackTrace();
                }
            });
    ​
            System.err.println("/n 首页定时任务完成.......");
        }
    ​
    }
    
  6. 主方法

    @SpringBootApplication
    @EnableScheduling// cron定时任务
    public class WebSocketMainApplication {
        public static void main(String[] args) {
            SpringApplication.run(WebSocketMainApplication.class, args);
        }
    }
    

    这里主要是想要注意一下@EnableScheduling开启定时检测

  7. 前端页面

    基本阅览器自带websocket了,所以直接搞

    <!DOCTYPE html>
    <html lang="en" xmlns:th="http://www.w3.org/1999/xhtml">
    <head>
        <meta charset="UTF-8">
        <title>Title</title>
        <script src="https://cdn.bootcdn.net/ajax/libs/jquery/3.6.0/jquery.js"></script>
    </head>
    <body class="gray-bg"><div class="online">
        <span class="online">测试在线人数:<span id="online"></span>&nbsp人</span>
    </div>
    ​
    ​
    <script th:inline="javascript">
    ​
        let websocket = null;
        let host = document.location.host;
        //判断当前浏览器是否支持WebSocket
        if ('WebSocket' in window) {
            //连接WebSocket节点
            websocket = new WebSocket("ws://" + host + "/webSocket");
        } else {
            alert('浏览器不支持webSocket');
        }
    ​
        //连接发生错误的回调方法
        websocket.onerror = function () {
            setMessageInnerHTML("error");
        };
    ​
        //连接成功建立的回调方法
        websocket.onopen = function (event) {
            setMessageInnerHTML("open");
        };
    ​
        //接收到消息的回调方法
        websocket.onmessage = function (event) {
            let data = event.data;
            console.log("后端传递的数据:" + data);
            //将后端传递的数据渲染至页面
            $("#online").html(data);
        };
    ​
        //连接关闭的回调方法
        websocket.onclose = function () {
            setMessageInnerHTML("close");
        };
    ​
        //监听窗口关闭事件,当窗口关闭时,主动去关闭websocket连接,防止连接还没断开就关闭窗口,server端会抛异常。
        window.onbeforeunload = function () {
            websocket.close();
        };
    ​
        //将消息显示在网页上
        function setMessageInnerHTML(innerHTML) {
    ​
        };
    ​
        //关闭连接
        function closeWebSocket() {
            websocket.close();
        };
    ​
        //发送消息
        function send() {
            let message = document.getElementById('text').value;
            websocket.send(message);
        };
    ​
    </script></body>