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

這一篇在做什麼

上一篇把客戶端網絡模塊拼起來,這一篇則對應服務端。核心問題是:一個通用的遊戲服務端,最少要怎麼切分職責,才能同時處理網絡、消息分發與玩家數據存取。

參考代碼

文中提到的服務端框架包在這裡:

Server Framework Code 7z

先看整體架構

整個服務端框架的出發點很簡單:它至少要同時做好兩件事。

  • 處理客戶端連接與消息
  • 保存與讀取玩家數據

總體結構如下:

模塊拆分如下:

網絡底層在做什麼

網絡底層的責任,主要是讓「客戶端送來的原始字節」變成「可以被邏輯層處理的協議消息」。

這裡大致可以拆成四個角色:

  • 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 多路複用。啟動流程是:

  1. 創建監聽 Socket
  2. 綁定端口
  3. 開始監聽
  4. 進入 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,代表該客戶端送來了消息

新連接如何處理

新連接進來時,服務端要做的事很直接:

  1. Accept 接受連接
  2. 創建一份新的 ClientState
  3. 放進客戶端列表
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);
    }
}

這裡的做法是:

  1. 先解析出協議名
  2. 再解析協議體
  3. 用協議名找到 MsgHandler 裡對應的方法
  4. 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 為例。整個交互流程通常是:

  1. 配置數據庫與驅動
  2. 建立連接
  3. 執行 SQL
  4. 讀回結果
  5. 關閉或維持連接

建立連接

//靜態數據庫連接對象
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 循環負責把新連接與客戶端消息分流
  • 協議解析後,應交給專門的消息處理器而不是塞回網絡管理器
  • 玩家對象通常同時持有運行時狀態、存盤數據與連接引用
  • 心跳與數據庫讀寫,是服務端框架不可缺的兩條支線