Cocos Creator 通用框架设计 —— 网络

在Creator中发起一个http请求是比较简单的,但很多游戏希望能够和服务器之间保持长连接,以便服务端能够主动向客户端推送消息,而非总是由客户端发起请求,对于实时性要求较高的游戏更是如此。这里我们会设计一个通用的网络框架,可以方便地应用于我们的项目中。

使用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.html

默认的url前缀是wss,由于wss抽风,使用ws才可以连接上,如果ws也抽风,可以试试连这个地址ws://121.40.165.18:8800,这是国内的一个免费测试websocket的网址。

  <!DOCTYPE html>
  <meta charset="utf-8" />
  <title>WebSocket Test</title>
  <script language="javascript" type="text/javascript">

  var wsUri = "ws://echo.websocket.org/";
  var output;

  function init() {
    output = document.getElementById("output");
    testWebSocket();
  }

  function testWebSocket() { // 初始化websocket,绑定回调
    websocket = new WebSocket(wsUri);
    websocket.onopen = onOpen;
    websocket.onclose = onClose;
    websocket.onmessage = onMessage;
    websocket.onerror = onError;
  }

  function onOpen(evt) { // 连接成功回调
    writeToScreen("CONNECTED");
    doSend("WebSocket rocks");
  }

  function onClose(evt) { // 连接关闭回调
    writeToScreen("DISCONNECTED");
  }

  function onMessage(evt) { // 收到消息回调
    writeToScreen('<span style="color: blue;">RESPONSE: ' + evt.data+'</span>');
    websocket.close();
  }

  function onError(evt) { // 错误回调
    writeToScreen('<span style="color: red;">ERROR:</span> ' + evt.data);
  }

  function doSend(message) { // 向服务器发送消息
    writeToScreen("SENT: " + message);
    websocket.send(message);
  }

  function writeToScreen(message) { // 将文字打印到网页上
    var pre = document.createElement("p");
    pre.style.wordWrap = "break-word";
    pre.innerHTML = message;
    output.appendChild(pre);
  }

  // 网页加载完成时调用init方法,初始化websocket
  window.addEventListener("load", init, false);
  </script>
  
  <h2>WebSocket Test</h2>
  <div id="output"></div>

参考

设计框架

一个通用的网络框架,在通用的前提下还需要能够支持各种项目的差异需求,根据经验,常见的需求差异如下:

  • 用户协议差异,游戏可能传输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对简单封装。

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;

    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();
    }
}

connect则需要根据传入的ip、端口等参数构造一个url来创建websocket,并绑定websocket的回调。

    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;
    }

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使用。

export enum NetTipsType {
    Connecting,					// 连接提示
    ReConnecting,               // 重连提示
    Requesting,                 // 请求提示
}

export enum NetNodeState {
    Closed,                     // 已关闭
    Connecting,                 // 连接中
    Checking,                   // 验证中
    Working,                    // 可传输数据
}

export interface NetConnectOptions {
    host?: string,              // 地址
    port?: number,              // 端口
    url?: string,               // url,与地址+端口二选一
    autoReconnect?: number,     // -1 永久重连,0不自动重连,其他正整数为自动重试次数
}

接下来是NetNode的成员变量,NetNode的变量可以分为以下几类

  • NetNode自身的状态变量,如ISocket对象、当前状态、连接参数等等。
  • 各种回调,包括连接、断开连接、协议处理、网络提示等回调。
  • 各种定时器,如心跳、重连相关的定时器。
  • 请求列表与监听列表,都是用于接收到的消息处理。
export class NetNode {
    protected _connectOptions: NetConnectOptions = null;            // 连接参数
    protected _autoReconnect: number = 0;	                        // 是否自动重连
    protected _isSocketInit: boolean = false;                     	// Socket是否初始化过
    protected _isSocketOpen: boolean = false;                     	// Socket是否连接成功过
    protected _state: NetNodeState = NetNodeState.Closed;         	// 节点当前状态
    protected _socket: ISocket = null;                            	// Socket对象(可能是原生socket、websocket、wx.socket...)
	
