WebSocket是什么?

  • WebSocket是基于HTTP的全双工通讯协议。在WebSocket协议中,浏览器和服务器只需要完成一次握手,就可以创建持久性连接,并进行双向数据传输。
  • WebSocket使用了HTTP/1.1的协议升级特性,一个WebSocket请求首先使用非正常的HTTP请求以特定的模式访问一个URL,这个URL有2种模式:ws、wss,对应HTTP、HTTPS;在请求头中Connection:Upgrade字段表示客户端想要对协议进行升级、Upgrade:WebSocket字段表示客户端想要将请求协议升级为WebSocket协议。
  • 特点
    • WebSocket使用时需要先创建连接,使得WebSocket成为一种有状态的协议。
    • WebSocket连接在端口80(ws)或443(wss)上创建,与HTTP使用的端口相同,基本上所有的***都不会阻止WebSocket连接。
    • WebSocket使用HTTP协议进行握手,因此它可以自然地集成到浏览器和HTTP服务器中,而不需要额外的成本。
    • 心跳消息(ping、pong)将被反复发送,以保持WebSocket连接一直处于活跃状态。
    • 使用该协议,当消息启动或到达的时候,服务端和客户端都可以知道。
    • WebSocket连接关闭时将发送一个特殊的关闭消息。
    • WebSocket支持跨域。
    • HTTP规范要求浏览器将并发连接数限制为每个主机名两个连接,但使用WebSocket,当握手完成后,该限制就不存在了,因为此时的连接不再是HTTP连接了。
    • WebSocket协议支持扩展。
      更好的二进制支持以及更好的压缩效果。
  • 其他类似技术
    • 服务器发送事件:SSE(Server-Sent Event)提供的EventSource API可以实现服务器向客户端广播或推送信息,而客户端无法向服务器发送数据,且只支持文本数据。
    • SPDY:扩充了HTTP,通过压缩HTTP首标和多路复用等手段改进HTTP请求性能。
    • Web实时通信:WebRTC(Web Real-Time Communication)是Web的点对点技术。浏览器可以直接通信,而不需要通过服务器传输所有的数据。WebRTC包含可以让浏览器相互之间实时通信的API。

实现方式

服务器端

使用Java提供的@ServerEndpoint注解实现
  • 创建一个服务端WebSocket处理类,并注解为@ServerEndpoint(value = "/myWebSocket")、@Component。

    • @ServerEndpoint(value = "/myWebSocket")让该类作为一个服务端Socket,监听/myWebSocket路径的访问。

    • @Component把该类交给Spring管理。

      @ServerEndpoint(value = "/chat")
      @Component
      public class MyWebSocket {
      //用来存放每个客户端对应的MyWebSocket对象
      private static CopyOnWriteArraySet<MyWebSocket> user = new CopyOnWriteArraySet<MyWebSocket>();
      //与某个客户端的连接会话,需要通过它来给客户端发送数据
      private Session session;
      
      @OnMessage
      public void onMessage(String message, Session session) throws Exception {
        Message m = JSON.parseObject(message, Message.class);
      
        boolean flag = false;// true:设置昵称;false:聊天内容
        if (m.getType().equals("nickName")) {
            UserContainer.addUserNickName(session.getId(), m.getMessage());
            flag = true;
        }
        Message msg = new Message();
        if (flag) {
            msg.setType("welcome");
            msg.setMessage(UserContainer.getUserNickName(session.getId()) + " 进入聊天室");
            Message count = new Message();
            count.setType("count");
            count.setMessage(user.size() + "");
            for (MyWebSocket myWebSocket : user) {
                myWebSocket.session.getBasicRemote().sendText(JSON.toJSONString(count));
            }
        } else {
            msg.setType("news");
            msg.setNickName(UserContainer.getUserNickName(session.getId()));
            msg.setMessage(m.getMessage());
        }
      
        //群发消息
        for (MyWebSocket myWebSocket : user) {
            myWebSocket.session.getBasicRemote().sendText(JSON.toJSONString(msg));
        }
      }
      
      @OnOpen
      public void onOpen(Session session) {
        this.session = session;
        user.add(this);
      }
      
      @OnClose
      public void onClose(Session session) throws Exception{
        user.remove(this);
        Message msg = new Message();
        msg.setType("welcome");
        msg.setMessage(UserContainer.getUserNickName(session.getId()) + " 退出聊天室");
        UserContainer.removeUserNickName(session.getId());
        Message count = new Message();
        count.setType("count");
        count.setMessage(user.size() + "");
        for (MyWebSocket myWebSocket : user) {
            myWebSocket.session.getBasicRemote().sendText(JSON.toJSONString(count));
            myWebSocket.session.getBasicRemote().sendText(JSON.toJSONString(msg));
        }
      }
      
      @OnError
      public void onError(Session session, Throwable error) {
        error.printStackTrace();
      }
      }
  • 使用SpringBoot内置Tomcat,需要添加如下类

    @Configuration
    public class WebConfig {
      /**
       * 支持websocket
       * 如果不使用内置tomcat,则无需配置
       * @return
       */
      @Bean
      public ServerEndpointExporter createServerEndExporter(){
          return new ServerEndpointExporter();
      }
    }
