Unity教程之-创建你自己的游戏服务器(五):游戏服务器

 

上篇文章《Unity教程之-创建你自己的游戏服务器(四):服务器间通信》,那么在此基础上,本篇unity3d教程我们来搭建我们的游戏服务器!下面开始!

终于要对游戏服务器开刀了。

此前先提一下,其实只是用SS就可以完成一个简单的游戏服务器了。SS本身就是一个很强大的服务器框架,你可以在SS上就完成登录相关的操作,其他与游戏内容有关的操作也全部通过他的Command,用CommandFilter来筛选掉未登录过的Command,保证与游戏内容有关的操作Command都是登录过的玩家,也就是玩家数据是有效的。

分离开来之后也有好处,第一个是账号可以通用了,再一个连接也挺费机器资源的能够集中到一起了,分开部署有利于机器资源的利用嘛。

这次的游戏服务器搭建,没有新的框架引入了,主要还是上回讲到的用于服务器间通讯的NetMQ。与上回不同的是,上回使用的因为有严格的同步需求,所以是RequestResponse模型,这回只要用PushPull模型即可了。因为是双向通讯,所以还得有两个队列。

涉及到游戏信息的传递,就需要解码和编码报文。这里我选用的是ProtoBuf。第一回也有提到。有稍些听说过U3D用ProtoBuf在iOS上遇到的麻烦,其实勤奋的同学也可以自己写一个序列化的工具,想怎么序列化反序列化就怎么搞,可能会比PB还好用(我以前的公司一个项目就是这样搞的),不过对应多语言什么的可能有不小的工作量。 地址:https://github.com/mgravell/protobuf-net

游戏服务器这里开始代码量就会比较大了,所以不再像之前那样把所有的代码贴出来。因此,我在GitHub上建了个项目,你可以访问这个地址https://github.com/Roytin/RoyNet来获取最新的代码。

下面讲下主要的代码段和思路

基本结构

第一、肯定会一个逻辑主线程。

所有的玩家操作在处理之前都必须进入队列排队,这样在游戏逻辑上保证了所有操作都是有先后顺序的。主线程由一个死循环保持存活,这个死循环就是整个游戏世界的MainLoop。

在这个MainLoop中,需要做的事情就是获取每一个发送过来的玩家操作报文,进行执行。

我和SS一样使用Command模式来派发执行。每一个最小执行单元就是一个Command的Execute。这里需要做一个策略,就是额定最大处理时间,当执行Command超过最大处理时间戳的时候就跳出,Sleep让出CPU,等待下次循环到来。此处还可以进行记录统计,以进行性能分析。

另外,别忘了try、catch、输出错误日志,这是作为一个服务器必不可少的。

因为报文刚过来是byte[],需要反序列化,而反序列化是计算密集的,我觉得应该要划一个线程来单独处理,所以,就有了第二。

第二、报文解析线程

任务很简单,解析每一个报文,然后放进队列中等待执行线程调用。

因为是根据Command来解析的,每一个Command对应的报文都不同,所以每一个Command都有一个ID,我用了一个int来作为CommandID。在报文中,格式差不多是这样:

  • 4个字节的UserID
  • 2个字节的Body长度
  • 4个字节的CommandID
  • 根据Body长度获取Entity,然后用ProtoBuf解析

Command我用反射获取,你也可以做成注册的形式。

Command必须继承自泛型基类CommandBase, 这样GameServer在启动的时候就会通过反射获取所有的Command,并知道CommandID对应得Command类型和Command需要的报文Entity的类型TEntity。

于是,当收到来自Gate的报文的时候,就能根据第三行解析出来的CommandID实例化出Entity类型,并进行反序列化了。

我创建了一个类,用来记录反射之后获得的类型信息


public class RequestFactor
{
public CommandBase Command;
public Type PackageType;
public MethodInfo CreatePackageMethod;
}

下面是通过反射获取Command类型信息的代码


foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies())
{
IEnumerable<Type> ts = assembly.GetExportedTypes();
foreach (Type t in ts)
{
if (t.IsAbstract) continue;
if (t.IsSubclassOf(typeof(CommandBase)))
{
var ins = Activator.CreateInstance(t) as CommandBase;
Debug.Assert(t.BaseType != null, "t.BaseType != null");
var entityType = t.BaseType.GetGenericArguments()[0];
Debug.Assert(ins != null, "ins != null");
_commands[ins.Name] = new RequestFactor()
{
Command = ins,
PackageType = entityType,
CreatePackageMethod = methodSerializer.MakeGenericMethod(entityType)
};
}
}
}

