# 游戏网络通信浅析

# 服务器三问

我们都知道现在游戏可以分为单机游戏和网络游戏,简单区别就是一个独自快乐,一个是多人快乐。

# 什么是服务器

简单地说就是一台电脑。在那台电脑上运行一些特定程序,这些程序主要的功能:

  • 游戏和玩家的数据存储
  • 玩家交互数据进行广播和同步
  • 重要逻辑要在服务器上运算,做好验证,防止外挂

# 为啥要服务器

因为玩家不想让他很牛逼这个事情,只有他知道,他想告诉全世界。当然对开发者来说,是一个比较简单防破解的方式。

# 怎么用服务器

我们从编程的角度来看看单机游戏和网络游戏的区别:


单机游戏设计思路如下图

在游戏开始时,需要加载一些资源,这些资源包括地图信息、模型、配置表等。加载完成之后,选择角色进入有即可。

从编码的角度来说,游戏一旦开始,运行的就是一个无限循环,就像是Unity中Monobehavior的Update,我们可以认为它是一个 while(true){...},除非离开游戏,否则这个循环会一直执行下去。循环的每一次执行称为一帧。在一帧中包括三个主要操作:

  • 检测玩家输入
  • 根据输入更新内存数据,刷新场景、人物模型、界面等。
  • 捕捉退出请求,如果有退出请求,这个循环就会被打破,游戏结束。

理论上来讲,一秒能产生的循环数越大,程序的反应就越灵敏,玩家看到就是越流畅。


网络游戏设计思路如下图

网络游戏和单机游戏一样也有一个循环,只是多了一个网络层的处理。简单的理解就是把以前一个人完成的任务,分成两个人来做。因为是两个人做事,所以就会出现异步这个概念。也就是一个人需要另一个人的支持,所以就会出现一个人等待另一个人的支持。

我们以登录为例来展示一下具体不同之处,登录流程:

在登录界面输入账号和密码之后,要经历一个异步的操作,客户端向服务端发送协议,等待服务端返回数据,由此来判断登录成功或者失败。网络游戏yum的哭护短发出命令,只有等到服务端给它结果,它才会做出反应。客户端向服务端请求账号验证,这个请求数据不是一个函数可以完成的,这就是一个异步的过程。


# 网络基础

计算机网络体系结构

我们可以把网络使用不同的数据模型分层,我通常使用五层协议来对网络理解。

# 数据流程

这里我们使用发一个微信消息给好友来简单解释一下数据的流向。

# 使用

我们游戏服务器与客户端使用到的就是运输层中的TCP与UPD协议,我们一般使用套接字(socket)创建

我们可以使用套接字(Socket)来对不同主机上的应用进程进行通信。一个套接字就是网络上进程通信的一端,提供了应用层进程利用网络协议交换数据的机制。从所处的地位来讲,套接字上联应用进程,下联网络协议栈,是应用程序通过网络协议进行通信的接口,是应用程序与网络协议根进行交互的接口。

Api:

  • 连接 socket.Connect
  • 发送 socket.Send
  • 接收 socket.Receive
  • 关闭 socket.Close

当收到对端(服务器或客户端)数据时,操作系统会将数据存入到Socket的接收缓冲区中,接收缓冲区存有数据的TCP Socket示意图如下:

上图可以看到,接收缓冲区有4个字节数据,分别是1、2、3、4。操作系统层面上的缓冲区完全由操作系统操作,程序不能直接操作它们,只能通过socket.Receive、socket.Send等方法来间接操作。

Socket的Receive方法只是把接收缓冲区的数据提取出来,比如调用 Receive(readBuff,0,2) ,接收2个字节的数据到readbuff。上图例子中,调用后操作系统接收缓冲区只剩下了2个字节数据,用户缓冲区readBuff保存了接收到的2字节数据,形成下图:

当系统的接收缓冲区为空,Receive方法会被阻塞,知道里面有数据。

同样地,Socket的Send方法只是把数据写入到发送缓冲区里,具体的发送过程由操作系统负责。当操作系统的发送缓冲区满了,Send方法将会阻塞。

# 网络模型

我们知道可以使用计算机网络中的Socket来传输信息。那么我们是以什么角色来使用这个工具呢?这里就引出我们常说几个名词:

  • 客户端
  • 服务器 我们使用排列组合,就可以列出两种常见的网络模型

# 客户端-服务器

我们从名字(Client-Server)可以看出这个模型中是有两个不同的角色。这种网络模型也是最常用的。我们看看他们各自的定义:

  • 服务器:一个具有高性能可以存储数据和信息的机器
  • 客户端:一个让用户可以访问远程数据的机器

系统管理员在服务器上管理数据。客户端与服务器通过网络连接。客户端可以访问数据,即使客户端机器离服务器机器很远在物理距离上。

客户端向服务器发送请求,服务器接收到请求后,处理数据,再向客户端回复信息。

所有的服务都是由中心服务器提供,所以这个模型会出现瓶颈的问题。

# 点对点

不同于客户端-服务器模型,在点对点(Peer to Peer — P2P)并没有特别定义哪个是服务器那个是客户端。每一个节点都可以作为客户端和服务器。

在P2P网络环境中,彼此连接的多台计算机之间都处于对等的地位,各台计算机有相同的功能,无主从之分,一台计算既可作为服务器,设定共享资源供网络中其他计算机所使用,又可以作为工作站,整个网络一般来说不依赖专用的集中服务器,也没有专用的工作站。网络中的每一台计算机既能充当网络服务的请求者,又对其他计算机的请求做出响应,提供资源、服务和内容。通常这些资源和服务包括:信息的共享和交换、计算资源(如CPU计算能力共享)、存储共享(如缓存和磁盘空间的使用)、网络共享、打印机共享等。

# 区别

内容 服务器 - 客户端 点对点
基础 有一个服务器与多个连接到服务器的客户端 没有定义服务器与客户端,每一个节点都可以是客户端或服务器
服务 客户端发出请求,服务器响应请求 每个节点都可以发出请求也可以提供服务
聚焦 共享信息 连接
数据 数据存在中心服务器 没有端都有自己的数据
服务器 当多个客户端向一个服务器发出请求,这个服务器会出现瓶颈 由于服务是由分布在对等系统中的多个服务器提供的,因此服务器不会出现瓶颈
价格 不是那么贵
稳定性 很稳 节点越多稳定减少

# 同步理论

在客户端——服务端架构中,无论是用什么样的同步方法,都始终遵循着下图所示的过程:

客户端1向服务端发送一条消息,服务端收到后稍作处理,把它广播给所需的客户端(客户端1、客户端2和客户端3)。

所传递的消息可以是角色的位置、旋转这样的状态值,也可以是“向前走”这样的指令值。前者称之为状态同步,后者称之为指令同步。

# 同步的难题

由于存在网络延迟和抖动,往往很难做到精确的同步。

例如:下图左边展示的是理想的网络情况,服务端定时发送消息给客户端,客户端立刻就能够收到。

而实际的网络情况并非如此,更像右边的情况,这里出现了两个问题:

  • 消息的传播需要时间,会有延迟
  • 消息的倒带时间并不稳定,有时候两条消息会像个较长时间,有时候却相隔很短

传播时间长了,也就是我们常说的延迟高了,就会出现卡顿的现象。

看一个实例: 玩家1控制的坦克从A点走向C点,玩家2看到的坦克总是延迟了一小段时间,所以玩家1和玩家2看到的战场不会完全一致。

网络颜值问题基本无解,只能权衡。比如:

  • 尽量发送更少的数据,数据越少,发生数据丢失并重传的概率就越小,平均速度越快。
  • 在客户端上做些“障眼法”,让玩家感受不到延迟。

# 状态同步

状态同步是同步状态信息。如在坦克游戏中,客户端把坦克的位置坐标、旋转发给服务端,服务端再做广播。

我们都以坦克的位置同步来讲解几种不同的状态同步方式。前提条件都为: 玩家1位发送位置信息的一方,玩家2为同步方,网络延迟为250毫秒。

# 直接状态同步

# 是什么

最直接的同步方案就是客户端定时向服务器报告位置,其他玩家收到转发的消息后,直接将对方坦克移动到指定位置。

# 分析

当玩家1在经过B点时发送同步信息,经过一定的网络延迟,当玩家1的坦克走到C点时,玩家B才收到消息。这时两个客户端的误差为“速度*延迟”。

如果网络延迟是固定的,那么两个客户端虽然有着不同的状态,但是最终结果会是一样的。实际上网络是波动的,假设玩家1到C点时又发送了位置信息,这时没有网络延迟,玩家2看到的同步坦克瞬移的,从B直接跳到C,很不自然。所以我们一般不这样同步位置。

# 跟随算法

# 是什么

为了解决“直接状态同步”的瞬移问题,人们引入了一种障眼法————“跟随算法”。在收到同步消息后,客户端不直接将坦克到目的地,而是让坦克以一定的速度移动。

# 分析

玩家1经过B点发送同步信息,玩家2收到后,将坦克以同样的速度从A点移动到B点。此种情况下,误差更大了,因为在玩家1从B点移动到C点的过程中,玩家2看到的坦克才从A点移向B点。

然而很多时候,游戏并不需要非常精确的同步,只要同步频率足够高(玩家每1秒发送位置的次数,比如每秒发送30次),误差就可以忽略

# 预测算法

# 是什么

跟随算法的一大缺陷就是误差会变得很大,在某些有规律可循的条件下,比如坦克匀速运动,或者匀加速运动,我们能够预测坦克在接下来某个时间点的位置,让坦克提前走到预测的位置上去。这就是预测算法。

# 分析

假设坦克匀速前进。玩家1经过B点时发送位置信息,玩家2根据 “距离=速度*时间” 可以计算出下一次收到同步信息时,坦克应移动到C点。于是玩家2让同步坦克移向C点,玩家1和玩家2之间的误差会很小。

然而玩家1操控的坦克不可能一直保持匀速。当玩家1突然停下,玩家2看到的坦克会向前移动一段距离,又再向后移动一段距离。

跟随算法和预测算法各有优缺点,具体使用哪种算法,应当视项目需求而定。

# 帧同步

帧同步是指令同步的一种,即同步操作信息。基本上所有指令同步方法都结合了帧同步,两者可以视为一体。这里 “帧” 的概念与Unity中 “每一帧执行一次Update” “30FPS(每秒传输帧数,Frames Per Second)” 里的 “Unity帧” 有所不同,我们会实现独立于 “Unity帧” 的另外一种 “同步帧”。

# 指令同步

# 是什么

状态同步所同步的是状态消息,如果要同步坦克的位置和旋转,那就需要同步六个值(三个坐标值和三个旋转值)。上面提到,缓解网络延迟的一个办法是减少传输的数据量,如果只传输玩家的操作指令,数据量就会减少很多。

# 分析

当玩家1要移动坦克,按下键盘上的 “上” 键时,玩家1会发送 “往前走” 的消息给服务端,经由转发,玩家2收到后,让同步坦克向前移动。当玩家1要定制移动坦克,会放开按键,发送 “停止” 指令,玩家2收到后,让坦克停止移动。

# 缺点

上诉过程的一大缺点是误差的累积。有些电脑速度快,有些电脑速度慢,尽管玩家2收到了玩家1的指令,但只要两者的电脑运行速度不同,可能有人看到坦克走了很远,有人看到的却只移动了一点点的距离。为了解决这个问题,人们操作同步的基础上,引入了 “同步帧” 的概念。

# 从Update说起

如果有一种办法,让不同的电脑有一致的运行效果,便可以解决指令同步中的误差累积问题。

我们看看坦克移动的代码 :

