一、服务端架构
1、总体架构
服务端程序的两大核心是处理客户端的消息和存储玩家数据。图7-2展示的是最基础的单进程服务端结构,客户端与服务端通过TCP连接,使两者可以传递数据;服务端还连接着MySQL数据库,可将玩家数据保存到数据库中。
二、Json解码编码
上一章“客户端网络模块”中,使用了Unity提供Json辅助类JsonUtility来序列化Json协议,但服务端程序和Unity无关,无法使用JsonUtility,会改用System.Web提供的方法实现
1、引用System.web.Extensions
.net提供了多种编码解码Json的方法,最常用的是“JavaScriptJsonSerializer”,但默认的程序并不包含它,需要手动引用System.web.Extensions。这一步的操作称为“添加引用”,可以让程序使用一些系统安装好的类库。如下图操作,找到我们需要的引用,并添加。
2、修改MsgBase类
添加引用后,即可修改协议基类MsgBase中有关Json编码解码的方法,避免报错。“JavaScriptSerializer”的调用方法与JsonUtility略有不同。首先,JavaScriptSerializer位于System.Web.Script.Serialization命名空间中,需要引用(using)它。其次,JavaScriptSerializer不是静态类,需要先定义一个JavaScriptSerializer对象(此处命名为Js),再使用Js.Serialize和Js.Deserialize编码解码,编码解码方法的参数与JsonUtility大同小异。
using System;
using System.Web.Script.Serialization;
public class MsgBase
{
public string protoName = "null";
//编码器
static JavaScriptSerializer Js = new JavaScriptSerializer();
//编码
public static byte[] Encode(MsgBase msgBase)
{
string s = Js.Serialize(msgBase);
return System.Text.Encoding.UTF8.GetBytes(s);
}
//解码
public static MsgBase Decode(string protoName,
byte[] bytes, int offset, int count)
{
string s = System.Text.Encoding.UTF8.GetString(bytes, offset, count);
MsgBase msgBase = (MsgBase)Js.Deserialize(s, Type.GetType(protoName));
return msgBase;
}
//编码协议名(2字节长度+字符串)
public static byte[] EncodeName(MsgBase msgBase)
{
//名字bytes和长度
byte[] nameBytes = System.Text.Encoding.UTF8.GetBytes(msgBase.protoName);
Int16 len = (Int16)nameBytes.Length;
//申请bytes数值
byte[] bytes = new byte[2 + len];
//组装2字节的长度信息
bytes[0] = (byte)(len % 256);
bytes[1] = (byte)(len / 256);
//组装名字bytes
Array.Copy(nameBytes, 0, bytes, 2, len);
return bytes;
}
//解码协议名(2字节长度+字符串)
public static string DecodeName(byte[] bytes, int offset, out int count)
{
count = 0;
//必须大于2字节
if (offset + 2 > bytes.Length)
{
return "";
}
//读取长度
Int16 len = (Int16)((bytes[offset + 1] << 8) | bytes[offset]);
//长度必须足够
if (offset + 2 + len > bytes.Length)
{
return "";
}
//解析
count = 2 + len;
string name = System.Text.Encoding.UTF8.GetString(bytes, offset + 2, len);
return name;
}
}
三、网络模块
本章的服务端程序与第3章的服务端程序在结构上基本相似,是在第3章程序的基础上,添加粘包半包处理、协议解析、数据库存储等功能。除了协议解析相关,网络模块还分为4个部分:一是处理select多路复用的网络管理器NetManager,它是服务端网络模块的核心部件;二是定义客户端信息的ClientState类,第3章的ClientState类相对简单,本章会继续完善它;三是处理网络消息的MsgHandler类,第3章中所有的消息处理都写在同一个文件里,但对于大型游戏来说,一个几十万行的文件不太容易编辑,本章会根据消息的类型,将MsgHandler分拆到多个文件中(如BattleMsgHanler.cs专门处理战斗相关的协议,SysMsgHandler.cs专门处理MsgPing、MsgPong等系统协议);四是事件处理类EventHandler。
下图展示了服务端网络模块的整体结构,与第3章不同的是,程序引入了玩家列表,玩家登录后clientState会与player对象关联。通过判断clientState是否持有player对象即可判断客户端是处于“连接但未登录”状态,还是处于“登录成功”状态。
四、数据库配置
本章使用的是MySql数据库,具体安装就不讲了,大家应该都有(学计算机没有下mysql和navicaat?)。
为了可以使用MySql,也需要添加引用MySql.Data.dll和System.Data.dll,前者可以在官网里面下载获得,网站如下:
MySQL :: Download Connector/NEThttp://dev.mysql.com/downloads/connector/net/6.6.html#downloads
五、Scripts
本书最后的坦克大战游戏的服务器代码框架如下,但本章只涉及服务器框架,不涉及具体游戏逻辑内容
1、ClientState.cs
ClientState即客户端信息,每一个客户端连接会对应一个ClientState对象。
using System.Net.Sockets;
public class ClientState
{
public Socket socket;//客户端套接字
public ByteArray readBuff = new ByteArray();//BtyeArray,读缓冲区
public long lastPingTime = 0;//心跳机制,上一次接收到PingMsg的时间
public Player player;//Player类存储玩家的一些玩家数据,不同游戏肯定有所不同
}
2、Player、PlayerData
Player类是用于存储正在游玩时,玩家的数据,如坐标、房间、HP等,PlayerData是用于存储在数据库中的数据,没错登录时,会把数据库里的数据转换成PlayerDaPlayer类的data成员编程(每个连接客户端都有一个player成员变量),玩游戏的过程中,会不断更新这个PlayerData,退出游戏的时候,会把当前的PlayerData转换成字符串格式存储在数据库中,也就是更新数据库中的数据。
public class Player
{
//id,玩家登录时的账号
public string id = "";
//指向ClientState
public ClientState state;
//坐标和旋转
public float x;
public float y;
public float z;
public float ex;
public float ey;
public float ez;
//在哪个房间
public int roomId = -1;
//阵营,1和2代表两个阵营
public int camp = 1;
//坦克生命值
public int hp = 100;
//数据库数据
public PlayerData data;
//构造函数
public Player(ClientState state)
{
this.state = state;
}
//发送信息
public void Send(MsgBase msgBase)
{
NetManager.Send(state, msgBase);
}
}
public class PlayerData
{
//金币
public int coin = 0;
//记事本
public string text = "new text";
//胜利数
public int win = 0;
//失败数
public int lost = 0;
}
3、PlayerManager.cs
用于管理玩家列表。
using System.Collections.Generic;
public class PlayerManager
{
//玩家列表,string用来存储账号
static Dictionary<string, Player> players = new Dictionary<string, Player>();
//玩家是否在线
public static bool IsOnline(string id)
{
return players.ContainsKey(id);
}
//获取玩家
public static Player GetPlayer(string id)
{
if (players.ContainsKey(id))
{
return players[id];
}
return null;
}
//添加玩家
public static void AddPlayer(string id, Player player)
{
players.Add(id, player);
}
//删除玩家
public static void RemovePlayer(string id)
{
players.Remove(id);
}
}
4、DbManager.cs
和数据库相关的一些函数,和数据库相关的一些操作,每种语言都大同小异,应该也不难。
using System;
using MySql.Data.MySqlClient;
using System.Text.RegularExpressions;
using System.Web.Script.Serialization;
public class DbManager
{
public static MySqlConnection mysql;
static JavaScriptSerializer Js = new JavaScriptSerializer();
//连接mysql数据库
public static bool Connect(string db,string ip, int port, string user, string pw)
{
//创建MySqlConnection对象
mysql = new MySqlConnection();
//连接参数
string s = string.Format("Database={0};DataSource ={1}; port ={2}; UserId = {3}; Password ={4}", db, ip, port, user, pw);
mysql.ConnectionString = s;
//连接
try
{
mysql.Open();
Console.WriteLine("[数据库]connect succ ");
return true;
}
catch (Exception e)
{
Console.WriteLine("[数据库]connect fail, " + e.Message);
return false;
}
}
//判定安全字符串,为了防止SQL注入
private static bool IsSafeString(string str)
{
//所谓SQL注入,就是通过输入请求,把SQL命令插入到SQL语句中,以达到欺骗服务器执行恶意SQL命令的目的。
//假如我们会执行这样一条sql: string sql = "Select * form player where id =" + id;
//但是有个人的id取为:xiaoming;delete*form player;
//最后得到的就是:Select * form player where id = xiaoming ;delete * form player;
//这样就可能导致吧所有player信息删除了,所有需要判断安全字符串
return !Regex.IsMatch(str, @"[-|;|,|\/|\(|\)|\[|\]|\}|\{|%|@|\*|!|\']");
}
//是否存在该用户,存在返回false
public static bool IsAccountExist(string id)
{
//防SQL注入
if (!IsSafeString(id))
{
return false;
}
//SQL语句
string s = string.Format("select * from account where id='{0}';", id);
//查询
try
{
MySqlCommand cmd = new MySqlCommand(s, mysql);
MySqlDataReader dataReader = cmd.ExecuteReader();
bool hasRows = dataReader.HasRows;
dataReader.Close();
return !hasRows;
}
catch (Exception e)
{
Console.WriteLine("[数据库] IsSafeString err, " + e.Message);
return false;
}
}
//注册
public static bool Register(string id, string pw)
{
//防SQL注入
if (!IsSafeString(id))
{
Console.WriteLine("[数据库] Register fail, id not safe");
return false;
}
if (!IsSafeString(pw))
{
Console.WriteLine("[数据库] Register fail, pw not safe");
return false;
}
//能否注册
if (!IsAccountExist(id))
{
Console.WriteLine("[数据库] Register fail, id exist");
return false;
}
//写入数据库User表
string sql = string.Format("insert into account set id ='{0}' ,pw = '{1}'; ", id, pw);
try
{
MySqlCommand cmd = new MySqlCommand(sql, mysql);
cmd.ExecuteNonQuery();
return true;
}
catch (Exception e)
{
Console.WriteLine("[数据库] Register fail " + e.Message);
return false;
}
}
//创建角色,注册的时候要为每个账号创建一个PlayerData
public static bool CreatePlayer(string id)
{
//防sql注入
if (!IsSafeString(id))
{
Console.WriteLine("[数据库] CreatePlayer fail, id not safe");
return false;
}
//序列化
PlayerData playerData = new PlayerData();
//把playerData转换成jason然后在存入数据库
string data = Js.Serialize(playerData);
//写入数据库
string sql = string.Format("insert into player set id ='{0}' ,data = '{1}'; ", id, data);
try
{
MySqlCommand cmd = new MySqlCommand(sql, mysql);
cmd.ExecuteNonQuery();
return true;
}
catch (Exception e)
{
Console.WriteLine("[数据库] CreatePlayer err, " + e.Message);
return false;
}
}
//检测用户名密码
public static bool CheckPassword(string id, string pw)
{
//防sql注入
if (!IsSafeString(id))
{
Console.WriteLine("[数据库] CheckPassword fail, id not safe");
return false;
}
if (!IsSafeString(pw))
{
Console.WriteLine("[数据库] CheckPassword fail, pw not safe");
return false;
}
//查询
string sql = string.Format("select * from account where id = '{0}' and pw = '{1}'; ", id, pw);
try
{
MySqlCommand cmd = new MySqlCommand(sql, mysql);
MySqlDataReader dataReader = cmd.ExecuteReader();
bool hasRows = dataReader.HasRows;
dataReader.Close();
return hasRows;
}
catch (Exception e)
{
Console.WriteLine("[数据库] CheckPassword err, " + e.Message);
return false;
}
}
//获取玩家数据PlyaerData
public static PlayerData GetPlayerData(string id)
{
//防SQL注入
if (!IsSafeString(id))
{
Console.WriteLine("[数据库] GetPlayerData fail, id not safe");
return null;
}
//SQL
string sql = string.Format("select * from player where id ='{0}';", id);
try
{
//查询
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();
return playerData;
}
catch (Exception e)
{
Console.WriteLine("[数据库] GetPlayerData fail, " + e.Message);
return null;
}
}
//保存角色,把最新的playerdata存储到数据库
public static bool UpdatePlayerData(string id, PlayerData playerData)
{
//序列化
string data = Js.Serialize(playerData);
//sql
string sql = string.Format("update player set data = '{0}' where id = '{1}'; ", data, id);
//更新
try
{
MySqlCommand cmd = new MySqlCommand(sql, mysql);
cmd.ExecuteNonQuery();
return true;
}
catch (Exception e)
{
Console.WriteLine("[数据库] UpdatePlayerData err, " + e.Message);
return false;
}
}
}
5、LoginMsg.cs
登录注册有关的协议
//注册
public class MsgRegister : MsgBase
{
public MsgRegister() { protoName = "MsgRegister"; }
//客户端发
public string id = "";
public string pw = "";
//服务端回(0-成功,1-失败)
public int result = 0;
}
//登录
public class MsgLogin : MsgBase
{
public MsgLogin() { protoName = "MsgLogin"; }
//客户端发
public string id = "";
public string pw = "";
//服务端回(0-成功,1-失败)
public int result = 0;
}
//踢下线(服务端推送)
public class MsgKick : MsgBase
{
public MsgKick() { protoName = "MsgKick"; }
//原因(0-其他人登录同一账号)
public int reason = 0;
}
6、EventHandler.cs
代码中和room相关的都是和坦克大战相关的逻辑代码,可以不算在通用框架中
using System;
public partial class EventHandler
{
public static void OnDisconnect(ClientState c)
{
Console.WriteLine("Close");
//Player下线
if (c.player != null)
{
//离开战场
int roomId = c.player.roomId;
if (roomId >= 0)
{
Room room = RoomManager.GetRoom(roomId);
room.RemovePlayer(c.player.id);
}
//保存数据
DbManager.UpdatePlayerData(c.player.id, c.player.data);
//移除
PlayerManager.RemovePlayer(c.player.id);
}
}
public static void OnTimer()
{
CheckPing();
RoomManager.Update();
}
//Ping检查
public static void CheckPing()
{
//现在的时间戳
long timeNow = NetManager.GetTimeStamp();
//遍历,删除
foreach (ClientState s in NetManager.clients.Values)
{
if (timeNow - s.lastPingTime > NetManager.pingInterval * 4)
{
Console.WriteLine("timeNOw" + timeNow+" "+s.lastPingTime);
Console.WriteLine("Ping Close " + s.socket.RemoteEndPoint.ToString());
NetManager.Close(s);
return;
}
}
}
}
7、NetManager
using System;
using System.Net;
using System.Net.Sockets;
using System.Collections.Generic;
using System.Reflection;
class NetManager
{
//监听Socket
public static Socket listenfd;
//客户端Socket及状态信息
public static Dictionary<Socket, ClientState> clients = new Dictionary<Socket,ClientState>();
//Select的检查列表
static List<Socket> checkRead = new List<Socket>();
//ping间隔
public static long pingInterval = 30;
//服务器开始执行的函数
public static void StartLoop(int listenPort)
{
//Socket
listenfd = new Socket(AddressFamily.InterNetwork,SocketType.Stream, ProtocolType.Tcp);
//Bind
IPAddress ipAdr = IPAddress.Parse("0.0.0.0");
IPEndPoint ipEp = new IPEndPoint(ipAdr, listenPort);
listenfd.Bind(ipEp);
//Listen
listenfd.Listen(0);
Console.WriteLine("[服务器]启动成功");
//循环
while (true)
{
ResetCheckRead(); //重置checkRead ,也就是把所有clients的客户端套接字和 服务器套接字listenfd 放到checkRead列表里面
//四个参数为:检查是否有可读的Socket列表、检查是否有可写的Socket列表、检查是否有出错的Socket列表、等待回应时间(微秒)
//第四个参数单位为微秒,-1表示一直等待,0表示非阻塞。
Socket.Select(checkRead, null, null, 1000);//所以这个函数可以把checkRead列表中有可读信息的socket筛选出来
//遍历检查可读对象
for (int i = checkRead.Count - 1; i >= 0; i--)
{
Socket s = checkRead[i];
if (s == listenfd)//表示服务器套接字有可读消息,表示有新的客户端连接
{
ReadListenfd(s);//处理连接消息
}
else
{
ReadClientfd(s);//读取客户端发送过来的消息
}
}
//超时
Timer();//
}
}
//填充checkRead列表
public static void ResetCheckRead()
{
checkRead.Clear();
checkRead.Add(listenfd);
foreach (ClientState s in clients.Values)
{
checkRead.Add(s.socket);
}
}
//读取Listenfd,新的客户端连接
public static void ReadListenfd(Socket listenfd)
{
try
{
Socket clientfd = listenfd.Accept();
Console.WriteLine("Accept " + clientfd.RemoteEndPoint.ToString());
ClientState state = new ClientState();
state.socket = clientfd;
state.lastPingTime = GetTimeStamp();
clients.Add(clientfd, state);
}
catch (SocketException ex)
{
Console.WriteLine("Accept fail" + ex.ToString());
}
}
//读取Clientfd,读取客户端发送过来的消息
public static void ReadClientfd(Socket clientfd)
{
ClientState state = clients[clientfd];
ByteArray readBuff = state.readBuff;
//接收
int count = 0;
//缓冲区不够,清除,若依旧不够,只能返回
//缓冲区长度只有1024,单条协议超过缓冲区长度时会发生错误,根据需要调整长度
if (readBuff.remain <= 0)
{
//缓冲区不够,先看看也没有数据还未处理,再移动数据腾出空间,如果还是不行只能return了
OnReceiveData(state);
readBuff.MoveBytes();
};
if (readBuff.remain <= 0)
{
Console.WriteLine("Receive fail , maybe msg length > buff capacity");
Close(state);
return;
}
try
{
//接收数据,返回数据长度
count = clientfd.Receive(readBuff.bytes, readBuff.writeIdx, readBuff.remain, 0);
}
catch (SocketException ex)
{
Console.WriteLine("Receive SocketException " + ex.ToString());
Close(state);
return;
}
//客户端关闭
if (count <= 0)
{
Console.WriteLine("Socket Close " + clientfd.RemoteEndPoint.ToString());
Close(state);
return;
}
//消息处理
readBuff.writeIdx += count;
//处理二进制消息
OnReceiveData(state);
//移动缓冲区
readBuff.CheckAndMoveBytes();
}
//关闭连接
public static void Close(ClientState state)
{
//事件分发
MethodInfo mei = typeof(EventHandler).GetMethod("OnDisconnect");
object[] ob = { state };
mei.Invoke(null, ob);
//关闭
state.socket.Close();
clients.Remove(state.socket);
}
//数据处理
public static void OnReceiveData(ClientState state)
{
ByteArray readBuff = state.readBuff;
//消息长度
if (readBuff.length <= 2)
{
return;
}
Int16 bodyLength = readBuff.ReadInt16();
//消息体
if (readBuff.length < bodyLength)
{
return;
}
//解析协议名
int nameCount = 0;
string protoName = MsgBase.DecodeName(readBuff.bytes, readBuff.readIdx, out nameCount);
if (protoName == "")
{
Console.WriteLine("OnReceiveData MsgBase.DecodeName fail");
Close(state);
}
readBuff.readIdx += nameCount;
//解析协议体
int bodyCount = bodyLength - nameCount;
MsgBase msgBase = MsgBase.Decode(protoName, readBuff.bytes, readBuff.readIdx, bodyCount);
readBuff.readIdx += bodyCount;
readBuff.CheckAndMoveBytes();
//分发消息
MethodInfo mi = typeof(MsgHandler).GetMethod(protoName);
object[] o = { state, msgBase };
Console.WriteLine("Receive " + protoName);
if (mi != null)
{
mi.Invoke(null, o);//执行对应协议的函数
}
else
{
Console.WriteLine("OnReceiveData Invoke fail " + protoName);
}
//如果还有消息则继续读取消息
if (readBuff.length > 2)
{
OnReceiveData(state);
}
}
//定时器,有点像unity生命周期函数的update
static void Timer()
{
//定时器Timer会调用EventHandler的OnTimer方法。这一步的目的是将游戏逻辑与网络模块分开
//使开发者只需在EventHandler、MsgHandler等几个类中编写逻辑,让NetManager可以通用
//消息分发
MethodInfo mei = typeof(EventHandler).GetMethod("OnTimer");
object[] ob = { };
mei.Invoke(null, ob);
}
//发送
public static void Send(ClientState cs, MsgBase msg)
{
//状态判断
if (cs == null)
{
return;
}
if (!cs.socket.Connected)
{
return;
}
//数据编码
byte[] nameBytes = MsgBase.EncodeName(msg);
byte[] bodyBytes = MsgBase.Encode(msg);
int len = nameBytes.Length + bodyBytes.Length;
byte[] sendBytes = new byte[2 + len];
//组装长度
sendBytes[0] = (byte)(len % 256);
sendBytes[1] = (byte)(len / 256);
//组装名字
Array.Copy(nameBytes, 0, sendBytes, 2, nameBytes.Length);
//组装消息体
Array.Copy(bodyBytes, 0, sendBytes, 2 + nameBytes.Length, bodyBytes.Length);
//为简化代码,不设置回调
try
{
cs.socket.BeginSend(sendBytes, 0, sendBytes.Length, 0, null, null);
}
catch (SocketException ex)
{
Console.WriteLine("Socket Close on BeginSend" + ex.ToString());
}
}
//获取时间戳
public static long GetTimeStamp()
{
TimeSpan ts = DateTime.UtcNow - new DateTime(1970, 1, 1, 0, 0, 0, 0);
return Convert.ToInt64(ts.TotalSeconds);
}
}
8、Program.cs
主程序
namespace Game
{
class MainClass
{
public static void Main(string[] args)
{
if (!DbManager.Connect("game", "127.0.0.1", 3306, "root", "root"))
{
return;
}
NetManager.StartLoop(8888);
}
}
}