所讀版本:機械工業出版社

這一篇在做什麼

前幾篇把 Socket、異步、收發與協議拆開講完,這一篇開始把它們組成一個比較完整的 Unity 客戶端網絡模塊。重點是兩部分:

  • NetManager 這個客戶端網絡管理器怎麼分層
  • 協議格式與編解碼要怎麼定

參考代碼

文中提到的客戶端模塊包在這裡:

Client Module Code unitypackage

先看 NetManager 想解決什麼

如果把客戶端網絡邏輯全部塞在同一個腳本裡,通常很快就會混成一團,因為它同時要負責:

  • 建立連接
  • 發送數據
  • 接收數據
  • 解析協議
  • 分發網絡事件
  • 分發業務消息
  • 心跳檢測

所以這裡的 NetManager,本質上是一個把這些責任拆開再收束起來的中控層。

網絡事件分發

除了具體業務消息之外,網絡本身也會產生事件,例如:

  • 連接成功
  • 連接失敗
  • 連接關閉

這些事件適合單獨維護一套監聽器列表。

//網絡事件監聽列表
private static Dictionary<NetEvent, EventListener> eventListeners = new Dictionary<NetEvent, EventListener>();
//添加網絡事件監聽
public static void AddEventListener(NetEvent netEvent, EventListener listener)
{
    //...
}
//移除網絡事件監聽
public static void RemoveEventListener(NetEvent netEvent, EventListener listener)
{
    //...
}
//分發網絡事件
public static void FireEvent(NetEvent netEvent, string err)
{
    //...
}

這樣上層 UI 或場景邏輯就不必直接依賴底層 Socket,而是只關心「連接成功了沒」「是不是斷線了」。

服務器連接模塊

連接部分採用 BeginConnect。這裡額外要處理一個很現實的問題:如果還在連接中,玩家又按了一次連接按鈕怎麼辦。

因此會用一個 isConnecting 標記保護當前狀態。

static bool isConnecting = false;
//連接
public static void Connect(string ip, int port)
{
    //...
    isConnecting = true;
    socket.BeginConnect(ip, port, ConnectCallback, socket);
}
//連接回調
private static void ConnectCallback(IAsyncResult ar)
{
    //...
    //終止連接
    socket.EndConnect(ar);
    //分發連接消息
    FireEvent(NetEvent.ConnectSucc, "");
    //重置連接狀態
    isConnecting = false;
    //繼續接收
    socket.BeginReceive(readBuff.bytes, readBuff.writeIdx, readBuff.remain, 0, ReceiveCallback, socket);
    //...
}

這段邏輯的重點是:

  1. 發起連接時先鎖定狀態
  2. 連接成功後分發事件
  3. 再正式進入接收循環

數據發送模塊

這套客戶端模塊不是直接把一個字符串送出去,而是先把消息拆成:

  • 協議名
  • 協議體

然後分別編碼,再組裝成完整字節流。

//發送數據
public static void Send(MsgBase msg)
{
    //...
    byte[] nameBytes = MsgBase.EncodeName(msg);
    //協議名數據:長度信息+協議名2進制數據
    byte[] bodyBytes = MsgBase.Encode(msg);
    //協議體數據:長度信息 + 協議體2進制數據
    //消息長度計算及組裝
    //...
    //寫入隊列
    ByteArray ba = new ByteArray(sendBytes);
    writeQueue.Enqueue(ba);
    //...
    //發送
    socket.BeginSend(sendBytes, 0, sendBytes.Length, 0, SendCallback, socket);
}

//發送數據回調
static void SendCallback(IAsyncResult ar)
{
    //...
    //獲取成功發送的數據長度
    int count = socket.EndSend(ar);
    //獲取寫入隊列的第一條數據
    //...
    writeQueue.Dequeue();
    ba = writeQueue.First();
    //...
    //還有數據就繼續發送
    socket.BeginSend(ba.bytes, ba.readIdx, ba.length, 0, SendCallback, socket);
    //...
}