public void MoveUpdate(){     //在Unity的Update中调用
    ……
    float y = Input.GetAxis("Vertical");  // 如果是 SyncTank ,改为由网络传播的指令 
    Vector3 s = y*transform.forward * speed * Time.deltaTime;
    transform.transform.position += s;
}
1
2
3
4
5
6

由于采用了 “ 速度*时间” 的公式,理论上说,无论电脑运行速度快慢,坦克移动的路径都能够保持一致。因为当电脑很慢时,Update的执行次数会变少,但 Time.deltaTime 的值变大,反之一样,但坦克移动的路径不变。

尽管如此,我们还不能够保证经由网络同步的坦克能够有一致的行为。因为网络延迟的存在,从发出 “前进” 到 “停止” 指令之间的时间可能不一致,坦克移动的路径也就不同。一种解决办法是,在发送命令的时候附带时间信息,客户端根据指令的时间信息区修正路程的计算方式,使所有客户端表现一致。人们定义了 “帧” 的概念,来表示时间(为和Unity本身的帧区分,这里称为 “同步帧”)。

# 什么是同步帧

假如我们自己实现一个类似 Update 的方法,称之为 FrameUpdate,程序会固定每隔0.1秒调用它一次。每一次调用 FrameUpdate 称之为一帧,第1次调用称为第一帧,第二次调用称为第2帧,以此类推。

上图展示了一种理想情况,现实往往很残酷。比如在执行第2帧的时候,系统突然卡顿了一下,这一帧的执行时间变长了,就超过0.1秒,这会导致第3帧无法按时执行。

为了保证后面的帧能够按时执行,程序需要做出调整,即减少第2帧和第3帧之间、第3帧和第4帧之间的时间间隔,保证程序在第0.5秒是,执行到第5帧。

同步帧的具体实现如下:

int frame = 0;            // 当前执行到第几帧 
float interval = 0.1f;  // 两帧之间的理想间隔时间 

public void Update(){
    while(Time.time < frame*interval){
        FrameUpdate();
        frame++;
    }
}
1
2
3
4
5
6
7
8
9

上述程序中,如果某几帧的执行时间太长,程序就会立即调用下一帧(注意使用到while循环),间隔时间为0。程序尽量保证在第N秒的时候,执行到第10*N帧。FrameUpdate 每执行一次,即表示执行一次同步帧。如果程序运行了较长时间,FrameUpdate 的执行频率会相对稳定。

帧同步所保证的,就是各个客户端在执行到同一个 “同步帧” 时,表现效果完全一样。如果将移动坦克的逻辑在 FrameUpdate 里,无论这一帧的执行时间多长,每一帧移动的距离都设定为 “速度*0.1秒” ,只要执行相同的帧数,移动的距离必然相同。

# 指令

比起操作同步,在指令同步中,客户端向服务端发送的指令包含了具体的指令和时间信息,即是在哪一帧(特指同步帧)做了哪些操作。例如:在第10帧发出了“前进”指令(按下“上”键),在第20帧发出了“后退”指令(按下“下”键)。

指令同步的协议形式如下:

// 同步协议 
public class MsgFrameSync:MsgBase {
    public MsgFrameSync() {protoName = "MsgFrameSync"; }
    // 指令, 0- 前进  1- 后退  2- 左转  3- 右转   4- 停止   ...
    public int cmd = 0;
    // 在第几帧发生事件 
    public int frame = 0;
}
1
2
3
4
5
6
7
8

# 指令的执行

为了保持所有客户端有一样的表现,往往要做一些妥协,有两种常见的妥协方案:

  1. 有的客户端运行速度快,有运行速度慢,如果要让它们表现一致,那只能让快的客户端区等待慢的客户端,所有客户端和最慢的客户端保持一致,才有可能表现一致,毕竟,慢的客户端无论如何都快不了。这种方案对速度快的客户端较为不利。达成此方案的一个方法称为延迟执行,如果客户端1在第3帧发出向前的指令,由于网络延迟,客户端2可能在第5帧才收到,所以客户端1的坦克也只能在第5帧(或之后的某一帧)才开始前进。
  2. 对于速度慢客户端所发送的,丢弃安歇已经过时的指令,知道它赶上来。此种方案也称之乐观帧同步,对速度慢的玩家较为不利,因为某些操作指令会被丢弃。比如发出“前进”指令,但该指令被丢弃了,坦克不会移动。

