《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; } } } }
-
- Timer
-
數據庫交互
-
玩家數據
- 存盤數據
- 不存盤數據
- 對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();
-
-
-