《Unity3D网络游戏实战》第七章:通用服务端架构

一、服务端架构

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/NETicon-default.png?t=N7T8http://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);
        }
    }
}

  • 15
    点赞
  • 20
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

buzhengli

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值