大神駕到 | 騰訊光子大牛的 Cocos Creator 網路通用框架(強勢圍觀)

  • 2019 年 11 月 8 日
  • 筆記

編者按

作者,寶爺寶爺是光子工作室的開發工程師,謙稱自己為一枚碼農,是一個熱愛遊戲、熱愛開發、熱愛學習並堅持沉澱知識的開發者,曾寫過《精通 Cocos2d-x 遊戲開發》基礎卷與進階卷,感謝寶爺為社區所做的貢獻!

在 Cocos Creator 中發起一個 http 請求是比較簡單的,但很多遊戲希望能夠和伺服器之間保持長連接,以便服務端能夠主動向客戶端推送消息,而非總是由客戶端發起請求,對於實時性要求較高的遊戲更是如此。這裡我們會設計一個通用的網路框架,可以方便地應用於我們的項目中。

項目源碼

https://github.com/wyb10a10/cocos_creator_framework

本項目在不斷完善中,包含 bug修改和程式碼更新,下文所展示的程式碼請以源碼為準。

使用websocket

在實現這個網路框架之前,我們先了解一下 websocket。websocket 是一種基於 tcp 的全雙工網路協議,可以讓網頁創建持久性的連接,進行雙向的通訊。在 Cocos Creator 中使用 websocket 既可以用於 H5 網頁遊戲上,同樣支援原生平台 Android 和 iOS。

構造 websocket 對象

在使用 websocket 時,第一步應該創建一個 websocket 對象。websocket 對象的構造函數可以傳入2個參數,第一個是 url 字元串,第二個是協議字元串或字元串數組,指定了可接受的子協議,服務端需要選擇其中的一個返回,才會建立連接,但我們一般用不到。

url 參數非常重要,主要分為4部分:協議、地址、埠、資源。

比如 ws://echo.websocket.org:

  • 協議:必選項,默認是 ws 協議,如果需要安全加密則使用 wss。
  • 地址:必選項,可以是 ip 或域名,當然建議使用域名。
  • 埠:可選項,在不指定的情況下,ws 的默認埠為 80,wss 的默認埠為 443。
  • 資源:可選性,一般是跟在域名後某資源路徑,我們基本不需要它。

websocket 的狀態

websocket 有4個狀態,可以通過 readyState 屬性查詢:

  • 0 CONNECTING 尚未建立連接。
  • 1 OPEN WebSocket連接已建立,可以進行通訊。
  • 2 CLOSING 連接正在進行關閉握手,或者該close()方法已被調用。
  • 3 CLOSED 連接已關閉。

websocket 的 API

websocket 只有2個 API,void send( data ) 發送數據和 void close( code, reason ) 關閉連接。

send 方法只接收一個參數——即要發送的數據,類型可以是以下4個類型的任意一種:string | ArrayBufferLike | Blob | ArrayBufferView。

如果要發送的數據是二進位,我們可以通過 websocket 對象的 binaryType 屬性來指定二進位的類型,binaryType 只可以被設置為「blob」或「arraybuffer」,默認為「blob」。如果我們要傳輸的是文件這樣較為固定的、用於寫入到磁碟的數據,使用 blob。而你希望傳輸的對象在記憶體中進行處理則使用較為靈活的 arraybuffer。如果要從其他非 blob 對象和數據構造一個 blob,需要使用 blob 的構造函數。

在發送數據時,官方有2個建議:

  • 檢測 websocket 對象的 readyState 是否為 OPEN,是才進行 send。
  • 檢測 websocket 對象的 bufferedAmount 是否為0,是才進行 send(為了避免消息堆積,該屬性表示調用 send 後堆積在 websocket 緩衝區的還未真正發送出去的數據長度)。

close 方法接收2個可選的參數,code 表示錯誤碼,我們應該傳入 1000 或 3000~4999 之間的整數,reason 可以用於表示關閉的原因,長度不可超過 123 位元組。

websocket 的回調

