← 筆記

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

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

作者:羅培羽

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

Server Framework Code 7z(Google Drive):

https://drive.google.com/file/d/1Up5WeOsL9Ug7hXs__wbdGxOLY_qumgXT/view?usp=sharing

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

  • 總體架構

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

  • 網絡底層

    • 處理網絡連接

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

      • 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();