然后是解析,如之前所说的,解析也需要一条单独的线程监听接收。因为NetMQ的Receive方法是阻塞的,所以,关闭服务器的时候怎么结束这根线程我想了一会儿,后来发现原来有一个HasIn的属性可以用来判断是否有数据可以接收,就好多了。


void OnReceive()
{
if (!_pullSocket.HasIn)
{
return;
}
byte[] data = _pullSocket.Receive();
int offset = 0;
var converter = EndianBitConverter.Big;
int userID = converter.ToInt32(data, offset);
offset += 4;
int length = converter.ToUInt16(data, offset);
offset += 2;
int cmdName = converter.ToInt32(data, offset);
offset += 4;
RequestFactor factor;
if (_commands.TryGetValue(cmdName.ToString("D"), out factor))
{
using (var stream = new MemoryStream())
{
stream.Write(data, offset, length - 4);
stream.Position = 0;
var package = factor.CreatePackageMethod.Invoke(null, new object[] { stream });
_actionsWaiting.Enqueue(new Tuple<int, CommandBase, object>(userID,factor.Command, package));
}
}
else
{
//todo:不认识的协议(可能是外挂),可以通知网关断开与该客户端的连接
}
}

解析完之后的报文存入一个线程安全的队列中,等待游戏逻辑线程来调用,我用的是ConcurrentQueue。

同逻辑线程,你也可以在这里设置一个最大处理时间,超时则跳过,记录本次解析的报文数量,用于性能分析。

BTW,别忘了try、catch,如果遇到异常,就是协议错误,直接通知网关断开连接,网关处的整个接收缓冲区的偏移已错误。

第三、发送线程

Server提供一个Send方法,将需要反馈给客户端的报文存入队列,等待发送线程进行序列化和发送。因为序列化是计算密集的,所以独立这个线程。

序列化比反序列化简单,不需要反射获取类型信息。直接用ProtoBuf序列化报文Entity,然后加上给客户端用的CommandID,长度,UserID即可。这里考虑到群发的情况,UserID是个集合,放在报文的最前面,标识要发送的那些玩家,如果缺省,默认是全部客户端。

  • 4字节指向客户端数量(在网关处卸掉)
  • 根据客户端数量取走头(在网关处卸掉)
  • 4字节CommandID
  • 2字节Entity长度
  • Entity(proto-buf)

我封装了一个Message和Serialize()方法用来序列化这些内容,发送的时候就直接:


IMessageEntity msg;
if (_msgsWaiting.TryDequeue(out msg))
{
byte[] data = msg.Serialize();
_pushSocket.Send(data);
}

同样可以做个性能统计以备优化。

BTW,做序列化和反序列化的时候,还得注意一点,网络通讯使用的是大端BigEndian而Windows是小端存储的(对应C#的BitConvert.IsLittleEndian),你需要自己封装一个(或者找一个别人写的)BigEndianBitConvert来做上面的序列化反序列化。

同样的,在游戏服务器做完发送之后,网关服务器也需要做接收。Push->Pull对应起来。网关服务器在接收之后读出UserID的列表,找到对应的Session对剩余的Body字节数组进行转发。

报文代码生成工具

ProtoBuf下载之后有一个Tools的文件夹,里面有个ProtoGen的工具,可以用来生成proto文件对应得cs文件。 先建一个文件夹专门用于存放proto文件,proto文件里面是各种报文。

proto文件的格式请自行查阅官方文档。小提示:编辑的时候最好不要用windows自带的notepad,用Sublime之类的来编辑,可以避免不必要的编码之类的小麻烦。

我写了个bat批处理,用来调用ProtoBufCS.exe生成代码到指定的cs文件。内容非常简单。


E:\ProtoCS\ProtoBufCS.exe D:\RoyNet "C:\Users\Roy\Documents\visual studio 2013\Projects\RoyNet\RoyNet.GameServer\Entity\PackageEntity.cs" RoyNet.GameServer.Entity
@echo Complete!
pause

后面三个参数分别是需要转换的proto文件存放的目录、转换后的csharp代码存放的文件、生成的命名空间。

好了,游戏服务器基本结构是这样。

Command方面、接下去需要做几个针对网关消息的特殊处理: 玩家登录时角色信息的获取、玩家断线离开通知、主动断开。

这几点之外的就是游戏操作的Command了,就可以在Command的执行函数Execute()方法中加入Player参数和TEntity参数,这里的player数据是确保可以获取到的、可信的。

如果是一玩家多角色的游戏,还需要传递当前玩家选定的角色,在特殊处理中还需要多加一步交互。

那么本篇结束,下篇完成整体的结构,然后做一个玩家的注册登录和简单的聊天室功能。好了,本篇unity3d教程到此结束,下篇我们再会!