前言
网络游戏涉及客户端和服务端。服务端程序记录玩家数据,处理客户端发来的协议。本文就介绍一套通用客户端的实现。 该框架基于Select多路复用处理网络消息,具有粘包半包处理、心跳机制等功能,还是用MySQL数据库存储玩家数据,是一套功能较完备的C#服务端程序。一般单个服务端进程可以承载数百名玩家,如果更多就需要改为分布式架构。
7.1服务端架构
服务端两大核心是处理客户端的消息和存储玩家数据。 客户端与服务端通过TCP连接,使两者可以传递数据,服务端还连接着MySQL数据库,可将玩家数据保存到数据库中。
7.1.2模块划分
- 逻辑层:消息处理,事件处理,存储结构。
- 网络底层:连接客户端,消息处理,事件处理
- 数据库底层:连接数据库,存储结构。
7.2Json编码解码
和客户端不同的是,因为服务端程序和Unity无关,无法使用JsonUtility,所以改用System.Web提供的方法实现。(需要手动引用System.web.Extensions)
新建一个控制台程序,将协议文件全部放在proto下。
7.2.3修改MsgBase
引入System.Web.Script.Serialization头文件后,和JsonUtility调用方法不同。因为JavaScriptSerializer不是静态类,需要定义一个该类型的对象,再让对象调用Serialize和Deserialize方法进行编码解码
7.3网络模块
7.3.1整体架构
- 协议解析
- 处理select多路复用的网络管理器NetManager(核心)
- 定义客户端信息ClientState类
- 处理网络消息的MsgHandler类,但是把不同消息类型的处理分拆到多个文件中。(BattleMsgHandler处理战斗协议、SysMsgHandler处理ping、pong协议)
- 事件处理类EventHandler
7.3.2ClientState
客户端信息,每一个客户端连接对应一个ClientState,含有与客户端连接的套接字socket和读缓冲区readBuff,以及对应的玩家数据和最后一次收到ping的时间。
using System.Net.Sockets;
public class ClientState
{
public Socket socket;
public ByteArray readBuff = new ByteArray();
public long lastPingTime = 0;
public Player player;
}
7.3.3开启监听和多路复用
客户端和服务端的NetManager功能相似,都是处理链接、粉发消息和网络事件。 但是为了管理多个连接,服务端采用了多路复用技术。
public static void StartLoop(int listenPort)
{
listenfd = new Socket(AddressFamily.InterNetwork,
SocketType.Stream, ProtocolType.Tcp);
IPAddress ipAdr = IPAddress.Parse("0.0.0.0");
IPEndPoint ipEp = new IPEndPoint(ipAdr, listenPort);
listenfd.Bind(ipEp);
listenfd.Listen(0);
Console.WriteLine("[服务器]启动成功");
while (true)
{
ResetCheckRead();
Socket.Select(checkRead, null, null, 1000);
for (int i = checkRead.Count - 1; i >= 0; i--)
{
Socket s = checkRead[i];
if (s == listenfd)
{
ReadListenfd(s);
}
else
{
ReadClientfd(s);
}
}
Timer();
}
}
服务端开启了端口监听后,进入循环。针对Select返回的列表,程序遍历它判断有新的客户端连接还是某个客户端发来消息,然后分别调用处理函数ReadListenfd和ReadClientfd。 当程序在Select有可读事件和超时都会调用Timer,空闲状态每秒调用一次。
处理监听消息以及处理客户端消息和前面写的都差不多,就不详细介绍了。
7.4心跳机制
我们在前面的clientstate已经加入了lastpingtime,注意是long,游戏客户端只运行几小时,unity提供的Time.time即可记录。但是服务端可能运行纪念,所以要用long保存。
7.4.2时间戳
时间戳是一种记录时间的方法,也就是1970年1月1日零点到现在的秒数。
public static long GetTimeStamp()
{
TimeSpan ts = DateTime.UtcNow - new DateTime(1970, 1, 1, 0, 0, 0, 0);
return Convert.ToInt64(ts.TotalSeconds);
}
7.4.3回应MsgPing协议
更新lastPingTime并回应
using System;
public partial class MsgHandler
{
public static void MsgPing(ClientState c,MsgBase msgBase)
{
Console.WriteLine("MsgPing");
c.lastPingTime = NetManager.GetTimeStamp();
MsgPong msgPong = new MsgPong();
NetManager.Send(c, msgPong);
}
}
7.4.4超时处理
遍历客户端连接,太久没收到就断开连接,并删除clients列表元素。注意这是在遍历clients,删除后再遍历会出错,所以直接break。每次checkping最多断开一个连接。 在ontimer中调用,ontimer是在timer里通过反射调用,timer在startloop里调用
public static void OnTimer()
{
CheckPing();
}
public static void CheckPing()
{
long timeNow = NetManager.GetTimeStamp();
foreach (ClientState s in NetManager.clients.Values)
{
if (timeNow - s.lastPingTime > NetManager.pingInterval * 4)
{
Console.WriteLine("Ping Close " + s.socket.RemoteEndPoint.ToString());
NetManager.Close(s);
return;
}
}
}
7.5玩家的数据结构
7.5.2Player
我们需要Player和PlayerData,Player里有id、socket,playerdata(存入数据库),临时坐标(无需存入数据库)
public class PlayerData
{
public int coin = 0;
public string text = "new text";
}
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
public class Player
{
public string id = "";
public ClientState state;
public int x;
public int y;
public int z;
public PlayerData data;
public Player(ClientState state)
{
this.state = state;
}
public void Send(MsgBase msgBase)
{
NetManager.Send(state, msgBase);
}
}相互引用
7.5.3PlayerManager
用id作为player(字典)的key来获取player,而不是ip+port,因为随时会变。 NetManager.clients保存所有客户端信息(ClientState),PlayerManager.players保存所有玩家对象(player),客户端信息通过clientState.player引用玩家对象,玩家对象通过player.state引用客户端信息,两者相互引用配合。
7.6配置MySQL数据库
两部分
- 安装和启动MySQL服务器,让它监听某个端口
- 使用第三方库编码和解码MySQL特定形式的协议
7.6.1安装并启动MySQL数据库
这里安装配置好的集成环境xampp
XAMPP是apache 、mysql、PHP 的集成的web 服务器软件
简单地说就是个数据库服务器! 安装完成后点击MySQL后方的Start按钮即可。
7.6.2Navicat for MySQL
这是一套专为MySQL数据库服务的管理工具,可以用它建立数据库并查看数据表的内容,比直接使用MySQL的命令行语句方便。 安装好点击连接,新建连接,填入MySQL数据库的IP、端口用户名和密码,登陆数据库。 在这里我们自己创建game库,下面包含account和player两个表,分别记录id和password以及id和playerdata(都将键长度为20的id作为主键)。账号和游戏数据分开的好处是,一个账号可对应多个游戏的数据。
7.6.4安装connector
为解析MySQL的网络数据,我们需要引用connector这个第三方库,可以直接从书中资源下载。 添加MySql.Data.dll和System.Data.dll的依赖。
7.7数据库模块
在服务端编写DbManager.cs,用于处理数据库相关事务。提供从数据库读取玩家数据、将玩家数据保存到数据库、注册、检测密码是否正确等功能。 因为connector的.Net Framework版本是4.5.2,所以我们需要将服务端程序的框架也改为这个,防止引用不到MySql.Data。
7.7.1连接数据库
连接MySQL第一步就是发起对数据库的网络连接。connector封装了所有与数据库交互的方法。 在引用"MySql.Data.MySqlClient"后,新建一个MySQL连接对象,设置数据库、用户名、密码后调用Open即可发起连接。 该连接对象和socket相似,我们将数据库名、数据库IP、数据库端口号及用户名密码等组装成ConnectionString所需的格式再调用open即可。
public static MySqlConnection mysql;
static JavaScriptSerializer Js = new JavaScriptSerializer();
public static bool Connect(string db,string ip,int port,string user,string pw)
{
mysql = new MySqlConnection();
string s = string.Format("Database={0}; Data Source={1}; port={2}; User Id={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;
}
}
先开启数据库服务器,再开启服务端程序,就可以显示连接成功。
7.1.2防止SQL注入
SQL注入,就是通过输入请求,把SQL命令插入到SQL语句中,已达到欺骗服务期执行恶意SQL命令的目的。比如取一个"xiaoming;delete * from player;"的名字,这样就会在操作这条用户名的时候删除player表。 所以为了防止SQL注入,我们把含有逗号、分号等特殊字符的字符串判定为不安全字符串,在拼装SQL语句前就进行安全监测。
using System.Text.RegularExpressions;
private static bool IsSafeString(string str)
{
return !Regex.IsMatch(str, @"[-|;|,|\/|\(|\)|\[|\]|\}|]{|%|@|\*|!\']");
}
7.7.3IsAccountExist
注册账号时判断账号是否已存在,不能再次注册。MySqlDataReader提供遍历数据集的方法,HasRows指明数据及是否包含数据。
public static bool IsAccountExist(string id)
{
if (!DbManager.IsSafeString(id))
{
return false;
}
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;
}
}
7.7.4Register
通过insert into account set id=***, pw=***向account表插入数据。 但是一般不会吧明文的password存入数据库,而是先加密,比如加上md5加密,这样数据库被盗仍然不能从加密的密码获取用户信息。
public static bool Register(string id,string pw)
{
if (!DbManager.IsSafeString(id))
{
Console.WriteLine("[数据库] Register fail, id not safe");
return false;
}
if (!DbManager.IsSafeString(pw))
{
Console.WriteLine("[数据库] Register fail, pw not safe");
return false;
}
if (!IsAccountExist(id))
{
Console.WriteLine("[数据库] Register fail, id exist");
return false;
}
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;
}
}
7.7.5CreatePlayer
1.将默认的PlayerData对象序列化成Json数据 2.将数据保存到player表的data栏位中
public static bool CreatePlayer(string id)
{
if (!DbManager.IsSafeString(id))
{
Console.WriteLine("[数据库] CreatePlayer fail, id not safe");
return false;
}
PlayerData playerData = new PlayerData();
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;
}
}
7.7.6CheckPassword
通过select * from account where id=*** and pw= ***查询数据库,如果有数据dataReader.HasRows==true说明id和pw正确。
7.7.7GetPlayerData
读取玩家数据,通过id在player表中搜寻数据,player表以id为key,以字符串的形式存放着序列化后的Json数据。 先用dataReader获取到对应账号的玩家数据,再将字符串反序列化PlayerData对象返回。
public static bool CreatePlayer(string id)
{
if (!DbManager.IsSafeString(id))
{
Console.WriteLine("[数据库] CreatePlayer fail, id not safe");
return false;
}
PlayerData playerData = new PlayerData();
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;
}
}
7.7.8UpdatePlayerData
将玩家数据playerData序列化成字符串,然后用形如"update player set data="{“coin”:100}" where id= “lpy”;"的SQL语句更新数据库中的数据
public static bool UpdatePlayerData(string id, PlayerData playerData)
{
string data = Js.Serialize(playerData);
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;
}
}
7.8登录注册功能
接下来用一个记事本跑通前面的流程。
7.8.1注册登陆协议
LoginMsg中包含了注册、登陆和踢出三条协议,均为服务端回应客户端的,一般有result和reason说明成功与否或者失败的原因
using System;
using System.Collections.Generic;
public class MsgRegister : MsgBase
{
public MsgRegister() { protoName = "MsgRegister"; }
public string id = "";
public string pw = "";
public int result = 0;
}
public class MsgLogin : MsgBase
{
public MsgLogin() { protoName = "MsgLogin"; }
public string id = "";
public string pw = "";
public int result = 0;
}
public class MsgKick : MsgBase
{
public MsgKick() { protoName = "MsgKick"; }
public int reason = 0;
}
7.8.3注册功能
添加LoginMsgHandle.cs,在其中编写MsgHandler(partial),先注册,再创建角色。
public static void MsgRegister(ClientState c, MsgBase msgBase)
{
MsgRegister msg = (MsgRegister)msgBase;
if (DbManager.Register(msg.id, msg.pw))
{
DbManager.CreatePlayer(msg.id);
msg.result = 0;
}
else
{
msg.result = 1;
}
NetManager.Send(c, msg);
}
7.8.4登陆功能
- 验证密码
- 状态判断(是否已经登陆)
- 踢下线,后上的踢掉先上的
- 读取playerData构建player对象,并加入到PlayerManager的列表中
public static void MsgLogin(ClientState c,MsgBase msgBase)
{
MsgLogin msg = (MsgLogin)msgBase;
if (!DbManager.CheckPassword(msg.id, msg.pw))
{
msg.result = 1;
NetManager.Send(c, msg);
return;
}
if(c.player != null)
{
msg.result = 1;
NetManager.Send(c, msg);
return;
}
if (PlayerManager.IsOnline(msg.id))
{
Player other = PlayerManager.GetPlayer(msg.id);
MsgKick msgKick = new MsgKick();
msgKick.reason = 0;
other.Send(msgKick);
NetManager.Close(other.state);
}
PlayerData playerData = DbManager.GetPlayerData(msg.id);
if(playerData == null)
{
msg.result = 1;
NetManager.Send(c, msg);
return;
}
Player player = new Player(c);
player.id = msg.id;
player.data = playerData;
PlayerManager.AddPlayer(msg.id, player);
c.player = player;
msg.result = 0;
player.Send(msg);
}
7.8.9客户端监听
先把服务端的两个协议文件复制到客户端中,在start进行协议的监听!
void Start()
{
NetManager.AddEventListener(NetManager.NetEvent.ConnectSucc, OnConnectSucc);
NetManager.AddEventListener(NetManager.NetEvent.ConnectFail, OnConnectFail);
NetManager.AddEventListener(NetManager.NetEvent.Close, OnConnectClose);
NetManager.AddMsgListener("MsgMove", OnMsgMove);
NetManager.AddMsgListener("MsgRegister", OnMsgRegister);
NetManager.AddMsgListener("MsgLogin", OnMsgLogin);
NetManager.AddMsgListener("MsgKick", OnMsgKick);
NetManager.AddMsgListener("MsgGetText", OnMsgGetText);
NetManager.AddMsgListener("MsgSaveText", OnMsgSaveText);
}
7.8.10注册功能
public void OnMsgRegister(MsgBase msgBase)
{
MsgRegister msg = (MsgRegister)msgBase;
if(msg.result == 0)
{
Debug.Log("注册成功");
}
else
{
Debug.Log("注册失败");
}
}
public void OnRegisterClick()
{
MsgRegister msg = new MsgRegister();
msg.id = idInput.text;
msg.pw = pwInput.text;
NetManager.Send(msg);
}
7.8.11登陆
public void OnMsgRegister(MsgBase msgBase)
{
MsgRegister msg = (MsgRegister)msgBase;
if(msg.result == 0)
{
Debug.Log("注册成功");
}
else
{
Debug.Log("注册失败");
}
}
public void OnRegisterClick()
{
MsgRegister msg = new MsgRegister();
msg.id = idInput.text;
msg.pw = pwInput.text;
NetManager.Send(msg);
}
|