基於 websocket 的多端橋接平台

  • 2020 年 3 月 27 日
  • 筆記

我們現在的業務是基於新聞客戶端實現的,都要經過新聞客戶端的環境,進行前後端數據上的交互。但是我們在調試過程中,非常的不方便。

通常使用的工具有:modheader, postman, fiddler 等,但這些工具都會存在的問題:

  1. 缺少客戶端里相應的設備資訊;
  2. 即使將 cookie 資訊複製出來,也是存在過期的問題;
  3. 多個設備之間切換時不方便;

針對這些存在的問題和不足,我基於 websocket 雙向通訊的特點,並實現了「多端橋接管理平台」:通過在 PC 端上的操作,可以直接在新聞客戶端內直接執行相應的命令,並將結果、cookie、設備資訊等一起返回到 PC 端。

1. 要調試什麼

我們主要要知道調試什麼,最終回去到什麼樣子的結果:

  1. 調試介面,傳入介面地址,即可獲取對應的結果;並且可以同時調試多個設備;
  2. 調試 jsapi,輸入對應的方法,則即可在新聞客戶端中展示出效果。

在調試介面方面,其實我們有一種方法可以方便地進行調試,但有兩個限制條件:Android系統測試版的客戶端,這樣通過 Chrome 瀏覽器進行橋接。但這種方式,在 iOS 系統和正式版的客戶端中,就失效了。

2. websocket 的特性

WebSocket 協議的最大特點就是,伺服器可以主動向客戶端推送資訊,客戶端也可以主動向伺服器發送資訊,是真正的雙向平等對話,屬於伺服器推送技術的一種。

websocket與http的對比

其他特點包括:

  1. 建立在 TCP 協議之上,伺服器端的實現比較容易。
  2. 與 HTTP 協議有著良好的兼容性。默認埠也是 80 和 443,並且握手階段採用 HTTP 協議,因此握手時不容易屏蔽,能通過各種 HTTP 代理伺服器。
  3. 數據格式比較輕量,性能開銷小,通訊高效。
  4. 可以發送文本,也可以發送二進位數據。
  5. 沒有同源限制,客戶端可以與任意伺服器通訊。
  6. 協議標識符是 ws(如果加密,則為 wss),伺服器網址就是 URL。

3. 建立 socket 連接

為了滿足我們在第 1 部分設置的調試目標,我們這裡要實現的功能有:

  1. PC 端相當於房主,建立房間後,其他設備可以進入到該房間,一個設備只能進入到一個房間中;
  2. 客戶端有斷線重連的機制,當客戶端斷開連接後,可以嘗試重連;
  3. 服務端維護一個心跳檢測的機制,當有新設備進入或者之前的設備退出時,要及時地更新當前房間中的設備列表;

3.1 如何創建房間

在瀏覽器上輸入房間的標識,若瀏覽器與服務端成功建立起 websocket 連接後,則在瀏覽器端創建對應的二維碼。用微信/手 Q 或者其他掃描二維碼的設備進行掃描,即可通過提前設定的 scheme 協議,跳轉到新聞客戶端里對應的調試頁面。

若客戶端里也與服務端成功建立 websocket 連接後,則相當於進入房間成功,PC 端會出現一個對應的圖標。

