所讀版本:機械工業出版社
這一篇在做什麼
上一篇把客戶端網絡模塊拼起來,這一篇則對應服務端。核心問題是:一個通用的遊戲服務端,最少要怎麼切分職責,才能同時處理網絡、消息分發與玩家數據存取。
參考代碼
文中提到的服務端框架包在這裡:
先看整體架構
整個服務端框架的出發點很簡單:它至少要同時做好兩件事。
- 處理客戶端連接與消息
- 保存與讀取玩家數據
總體結構如下:

模塊拆分如下:

網絡底層在做什麼
網絡底層的責任,主要是讓「客戶端送來的原始字節」變成「可以被邏輯層處理的協議消息」。
這裡大致可以拆成四個角色:
MsgHandler:處理具體消息EventHandler:處理網絡事件ClientState:保存每個客戶端的連接狀態NetManager:管理 Select 循環與整體網絡流程
ClientState
服務端需要為每個連進來的客戶端保存一份狀態。這份狀態不只是 Socket,本質上是「這個連接目前的上下文」。
public class ClientState
{
public Socket socket; //與客戶端連接的socket
public ByteArray readBuff = new ByteArray(); //讀緩沖區
public long lastPingTime = 0; //上次客戶端發來Ping的時間記錄
public Player player; //對玩家對象的引用
}
這裡至少保存了:
- 連接本身
- 對應的接收緩衝區
- 心跳時間
- 綁定的玩家對象
NetManager 與 Select 循環
這套服務端用的是 Select 多路複用。啟動流程是:
- 創建監聽 Socket
- 綁定端口
- 開始監聽
- 進入 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{//客戶端發來消息}
}
}
}
這裡的關鍵判斷是:
- 如果可讀的是
listenfd,代表有新客戶端連入 - 如果可讀的是某個 client socket,代表該客戶端送來了消息
新連接如何處理
新連接進來時,服務端要做的事很直接:
Accept接受連接- 創建一份新的
ClientState - 放進客戶端列表
public static void ReadListenfd(Socket listenfd)
{
try
{
//新建一個socket來接受客戶端連接
Socket clientfd = listenfd.Accept();
//填充客戶端信息列表
ClientState state = new ClientState();
state.socket = clientfd;
clients.Add(clientfd, state);
}
//...
}
從這一刻開始,這個客戶端就變成 Select 循環裡要持續監視的對象。
客戶端消息如何處理
當某個客戶端 Socket 可讀時,服務端要進行:
- 收包
- 緩衝區空間檢查
- 粘包半包解析
- 協議分發
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();
}
這裡的結構和客戶端其實是對應的,只是現在邏輯發生在服務端,而且一個服務端要同時面對很多個 ClientState。
協議解析與消息分發
真正把字節流變成業務消息的地方,是 OnReceiveData。
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);
}
}
這裡的做法是:
- 先解析出協議名
- 再解析協議體
- 用協議名找到
MsgHandler裡對應的方法 - 把
ClientState和解析好的消息對象一起傳進去
也就是說,NetManager 不直接寫死每種業務邏輯,它只負責把消息正確交到應該處理它的人手上。
邏輯層與數據庫層
當網絡層把消息拆好之後,邏輯層才真正處理遊戲業務。
邏輯層
邏輯層主要負責:
- 處理客戶端消息
- 處理網絡事件
- 管理玩家在內存中的狀態
例如:
- 上線初始化
- 下線保存數據
- 同步消息轉發
- 角色狀態更新
數據庫層
數據庫層則專門處理:
- 玩家數據讀取
- 玩家數據保存
- 賬號校驗
- 註冊 / 建角 / 更新
這樣網絡層、業務層、持久化層就不會攪在一起。
一個典型流程
如果把服務端整體流程壓縮成一條線,大致是:
連接
客戶端先連上服務端,此時服務端只知道「有一條連接」,還不知道這條連接對應哪個玩家。
登錄
客戶端送出登錄協議,服務端驗證賬號密碼,並把這條連接與具體玩家數據綁定。
交互
登錄後雙方開始透過共享協議文件互發消息。這裡的前提是:客戶端與服務端必須使用一致的協議定義。
登出
玩家下線時,把當前玩家數據保存回數據庫。
心跳機制
服務端心跳的核心目的,是主動清理那些「看起來還連著,但其實已經失效」的客戶端。
客戶端發 Ping,服務端回 Pong
服務端會記錄每個客戶端最近一次 Ping 的時間。
//客戶端列表
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();
//...
}
定時檢查是否超時
接著由服務端定時器去掃描所有客戶端,如果某個連接長時間沒更新心跳,就直接關掉。
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;
}
}
}
}
玩家數據與服務端內存對象
服務端通常不會只拿到一坨原始存盤數據就算了,它還需要一個方便運行時操作的玩家對象。
// 玩家數據(存盤 + 不存盤)
public class Player
{
//不存盤數據
//...
//存盤數據
public PlayerData data;
//指向持有該Player的ClientState,用於讓其他Player通過id來找到對應的ClientState執行一些邏輯操作
public ClientState state;
}
這樣可以把:
- 需要落盤的數據
- 運行時狀態
- 和網絡連接的引用
統一整理在同一個玩家對象附近。
數據庫交互
這裡以 MySQL 為例。整個交互流程通常是:
- 配置數據庫與驅動
- 建立連接
- 執行 SQL
- 讀回結果
- 關閉或維持連接
建立連接
//靜態數據庫連接對象
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 注入風險
文中也提到一個不能忽略的點:直接拼接字符串做 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();
這一篇先記住的重點
如果把這篇再壓成最關鍵的幾條,可以記成:
- 服務端至少要把網絡層、邏輯層、數據庫層拆開
ClientState是服務端管理單個連接的核心上下文Select循環負責把新連接與客戶端消息分流- 協議解析後,應交給專門的消息處理器而不是塞回網絡管理器
- 玩家對象通常同時持有運行時狀態、存盤數據與連接引用
- 心跳與數據庫讀寫,是服務端框架不可缺的兩條支線