傳統的 C/S 架構下,Client 和 Server 通常會建立一條抽象的 Connection,用來進行兩端的通訊。
UE 的官方檔案中提供了 Client 連線到 Server 的範例 ,簡單來說分為如下幾步:
./Binaries/Win64/<PROJECT_NAME>Server.exe -log
./Binaries/Win64/<PROJECT_NAME>Client.exe 127.0.0.1:7777 -WINDOWED -ResX=800 -ResY=450
預設情況下,專用伺服器在 localhost Ip 地址(
127.0.0.1
)的埠7777
處監聽。可以新增命令列引數-port=<PORT_NUMBER>
,更改專用伺服器的埠。如果要更改伺服器正在使用的埠,則還需要更改將使用者端連線到伺服器時的埠。
Client 連線到 Server 的前提是 Server 啟動完畢,監聽完畢埠,準備好接收連線了。UE 中監聽的核心介面如下:
bool UWorld::Listen( FURL& InURL );
其介面核心引數為一個 FURL
,UE 中會根據啟動引數和設定等構建一個 FURL,其結構如下 (只展示部分變數):
//URL structure.
USTRUCT()
struct FURL
{
// Optional hostname, i.e. "204.157.115.40" or "unreal.epicgames.com", blank if local.
UPROPERTY()
FString Host;
// Optional host port.
UPROPERTY()
int32 Port;
// Map name, i.e. "SkyCity", default is "Entry".
UPROPERTY()
FString Map;
// Options.
UPROPERTY()
TArray<FString> Op;
}
可以看到裡面有關鍵的 Host 和 Port 等資訊。
Listen 介面具體做了什麼呢?
UEngine:: CreateNamedNetDriver
建立 NetDriver,主要驅動網路同步UNetDriver::InitListen
解析 FURL,監聽埠UNetDriver
,顯然它是一個比較重要的網路管理類,這裡簡單看下其結構可以看到主要負責:
同時其派生了不同的類,如:
UWebSocketNetDriver
,可以在 UE4中使用 WebSocket 協定進行網路通訊可以看到其實和普通的 C++ 建立 TCP C/S 連線類似,最終都是建立一個 Socket 並且 Bind 到指定埠。
使用者端啟動之後,也是類似的流程,建立 NetDriver 驅動網路相關的流程,對比 Server,其多了一個 UPendingNetGame
的物件。UPendingNetGame
類是一個用於處理網路遊戲連線過程的類。它在使用者端嘗試連線到伺服器時建立,並在連線成功或失敗後銷燬。
關於 UPendingNetGame
用處:
UPendingNetGame 主要負責處理使用者端與伺服器之間的連線流程。主要功能包括:
a. 處理連線請求:使用者端向伺服器發起連線請求時,UPendingNetGame 負責處理這個請求,包括建立通訊端連線、傳送握手請求等。
b. 載入關卡:在連線過程中,若伺服器需要使用者端載入一個關卡,UPendingNetGame 負責處理這個請求,包括載入關卡資源、同步關卡狀態等。
c. 狀態同步:在連線過程中,UPendingNetGame 負責與伺服器進行狀態同步,包括玩家資料、遊戲規則等。
d. 錯誤處理:若連線過程中出現錯誤,如超時、被拒絕等,UPendingNetGame 負責處理這些錯誤,通知使用者並做出相應處理建立與銷燬:
a. 建立:當用戶端嘗試連線到伺服器時,會建立一個 UPendingNetGame 範例。
b. 銷燬:當用戶端成功連線到伺服器並完成狀態同步後,UPendingNetGame 完成其任務並被銷燬。如果連線過程中出現錯誤,如超時、被拒絕等, UPendingNetGame 也會在處理完錯誤後被銷燬
Client 的初始化流程大致如下:
Server 端上 PacketHandler 處理的封包的結構如下:
/**
* Represents a view of a received packet, which may be modified to update Data it points to and Data size, as a packet is processed.
* Should only be stored as a local variable within functions that handle received packets.
**/
struct FReceivedPacketView
{
/** View of packet data, with Num() representing BytesRead - can reassign to point elsewhere, but don't use to modify packet data */
TArrayView<const uint8> Data;
/** Receive address for the packet */
TSharedPtr<FInternetAddr> Address;
/** Error if receiving a packet failed */
ESocketErrors Error;
};
Server 監聽完埠之後就要處理使用者端發過來的連線請求,由於是 UDPSocket,所以只需要簡單的 Bind + RecvFrom 就能接收資料了。其主流程主要由 NetDriver 的 TickDispatch 驅動,如下:
UIpNetDriver::TickDispatch
UIpNetDriver::AdvanceCurrentPacket
FPacketIterator::ReceiveSinglePacket
迭代器收包
FReceiveThreadRunnable::Run
本身是生產者,可以將 ReceiveQueue 理解為一個資料中介軟體,IpNetDriver 的 TickDispatch 則是消費者,一直消費 ReceiveQueue 的資料SocketReceiveThreadRunnable
執行緒中一直使用 FSocket::RecvFrom
(抽象介面,大部分情況下都是為 FSocketBSD::RecvFrom
)接收資料,其底層實現就是使用 recvfrom
這個作業系統介面SocketReceiveThreadRunnable 預設是沒有開啟的,官方說明如下:
// If the cvar is set and the socket subsystem supports it, create the receive thread.
CVarNetIpNetDriverUseReceiveThread.GetValueOnAnyThread() != 0 && SocketSubsystem->IsSocketWaitSupported()
首先 Server 需要檢查這個 Packet 是否已經有連線了,這裡引出一個問題,Server 端是如何管理和查詢 Connection 的?主要是通過解析 Packet 的 Address,在 UNetDriver
中查詢快取地址對映關係。
// 宣告
class UNetDriver {
TMap<TSharedRef<const FInternetAddr>, UNetConnection*, FDefaultSetAllocator, FInternetAddrConstKeyMapFuncs<UNetConnection*>> MappedClientConnections;
}
// 使用
const TSharedRef<const FInternetAddr> FromAddr = ReceivedPacket.Address.ToSharedRef();
UNetConnection** Result = MappedClientConnections.Find(FromAddr);
接下來是處理 Packet
UIpNetDriver::ProcessConnectionlessPacket
PacketHandler::IncomingConnectionless
校驗 Packet 正確性
PacketHandler::Incoming_Internal
HandlerComponent
對包進行處理StatelessConnectHandlerComponent::IncomingConnectionless
處理無連線的 Packet
StatelessConnectHandlerComponent::ParseHandshakePacket
檢查是否為握手包,根據 Packet 時間戳確定是否是 bInitialConnectStatelessConnectHandlerComponent::SendConnectChallenge
StatelessConnectHandlerComponent::HasPassedChallenge
校驗UIpConnection
UIpConnection::InitRemoteConnection
這裡初始化連線,給使用者端傳送 NMT_Hello
包,開始正式的握手流程,這裡開始有一個狀態機來驅動連線過程
EClientLoginState::Type::LoggingIn
FNetworkNotify::NotifyAcceptedConnection
通知接收連線UNetDriver::AddClientConnection
新增 UIpConnection
關於 Challenge
Challenge 訊息是 Unreal Engine 4(UE4)中的一種網路訊息,用於在使用者端和伺服器之間進行身份驗證。在 UE4 中,使用者端和伺服器之間的通訊是通過一種稱為 Unreal Network Protocol(簡稱 UNet)的協定實現的。UNet 通過在使用者端和伺服器之間傳送各種型別的網路訊息來管理通訊。在 UE4 中,當用戶端第一次連線到伺服器時,伺服器會向用戶端傳送一個 Challenge 訊息,其中包含一個隨機生成的 Challenge 令牌。使用者端必須將這個 Challenge 令牌使用預共用金鑰(PSK)進行簽名,並將簽名後的結果傳送回伺服器。伺服器會驗證簽名是否正確,如果正確,則表示使用者端是一個合法的使用者,並將向用戶端傳送一個 ChallengeAck 訊息,其中包含伺服器的簽名和一些其他的驗證資訊。使用者端必須驗證 ChallengeAck 訊息是否正確,並將訊息傳送回伺服器,以便進行最終的身份驗證。
關於 NMT_Hello
可以看到收到使用者端連線包之後,除了回覆正常的 Ack 包之外,會主動給使用者端傳送一個 NMT_Hello 包,這裡的 NMT_Hello 是一個列舉。UE4 中 NMT 開頭的列舉是指 NetworkMessageTypes,是 Unreal Engine 4(UE4)中用於管理網路訊息型別的一組列舉。在 UE4 中,網路訊息是通過一種稱為 Unreal Network Protocol(簡稱 UNet)的協定進行傳輸和管理的。UNet 通過在使用者端和伺服器之間傳送各種型別的網路訊息來管理通訊。通過接收不同的 NMT 訊息,從而在使用者端伺服器連線過程中,不同階段執行不同的操作,比如當前收到這個訊息應該載入地圖或者建立 PlayerController。
至此大致梳理完了 Client 和 Server 的握手流程:
UPendingNetGame
在正式連線前驅動握手過程PacketHandler
的 HandlerComponent 中的 StatelessConnectHandlerComponent
,其負責整個握手過程,此外 PacketHandler 的 HandlerComponent 可以掛載各種元件來支援對封包的處理,比如 RSA,加密解密等握手過程中顯然有丟包的可能,在 CS 握手過程中,大致傳送的 Packet 如下:
Client 主要傳送兩個包,Handshake 和 ChallengeResponse,當 Client 沒有收到迴應時,對應階段在 StatelessConnectHandlerComponent::Tick
都會有一個重發機制。參考程式碼如下:
void StatelessConnectHandlerComponent::Tick(float DeltaTime)
{
if (Handler->Mode == Handler::Mode::Client)
{
// ... 省略一些程式碼
if (LastSendTimeDiff > 1.0)
{
if (State == Handler::Component::State::UnInitialized)
{
NotifyHandshakeBegin();
}
else if (State == Handler::Component::State::InitializedOnLocal && LastTimestamp != 0.0)
{
SendChallengeResponse(LastSecretId, LastTimestamp, LastCookie);
}
}
}
大致如下:
握手完畢後就要準備一些 Gameplay 層的相關操作,比如載入地圖等,Packet 對於應用層還是太底層了,UE 為此引入了 Bunch 和 Channel 的概念
首先 Bunch 和 Packet 的關係如下:
Bunch 分為 FInBunch 和 FOutBunch,根據這個名字可以看出分別對應收到的 Bunch 結構和 傳送的 Bunch 結構,其繼承鏈如下:
FInBunch 的結構如下:
class ENGINE_API FInBunch : public FNetBitReader
{
public:
// 省略一些欄位
int32 PacketId; // Note this must stay as first member variable in FInBunch for FInBunch(FInBunch, bool) to work
FInBunch * Next;
UNetConnection * Connection; // 屬於哪個 Connection
int32 ChIndex; // channel 的下標
int32 ChType; // channel 的型別
FName ChName; // channel 的名稱
int32 ChSequence; // Channel 的 Seqid
uint8 bOpen:1; // 是否是 Channel 的首包
uint8 bClose:1; // 是否是 Channel 的結束包
uint8 bDormant:1; // 是否處於休眠
uint8 bIsReplicationPaused:1; // 複製同步是否被暫停了
uint8 bReliable:1; // 是否為可靠的 Bunch
uint8 bPartial:1; // 該 Bunch 是否被拆分
uint8 bPartialInitial:1; // 是不是分片傳輸中的第一個 Bunch
uint8 bPartialFinal:1; // 是不是分片傳輸中的最後一個 Bunch
}
FOutBunch 的結構如下:
class ENGINE_API FOutBunch : public FNetBitWriter
{
public:
// 省略一些欄位
FOutBunch * Next;
UChannel * Channel;
double Time;
int32 ChIndex;
int32 ChType;
FName ChName;
int32 ChSequence;
int32 PacketId;
uint8 ReceivedAck:1; // 標記這個封包是否已經被確認,以避免重複傳送
uint8 bOpen:1;
uint8 bClose:1;
uint8 bDormant:1;
uint8 bReliable:1;
uint8 bPartial:1; // Not a complete bunch
uint8 bPartialInitial:1; // The first bunch of a partial bunch
uint8 bPartialFinal:1; // The final bunch of a partial bunch
}
Bunch 的資訊中,除了一些分包相關的資訊,最主要的便是 Channel 相關的資訊了,比如這個 Bunch 屬於哪個 Channel?Channel 的型別是什麼?那麼什麼是 Channel ?其用處是什麼?
UE 中,Channel 主要分為三種型別:
- 連線建立和斷開:UControlChannel會處理網路連線建立和斷開的訊息。例如,當用戶端與伺服器建立連線時,UControlChannel會傳送和接收連線請求和響應,以便雙方建立通訊。同樣,當連線斷開時,UControlChannel會負責傳送斷開通知,通知另一方連線已關閉。
- 心跳檢測:為了確保連線保持活躍,UControlChannel會定期傳送和接收心跳訊息。這些訊息用於檢測雙方是否仍線上,以便在一方掉線時及時處理連線斷開事件。
- 通道管理:UControlChannel負責處理通道的開啟和關閉。例如,當需要建立一個新的UActorChannel以傳輸遊戲物件資料時,UControlChannel會傳送相應的開啟通道請求。同樣,當某個通道不再需要時,UControlChannel會負責傳送關閉通道請求。
- 控制訊息:UControlChannel還可以處理其他一些控制訊息,如暫停、恢復遊戲等。這些訊息通常對遊戲邏輯產生一定影響,但主要用於維護遊戲狀態和連線。
Client :Client 上 Channel 的建立介面為 UNetDriver::CreateInitialCilentChannels
,其實就是在 InitNetDriver 的時候就建立好了 Channel
Server :Server 上 Channel 的建立時機如下:
基本上都是在握手過程中就建立好了 Channel。其關係如下:
Server 端在 InitRemoteConnection 之後,會執行 UNetConnection::SetExpectedClientLoginMsgType(NMT_Hello)
,表示等待 Client 端傳送 NMT_Hello 的訊息,而 Client 端傳送該訊息的時機就在握手完畢之後。
Client 端在呼叫 BeginHandshake 的時候,會傳入一個 Delegates,Handshake 完畢之後會呼叫 Delegates. Broadcast,通知握手完畢,繫結了該 Delegate 的介面都會被執行,大致如下:
// 握手完畢的回撥
void UPendingNetGame::InitNetDriver() {
// 省略一些程式碼
// 發起握手,傳入握手完畢的回撥
ServerConn->Handler->BeginHandshaking( FPacketHandlerHandshakeComplete::CreateUObject(this, &UPendingNetGame::SendInitialJoin));
}
// SendInit
void UPendingNetGame::SendInitialJoin() {
// 省略一些程式碼
// 傳送 NMT_Hello
FNetControlMessage<NMT_Hello>::Send(ServerConn, IsLittleEndian, LocalNetworkVersion, EncryptionToken);
}
因此握手完畢後,Client 端就會呼叫 UPendingNetGame::SendInitialJoin ,傳送 NMT_Hello 給 Server 端。
這裡還有個問題,如何確定這個 Message 會傳送給 ControlChannel ?實際上這裡由 FNetControlMessage<>::Send
介面處理,其內部實現會直接傳送一個 FControlChannelOutBunch
,該 Bunch 會直接使用 Channel[0] 初始化,Channel[0] 預設情況下就是 ControlChannel。
Server 端處理 Bunch 的 CallStack 如下:
其大致流程如下:
ReceivedBunch
(不同的 Channel 會各自重寫該介面)UWorld::NotifyControlMessage
介面Client 端登入過程中主要處理 ControlMessage 的介面為 UPendingNetGame::NotifyControlMessage
UPendingNetGame::LoadMapCompleted
-> UPendingNetGame::SendJoin
AGameModeBase::Login
個人將 UE 中,Client 和 Server 建立連線到進入遊戲中的過程分為了 2 步: