以下内容摘自《Unity3D网络游戏实战》 PS
服务端架构
服务端的大致框架如下图所示 服务端程序的两大核心是处理客户端的消息和存储玩家数据。(也可以将一些消耗性能的计算放到服务端来进行)。“网络底层”是指处理网络连接的底层模块,它有处理粘包半包,协议解析等功能。消息处理模块属于游戏的逻辑层,比如当收到客户端的MsgMove协议的时候,服务端会在消息处理模块中记录玩家坐标,然后将MsgMove协议广播给所有的客户端。在服务端中,事件处理指的是玩家上线和下线之类的操作,这些逻辑在事件处理模块中执行。数据库底层模块则提供了保存玩家数据、读取玩家数据、注册、检验用户名和密码是否正确等功能,是服务端和数据库交互的一层封装。存储结构指定哪些数据需要保存,比如在线版记事本中需要保存文本信息,对于大部分游戏需要存储玩家的例如金币、经验、等级等信息。 从服务端的角度来看,玩家会有三个阶段:
- 连接阶段: 客户端调用Connect连接服务器,连接后双端即可通信。但是服务器还不知道玩家具体控制的哪个角色,于是需要客户端发送一条登录协议,协议中包含用户名、密码等信息,待检验通过后,服务端才会将网络连接与游戏角色对应起来,去数据库中获取该角色的数据才算登录成功
- 交互阶段: 双端互通协议,这里就是主要发生游戏逻辑处理的地方
- 登出阶段: 玩家下线,服务端把玩家的数据保存到数据库中。可以分为定时存储和统一下线时存储的做法
Json协议编码解码
整体上和上一篇客户端中做的相同,简单复制过来就行。需要注意的是,我们这里使用的编码解码方法由于不依靠Unity了,所以需要添加System.Web.Extensions依赖,同时其方法位于System.Web.Script.Serialization命名空间中,需要使用using引用它。同时,解码方法JavaScriptSerilaizer不是静态类,所以需要创建一个对象才能使用,其它的都和JsonUtility方法差不多
using System.Web.Script.Serialization;
static JavaScriptSerializer Js = new JavaScriptSerializer();
string s = Js.Serialize(msgBase);
网络模块
网络模块主要分为四个部分:
- 处理select多路复用的网络管理器NetManager,它是服务端网络模块的核心部件
- 定义客户端信息的ClientState类
- 处理网络消息的MsgHandler类
- 事件处理类EventHandler
ClientState
该类是记录客户端信息的类,即每一个客户端连接会对应一个ClientState对象。ClientState含有与客户端连接的套接字socket和读缓冲区readBuff。
class ClientState
{
public Socket socket;
public ByteArray readBuff = new ByteArray();
}
开启监听和多路复用
核心类NetManager至少包含下列成员:一个是用于监听的套接字listenfd,一个是管理客户端状态的列表clients和一个用于Select多路复用的列表checkRead。
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Sockets;
using System.Reflection;
using System.Text;
using System.Threading.Tasks;
namespace ConsoleApp1.net
{
class NetManager
{
public static Socket listenfd;
public static Dictionary<Socket, ClientState> clients = new Dictionary<Socket, ClientState>();
static List<Socket> checkRead = new List<Socket>();
}
}
服务端开启监听的方法为StartLoop,它接收一个参数listenPort,代表正在监听的端口。经过Socket->Bind->Listen三个步骤后,服务端开启了端口监听,然后进入循环。在循环中,程序先调用ResetCheckRead重置需要传入Select的Socket列表,包括监听套接字listenfd以及每一个已经连接的客户端套接字。针对Select返回的列表,程序会遍历它,判断是有新的客户端连接还是某个客户端法发来消息,然后分别调用处理函数ReadListenfd和ReadClientfd。代码中Socket.Select的第三个参数填了1000,代表设置超过1s的超时时间。当程序执行到Socket.Select的时候,它会阻塞等待可读的连接。当1S内没有可读的消息时,它会停止阻塞,返回空的checkRead列表,程序继续执行。所以,程序在Select有可读事件和超时都会调用Timer方法,空闲时状态下每秒调用一次
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();
}
}
public static void ResetCheckRead()
{
checkRead.Clear();
checkRead.Add(listenfd);
foreach(ClientState s in clients.Values)
{
checkRead.Add(s.socket);
}
}
处理监听消息
ReadListenfd时处理监听事件的方法,它会调用Accept接收客户端连接,然后新建一个客户端信息对象state,把它存入客户端信息列表clients。由于在访问套接字时出错、Socket已经关闭等情形下调用Accept方法会抛出异常,因此程序代码应该放在try-catch中
public static void ReadListenfd(Socket listenfd)
{
try
{
Socket clientfd = listenfd.Accept();
Console.WriteLine("Accept" + clientfd.RemoteEndPoint.ToString());
ClientState state = new ClientState();
state.socket = clientfd;
clients.Add(clientfd, state);
}
catch(SocketException ex)
{
Console.WriteLine("Accept fail" + ex.ToString());
}
}
处理客户端消息
处理客户端消息的ReadClientfd,它会先调用clientfd.Receive接收数据,并且将数据保存在缓冲区,为了提高程序的运行效率,需要手动设置缓冲区readBuff的readIdx和writeIdx,以及手动移动缓冲区数据的checkAndMoveBytes方法。 clientfd.Receive: clientfd.Receive的第一个参数readBuff.bytes代表缓冲区的byte数据,第二个参数readBuff.readIdx代表从readIdx处开始写入接收到的数据,第三个参数readBuff.remain代表最多接收remain个字节的数据,避免缓冲区溢出。由于clientfd.Receive可能引发异常,所以要将其放到try-catch结构中,以便捕获异常。如果发生了异常,说明该连接失效,调用Close方法关闭连接。 if(count<0): 当客户端主动断开连接的时候,服务端会收到长度为0的数据,当收到长度为0的数据时,调用Close方法关闭连接 OnReceiveData: OnReceiveData会处理粘包分包问题,并且解析出协议对象
public static void ReadClientfd(Socket clientfd)
{
ClientState state = clients[clientfd];
ByteArray readBuff = state.readBuff;
int count = 0;
if(readBuff.remain<=0)
{
OnReceiveData(state);
readBuff.MoveBytes();
}
if (readBuff.remain <= 0)
{
Console.WriteLine("Receive fail,maybe msg length > buff capacity");
Close(state);
}
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("Scoket Close" + clientfd.RemoteEndPoint.ToString());
Close(state);
return;
}
readBuff.writeIdx += count;
OnReceiveData(state);
readBuff.CheckAndMoveBytes();
}
关闭连接
关闭连接的Close会处理三件事情:第一是分发OnDisconnect事件,让程序可以在玩家掉线时做一些处理;第二是调用socket.Close关闭连接;其三是将客户端状态state移出clients列表
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);
}
处理协议
处理协议方法OnReceiveData与客户端网络模块的同名方法相似。它会先判断读缓冲区的数据是否足够长,如果条件满足,它会调用MsgBase.DecodeName和MsgBase.Decode解析出协议名和协议体,最后做消息分发,即调用MsgHandler类名为protoName的方法。它会传入两个参数,第一个参数state代表该消息来自哪个客户端,第二个参数msgBase代表协议对象。处于效率考虑,OnReceiveData也需要手动设置缓冲区的readIdx等属性
public static void OnReceiveData(ClientState state)
{
ByteArray readBuff = state.readBuff;
byte[] bytes = readBuff.bytes;
int readIdx = readBuff.readIdx;
if (readBuff.length <= 2)
{
return;
}
Int16 bodyLength = (Int16)((bytes[readIdx + 1] << 8) | bytes[readIdx]);
if(readBuff.length < bodyLength)
{
return;
}
readBuff.readIdx += 2;
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);
}
}
发送协议
服务端发送协议的方法和客户端大致相同,它接收两个参数:第一个参数是客户端信息对象cs,代表要将协议发哦是那个给哪个客户端;第二个参数msg代表发送的协议的对象。Send方法要先做一系列状态判断,确保客户端连接有效,然后将msg编码成Json协议格式,最后调用cs.socket.Send将字节流发送给客户端
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());
}
}
心跳机制
同客户端的心跳机制,客户端会定时向服务端发送MsgPing协议,服务端收到后需要回应MsgPong协议,并且记录当前时间。这里需要注意的是,由于服务器程序会运行很久,所以需要使用long来保存更大的数值
public class ClientState
{
public Socket socket;
public ByteArray readBuff = new ByteArray();
public long lastPingTime = 0;
}
采用的时间记录机制是时间戳,指的是当前时间减去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);
}
回应协议
这一部分代买和客户端相同
public static void MsgPing(ClientState c, MsgBase msgBase)
{
Console.WriteLine("MsgPing");
c.lastPingTime = NetManager.GetTimeStamp();
MsgPong msgPong = new MsgPong();
NetManager.Send(c, msgPong);
}
超时处理
当服务端很久没有收到MsgPing时,可以认为连接已经断开。在服务端的定时事件中编写心跳机制的处理函数。CheckPing方法会遍历所有的客户端信息,然后判断连接是否超时,由于网络可能存在延迟,所以使用了相对宽松的条件,默认为120s没有收到MsgPing协议才调用NetMananger.Close断开连接
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;
}
}
}
|