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 调用了

总结

初始化流程图

graph TB
    A[FEngineLoop::Init]
    B[UGameEngine::Init]
    C[FURL::StaticInit]
    D["UGameInstance::InitializeStandalone"]
    E["CreateNewWorldContext(EWorldType::Game)"]
    F["DummyWorld = UWorld::CreateWorld"]
    G["UGameEngine::Start"]
    H["UGameInstance::StartGameInstance"]
    I["UEngine::Browse"]
    J("FURL::IsLocalInternal() ?")
    K[UEngine::LoadMap]
    L[Client Workflow: Start Connect]

    A --> B
    B --> C
    C --> D
    D --> E
    E --> F
    A --> G
    G --> H
    H --> I
    I --> J
    J --> |Yes| K
    J --> |No| L

服务器监听流程

graph TB
    S1[UEngine::LoadMap]
    S2[UWorld::Listen]
    S3[UEngine::CreateNamedNetDriver]
    S4[UIpNetDriver::InitListen]
    S5[UIpNetDriver::InitBase]
    S6[UIpNetDriver::InitConnectionlessHandler]

    S1 --> S2
    S2 --> S3
    S2 --> S4
    S4 --> S5
    S4 --> S6

客户端切换地图流程

graph TB
    S1[UEngine::SetClientTravel]
    S2[Save travel info to Context.TravelURL]

    S1 --> S2

    C1[UEngine::TickWorldTravel]
    C2("Context.TravelURL.IsEmpty() ?")
    C3("Context.PendingNetGame is valid ?")
    C4[UEngine::Browse]
    C5[UEngine::CancelPending]
    C6[UEngine::ShutdownWorldNetDriver]
    C7[NewObject UPendingNetGame]
    C8[UPendingNetGame::InitNetDriver]
    C9[UEngine::CreateNamedNetDriver]
    C10[UIpNetDriver::InitConnect]
    C11[UIpNetDriver::InitBase]
    C12[NewObject ServerConnection]
    C13[UIpConnection::InitBase]
    C14[UNetConnection::InitHandler]
    C15[Create Voice Channel On Current Connection]
    C16[Create Control Channel On ServerConnection]
    C17[ServerConn->Handler->BeginHandshaking]
    C18["Handshaking workflow(见时序图)"]

    C19[UPendingNetGame::Tick]
    C20[UIpNetDriver::TickDispatch]
    C21[UIpNetDriver::TickFlush]
    C22[UIpNetDriver::PostTickFlush]
    C23(Is bSuccessfullyConnected ?)
    C24[UEngine::LoadMap]
    C25[UPendingNetGame::LoadMapCompleted]
    C26[UPendingNetGame::SendJoin]
    C27[Clear NetDriver in PendingNetGame]
    C28[Clear PendingNetGame in Context]

    C1 --> C2
    C1 --> C3
    C2 --> |No| C4
    C4 --> C5
    C5 --> C6
    C6 --> C7
    C7 --> C8
    C8 --> C9
    C9 --> C10
    C10 --> C11
    C10 --> C12
    C12 --> C13
    C13 --> C14
    C14 --> C15
    C10 --> C16
    C8 --> C17
    C17 --> C18
    C3 --> |Yes| C19
    C19 --> C20
    C20 --> C21
    C21 --> C22
    C3 --> |Yes| C23
    C23 --> |Yes| C24
    C24 --> C25
    C25 --> C26
    C26 --> C27
    C27 --> C28

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

sequenceDiagram
    participant C1 as UPendingNetGame
    participant C2 as Client:StatelessComp
    participant C3 as Server: StatelessComp
    participant C4 as Server: UWorld

    C2 ->> C3: HandshakeBegin: 时间戳为0
    activate C3
    Note right of C3: 在回包中设置时间戳
和cookie C3 -->> C2: ConnectChallenge deactivate C3 activate C2 Note left of C2: 在Res包中复制服务器
的时间戳和cookie并
将状态设置为Initiali
zedOnLocal然后根据
cookie计算网络包初
始序列号 C2 ->> C3: ChallengeResponse deactivate C2 activate C3 Note right of C3: 重新生成cookie和包
中的cookie进行比较。
并根据cookie计算网
络包初始序列号。在
Ack包中将时间戳置
为-1。最后创建一个
Connection对象加到
连接列表中 C3 -->> C2: ChallengeAck deactivate C3 activate C2 Note left of C2: 收到握手包时间戳为
-1表示握手成功结束。
将状态设置为Initializ
ed并通知Handler完成
初始化,Handler会执
行HandshakeComple
te代理 C2 ->> C1: HandshakeComplete deactivate C2 activate C1 Note left of C1: SendInitialJoin函数被
代理触发,将本地大
小端,版本信息,加
密Token放在Hello包
中发给服务器 C1 ->> C4: NMT_Hello deactivate C1 activate C4 Note right of C4: 判断客户端的版本是
否符合,符合就发
NMT_Challenge不符
合则发送
NMT_Upgrade C4 -->> C1: NMT_Challenge(NMT_Upgrade) deactivate C4 activate C1 Note left of C1: 收集昵称, Id, 用游戏
选项构造地图URL C1 ->> C4: NMT_Login deactivate C1 activate C4 Note right of C4: 保存包里的昵称Id信
息到连接中,调用
GameMode::PreLogin
进行登录验证,如果
验证失败返回
NMT_Failure,否则会
将当前地图名,游戏
模式发给客户端 C4 -->> C1: NMT_Welcome deactivate C4 activate C1 Note left of C1: 保存服务器下发的地
图信息,设置
bSuccessfullyConnec
ted标记,发送
NMT_Netspeed并等待
地图加载完成 Note right of C1: 地图加载完成会将
PendingNetGame中
的NetDriver绑定到新
的World对象中 C1 ->> C1: LoadMapCompleted Note left of C1: 置空 PendingNet
Game,正式加入
服务器 C1 ->> C4: NMT_Join deactivate C1 activate C4 Note right of C4: 调用GameMode::Log
in创建PlayerControll
er,调用GameMode::
InitNewPlayer初始化
新的Controller,调用
SetReplicates设置控
制器为可复制,并与
当前连接绑定,最后
调用 GameMode::Po
stLogin 完成登录创建
新的DefaultPawn deactivate C4

其他注意点

  • 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,然后自己功成身退(销毁)。