【C#】游戏网络模块的设计与实现

游戏的网络模块其实有很多可以深挖的技术、细节供大家讨论,本文仅作为笔者的经验总结与记录,对在Unity中如何开发一个快速可用的网络模块进行介绍与记录。我希望能在需要使用时参考这篇文章就能快速搭建一个简易基础的网络模块,如果能帮到读者就算这篇文章有了大作用了。

游戏的网络通常分为了短链接与长链接两种。

短链接:以HTTP通讯为基础,由客户端发起向服务器请求数据。

长链接:大多情况下以TCP通讯为基础,在客户端与服务器之间构建稳定链接,以便于相互传输数据;具体通讯协议依据需求可以选择可靠UDP等其他通讯协议。

在Unity的实现

注意事项

因为我们使用Unity开发游戏或应用,都不希望因网络发送或接收数据导致界面出现卡顿,所以常见的在主线程调用同步发送方式就不属于我们的选择范围。

我们需要尽可能采取各种异步的形式,既能够向服务器发送或请求数据,又不影响主线程的运行。

短链接

Unity中实现短链接是非常简单的,因为Unity内部封装了UnityWebRequest方法,极大简化了我们发送HTTP请求的复杂度。

客户端通过HTTP向服务器请求数据,一般分为GETPOST两种方式。对应这两种方式,Unity提供了UnityWebRequest.PostUnityWebRequest.Get静态方法,以创建UnityWebRequest实例,或者也可以依据自身需求,通过调用UnityWebRequest的构造方法来创建实例。

接下来我将着重讲述POST数据通过UnityWebRequest如何实现(因为GET参数外露,太容易被破解了,所以项目中也很少再使用它)。

首先是UnityWebRequest的构建与数据发送:

UnityWebRequest webRequest = new UnityWebRequest(url, "POST"); // 初始化使用POST的UnityWebRequest,并附上目标地址

webRequest.SetRequestHeader("", ""); // 设置HTTP的头信息

// 初始化UnityWebRequest的downloadHandler与uploadHandler
webRequest.downloadHandler = (DownloadHandler) new DownloadHandlerBuffer();
webRequest.uploadHandler = (UploadHandler) new UploadHandlerRaw(data);
webRequest.uploadHandler.contentType = "application/x-www-form-urlencoded";

var asyncOperation = webRequest.SendWebRequest(); // 数据发送

在调用了UnityWebRequest.SendWebRequest之后,会得到一个UnityWebRequestAsyncOperation类型的实例。作为继承自AsyncOperation的类,UnityWebRequestAsyncOperation除了能表示请求的进度以及是否结束外,还附带有创建了本次请求的UnityWebRequest的实例的引用。后续数据的接收方案都基于此。

短链接的接收方案1——completed回调

当调用了UnityWebRequest.SendWebRequest之后,可以对得到的UnityWebRequestAsyncOperation类型实例内的completed事件添加行为,以期在收到服务器返回消息后能够立即处理接收到的数据。

asyncOperation.completed += (a) =>
{
    // 将事件传入的 AsyncOperation 转换为 UnityWebRequestAsyncOperation,
    // 并从webRequest的downloadHandler中获取下载的数据
    Do(((UnityWebRequestAsyncOperation)a).webRequest.downloadHandler.data);
};

短链接的接收方案2——Update

当调用UnityWebRequest.SendWebRequest获取到UnityWebRequestAsyncOperation类型的实例后,可以将该实例加入一个队列。依托网络模块的心跳(Update方法,这里的网络模块指你自己实现的网络架构,如何让它的Update执行起来由你说了算),在每次心跳时检测队列内的请求是否完成,并对完成的请求执行后续操作。

// 存放 UnityWebRequestAsyncOperation 的列表
private List _asyncOperations = new List();

public void Update()
{
    for (int i = 0; i < _asyncOperations.Count; i++)
    {
        var asyncOperation = _asyncOperations[i];
        if (!asyncOperation.isDone)
        {
            // 请求未完成,则略过后续处理逻辑
            continue;
        }

        // 从webRequest的downloadHandler中获取下载的数据并处理
        Do(asyncOperation.webRequest.downloadHandler.data);
        asyncOperation.Dispose();
        // 移除已处理完的请求
        _asyncOperations.RemoveAt(i--);
    }
}

需要说明的是,处理完下载数据后一定要对asyncOperation执行Dispose操作,否则Unity很可能会有Native数据泄露的报错。初步判定这是Unity对UnityWebRequest管理导致的报错。

UnityWebRequest的结果

上述代码并没有加入处理网络错误的内容。

UnityWebRequest对于错误的处理,主要是识别UnityWebRequest.result的值。其本质是一个枚举,具体如下:

枚举值 含义 解释
InProgress 请求尚未结束 -
Success 请求成功 -
ConnectionError 与服务器通信失败 例如,请求无法连接或无法建立安全通道
ProtocolError 服务器返回一个错误响应 请求成功地与服务器进行了通信,但收到了连接协议定义的错误
DataProcessingError 数据处理错误。 请求成功地与服务器通信,但在处理接收到的数据时遇到了错误。例如,数据已损坏或格式不正确

长链接

大多数情况下,我们游戏开发的长链接都是建立在TCP协议之上的,所以在Unity的实现中,主要用到的就是Socket

基于异步的需求,我们大体有两种方案实现:

  1. 使用C#的SocketBeginEnd方法实现连接、发送、接收,如BeginConnect/EndConnectBeginSend/EndSendBeginReceive/EndReceive
  2. 利用多线程,开启单独的线程进行Socket的连接、发送、接收功能,主线程与网络线程之间采用环形Buffer实现数据交互。