所以,帧同步是一种为了保证多个客户端表现一致,让某些客户端做妥协的方案。而且如果启用了延迟执行,在玩家发出“前进”指令之后,要隔一小段时间坦克才能移动,玩家会感受到延迟。但无论如何,只要帧率(每秒执行多少帧)足够高,玩家就不会感觉到明显的延迟。

在方案一中,为了让各个客户端知道对方是否执行完某一帧,我们假定客户端每一帧都需要向服务端发送指令,没有操作也要发一个代表“没有操作”的指令。服务端要收集各个客户端的指令,收集满时,才在接下的某一帧广播出去。而客户端也只有在收到服务端的消息时,才执行下一帧。此时客户端的帧调用完全由服务端控制。

上图展示了一种帧同步的执行情况。在第1帧时(0.1s)客户端1和客户端2都向服务端发送指令,由于网络延迟,指令到达的时间也不同。服务端收集两个客户端的指令后将两条指令都广播出去,两个客户端会根据指令去执行,比如让坦克向前移动。如果某一个客户端执行很慢,另一个客户端也要等待很久才能收到服务端的指令,才会执行新的一帧,相当于等待慢的客户端。

按照每秒执行30帧的频率,客户端和服务端之间的信息交流也许太过频繁,会带来较大的网络负担。于是人们把多个帧合成为一轮(比如4帧组成一轮),每一轮向服务端同步一次指令。

帧同步还可以配合投票法来防止作弊。例如在坦克游戏中,某个玩家击中另外一个玩家,由于所有客户端的运行结果严格一致,它们都可以向服务端发送“谁击中了谁”的消息。服务端可以收集这些信息,如果半数以上的玩家都发送了击中消息,才认为有效。

# 传输问题

# 沾包半包

# 问题:

如果发送端快速发送多条数据,接收端没有及时调用Receive,那么数据便会在接收端的缓冲区中积累,如下图:

客户端先发送 “1、2、3、4” 四个字节的数据,紧接着又发送 “5、6、7、8” 四个字节的数据。等到服务端调用Receive时,服务端操作系统已经将接收到的数据全部写入缓冲区,共接收到8个数据。

我们举个例子:在聊天软件中,客户端依次发送 “LSP”“_is_handsome” ,期望其他客户端也展示出 “LSP”“_is_handsome” 两条信息,但由于Receive可能把两条信息当做一条信息处理,有可能只展示 “LSP_is_handsome” 一条信息,如下图:

Receive方法返回多少个数据,取决于操作系统接收缓冲区中存放的内容。

发送端发送的数据还有可能被拆分,如发送 “HelloWorld”,如下图:

但在接收端调用Receive时,操作系统只接收到了部分数据,如 “Hel” ,在等待一小段时间后再次调用Receive才接收到另一部分数据 “loWord”

# 解决 :

我们可以效仿网络协议包体,为我们的数据包加一个包头,这个包头中存放该数据包长度,当然这个包头还可以放入其他的自定义信息,但是我们的包头长度需要固定

游戏程序的包头只包含了数据包长度,这个长度一般使用16位整数或32位整数来存放。

16位消息长度的格式如下图:

32位消息长度的格式如下图:

# 大端小端

# 问题:

我们读取接收到的字节流时,可以按照来类型来读取,比如我们要先读取消息长度,规定消息长度为16位整数,使用API BitConverter.ToInt16(buffer,offset) 来读取。这个方法的底层是怎么实现的呢?我们看看 .Net的源码 (opens new window)