使用STOMP消息实现(Java EE开发的颠覆者:Spring Boot实战)
  • 创建Spring项目,引入依赖(Spring Boot版本:2.1.3.RELEASE)

    <dependency>
          <groupId>org.springframework.boot</groupId>
          <artifactId>spring-boot-starter-thymeleaf</artifactId>
    </dependency>
    dependency>
          <groupId>org.springframework.boot</groupId>
          <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
          <groupId>org.springframework.boot</groupId>
          <artifactId>spring-boot-starter-websocket</artifactId>
    </dependency>
  • 广播式:服务端有消息时,会将消息发送给所有连接了当前endpoint的浏览器。

  • 配置WebSocket

    @Configuration
    @EnableWebSocketMessageBroker // 开启使用STOMP协议来传输基于代理(message broker)的消息,这时控制器支持使用@MessageMapping
    public class MyWebSocketConfig implements WebSocketMessageBrokerConfigurer{
      @Override
      public void registerStompEndpoints(StompEndpointRegistry registry) {
          registry.addEndpoint("/chat").withSockJS();// 注册一个STOMP的endpoint,并指定使用SockJS协议。
      }
    
      @Override
      public void configureMessageBroker(MessageBrokerRegistry registry) {
          registry.enableSimpleBroker("/topic");// 配置消息代理。
      }
    }
  • 封装消息

    /**
    * 浏览器发送给服务端的消息
    */
    public class ClientMessage {
      private String name;
    
      public String getName() {
          return name;
      }
    
      public void setName(String name) {
          this.name = name;
      }
    }
    /**
    * 服务端发送给浏览器的消息
    */
    public class ServerMessage {
      private String response;
    
      public ServerMessage(String response){
          this.response=response;
      }
    
      public String getResponse() {
          return response;
      }
    }
  • Controller

    @Controller
    public class WsController {
      @MessageMapping("/welcome") // 当浏览器向服务端发送请求时,将"/welcome"映射到这个方法,类似于@RequestMapping
      @SendTo("/topic/getResponse") // 当服务端有消息时,会对订阅了该路径的浏览器发送消息
      public ServerMessage say(ClientMessage message) throws Exception{
          Thread.sleep(1000);
          return new ServerMessage("Welcome,"+message.getName()+"!");
      }
    }
  • 点对点式(Spring Boot实战 205页)