方案1——Begin/End方法对

对这些方法对的解释,建议直接查看MSDN上的解释:Socket 类 (System.Net.Sockets) | Microsoft Docs。下面着重讲数据发送与接收的方案。

数据发送,因为采用了异步方案,所以整体难度不大,只是在EndSend调用时需要判断返回的已发送的字节数是否与BeginSend发送的一致,以及会否try-catch到异常。一旦发现数据发送异常(实际发送数量小于开始发送的数量,或捕获到了异常),就需要考虑做断线处理。

public void Send(byte[] data)
{
    _socket.BeginSend(data, 0, data.Length, SocketFlags.None, OnSend, _socket);
}

private void OnSend(IAsyncResult result)
{
    try
    {
        Socket socket = result.AsyncState as Socket;
        int sendLen = socket.EndSend(result);
        // 验证发送数据长度
    }
    catch (Exception e)
    {
        // 关闭Socket
    }
}

数据接收的方案有很多,我所才用的方案需要与底层数据协议相关联。

底层数据协议

字节数 4 byte n byte
含义: 数据长度 数据体
详情: 存储数据体的长度n,因占用4字节而存储为Int型 具体的数据体,可以是一个PB协议序列化后的内容,也可以是PB类型id与PB数据的组合

基于上面的协议,就可以给出代码:

// 数据缓存buffer
byte[] buffer = new byte[1024];

void BeginReceive()
{
    // 开始接收数据长度
    _socket.BeginReceive(buffer, 0, 4, SocketFlags.None, OnReceiveHead, _socket);
}

void OnReceiveHead(IAsyncResult result)
{
    try
    {
        Socket socket = result.AsyncState as Socket;
        int sendLen = socket.EndReceive(result);
        // 将接收到的数据长度转化为int值
        int bodyLen = BitConverter.ToInt32(buffer);
        // 开始接收数据体
        socket.BeginReceive(buffer, 0, bodyLen, SocketFlags.None, OnReceiveHead, _socket);
    }
    catch (Exception e)
    {
        // 关闭Socket
    }
}

void OnReceiveBody(IAsyncResult result)
{
    try
    {
        Socket socket = result.AsyncState as Socket;
        int sendLen = socket.EndReceive(result);
        // 对接收到的数据进行处理
        BeginReceive(); // 继续开始接收下一条数据
    }
    catch (Exception e)
    {
        // 关闭Socket
    }
}

方案的具体执行在上面的代码中已给出,虽然缺少了很多异常的验证以及拼包的操作,但是上面的代码已经能够说明我们方案的思路

这个方案就是借助了Socket的异步操作可以指定接收数据长度的特性,将每次数据接收分为了两部分:

  1. 接收数据的长度信息
  2. 依据已经接收到的数据长度信息,继续接收指定长度的数据体

这两部分构成了一条完整数据的接收流程,每一步完成后再继续执行下一步,直到socket主动关闭或收到异常信息(可能是服务器主动断开连接)。

方案2——多线程

除了利用Socket所提供的异步方法,我们还可以将它的同步方法用在非主线程中。

网络上关于Socket的同步方法实现发送与接收的文章已经很多了,这里不做赘述,只是提醒这一部分我们需要仔细设计的是环形队列的部分。

PS:大多数使用多线程的网络方案,主要是为了减轻主线程的计算、序列化、反序列化的压力。一旦要考虑这些,就说明当前的使用场景是一个与服务器进行高频互动的场景。

环形队列本身可以参考linux中的实现,只是需要我们依据项目需求去设定队列的长度,只有有了合适的长度才能去使用无锁环形队列以帮助我们提升效率。

发送、接收分别采用两个环形队列。

因为多线程收到的数据都会直接放到接收队列中,所以需要网络模块的心跳介入,每一帧都去处理接收队列中的数据(虽然我们可以采用委托的形式,绑定Action以便于收到数据后立即执行,但是因为多线程中牵扯到主线程内Unity相关的运算大多是不支持的,所以就需要在主线程内读取数据并处理,最合适的地方就是网络模块的心跳方法)。

C#中socket的异步用法与注意事项

我的项目中的长链接使用了方案1,所以有一些开发当中遇到的坑,在此记录并警醒。

异步操作在多线程内执行

C#中socketBegin方法的回调,是在一根单独的线程内执行的。这就引入了一些与多线程相关的问题

比如:当我们将异步接收的数据放入队列,而Unity又在心跳内从队列中拿取数据,这就牵扯到了多线程对队列资源的争抢,需要考虑线程安全问题与解决方案。

再比如:当我们在连接成功的回调内调用函数X时,这个X可能也会在主线程内调用,这时就凸显出函数X内线程安全的重要性。我们项目中的一个bug就与此相关,就是因为我们在这个X函数中使用了一个公共的缓存数组,导致这个函数X被Unity主线程与socket的回调线程同时调用,出现了意想不到的结果。

关闭socket与异步操作的时序

当关闭socket时,我们依此调用了socket的ShutdownDisconnectClose方法,而此时我们的BeginReceive还处于阻塞状态。因为BeginReceive多线程实现的原因,我们的Close方法执行过程内并不会立即执行BeginReceive的终止操作,这就导致我们在Close调用后的逻辑可能会先于BeginReceive的中止执行。也是因为这个原因,当我们在调用Close之后立即执行SocketConnect以期建立一个新链接时,却受到了链接断开的结果(因为我们的代码中当BeginReceive终止时执行断开链接操作)。


以上,就是我对于一个简易而又基础的网络模块的整理与总结。

你可能感兴趣的:(【C#】游戏网络模块的设计与实现)