談一款MOBA類遊戲《碼神聯盟》的服務端架構設計與實現(更新優化思路)
-
一、前言
《碼神聯盟》是一款為技術人做的開源情懷遊戲,每一種程式語言都是一位英雄。客戶端和服務端均使用C#開發,客戶端使用Unity3D引擎,資料庫使用MySQL。這個MOBA類遊戲是筆者在學習時期和客戶端美術策劃的小夥伴一起做的遊戲,筆者主要負責遊戲服務端開發,客戶端也參與了一部分,同時也是這個專案的發起和負責人。這次主要分享這款遊戲的服務端相關的設計與實現,從整體的架構設計,到伺服器網路通訊底層的搭建,通訊協議、模型定製,再到遊戲邏輯的分層架構實現。同時這篇部落格也沉澱了筆者在遊戲公司實踐五個月後對遊戲架構與設計的重新審視與思考。
這款遊戲自去年完成後筆者曾多次想寫篇部落格來分享,也曾多次停筆,只因總覺得靈感還不夠積澱還不夠思考還不夠,現在終於可以跨過這一步和大家分享,希望可以帶來的是乾貨與誠意滿滿。由於目前關於遊戲服務端相關的介紹文章少之又少,而為數不多的幾篇也都是站在遊戲服務端發展歷史和架構的角度上進行分享,很少涉及具體的實現,這篇文章我將嘗試多從實現的層面上加以介紹,所附的程式碼均有詳盡註釋,篇幅較長,可以關注收藏後再看。學習時期做的專案可能無法達到工業級,參考了github上開源的C#網路框架,筆者在和小夥伴做這款遊戲時農藥還沒有現在這般火。 : )
-
二、伺服器架構
上圖為這款遊戲的伺服器架構和主要邏輯流程圖,筆者將遊戲的程式碼實現分為三個主要模組:Protocol通訊協議、NetFrame伺服器網路通訊底層的搭建以及LOLServer遊戲的具體邏輯分層架構實現,下面將針對每個模組進行分別介紹。
-
三、通訊協議
先從最簡單也最基本的通訊協議部分說起,我們可以看到這部分程式碼主要分為xxxProtocol、xxxDTO和xxxModel、以及xxxData四種類型,讓我們來對它們的作用一探究竟。
-
1.Protocol協議
LOLServer\Protocol\Protocol.cs
using System;usingSystem.Collections.Generic;using System.Text;namespace GameProtocol{ public class Protocol { public const byte TYPE_LOGIN = 0;//登入模組 public const byte TYPE_USER = 1;//使用者模組 public const byte TYPE_MATCH = 2;//戰鬥匹配模組 public const byte TYPE_SELECT = 3;//戰鬥選人模組 public const byteTYPE_FIGHT = 4;//戰鬥模組 }}
從上述的程式碼舉例可以看到,在Protocol協議部分,我們主要是定義了一些常量用於模組通訊,在這個部分分別定義了使用者協議、登入協議、戰鬥匹配協議、戰鬥選人協議以及戰鬥協議。
-
2.DTO資料傳輸物件
DTO即資料傳輸物件,表現層與應用層之間是通過資料傳輸物件(DTO)進行互動的,需要了解的是,資料傳輸物件DTO本身並不是業務物件。資料傳輸物件是根據UI的需求進行設計的,而不是根據領域物件進行設計的。比如,User領域物件可能會包含一些諸如name, level, exp, email等資訊。但如果UI上不打算顯示email的資訊,那麼UserDTO中也無需包含這個email的資料。
簡單來說Model面向業務,我們是通過業務來定義Model的。而DTO是面向介面UI,是通過UI的需求來定義的。通過DTO我們實現了表現層與Model之間的解耦,表現層不引用Model,如果開發過程中我們的模型改變了,而介面沒變,我們就只需要改Model而不需要去改表現層中的東西。
using System;using System.Collections.Generic;using System.Text;namespace GameProtocol.dto{ [Serializable] public class UserDTO { public int id;//玩家ID 唯一主鍵 public string name;//玩家暱稱 public int level;//玩家等級 public int exp;//玩家經驗 public int winCount;//勝利場次 public int loseCount;//失敗場次 public int ranCount;//逃跑場次 public int[] heroList;//玩家擁有的英雄列表 public UserDTO() { } public UserDTO(string name, int id, int level, int win, int lose, int ran,int[] heroList) { this.id = id; this.name = name; this.winCount = win; this.loseCount = lose; this.ranCount = ran; this.level = level; this.heroList = heroList; } }}
-
3.Data屬性配置表
這部分的實現主要是為了將程式功能與屬性配置分離,後面可以由策劃來配置這部分內容,由導表工具自動生成配表,從而減輕程式的開發工作量,擴充套件遊戲的功能。
using System;using System.Collections.Generic;using System.Text;namespace GameProtocol.constans{ /// <summary> /// 英雄屬性配置表 /// </summary> public class HeroData { public static readonly Dictionary<int, HeroDataModel> heroMap = new Dictionary<int, HeroDataModel>(); /// <summary> /// 靜態構造 初次訪問的時候自動呼叫 /// </summary> static HeroData() { create(1, "西嘉迦[C++]", 100, 20, 500, 300, 5, 2, 30, 10, 1, 0.5f, 200,200, 1, 2, 3, 4); create(2, "派森[Python]", 100, 20, 500, 300, 5, 2, 30, 10, 1, 0.5f, 200, 200, 1, 2, 3, 4); create(3, "扎瓦[Java]", 100, 20, 500, 300, 5, 2, 30, 10, 1, 0.5f, 200, 200, 6, 2, 3, 4); create(4, "琵欸赤貔[PHP]", 100, 20, 500, 300, 5, 2, 30, 10, 1, 0.5f, 200, 200, 3, 2, 3, 4); } /// <summary> /// 建立模型並新增進字典 /// </summary> /// <param name="code"></param> /// <param name="name"></param> /// <param name="atkBase"></param> /// <param name="defBase"></param> /// <param name="hpBase"></param> /// <param name="mpBase"></param> /// <param name="atkArr"></param> /// <param name="defArr"></param> /// <param name="hpArr"></param> /// <param name="mpArr"></param> /// <param name="speed"></param> /// <param name="aSpeed"></param> /// <param name="range"></param> /// <param name="eyeRange"></param> /// <param name="skills"></param> private static void create(int code, string name, int atkBase, int defBase, int hpBase, int mpBase, int atkArr, int defArr, int hpArr, int mpArr, float speed, float aSpeed, float range, float eyeRange, params int[] skills) { HeroDataModel model = new HeroDataModel(); model.code = code; model.name = name; model.atkBase = atkBase; model.defBase = defBase; model.hpBase = hpBase; model.mpBase = mpBase; model.atkArr = atkArr; model.defArr = defArr; model.hpArr = hpArr; model.mpArr = mpArr; model.speed = speed; model.aSpeed = aSpeed; model.range = range; model.eyeRange = eyeRange; model.skills = skills; heroMap.Add(code, model); } } public partial class HeroDataModel { public int code;//策劃定義的唯一編號 public string name;//英雄名稱 public int atkBase;//初始(基礎)攻擊力 public int defBase;//初始防禦 public int hpBase;//初始血量 public int mpBase;//初始藍 public int atkArr;//攻擊成長 public int defArr;//防禦成長 public int hpArr;//血量成長 public int mpArr;//藍成長 public float speed;//移動速度 public float aSpeed;//攻擊速度 public float range;//攻擊距離 public float eyeRange;//視野範圍 public int[] skills;//擁有技能 } }
-
四、伺服器通訊底層搭建
這部分為伺服器的網路通訊底層實現,也是遊戲伺服器的核心內容,下面將結合具體的程式碼以及程式碼註釋一一介紹底層的實現,可能會涉及到一些C#的網路程式設計知識,對C#語言不熟悉沒關係,筆者對C#的運用也僅僅停留在使用階段,只需通過C#這門簡單易懂的語言來窺探整個伺服器通訊底層搭建起來的過程,來到我們的NetFrame網路通訊框架,這部分乾貨很多,我將用完整的程式碼和詳盡的註釋來闡明其意。
-
1.四層Socket模型
將SocketModel分為了四個層級,分別為:
(1)type:一級協議 用於區分所屬模組,如使用者模組
(2)area:二級協議 用於區分模組下的所屬子模組,如使用者模組的子模組為道具模組1、裝備模組2、技能模組3等
(3)command:三級協議 用於區分當前處理邏輯功能,如道具模組的邏輯功能有“使用(申請/結果),丟棄,獲得”等,技能模組的邏輯功能有“學習,升級,遺忘”等;
(4)message:訊息體 當前需要處理的主體資料,如技能書
using System;using System.Collections.Generic;using System.Linq;using System.Text;using System.Threading.Tasks;namespace NetFrame.auto{ public class SocketModel { /// <summary> /// 一級協議 用於區分所屬模組 /// </summary> public byte type {get;set;} /// <summary> /// 二級協議 用於區分 模組下所屬子模組 /// </summary> public int area { get; set; } /// <summary> /// 三級協議 用於區分當前處理邏輯功能 /// </summary> public int command { get; set; } /// <summary> /// 訊息體 當前需要處理的主體資料 /// </summary> public object message { get; set; } public SocketModel() { } public SocketModel(byte t,int a,int c,object o) { this.type = t; this.area = a; this.command = c; this.message = o; } public T GetMessage<T>() { return (T)message; } }}
同時封裝了一個訊息封裝的方法,收到訊息的處理流程如圖所示:
-
2.物件序列化與反序列化為物件
序列化: 將資料結構或物件轉換成二進位制串的過程。
反序列化:將在序列化過程中所生成的二進位制串轉換成資料結構或者物件的過程。
using System;using System.Collections.Generic;using System.IO;using System.Linq;using System.Runtime.Serialization.Formatters.Binary;using System.Text;using System.Threading.Tasks;namespace NetFrame{ public class SerializeUtil { /// <summary> /// 物件序列化 /// </summary> /// <param name="value"></param> /// <returns></returns> public static byte[] encode(object value) { MemoryStream ms = new MemoryStream();//建立編碼解碼的記憶體流物件 BinaryFormatter bw = new BinaryFormatter();//二進位制流序列化物件 //將obj物件序列化成二進位制資料 寫入到 記憶體流 bw.Serialize(ms, value); byte[] result=new byte[ms.Length]; //將流資料 拷貝到結果陣列 Buffer.BlockCopy(ms.GetBuffer(), 0, result, 0, (int)ms.Length); ms.Close(); return result; } /// <summary> /// 反序列化為物件 /// </summary> /// <param name="value"></param> /// <returns></returns> public static object decode(byte[] value) { MemoryStream ms = new MemoryStream(value);//建立編碼解碼的記憶體流物件 並將需要反序列化的資料寫入其中 BinaryFormatter bw = new BinaryFormatter();//二進位制流序列化物件 //將流資料反序列化為obj物件 object result= bw.Deserialize(ms); ms.Close(); return result; } }}
-
3.訊息體序列化與反序列化
相應的,我們利用上面寫好的序列化和反序列化方法將我們再Socket模型中定義的message訊息體進行序列化與反序列化
using System;using System.Collections.Generic;using System.Linq;using System.Text;using System.Threading.Tasks;namespace NetFrame.auto{ public class MessageEncoding { /// <summary> /// 訊息體序列化 /// </summary> /// <param name="value"></param> /// <returns></returns> public static byte[] encode(object value) { SocketModel model = value as SocketModel; ByteArray ba = new ByteArray(); ba.write(model.type); ba.write(model.area); ba.write(model.command); //判斷訊息體是否為空 不為空則序列化後寫入 if (model.message != null) { ba.write(SerializeUtil.encode(model.message)); } byte[] result = ba.getBuff(); ba.Close(); return result; } /// <summary> /// 訊息體反序列化 /// </summary> /// <param name="value"></param> /// <returns></returns> public static object decode(byte[] value) { ByteArray ba = new ByteArray(value); SocketModel model = new SocketModel(); byte type; int area; int command; //從資料中讀取 三層協議 讀取資料順序必須和寫入順序保持一致 ba.read(out type); ba.read(out area); ba.read(out command); model.type = type; model.area = area; model.command = command; //判斷讀取完協議後 是否還有資料需要讀取 是則說明有訊息體 進行訊息體讀取 if (ba.Readnable) { byte[] message; //將剩餘資料全部讀取出來 ba.read(out message, ba.Length - ba.Position); //反序列化剩餘資料為訊息體 model.message = SerializeUtil.decode(message); } ba.Close(); return model; } }}
-
4.將資料寫入成二進位制
using System;using System.Collections.Generic;using System.IO;using System.Linq;using System.Text;namespace NetFrame{ /// <summary> /// 將資料寫入成二進位制 /// </summary> public class ByteArray { MemoryStream ms = new MemoryStream(); BinaryWriter bw; BinaryReader br; public void Close() { bw.Close(); br.Close(); ms.Close(); } /// <summary> /// 支援傳入初始資料的構造 /// </summary> /// <param name="buff"></param> public ByteArray(byte[] buff) { ms = new MemoryStream(buff); bw = new BinaryWriter(ms); br = new BinaryReader(ms); } /// <summary> /// 獲取當前資料 讀取到的下標位置 /// </summary> public int Position { get { return (int)ms.Position; } } /// <summary> /// 獲取當前資料長度 /// </summary> public int Length { get { return (int)ms.Length; } } /// <summary> /// 當前是否還有資料可以讀取 /// </summary> public bool Readnable{ get { return ms.Length > ms.Position; } } /// <summary> /// 預設構造 /// </summary> public ByteArray() { bw = new BinaryWriter(ms); br = new BinaryReader(ms); } public void write(int value) { bw.Write(value); } public void write(byte value) { bw.Write(value); } public void write(bool value) { bw.Write(value); } public void write(string value) { bw.Write(value); } public void write(byte[] value) { bw.Write(value); } public void write(double value) { bw.Write(value); } public void write(float value) { bw.Write(value); } public void write(long value) { bw.Write(value); } public void read(out int value) { value= br.ReadInt32(); } public void read(out byte value) { value = br.ReadByte(); } public void read(out bool value) { value = br.ReadBoolean(); } public void read(out string value) { value = br.ReadString(); } public void read(out byte[] value,int length) { value = br.ReadBytes(length); } public void read(out double value) { value = br.ReadDouble(); } public void read(out float value) { value = br.ReadSingle(); } public void read(out long value) { value = br.ReadInt64(); } public void reposition() { ms.Position = 0; } /// <summary> /// 獲取資料 /// </summary> /// <returns></returns> public byte[] getBuff() { byte[] result = new byte[ms.Length]; Buffer.BlockCopy(ms.GetBuffer(), 0, result, 0, (int)ms.Length); return result; } }}
-
5.粘包長度編碼與解碼
粘包出現原因:在流傳輸中出現(UDP不會出現粘包,因為它有訊息邊界)
1 傳送端需要等緩衝區滿才傳送出去,造成粘包
2 接收方不及時接收緩衝區的包,造成多個包接收
所以這裡我們需要對粘包長度進行編碼與解碼,具體的程式碼如下:
using System;using System.Collections.Generic;using System.IO;using System.Linq;using System.Text;using System.Threading.Tasks;namespace NetFrame.auto{ public class LengthEncoding { /// <summary> /// 粘包長度編碼 /// </summary> /// <param name="buff"></param> /// <returns></returns> public static byte[] encode(byte[] buff) { MemoryStream ms = new MemoryStream();//建立記憶體流物件 BinaryWriter sw = new BinaryWriter(ms);//寫入二進位制物件流 //寫入訊息長度 sw.Write(buff.Length); //寫入訊息體 sw.Write(buff); byte[] result = new byte[ms.Length]; Buffer.BlockCopy(ms.GetBuffer(), 0, result, 0, (int)ms.Length); sw.Close(); ms.Close(); return result; } /// <summary> /// 粘包長度解碼 /// </summary> /// <param name="cache"></param> /// <returns></returns> public static byte[] decode(ref List<byte> cache) { if (cache.Count < 4) return null; MemoryStream ms = new MemoryStream(cache.ToArray());//建立記憶體流物件,並將快取資料寫入進去 BinaryReader br = new BinaryReader(ms);//二進位制讀取流 int length = br.ReadInt32();//從快取中讀取int型訊息體長度 //如果訊息體長度 大於快取中資料長度 說明訊息沒有讀取完 等待下次訊息到達後再次處理 if (length > ms.Length - ms.Position) { return null; } //讀取正確長度的資料 byte[] result = br.ReadBytes(length); //清空快取 cache.Clear(); //將讀取後的剩餘資料寫入快取 cache.AddRange(br.ReadBytes((int)(ms.Length - ms.Position))); br.Close(); ms.Close(); return result; } }}
-
6.delegate委託宣告
delegate 是表示對具有特定引數列表和返回型別的方法的引用的型別。 在例項化委託時,可以將其例項與任何具有相容簽名和返回型別的方法相關聯。通過委託例項呼叫方法。委託相當於將方法作為引數傳遞給其他方法,類似於 C++ 函式指標,但它們是型別安全的。
using System;using System.Collections.Generic;using System.Linq;using System.Text;using System.Threading.Tasks;namespace NetFrame{ public delegate byte[] LengthEncode(byte[] value); public delegate byte[] LengthDecode(ref List<byte> value); public delegate byte[] encode(object value); public delegate object decode(byte[] value);}
-
7.使用者連線物件UserToken
-
SocketAsyncEventArgs介紹
SocketAsyncEventArgs是微軟提供的高效能非同步Socket實現類,主要為高效能網路伺服器應用程式而設計,主要是為了避免在在非同步套接字 I/O 量非常大時發生重複的物件分配和同步。使用此類執行非同步套接字操作的模式包含以下步驟:
(1)分配一個新的SocketAsyncEventArgs 上下文物件,或者從應用程式池中獲取一個空閒的此類物件。
(2)將該上下文物件的屬性設定為要執行的操作(例如,完成回撥方法、資料緩衝區、緩衝區偏移量以及要傳輸的最大資料量)。
(3)呼叫適當的套接字方法(xxxAsync) 以啟動非同步操作。
(4)如果非同步套接字方法 (xxxAsync)返回 true,則在回撥中查詢上下文屬性來獲取完成狀態。
(5)如果非同步套接字方法 (xxxAsync)返回 false,則說明操作是同步完成的。可以查詢上下文屬性來獲取操作結果。
(6)將該上下文重用於另一個操作,將它放回到應用程式池中,或者將它丟棄。
- SocketAsyncEventArgs.UserToken屬性
獲取或設定與此非同步套接字操作關聯的使用者或應用程式物件。
名稱空間: System.Net.Sockets
public object UserToken { get; set; }
備註:
此屬性可以由應用程式相關聯的應用程式狀態物件與 SocketAsyncEventArgs 物件。 首先,此屬性是一種將狀態傳遞到應用程式的事件處理程式(例如,非同步操作完成方法)的應用程式的方法。
此屬性用於所有非同步套接字 (xxxAsync) 方法。
UserToken類的完整實現程式碼如下,可以結合程式碼註釋加以理解:
using System;using System.Collections.Generic;using System.Linq;using System.Net.Sockets;using System.Text;using System.Threading.Tasks;namespace NetFrame{ /// <summary> /// 使用者連線資訊物件 /// </summary> public class UserToken { /// <summary> /// 使用者連線 /// </summary> public Socket conn; //使用者非同步接收網路資料物件 public SocketAsyncEventArgs receiveSAEA; //使用者非同步傳送網路資料物件 public SocketAsyncEventArgs sendSAEA; public LengthEncode LE; public LengthDecode LD; public encode encode; public decode decode; public delegate void