网络游戏涉及客户端和服务端。服务端程序记录玩家数据,处理客户端发来的协议。本文就介绍一套通用客户端的实现。
该框架基于Select多路复用处理网络消息,具有粘包半包处理、心跳机制等功能,还是用MySQL数据库存储玩家数据,是一套功能较完备的C#服务端程序。一般单个服务端进程可以承载数百名玩家,如果更多就需要改为分布式架构。
服务端两大核心是处理客户端的消息和存储玩家数据。
客户端与服务端通过TCP连接,使两者可以传递数据,服务端还连接着MySQL数据库,可将玩家数据保存到数据库中。
- 逻辑层:消息处理,事件处理,存储结构。
- 网络底层:连接客户端,消息处理,事件处理
- 数据库底层:连接数据库,存储结构。
和客户端不同的是,因为服务端程序和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
客户端信息,每一个客户端连接对应一个ClientState,含有与客户端连接的套接字socket和读缓冲区readBuff,以及对应的玩家数据和最后一次收到ping的时间。
using System.Net.Sockets; public class ClientState { public Socket socket; public ByteArray readBuff = new ByteArray(); //Ping public long lastPingTime = 0; //玩家 public Player player; }7.3.3开启监听和多路复用
客户端和服务端的NetManager功能相似,都是处理链接、粉发消息和网络事件。
但是为了管理多个连接,服务端采用了多路复用技术。
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 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(); } //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("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 { //id public string id = ""; //指向ClientState public ClientState state; //临时数据,如:坐标 public int x; public int y; public int z; //数据库数据 public PlayerData data; //construct 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引用客户端信息,两者相互引用配合。
两部分
- 安装和启动MySQL服务器,让它监听某个端口
- 使用第三方库编码和解码MySQL特定形式的协议
这里安装配置好的集成环境xampp
XAMPP是apache 、mysql、PHP 的集成的web 服务器软件
简单地说就是个数据库服务器!
安装完成后点击MySQL后方的Start按钮即可。
这是一套专为MySQL数据库服务的管理工具,可以用它建立数据库并查看数据表的内容,比直接使用MySQL的命令行语句方便。
安装好点击连接,新建连接,填入MySQL数据库的IP、端口用户名和密码,登陆数据库。
在这里我们自己创建game库,下面包含account和player两个表,分别记录id和password以及id和playerdata(都将键长度为20的id作为主键)。账号和游戏数据分开的好处是,一个账号可对应多个游戏的数据。
为解析MySQL的网络数据,我们需要引用connector这个第三方库,可以直接从书中资源下载。
添加MySql.Data.dll和System.Data.dll的依赖。
在服务端编写DbManager.cs,用于处理数据库相关事务。提供从数据库读取玩家数据、将玩家数据保存到数据库、注册、检测密码是否正确等功能。
因为connector的.Net framework版本是4.5.2,所以我们需要将服务端程序的框架也改为这个,防止引用不到MySql.Data。
连接MySQL第一步就是发起对数据库的网络连接。connector封装了所有与数据库交互的方法。
在引用"MySql.Data.MySqlClient"后,新建一个MySQL连接对象,设置数据库、用户名、密码后调用Open即可发起连接。
该连接对象和socket相似,我们将数据库名、数据库IP、数据库端口号及用户名密码等组装成ConnectionString所需的格式再调用open即可。
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}; 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) { //防SQL注入 if (!DbManager.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; } }7.7.4Register
通过insert into account set id=***, pw=***向account表插入数据。
但是一般不会吧明文的password存入数据库,而是先加密,比如加上md5加密,这样数据库被盗仍然不能从加密的密码获取用户信息。
//注册 public static bool Register(string id,string pw) { //防SQL注入 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; } //写入数据库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; } }7.7.5CreatePlayer
1.将默认的PlayerData对象序列化成Json数据
2.将数据保存到player表的data栏位中
//创建角色 public static bool CreatePlayer(string id) { //防sql注入 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) { //防sql注入 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); //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; } }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 = ""; //服务端回(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; }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 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); }
欢迎分享,转载请注明来源:内存溢出
评论列表(0条)