    protected _networkTips: INetworkTips = null;                  	// 网络提示ui对象(请求提示、断线重连提示等)
    protected _protocolHelper: IProtocolHelper = null;            	// 包解析对象
    protected _connectedCallback: CheckFunc = null;               	// 连接完成回调
    protected _disconnectCallback: BoolFunc = null;               	// 断线回调
    protected _callbackExecuter: ExecuterFunc = null;             	// 回调执行
	
    protected _keepAliveTimer: any = null;                        	// 心跳定时器
    protected _receiveMsgTimer: any = null;                       	// 接收数据定时器
    protected _reconnectTimer: any = null;                        	// 重连定时器
    protected _heartTime: number = 10000;                         	// 心跳间隔
    protected _receiveTime: number = 6000000;                     	// 多久没收到数据断开
    protected _reconnetTimeOut: number = 8000000;                 	// 重连间隔
	
    protected _requests: RequestObject[] = Array<RequestObject>();	// 请求列表
    protected _listener: { [key: number]: CallbackObject[] } = {} 	// 监听者列表

接下来介绍网络相关的成员函数,首先看初始化与:

  • init方法用于初始化NetNode,主要是指定Socket与协议等处理对象。
  • connect方法用于连接服务器。
  • initSocket方法用于绑定Socket的回调到NetNode中。
  • updateNetTips方法用于刷新网络提示。
    /********************** 网络相关处理 *********************/
    public init(socket: ISocket, protocol: IProtocolHelper, 
	networkTips: any = null, execFunc : ExecuterFunc = null, checkFunc : CheckFunc = null) {
        console.log(`NetNode init socket`);
        this._socket = socket;
        this._protocolHelper = protocol;
        this._networkTips = networkTips;
        this._callbackExecuter = execFunc ? execFunc : (callback: CallbackObject, buffer: NetData) => {
            callback.callback.call(callback.target, 0, buffer);
        }
		this._connectedCallback = checkFunc;
    }

    public connect(options: NetConnectOptions): boolean {
        if (this._socket && this._state == NetNodeState.Closed) {
            if (!this._isSocketInit) {
                this.initSocket();
            }
            this._state = NetNodeState.Connecting;
            if (!this._socket.connect(options)) {
                this.updateNetTips(NetTipsType.Connecting, false);
                return false;
            }

            if (this._connectOptions == null) {
                options.autoReconnect = options.autoReconnect;
            }
            this._connectOptions = options;
            this.updateNetTips(NetTipsType.Connecting, true);
            return true;
        }
        return false;
    }

    protected initSocket() {
        this._socket.onConnected = (event) => { this.onConnected(event) };
        this._socket.onMessage = (msg) => { this.onMessage(msg) };
        this._socket.onError = (event) => { this.onError(event) };
        this._socket.onClosed = (event) => { this.onClosed(event) };
        this._isSocketInit = true;
    }

    protected updateNetTips(tipsType: NetTipsType, isShow: boolean) {
        if (this._networkTips) {
            if (tipsType == NetTipsType.Requesting) {
                this._networkTips.requestTips(isShow);
            } else if (tipsType == NetTipsType.Connecting) {
                this._networkTips.connectTips(isShow);
            } else if (tipsType == NetTipsType.ReConnecting) {
                this._networkTips.reconnectTips(isShow);
            }
        }
    }

onConnected方法在网络连接成功后调用,自动进入鉴权流程(如果设置了_connectedCallback),在鉴权完成后需要调用onChecked方法使NetNode进入可通讯的状态,在未鉴权的情况,我们不应该发送任何业务请求,但登录验证这类请求应该发送给服务器,这类请求可以通过带force参数强制发送给服务器。

    // 网络连接成功
    protected onConnected(event) {
        console.log("NetNode onConnected!")
        this._isSocketOpen = true;
        // 如果设置了鉴权回调,在连接完成后进入鉴权阶段,等待鉴权结束
        if (this._connectedCallback !== null) {
            this._state = NetNodeState.Checking;
            this._connectedCallback(() => { this.onChecked() });
        } else {
            this.onChecked();
        }
        console.log("NetNode onConnected! state =" + this._state);
    }

