游戏服务端作为信息交互与处理的核心,由下面5个部分组成
1.Server(创建socket以及监听客户端连接)
2.ConnHelper(工具类,建立与MySQL的连接)
3.Controller(处理客户端发送的请求)
4.Model(与数据库中的表对应)
5.DAO(Data Access Object,操作数据库)
本篇博客介绍基础的服务器端搭建方法,由于代码还不够完善,整个工程文件暂时不贴出来,完善后再上传。
首先梳理一下流程:当客户端访问服务器端Server时,Server首先建立一个Client,用来维护与某一个客户端的连接,并且收发消息。之后通过Client调用Controller处理客户端的请求,如果需要访问数据库,就让Client调用ConnHelper进行访问,Controller在处理请求时如果需要对数据库进行交互,就要访问DAO层,DAO在访问Model时要通过Client取得与数据库的连接,简略图如下所示。
理解了本游戏的层级结构,接下来创建游戏服务器端,在工程下新建一个项目GameServer作为服务器端,添加5个文件夹(对应上图的5个部分)以及连接MySQL的引用,如下图所示
1.管理客户端的连接
客户端的连接管理放在Server文件夹下,在文件夹下创建Server和Client两个类,其中Server负责管理客户端的连接请求,Client负责管理与各个客户端的收发数据。
1.1 Server类
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Net;
using System.Net.Sockets;
namespace GameServer.Server
{
class Server
{
private IPEndPoint ipEndPoint;
private Socket serverSocket;
public Server() { }
public Server(string ipAddr, int port)
{
SetIPandPort(ipAddr, port);
}
public void SetIPandPort(string ipAddr, int port)
{
ipEndPoint = new IPEndPoint(IPAddress.Parse(ipAddr), port);
}
public void StartServer() //开启监听
{
Socket serverSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
serverSocket.Bind(ipEndPoint); //绑定
serverSocket.Listen(0); //监听数无限
serverSocket.BeginAccept(AcceptCallBack, null);
}
private void AcceptCallBack(IAsyncResult ar) //与客户端的连接通过新创建的Client进行
{
Socket clientSocket = serverSocket.EndAccept(ar); //接收到客户端的连接
Client client = new Client(clientSocket,this); //创建一个与客户端的实例,并把server自身传递过去,方便Client后期与Server交互
}
}
}
为了在Server中查看并管理已连接的客户端,我们将这些信息存储到List中,
private Socket serverSocket;
private void AcceptCallBack(IAsyncResult ar)
{
Socket clientSocket = serverSocket.EndAccept(ar);
Client client = new Client(clientSocket,this);
clientList.Add(client); //添加到List中
}
1.2 Client类
Client类负责操作与每个客户端的收发数据,方法与(一)类似:
https://blog.csdn.net/s1314_JHC/article/details/80914044
代码如下
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Net.Sockets;
namespace GameServer.Server
{
class Client
{
private Socket clientSocket;
private Server server;
public Client(){ }
public Client(Socket clientSocket,Server server) //形参 3.27
{
this.clientSocket = clientSocket;
this.server = server;
}
public void StartRecv()
{
clientSocket.BeginReceive(null, 0, 0, SocketFlags.None, RecvCallBack,null); //消息类还没有创建,因此参数先为null
}
private void RecvCallBack(IAsyncResult ar)
{
try //接收数据时可能出现异常
{
int count = clientSocket.EndReceive(ar); //数据长度
if (count == 0)
{
CloseConn();
}
//todo:处理接收到的数据
clientSocket.BeginReceive(null, 0, 0, SocketFlags.None, RecvCallBack, null);
}
catch (Exception e)
{
Console.WriteLine(e); //输出错误信息
CloseConn();
}
}
private void CloseConn() //断开连接
{
if (clientSocket != null)
{
clientSocket.Close();
server.RemoveClient(this); //断开连接时,Server还持有对Client的引用,因此要移除自身
}
}
}
}
其中server.RemoveClient()实现如下
public void RemoveClient(Client client)
{
lock (clientList) //防止client与多个客户端的通信,因此先加锁,确保移除的安全性
{
clientList.Remove(client);
}
}
1.3Message类
Message类负责对消息的解析,对收发消息的处理单独通过一个Message类管理,方便后期对接收方法的修改。处理方法与(一)中类似,代码如下
class Message
{
private byte[] data = new byte[1024]; //确保一条完整的数据可以存放
private int startIdx = 0; //表示数组中已经存储了多少字节的数据
public byte[] Data //获取的值
{
get { return data; }
}
public int GetStartIdx //开始的位置
{
get { return startIdx; }
}
public int RemainSize //data中剩余数据
{
get { return Data.Length - startIdx; }
}
//public void AddCount(int count)
//{
// startIdx += count;
//}
public void ReadMsg(int newDataAmount) //完成一条消息的解析,这里直接将Client中消息的长度count传递进来,就不用额外调用一次AddCount(int count)了
{
startIdx += newDataAmount;
while (true)
{
if (startIdx <= 4) return; //小于4表示信息不完整,不解析,继续接收下一条
int count = BitConverter.ToInt32(data, 0); //读取数据长度,0表示startIdx的位置
if ((startIdx - 4) >= count)
{
string s = Encoding.UTF8.GetString(data, 4, count);
Console.WriteLine("解析出一条数据:" + s);
Array.Copy(data, count + 4, data, 0, startIdx - 4 - count); //接收完数据后将其余数据往前移,便于下一次解析
startIdx -= (count + 4); //更新startIdx
}
else
{
break;
}
}
}
}
并将Client中clientSocket.BeginReceive()修改为
clientSocket.BeginReceive(msg.Data, msg.GetStartIdx, msg.RemainSize, SocketFlags.None, RecvCallBack,null);
在之前的//todo中使用
msg.ReadMsg(count);
读取数据。但在代码中可以看出,现有对数据的操作只是一些输出功能,实际上Client在对数据处理时,要调用Controller处理数据,因此接下来解决Controller类。
1.4 Controller类
Controller作为处理消息数据的类,首先为其创建一个基类,并让其余的类继承自该基类,基类BaseController定义为抽象类,具体实现如下。
抽象类(abstract)概念:https://blog.csdn.net/wzj0808/article/details/51388034
abstract class BaseController
{
RequestCode requestCode = RequestCode.None;
public RequestCode RequestCode
{
get
{
return requestCode;
}
}
public virtual string DefaultHandle(string data,Client client,Server server) //data是客户端发送的数据,因为是默认请求,因此不需要传递RequestCode和ActionCode,返回给客户端发送的数据,并且由于data可能要在服务端和客户端进行访问,因此加入服务端和客户端的引用
{
return null;
}
}
在实现Controller功能之前,我们在工程下新建一个项目,命名为Common,修改目标框架为.NET 2.0(考虑到Unity的兼容性),作为共享项目,用以处理服务端与客户端的共同请求,并添加两个类 RequestCode和ActionCode。当客户端与服务器端进行通信时,需要互相传递请求代码(RequestCode,决定请求类型)和命令代码(ActionCode,决定具体的请求)设置如下
我们在Controller中添加对Common的引用,如下
Client在调用Controller时需要与服务器进行通信,判断当前消息的请求方式,是数据库命令请求还是普通操作请求,如下图所示。
其中request为客户端向服务端发送的请求,这些请求信息包括数据长度、requestCode(每个请求对应一个独有的requestCode),ActionCode(对应的操作)以及数据,服务器端处理完这些信息后,某些信息(比如客户端的位置信息)只需要共享给其余客户端,不需要返回数据。对于需要返回的数据,内容包括数据长度、requestCode和数据。
我们在Controller中创建一个ControllerManager以管理所有的控制请求,代码如下
class ControllerManager
{
private Dictionary<RequestCode, BaseController> controllerDict = new Dictionary<RequestCode, BaseController>(); //RequestCode作为索引,访问BaseController
public ControllerManager()
{
Init();
}
void Init()
{
}
}
为了保证安全性,Client在调用ControllerManager功能时需要通过Server访问,即Server起到了一个中介的功能,因此Server应当要持有ControllerManager的引用,如下
using GameServer.Controller;
private ControllerManager ctrlManager = new ControllerManager();
ControllerManager中处理请求的方式如下
public void HandleRequest(RequestCode requestCode, ActionCode actionCode,string data) //RequestCode找到Controller,ActionCode找到Controller中的方法
{
BaseController baseCtrl;
bool isSuccessGet = controllerDict.TryGetValue(requestCode, out baseCtrl);
if (isSuccessGet == false)
{
Console.WriteLine("无法得到" + requestCode + "对应的Controller,读取失败"); //商业化的服务器一般使用日志记录
return;
}
else //在得到requestCode后,通过反射的方法调用actionCode方法
{
string methodName = Enum.GetName(typeof(ActionCode), actionCode); //将actionCode转换成methodName方法
MethodInfo mi = baseCtrl.GetType().GetMethod(methodName); //得到方法信息
if (mi == null)
{
Console.WriteLine("warning:未能在baseCtrl[" + baseCtrl.GetType() + "]找到" + mi + "方法!");
return;
}
object[] paraData = new object[]{data};
object o = mi.Invoke(baseCtrl, paraData); //在baseCtrl中调用mi方法,mi方法中的参数为paraData
}
}
2.客户端对消息进行解析并传递给ControllerManager
目前的Client在接收到消息时采用的是msg.ReadMsg(count)进行处理,现有的ReadMsg只是将信息读取并输出到控制台,需要改进。即将消息解析后通过ControllerManager调用具体的Controller进行处理。
需要解析的数据有数据长度、requestCode、actionCode和数据,因此将ReadMsg()函数修改为
public void ReadMsg(int newDataAmount) //完成一条消息的解析
{
startIdx += newDataAmount;
while (true)
{
if (startIdx <= 4) return; //小于4表示信息不完整,不解析,继续接收下一条
int count = BitConverter.ToInt32(data, 0); //读取数据长度,0表示startIdx的位置
if ((startIdx - 4) >= count)
{
RequestCode reqCode = (RequestCode)BitConverter.ToInt32(data, 4); //从4的位置开始解析,解析出RequestCode
ActionCode actionCode = (ActionCode)BitConverter.ToInt32(data, 8); //解析出ActionCode
string s = Encoding.UTF8.GetString(data, 12, count - 8); //读取的长度需要扣除RequestCode和ActionCode
Array.Copy(data, count + 4, data, 0, startIdx - 4 - count); //接收完数据后将其余数据往前移,便于下一次解析
startIdx -= (count + 4); //更新startIdx
}
else
{
break;
}
}
}
并在Server中进行消息处理
public void HandleRequest(RequestCode requestCode, ActionCode actionCode, string data, Client client)
{
ctrlManager.HandleRequest(requestCode, actionCode, data, client);
}
3.数据打包并发送到客户端
数据打包的功能在Message类中实现,
public static byte[] PackData(RequestCode requestData,string data)
{
byte[] requestCodeByte = BitConverter.GetBytes((int)requestData); //获取个部分的数据长度并进行拼接
byte[] dataBytes = Encoding.UTF8.GetBytes(data);
int dataAmount = requestCodeByte.Length + dataBytes.Length;
byte[] dataAmountBytes = BitConverter.GetBytes(dataAmount);
dataAmountBytes.Concat(requestCodeByte).Concat(dataBytes);
return dataAmountBytes; //error
}
Client通过一下代码完成数据发送
public void Send(RequestCode requestCode,string data)
{
byte[] bytes = Message.PackData(requestCode, data);
clientSocket.Send(bytes);
}
之后可以通过Server中的SendResponse()给客户端响应,
public void SendResponse(Client client,RequestCode requestCode,string data)
{
client.Send(requestCode, data);
}
SendResponse()的调用是在Controller处理完请求之后进行调用,至此对消息的解析与发送就完成了。
4.Conn与数据库的连接操作
再回顾下整体的服务器架构图,蓝框部分已经开发地差不多了,接下来解决ConnHelper,即与数据库的连接操作。
在ConnHelper文件夹下创建Conn类,用以处理与数据库的连接
代码如下
class Conn
{
public const string CONNECTIONSTRING = "datasource=127.0.0.1;port=3306;database=MyGame;user=root;pwd=Syj1993..;";
public static MySqlConnection Connect()
{
MySqlConnection conn = new MySqlConnection(CONNECTIONSTRING);
try
{
conn.Open();
return conn;
}
catch (Exception e)
{
Console.WriteLine("连接数据库时出现异常:" + e);
return null;
}
}
public static void Close(MySqlConnection conn)
{
if (conn != null)
{
conn.Close();
}
else
{
Console.WriteLine("MySqlConnection不能为空");
}
}
}
创建之后,在Client中进行使用即可。
以上部分就是大体上的服务器逻辑,至于具体与U3D客户端的信息交互与功能完善会在之后的学习中进行,相应代码也会在之后进行优化。