Unity3D高级编程主程手记 学习笔记六:网络通讯
1.C#实现TCP
1.1 实现所需API
C#提供了TCP的Socket连接API。一般的游戏项目我们不会使用阻塞方式连接和接收。因为我们不会让游戏卡住等待传输链接,大多数情况下我们还是会使用更加平滑的异步操作作为网络连接和收发的操作。常用的API如下:
BeginConnect : 开始连接
BeginReceive : 开始接收信息
BeginSend : 开始发送数据
BeginDisconnect :开始断开
Disconnect : 立即断开连接
上述前四个接口都是异步的,开启后会调用一个线程来工作,最后一个为同步阻塞方式断开连接。最后一个一般在游戏退出时调用,确保强制退出时不会发生崩溃。
1.2 线程锁
实际的网络模块中,所有的操作都会以线程级的形式对峙,而Unity中大部分的API都是在主线程上运作的,这里就涉及了主线程和子线程对资源抢占冲突导致需要线程锁的问题。所有的网络模块线程操作时都应当执行线程锁操作。
我们以网络接收数据的线程来举例,接收线程在接收到网络数据后将数据推入队列里,在push操作上就需要执行锁操作:
// Push lock(obj){ mQueue.Push(data); } //Pop lock (obj) { data = mQueue.Pop(); }
1.3 缓冲队列
网络收发时,会源源不断地发送和接收数据,很多时候,程序还没处理好当前的数据包,就有许多数据已经从服务器传送到了客户端。发送数据也一样,会瞬间积累很多需要被发送出去的数据包,这些数据包如果没有保存好,则无法进行重发甚至还会丢失,所以我们需要用一个队列进行存储和缓冲,这个队列叫做缓冲队列。由于TCP本身就自带数据包校验和重发的功能,因此我们保存数据包的主要是为了在断线重连时能够重发数据包。
发送队列比较简单,不会遇到多线程问题,下面介绍接收队列。一般会让负责接收的子线程将接收好的网络数据包放入接收缓冲队列,再由主线程通过Update轮训去检查是否含有数据包,有则一个个地取出来处理,没有则继续轮训等待。
// 伪代码 /// 接收队列线程等待接收数据并推入队列 try { socket.BeginReceive (Receive_Callback); } catch{ Log(Error); } void Receive_Callback(IAsyncResult _result){ PushNetworkData(_result); //将数据推入队列 Receive(); //继续接收数据信息 } /// // 主线程处理数据队列里的数据 void Update(){ while( (data = PopNetworkData())!= null){ DealNetworkData(data); } }
1.4 双队列结构
缓冲队列是多线程编程中的常见手段之一,不过效率还是不够高。多个线程会因为线程锁的限制而卡住,导致其他线程无法工作。双队列结构就能够很好的解决这个问题。
与缓冲队列相比,区别在于:在轮训时,会先将接收线程接收到数据时直接推入接收数据的队列,并清空接收数据的队列,然后主线程会对复制后的数据队列进行处理,这时子线程无须等待主线程的逻辑处理时间就能继续接收数据。
1.5 发送数据
发送时也有发送队列,当发送的数据包很多时,也有可能短时间内会累计过多数据包而导致发送池溢出。如果发送的数据包都是很小很小的数据包,每个数据包都发送一次,等待接收后再发送,这会导致发送速度过低,发送速度慢。如果一下子全发送,发送的数据包会过大,容易发送失败和丢包。
解决办法:合并发送数据(并包)
1)每当调用发送接口时,先把数据包推入发送队列,发送程序就开始轮询是否需要发送的信息在队列,有就发送,没有就继续轮训。
2)发送时合并队列里的一部分数据包,这样可以一次性发送多个数据包,提高效率。
3)对合并操作进行限制,最多只合并10K数据,避免数据包过大。
1.6 协议数据定义标准
协议负责制定了客户端与服务器的交流方式。加入两边都使用了JSON通讯,双方才能根据协议格式知道传输了什么信息,以及自己信息是如何传送给对方。
选择项目协议时需要注意的几点:1.选择客户端和服务器都能接受的格式。2.数据包最小化,选择尽可能小的包。3.要有一定的校验能力,包之间在传输时可能会有粘包问题,也是我们需要解决的。或者说接收到了错误的被篡改的。因此需要一定的校验能力
为了实现校验工作一般用以下的方法:
1.MD5校验: 这种方式是将数据块整个用MD5散列函数生成一个校验字符串,将校验字符串保存在数据包。接收时将md5码与数据包的解码对比,一致则校验通过。
2.奇偶校验: 与MD5码类似,只是使用的函数不同,对数据包的数据进行异或得到一个变量。
3.循环冗余校验: 这是利用除法和余数的原理来进行错误检测的。将接收到的数据组进行除法运算,如果能被除尽,说明数据校验正确,如果不行则说明数据被篡改过。
4.加密: 为了保证网络数据包不被黑客篡改,我们需要加密操作。常见的有RSA,公匙非对称加密算法等。其中最简单的加密方式就是对数据进行“异或”处理,由于两次“异或”能够让数据回到原型,所以很方便。还有就是使用非对称加密的方式,前后端分别使用不同的密匙进行查看。
1.7断线检测
为了能有效的检测TCP连接是否正常,需要服务器和客户端共同达成一个协议来进行检测。通常把这个包称为心跳包协议。
心跳包协议中,每几秒服务器向客户端发送一个心跳包,其中包含了服务器时间,状态等少量信息。接收到心跳包的客户端也会发送一个心跳回应包。当30秒之内要是服务器没有接收到客户端的包或者客户端没有接收到服务器的包就认为网络已经断开,这是客户端最好重新登录。
2.C#实现UDP
UDP自己不会检验重发,且由于数据包的发送模式,丢包概率大,数据包接收顺序也有不确定性。可以说如果UDP不加入任何修饰是无法在游戏项目中直接使用的。
1.确认连接机制
UDP是无状态连接,没有三次握手协议,我们为了判断是否连接成功我们可以模仿TCP的连接过。
// 1.使用API建立UDP连接 // 使用C#的 UDP接口对指定的IP和端口打开连接 SvrEndPoint = new IPEndPoint(IPAddress.Parse(host),port); UdpClient = new UdpClient(host,port); UdpClient.Connect(SvrEndPoint); // 2.启动接收数据线程 代码如下 UdpClient.BeginReceive(ReceiveCallback, this); void ReceiveCallback(IAsyncResult ar){ Byte[] data = (mIPEndPoint==null) ? UpdClient.Receive(ref mIPEndPoint) : UdpClient.Endceive(ar, ref mIPEndPoint); if(data!=null){ OnData(data); } if(mUpdClient !=null){ // 尝试接收信息 mUpdClient.BeginReceive(ReceiveCallback,this); } } // 3.UDP是无状态连接,打开连接后可以立即开始接收数据的接口并开启线程 SendConnectRequest(); StopSendNormalPackage(); StopReceiveNormalPackage(); // 4. 等待握手数据包接收到后,就说明连接建立成功 void ProcessNormalData(data){ if( !IsConnected) return; DealNetworkData(data); }
UDP是无状态连接,不能靠自己判断连接是否断开。检测方式与之前介绍一致,都是使用心跳包机制。
2.数据包校验和重发机制
如果没有校验和重发机制,我们就不知道数据包发送是否到达,丢失了也无法重新发送。因此我们要重新编写。
我们可以模仿TCP的校验和重发机制,将它照搬到UDP上,并进行改进,使得UDP既有速度又具有可靠性。传输过程采用累计重传机制作为重发机制。改进细节如下:
1)A端向B端发送数据包,数据包包含Seq=1(表示数据包的发送队列),发送后将此数据包推入已经发送但还没有确认的队列中。
如果B端接收到了Seq=1的数据包,就回应客户端一个确认包,包中Ack=1,表示Seq=1的包已经确认收到。
如果B端没有接收到数据,客户端x秒后发现仍然没有收到Seq为1的确认包,则判定Seq=1的数据包传输失败,从已经发送但未确认的数据包队列中取出Seq为1的数据包,重新发送。
2) 例如A端向B端发送了10个数据包,分别是Seq =1、2。。10.服务器收到了1,3,4,5,7,8,9,10,。 2和6没有收到确认。
A端在等待确认包超时后对2和6进行重传。在B端收到数据包后,处理数据包时,如果数据包顺序由跳跃的现象,就说明丢失,等待A端重传,这是就断开的序列处停止处理数据包,等待重传数据包到来。
3)B端也可以设置加快重传速度,例如A传入 1,2,3,4,5。但是B只接收到了1,3,4,5。此时当B在接收3时发现2被跳过了1次,接收4时发现2被跳过了2次。此时立即发送信息给A让其重传2,这就加快了重传速度。
3.丢包问题分析
UDP丢包是很正常的现象,因为UDP牺牲了质量提高了速度。UDP造成丢包的主要原因由一下,需要注意:
1)接收端处理时间过长导致丢包,可以使用双队列解决
2)发送的数据包大,导致丢包概率加大,可以通过限制包大小解决,一般MTU限制在1280byte
3)发送包频率太快,可以使用缓冲队列解决
3.封装HTTP
HTTP在游戏圈又称为短连接,因为其平均连接时间短,不受前端控制。在Untiy中使用HTTP协议可以使用UnityWebRequest,HTTP是一种请求/响应式的协议。也就是说请求通常是由浏览器这样的用户代理发起的,在用户接收到服务器的响应数据后,通过这些数据处理响应的逻辑,再反映到画面上的,其特点都是一一对应的,一个请求有仅有一个响应。
HTTP是应用层协议,要求底层的使用协议是可靠的也就是TCP协议。
HTTP协议版本,HTTP2.0与HTTP1.1差别较大,且两个版本不能兼容,所以在HTTP世界被分成了两块,一块是HTTP2.0运用到HTTPS上,另一块是HTTP1.1运用到HTTP上。
HTTP:在TCP连接上,即使后面的请求处理完了,也必须等待前面的应答发送完毕,才能发送后面的应答。数据都以字符串的形式存储,这样会导致协议传输和解析效率不是很高,不过可以提升压缩效率,但是协议头无法压缩。
HTTPS: 引入了Stream概念,这使得一个TCP连接可以被多个Stream共享,每个Stream上都可以运行单独的请求与应答,从而实现TCP连接的复用,即单个TCP连接可以并行传输多个请求与应答数据。同时协议采用文本协议替代了二进制协议,使得协议头也可以被压缩,减少了数据开销。此外还可以支持服务端主动推送,进一步减少交互流程。还支持流量控制,并引入了流的优先级和依赖关系,这使得我们能够对流量和资源进行更加细致的控制。
HTTP的无状态连接:
无记忆性指的是对于事物处理没有记忆力,在处理HTTP请求时,只关注当先这个连接可获得的数据,不会去关心也没有记忆去关联上一次请求的参数数据。
HTTP每次请求访问结束都有可能断开连接:
Http使用Content-Length来判断信息发送多少,当发送完毕时会主动请求断开连接。有什么方法可以保持一直连接呢?使用Keep-Alive标识。但Keep-Alive在网络波动时会无法判断是否在连接,所以游戏项目一般不会打开Keep-Alive。
Unity中的HTTP封装:
Unity现在使用的是UnityWebRequest封装了Http协议。其中有一些重要的接口:
// 该接口用来创建一个带有地址和Post数据的UnityWebRequest Post(string url,WWWForm postData) // 用来开始发送请求和迭代请求 SendWebRequest() // 该接口可以设置HTTP的标签头 SetRequestHeader(string name, string value)
如何解决多次请求时连续发送HTTP请求引起的问题?
多次或者连续发送HTTP在项目中很常见,大量的HTTP可能会导致服务器返回数据是不知道哪个在前哪个在后。当接收响应数据无法确定时,我们还使用顺序接收数据的方式处理就会发生异常。
方案 1)多个连接同时发送请求,等待所有数据到齐后再调用执行逻辑:只提高了网络效率,无法解决逻辑顺序问题。
方案 2)逐个发起请求,保证顺序:解决了顺序问题,但是降低了网络连接效率。
方案 3)多连接与逐个发送混用:既提高了效率又提高了速度,但是多连接导会导致频繁的连接和断开,且逻辑层次会不清晰。
方案 4)合并请求,并逐个发送合并后的请求包:效率,速度都提高,还减少了连接数。
4.网络数据协议原理
4.1 JSON
JSON本來是JavaScript的对象表示法,用于存储和交换文本信息的语法,类似于XML但是又比XML更小,更快,更容易被解析。JSON文本的MIME类型是application/json,包含文本,图像,图片等专用数据。
4.2 自定义二进制数据流协议格式
使用这种方式就需要客户端与服务端共同制定一个标准。定义的细节可以参考JSON,XML来自定义数据结构。
4.3 MessagePack
这是一种介于JSON与自定义二进制数据流的格式,“It's like JSON,but fast and small”,与JSON相同,采用了Key-Value形式的映射。但是不同的是MessagePack采用了二进制流的形式用来传输。
4.4 Protobuf
采用了message的方式,可以将数据打包成熟悉的样子,通过传送自己的文件实现序列化和反序列化。
// 不采用 Key-Value的方式存储数据 message LoginReqMessage{ required int64 acct_id = 1; required string passwd = 2; }
这种方式是最小,速度最快的,缺点就是使用还没有那么广泛,新手上手需要一定时间。
5.网络同步解决方案
网络游戏中,实现同步的方法有三大类:状态同步,实时广播同步,帧同步。
在开发时,我们可以多种同步方式一起使用,例如魔兽世界这种MMORPG,绝地求生这类战术FPS就采用了状态同步和实时广播这两种方案。传奇这类有严格的寻路同步机制,所以只使用了状态同步,王者荣耀这类竞技性很强的游戏则使用的是帧同步。
1.状态同步:
为什么要状态同步?我们如果每帧都将自己的信息同步给所有人,那么需要传输和广播的信息量就很大,因此我们需要尝试更加节约流量的方式。只以状态信息作为同步信息就是很好的办法。
我们可以把每个人的行为方式抽象成若干状态,每个角色的状态就相当于一种固定的行为模式,这种固定的行为模式就像一个黑盒,只要给到需要的数据,就能表现出相同的行为。现在让这些状态连贯起来拼接成一个拥有一系列动作的角色,当我们向这个角色发送各种各样的指令时,就是在告诉它应触发这个状态。
在状态同步中,服务器扮演了幕后操纵者的角色,世界中的物体只有在经过服务器的同意之后才能进行状态切换,当然也不是所有都需要经过服务器同意,为了让玩家在糟糕的网络环境下也能看到流畅的游戏画面,在制作网络同步逻辑时,可以让玩家随意操控自己的角色,不受服务器延迟指令,在稍后进行服务器端效验时再对玩家进行校正。最明显的就是,自己可以移动,但是经过服务器发来的正确数据后,玩家瞬移回了之前的位置。
2.实时广播同步:
在一些FPS类型的竞技游戏中,人物的移动速度频率变换快,想要模拟玩家的移动旋转,就要实时更新这些信息,这样状态同步就不满足需求了。不过除了移动旋转外的信息还是可以使用状态同步的方法。每个玩家会在1秒内向服务器发送15-30次自身移动和旋转数据,目的是让其他玩家在收到广播数据时,能更加顺畅地模拟玩家在游戏中移动旋转的表现,也只有这样,才能让其他游戏客户端不断地更新玩家的位置、移动速度和旋转角度。不过如果只是单纯的更新位置和旋转信息,会导致玩家在屏幕中不断得闪跳,因此可以用速度的方式表示他们移动,会让角色运行自然些。
3.帧同步:
对于竞技类游戏这类需要精准控制移动,同步角色状态和同步检验的游戏,实时广播同步方案就没办法满足需求了。
帧同步与实时广播同步不同,帧同步的逻辑不再由客户端本身的逻辑帧Update来决定,而是转由从网络收到帧数据包来驱动执行逻辑更新,这也是帧同步最大的特点。所有的逻辑都放在帧数据包中,包括了角色的移动,攻击,释放技能等,每收到一个服务器发送的帧数据包,就更新一帧或更新前面因延迟累积的帧数。
帧同步的服务器需要向客户端每秒发送15-30个帧数据包,且即使没有信息也要发送空包。因为客户端要根据数据来验算游戏。客户端中角色每移动x米的逻辑转移到了从网络中收到数据包时,每收到一个帧数据包,角色就调用一次移动逻辑。
所以,客户端的执行步骤是:客户端不断地收到从服务器广播的帧数据,每帧都执行一次更新逻辑,执行到某一帧带有指令数据时就执行该帧内的所有指令,同时也更新逻辑。
比如:帧数据指令为某角色以每帧1米的速度向前移动,那么客户端就启动移动状态执行该指令,在接下来收到的数据帧中,客户端每执行一次逻辑更新,就会执行一次每帧一米的逻辑,比如后面总共收到的20帧的网格空数据帧,那么久执行20次每帧1米的行走逻辑,直到玩家在此操作停止移动指令,并把该数据发送给服务器,服务器再以帧数据的形式广播给所有玩家,任何收到这个带有停止指令的都会停止移动。
4.同步快进:
现实的网络往往不稳定,时而会出现一堆数据帧涌过来,或者又收不到数据帧的情况,因此如何预测和模拟延迟成为了客户端要解决同步问题的关键。
对于收到太多的情况,我们可以加快帧的步长,从帧队列每次加载1帧转换到每次加载N帧,N可以更具网络情况而变化。
如果落后的太多,比如掉线后重连,可以使用内存快照的方式执行快速操作。意思是把内存中关于战斗的所有数据都备份到一份文件上,当需要渲染最后一帧的数据时,客户端可以使用该快照数据获得内存数据的结果,以此渲染画面。
还有就是当玩家的操作过多,会发送大量的信息,这是会导致混乱帧问题,为了不发送过多的混乱帧数据,可以选择把需要发送的指令存起来,等收到一个服务器的网络帧时再传输,如果又很多指令则替换掉,例如拳王某一帧按了WASD,则只存储D这个按键。
5. 精度问题:
我们通常采用定点数的方式,对于一个小数,小数部分用整数存储,整数部分也有一个小数存储,这样可以使得我们的小数精度足够,并且在计算时不会产生精度消失的问题。