Unity客户端网路编程要点

2021/5/30 20:53:34

本文主要是介绍Unity客户端网路编程要点,对大家解决编程问题具有一定的参考价值,需要的程序猿们随着小编来一起学习吧!

TCP长连接

TCP协议对于一般应用开发程序员来说是只可配置的部分。就是说操作系统底层已经帮我们实现了逻辑,我们只需输入参数进行配置。而在TCP协议之上的应用层,我们可以自定义多种协议,诸如如何划分包体、使用何种加密协议、使用何种序列化协议等等。

众所周知,TCP协议是面向连接、可靠的协议。可靠的要素如下:

1、序列号

        在TCP数据包的协议头会有一个序列号标记本包,序列号的生成机制是前一个包的序列号+前一个包的数据长度,即第一个包序列号为1,其长度为1000,则第二个包序列号为1001,以此类推……当顺序发送的包因为网络原因出现“后发送的先到”的情况,接收端会根据缓存中包的序列号进行重新排序,等待缺少的包,直到一段”窗口“(多个包组成一个窗口)的包都完整了,再送入应用层处理。对于应用层来说,TCP传递上来的数据已经是时序正确的包。

2、确认重传

       对于网络中出现丢包现象,使用确认重传机制。发送端为了保证接收端已经收到包,会等待某个序列号的确认应答包,但因为迟迟收不到(可能是200ms),于是再次发送之前的包,在有限次数的重传中,收到确认应答包则停止重传,发送后续包体,否则强制关闭连接,在游戏中的表现可能就是玩家被强制下线。

3、确认应答

      接收端可以是对一个数据包或多个数据包形成的一个窗口,发送确认应答包,告知发送端已经接收成功,并继续接下来的发送,如果发送端长久没有接收到某个包对应的应答包,就会出现上一条中的强制关闭连接的情况。

4、三次握手和四次握手

  建立连接时需要三次握手,断开连接时需要四次握手,是为了保证连接能够正确建立和断开。具体内容网上已经有很多了,不再展开。

扩充:TCP连接可分为长连接或短连接。短连接通常指,发送端和接收端建立连接后,进行一次或次数极少的传输后就关闭连接的一种连接类型;长连接通常指建立连接后应用运行期间一直存活的连接。长连接适合强联网的应用,短连接适合弱联网的应用(偏单机的游戏)。

        综上,TCP解决了大部分数据传输中可能出现的问题,让我们可以完全放心使用他们(当然仍有一些问题是我们在应用层需要解决的)。

Socket

        C#的Socket类是对操作系统底层socket API的封装。

        一个Socket对象,包含了本地Ip、本地端口、远程ip、远程端口、协议相关配置等信息,这些信息构成了最基本的沟通元素。ip和端口构成一个网络的终端,在C#中有一个类型专门用来描述两者,这个类是IEndPoint,定义一个IEndPoint:

IPEndPoint endpoint=new IPEndPoint(ipaddress, port);

        Socket提供的最基础的几个接口如下(具体参数省略):

        Connect(同步):向远程终端发起连接,是一种同步方法,会阻塞线程,在主线程中使用可能会造成卡顿。

        BeginConnect(异步):参数列表中需要提供一个回调方法,在连接完成时,调用该回调,并在回调方法中调用EndConnect(不是结束连接的意思,而是表示连接建立完成)。

        EndConnect:在使用BeginConnect的回调中调用,表示连接成功建立,可以进行下一步操作了。需要注意的是,EndConnect可能抛出异常,我们需要用try……catch块将其包裹起来。

        Send(同步):向远程终端发送消息——实际上是先向本机的发送缓冲区里写入需要发送的信息,等发送缓冲区满了或者操作系统规定的发送时间阈值到了(这个时间通常是比较小的,在发送频率不高的程序里,理论上可以忽视发送缓冲区的存在,当然发送缓冲区可能还会引发一些其他问题,不能完全忽视),就将发送缓冲区的数据发出去。

        BeginSend(异步):Send的异步方法,同样是向发送缓冲区送入数据,与BeginConnect一样,需要在参数列表里提供一个回调方法,并在回调中调用EndSend,表示发送完成。

        EndSend:与EndConnect同理。

        Receive(同步):从接收缓冲区中取数据,如果接收缓冲区中没有数据,就阻塞线程。

        BeginReceive(异步):基本上可以略过了,和上面两个BeginXXX的用法相似。

        EndReceive:与EndConnect同理。

        ShutDown:关闭发送或接收的某个通道或者关闭两者。

        Close:关闭套接字,即关闭连接,并回收套接字,在客户端需要断开连接时,可直接调用该方法。

        Disconnect:关闭套接字连接并允许重用套接字。

        BeginDisconnect:Disconnect对应的异步方法。

        以上是客户端常用的API,服务器独有的API则有Accept,用于监听新的连接,为此创建套接字。

 