websocket 提供了4個回調函數供我們綁定:

  • onopen:連接成功後調用。
  • onmessage:有消息過來時調用:傳入的對象有 data 屬性,可能是字元串、blob 或 arraybuffer。
  • onerror:出現網路錯誤時調用:傳入的對象有 data 屬性,通常是錯誤描述的字元串。
  • onclose:連接關閉時調用:傳入的對象有 code、reason、wasClean 等屬性。

注意:當網路出錯時,會先調用 onerror 再調用 onclose,無論何種原因的連接關閉,onclose 都會被調用。

Echo 實例

下面 websocket 官網的 echo demo 的程式碼,可以將其寫入一個 html 文件中並用瀏覽器打開,打開後會自動創建 websocket 連接,在連接上時主動發送了一條消息「WebSocket rocks」,伺服器會將該消息返回,觸發 onMessage,將資訊列印到螢幕上,然後關閉連接。具體可以參考:http://www.websocket.org/echo.html17

默認的 url 前綴是wss,由於 wss 抽風,使用 ws 才可以連接上,如果 ws 也抽風,可以試試連這個地址ws://121.40.165.18:8800,這是中國的一個免費測試 websocket 的網址。

參考鏈接

  • https://www.w3.org/TR/websockets/9
  • https://developer.mozilla.org/en-US/docs/Web/API/Blob6
  • http://www.websocket.org/echo.html17
  • http://www.websocket-test.com/2

設計框架

一個通用的網路框架,在通用的前提下還需要能夠支援各種項目的差異需求,根據經驗,常見的需求差異如下:

  • 用戶協議差異,遊戲可能傳輸 json、protobuf、flatbuffer 或者自定義的二進位協議。
  • 底層協議差異,我們可能使用 websocket、或者微信小遊戲的 wx.websocket、甚至在原生平台我們希望使用 tcp/udp/kcp 等協議。
  • 登陸認證流程,在使用長連接之前我們理應進行登陸認證,而不同遊戲登陸認證的方式不同。
  • 網路異常處理,比如超時時間是多久,超時後的表現是怎樣的,請求時是否應該屏蔽 UI 等待伺服器響應,網路斷開後表現如何,自動重連還是由玩家點擊重連按鈕進行重連,重連之後是否重發斷網期間的消息?等等這些。
  • 多連接的處理,某些遊戲可能需要支援多個不同的連接,一般不會超過2個,比如一個主連接負責處理大廳等業務消息,一個戰鬥連接直接連戰鬥伺服器,或者連接聊天伺服器。

根據上面的這些需求,我們對功能模組進行拆分,盡量保證模組的高內聚,低耦合。

ProtocolHelper 協議處理模組——當我們拿到一塊 buffer時,我們可能需要知道這個 buffer 對應的協議或者 id 是多少,比如我們在請求的時候就傳入了響應的處理回調,那麼常用的做法可能會用一個自增的 id 來區別每一個請求,或者是用協議號來區分不同的請求,這些是開發者需要實現的。我們還需要從 buffer 中獲取包的長度是多少?包長的合理範圍是多少?心跳包長什麼樣子等等。

Socket 模組——實現最基礎的通訊功能,首先定義 Socket 的介面類 ISocket,定義如連接、關閉、數據接收與發送等介面,然後子類繼承並實現這些介面。

NetworkTips 網路顯示模組——實現如連接中、重連中、載入中、網路斷開等狀態的顯示,以及 UI 的屏蔽。

NetNode 網路節點——所謂網路節點,其實主要的職責是將上面的功能串聯起來,為用戶提供一個易用的介面。

NetManager 管理網路節點的單例——我們可能有多個網路節點(多條連接),所以這裡使用單例來進行管理,使用單例來操作網路節點也會更加方便。

ProtocolHelper

在這裡定義了一個 IProtocolHelper 的簡單介面,如下所示:

export type NetData = (string | ArrayBufferLike | Blob | ArrayBufferView);    // 協議輔助介面  export interface IProtocolHelper {      getHeadlen(): number;                   // 返回包頭長度      getHearbeat(): NetData;                 // 返回一個心跳包      getPackageLen(msg: NetData): number;    // 返回整個包的長度      checkPackage(msg: NetData): boolean;    // 檢查包數據是否合法      getPackageId(msg: NetData): number;     // 返回包的id或協議類型  }

