基於java語言的websocket技術及實現

2020-10-11 11:00:32

1.webSocket簡介

WebSocket 是 HTML5 開始提供的一種在單個 TCP 連線上進行全雙工通訊的協定。

WebSocket 使得使用者端和伺服器之間的資料交換變得更加簡單,允許伺服器端主動向使用者端推播資料。在 WebSocket API 中,瀏覽器和伺服器只需要完成一次握手,兩者之間就直接可以建立永續性的連線,並進行雙向資料傳輸。

在 WebSocket API 中,瀏覽器和伺服器只需要做一個握手的動作,然後,瀏覽器和伺服器之間就形成了一條快速通道。兩者之間就直接可以資料互相傳送。

簡單的說,就是一次握手,持續通訊。

2.使用場景

採用java實現的websocket使用者端與伺服器端除聊天室實現外,因其互動只需建立一次連結關係,極大的節省了記憶體與頻寬,所以也常用於實時資料傳輸與獲取。
某些業務需要在較短的時間間隔下,不斷的去獲取或傳輸資料,便可以考慮採用webSocket。
如:實時公交位置的獲取,實時人員位置的獲取,暴雨天氣中水庫的水位,某裝置的實時溫度等等。

3.工程簡介

本專案共分兩個模組

  1. websocket伺服器端,採用java語言實現,繼承springboot框架,使用maven依賴
  2. websocket測試用使用者端,採用java語言實現,使用maven依賴

不必過分糾結專案中依賴所使用的版本,根據各自專案所需,切換合適的版本即可

3.伺服器端webSocketServer

首先我們來貼上關鍵程式碼,然後再進行解讀
以下程式碼共三部分:

  • 所需pom依賴
  • 開啟webSocket所需要的設定支援
  • webSocket伺服器端

接下來我們依次來看:

所需pom依賴

 <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-websocket</artifactId>
 </dependency>
 <dependency>
      <groupId>com.alibaba</groupId>
      <artifactId>fastjson</artifactId>
      <version>1.2.47</version>
 </dependency>

springboot整合了對webSocket的操作,此處我們使用的版本為2.3.3,同時涉及到資料通訊,難免用到json解析,所以此處我們新增alibaba的fastjson依賴,用作解析json資料

開啟webSocket所需要的設定支援

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.socket.server.standard.ServerEndpointExporter;


/**
 * @author zhaiLiMing
 * @version 2020-9-16
 * webSocket設定開啟websocket支援
 */
@Configuration
public class WebSocketConfig {

    @Bean
    public ServerEndpointExporter serverEndpointExporter(){
        return new ServerEndpointExporter();
    }
}

webSocket伺服器端

伺服器端基於5個註解實現,分別是:

  1. @ServerEndpoint("/url") 該註解用於註釋伺服器端的類,被該註解註釋的類,將會被標註為webSocket的服務類,引數value為存取的路徑
  2. @OnOpen 被該註解註釋的方法,將在使用者端與伺服器端建立連線時執行
  3. @OnMessage 被該註解註釋的方法,將在伺服器端收到訊息時執行
  4. @OnClose 被該註解註釋的方法,將在連結關閉時執行
  5. @OnError 被該註解註釋的方法,將在連結髮生錯誤時執行
package com.modules.web;

import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONArray;
import com.modules.service.StudentServiceImpl;
import com.modules.utils.DataTranslate;
import org.springframework.stereotype.Component;

import javax.websocket.*;
import javax.websocket.server.ServerEndpoint;
import java.io.IOException;
import java.util.List;
import java.util.Map;

/**
 * @author zhaiLiMing
 * @version 2020-9-16
 * webSocket伺服器端
 * @ServerEndpoint 將本類註解為webSocket伺服器端,其value為使用者端存取URI
 * @Compoent 使得使用者端在spring容器啟動時候就被載入
 */
@ServerEndpoint("/endpoint")
@Component
public class WebSocketServer {

    public WebSocketServer()
    {
        System.out.println("EchoSocket:start");
    }

    private Session session;

    /**
     * 範例化service層,此處不能使用autowired等註解自動注入,
     * 因spring的bean是預設單例模式
     */
    private static StudentServiceImpl studentService=new StudentServiceImpl();

    /**
     * 開啟連線時執行
     */
    @OnOpen
    public void onOpen(Session session) {
        this.session = session;
        System.out.println("連線已經開啟");
    }

