4月 28 2015
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教程到此结束,下篇我们再会!