《Unity3D網絡遊戲實踐》(第2版)要點摘錄 – 「通用服務端框架」

書名:《Unity3D網絡遊戲實踐》(第2版)

作者:羅培羽

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

通用服務端框架及內部邏輯

  • 總體架構

  • 服務端程序的兩大核心:處理客戶端消息、存儲玩家數據

  • 模塊劃分

    • 網絡底層
      • 處理網絡連接

      • 粘包半包處理/解析協議等等

      • 4個部分

        • 監聽消息並進行處理的類(MsgHandler)

        • 監聽網絡事件並進行處理的類(EventHandler)

        • 定義客戶端信息的ClientState類

          • public class ClientState
            {
               public Socket socket; //與客戶端連接的socket
               public ByteArray readBuff = new ByteArray(); //讀緩沖區
               
               public long lastPingTime = 0; //上次客戶端發來Ping的時間記錄
               public Player player; //對玩家對象的引用
            }
        • 處理Select多路複用的網絡管理器

          • 創建監聽Socket -> 綁定端口 -> 開啟監聽 -> 進入Select循環

          • Select循環:判斷Select返回的可讀列表中的有效元素是監聽端還是客戶端,前者代表有新連接,後者代表客戶端發來了消息

          • public static void StartLoop(int listenPort)
            {
               //Socket創建
              //...
               //綁定端口
            //...
            //開啟監聽
            //...
               //Select循環
               while (true)
              {
                  //重置checkRead
            //...
                   Socket.Select(checkRead, null, null, 1000);
                   for (int i = 0; i < checkRead.Count; i++)
                  {
                       Socket s = checkRead[i];
                       if(s == listenfd){//有新連接}
                       else{//客戶端發來消息}
                  }
              }
            }
          • 新連接的處理

            • 調用Accept接受客戶端連接

            • 新建一個客戶端信息對象,存入客戶端信息列表

            • public static void ReadListenfd(Socket listenfd)
              {
                 try
                {
                     //新建一個socket來接受客戶端連接
                     Socket clientfd = listenfd.Accept();
                     //填充客戶端信息列表
                     ClientState state = new ClientState();
                     state.socket = clientfd;
                     clients.Add(clientfd, state);
                }
              //...
              }
          • 客戶端的處理

            • 調用Receive接收數據,將數據保存在緩沖區

            • 處理粘包問題,解析出協議名和協議體

            • 根據協議名獲取消息,以協議體為參數分發消息

            • public static void ReadClientfd(Socket clientfd)
              {
                 //接收數據,將數據保存在緩存區
                 ClientState state = clients[clientfd];
                 ByteArray readBuff = state.readBuff;
                 int count = 0;

                 //緩存區不夠的處理
                 if(readBuff.remain <= 0)
                {
                     OnReceiveData(state);
                     readBuff.MoveBytes();
                }
                 if(readBuff.remain <= 0)
                {
                     Close(state);
                     return;
                }

                //接收客戶端信息
              //...

                 //消息處理
              //...
                 OnReceiveData(state);
                 //移動緩沖區
                 readBuff.CheckAndMoveBytes();
              }
            • 數據處理
              • public static void OnReceiveData(ClientState state)
                {
                   //解析協議名
                //...
                   //解析協議體
                  //...
                   //分發消息
                   MethodInfo mi = typeof(MsgHandler).GetMethod(protoName);
                   object[] o = { state, msgBase };
                   if(mi != null)
                  {
                       mi.Invoke(null, o);
                  }  

                   //繼續讀取消息
                   if(readBuff.length > 2)
                  {
                       OnReceiveData(state);
                  }
                }
    • 邏輯層

      • 消息處理

        • 客戶端消息/同步請求的處理/把一些消息轉發給所有相關的客戶端

      • 事件處理

        • 玩家連接事件的處理,如上/下線時的初始化、數據記錄

      • 存儲結構

        • 指定需要保存的數據

    • 數據庫底層

      • 保存/讀取玩家數據

  • 流程

    • 連接

      • 連接未登錄

        • 客戶端調用Connect連接服務端

        • 此時服務端不知道玩家具體是哪個角色

      • 登錄成功

        • 客戶端發送一條包含「用戶名、密碼」等信息的登錄協議

        • 服務端對信息進行檢驗,將連接與角色對應,從數據庫中獲取角色的數據

    • 交互:雙端互通協議

      • 為了讓雙端溝通,協議文件需要同時存在於服務端與客戶端中

    • 登出:玩家下線,數據保存到數據庫

  • 心跳機制

    • 客戶端定時會向將Ping協議發送至服務端,服務端把收到Ping的時間保存起來,然後發送Pong協議回客戶端

      • //客戶端列表
        public static Dictionary<Socket, ClientState> clients = new Dictionary<Socket, ClientState>();

        //客戶端信息類
        public class ClientState
        {
           //...
           public long lastPingTime = 0; //最後收到MsgPing協議的時間
        }

        //SysMsgHandler.cs
        //服務端收到MsgPing協議,更新該客戶端上次Ping的時間,回應Pong至該客戶端
        public static void MsgPing(ClientState state, MsgBase msgBase)
        {
           state.lastPingTime = NetManager.GetTimeStamp();
           MsgPong msgPong = new MsgPong();
           NetManager.Send(state, msgPong);
        }

        //NetManager.cs
        //連接成功時先記錄一次
        public static void ReadListenfd(Socket listenfd)
        {
           //...
           state.lastPingTime = GetTimeStamp();
           //...
        }
    • 服務端定時器循環對客戶端列表遍歷判斷當前時間 – 上次客戶端發出Ping時間是否已超時,是則斷開該客戶端連接

      • Timer

        • static void Timer()
          {
             //消息分發
             MethodInfo mi = typeof(EventHandler).GetMethod("OnTimer");
             object[] obj = { };
             mi.Invoke(null, obj);
          }
        • public partial class EventHandler
          {
             public static void OnTimer()
            {
                 //客戶端心跳超時處理
                 CheckPing();
            }

             // Ping檢查
             public static void CheckPing()
            {
                 long timeNow = NetManager.GetTimeStamp();
                 foreach (ClientState state in NetManager.clients.Values)
                {
                     if(timeNow - state.lastPingTime > NetManager.pingInterval * 4)
                    {
                         NetManager.Close(state);
                         return;
                    }
                }
            }
          }

數據庫交互

  • 玩家數據

    • 存盤數據

    • 不存盤數據

    • 對ClientState的引用

    • // 玩家數據(存盤 + 不存盤)
      public class Player
      {
         //不存盤數據
         //...
         
         //存盤數據
         public PlayerData data;
         
         //指向持有該Player的ClientState,用於讓其他Player通過id來找到對應的ClientState執行一些邏輯操作
         public ClientState state;
      }
  • 數據庫(以MySQL為例)

    • 配置

      • 服務器裡新建需要的數據庫

      • 服務端工程增加對數據庫網格數據進行解析的工具引用

        • 如MySQL需要增加對Mysql.Data.dll的引用(connector)

      • 啟動服務器(如MySQL),監聽指定端口

      • 用第三方庫編碼和解碼MySQL特定形式的協議

    • 例子工具

      • Xampp + Navicat for MySQL

    • 數據庫操作流程

      • 連接服務器

        • //靜態數據庫連接對象
          public static MySqlConnection mysql;

          //連接數據庫
          //數據庫名; ip地址; 數據庫端口號; 用戶名; 密碼
          public static bool Connect(string db, string ip, int port, string user, string password)
          {
             mysql = new MySqlConnection();
             //組裝連接信息字符串
             string s = string.Format("Database={0}; Data Source={1}; port={2}; User Id={3}; Password={4}", db, ip, port, user, password);
             mysql.ConnectionString = s;

             //嘗試連接
            mysql.Open();
          //...
          }
      • 選擇數據庫

      • 執行SQL語句

        • 創建指令

          • string sql = string.Format("insert into account set id ='{0}', password = '{1}';", id, password);
            try
            {
               MySqlCommand cmd = new MySqlCommand(sql, mysql);
               cmd.ExecuteNonQuery(); //執行非查詢命令          
            }
            //...
        • 防止SQL注入

          • SQL注入:通過輸入請求,把SQL命令插入到SQL語句中,欺騙服務器執行惡意SQL命令

          • //防止SQL注入
            private static bool IsSafeString(string str)
            {
               return !Regex.IsMatch(str, @"[-|;|,|\/|\(|\)|\[|\]|\}|\{|%|@|\*|!|\']");
            }
      • 關閉數據庫

    • 基礎功能(以玩家數據為例)

      • 注冊

        • string sql = string.Format("insert into account set id ='{0}', password = '{1}';", id, password);
          MySqlCommand cmd = new MySqlCommand(sql, mysql);
          cmd.ExecuteNonQuery(); //執行非查詢命令      
      • 創建角色

        • //序列化
          PlayerData playerData = new PlayerData();
          string data = js.Serialize(playerData);
          //寫入數據庫
          string sql = string.Format("insert into player set id='{0}', data='{1}';", id, data);
          MySqlCommand cmd = new MySqlCommand(sql, mysql);
          cmd.ExecuteNonQuery();
      • 密碼校驗

        • string sql = string.Format("select * from account where id='{0}' and password='{1}';", id, password);
          MySqlCommand cmd = new MySqlCommand(sql, mysql);
          MySqlDataReader dataReader = cmd.ExecuteReader();
          bool hasRows = dataReader.HasRows;
      • 獲取數據

        • string sql = string.Format("select * from player where id='{0}';", id);

          MySqlCommand cmd = new MySqlCommand(sql, mysql);
          MySqlDataReader dataReader = cmd.ExecuteReader();

          //無數據
          if (!dataReader.HasRows)
          {
             dataReader.Close();
             return null;
          }

          //讀取數據
          dataReader.Read();
          string data = dataReader.GetString("data");
          //反序列化
          PlayerData playerData = js.Deserialize<PlayerData>(data);
          dataReader.Close();
      • 更新數據

        • //序列化
          string data = js.Serialize(playerData);
          //sql
          string sql = string.Format("update player set data='{0}' where id='{1}';", data, id);
          MySqlCommand cmd = new MySqlCommand(sql, mysql);
          cmd.ExecuteNonQuery();