打造穩定可靠的 websocket 鏈接

目的

可靠穩固的鏈接,無感的自動驗證、數據同步、多終端同步數據,並保障用戶數據安全、隱私,打造與 Telegram 同樣專一於 IM 的應用。因此保障與服務端的可靠鏈接是最重要的事情之一。git

little-chat 擁有上述特色,是體驗更好的 IM 客戶端。github

websocket

websocket 鏈接並不可靠,想要創建穩定可靠的 websocket 連接,最理想的是在 oncloseonerr 的回調中嘗試作重連。web

但這兩個回調有時候並不可靠,特別在移動端,當瀏覽器被退到後臺運行時,即便斷開異常也未必觸發 oncloseonerr 回調。api

那如何保證 websocket 正確地、持續地與服務端鏈接?瀏覽器

主動斷開

瀏覽器的 DOM 提供了一個 visibilitychange 事件,因而咱們即可以經過監聽它判斷 document.hidden 獲知當前頁面是否退到後臺運行。緩存

function handleVisibilityChange() {
  const isPageBeHide = document.hidden;
  if(isPageBeHide) {
    // ...do something
  }
}
document.addEventListener("visibilitychange", handleVisibilityChange);

通過測試,visibilitychange 事件在在 Chrome, Safari, Firefox 等主流瀏覽器都是起做用的,包括 Android 和 iOS 終端下的 webview,因此 React-Native 封裝的 webview 也適用。安全

因而能夠嘗試這樣的機制:websocket

  1. 當頁面被退到後臺時,主動斷開 websocket,中止心跳檢測、消息發送,並清除 websocket 實例。
  2. 當頁面被激活時,再次創建 websocket 鏈接。
let $WS

// 啓動 websocket
function initWS() {
  if(!$WS) {
    $WS = new websocket('ws://api');
    $WS.onopen = () => {

    }
    $WS.onclose = () => {

    }
    $WS.onerror = () => {

    }
  }
}
// 關閉 websocket
function closeWS() {
  if($WS) {
    // 主動斷開
    $WS.close();
    // 清除 websocket 實例
    $WS = null;
  }
}
// 重連 websocket
function reconnect() {
  closeWS();
  initWS();
}
// 應用啓動時
initWS();

function handleVisibilityChange() {
  const isPageBeHide = document.hidden;
  if(isPageBeHide) {
    closeWS();
  } else {
    reconnect();
  }
}
document.addEventListener("visibilitychange", handleVisibilityChange);

上述能夠主動掌握鏈接,但這不能確保 $WS.send() 會在 onopen 後執行,畢竟這操做的主動權在應用業務中,因此咱們還須要確保消息必定在 onopen 以後發送。session

未鏈接成功前的請求隊列

如下爲基礎 websocket 封裝類,設置了 unSendQueue 來保存未鏈接成功時的請求,具體以下(被隱去了不少細節):socket

import { EventEmitter, EventEmitterClass, Call } from 'basic-helper';

const onOpenMark = 'onOpen';
const onMessageMark = 'onMessage';

function wrapWSUrl(hostname) {
  if (!/wss?:\/\//.test(hostname)) {
    console.warn('websocket host 不正確', hostname);
  }
  return hostname;
}

class SocketHelper extends EventEmitterClass {
  // ... 被隱去的細節

  // 未鏈接成功前發起的請求
  unSendQueue: UnSendEntity = {};

  permissionsQueue: UnSendEntity = {};

  constructor(params: SocketParams) {
    super();
    this.params = params;
    this.initWS();
  }

  initWS = () => {
    if (this.connecting) return;
    this.connecting = true;
    const { apiHost } = this.params;
    if (!apiHost) {
      console.error('請傳入 apiHost');
      return;
    }
    const wsApiHost = wrapWSUrl(apiHost);
    this.socket = new WebSocket(wsApiHost);
    this.socket.binaryType = 'arraybuffer';

    this.socket.onopen = this.onOpen;
    this.socket.onmessage = this.onMessage;
    this.socket.onerror = this.onErr;
    this.socket.onclose = this.onClose;
  }

  setReqQuquq = (requestID, success, fail) => {
    this.reqQueue[requestID.toString()] = {
      success,
      fail,
    };
  }

  clearQueue = () => {
    this.reqQueue = {};
    this.permissionsQueue = {};
  }

  send = (sendOptions) => {
    const {
      apiName, bufData, requestID,
      success, fail, needAuth
    } = sendOptions;
    if (!this.connected) {
      /**
       * 若是還沒 onOpen 打開的,放入待發送隊列中
       */
      // console.error('還沒有鏈接');
      this.unSendQueue[requestID.toString()] = sendOptions;
      if (!this.isClosed) this.initWS();
    } else if (this.socket) {
      this.socket.send(data);
      this.setReqQuquq(requestID, success, fail);
    }
  }

  /**
   * 在 onopen 的時候發送在未 open 時候發送請求
   */
  sendNotComplete = (queue: UnSendEntity) => {
    const unSendList = Object.keys(queue);
    if (unSendList.length === 0) return;
    unSendList.forEach((requestID) => {
      const sendOptions = queue[requestID];
      this.send(sendOptions);
      delete queue[requestID];
    });
  }

  onOpen = () => {
    // this.params.onOpen();
    this.connected = true;
    this.connecting = false;
    this.emit(onOpenMark, {});
    this.emit(CONNECT_READY, {});
    // 在 onopen 發送未鏈接時發起的請求
    this.sendNotComplete(this.unSendQueue);
    this.isClosed = false;
  }

  onMessage = (event) => {
  }

  onErr = (e) => {
    console.log('onErr');
    /** 若是發生錯誤,則主動關閉 websocket 連接 */
    this.socket && this.socket.close();
  }

  onClose = (e) => {
    console.log('onClose');
    this.handleException(e);
  }

  handleException = (event) => {
    this.connected = false;
    this.socket = null;
    this.isClosed = true;
    this.clearQueue();
    EventEmitter.emit(ON_CONNECT_CLOSE, event);
  }
}

export default SocketHelper;

如下爲基於 SocketHelper 的更進一步的封裝(讓 API 的用法與 HTTP 一致):

import SocketHelper from './socket';

let $WS;
let prevWSParams;

function GetWS() {
  if (!$WS) console.error(SDKErrorDesc);
  return $WS;
}

function WSSend<T extends Api, S>(api: T, apiName: string, data?, needAuth = true): Promise<S> {
  return new Promise((resolve, reject) => {
    if (!$WS) {
      console.error(SDKErrorDesc);
      return reject(SDKErrorDesc);
    }
    const requestID = BigInt(UUID(16));
    const msgWrite = api.create(data);
    const bufData = api.encode(msgWrite).finish();

    // const finalData = encodeData(apiName, bufData, requestID);
    $WS.send({
      apiName,
      bufData,
      requestID,
      success: (res) => {
        resolve(res);
      },
      fail: (res) => {
        failResHandler(res);
        reject(res);
      },
      needAuth
    });
  });
}

function InitSDK(params: Params = prevWSParams) {
  /** 保存上一個參數 */
  if (params) prevWSParams = params;
  const { apiHost } = params;
  $WS = new SocketHelper({
    apiHost
  });
  return $WS;
}

/**
 * 檢查是否正常連接
 */
function CheckConnectState() {
  let isConnecting = false;
  if (!$WS) return isConnecting;
  isConnecting = $WS.connected;
  return isConnecting;
}

/**
 * 關閉 websocket 連接
 */
function CloseWS() {
  if ($WS) {
    if ($WS.socket) $WS.socket.close();
    $WS = null;
  }
}

export {
  InitSDK, GetWS, WSSend, CheckConnectState, CloseWS
};

如下爲發起請求的 API:

export async function ApplyLogin(form: IUserLoginReq) {
  const res = await WSSend<typeof UserLoginReq, IUserLoginResp>(
    UserLoginReq, 'UserLoginReq', form, false
  );
  if (res.SessionID) {
    /**
     * 1. 成功後設置 sessionID
     * 2. 設置 websocket 的權限
     */
    setHeaderSSID(res.SessionID);
    GetWS().setPermissions(true);
  }
  const result = Object.assign({}, res, {
    UserName: form.UserName,
    ...res.User
  });
  return result;
}

最後在業務應層調用此 API:

const business = () => {
  ApplyLogin({
    // ...
  })
    .then((res) => {
      // ...
    })
}

固然還有一個問題是,有少部分請求能夠不帶 session,例如登錄,可是其餘請求須要,這個須要在 SocketHelper 中再作進一步的驗證封裝,在未驗證經過時,把須要驗證的請求緩存到隊列,而後鏈接成功而且驗證成功後再發送,這樣能夠達到無感登錄地數據同步的體驗。

詳情參考 little-chat

相關文章
相關標籤/搜索