客户端

  • 创建WebSocket对象
    ```java
    <!--

1.实例化一个WebSocket对象,指定服务器URL。
2.ws:非加密方式、wss:加密方式
3.可选参数protocols表示支持的子协议,可选值为:XMPP(可扩展消息处理现场协议)、SOAP(简单对象访问协议)、STOMP(简单可互操作的协议)或自定义协议。可以以数组的形式告诉服务器客户端所支持的自协议,服务器将从中选择一个。
-->
var websocket = new WebSocket("ws://localhost:8080/chat"[,["myProtocol1","myProtocol2"]]);

- WebSocket对象的事件
```java
<!--
WebSocket API是纯事件驱动的。WebSocket编程遵循异步编程模式。WebSocket对象的事件如下:
open:一旦服务器响应了WebSocket连接请求,open事件触发并建立一个连接。
message:在接收到消息时触发。可以是文本、二进制数据;如果是二进制数据,在接收前需要设置类型。
error:响应故障时触发。
close:WebSocket连接关闭时触发。
-->
websocket.onopen = function (event) {
    console.log("连接成功");
}
var binaryType='blob';
<!--
var binaryType='arraybuffer';
-->
websocket.onmessage = function (event) {
    console.log("收到消息");
    var blob=new Blob(event.data);
    <!--
    var arr=new Unit8Array(event.data);
    -->
}
  • WebSocket对象的方法

    <!-- 在连接建立后向服务器发送消息 -->
    var blob=new Blob("hello");
    websocket.send(blob);
    var arr=new Unit8Array([1,2,3]);
    websocket.send(a.buffer);
    <!-- 客户端主动关闭连接 -->
    websocket.close();
  • WebSocket对象的属性

    readyState:连接状态;0:连接中、1:连接建立、2:关闭中、3:连接关闭。
    bufferedAmount:发往服务器的缓冲数据量。
    protocol:当前使用的子协议。
  • 检查WebSocket支持

    if ('WebSocket' in window) {
      console.log('支持');
    }else{
      console.log('当前浏览器不支持');
    }
  • 完整访问页面

    <html lang="en" xmlns:th="http://www.thymeleaf.org">
    <head>
      <meta charset="UTF-8">
      <link rel="stylesheet" href="https://cdn.jsdelivr.net/semantic-ui/2.2.4/semantic.min.css">
      <link rel="stylesheet" href="/css/me.css">
      <title>Spring Boot+WebSocket+广播式</title>
    </head>
    <body onload="disconnect();">
    <noscript><h2 style="color:#ff0000">貌似当前浏览器不支持WebSocket</h2></noscript>
    <div class="ui container">
      <div class="ui basic segment center aligned">
          <h3>在线人数:<span id="count"></span></h3>
      </div>
      <div class="ui segment center aligned" style="height: 500px;overflow-x: hidden;overflow-y: scroll;" id="response">
      </div>
      <div id="conversationDiv" class="ui segment">
          <label class="ui big label">输入你的昵称</label>
          <div class="ui input">
              <input type="text" id="name">
          </div>
          <button class="ui orange button" id="sendName" onclick="sendName();">设定</button>
      </div>
    
      <div class="ui basic segment" id="MyMessage">
          <div class="ui segment">
              <textarea name="" id="message" cols="30" rows="10" style="height:200px;width:100%;"></textarea><br>
          </div>
          <button class="ui orange button" onclick="sendMessage();">发送</button>
      </div>
      <div class="ui basic segment right aligned">
          <button class="ui teal button" id="connect" onclick="connect();">上线</button>
          <button class="ui red button" id="disconnect" disabled="disabled" onclick="disconnect();">下线</button>
      </div>
    </div>
    <script src="https://cdn.bootcss.com/jquery/3.4.1/jquery.min.js"></script>
    <script src="https://cdn.bootcss.com/sockjs-client/1.4.0/sockjs.min.js"></script>
    <script src="https://cdn.bootcss.com/stomp.js/2.3.3/stomp.min.js"></script>
    <script src="https://cdn.bootcss.com/semantic-ui/2.4.1/semantic.min.js"></script>
    <script th:inline="javascript" type="text/javascript">
      var websocket = null;
    
      //关闭WebSocket连接
      function disconnect() {
          if (websocket != null) {
              websocket.close();
          }
          document.getElementById('connect').disabled = false;
          document.getElementById('disconnect').disabled = true;
          document.getElementById('conversationDiv').style.display = 'none';
          document.getElementById('MyMessage').style.display = 'none';
      }
    
      function connect() {
          //判断当前浏览器是否支持WebSocket
          if ('WebSocket' in window) {
              websocket = new WebSocket("ws://localhost:8080/chat");
    
              //接收到消息的回调方法
              websocket.onmessage = function (event) {
                  var data = event.data;
                  var message = JSON.parse(data);
                  if (message.type == "welcome") {
                      document.getElementById('response').innerHTML += '<div style="opacity: 0.5;clear:both;">' + message.message + '</div>' + '<br/>';
                  }
                  if (message.type == "news") {
                      nickName = $('#name').val();
                      if (nickName == message.nickName) {
                          document.getElementById('response').innerHTML += '<span style="opacity: 0.5;float:right;clear:both;">' + message.nickName + ' ' + '</span>' +
                              '<div class="ui teal segment p-message" style="float:right;">' + message.message + '</div><br>';
                      } else {
                          document.getElementById('response').innerHTML += '<span style="opacity: 0.5;float:left;clear:both;">' + message.nickName + ' ' + '</span>' +
                              '<div class="ui teal segment p-message" style="float:left;">' + message.message + '</div><br>';
                      }
                  }
                  if (message.type == "count") {
                      $('#count').text(message.message);
                  }
                  $('#response').scrollTop($('#response').prop('scrollHeight'));
              }
    
              //连接成功建立的回调方法
              websocket.onopen = function () {
                  console.log("连接成功");
                  document.getElementById('connect').disabled = true;
                  document.getElementById('disconnect').disabled = false;
                  document.getElementById('conversationDiv').style.display = '';
              }
    
              //连接关闭的回调方法
              websocket.onclose = function () {
                  console.log("连接关闭");
                  document.getElementById('disconnect').disabled = true;
              }
    
              //连接发生错误的回调方法
              websocket.onerror = function () {
                  console.log("连接错误");
              }
    
              //监听窗口关闭事件,当窗口关闭时,主动去关闭websocket连接,防止连接还没断开就关闭窗口,server端会抛异常。
              window.onbeforeunload = function () {
                  disconnect();
              }
          }
          else {
              alert('当前浏览器不支持websocket');
          }
      }
    
      //设置昵称
      function sendName() {
          var message = document.getElementById('name').value;
          websocket.send(JSON.stringify({'type': 'nickName', 'message': message}));
          document.getElementById('MyMessage').style.display = '';
          document.getElementById('conversationDiv').style.display = 'none';
      }
    
      //发送聊天内容
      function sendMessage() {
          var message = document.getElementById('message').value;
          websocket.send(JSON.stringify({'type': 'news', 'message': message}));
          document.getElementById('message').value = '';
      }
    </script>
    </body>
    </html>
  • SockJS

    <html lang="en" xmlns:th="http://www.thymeleaf.org">
    <head>
      <meta charset="UTF-8">
      <title>Spring Boot+WebSocket+广播式</title>
    </head>
    <body onload="disconnect()">
    <noscript><h2 style="color:#ff0000">貌似当前浏览器不支持WebSocket</h2></noscript>
    <div>
      <div>
          <button id="connect" onclick="connect();">连接</button>
          <button id="disconnect" disabled="disabled" onclick="disconnect();">断开连接</button>
      </div>
      <div id="conversationDiv">
          <label>输入你的昵称</label><input type="text" id="name">
          <button id="sendName" onclick="sendName();">发送</button>
          <p id="response"></p>
      </div>
    </div>
    <script src="https://cdn.bootcss.com/jquery/3.4.1/jquery.min.js"></script>
    <script src="https://cdn.bootcss.com/sockjs-client/1.4.0/sockjs.min.js"></script>
    <script src="https://cdn.bootcss.com/stomp.js/2.3.3/stomp.min.js"></script>
    <script th:inline="javascript" type="text/javascript">
      var stompClient=null;
      function setConnected(connected){
          document.getElementById('connect').disabled=connected;
          document.getElementById('disconnect').disabled=!connected;
          document.getElementById('conversationDiv').style.visibility=connected?'visible':'hidden';
          $('#response').html();
      }
    
      function connect(){
          var socket=new SockJS("/chat");// 连接SockJS的endpoint名为"/chat"
          stompClient=Stomp.over(socket);// 使用STOMP子协议
          stompClient.connect({},function(frame){ // 连接WebSocket服务端
              setConnected(true);
              stompClient.subscribe('/topic/getResponse',function(response){// 订阅/topic/getResponse发送的消息。
                  showResponse(JSON.parse(response.body).response);
              });
          });
      }
    
      function disconnect(){
          if(stompClient!=null){
              stompClient.disconnect();
          }
          setConnected(false);
      }
    
      function sendName(){
          var name=$('#name').val();
          stompClient.send('/welcome',{},JSON.stringify({'name':name}));// 向/welcome发送消息
      }
    
      function showResponse(message){
          var response=$('#response');
          response.html(message);
      }
    </script>
    </body>
    </html>