ws.open(serverId)      .then(() => {          // PC 端成功建立連接後            setStatus("linked"); // 更新頁面的狀態          // 生成二維碼          qrcode(`/tools/index.html#/newslist?serverId=${serverId}`).then(url => {              setCodeUrl(url);          });      })      .catch(e => {          // 建立連接失敗          console.error(e);          Modal.error({ title: "當前伺服器出現問題啦,正在搶修中" });          setStatus("unlink");      });  

3.2 客戶端的斷線重現機制

在移動端中的頁面有個特點,當螢幕黑屏後,或者因為其他的原因,客戶端會自動斷開 socket 連接。

為了方便進行調試,而不是每次在斷開連接後,需要手動點擊,或者重新進入頁面。我在這裡實現了一個簡單的斷線重連機制。websocket 連接斷開時,會執行onclose的回調,因此,我們可以在 onclose 事件中進行再次重連的機制。

同時,為了防止無限制的重連嘗試,我在這裡也進行了下限制,最多重連 3 次,3 次後還沒有重新連接上,則停止連接;若重連成功,則將重連次數重置為 3。

斷開連接時:

// 斷開連接時  ws.onclose(() => {      timer = setTimeout(() => {          setStatus("unlink");          setCodeUrl("");      }, 500);        reconnectNum--;      // 限制重連的次數      if (reconnectNum >= 0) {          _open(); // 嘗試重新連接      }  });  

連接成功時:

ws.open(serverId).then(() => {      // PC 端成功建立連接後      +reconnectNum = 3;      +timer && clearTimeout(timer);        setStatus("linked"); // 更新頁面的狀態      // 生成二維碼      qrcode(`/tools/index.html#/newslist?serverId=${serverId}`).then(url => {          setCodeUrl(url);      });  });  

3.3 心跳檢測

就像我們在 QQ 群里聊天一樣,哪個人在線要一目了然,若有人進入到聊天群,或者有人退出了,都要通知房主,並及時地更新群列表。

心跳檢測主要有 2 種方式:客戶端發起的心跳檢測和服務端維護的心跳檢測。我們稍微講解下這兩種:

  • 客戶端發起的心跳:每隔一段固定的時間,向伺服器端發送一個 ping 數據,如果在正常的情況下,伺服器會返回一個 pong 給客戶端,如果客戶端通過 onmessage 事件能監聽到的話,說明請求正常。
  • 服務端維護的心跳:每隔一段時間,檢測所有連接的狀態,若狀態為斷開時,則將其從列表中剔除。

我在這裡使用的是服務端維護的心跳檢測,當房間里的設備數量發生變化時,則服務端向客戶端推送最新的設備列表:

// 持續監測客戶端的連接狀態  // 若已斷開連接,則將客戶端清除  let aliveClients = new Map();  let lastAliveLength = new Map();  setInterval(() => {      let clients = {};      wss.clients.forEach(function each(ws) {          if (ws.isAlive === false) {              return ws.terminate();          }          const serverId = ws.serverId;          if (clients[serverId]) {              clients[serverId].push(ws);          } else {              clients[serverId] = [ws];          }            ws.isAlive = false;          ws.ping(() => {});      });      for (let serverId in clients) {          aliveClients.set(serverId, clients[serverId]);          const length = clients[serverId].length;            // 若當前serverId連接的設備數量發生變化,則發送消息          if (length !== lastAliveLength.get(serverId)) {              // 想當前所有serverId的設備發送消息              sendAll("devices", clients[serverId], serverId);                // 存儲上次當前serverId的連接數              lastAliveLength.set(serverId, length);          }      }        const size = wss.clients.size;        console.log("connection num: ", size, new Date().toTimeString());  }, 2000);  

4. 進行介面的調試

我們在第 3 節已經成功把 PC 端和新聞客戶端連接起來了,那麼怎麼進行雙端數據的通訊?

4.1 介面的調試

我們在這裡要傳入 3 個欄位:

  • serverId: 即房間號,服務端要將資訊廣播給所有帶有 serverId 的成員;
  • type: 類型,這條指令是要做什麼的;
  • msg: 傳入的參數;

在介面調試的過程中,則傳入的參數是:

const params = {      type: "post", // 類型      msg: {          // 參數          url: "https://api.prize.qq.com/v1/newsapp/answer/share/oneQ?qID=506336"      }  };  

當客戶端正常完成介面的請求後,則將介面結果、cookie 和設備資訊等返回到 PC 端:

// 請求的方法  const post = url => {      if (window.TencentNews && window.TencentNews.post) {          window.TencentNews.post(url, {}, window[id], { loginType: "qqorweixin" }, {});      } else if (window.TencentNews && window.TencentNews.postData) {          window.TencentNews.postData(url, '{"a":"b"}', id, "requestErrorCallback");      }  };    // 移動端向服務端發起的數據  ws.send({      type: "postCb", // 執行的結果      msg: {          method: "post",          result,          cookie: document.cookie,          appInfo      }  });  

這樣就能在前端展示出結果了,而且是真實的數據請求。

4.2 歷史記錄的存儲

歷史記錄這塊,我們周邊的同學在試用的過程中,還是非常迫切需要的需求。要不然每次要測試之前的介面地址時,都需要重新輸入或者粘貼,非常不方便。

我們把用戶請求的 URL、返回的結果、cookie、設備資訊等比較完整的資訊存儲到 boss 中,而本地只存儲歷史的 URL,當用戶需要再次測試之前的介面時,點擊一下即可。若需要查看之前調試的介面,可以去鷹眼上進行查看。

調試介面-蚊子的部落格

本地採用的是localStorage的方式進行存儲。還有更重要的是,我們也使用mobx的響應式工具,能夠在用戶完成這次請求後,馬上在側邊的歷史記錄里看到結果。

5. 新聞客戶端內 jsapi 的調試

除了可以調試介面外,還可以進行一些新聞客戶端內的 jsapi 調試。我們新聞客戶端的 jsapi 有兩種調用的方式:

// 直接調用  window.TencentNews.login("qqorweixin", isLogined => console.log(isLogined));    // invoke方式調用  window.TencentNews.invoke("login", "qqorweixin", isLogined => console.log(isLogined));  

這裡我選擇了使用invoke的方式來調用 jsapi。

PC 端發起 jsapi 的調用:

ws.send({      type: "call",      msg: {          method: method,          params: slice.call(arguments)      }  });  

移動端在收到服務端發過來的請求後,進行 jsapi 的調用,並將執行的結果返回到 PC 端即可:

websocket的客戶端調試-蚊子的部落格

const handleNewsApi = async (msg: any): Promise<any> => {      await tencentReady();        const { method, params } = msg;      return new Promise(resolve => {          window.TencentNews.invoke(method, ...params, (result: any) => {              resolve({ method, result });          });      });  };  

6. 總結

到這裡,我的「基於 websocket 的多端橋接平台」基本上已經構建完畢了。不過還是有 2 個問題要簡要的說明下。

6.1 為什麼要手動輸入 serverId

最開始想著用戶創建房間時,由系統隨機產生一個 uuid,但後來想,如果用戶刷新頁面了,這個 uuid 就會發生變化,導致無法連接到之前的 uuid,所以這裡就換成了手動輸入。

6.2 如何保證一個客戶端的 socket 請求都進入到同一個進程中

當我們後台採用多個進程時,若用戶的請求我們不做干預,會造成請求的隨機訪問,產生 400 的請求,畢竟最開始連接在 A 進程中,現在發起的請求到 B 進程中,B 進程不知道怎麼處理了。

這裡有多種方式可以進行處理:

方法 介紹 優點 缺點
一致性 hash 演算法 所有的主機和連接都分配到 0 ~ 2^32-1 的虛擬圓中 1. 適用在大規模的應用;
2. 某個主機或者進程掛掉後,影響小
實現比較複雜
nginx 分配 自帶的 ip_hash 可實現負載均衡;
同一 ip 會被分配給固定的後端伺服器
配置方便 可能會集中到某個進程中

我這裡的平台是內部的調試平台,用戶量不大,殺雞焉用牛刀,而且我們只有一台機器,因此我們考慮的是同一個 IP 進入到同一個進程中。這裡我借用里 nginx 中的 ip_hash 思想:當請求來到主進程後,我這裡對 IP 進行加權計算後,然後按照進程的個數進行取模。

顯然這種方式也有可能存在一個進程中 socket 連接過多的問題,不過在用戶量不多的時候完全可以接受(針對這個問題我也考慮了別的方法,例如瀑布流的方式,每次給子進程分配連接的時候,都首先獲取到連接數最少的那個進程,然後連接分配給這個進程,不過還要維護一個表,每次都要計算)。

6.4 多進程之間的通訊

同一個房間里,當 PC 端的 socket 連接和多個移動端的連接不在同一個進程中時,就會存在跨進程的問題。一個極端的例子,每個 socket 連接都在不同的進程中,那麼就要考慮如何通知其他的進程,需要給客戶端發送請求了。

比較簡單的方式利用我們的機制,每個 PC 端的用戶就是房主,可以創建一個房間,移動設備就是房間中的成員,每個房間都是獨立的,互不干擾。這樣我們把房間里所有的 socket 連接,通過房間的標識,都放到同一個進程中,這樣就沒有跨進程的問題了。但這種方式存在的一個問題是:一個房間里的連接過多時,都需要這同一個進程來承擔,而別的進程卻閑著的。

還有可以使用 redis:利用 redis 的發布/訂閱者模式,將當前進程中的房間標識和資訊廣播到其他的進程中,其他進程中有相同房間標識的 socket 連接,進行相應的操作。

歡迎我的公眾號,多多交流:
蚊子的部落格