和前面章節一樣,這裡用隊列是為了避免多條消息交錯發送時互相覆蓋狀態。

消息事件分發

業務消息和網絡事件是兩條線。網絡事件用枚舉值分發,而業務消息通常是依照協議名分發。

也就是說:

  • 網絡事件處理的是連接層狀態
  • 消息事件處理的是遊戲協議本身

兩者結構相似,但用途不同。

接收與數據處理模塊

當連接成功後,NetManager 會持續進入異步接收。接收回調要做的事大致有三步:

  1. 更新接收緩衝區寫入位置
  2. 嘗試解析完整消息
  3. 繼續掛起下一次接收
//消息列表
static List<MsgBase> msgList = new List<MsgBase>();
//消息列表長度
static int msgCount = 0;
//每一次Update處理的消息量
readonly static int MAX_MESSAGE_FIRE = 10;
//Receive回調
private static void ReceiveCallback(IAsyncResult ar)
{
    Socket socket = (Socket)ar.AsyncState;
    //獲取接收數據長度
    int count = socket.EndReceive(ar);
    //收到FIN信號(count == 0),斷開連接
    //...
    //更新寫入索引
    readBuff.writeIdx += count;
    //數據處理
    OnReceiveData();
    //剩餘空間即將不足,進行數據移位與擴容
    //...
    //繼續接收
    socket.BeginReceive(readBuff.bytes, readBuff.writeIdx, readBuff.remain, 0, ReceiveCallback, socket);
    //...
}

OnReceiveData 在做什麼

這一層才是真正的拆包與解析邏輯。它需要處理:

  • 粘包半包
  • 大小端
  • 協議名解析
  • 協議體解析
  • 消息入隊
//數據處理
private static void OnReceiveData()
{
    if (readBuff.length <= 2)
    {
        return;
    }
    //消息長度不足解析長度信息
    //...
    //解析協議名
    //...
    //解析協議體
    //...
    //檢查接收緩沖區剩餘空間
    readBuff.CheckAndMoveBytes();
    //把解析出來的消息加到消息列表中
    lock (msgList)
    {
        msgList.Add(msgBase);
    }
    msgCount++;
    //繼續讀取消息
    if(readBuff.length > 2)
    {
        OnReceiveData();
    }
}

這裡遞歸繼續解析的原因,是一次 Receive 可能同時收進來多條完整協議。

為什麼主線程只處理固定數量消息

消息最後不會在接收回調中直接執行,而是由主線程 Update 統一處理。這裡還做了一個小優化:每幀只處理上限數量的消息,避免單幀爆量消息把畫面卡住。

//Updatepublic
static void Update()
{
    MsgUpdate();
}

//更新消息
public static void MsgUpdate()
{
    if (msgCount == 0)
    {
        return;
    }
    //處理多條消息
    for (int i = 0; i < MAX_MESSAGE_FIRE; i++)
    {
        //分發消息
    }
}

心跳機制

客戶端這裡同樣要做心跳。基本策略是:

  • 距離上次發送 Ping 太久,就再發一次
  • 距離上次收到 Pong 太久,就視為斷線
//心跳間隔時間
public static int pingInterval = 10;
//上一次發送Ping的時間
static float lastPingTime = 0;
//上一次收到Pong的時間
static float lastPongTime = 0;
//發送Ping協議
private static void PingUpdate()
{
    //Check Ping Signal Overtime and try ping again
    if(Time.time - lastPingTime > pingInterval)
    {
        //...
    }
    //Check Pong Signal Overtime
    if(Time.time - lastPongTime > pingInterval * 4)
    {
        //...
    }
}
//監聽Pong協議
private static void OnMsgPong(MsgBase msgBase)
{
    lastPongTime = Time.time;
}

這樣客戶端就能比 TCP KeepAlive 更快地發現「連著但其實已經失效」的連接。

