UE4基础:客户端服务器连接流程
引擎与网络相关的初始化步骤
- 客户端和服务器启动时,首先会在
FEngineLoop::Init
中创建GEngine
对象,并调用GEngine->Init
进行初始化,这里和网络相关的初始化有以下- 调用静态函数
FURL::StaticInit
从配置文件 xxxEngine.ini 中初始化默认的服务器信息,如 Protocol, Name, Host, Port,这里的 Port 可以通过命令行 -Port=n 指定 - 创建
GameInstance
对象并调用InitializeStandalone
进行初始化- 创建
GameInstance
对应的WorldContext
- 创建临时的
DummyWorld
- 创建
- 调用静态函数
- 调用
GEngine->Start
启动游戏,默认的内部的逻辑只是调用GameInstance->StartGameInstance()
- 从配置文件中读取默认的地图名参数
UGameMapsSettings
- 调用
UEngine::Browse
加载默认的地图,客户端连接服务器,服务器启动监听都是发生在这个函数里
- 从配置文件中读取默认的地图名参数
客户端流程
- 客户端调用
UEngine::SetClientTravel
发起地图切换,这个函数里只是把这个请求记录下 - 在引擎的每帧的
UEngine::TickWorldTravel
里,会去检查是否有服务器或客户端的地图切换请求,如果有就用 URL 参数构造一个 FURL 对象,并调用UEngine::Browse
进行切换,FURL 的构造函数里会去解析服务器地址,端口,参数等信息 UEngine::Browse
里对地图切换会有个判断,如果是URL.IsLocalInternal()
也就是服务器地址是空的,那就直接调用UEngine::LoadMap
直接加载本地地图,如果是URL.IsInternal() && GIsClient
那就需要发起和服务器的连接流程- 调用
UEngine::CancelPending
关闭已有的 PendingNetGame 对象 - 调用
UEngine::ShutdownWorldNetDriver
销毁当前 World 的 NetDriver 和 DemoNetDriver - 调用
NewObject<UPendingNetGame>()
新建一个 PendingNetGame 对象 - 初始化新的 PendingNetGame 对象并调用
UPendingNetGame::InitNetDriver
初始化 NetDriver- 调用
GEngine->CreateNamedNetDriver
创建一个具名 NetDriver 对象,这个函数有两个参数,名称参数为NAME_PendingNetDriver("PendingNetDriver")
定义参数为NAME_GameNetDriver("GameNetDriver")
。名称参数是之后用来查找 NetDriver 用的。定义参数是用来创建 NetDriver 对象时,根据这个字符串在Engine->NetDriverDefinitions
中查找对应的FNetDriverDefinition
对象,然后根据查找出来的定义对象中DriverClassName/DriverClassNameFallback
两个变量确定 NetDriver 对象的类名,最后用这个类调用 NewObject 创建 NetDriver 对象。默认的定义有两个配置在 xxxEngine.ini 中,分别是 GameNetDriver 对应的 NetDriver 类是 /Script/OnlineSubsystemUtils.IpNetDriver; DemoNetDriver 对应的 NetDriver 类是 /Script/Engine.DemoNetDriver - 在创建出来的 NetDriver 对象(这里的 NetDriver 对象一般都是
UIpNetDriver
)上调用InitConnect
初始化连接- 调用
UIpNetDriver::InitBase
进行基础信息初始化和 Socket 初始化- 加载
NetConnectionClass
,配置在 xxxEngine.ini 文件的 NetConnectionClassName 中 - 注册
FNetworkNotify
类型的回调 - 调用
ISocketSubsystem::CreateSocket
创建FSocket
对象,协议为 UDP。然后对这个对象设置一些 socket 属性,比如是否支持广播,是否支持 IP 端口重用,设置接收/发送缓冲区大小,绑定端口,设置是否阻塞 - 根据
CVarNetIpNetDriverUseReceiveThread
和SocketSubsystem->IsSocketWaitSupported
来决定是否创建单独的接收线程
- 加载
- 根据
NetConnectionClass
创建客户端与服务器的连接对象ServerConnection
(这个对象一般都是UIpConnection
),并调用UNetConnection::InitLocalConnection
进行初始化,此时的连接状态是EConnectionState::USOCK_Pending
(等待连接)- 调用
UIpConnection::InitBase
进行基础信息初始化- 初始化一些状态信息
- 调用
UNetConnection::InitHandler
初始化PacketHandler
和StatelessConnectHandlerComponent
- 创建 VoiceChannel
- 初始化服务器地址对象
RemoteAddr
- 初始化发送缓冲区
- 调用
- 对 ServerConnection 对象创建控制通道 ControlChannel
- 调用
- 连接初始化成功后,调用
PacketHandler::BeginHandshaking
开始握手流程,最终会调用StatelessConnectHandlerComponent::NotifyHandshakeBegin
(这个StatelessConnectHandlerComponent
是专门用来处理握手包的)发送握手包,握手包的大小为 195 位 (HANDSHAKE_PACKET_SIZE_BITS
+ 1),格式为 1 为握手包标志位 + 1 位 SecretId + 4字节时间戳 + 20字节哈希值(cookie) + 1 位包结束符标志位。不过客户端这时发的握手包时间戳和哈希值都为0 - 接着客户端会收到服务器的握手回包 ConnectChallenge,此次回包中的时间戳会大于0,然后向服务器发送 ChallengeResponse,也是握手包格式,只不过此时时间戳和哈希值填的值是复制的服务器返回的握手包里的值,并且将
StatelessConnectHandlerComponent
的状态设置为Handler::Component::State::InitializedOnLocal
表示本端已初始化,并且根据 cookie 计算LastServerSequence/LastClientSequence
做为后续的网络包初始序列号 - 接着客户端会再次收到服务器的握手回包 ConnectChallengeAck,不过此次的回包中的时间戳值小于0 (实际为 -1),表示握手流程结束,将 State 设置为
Handler::Component::State::Initialized
表示双端都已完成初始化。同时会调用HandlerComponent::Initialized
表示自己完成初始化,这个函数里会通知所属的 PacketHandler 去检查是否所有的 HandlerComponent 都完成初始化,如果是的话就调用PacketHandler::HandlerInitialized
,在其中调用执行代理HandshakeCompleteDel
,这个代理就是之前在UPendingNetGame::InitNetDriver
里调用ServerConn->Handler->BeginHandshaking
时传入的成员函数UPendingNetGame::SendInitialJoin
SendInitialJoin
就是通过 ControlChannel 向服务器发送NMT_Hello
包,参数为大小端标志,网络版本信息CRC32哈希值,EncryptionToken 值- 如果版本信息的CRC32一致的话,客户端接着会收到服务器的
NMT_Challenge
包(否则就是NMT_Upgrade
),在这里,客户端会通过ULocalPlayer
收集昵称,游戏选项等信息用来拼出正式的地图URL,然后向服务器发送登录包NMT_Login
,登录包有 4 个参数,分别是ClientResponse
字符串,值固定为 “0”; URL 字符串,LocalPlayer->GetPreferredUniqueNetId
值,UGameInstance::GetOnlinePlatformName
值 - 接着会收到服务器的
NMT_Welcome
包,表示服务器允许登录,这个包里有3个参数,分别是地图名,GameMode类名,RedirectURL,用这些可以构造出一个正式的 URL 赋值给UPendingNetGame::URL
,同时设置UPendingNetGame::bSuccessfullyConnected
为 true, 标志连接流程结束,客户端可以开始加载地图了。最后向服务器发送NMT_Netspeed
包通知客户端当前的网络速率
- 调用
- 回到
UEngine::TickWorldTravel
这个函数里,这个函数除了会处理地图切换请求外,还会检查是否PendingNetGame
对象,如果有这个对象,会调用它的Tick
函数驱动整个握手和控制通道消息流程,同时也会每帧检查PendingNetGame->bSuccessfullyConnected
标志和PendingNetGame->URL.Map
,如果都合法就调用LoadMap
开始本地加载地图,加载地图的时候会调用UEngine::MovePendingLevel
将PendingNetGame
中的NetDriver
对象赋值给新的UWorld
的NetDriver
- 同时将
NetDriver
重命名为NAME_GameNetDriver
- 调用
UNetDriver::SetWorld
设置NetDriver
对象的World
成员变量为新World
FNetworkNotify
回调也改为新World
- 绑定成员函数到
World
的几个Tick
代理上(OnTickDispatch/OnPostTickDispatch/OnTickFlush/OnPostTickFlush
) - 将新 World 中的网络相关的 Actor 加到 NetDriver 中
- 同时将
- 上一步的
UEngine::LoadMap
成功之后,会调用UPendingNetGame::LoadMapCompleted
这个函数里会做如下事情,然后置空Context.PendingNetGame
之后服务器就会向客户端同步 PlayerController 等信息,整个连接流程到这里结束。- 调用
UPendingNetGame::SendJoin
向服务器发送NMT_Join
包,无参数 - 将
Context.PendingNetGame->NetDriver
置空
- 调用
- 调用
服务器流程
监听
在 UEngine::Browse
会对判断地图切换的 URL,如果是本地切换(判断条件是 Host 为空),则直接调用 UEngine::LoadMap
加载目标地图,DS 服务器就是在这里加载地图,也就是说 DS 的 Engine.ini 配置里 URL 的配置选项里的 Host 字段必须为空,否则 DS 是启动不了的
在 UEngine::LoadMap
里主要做的事情就是清理销毁旧地图及其资源,创建真正的 World
替换旧地图(第一次的旧地图是之前创建的 DummyWorld
),加载新地图资源。如果是服务器的话(或者命令行里有 -Listen 选项),就会调用新 World
对象的 Listen
函数开始监听,监听函数里要做以下事情
-
调用
GEngine->CreateNamedNetDriver
创建一个具名 NetDriver 对象,名称参数为NAME_GameNetDriver
类型参数为NAME_GameNetDriver
,并与World
对象绑定 -
调用
UNetDriver::InitListen
开始监听- 调用
UIpNetDriver::InitBase
进行基础信息初始化和 Socket 初始化,具体的内容已经在之前客户端部分分析过了,与客户端有几点不同- 这里
FNetworkNotify
的回调是World
对象,就是说 DS 是在World
里处理ControlMessage
而客户端是在UPendingNetGame
中处理的。 - 还有一点是 Socket 初始化好之后就会在指定的端口开始监听,如果这个端口被占用了,会将端口号 +1 进行重试,直到找到一个可用的端口号
- 这里
- 调用
UIpNetDriver::InitConnectionlessHandler
初始化ConnectionlessHandler
和StatelessConnectHandlerComponent
。这里也和客户端有所不同,客户端的这部分初始化是在 Conenction 里进行的,而服务器是在 NetDriver 里完成的,因为服务器只有在经过握手流程验证成功之后才会给每个客户端创建对应的Connection
对象加到连接列表中,换句话说,这个ConnectionlessHandler
和StatelessConnectHandlerComponent
是需要服务所有的客户端的,所以需要在 NetDriver 这一层进行初始化一个全局的 Handler
- 调用
到这里服务器监听流程结束了,引擎在走完接下来的初始化流程后就开始等待客户端的连接
连接
当服务器收到客户端发来的第一个包(UIpNetDriver::TickDispatch
中收包),首先会根据客户端地址去 MappedClientConnections
里查找是否有对应的连接对象,如果没有会首先调用 FNetworkNotify::NotifyAcceptingConnection
也就是 UWorld::NotifyAcceptingConnection
,根据这个函数的返回值判断是否处理这个请求,只有是服务器并且没有在切换地图,才会处理
通过 NotifyAcceptingConnection
检查之后,NetDriver
会将这个包直接调用 PacketHandler::IncomingConnectionless
交给 ConnectionlessHandler
去处理,最终分发到 StatelessConnectHandlerComponent::IncomingConnectionless
函数中进行握手包的判断和处理,握手包的处理逻辑是根据握手包中的时间戳来判断的,如果这个时间戳为0,则代表是初始握手包,这时候调用 StatelessConnectHandlerComponent::SendConnectChallenge
向客户端发送 ConnectChallenge
包,包格式和握手包一样(格式见客户端分析部分),此时会带上服务器的时间戳和根据时间戳,客户端地址,还有握手加密随机种子计算出来的cookie值
按之前客户端部分的分析,客户端此时会向服务器发送 ChallengeResponse
,和上一步一样,因为还没有对这个客户端地址创建 Connection
对象,所以还是使用 PacketHandler::IncomingConnectionless
分发,最终进到 StatelessConnectHandlerComponent::IncomingConnectionless
中处理,此时的握手包中的时间戳不为 0,说明是 ChallengeResponse
包,这时服务器会从包里取出 SecretId 和 时间戳,然后和客户端地址再次生成 cookie,并且和包里带的 cookie 进行比较,如果符合说明这个包合法,接着服务器根据 cookie 计算 LastServerSequence/LastClientSequence
做为后续的网络包初始序列号,最后向客户端发送 ChallengeAck
包,此时包里的时间戳字段值为 -1,表示握手阶段成功结束。然后在 NetDriver
中,会创建一个 Connection 对象,然后调用 UIpConnection::InitRemoteConnection
进行初始化,这部分初始化和之前客户端部分里的分析差不多,额外会设置当前 Connection 的下一个期待消息类型为 NMT_Hello
(用来之后过滤消息用),最后将这个连接对象加到连接列表中
连接对象创建后,每次收到客户端来的包,服务器都会从包里解析出通道类型和通道下标,如果该连接还没有对应的通道,则会给这个连接创建相应的通道用于通信
服务器收到客户端发来的 NMT_Hello
之后,会先判断网络版本信息是否符合,如果不符合会下发 NMT_Upgrade
然后断开连接。如果包里带了 EncryptionToken 字段,会执行 FNetDelegates::OnReceivedNetworkEncryptionToken
代理(实际就是 UGameInstance::ReceivedNetworkEncryptionToken
)。最后发送 ControlChallenge 包 NMT_Challenge
并等待客户端登录
服务器收到客户端发来的 NMT_Login
之后,使用包里的 URL 参数构造一个新的 URL,保存 PlayerId 和 OnlinePlatformName 到相应的 Connection
中。然后调用 GameMode 的 AGameModeBase::PreLogin
方法验证登录,如果 PreLogin
失败则发送 NMT_Failure
否则调用 UWorld::WelcomePlayer
,将当前地图名,GameMode 类名写入 NMT_Welcome
发给客户端(在发送之前还会调用一次 AGameModeBase::GameWelcomePlayer
获取 RedirectURL)
等客户端地图加载完成后,服务器会收到客户端发来的 NMT_Join
包,此时服务器会调用 UWorld::SpawnPlayActor
内部逻辑如下
- 调用
AGameModeBase::Login
创建一个新的PlayerController
- 调用
AGameModeBase::InitNewPlayer
初始化PlayerController
- 初始化出生点位置
- 初始化
PlayerState
中的PlayerName
- 调用
- 调用
SetReplicates
设置新的PlayerController
的可复制性 - 将新的
PlayerController
与Connection
进行关联 - 调用
AGameModeBase::PostLogin
完成登录- 生成
DefaultPawn
并关联
- 生成
最后设置 Connection
的登录状态为 EClientLoginState::ReceivedJoin
表示已登录。如果服务器在进行 SeamlessTravel 或者客户端当前地图不匹配,就会调用 APlayerController::ClientTravel
通知客户端切换地图
服务器的连接流程到此结束,接下来就是正常的值复制和 RPC 调用了
总结
初始化流程图
服务器监听流程
客户端切换地图流程
客户端与服务器握手登录时序图
其他注意点
-
StatelessConnectHandlerComponent::Tick
这个函数中,如果是客户端没收到服务器的 ConnectChallenge 和 ChallengeAck 包的话,会每隔 1 秒进行一次重传。如果是服务器的话,会每隔 [15, 20] 秒更新 cookie 加密种子 -
NetDriver
在TickDispatch
函数中负责从 Socket 里收取网络包,并且将网络包分发给对应的Connection
对象UNetConnection::ReceivedPacket
进行处理。如果是服务器,那么握手阶段的包会直接丢给NetDriver
的PacketHandler
进行处理。在UNetConnection::ReceivedPacket
会将网络包分发给对应的Channel
的UChannel::ReceivedRawBunch
进行处理。如果没有对应通道,会新创建一个。总的来说,NetDriver
负责管理Socket
和NetConnection
,分发从Socket
里的原始数据给NetConnection
。NetConnection
负责管理Channel
,然后将原始数据组装成Bunch
分发给对应的Channel
,还有一个重要功能是实现可靠 UDP 的功能(Ack, 重传等机制),这里不展开。Channel
就是和 GamePlay 逻辑直接关联的,GamePlay 通过Channel
进行复制和 RPC 调用。 -
对客户端的来说,PendingNetGame 是非常重要的,这个对象驱动着整个连接流程,当连接成功后,这个对象会将 NetDriver 的控制权转交给 World,然后自己功成身退(销毁)。