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 端口重用,设置接收/发送缓冲区大小,绑定端口,设置是否阻塞
          • 根据 CVarNetIpNetDriverUseReceiveThreadSocketSubsystem->IsSocketWaitSupported 来决定是否创建单独的接收线程
        • 根据 NetConnectionClass 创建客户端与服务器的连接对象 ServerConnection(这个对象一般都是 UIpConnection),并调用 UNetConnection::InitLocalConnection 进行初始化,此时的连接状态是 EConnectionState::USOCK_Pending (等待连接)
          • 调用 UIpConnection::InitBase 进行基础信息初始化
            • 初始化一些状态信息
            • 调用 UNetConnection::InitHandler 初始化 PacketHandlerStatelessConnectHandlerComponent
            • 创建 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::MovePendingLevelPendingNetGame 中的 NetDriver 对象赋值给新的 UWorldNetDriver
      • 同时将 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 初始化 ConnectionlessHandlerStatelessConnectHandlerComponent。这里也和客户端有所不同,客户端的这部分初始化是在 Conenction 里进行的,而服务器是在 NetDriver 里完成的,因为服务器只有在经过握手流程验证成功之后才会给每个客户端创建对应的 Connection 对象加到连接列表中,换句话说,这个 ConnectionlessHandlerStatelessConnectHandlerComponent 是需要服务所有的客户端的,所以需要在 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 的可复制性
  • 将新的 PlayerControllerConnection 进行关联
  • 调用 AGameModeBase::PostLogin 完成登录
    • 生成 DefaultPawn 并关联

最后设置 Connection 的登录状态为 EClientLoginState::ReceivedJoin 表示已登录。如果服务器在进行 SeamlessTravel 或者客户端当前地图不匹配,就会调用 APlayerController::ClientTravel 通知客户端切换地图

服务器的连接流程到此结束,接下来就是正常的值复制和 RPC 调用了

总结

初始化流程图

服务器监听流程

客户端切换地图流程

客户端与服务器握手登录时序图

其他注意点

  • StatelessConnectHandlerComponent::Tick 这个函数中,如果是客户端没收到服务器的 ConnectChallenge 和 ChallengeAck 包的话,会每隔 1 秒进行一次重传。如果是服务器的话,会每隔 [15, 20] 秒更新 cookie 加密种子

  • NetDriverTickDispatch 函数中负责从 Socket 里收取网络包,并且将网络包分发给对应的 Connection 对象 UNetConnection::ReceivedPacket 进行处理。如果是服务器,那么握手阶段的包会直接丢给 NetDriverPacketHandler 进行处理。在 UNetConnection::ReceivedPacket 会将网络包分发给对应的 ChannelUChannel::ReceivedRawBunch 进行处理。如果没有对应通道,会新创建一个。总的来说,NetDriver 负责管理 SocketNetConnection,分发从 Socket 里的原始数据给 NetConnectionNetConnection 负责管理 Channel,然后将原始数据组装成 Bunch 分发给对应的 Channel,还有一个重要功能是实现可靠 UDP 的功能(Ack, 重传等机制),这里不展开。Channel 就是和 GamePlay 逻辑直接关联的,GamePlay 通过 Channel 进行复制和 RPC 调用。

  • 对客户端的来说,PendingNetGame 是非常重要的,这个对象驱动着整个连接流程,当连接成功后,这个对象会将 NetDriver 的控制权转交给 World,然后自己功成身退(销毁)。