在研究UE4网络的源码时发现一大段英文注释。
这一大段注释大致说了网络同步使用的基本类、客户端和服务器连接握手过程、客户端和服务器通信之间数据是如何组织的,如何传输的。还有大致描述了Packet和Bunches的概念以及UE4在应用层实现的可靠传输。
这段注释对于整一个网络同步有一个很好的梳理,从梳理中可以方便找到感兴趣的模块进行源码阅读。遂翻译之,这段英文概叙在UE4源码的NetDriver.h下。
NetDrivers NetConnections Channels:
UNetDrivers 负责管理UNetConnections集合以及UNetConnections之间共享的数据。
对于一个游戏,UNetDrivers的数量通常来说相对较少。这些UNetDrivers可能包含:
- Game NetDriver,负责标准游戏网络流量
- Demo NetDriver,负责记录和回放之前的游戏记录。这就是录像回放的原理。
- Becon NetDriver,负责超出“正常”游戏流量的网络流量
- 自定义NetDrivers也可以由游戏或者应用程序实现和使用。
NetConnections表示连接到游戏的单个客户端(或者更一般的说,连接到NetDriver)。端点数据不是由NetConnections直接处理的,NetConnections会转交数据给Channels。
每个NetConnection都有自己的一组Channels集合。
这些Channels大致有一下类型:
- Control Channel:用于发送关于连接状态的信息(连接是否应该关闭等)
- Voice Channel:用于在客户端和服务器之间传送音频数据
- Actor Channel:用于从Server端同步Actor数据到Client端,每个被标记为Replicated的Actor都有一个独一无二的Actor Channel。
除了上述这些Channel类型以外,自定义Channels也能用于特定的目的,但不是很常用。
通常情况下,只会有一个NetDriver(在Client端和Server端创建)用于“标准”的游戏流量传输和游戏连接。
Server端的NetDriver将维护一个NetConnections列表,每个NetConnection代表游戏中的一个玩家。NetDriver它负责同步Actor数据。
Client端的NetDriver只有一个NetConnection表示到Server端的连接。
不论是Server端还是Client端,NetDriver都负责接受来自网络的数据包并将其转交给给相应的NetConnection(必要时会建立新的NetConnections)。
Initiating Connections / Handshaking Flow 初始化连接/握手流程
UIpNetDriver和UIpConnection(或者派生类)是引擎几乎在每个平台下的默认使用类。下面的内容描述了它们如何建立和管理连接。但是,这些步骤在NetDriver的实现之间可能有所不同。
服务器和客户端都有自己的NetDrivers,游戏的所有同步(Replication)流量传输都会由IpNetDriver发送或者接收。这些通信流还包括用于建立连接的逻辑以及在何时重新建立连接的逻辑如果出现问题了的话。
握手(Handshaking)分为几个不同的部分:NetDriver, PendingNetGame, World, PacketHandlers等等。这么做的原因是因为有不同的需求,例如:确定到来的连接是否以“UE-Protocol”发送数据的、确定一个地址是否有恶意、确定一个给定的客户端是否有正确的游戏版本等等。
Startup and Handshaking 启动和握手
当服务器加载一个map时(通过UEngine::LoadMap),我们会调用UWorld::Listen。该代码负责创建主游戏NetDriver,解析设置并调用UNetDriver::InitListen。
最终,这些代码负责弄清楚我们是如何监听客户端连接的。例如在IpNetDriver中,我们通过调用configured Socket Subsystem(参见ISocketSubsystem::GetLocalBindAddresses和ISocketSubsystem::BindNextPort)来确定要绑定到的IP/端口。
一旦服务器处于监听状态,它就可以开始接受客户端连接了。
当一个客户端连接服务器时,他们首先会用服务器的Ip在UEngine::Browse中建立一个新的UpendingNetGame。UpendingNetGame::Initialize和UpendingNetGame::InitNetDriver分别负责初始化设置和设置NetDriver。
客户端将立即为服务器设置一个UNetConnection作为初始化的一部分,并将开始发送数据到该连接上的服务器。
然后开始握手的过程。
在客户端和服务器端,UNetDriver::TickDispatch通常负责接收网络数据,通常情况下,当我们收到一个数据包的时候,我们会检查它的地址,看看它是否来自一个我们已经知道的连接。
我们通过保持一个从FInternetAddr到UNetConnection的映射来判断是否已经建立一个连接。如果一个包来自一个已经建立的连接,我们通过UNetConnection::ReceivedRawPacket将这个包传递给这个连接。如果包不是来自已经建立的连接,我们将其视为“非连接”,并开始握手过程。
详情请参阅StatelessConnectionHandlerComponent.cpp。
UWorld/UPendingNetGame/AGameModeBase 启动和握手
在UNetDriver和UNetConnection完成客户端和服务器端之间的握手过程后,UPendingNetGame::SendInitialJoin将在客户端被调用,以启动游戏关卡(game level)的握手。
游戏关卡的握手是通过一组更加结构化和复杂的FNetControlMessages来完成的。可以在DataChannel.h中找到完整的控制消息集。
处理这些控制消息的大部分工作是在UWorld::NotifyControlMessage和UPendingNetGame::NotifyControlMessage中完成的。简单地说,流程是这样的:
- 客户端UPendingNetGame::SendInitialJoin发送NMT_Hello。
- 服务器的UWorld::NotifyControlMessage接收NMT_Hello,发送NMT_Challenge。
- 客户端UPendingNetGame::NotifyControlMessage接收NMT_Challenge,并返回NMT_Login中的数据。
- 服务器的UWorld::NotifyControlMessage接收NMT_Login,验证Challenge数据,然后调用AGameModeBase::PreLogin。
- 如果PreLogin没有报告任何错误,服务器调用UWorld::WelcomePlayer,它调用AGameModeBase::GameWelcomePlayer,发送NMT_Welcome和地图信息。
- 客户端UPendingNetGame::NotifyControlMessage接收NMT_Welcome,读取地图信息(以便稍后开始加载)和发送一个NMT_NetSpeed消息包含客户端的网速。
- 服务器的UWorld::NotifyControlMessage接收NMT_NetSpeed,并适当调整连接的网络速度。
至此,握手被认为是完整的, 玩家连接到游戏。
根据加载地图所需的时间,在控制转换到UWorld之前客户端在UPendingNetGame仍然可以接收到一些非握手控制消息。
如果需要,还有其他处理加密的步骤。
Reestablishing Lost Connections 重新建立丢失的连接
在整个游戏过程中,连接可能会因为一些原因而丢失。例如:网络断开,用户可以从LTE切换到WIFI,离开游戏等等。
如果服务器尝试向这些断开的连接发送消息,或者由于超时或者错误服务器知道了这些断开的连接,那么断开连接这个连接在服务器上将被关闭UNetConnection并通知游戏。
如果游戏支持,我们将完全重新启动上面的握手流程。
如果只是短暂地中断了客户端连接,但服务器却不知道,那么引擎/游戏通常会自动恢复(尽管有一些包丢失/延迟峰值)。
然而,如果客户端IP地址或端口由于任何原因发生变化,而服务器不知道这一点,我们将通过更底层的的握手来开始恢复过程。这里是游戏代码不会收到警告的。
这个过程包含在StatlessConnectionHandlerComponent.cpp中。
Data Transmission 数据传输
游戏NetConnections和NetDrivers通常与底层通信方法/技术无关。
这是留给子类来决定的(类如UIpConnection / UIpNetDriver或UWebSocketConnection / UWebSocketNetDriver)。
但是,UNetDriver和UNetConnection使用Packets和Bunches。
Packets是主机和客户端上的NetConnections之间发送的数据块。
Packets是由关于数据包的元数据(如报头信息和ACK)和Bunches组成;
Bunches是主机和客户端上的Channels之间发送的数据块。
当一个连接收到一个Packet时,该Packets会被分解成单独的Bunches,这些Bunches随后被传递到单独的Channels上,以便进一步处理。
一个Packet可能不包含任何Bunches,或者包含单个Bunches或者多个Bunches
由于Bunches的大小限制可能大于单个Packet的大小限制,因此UE4支持分Bunches的概念。
当一个Bunches太大时,在传输之前我们会将它分成许多小的Bunches。这些小的Bunches将会被标记为分组的开始、分组的部分以及分组的结束。利用这些信息我们可以在对端收到这些小的Bunches时候重新组装成完整的Bunch。
例如:客户端RPC到服务器
客户端调用Server_PRC
该请求被转发(通过NetDriver和NetConnection)到Actor的Channel,这个Channel拥有调用RPC的Actor。
Actor Channel将RPC标识符和参数序列化到Bunch中,这个Bunch还将包含Actor Channel的ID。
稍后,NetConnection将会把这个Bunch(和其他Bunch)数据组装成一个Packet发送给服务器
在服务器端,Packet包将被NetDriver接收。NetDriver会检查发送这个Packet的地址,然后将这个Packet转交给对应的NetConnection处理。
NetConnection会将Packet分解成Bunces(一个接一个处理);
NetConnection将使用Bunch上的Channel ID将Bunch转交到相应的Actor Channel上。
Actor Channel将分解Bunch,查看它是否包含RPC数据,并使用RPC ID和序列化参数在Actor上调用适当的函数。
Reliability and Retransmission 可靠性和重传
UE4网络通常假定底层网络协议不可靠,因此它实现了自己的Packets和Bunches的可靠性和重传。
当一个NetConnection建立,他将为他的Packets和Bunches建立一个序列号,这些序列号可以是固定的也可以是随机的(当序列号是随机时,序列号将由服务器发送)。
(The packet number is per NetConnection)每个NetConnection的Packet号每次发送一个包就增加,每个Packet都包含它的packet号。我们永远不会重传相同Packet号的Packet。
(The bunch number is per Channel)每个Channel的Bunch号随着每个“可靠”Bunch的发送而增加,每个Bunch都包含它的Bunch号。但是与Packets不同,精确(可靠的)Bunches可以被重传。这意味着我们会用相同的Bunch号重传Bunches。
注意,在整个代码中,上面描述的Packet号和Bunch号通常就是一个序号值。为了更清楚地理解,我们在这里做了区分。
Detecting Incoming Dropped Packets检测收包时发生了丢包
通过确定Packet号,我们可以很容易地判断接收到的Packets是否有发生过丢包。
这只需要取最后一个成功接收到的Packet号与当前正在处理的Packet号之间的差值即可。在未发生丢包的情况下,所有Packet都会按其发出的次序接收。这意味着差异会 + 1。如果差值大于1,说明我们遗漏了一些Packets。我们会假设这些之前的Packets已被丢弃,但假设当前的Packet已被成功接收,并且使用这个Packet号向前递增。
如果差值为负(或0),则表示我们接收到的数据包出现了失序,或者外部服务试图重新向我们发送数据(请记住,引擎不会重用Packet号)。
在这两种情况下,引擎通常会忽略丢失的或无效的Packet,并且不会为它们发送ACKs。
我们确实有方法来“修复”在同一帧上接收的无序数据Packets包。
当启用时,如果我们检测到丢包发生(Packet号差异> 1),我们不会立即处理当前Packet,而是把当前Packet包加入一个队列中。下一次我们成功地接收到数据包(difference == 1)时,我们将查看队列的头部是否得到了正确的排序。如果是,我们将处理它,否则我们将继续接收数据包。
一旦我们读取了所有当前可用的包,我们将刷新这个队列处理任何剩余的包。在这一点上丢失的任何东西都将被假定为已经被丢弃。
每个成功收到的Packet包都会将其Packet号作为确认(ACK)发送回给发送方。
Detecting Outgoing Dropped Packets检测发包时发生了丢包
如上所述,无论何时成功接收到数据包,接收方都将发送回一个ACK。这些ACKs将按顺序包含被成功接收到的Packet的Packet号。与接收方跟踪包号的方式类似,发送方将跟踪最高的ACK包号。当ACK被处理时,任何低于我们最后收到的ACK的ACK都被忽略,包号中的任何间隙都被认为是不被承认的(NAKed)。
发送方有责任处理这些ack和nak并重新发送任何丢失的数据。新数据将被添加到新的发送数据包(同样,我们不会重新发送我们已经发送的数据包,或重复使用包序列号)。
Resending Missing Data重传丢失的数据
如上所述,Packets包本身并不包含有用的游戏数据。而组成它们的Bunches包才含有有意义的数据。
Bunches可以被标记为可靠或不可靠。
如果不可靠的Bunches发生了丢包,引擎将不会尝试重新发送它们。因此,如果标记为不可靠,游戏/引擎应该能够在没有它们的情况下继续运行,或者必须放置外部重试机制,或者必须发送冗余的数据。
但是,引擎会尝试重新发送可靠的Bunches。无论何时发送一个可靠的Bunch,它都将被添加到un-ACKed的可靠的Bunches列表中。如果我们收到一个Packet包包含这个Bunch的NAK,引擎会重新发送这个Bunch的精确副本。注意,由于Bunches可能是被拆分过的,即使丢弃一个部分的Bunch也会导致整个Bunch的重新传输。当所有的包含Bunch的Packets包都被ACK时,我们将把它从列表中删除。
与Packet类似,我们将比较接收到的可靠Bunch的Bunch号与最后成功接收Bunch的Bunch号。如果我们发现差值是负的,我们就忽略这一串。如果差值大于1,我们就会认为我们漏了Bunch。与Packet处理不同,我们不会丢弃放弃这些数据。我们会将这些bunch放入队列并暂停处理“任何”可靠或不可靠的Bunches。
直到我们检测到已接收到丢失的Bunch我们才恢复处理接下来的Bunches,在此我们会处理这些Bunch并开始处理已在队列的Bunches。
在等待接收丢失的Bunches时收到的新的Bunches,或在队列中仍然有Bunches时收到的新的Bunches,这些新收到的Bunches将被添加到队列中,而不是立即处理。
后面关于可靠性和重传部分翻译得不加,可能要结合源码才能翻译得更加准确。