書名:《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();