    /**
     * 收到訊息時執行
     */
    @OnMessage
    public void onMessage(String message, Session session) {
        System.out.println("從使用者端收到的訊息:" + message);
   	    sendMessage(JSON.toJSONString(JSONArray.toJSONString(「返回給使用者端的訊息」)));
    }

    /**
     * 關閉連線時執行
     */
    @OnClose
    public void onClose(Session session) {
        System.out.println("連線已經關閉");
    }

    /**
     * 連線發生錯誤時執行
     */
    @OnError
    public void onError(Throwable error, Session session) {
        System.out.println("連線發生錯誤");
    }

    /**
      websocket session傳送文字訊息有兩個方法:getAsyncRemote()和getBasicRemote()
      getAsyncRemote()和getBasicRemote()是非同步與同步的區別,
      大部分情況下,推薦使用getAsyncRemote()。
    */
    public void sendMessage(String message) throws IOException {
        this.session.getAsyncRemote().sendText(message);
    }
}

需要注意的問題及常見異常

如果需要在webSocket服務類中呼叫service層,使用註解(如@Autowired等)自動注入,會丟擲空指標異常
此處原因是,websocket每接收到一個使用者端的握手請求,就會開啟一個新的執行緒來處理該使用者端,然而,spring的bean預設是singleton單例模式,所以就會導致此類問題。
針對其的解決方法,可以採用傳統的new方式去建立javaBean,或者修改spring的bean為prototype。

4.使用者端webSocketClient

所需pom依賴

  <dependency>
      <groupId>org.java-websocket</groupId>
      <artifactId>Java-Websocket</artifactId>
      <version>1.3.8</version>
  </dependency>
  
  <dependency>
      <groupId>com.alibaba</groupId>
      <artifactId>fastjson</artifactId>
      <version>1.2.47</version>
  </dependency>

針對所使用的依賴不再過多贅述

webSocket使用者端

webSocket使用者端的實現基於webSocketClient類實現,範例化webSocketClient並重寫以下四個方法:

  1. onOpen 與伺服器端建立連線時執行
  2. onMessage 收到伺服器端訊息時執行
  3. onClose 連線關閉時執行
  4. onError 發生錯誤時執行
import com.alibaba.fastjson.JSON;
import modules.entry.student.Student;
import modules.service.StudentService;
import modules.service.StudentServiceImpl;
import modules.utils.JsonFormat;
import org.java_websocket.WebSocket;
import org.java_websocket.client.WebSocketClient;
import org.java_websocket.handshake.ServerHandshake;

import java.net.URI;
import java.net.URISyntaxException;
import java.util.List;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

/**
 * @author zhaiLiMing
 * @version 2020-9-21
 * webSocketClient使用者端
 */
public class WebsocketClient {

    //建立webSocketClient使用者端
    private static WebSocketClient client;

    //範例化service層
    private static StudentService studentService=new StudentServiceImpl();

    //建立一個5個執行緒的執行緒池,用來接收onMessage
    private static ExecutorService fixedThreadPool = Executors.newFixedThreadPool(5);

    public static void main(String[] args) throws URISyntaxException, InterruptedException {



        //範例化webSocketClient,以ws或wss形式傳送請求,重寫4個方法
        client=new WebSocketClient(new URI("ws://localhost:8080/endpoint")) {

            //建立連線時執行
            @Override
            public void onOpen(ServerHandshake serverHandshake) {
                System.out.println("建立連線");
            }

            //接收到訊息時執行
            @Override
            public void onMessage(String s) {

                Runnable runnable = new Runnable() {
                    @Override
                    public void run() {
                        System.out.println(JSON.parseObject(s).getString("code"));
                        //業務處理,接收返回的訊息,解析JSON字串,存入資料庫
                        if (JSON.parseObject(s).getString("code").equals("1100")){
                            String data=JSON.parseObject(s).getString("data");
                             //JsonFormat是自己寫的工具類
                            List<Student> studentList= JsonFormat.jsonFormatStudent(data);
                            //呼叫service接收結果
                            int result=studentService.insert(studentList);
                            System.out.println("插入成功:"+result+"條資料!");
                        }else{
                            System.err.println("伺服器出錯!");
                        }
                    }
                };

                fixedThreadPool.submit(runnable);
            }

            //連線關閉時執行
            @Override
            public void onClose(int i, String s, boolean b) {
                System.out.println("連結關閉");
            }

            //連線出錯時執行
            @Override
            public void onError(Exception e) {
                System.out.println("連結出錯");
            }
        };
        client.connect();

        //檢測連線狀態,重複嘗試連線
        while (!client.getReadyState().equals(WebSocket.READYSTATE.OPEN)) {
            System.out.println("before reconnect statte:"+client.getReadyState());
            Thread.sleep(2000);
            if(client.getReadyState().equals(WebSocket.READYSTATE.CLOSING) || client.getReadyState().equals(WebSocket.READYSTATE.CLOSED)){
                client.reconnect();
            }
            System.out.println("After reconnect statte:"+client.getReadyState());
        }

        //傳送資料
        client.send("getStudent");

    }
}