Socket

在這裡定義了一個 ISocket 的簡單介面,如下所示:

// Socket介面  export interface ISocket {      onConnected: (event) => void;           // 連接回調      onMessage: (msg: NetData) => void;      // 消息回調      onError: (event) => void;               // 錯誤回調      onClosed: (event) => void;              // 關閉回調        connect(ip: string, port: number);      // 連接介面      send(buffer: NetData);                  // 數據發送介面      close(code?: number, reason?: string);  // 關閉介面  }

接下來我們實現一個 WebSock,繼承於 ISocket,我們只需要實現 connect、send 和 close 介面即可。send 和 close 都是對 websocket 對簡單封裝,connect 則需要根據傳入的 ip、埠等參數構造一個 url 來創建 websocket,並綁定 websocket 的回調。

export class WebSock implements ISocket {      private _ws: WebSocket = null;              // websocket對象        onConnected: (event) => void = null;      onMessage: (msg) => void = null;      onError: (event) => void = null;      onClosed: (event) => void = null;        connect(options: any) {          if (this._ws) {              if (this._ws.readyState === WebSocket.CONNECTING) {                  console.log("websocket connecting, wait for a moment...")                  return false;              }          }            let url = null;          if(options.url) {              url = options.url;          } else {              let ip = options.ip;              let port = options.port;              let protocol = options.protocol;              url = `${protocol}://${ip}:${port}`;          }            this._ws = new WebSocket(url);          this._ws.binaryType = options.binaryType ? options.binaryType : "arraybuffer";          this._ws.onmessage = (event) => {              this.onMessage(event.data);          };          this._ws.onopen = this.onConnected;          this._ws.onerror = this.onError;          this._ws.onclose = this.onClosed;          return true;      }        send(buffer: NetData) {          if (this._ws.readyState == WebSocket.OPEN)          {              this._ws.send(buffer);              return true;          }          return false;      }        close(code?: number, reason?: string) {          this._ws.close();      }  }

NetworkTips

INetworkTips 提供了非常的介面,重連和請求的開關,框架會在合適的時機調用它們,我們可以繼承 INetworkTips 並訂製我們的網路相關提示資訊,需要注意的是這些介面可能會被**多次調用**。

// 網路提示介面  export interface INetworkTips {      connectTips(isShow: boolean): void;      reconnectTips(isShow: boolean): void;      requestTips(isShow: boolean): void;  }  

NetNode

NetNode 是整個網路框架中最為關鍵的部分,一個 NetNode 實例表示一個完整的連接對象,基於 NetNode 我們可以方便地進行擴展,它的主要職責有:

連接維護

  • 連接的建立與鑒權(是否鑒權、如何鑒權由用戶的回調決定)
  • 斷線重連後的數據重發處理
  • 心跳機制確保連接有效(心跳包間隔由配置,心跳包的內容由ProtocolHelper定義)
  • 連接的關閉

數據發送

  • 支援斷線重傳,超時重傳
  • 支援唯一發送(避免同一時間重複發送)

數據接收

  • 支援持續監聽
  • 支援request-respone模式

介面展示

  • 可自定義網路延遲、短線重連等狀態的表現

首先我們定義了 NetTipsType、NetNodeState 兩個枚舉,以及 NetConnectOptions 結構供 NetNode 使用。

接下來是 NetNode 的成員變數,NetNode 的變數可以分為以下幾類:

  • NetNode 自身的狀態變數,如 ISocket 對象、當前狀態、連接參數等等。
  • 各種回調,包括連接、斷開連接、協議處理、網路提示等回調。
  • 各種定時器,如心跳、重連相關的定時器。
  • 請求列表與監聽列表,都是用於接收到的消息處理。

接下來介紹網路相關的成員函數,首先看初始化與:

  • init 方法用於初始化 NetNode,主要是指定 Socket 與協議等處理對象。
  • connect 方法用於連接伺服器。
  • initSocket 方法用於綁定 Socket 的回調到 NetNode 中。
  • updateNetTips 方法用於刷新網路提示。

onConnected 方法在網路連接成功後調用,自動進入鑒權流程(如果設置了_connectedCallback),在鑒權完成後需要調用 onChecked 方法使 NetNode 進入可通訊的狀態,在未鑒權的情況,我們不應該發送任何業務請求,但登錄驗證這類請求應該發送給伺服器,這類請求可以通過帶force參數強制發送給伺服器。

接收到任何消息都會觸發 onMessage,首先會對數據包進行校驗,校驗的規則可以在自己的 ProtocolHelper 中實現,如果是一個合法的數據包,我們會將心跳和超時計時器進行更新——重新計時,最後在 _requests 和 _listener 中找到該消息的處理函數,這裡是通過 rspCmd 進行查找的,rspCmd 是從 ProtocolHelper 的 getPackageId 取出的,我們可以將協議的命令或者序號返回,由我們自己來決定請求和響應如何對應。

onError 和 onClosed 是網路出錯和關閉時調用的,無論是否出錯,最終都會調用 onClosed,在這裡我們執行斷線回調,以及做自動重連的處理。當然也可以調用 close來關閉套接字。close 與 closeSocket 的區別在於 closeSocket 只是關閉套接字——我仍然要使用當前的 NetNode,可能通過下一次 connect 恢復網路。而 close則是清除所有的狀態。

發起網路請求有3種方式:

send 方法,純粹地發送數據,如果當前斷網或者驗證中會進入 _request 隊列。

request 方法,在請求的時候即以閉包的方式傳入回調,在該請求的響應回到時會執行回調,如果同時有多個相同的請求,那麼這 N 個請求的響應會依次回到客戶端,響應回調也會依次執行(每次只會執行一個回調)。

requestUnique 方法,如果我們不希望有多個相同的請求,可以使用 requestUnique 來確保每一種請求同時只會有一個。

這裡確保沒有重複之所以使用的是遍歷 _requests,是因為我們不會積壓大量的請求到 _requests中,超時或異常重發也不會導致 _requests 的積壓,因為重發的邏輯是由 NetNode 控制的,而且在網路斷開的情況下,我們理應屏蔽用戶發起請求,此時一般會有一個全螢幕遮罩——網路出現波動之類的提示。

我們有2種回調,一種是前面的 request 回調,這種回調是臨時性的,一般隨著請求-響應-執行而立即清理,_listener 回調則是常駐的,需要我們手動管理的,比如打開某介面時監聽、離開是關閉,或者在遊戲一開始就進行監聽。適合處理伺服器的主動推送消息。

最後是心跳與超時相關的定時器,我們每隔 _heartTime 會發送一個心跳包,每隔 _receiveTime 檢測如果沒有收到伺服器返回的包,則判斷網路斷開。

完整程式碼,大家可以進入源碼查看!

NetManager

NetManager 用於管理 NetNode,這是由於我們可能需要支援多個不同的連接對象,所以需要一個 NetManager 專門來管理 NetNode,同時,NetManager 作為一個單例,也可以方便我們調用網路。

export class NetManager {      private static _instance: NetManager = null;      protected _channels: { [key: number]: NetNode } = {};        public static getInstance(): NetManager {          if (this._instance == null) {              this._instance = new NetManager();          }          return this._instance;      }        // 添加Node,返回ChannelID      public setNetNode(newNode: NetNode, channelId: number = 0) {          this._channels[channelId] = newNode;      }        // 移除Node      public removeNetNode(channelId: number) {          delete this._channels[channelId];      }        // 調用Node連接      public connect(options: NetConnectOptions, channelId: number = 0): boolean {          if (this._channels[channelId]) {              return this._channels[channelId].connect(options);          }          return false;      }        // 調用Node發送      public send(buf: NetData, force: boolean = false, channelId: number = 0): boolean {          let node = this._channels[channelId];          if(node) {              return node.send(buf, force);          }          return false;      }        // 發起請求,並在在結果返回時調用指定好的回調函數      public request(buf: NetData, rspCmd: number, rspObject: CallbackObject, showTips: boolean = true, force: boolean = false, channelId: number = 0) {          let node = this._channels[channelId];          if(node) {              node.request(buf, rspCmd, rspObject, showTips, force);          }      }        // 同request,但在request之前會先判斷隊列中是否已有rspCmd,如有重複的則直接返回      public requestUnique(buf: NetData, rspCmd: number, rspObject: CallbackObject, showTips: boolean = true, force: boolean = false, channelId: number = 0): boolean {          let node = this._channels[channelId];          if(node) {              return node.requestUnique(buf, rspCmd, rspObject, showTips, force);          }          return false;      }        // 調用Node關閉      public close(code?: number, reason?: string, channelId: number = 0) {          if (this._channels[channelId]) {              return this._channels[channelId].closeSocket(code, reason);          }      }

測試例子

接下來我們用一個簡單的例子來演示一下網路框架的基本使用,首先我們需要拼一個簡單的介面用於展示,3個按鈕(連接、發送、關閉),2個輸入框(輸入 url、輸入要發送的內容),一個文本框(顯示從伺服器接收到的數據),如下圖所示。

該例子連接的是 websocket 官方的 echo.websocket.org 地址,這個伺服器會將我們發送給它的所有消息都原樣返回給我們。

接下來,實現一個簡單的 Component,這裡新建了一個 NetExample.ts 文件,做的事情非常簡單,在初始化的時候創建 NetNode、綁定默認接收回調,在接收回調中將伺服器返回的文本顯示到 msgLabel中。接著是連接、發送和關閉幾個介面的實現:

// 不關鍵的程式碼省略    @ccclass  export default class NetExample extends cc.Component {      @property(cc.Label)      textLabel: cc.Label = null;      @property(cc.Label)      urlLabel: cc.Label = null;      @property(cc.RichText)      msgLabel: cc.RichText = null;      private lineCount: number = 0;        onLoad() {          let Node = new NetNode();          Node.init(new WebSock(), new DefStringProtocol());          Node.setResponeHandler(0, (cmd: number, data: NetData) => {              if (this.lineCount > 5) {                  let idx = this.msgLabel.string.search("n");                  this.msgLabel.string = this.msgLabel.string.substr(idx + 1);              }              this.msgLabel.string += `${data}n`;              ++this.lineCount;          });          NetManager.getInstance().setNetNode(Node);      }        onConnectClick() {          NetManager.getInstance().connect({ url: this.urlLabel.string });      }        onSendClick() {          NetManager.getInstance().send(this.textLabel.string);      }        onDisconnectClick() {          NetManager.getInstance().close();      }  }

程式碼完成後,將其掛載到場景的 Canvas 節點下(其他節點也可以),然後將場景中的 Label 和 RichText 拖拽到我們的 NetExample 的屬性面板中:

運行效果如下所示:

小結

可以看到,Websocket 的使用很簡單,我們在開發的過程中會碰到各種各樣的需求和問題,要實現一個好的設計,快速地解決問題。

我們一方面需要對我們使用的技術本身有深入的理解,websocket 的底層協議傳輸是如何實現的?與 tcp、http 的區別在哪裡?基於 websocket 能否使用 udp 進行傳輸呢?使用 websocket 發送數據是否需要自己對數據流進行分包(websocket 協議保證了包的完整)?數據的發送是否出現了發送快取的堆積(查看 bufferedAmount)?

另外需要對我們的使用場景及需求本身的理解,對需求的理解越透徹,越能做出好的設計。哪些需求是項目相關的,哪些需求是通用的?通用的需求是必須的還是可選的?不同的變化我們應該封裝成類或介面,使用多態的方式來實現呢?還是提供配置?回調綁定?事件通知?

我們需要設計出一個好的框架,來適用於下一個項目,並且在一個一個的項目中優化迭代,這樣才能建立深厚的沉澱、提高效率。

接下來的一段時間,我會將之前的一些經驗整理為一個開源易用的 Cocos Creator 框架。

源碼鏈接:

https://github.com/wyb10a10/cocos_creator_framework

———— / END / ————

再次向作者寶爺的分享致以謝意!