// Converts an array of bytes into a short.  
public static unsafe short ToInt16(byte[] value, int startIndex) {
    if( value == null)  {
        ThrowHelper.ThrowArgumentNullException(ExceptionArgument.value);
    }
    
    if ((uint) startIndex >= value.Length) {
        ThrowHelper.ThrowArgumentOutOfRangeException(ExceptionArgument.startIndex, ExceptionResource.ArgumentOutOfRange_Index);
    }

    if (startIndex > value.Length -2) {
        ThrowHelper.ThrowArgumentException(ExceptionResource.Arg_ArrayPlusOffTooSmall);
    }
    Contract.EndContractBlock();

    fixed( byte * pbyte = &value[startIndex]) {
        if( startIndex % 2 == 0) { // data is aligned 
            return *((short *) pbyte);
        }
        else {
            if(IsLittleEndian) { 
                return (short)((*pbyte) | (*(pbyte + 1) << 8)) ;
            }
            else {
                return (short)((*pbyte << 8) | (*(pbyte + 1)));                        
            }
        }
    }
    
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30

我们看到代码中有一个变量 IsLittleEndian,它代表这台计算机是大端编码还是小端编码,不同的计算机编码方式会有不同。所以问题就是,不同编码方式下,计算方法不同,那对与不同的计算机,读取出来的数据长度肯定是不同的,这个是需要我们处理。


# 什么是大端小端?

我们知道在计算机中所有数据都是用二进制表示的,举个例子,如果用16位二进制表示数字 258 ,它的二进制是 0000000100000010 ,转换成16进制是 0x0102

假如使用大端模式存入内存,内存数据如图:

还原这个数字的步骤是:

  • 拿到第1个字节的数据 00000001,乘以进制位256(2的8次方),得到256,即第1个字节(低地址)代表了十进制数字256;
  • 拿到第2个字节的数据 00000010 ,它代表十进制数字2,乘以进制位1,得到2;
  • 将前两步得到的数字相加,即256+2,得到258,还原出数字。

假如使用小端模式存入内存,内存数据如图:

还原这个数字的步骤是:

  • 拿到第1个字节的数据 00000010 ,它代表十进制数字2,乘以进制位1,得到2;
  • 拿到第2个字节的数据 00000001,乘以进制位256(2的8次方),得到256,即第1个字节(低地址)代表了十进制数字256;
  • 将前两步得到的数字相加,即256+2,得到258,还原出数字。

常用的X86结构是小端模式,很多的ARM、DSP都为小端模式,但KEIL C51则为大端模式,有些ARM处理器还可以由硬件来选择是大端模式还是小端模式。也就是说市面上的手机有些采用大端,有些采用小端模式。


# 解决

在不确定的问题出现了,我们的解决方案就是把它确定就好了,就可以规定都以小端的方式发送即可。以发送代码为例,就可以写成:

public void Send()
{
    string sendStr = InputFeld.text;
    // 组装协议 
    byte[] bodyBytes = System.Text.Encoding.Default.GetBytes(sendStr);
    Int16 len = (Int16)bodyBytes.Length;
    byte[] lenBytes = BitConverter.GetBytes(len);

     // 大小端编码  
     if(!BitConverter.IsLittleEndian){ 
        Log("[Send] Reverse lenBytes"); 
        lenBytes.Reverse(); 
    }

    // 拼接字节 
    byte[] sendBytes = lenBytes.Concat(bodyBytes).ToArray();
    socket.Send(sendBytes);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

# 完整发送数据

# 问题

回忆一下Send方法,该方法会把要发的数据存入操作系统的发送缓冲区,然后返回成功写入的字节数。简单的解释是,对于那些没有成功发送的数据,程序需要把他们保存起来,在适当的时机再次发送。由于在网络通畅的环境下,Send只发送部分数据的概率并不高,所以很多商业游戏也没有处理这种情况。

我们以异步聊天客户端为例,假设操作系统缓冲区被设置得很小,只有8个字节,再假设网络环境很差,缓冲区的数据没能及时的发出去。我们看下图:

在客户端步骤:

  1. 假设客户端发送字符串 hero ,发送后,Send返回6(包含两字节的长度),数据全部存入操作系统缓冲区中。但此时网络拥堵,TCP尚未把数据发给服务器。
  2. 客户端又发送了字符串 cat ,由于操作系统的发送缓冲区只剩下2字节空位,只有把代表数据长度 03 写入缓冲区。
  3. 此时网络环境有所改善,TCP成功把缓冲区的数据发送给服务器,操作系统缓冲区被清空。
  4. 客户端又发送了字符串 hi ,数据发送成功

服务器收到的数据 04hero0302hi ,第一个字符串 hero 可以被解析,但对于后续 0302hi ,服务端会解析成一串3个字节的数据 02h,以及不完整的长度信息 i04hero 往后的数据全部无法解析,通讯失败。

# 解决

我们使用 Sokect.BeginSend 方法可以传入一个异步回调方法。

public IAsyncResult BeginSend(IList<ArraySegment<byte>> buffers, SocketFlags socketFlags, AsyncCallback callback, object state)
1

我们可以在这个回调中加入检测我们此次发送的数据是否发送完整。简单的实例代码:

//定义发送缓冲区
byte[] sendBytes = new byte[1024];
//缓冲区偏移值
int readIdx = 0;
//缓冲区剩余长度
int length = 0;

//点击发送按钮
public void Send()
{
    sendBytes = 要发送的数据;
    length = sendBytes.Length;       //数据长度
    readIdx = 0;
    socket.BeginSend(sendBytes, 0, length, 0, SendCallback, socket);
}

//Send回调
public void SendCallback(IAsyncResult ar){
    //获取state
    Socket socket = (Socket) ar.AsyncState;
    //EndSend的处理
    int count = socket.EndSend(ar);
    readIdx + =count;
    length -= count;
    //继续发送
    if(length > 0){
        socket.BeginSend(sendBytes,readIdx,  length, 0, SendCallback, socket);
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29

在检测到未发送完成后,我们在发送一次即可。

这里发送缓冲区设置的是一个长度为1024的数组,这个会有潜在的一些风险。我们可以简单的封装一个数据结构ByteArray,然后把这个数据结构使用队列来存储。

ByteArray的简单实现如下:

using System;

public class ByteArray  {
    //缓冲区
    public byte[] bytes;
    //读写位置
    public int readIdx = 0;
    public int writeIdx = 0;
    //数据长度
    public int length { get { return writeIdx-readIdx; }}
    //构造函数
    public ByteArray(byte[] defaultBytes){
        bytes = defaultBytes;
        readIdx = 0;
        writeIdx = defaultBytes.Length;
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

最终我们需要把上面发送缓冲区换成队列的实例如下:

// 发送缓冲区 
Queue<ByteArray> writeQueue = new Queue<ByteArray>();
// 点击发送按钮 
public void Send()
{
    // 拼接字节,省略组装  sendBytes  的代码 
    byte[] sendBytes =  要发送的数据 ;
    ByteArray ba = new ByteArray(sendBytes);
    int count = 0;
    lock(writeQueue){ 
        writeQueue.Enqueue(ba); 
        count = writeQueue.Count; 
    } 
    // send 
    if(count == 1){
        socket.BeginSend(sendBytes, 0, sendBytes.Length,
            0, SendCallback, socket);
    }
    Debug.Log("[Send]" + BitConverter.ToString(sendBytes));
}

// Send  回调 
public void SendCallback(IAsyncResult ar){
    // 获取  state、EndSend  的处理 
    Socket socket = (Socket) ar.AsyncState;
    int count = socket.EndSend(ar);
    ByteArray ba;
    lock(writeQueue){ 
        ba = writeQueue.First(); 
    } 
    ba.readIdx+=count;
    if(count == ba.length){
         lock(writeQueue){ 
             writeQueue.Dequeue(); 
             ba = writeQueue.First(); 
         } 
    }
    if(ba ! = null){
        socket.BeginSend(ba.bytes, ba.readIdx, ba.length,
            0, SendCallback, socket);
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42

这里我们对缓冲队列做了加锁的操作,因为在发送回调中极可能会是多个线程操作。所以我们需要保证这个队列只能被修改一次。

# 客户端网络模块

我们客户端网络模块的架构如下图:

工作流程:

  • 初始化,使用 Networkmanager 创建一个 NetworkChannel,
  • 当需要发消息时,我们使用特定的序列化工具把对象转化为bytes,再把bytes交给协议栈
  • 当收到消息时,我们使用特定的序列化工具把bytes 转化为对象,再把对象内容由事件传递出去。

我们可以使用一个 Networkmanager 去管理多个 NetworkChannel

# Networkmanager

网络管理器,管理,驱动多个连接。其中主要的方法:

  • CreateNetworkChannel : 创建一个连接
  • GetNetworkChannel :获得一个连接
  • DestroyNetworkChannel : 销毁一个连接
类图

# NetworkChannel

管理一个Socket连接。其中主要方法:

  • Connect :连接服务器
  • Send :发送消息
  • ReceiveCallback : 处理接收消息
  • Close :关闭连接
类图

# NetworkChannelHelper

序列化与反序列化消息包。主要方法:

  • Serialize : 序列化消息包
  • DeserializePacketHeader : 反序列化消息包头
  • DeserializePacket : 反序列化消息包
类图

# EventManager

事件分发器,负责分发消息包到执行,使用这个可以控制线程处理,可以把其他线程的运行的抛到主线程执行。主要方法:

  • Subscribe :订阅事件处理函数
  • Unsubscribe :取消订阅事件处理函数
  • Fire :抛出事件
类图

# Packet

消息包体,封装字节流,用于逻辑层使用。主要内容有:

  • Id :消息ID
类图

# 通用服务器网络模块

我们服务器的网络模块框架如下:

可以看出跟客户端的网络模块没有啥区别,很多就是换了个名字而已。这里跟客户端不同的是这里既有Server对象又有Client的对象。 这里的Client并不是连接这个服务器的客户端的意思,而是当该服务器去作为客户端去连接别的服务器时。

我们在游戏中实现一些跨服的功能(如跨服交易)时,就需要一个公共服务器来转发一些数据,或者是设计分布式服务器。我们当前的服务器都会作为客户端去连接别的服务器。

# Server

负责作为服务器时,驱动多个Session的适配器,其中主要方法:

  • ListenAccept : 监听新的连接
  • NewServer : 启动服务器
  • Update :轮询服务器,处理发送与接收数据流

# Client

负责处理作为客户端时,驱动一个Session的适配器。其中主要方法

  • Send : 发送消息到Session层
  • Update :轮询客户端,处理发送与接收数据流
  • NewClient : 新建一个连接到服务器

# Session

控制每一个连接的收发消息。主要方法有:

  • Write : 发送消息到协议栈
  • Read : 读取协议栈消息
  • NewSession : 新建一个连接的管理器

# Router

转发接收到的消息到逻辑层。主要方法有:

  • Register :注册路由
  • Handle : 处理路由

# PacketPaser

消息的解码器。主要方法有:

  • Encode :序列化对象到bytes
  • Decode :反序列化bytes到对象

# Packet

消息包体,可以分为:

  • SendPacket
  • ReceivePacket

# 参考

https://developer.valvesoftware.com/wiki/Source_Multiplayer_Networking

https://www.zhihu.com/question/41498780/answer/1537480110

https://www.zhihu.com/question/433768405/answer/1700193198

https://gameinstitute.qq.com/community/detail/117210

https://zhuanlan.zhihu.com/p/28447002

https://www.cnblogs.com/panchanggui/p/9841768.html

https://unitylist.com/p/j35/Unity-Lockstep

https://unitylist.com/p/bmi/Unity-texture-curve

https://unitylist.com/p/k9s/Unity-ECS-RTS

https://techdifferences.com/difference-between-client-server-and-peer-to-peer-network.html

上次更新: 7/21/2021, 4:33:57 PM