需要注意的問題及常見異常

範例化webSocketClient時,有一個引數URI,URI中傳入地址,有兩種請求方式

  1. ws請求:其類似於http請求,非安全
  2. wss請求:其類似於https請求,安全

請求方式不同時,可能會丟擲異常,兩種請求方式具體區別在此不做解釋,可查閱別的文章

5.繼續瞭解webSocket

在java中webSocket的5種狀態

參閱過webSocket API檔案的部分朋友或許會疑惑,為什麼是5種呢?API檔案寫的4種呀!
實際上我們仔細看就會發現,java中(基於其他語言的websocket沒有研究,所以只說java),webSocket的原始碼裡定義了內部列舉類READYSTATE,其中包含以下5種狀態

  1. NOT_YET_CONNECTED 尚未連結
  2. CONNECTING 連結中
  3. OPEN 連結已開啟
  4. CLOSING 連結正在關閉
  5. CLOSED 連結已經關閉
public static enum READYSTATE {
        NOT_YET_CONNECTED,
        CONNECTING,
        OPEN,
        CLOSING,
        CLOSED;

        private READYSTATE() {
        }
    }

不難看出,5種狀態表明著webSocket的整個生命週期,這對於我們在使用webSocket時解決一些問題是非常關鍵的

connect()與reconnect()

當webSocketClient初始化完畢之後,webSocketClient提供了兩種連結方式,封裝為兩個方法,分別是

  1. connect()
  2. reconnect()

那麼這兩者有什麼區別呢?這便設計到了上一個問題,webSocket的5種狀態。
起始時,webSocket狀態為 NOT_YET_CONNECTED ,尚未連結,而當一次連結關閉之後,其狀態為 CLOSED 。這兩者雖然都是沒有連結的狀態,但本質上是有區別的。
NOT_YET_CONNECTED 表示該webSocket範例還未開始連結,並處於等待連結的狀態,形象的講,就是初生的嬰兒;
而 CLOSED 則表示連結關閉,雖然也不是連結狀態,但其表示已經完成了一次生命週期,該webSocket範例到了消亡的時候,形象的講,就是垂暮的老人。
而webSocket想要連結,則只能在 NOT_YET_CONNECTED 狀態下進行,一旦狀態改變,則無法再次連結。這便是connect()連結。針對其解決方法,就是reconnect()連結。
reconnect()連結的實現,便是在connect之前呼叫了reset()方法,重置了當前webSocket,使得狀態又改變成了 NOT_YET_CONNECTED ,從而可以再次執行connect()方法,我們看一下原始碼:

  public void reconnect() {
        this.reset();
        this.connect();
    }

心跳機制及斷線重連方法

瞭解了webSocket的5種狀態以及connect()與reconnect()的區別後,就不難理解斷線重連和心跳機制。
所謂心跳機制,即為每隔一定時間,由使用者端傳送特定的心跳包給伺服器,伺服器也迴應訊息,雙方互相確認對方還"活著"。
例如我們每隔10秒則呼叫 webSocket.send("心跳包")
同時在onMessage中接收到返回的內容,如果能接收到預期返回的內容,則證明雙方都存在,反之則證明有一方掛掉。
至於重連機制,則可以利用reconnect()方法,在檢測到斷線後,重新嘗試連結伺服器端

//開啟一個新執行緒
new Thread(){
    @Override
    public void run(){
         try{
         //間隔10秒傳送心跳
             Thread.sleep(10000);
             webSocketClient.send("心跳包");
         }catch (Exception e){
         //捕獲異常進行重連
             webSocketClient.reconnect();
         }
     }
   }.start();