本文剖析 UE5 客户端与DS建立连接后的登录流程,以及 Bunch
的发送接收,这样之后看属性同步时,能轻松一些。
登录流程
书接上回,握手之后会执行 UPendingNetGame::SendInitialJoin
。
1 | void UPendingNetGame::BeginHandshake() |
在继续分析之前,先来看官方的注释,描述了登录的流程。
1 | Most of the work for handling these control messages are done either in UWorld::NotifyControlMessage, |
简单来说就是服务端接收到 ControlMessage 是在 UWorld::NotifyControlMessage
而客户端接收则在 UPendingNetGame::NotifyControlMessage
二者都是在 UNetDriver::InitBase(bool bInitAsClient, FNetworkNotify* InNotify, const FURL& URL, bool bReuseAddressAndPort, FString& Error)
中被设置的。当客户端登录成功后,就会将 UPendingNetGame
的功能转移回 UWorld
中。
最简登录只需要 NMT_Hello
NMT_Challenge
NMT_Login
NMT_Welcome
NMT_NetSpeed
这几条命令。
Control 命令定义在 DataChannel.h
1 | // message type definitions |
将宏展开可得,其实发送就是创建一个 Bunch
然后通过 Channel[0] 也就是 ControlChannel 将数据发送出去,这个 Channel 的创建可以从网络剖析第二篇文章中找到,关于 Bunch 的组织结构以及如何发送出去,会在下面分析,这里先跳过。
1 | enum { NMT_Hello = 0 }; |
FControlChannelOutBunch
默认为 Reliable,若丢包会自动重传,这点在网络剖析第一篇讲过了,可不可靠是跟随 Bunch
的。
1 | FControlChannelOutBunch::FControlChannelOutBunch(UChannel* InChannel, bool bClose) |
在 DataChannel.h
下方,还有 Beacon
的命令,这是一个插件,用于客户端还未正式建立连接时,能够执行 RPC
,本质原理是为客户端先创建一个同步的 Actor
,方便客户端在还未正式连入时,通过 RPC
处理一些业务逻辑,比如预排队,但这里不是重点,跳过。
1 | DEFINE_CONTROL_CHANNEL_MESSAGE(BeaconWelcome, 25); // server tells client they're ok to attempt to join (client sends netspeed/beacontype) |
NMT_Hello
告知DS,客户端是否为小端架构、本地网络版本和验证 Token
。
1 | void UPendingNetGame::SendInitialJoin() |
NMT_Challenge
服务端收到之后,会尝试对 Token 进行校验, OnReceivedNetworkEncryptionToken
Delegate 默认在 GameInstance 中绑定,因此若需要自定义 Token 校验逻辑,可继承 GameInstance::ReceivedNetworkEncryptionToken
。
1 | void UWorld::NotifyControlMessage(UNetConnection* Connection, uint8 MessageType, class FInBunch& Bunch) |
客户端收到 NMT_Challenge 后,拼凑 URL 告知服务端自己的别名,以及ID。
1 | void UPendingNetGame::NotifyControlMessage(UNetConnection* Connection, uint8 MessageType, class FInBunch& Bunch) |
NMT_Login
服务端收到客户端的 NMT_Login
后就拥有玩家的ID,此时进入 void AGameModeBase::PreLogin
。
1 | void UWorld::NotifyControlMessage(UNetConnection* Connection, uint8 MessageType, class FInBunch& Bunch) |
1 | void AGameModeBase::PreLogin(const FString& Options, const FString& Address, const FUniqueNetIdRepl& UniqueId, FString& ErrorMessage) |
GameSession->ApproveLogin
中会校验是否用满员,有需要的话,可以重写 bool AGameSession::AtCapacity(bool bSpectator)
自定义是否满员逻辑,默认最多16人,不算观战者。
1 | FString AGameSession::ApproveLogin(const FString& Options) |
失败则发送 NMT_Failure
,否则 WelcomePlayer()
1 | void UWorld::PreLoginComplete(const FString& ErrorMsg, TWeakObjectPtr<UNetConnection> WeakConnection) |
服务端有机会通过 GameInstance::ModifyClientTravelLevelURL
来修改 LevelName,这里默认是空的,需要的话也是重写。最后发送地图名和GameMode路径给客户端。
GameModeBase::GameWelcomePlayer
默认也是空的,官方说是可以利用它发送 NMT_GameSpecific
来通知客户端需要 DLC
才可进入。
1 | void UWorld::WelcomePlayer(UNetConnection* Connection) |
NMT_Welcome
客户端收到要进入的地图后,设置一下变量,后续 UEngine::TickWorldTravel
会进行真正加载地图。
1 | void UPendingNetGame::NotifyControlMessage(UNetConnection* Connection, uint8 MessageType, class FInBunch& Bunch) |
NMT_Netspeed
这个就是双方对网速,用于之后限流。但默认不开。
1 | TAutoConsoleVariable<int32> CVarNetEnableCongestionControl(TEXT("net.EnableCongestionControl"), 0, |
NMT_Join
客户端加载地图后,会将 PendingNetGame 中的 NetDriver 转移给 UWorld,同时还会将之后的 Control Message 回调转移到 UWorld 中。
1 | void UEngine::MovePendingLevel(FWorldContext &Context) |
用完 PendingNetGame 发完 NMT_Join,就不需要它了。
1 | void UPendingNetGame::TravelCompleted(UEngine* Engine, FWorldContext& Context) |
服务端收到 NMT_Join ,则是要为客户端创建 PlayerController,之后通过 PlayerController::ClientTravelInternal RPC 通知到客户端。
1 | void UWorld::NotifyControlMessage(UNetConnection* Connection, uint8 MessageType, class FInBunch& Bunch) |
SpawnPlayActor
就是创建 PlayerController 的地方,使用 GameMode::Login,创建 PlayerController,并设置相应的同步参数即可,最终将该玩家的PlayerController注册到 GameSession中。
1 | APlayerController* UWorld::SpawnPlayActor(UPlayer* NewPlayer, ENetRole RemoteRole, const FURL& InURL, const FUniqueNetIdRepl& UniqueId, FString& Error, uint8 InNetPlayerIndex) |
PostLogin 则是使用RPC创建HUD之类的东西,至此整个登录流程就已经走完了。
登录总结
- NMT_Hello
- Client:通知大小端、网络版本号、Token
- Server:校验版本号、Token
- NMT_Challenge
- Server:发送校验信息给客户端(但好像没用上)
- Client:收到校验信息
- NMT_Login
- Client:通过拼接URL 告知服务端自己的别名和 ID
- Server:检查是否满员,
GameMode::PreLogin
- NMT_Welcome
- Server:通知客户端当前的地图名和 GameMode 路径
- Client:记录收到的地图名,等下一轮 Tick 进行加载
- NMT_Join
- Client:地图加载完成后,发送 NMT_Join
- Server:
GameMode::Login
创建 PlayerController,设置好同步属性,GameMode::PostLogin
通过 PlayerController RPC 通知客户端创建 HUD,并换地图ClientTravel
(之前已经加载过一次地图了,这里还要再加载这个地图,是防止客户端在连接过程中换图?)
Bunch
以上登录的消息全是基于 Bunch 的,包括后续的属性同步也是。因此有必要在这认识一下 Bunch。
Bunch 分为两种,OutBunch 和 InBunch ,分别对应发送和接收。
1 | class FOutBunch : public FNetBitWriter |
其实就是带一些所属 Channel 信息,是否为开启 Channel 或 关闭 Channel,Packet 信息(因为依赖于 Packet发送)需要处理丢包的情况,和分包信息,因为 UDP 超过一定大小会直接丢包,需要分包处理。至于 ExportNetGUIDs
和 NetFieldExports
可以先不管,这是属性同步时,同步 Actor 用的,现在这还处于登录状态,根本没有 Actor 需要同步。
1 | class FInBunch : public FNetBitReader |
Channel
初步了解了 Bunch
后,还需要了解 Channel
毕竟是通过 Channel 的接口发出 Bunch。
1 | class UChannel : public UObject |
现在以 NMT_Hello 为例子,学习如何发送 Bunch。
1 | Conn->Channels[0]->SendBunch(&Bunch, true); |
Channel::SendBunch
NumOutRec
表示发出的需要可靠传输且还未确认对方收到的 Bunch 数量,此处认为若有太多数据对端还未确认则先暂存消息,否则调用父类进行发送。
1 | FPacketIdRange UControlChannel::SendBunch(FOutBunch* Bunch, bool Merge) |
检查对端是否需要打开一个新 Channel,Bunch 中的 bOpen
就是这个功能,通知对端创建一个 Channel, OpenedLocally
表示这个 Channel 是本地创建的。
1 | FPacketIdRange UChannel::SendBunch( FOutBunch* Bunch, bool Merge ) |
AppendExportBunches
使用 UPackageMapClient
将首次加入网络同步的 Actor 进行序列化,此处还处于登录环节,因此是空的,另外 IsInternalAck
为 true时 通常表示是回放的时候。
1 | TArray<FOutBunch*>& OutgoingBunches = Connection->GetOutgoingBunches(); |
当 Bunch 的基础属性相同,且发送缓冲区还未发送出去,且没超过最大 Bunch 大小,则考虑合包,当然若前面触发了 Actor 序列化,则不会进行合包。
1 | if |
若单个 Bunch 过大,又会进行拆包。
1 | if( Bunch->GetNumBits() > MAX_SINGLE_BUNCH_SIZE_BITS ) |
拆分包后,若拆分的包少于某个阈值,哪怕它原始 Bunch 不是可靠传输的,也会修改为可靠传输。
1 | int32 GCVarNetPartialBunchReliableThreshold = 8; |
若太多的可靠传输包,超出了阈值,就会断开连接,因为可靠传输是用的一条链表存放的还未确认的 Bunch 包,不能无限存放。
1 | if (Bunch->bReliable && bOverflowsReliable) |
还需要对拆分的 Bunch 做属性的调整。 OutgoingBunches
的数量通常只有一个,若大于 1 则要么是拆分包,要么是有导出的 Actor 同步包,它们都没有设置过 Bunch 的属性,需要在此处进行调整。
1 | for( int32 PartialNum = 0; PartialNum < OutgoingBunches.Num(); ++PartialNum) |
PrepBunch
是处理可靠 Bunch 的函数,若该 Bunch 为 bReliable
则将其放入 OutBunch
链表存起来,若发生丢包,则会取出该 Bunch,重新分配一个 Packet 将其传输过去。
1 | FOutBunch* UChannel::PrepBunch(FOutBunch* Bunch, FOutBunch* OutBunch, bool Merge) |
Bunch 丢失后的重传:
1 | void UChannel::ReceivedNak( int32 NakPacketId ) |
当然若对端连打开 ControlChannel 的 Bunch 都没有收到,会在 ControlChannel::Tick 中进行重发, OpenAcked
指的是打开 Channel 的 Bunch 是否收到 Ack。
1 | void UControlChannel::Tick() |
ReceivedBunch
Bunch 的接收的调用栈如下:
1 | UNetConnection::ReceivedRawPacket |
将 Packet 解包,让 Channel 来处理 Bunch。
1 | void UNetConnection::ReceivedPacket( FBitReader& Reader, bool bIsReinjectedPacket, bool bDispatchPacket ) |
最后将 Bunch 交给 void UControlChannel::ReceivedBunch( FInBunch& Bunch )
分发到 Notify 中,客户端对应 PendingNetGame 服务端对应 World
Connection->Driver->Notify->NotifyControlMessage(Connection, MessageType, Bunch);