    // 连接验证成功,进入工作状态
    protected onChecked() {
        console.log("NetNode onChecked!")
        this._state = NetNodeState.Working;
        // 关闭连接或重连中的状态显示
        this.updateNetTips(NetTipsType.Connecting, false);
        this.updateNetTips(NetTipsType.ReConnecting, false);

        // 重发待发送信息
        console.log(`NetNode flush ${this._requests.length} request`)
        if (this._requests.length > 0) {
            for (var i = 0; i < this._requests.length;) {
                let req = this._requests[i];
                this._socket.send(req.buffer);
                if (req.rspObject == null || req.rspCmd <= 0) {
                    this._requests.splice(i, 1);
                } else {
                    ++i;
                }
            }
            // 如果还有等待返回的请求,启动网络请求层
            this.updateNetTips(NetTipsType.Requesting, this.request.length > 0);
        }
    }

接收到任何消息都会触发onMessage,首先会对数据包进行校验,校验的规则可以在自己的ProtocolHelper中实现,如果是一个合法的数据包,我们会将心跳和超时计时器进行更新——重新计时,最后在_requests和_listener中找到该消息的处理函数,这里是通过rspCmd进行查找的,rspCmd是从ProtocolHelper的getPackageId取出的,我们可以将协议的命令或者序号返回,由我们自己来决定请求和响应如何对应。

    // 接收到一个完整的消息包
    protected onMessage(msg): void {
        // console.log(`NetNode onMessage status = ` + this._state);
        // 进行头部的校验(实际包长与头部长度是否匹配)
        if (!this._protocolHelper.checkPackage(msg)) {
            console.error(`NetNode checkHead Error`);
            return;
        }
        // 接受到数据,重新定时收数据计时器
        this.resetReceiveMsgTimer();
        // 重置心跳包发送器
        this.resetHearbeatTimer();
        // 触发消息执行
        let rspCmd = this._protocolHelper.getPackageId(msg);
        console.log(`NetNode onMessage rspCmd = ` + rspCmd);
        // 优先触发request队列
        if (this._requests.length > 0) {
            for (let reqIdx in this._requests) {
                let req = this._requests[reqIdx];
                if (req.rspCmd == rspCmd) {
                    console.log(`NetNode execute request rspcmd ${rspCmd}`);
                    this._callbackExecuter(req.rspObject, msg);
                    this._requests.splice(parseInt(reqIdx), 1);
                    break;
                }
            }
            console.log(`NetNode still has ${this._requests.length} request watting`);
            if (this._requests.length == 0) {
                this.updateNetTips(NetTipsType.Requesting, false);
            }
        }

        let listeners = this._listener[rspCmd];
        if (null != listeners) {
            for (const rsp of listeners) {
                console.log(`NetNode execute listener cmd ${rspCmd}`);
                this._callbackExecuter(rsp, msg);
            }
        }
    }

onError和onClosed是网络出错和关闭时调用的,无论是否出错,最终都会调用onClosed,在这里我们执行断线回调,以及做自动重连的处理。当然也可以调用close来关闭套接字。close与closeSocket的区别在于closeSocket只是关闭套接字——我仍然要使用当前的NetNode,可能通过下一次connect恢复网络。而close则是清除所有的状态。

    protected onError(event) {
        console.error(event);
    }

    protected onClosed(event) {
        this.clearTimer();

        // 执行断线回调,返回false表示不进行重连
        if (this._disconnectCallback && !this._disconnectCallback()) {
            console.log(`disconnect return!`)
            return;
        }

        // 自动重连
        if (this.isAutoReconnect()) {
            this.updateNetTips(NetTipsType.ReConnecting, true);
            this._reconnectTimer = setTimeout(() => {
                this._socket.close();
                this._state = NetNodeState.Closed;
                this.connect(this._connectOptions);
                if (this._autoReconnect > 0) {
                    this._autoReconnect -= 1;
                }
            }, this._reconnetTimeOut);
        } else {
            this._state = NetNodeState.Closed;
        }
    }

    public close(code?: number, reason?: string) {
        this.clearTimer();
        this._listener = {};
        this._requests.length = 0;
        if (this._networkTips) {
            this._networkTips.connectTips(false);
            this._networkTips.reconnectTips(false);
            this._networkTips.requestTips(false);
        }
        if (this._socket) {
            this._socket.close(code, reason);
        } else {
            this._state = NetNodeState.Closed;
        }
    }