協議格式

這套客戶端協議的核心思路是:消息名與消息體都顯式寫進字節流裡,而不是只傳一大坨純正文。

Json 協議格式

這裡的 Json 協議可以概括成:

  • 消息長度
  • 協議名長度
  • 協議名
  • 協議體

其中消息總長度會包含:

  • 協議名長度字段
  • 協議名字節
  • 協議體字節

協議名編碼與解碼

EncodeName

//編碼協議名
public static byte[] EncodeName(MsgBase msgBase)
{
    //協議名和長度
    byte[] nameBytes = System.Text.Encoding.UTF8.GetBytes(msgBase.protoName);
    Int16 len = (Int16)nameBytes.Length;
    byte[] bytes = new byte[2 + len]; //2字節用於描述協議名長度
    //小端組裝長度信息
    bytes[0] = (byte)(len % 256);
    bytes[1] = (byte)(len / 256);
    Array.Copy(nameBytes, 0, bytes, 2, len);
    return bytes;
}

DecodeName

//解碼協議名
public static string DecodeName(byte[] bytes, int offset, out int count)
{
    count = 0;
    if(offset + 2 > bytes.Length) //字節數組長度<2,無法解析長度信息
    {
        return "";
    }

    //小端模式讀取長度信息
    Int16 len = (Int16)((bytes[offset + 1] << 8) | bytes[offset]);
    if (len <= 0) //長度<=0
    {
        return "";
    }
    //長度不足
    if(offset + 2 + len > bytes.Length)
    {
        return "";
    }
    count = 2 + len;
    string name = System.Text.Encoding.UTF8.GetString(bytes, offset + 2, len);
    return name;
}

協議體編碼與解碼

Json

最簡單的方案是直接用 Json 把協議對象序列化成字符串,再轉成字節。

//編碼協議體
public static byte[] Encode(MsgBase msgBase)
{
    string s = JsonUtility.ToJson(msgBase);
    return System.Text.Encoding.UTF8.GetBytes(s);
}
//解碼協議體
public static MsgBase Decode(string protoName, byte[] bytes, int offset, int count)
{
    string s = System.Text.Encoding.UTF8.GetString(bytes, offset, count);
    MsgBase msgBase = (MsgBase)JsonUtility.FromJson(s, Type.GetType(protoName));
    return msgBase;
}

Json 的優點是簡單直觀,缺點是字節量通常比較大。

ProtoBuf

如果更在意協議體積與傳輸效率,可以改用 ProtoBuf。整體流程是:

  1. 定義 .proto 文件
  2. 用工具生成協議類
  3. protobuf-net 做編解碼

例如:

message MsgMove{ optional int32 x = 1; optional int32 y = 2; optional int32 z = 3;}

編碼:

//使用pb編碼
public static byte[] ProtobufEncode(ProtoBuf.IExtensible msgBase)
{
    using(MemoryStream ms = new MemoryStream())
    {
        ProtoBuf.Serializer.Serialize(ms, msgBase);
        return ms.ToArray();
    }
}

解碼:

//使用pb解碼
public static ProtoBuf.IExtensible ProtobufDecode(string protoName, byte[] bytes, int offset, int count)
{
    using (MemoryStream ms = new MemoryStream(bytes, offset, count))
    {
        System.Type t = System.Type.GetType(protoName);
        return (ProtoBuf.IExtensible)ProtoBuf.Serializer.NonGeneric.Deserialize(t, ms);
    }
}

這一篇先記住的重點

如果把整篇再壓縮成幾條核心原則,大概是:

  • 客戶端網絡模塊最好把連接、收發、事件、消息與心跳分層
  • 發送與接收都應建立在明確協議格式之上
  • 接收回調只負責拆包和入隊,真正的消息處理留在主線程
  • 心跳機制是客戶端檢測斷線的重要補充
  • Json 好上手,ProtoBuf 更省帶寬,取捨看項目需求