注:Socket中的异步方法,都是需要BeginXXXEndXXX成对出现的,必要求应用程序自行处理抛出的异常——网络环境中什么样的情况都可能出现。当然,我们也可以完全不用Socket提供的异步方法,而是另起两个线程,去做Send和Receive的工作。另起线程的方法,在代码逻辑方面可以更加清晰,一般情况下完全够客户端使用。关于Socket的异步API,实际上还有SendAysnc和RecieveAysnc,ConnectAsync等异步方法,我对此没有研究,读者希望更进一步可以看微软文档https://docs.microsoft.com/zh-cn/dotnet/api/system.net.sockets.socket.sendasync?view=netframework-4.8 。

 

发送/接收缓冲区

        发送缓冲区:操作系统会将需要发送的数据先送入发送缓冲区,等发送缓冲区满或者系统规定的时限到了,就将发送缓冲区的数据发送出去。这么做的目的是为了防止频繁的发送小体量的包,对于一个TCP包来说,如果包数据少,那么协议头就会占整包的大头,这样对带宽是严重的浪费,设置发送缓冲区可以提高效率。如果发送缓冲区过小,那么TCP包不可避免的就会很小,这样极端的情况是可能发生的。如果出现这种情况,一条完整的数据会被切割成多份TCP包,这个问题需要使用长度信息法来解决。可以跳到下面“应用层协议”模块,查看相关的方案。

        接收缓冲区:与发送缓冲区类似,当应用层调用Receive方法时,对应长度的数据就会被提取出来。对于很小的接收缓冲区,将会在应用层,用长度信息法还原成一个完整的数据。

客户端异步收发

        “Unity不支持多线程”,这个说法是错误的,准确来说,是Unity的API不支持多线程

        使用多线程就要注意线程间的资源竞争,需要注意线程同步的问题,大量的使用锁也会影响性能。所以Unity在设计上,就尽量杜绝程序员使用多线程访问Unity内部的数据。而且游戏运行在一个主循环中,多线程无法保证游戏运行到某一帧后能获得正确的执行结果,多数游戏引擎都会在主线程中使用一个游戏循环,来处理每帧需要执行的内容。

        那该如何使用多线程呢,答案是线程执行的内容只要不去访问Unity内部资源即可。网络中的数据收发就很适合使用多线程(当然,可以直接使用C#Socket提供的Begin/End异步接口或XXXAsync的异步接口来做),或者使用C# Thread来实现多线程。

 

        既然要做到和Unity API分离,那主线程和网络数据发送线程/网络数据接收线程如何通信呢?对于发送线程,通常使用一个队列(Queue)结构,在主线程中将需要发送的数据压入队列中,发送线程在定时(Thread.Sleep)循环中检查队列是否有数据,有则弹出数据。因为有两个线程要访问这个发送队列,所以每次访问队列时,都要lock一下这个队列资源,以保证线程同步。而对于接收线程,则无需维护一个队列,仅仅是将接收到的完整数据送入主线程中即可。关于C#多线程/锁的使用,读者可自行搜索,这里不再赘述。

Poll方法、Select方法

        Poll和Select方法都是Socket提供的同步方法,可以避免线程阻塞导致进程卡死。

        Poll方法,在Receive消息之前,先判断缓冲区是否有数据可读,如果有,则开始调用Receive,否则略过接收。这样就不会因为接受缓冲区无数据而陷入无尽等待中。可以设置等待时间。

        Select方法与Poll方法类似,不过,它可以同时检测Socket的三种状态:是否可读,是否可写,是否出错,并输出三种类型的socket列表,以供程序使用。可以设置等待时间。

应用层协议

1、包体划分和大小端问题

        一个TCP包在发送缓冲区成形后,它其实可能包含多条完整的数据,也可能仅有一条完整数据的部分。对同一台设备来说,这取决于应用层所发送的数据之大小。为了能够完整的提取数据,需要用一个长度信息标记应用层包的大小,这个长度信息本身会占有几个字节(2-4个通常够用了),但我们不会把长度信息(header)计入总长度(body)。

        比如,我们约定好长度信息占2个字节,实际它最多能表示包体有2^16(65534)的长度。每每收到数据,在保证上一条数据完整接收后,会先识别后面数据的前2个字节,并将其还原成长度信息。后面的数据读取了该长度的字节后,即表示一条完整数据读取完毕,如此递归。

        所以应用层包体的第一部分就是长度信息,后面部分则是正文。正文又可分为协议号和具体的数据对象。

        什么是具体的数据对象?对客户端和服务端来说,他是一个数据类对象,如客户端定义了一个MoveMsg的类,该类包含了玩家位移的一些信息,我们会将这个类对象“序列化”成字节流(字符串),这个字节流就是我们的数据对象。服务端必定也会有一个对应的MoveMsg,能够将这个字节流还原成一个MoveMsg,以供程序使用。光看这个数据字节流,不论是客户端或者服务端恐怕都不能识别它对应的是哪个协议数据类,于是就需要一个协议号标识。比如MoveMsg对应一个协议号111,客户端发送数据的时候就会在正式的数据字节流前面加几个字节(通常也是2-4的长度)的协议号。服务端接收数据后,就去客户端和服务端共用数据表里查,发现111对应了MoveMsg,就会把后面的字节流解析成对应MoveMsg数据类对象。

        长度,协议号,正式数据组成了一个应用层包体。

        在解析或设置长度信息时,需要注意大小端的问题。什么是大小端问题?这是设备差异导致的,对于多字节类型(如int,uint,short,ushort等),不同设备可能对其内存有不同的排布方式。比如ushort类型的1,有些设备排布成“00000001 00000000”,另一些设备会排布成"00000000 00000001",也就是字节从低位到高位的顺序是完全相反的。大端就是“高位字节在低地址,低位字节在高地址”,对于小端就是“低位字节在低地址,高位字节在高地址”。为了解决大小端问题,我们需要指定标准,前后双端统一使用小端或大端。为了判断本机是大端编码还是小端编码,可以使用BitConverter.IsLittleEndian,如下:

        MsgLengthType length = (MsgLengthType)packBody.Length;
        byte[] lenBytes = BitConverter.GetBytes(length);
        if (!BitConverter.IsLittleEndian)//大小端问题,统一改为小端编码
        {
            lenBytes.Reverse();//不是小端,就翻转表示长度的字节数组
        }

        设置协议号,为了与后面的正式数据字节流区分,可以在协议号后面加个分隔符(如果用分隔符就要注意正式数据字节流中不能出现相同字符,或者你有解决方案也可以这么用)。更好的方法就是如长度信息一样,约定好多少长度的字节是协议号,前后端就按这个长度来提取协议号,如果用的是这个方法,同样要注意大小端问题。

2、正式数据的格式

        如前文所述,正式数据通常是一个类对象的序列化数据。常用的序列化协议有XML协议/Json协议/Protobuf协议。在C#中,上述三个协议都利用了反射机制来实现序列化和反序列化,对于需要序列化的类,需要加上Serializable的标签。反射要讲起来内容就多了,在此就不多说了。

        我们可以不用上述三种协议,且不适用反射,自定义一种序列化/反序列化协议。当然上述三种协议都是成熟的协议,功能强大应用广泛。这里自定义的协议只为了简化说明,没有任何实用的价值。

        首先,定义一个网络数据类的基类:

public abstract class MsgBase
{
    public abstract string Pack();//将数据类序列化成一个字符串/字节流
    public abstract void UpPack(string msg);//将一个字符串/字节流反序列化成一个数据类
}

        举例一个MoveMsg:

public class MoveMsg : MsgBase
{
    public string desc;
    public Vector3 targetPos;
    public override string Pack()//用逗号隔开数据字段
    {
        return string.Format("{0},{1},{2},{3}", desc, targetPos.x, targetPos.y, targetPos.z);
    }

    public override void UpPack(string msg)//将字符串按逗号拆分,还原成数据字段
    {
        string[] splits = msg.Split(',');
        desc = splits[0];
        targetPos.x = float.Parse(splits[1]);
        targetPos.y = float.Parse(splits[2]);
        targetPos.z = float.Parse(splits[3]);

    }
}

        定义一个测试类:

public class TestClass
{
    public void Test()
    {
        MoveMsg moveMsg = new MoveMsg()
        {
            desc = NetManager.GetDesc(),//这个desc字段是Ip+端口号的信息,用于标识本机
            targetPos = Vector3.zero
        };
        //序列化
        string str=moveMsg.Pack();//获得字符串
        var bytes=System.Text.Encoding.Default.GetBytes(str);//转成字节流
        //反序列化
        MoveMsg moveMsg_Get = new MoveMsg();
        moveMsg_Get.UpPack(System.Text.Encoding.Default.GetString(bytes));
    }
}

        这是一种非反射的序列化方案,为了达到通用性,不至于每加一个协议,就要写全新的Pack/UnPack方法,就需要另外写个工具,根据类中的字段自动生成Pack/UnPack方法。通常来说,用现有的序列化协议就可以了。

拓展

1、帧同步和状态同步,区分方法很简单。当我们把大量的计算放在客户端,服务器做的最多的是转发工作,那么它就是帧同步;当我们把核心的逻辑和计算放在服务器,客户端仅作为表现层来使用,这就是状态同步



这篇关于Unity客户端网路编程要点的文章就介绍到这儿,希望我们推荐的文章对大家有所帮助,也希望大家多多支持为之网!


扫一扫关注最新编程教程