    // 只是关闭Socket套接字(仍然重用缓存与当前状态)
    public closeSocket(code?: number, reason?: string) {
        if (this._socket) {
            this._socket.close(code, reason);
        }
    }

发起网络请求有3种方式:

  • send方法,纯粹地发送数据,如果当前断网或者验证中会进入_request队列。
  • request方法,在请求的时候即以闭包的方式传入回调,在该请求的响应回到时会执行回调,如果同时有多个相同的请求,那么这N个请求的响应会依次回到客户端,响应回调也会依次执行(每次只会执行一个回调)。
  • requestUnique方法,如果我们不希望有多个相同的请求,可以使用requestUnique来确保每一种请求同时只会有一个。

这里确保没有重复之所以使用的是遍历_requests,是因为我们不会积压大量的请求到_requests中,超时或异常重发也不会导致_requests的积压,因为重发的逻辑是由NetNode控制的,而且在网络断开的情况下,我们理应屏蔽用户发起请求,此时一般会有一个全屏遮罩——网络出现波动之类的提示。

    // 发起请求,如果当前处于重连中,进入缓存列表等待重连完成后发送
    public send(buf: NetData, force: boolean = false): boolean {
        if (this._state == NetNodeState.Working || force) {
            console.log(`socket send ...`);
            return this._socket.send(buf);
        } else if (this._state == NetNodeState.Checking ||
            this._state == NetNodeState.Connecting) {
            this._requests.push({
                buffer: buf,
                rspCmd: 0,
                rspObject: null
            });
            console.log("NetNode socket is busy, push to send buffer, current state is " + this._state);
            return true;
        } else {
            console.error("NetNode request error! current state is " + this._state);
            return false;
        }
    }

    // 发起请求,并进入缓存列表
    public request(buf: NetData, rspCmd: number, rspObject: CallbackObject, showTips: boolean = true, force: boolean = false) {
        if (this._state == NetNodeState.Working || force) {
            this._socket.send(buf);
        }
        console.log(`NetNode request with timeout for ${rspCmd}`);
        // 进入发送缓存列表
        this._requests.push({
            buffer: buf, rspCmd, rspObject
        });
        // 启动网络请求层
        if (showTips) {
            this.updateNetTips(NetTipsType.Requesting, true);
        }
    }

    // 唯一request,确保没有同一响应的请求(避免一个请求重复发送,netTips界面的屏蔽也是一个好的方法)
    public requestUnique(buf: NetData, rspCmd: number, rspObject: CallbackObject, showTips: boolean = true, force: boolean = false): boolean {
        for (let i = 0; i < this._requests.length; ++i) {
            if (this._requests[i].rspCmd == rspCmd) {
                console.log(`NetNode requestUnique faile for ${rspCmd}`);
                return false;
            }
        }
        this.request(buf, rspCmd, rspObject, showTips, force);
        return true;
    }

我们有2种回调,一种是前面的request回调,这种回调是临时性的,一般随着请求-响应-执行而立即清理,_listener回调则是常驻的,需要我们手动管理的,比如打开某界面时监听、离开是关闭,或者在游戏一开始就进行监听。适合处理服务器的主动推送消息。

    /********************** 回调相关处理 *********************/
    public setResponeHandler(cmd: number, callback: NetCallFunc, target?: any): boolean {
        if (callback == null) {
            console.error(`NetNode setResponeHandler error ${cmd}`);
            return false;
        }
        this._listener[cmd] = [{ target, callback }];
        return true;
    }

    public addResponeHandler(cmd: number, callback: NetCallFunc, target?: any): boolean {
        if (callback == null) {
            console.error(`NetNode addResponeHandler error ${cmd}`);
            return false;
        }
        let rspObject = { target, callback };
        if (null == this._listener[cmd]) {
            this._listener[cmd] = [rspObject];
        } else {
            let index = this.getNetListenersIndex(cmd, rspObject);
            if (-1 == index) {
                this._listener[cmd].push(rspObject);
            }
        }
        return true;
    }

    public removeResponeHandler(cmd: number, callback: NetCallFunc, target?: any) {
        if (null != this._listener[cmd] && callback != null) {
            let index = this.getNetListenersIndex(cmd, { target, callback });
            if (-1 != index) {
                this._listener[cmd].splice(index, 1);
            }
        }
    }

    public cleanListeners(cmd: number = -1) {
        if (cmd == -1) {
            this._listener = {}
        } else {
            this._listener[cmd] = null;
        }
    }

    protected getNetListenersIndex(cmd: number, rspObject: CallbackObject): number {
        let index = -1;
        for (let i = 0; i < this._listener[cmd].length; i++) {
            let iterator = this._listener[cmd][i];
            if (iterator.callback == rspObject.callback
                && iterator.target == rspObject.target) {
                index = i;
                break;
            }
        }
        return index;
    }

最后是心跳与超时相关的定时器,我们每隔_heartTime会发送一个心跳包,每隔_receiveTime检测如果没有收到服务器返回的包,则判断网络断开。

    /********************** 心跳、超时相关处理 *********************/
    protected resetReceiveMsgTimer() {
        if (this._receiveMsgTimer !== null) {
            clearTimeout(this._receiveMsgTimer);
        }

        this._receiveMsgTimer = setTimeout(() => {
            console.warn("NetNode recvieMsgTimer close socket!");
            this._socket.close();
        }, this._receiveTime);
    }

    protected resetHearbeatTimer() {
        if (this._keepAliveTimer !== null) {
            clearTimeout(this._keepAliveTimer);
        }

        this._keepAliveTimer = setTimeout(() => {
            console.log("NetNode keepAliveTimer send Hearbeat")
            this.send(this._protocolHelper.getHearbeat());
        }, this._heartTime);
    }

    protected clearTimer() {
        if (this._receiveMsgTimer !== null) {
            clearTimeout(this._receiveMsgTimer);
        }
        if (this._keepAliveTimer !== null) {
            clearTimeout(this._keepAliveTimer);
        }
        if (this._reconnectTimer !== null) {
            clearTimeout(this._reconnectTimer);
        }
    }

    public isAutoReconnect() {
        return this._autoReconnect != 0;
    }

    public rejectReconnect() {
        this._autoReconnect = 0;
        this.clearTimer();
    }
}

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的属性面板中:

运行效果如下所示:

image

小结

可以看到,Websocket的使用很简单,我们在开发的过程中会碰到各种各样的需求和问题,要实现一个好的设计,快速地解决问题。

我们一方面需要对我们使用的技术本身有深入的理解,websocket的底层协议传输是如何实现的?与tcp、http的区别在哪里?基于websocket能否使用udp进行传输呢?使用websocket发送数据是否需要自己对数据流进行分包(websocket协议保证了包的完整)?数据的发送是否出现了发送缓存的堆积(查看bufferedAmount)?

另外需要对我们的使用场景及需求本身的理解,对需求的理解越透彻,越能做出好的设计。哪些需求是项目相关的,哪些需求是通用的?通用的需求是必须的还是可选的?不同的变化我们应该封装成类或接口,使用多态的方式来实现呢?还是提供配置?回调绑定?事件通知?

我们需要设计出一个好的框架,来适用于下一个项目,并且在一个一个的项目中优化迭代,这样才能建立深厚的沉淀、提高效率。

接下来的一段时间会将之前的一些经验整理为一个开源易用的cocos creator框架:https://github.com/wyb10a10/cocos_creator_framework

118赞

mark

1赞

mark

mark

强,教科书式教学

好顶赞
作者的文章给我提供了不少思路。
我的项目只有NetNode和ISocket两个模块,NetManager因为只需要单条长连接所以没实现,ProtocolHelp被集成在底层没提出来,NetworkTip是在每个接口的调用时手动实现的。
通过作者的文章我才考虑到,这些内容都可以规范化提供接口出来。mark一下。

mark

mark

mark

mark

mark。大佬流啤

mark

战略mark

是宝爷吗,顶一个,把更多好文章搬到这里来

赞赞 mark

感谢分享。

Mark

Mark

mark

过度设计