有关游戏开发:游戏开发始终是个小项目,另外由于开发时间的限制,软件工程的思想和方法在游戏开发领域并不怎么受欢迎。从游戏开发团队的规模也能看出,基本上只能算小开发团队,有些工作室性质的,那就更简单了。
我所了解的早些的开发团队,其成员间没有什么严格的分工,大家凭兴趣自由选择一些模块来负责,完成了再去负责另一模块,有其他同事的工作需要接手或协助的也会立即转入。所以游戏开发人员基本都是多面手,从网络到数据库,从游戏逻辑到图形图象,每一项都有所了解,并能实际应用。或者说都具有非常强的学习能力,在接手一项新的任务后能在很短的时间内对该领域的技术迅速掌握并消化,而且还能现炒现卖。当然,这也与早期2D游戏的技术要求相对比较简单,游戏逻辑也没有现在这般复杂有关。而更重要的可能是,都是被逼出来的吧!:)
什么MMORPG的逻辑可以使单台服务器能上2000? NPC都是死的?玩家都不干活????
首先2000这个数字是我统计的一个平均值,统计的依据来自于与其他人的讨论和别人提供的数据,另外一些公司提供的运营数据打点折也可以估算个大概。或许是大家都夸大了自己的能力,或公司公开的数据水分超过了我的估计?呵呵 当然,我们的游戏也没有单台2000,但我认为现在的服务器存在很大的提升余地 至于你说的NPC是死的,玩家干不干活 NPC最消耗系统资源的当属AI了,特别是一些复杂的寻路,这个完全可以分离到一台单独的AI服务器上,不少游戏都是这么做的,从一些泄漏出来的服务器端上也可以看到 生物管理,状态更新和消息广播在Zone Server上做,其他公共逻辑模块在Master Server和Center Server上跑,那这样其实也就没有一个单台服务器承载量的概念了,可能要说数据的话还是以一组服务器为单位比较合适 另外我一直以wow的结构来做参考,希望实现的也跟wow差不多,所以这些数字也可以算做是我的目标吧。
服务器结构探讨 -- 最终的结构
一般来说,当某一部分能力达不到我们的要求时,最简单的解决方法就是在此多投入一点资源。既然想要更多的连接数,那就再加一台网关服务器吧。新增加了网关服后需要在大区服上做相应的支持,或者再简单点,有一台主要的网关服,当其负载较高时,主动将新到达的连接重定向到其他网关服上。 而对于游戏服来说,有一台还是多台网关服是没有什么区别的。每个代表客户端玩家的对象内部都保留一个代表其连接的对象,消息广播时要求每个玩家对象使用自己的连接对象发送数据即可,至于连接是在什么地方,那是完全透明的。当然,这只是一种简单的实现,也是普通使用的一种方案,如果后期想对消息广播做一些优化的话,那可能才需要多考虑一下。 既然说到了优化,我们也稍稍考虑一下现在结构下可能采用的优化方案。 首先是当前的Zone Server要做的事情太多了,以至于他都处理不了多少连接。这其中最消耗系统资源的当属生物的AI处理了,尤其是那些复杂的寻路算法,所以我们可以考虑把这部分AI逻辑独立出来,由一台单独的AI服务器来承担。 然后,我们可以试着把一些与地图数据无关的公共逻辑放到Master Server上去实现,这样Zone Server上只保留了与地图数据紧密相关的逻辑,如生物管理,玩家移动和状态更新等。 还有聊天处理逻辑,这部分与游戏逻辑没有任何关联,我们也完全可以将其独立出来,放到一台单独的聊天服务器上去实现。 最后是数据库了,为了减轻数据库的压力,提高数据请求的响应速度,我们可以在数据库之前建立一个数据库缓存服务器,将一些常用数据缓存在此,服务器与数据库的通信都要通过这台服务器进行代理。缓存的数据会定时的写入到后台数据库中。 好了,做完这些优化我们的服务器结构大体也就定的差不多了,暂且也不再继续深入,更细化的内容等到各个部分实现的时候再探讨。 好比我们去看一场晚会,舞台上演员们按着预定的节目单有序地上演着,但这就是整场晚会的全部吗?显然不止,在幕后还有太多太多的人在忙碌着,甚至在晚会前和晚会后都有。我们的游戏服务器也如此。 在之前描述的部分就如同舞台上的演员,是我们能直接看到的,幕后的工作人员我们也来认识一下。 现实中有警察来维护秩序,游戏中也如此,这就是我们常说的GM。GM可以采用跟普通玩家一样的拉入方式来进入游戏,当然权限会比普通玩家高一些,也可以提供一台GM服务器专门用来处理GM命令,这样可以有更高的安全性,GM服一般接在中心服务器上。 在以时间收费的游戏中,我们还需要一台计费的服务器,这台服务器一般接在网关服务器上,注册玩家登录和退出事件以记录玩家的游戏时间。 任何为用户提供服务的地方都会有日志记录,游戏服务器当然也不例外。从记录玩家登录的时间,地址,机器信息到游戏过程中的每一项操作都可以作为日志记录下来,以备查错及数据挖掘用。至于搜集玩家机器资料所涉及到的法律问题不是我们该考虑的。 差不多就这么多了吧,接下来我们会按照这个大致的结构来详细讨论各部分的实现。
服务器结构探讨 -- 一点杂谈
突然发现自己一旦罗嗦起来还真是没完没了。接下来先说说我在开发中遇到过的一些困惑和一基础问题探讨吧,这些问题可能有人与我一样,也曾遇到过,或者正在被困扰中,而所要探讨的这些基础问题向来也是争论比较多的,我们也不评价其中的好与坏,只做简单的描述。 首先是服务器操作系统,linux与windows之争随处可见,其实在大多数情况下这不是我们所能决定的,似乎各大公司也基本都有了自己的传统,如网易的freebsd,腾讯的linux等。如果真有权利去选择的话,选自己最熟悉的吧。 决定了OS也就基本上确定了网络IO模型,windows上的IOCP和linux下的epool,或者直接使用现有的网络框架,如ACE和asio等,其他还有些商业的网络库在国内的使用好像没有见到,不符合中国国情嘛。:) 然后是网络协议的选择,以前的选择大多倾向于UDP,为了可靠传输一般自己都会在上面实现一层封装,而现在更普通的是直接采用本身就很可靠的TCP,或者TCP与UDP的混用。早期选择UDP的主要原因还是带宽限制,现在宽带普通的情况下TCP比UDP多出来的一点点开销与开发的便利性相比已经不算什么了。当然,如果已有了成熟的可靠UDP库,那也可以继续使用着。 还有消息包格式的定义,这个曾在云风的blog上展开过激烈的争论。消息包格式定义包括三段,包长、消息码和包体,争论的焦点在于应该是消息码在前还是包长在前,我们也把这个当作是信仰问题吧,有兴趣的去云风的blog上看看,论论。 另外早期有些游戏的包格式定义是以特殊字符作分隔的,这样一个好处是其中某个包出现错误后我们的游戏还能继续。但实际上,我觉得这是完全没有必要的,真要出现这样的错误,直接断开这个客户端的连接可能更安全。而且,以特殊字符做分隔的消息包定义还加大了一点点网络数据量。 最后是一个纯技术问题,有关socket连接数的最大限制。开始学习网络编程的时候我犯过这样的错误,以为port的定义为unsigned short,所以想当然的认为服务器的最大连接数为65535,这会是一个硬性的限制。而实际上,一个socket描述符在windows上的定义是unsigned int,因此要有限制那也是四十多亿,放心好了。 在服务器上port是监听用的,想象这样一种情况,web server在80端口上监听,当一个连接到来时,系统会为这个连接分配一个socket句柄,同时与其在80端口上进行通讯;当另一个连接到来时,服务器仍然在80端口与之通信,只是分配的socket句柄不一样。这个socket句柄才是描述每个连接的唯一标识。按windows网络编程第二版上的说法,这个上限值配置影响。 好了,废话说完了,下一篇,我们开始进入登录服的设计吧。
登录服的设计 -- 功能需求
想象一下帐号验证的实现方法,最容易的那就是把用户输入的明文用帐号和密码直接发给登录服,服务器根据帐号从数据库中取出密码,与用户输入的密码相比较。 这个方法存在的安全隐患实在太大,明文的密码传输太容易被截获了。那我们试着在传输之前先加一下密,为了服务器能进行密码比较,我们应该采用一个可逆的加密算法,在服务器端把这个加密后的字串还原为原始的明文密码,然后与数据库密码进行比较。既然是一个可逆的过程,那外挂制作者总有办法知道我们的加密过程,所以,这个方法仍不够安全。 哦,如果我们只是希望密码不可能被还原出来,那还不容易吗,使用一个不可逆的散列算法就行了。用户在登录时发送给服务器的是明文的帐号和经散列后的不可逆密码串,服务器取出密码后也用同样的算法进行散列后再进行比较。比如,我们就用使用最广泛的md5算法吧。噢,不要管那个王小云的什么论文,如果我真有那么好的运气,早中500w了,还用在这考虑该死的服务器设计吗? 似乎是一个很完美的方案,外挂制作者再也偷不到我们的密码了。慢着,外挂偷密码的目的是什么?是为了能用我们的帐号进游戏!如果我们总是用一种固定的算法来对密码做散列,那外挂只需要记住这个散列后的字串就行了,用这个做密码就可以成功登录。 嗯,这个问题好解决,我们不要用固定的算法进行散列就是了。只是,问题在于服务器与客户端采用的散列算法得出的字串必须是相同的,或者是可验证其是否匹配的。很幸运的是,伟大的数学字们早就为我们准备好了很多优秀的这类算法,而且经理论和实践都证明他们也确实是足够安全的。 这其中之一是一个叫做SRP的算法,全称叫做Secure Remote Password,即安全远程密码。wow使用的是第6版,也就是SRP6算法。有关其中的数学证明,如果有人能向我解释清楚,并能让我真正弄明白的话,我将非常感激。不过其代码实现步骤倒是并不复杂,mangos中的代码也还算清晰,我们也不再赘述。 登录服除了帐号验证外还得提供另一项功能,就是在玩家的帐号验证成功后返回给他一个服务器列表让他去选择。这个列表的状态要定时刷新,可能有新的游戏世界开放了,也可能有些游戏世界非常不幸地停止运转了,这些状态的变化都要尽可能及时地让玩家知道。不管发生了什么事,用户都有权利知道,特别是对于付过费的用户来说,我们不该藏着掖着,不是吗? 这个游戏世界列表的功能将由大区服来提供,具体的结构我们在之前也描述过,这里暂不做讨论。登录服将从大区服上获取到的游戏世界列表发给已验证通过的客户端即可。好了,登录服要实现的功能就这些,很简单,是吧。 确实是太简单了,不过简单的结构正好更适合我们来看一看游戏服务器内部的模块结构,以及一些服务器共有组件的实现方法。这就留作下一篇吧。
服务器公共组件实现 -- mangos的游戏主循环
由于我在读技术文章时最不喜看到的就是大段大段的代码,特别是那些直接Ctrl+C再Ctrl+V后未做任何修改的代码,用句时髦的话说,一点技术含量都没有!所以在我们今后所要讨论的内容中,尽量会避免出现直接的代码,在有些地方确实需要代码来表述时,也将会选择使用伪码。 先从mangos的登录服代码开始。mangos的登录服是一个单线程的结构,虽然在数据库连接中可以开启一个独立的线程,但这个线程也只是对无返回结果的执行类SQL做缓冲,而对需要有返回结果的查询类SQL还是在主逻辑线程中阻塞调用的。 登录服中唯一的这一个线程,也就是主循环线程对监听的socket做select操作,为每个连接进来的客户端读取其上的数据并立即进行处理,直到服务器收到SIGABRT或SIGBREAK信号时结束。 所以,mangos登录服主循环的逻辑,也包括后面游戏服的逻辑,主循环的关键代码其实是在SocketHandler中,也就是那个Select函数中。检查所有的连接,对新到来的连接调用OnAccept方法,有数据到来的连接则调用OnRead方法,然后socket处理器自己定义对接收到的数据如何处理。 很简单的结构,也比较容易理解。 只是,在对性能要求比较高的服务器上,select一般不会是最好的选择。如果我们使用windows平台,那IOCP将是首选;如果是linux,epool将是不二选择。我们也不打算讨论基于IOCP或是基于epool的服务器实现,如果仅仅只是要实现服务器功能,很简单的几个API调用即可,而且网上已有很多好的教程;如果是要做一个成熟的网络服务器产品,不是我几篇简单的技术介绍文章所能达到。 另外,在服务器实现上,网络IO与逻辑处理一般会放在不同的线程中,以免耗时较长的IO过程阻塞住了需要立即反应的游戏逻辑。 数据库的处理也类似,会使用异步的方式,也是避免耗时的查询过程将游戏服务器主循环阻塞住。想象一下,因某个玩家上线而发起的一次数据库查询操作导致服务器内所有在线玩家都卡住不动将是多么恐怖的一件事! 另外还有一些如事件、脚本、消息队列、状态机、日志和异常处理等公共组件,我们也会在接下来的时间里进行探讨。
服务器公共组件实现 -- 继续来说主循环
正如我们曾讨论过的,为了游戏主逻辑循环的流畅运行,所有比较耗时的IO操作都会分享到单独的线程中去做,如网络IO,数据库IO和日志IO等。当然,也有把这些分享到单独的进程中去做的。 另外对于大多数服务器程序来说,在运行时都是作为精灵进程或服务进程的,所以我们并不需要服务器能够处理控制台用户输入,我们所要处理的数据来源都来自网络。 这样,主逻辑循环所要做的就是不停要取消息包来处理,当然这些消息包不仅有来自客户端的玩家操作数据包,也有来自GM服务器的管理命令,还包括来自数据库查询线程的返回结果消息包。这个循环将一直持续,直到收到一个通知服务器关闭的消息包。 主逻辑循环的结构还是很简单的,复杂的部分都在如何处理这些消息包的逻辑上。我们可以用一段简单的伪码来描述这个循环过程: while (Message* msg = getMessage()) 这里就有一个问题需要探讨了,在getMessage()的时候,我们应该去哪里取消息?前面我们考虑过,至少会有三个消息来源,而我们还讨论过,这些消息源的IO操作都是在独立的线程中进行的,我们这里的主线程不应该直接去那几处消息源进行阻塞式的IO操作。 很简单,让那些独立的IO线程在接收完数据后自己送过来就是了。好比是,我这里提供了一个仓库,有很多的供货商,他们有货要给我的时候只需要交到仓库,然后我再到仓库去取就是了,这个仓库也就是消息队列。消息队列是一个普通的队列实现,当然必须要提供多线程互斥访问的安全性支持,其基本的接口定义大概类似这样: IMessageQueue 网络IO,数据库IO线程把整理好的消息包都加入到主逻辑循环线程的这个消息队列中便返回。有关消息队列的实现和线程间消息的传递在ACE中有比较完全的代码实现及描述,还有一些使用示例,是个很好的参考。 这样的话,我们的主循环就很清晰了,从主线程的消息队列中取消息,处理消息,再取下一条消息......
服务器公共组件实现 -- 消息队列
我们所能想到的最简单的消息队列可能就是使用stl的list来实现了,即消息队列内部维护一个list和一个互斥锁,putMessage时将message加入到队列尾,getMessage时从队列头取一个message返回,同时在getMessage和putMessage之前都要求先获取锁资源。 实现虽然简单,但功能是绝对满足需求的,只是性能上可能稍稍有些不尽如人意。其最大的问题在频繁的锁竞争上。 对于如何减少锁竞争次数的优化方案,Ghost Cheng提出了一种。提供一个队列容器,里面有多个队列,每个队列都可固定存放一定数量的消息。网络IO线程要给逻辑线程投递消息时,会从队列容器中取一个空队列来使用,直到将该队列填满后再放回容器中换另一个空队列。而逻辑线程取消息时是从队列容器中取一个有消息的队列来读取,处理完后清空队列再放回到容器中。 这样便使得只有在对队列容器进行操作时才需要加锁,而IO线程和逻辑线程在操作自己当前使用的队列时都不需要加锁,所以锁竞争的机会大大减少了。 这里为每个队列设了个最大消息数,看来好像是打算只有当IO线程写满队列时才会将其放回到容器中换另一个队列。那这样有时也会出现IO线程未写满一个队列,而逻辑线程又没有数据可处理的情况,特别是当数据量很少时可能会很容易出现。Ghost Cheng在他的描述中没有讲到如何解决这种问题,但我们可以先来看看另一个方案。 这个方案与上一个方案基本类似,只是不再提供队列容器,因为在这个方案中只使用了两个队列,arthur在他的一封邮件中描述了这个方案的实现及部分代码。两个队列,一个给逻辑线程读,一个给IO线程用来写,当逻辑线程读完队列后会将自己的队列与IO线程的队列相调换。所以,这种方案下加锁的次数会比较多一些,IO线程每次写队列时都要加锁,逻辑线程在调换队列时也需要加锁,但逻辑线程在读队列时是不需要加锁的。 虽然看起来锁的调用次数是比前一种方案要多很多,但实际上大部分锁调用都是不会引起阻塞的,只有在逻辑线程调换队列的那一瞬间可能会使得某个线程阻塞一下。另外对于锁调用过程本身来说,其开销是完全可以忽略的,我们所不能忍受的仅仅是因为锁调用而引起的阻塞而已。 两种方案都是很优秀的优化方案,但也都是有其适用范围的。Ghost Cheng的方案因为提供了多个队列,可以使得多个IO线程可以总工程师的,互不干扰的使用自己的队列,只是还有一个遗留问题我们还不了解其解决方法。arthur的方案很好的解决了上一个方案遗留的问题,但因为只有一个写队列,所以当想要提供多个IO线程时,线程间互斥地写入数据可能会增大竞争的机会,当然,如果只有一个IO线程那将是非常完美的。
服务器公共组件实现 -- 环形缓冲区
就好比两个人围着一张圆形的桌子在追逐,跑的人被网络IO线程所控制,当写入数据时,这个人就往前跑;追的人就是逻辑线程,会一直往前追直到追上跑的人。如果追上了怎么办?那就是没有数据可读了,先等会儿呗,等跑的人向前跑几步了再追,总不能让游戏没得玩了吧。那要是追的人跑的太慢,跑的人转了一圈过来反追上追的人了呢?那您也先歇会儿吧。要是一直这么反着追,估计您就只能换一个跑的更快的追逐者了,要不这游戏还真没法玩下去。 前面我们特别强调了,按照严格的先进先出顺序进行处理,这是环形缓冲区的使用必须遵守的一项要求。也就是,大家都得遵守规定,追的人不能从桌子上跨过去,跑的人当然也不允许反过来跑。至于为什么,不需要多做解释了吧。 环形缓冲区是一项很好的技术,不用频繁的分配内存,而且在大多数情况下,内存的反复使用也使得我们能用更少的内存块做更多的事。 在网络IO线程中,我们会为每一个连接都准备一个环形缓冲区,用于临时存放接收到的数据,以应付半包及粘包的情况。在解包及解密完成后,我们会将这个数据包复制到逻辑线程消息队列中,如果我们只使用一个队列,那这里也将会是个环形缓冲区,IO线程往里写,逻辑线程在后面读,互相追逐。可要是我们使用了前面介绍的优化方案后,可能这里便不再需要环形缓冲区了,至少我们并不再需要他们是环形的了。因为我们对同一个队列不再会出现同时读和写的情况,每个队列在写满后交给逻辑线程去读,逻辑线程读完后清空队列再交给IO线程去写,一段固定大小的缓冲区即可。没关系,这么好的技术,在别的地方一定也会用到的。
服务器公共组件实现 -- 发包的方式
一般来说最直接的方法就是逻辑线程什么时候想发数据了就直接调用相关的socket API发送,这要求服务器的玩家对象中保存其连接的socket句柄。但是直接send调用有时候有会存在一些问题,比如遇到系统的发送缓冲区满而阻塞住的情况,或者只发送了一部分数据的情况也时有发生。我们可以将要发送的数据先缓存一下,这样遇到未发送完的,在逻辑线程的下一次处理时可以接着再发送。 考虑数据缓存的话,那这里这可以有两种实现方式了,一是为每个玩家准备一个缓冲区,另外就是只有一个全局的缓冲区,要发送的数据加入到全局缓冲区的时候同时要指明这个数据是发到哪个socket的。如果使用全局缓冲区的话,那我们可以再进一步,使用一个独立的线程来处理数据发送,类似于逻辑线程对数据的处理方式,这个独立发送线程也维护一个消息队列,逻辑线程要发数据时也只是把数据加入到这个队列中,发送线程循环取包来执行send调用,这时的阻塞也就不会对逻辑线程有任何影响了。 采用第二种方式还可以附带一个优化方案。一般对于广播消息而言,发送给周围玩家的数据都是完全相同的,我们如果采用给每个玩家一个缓冲队列的方式,这个数据包将需要拷贝多份,而采用一个全局发送队列时,我们只需要把这个消息入队一次,同时指明该消息包是要发送给哪些socket的即可。有关该优化的说明在云风描述其连接服务器实现的blog文章中也有讲到,有兴趣的可以去阅读一下。
服务器公共组件实现 -- 状态机
首先还是从mangos的代码开始看起,我们注意到登录服在处理客户端发来的消息时用到了这样一个结构体: struct AuthHandler 该结构体定义了每个消息码的处理函数及需要的状态标识,只有当前状态满足要求时才会调用指定的处理函数,否则这个消息码的出现是不合法的。这个status状态标识的定义是一个宏,有两种有效的标识,STATUS_CONNECTED和STATUS_AUTHED,也就是未认证通过和已认证通过。而这个状态标识的改变是在运行时进行的,确切的说是在收到某个消息并正确处理完后改变的。 我们再来看看设计模式中对State模式的说明,其中关于State模式适用情况里有一条,当操作中含有庞大的多分支的条件语句,且这些分支依赖于该对象的状态,这个状态通常用一个或多个枚举变量表示。 描述的情况与我们这里所要处理的情况是如此的相似,也许我们可以试一试。那再看看State模式提供的解决方案是怎样的,State模式将每一个条件分支放入一个独立的类中。 由于这里的两个状态标识只区分出了两种状态,所以,我们仅需要两个独立的类,用以表示两种状态即可。然后,按照State模式的描述,我们还需要一个Context类,也就是状态机管理类,用以管理当前的状态类。稍作整理,大概的代码会类似这样: 状态基类接口: 状态机基类接口: StateBase* m_curState; 我们的逻辑处理类会从MachineBase派生,当取出数据包后交给当前状态处理,前面描述的两个状态类从StateBase派生,每个状态类只处理该状态标识下需要处理的消息。当要进行状态转换时,调用MachineBase的ChangeState()方法,显示地告诉状态机管理类自己要转到哪一个状态。所以,状态类内部需要保存状态机管理类的指针,这个可以在状态类初始化时传入。具体的实现细节就不做过多描述了。 使用状态机虽然避免了复杂的判断语句,但也引入了新的麻烦。当我们在进行状态转换时,可能会需要将一些现场数据从老状态对象转移到新状态对象,这需要在定义接口时做一下考虑。如果不希望执行拷贝,那么这里公有的现场数据也可放到状态机类中,只是这样在使用时可能就不那么优雅了。 正如同在设计模式中所描述的,所有的模式都是已有问题的另一种解决方案,也就是说这并不是唯一的解决方案。放到我们今天讨论的State模式中,就拿登录服所处理的两个状态来说,也许用mangos所采用的遍历处理函数的方法可能更简单,但当系统中的状态数量增多,状态标识也变多的时候,State模式就显得尤其重要了。 比如在游戏服务器上玩家的状态管理,还有在实现NPC人工智能时的各种状态管理,这些就留作以后的专题吧。
服务器公共组件 -- 事件与信号
有些类似于QT中的event与signal,我将一些动作请求消息定义为事件,而将状态改变消息定义为信号。比如在QT应用程序中,用户的一次鼠标点击会产生一个鼠标点击事件加入到事件队列中,当处理此事件时可能会导致某个按钮控件产生一个clicked()信号。 对应到我们的服务器上的一个例子,玩家登录时会发给服务器一个请求登录的数据包,服务器可将其当作一个用户登录事件,该事件处理完后可能会产生一个用户已登录信号。 这样,与QT类似,对于事件我们可以重定义其处理方法,甚至过滤掉某些事件使其不被处理,但对于信号我们只是收到了一个通知,有些类似于Observe模式中的观察者,当收到更新通知时,我们只能更新自己的状态,对刚刚发生的事件我不已不能做任何影响。 仔细来看,事件与信号其实并无多大差别,从我们对其需求上来说,都只要能注册事件或信号响应函数,在事件或信号产生时能够被通知到即可。但有一项区别在于,事件处理函数的返回值是有意义的,我们要根据这个返回值来确定是否还要继续事件的处理,比如在QT中,事件处理函数如果返回true,则这个事件处理已完成,QApplication会接着处理下一个事件,而如果返回false,那么事件分派函数会继续向上寻找下一个可以处理该事件的注册方法。信号处理函数的返回值对信号分派器来说是无意义的。 简单点说,就是我们可以为事件定义过滤器,使得事件可以被过滤。这一功能需求在游戏服务器上是到处存在的。 关于事件和信号机制的实现,网络上的开源训也比较多,比如FastDelegate,sigslot,boost::signal等,其中sigslot还被Google采用,在libjingle的代码中我们可以看到他是如何被使用的。 在实现事件和信号机制时或许可以考虑用同一套实现,在前面我们就分析过,两者唯一的区别仅在于返回值的处理上。 另外还有一个需要我们关注的问题是事件和信号处理时的优先级问题。在QT中,事件因为都是与窗口相关的,所以事件回调时都是从当前窗口开始,一级一级向上派发,直到有一个窗口返回true,截断了事件的处理为止。对于信号的处理则比较简单,默认是没有顺序的,如果需要明确的顺序,可以在信号注册时显示地指明槽的位置。 在我们的需求中,因为没有窗口的概念,事件的处理也与信号类似,对注册过的处理器要按某个顺序依次回调,所以优先级的设置功能是需要的。 最后需要我们考虑的是事件和信号的处理方式。在QT中,事件使用了一个事件队列来维护,如果事件的处理中又产生了新的事件,那么新的事件会加入到队列尾,直到当前事件处理完毕后,QApplication再去队列头取下一个事件来处理。而信号的处理方式有些不同,信号处理是立即回调的,也就是一个信号产生后,他上面所注册的所有槽都会立即被回调。这样就会产生一个递归调用的问题,比如某个信号处理器中又产生了一个信号,会使得信号的处理像一棵树一样的展开。我们需要注意的一个很重要的问题是会不会引起循环调用。 关于事件机制的考虑其实还很多,但都是一些不成熟的想法。在上面的文字中就同时出现了消息、事件和信号三个相近的概念,而在实际处理中,经常发现三者不知道如何界定的情况,实际的情况比我在这里描述的要混乱的多。 这里也就当是挖下一个坑,希望能够有所交流。
再谈登录服的实现
GateWay/WorldServer GateWay/WodlServer LoginServer LoginServer DNSServer WorldServerMgr 其中DNSServer负责带负载均衡的域名解析服务,返回LoginServer的IP地址给客户端。WorldServerMgr维护当前大区内的世界服列表,LoginServer会从这里取世界列表发给客户端。LoginServer处理玩家的登录及世界服选择请求。GateWay/WorldServer为各个独立的世界服或者通过网关连接到后面的世界服。 在mangos的代码中,我们注意到登录服是从数据库中取的世界列表,而在wow官方服务器中,我们却会注意到,这个世界服列表并不是一开始就固定,而是动态生成的。当每周一次的维护完成之后,我们可以很明显的看到这个列表生成的过程。刚开始时,世界列表是空的,慢慢的,世界服会一个个加入进来,而这里如果有世界服当机,他会显示为离线,不会从列表中删除。但是当下一次服务器再维护后,所有的世界服都不存在了,全部重新开始添加。 从上面的过程描述中,我们很容易想到利用一个临时的列表来保存世界服信息,这也是我们增加WorldServerMgr服务器的目的所在。GateWay/WorldServer在启动时会自动向WorldServerMgr注册自己,这样就把自己所代表的游戏世界添加到世界列表中了。类似的,如果DNSServer也可以让LoginServer自己去注册,这样在临时LoginServer时就不需要去改动DNSServer的配置文件了。 WorldServerMgr内部的实现很简单,监听一个固定的端口,接受来自WorldServer的主动连接,并检测其状态。这里可以用一个心跳包来实现其状态的检测,如果WorldServer的连接断开或者在规定时间内未收到心跳包,则将其状态更新为离线。另外WorldServerMgr还处理来自LoginServer的列表请求。由于世界列表并不常变化,所以LoginServer没有必要每次发送世界列表时都到WorldServerMgr上去取,LoginServer完全可以自己维护一个列表,当WorldServerMgr上的列表发生变化时,WorldServerMgr会主动通知所有的LoginServer也更新一下自己的列表。这个或许就可以用前面描述过的事件方式,或者就是观察者模式了。 WorldServerMgr实现所要考虑的内容就这些,我们再来看看LoginServer,这才是我们今天要重点讨论的对象。 前面探讨一些服务器公共组件,那我们这里也应该试用一下,不能只是停留在理论上。先从状态机开始,前面也说过了,登录服上的连接会有两种状态,一是帐号密码验证状态,一是服务器列表选择状态,其实还有另外一个状态我们未曾讨论过,因为它与我们的登录过程并无多大关系,这就是升级包发送状态。三个状态的转换流程大致为: LogonState -- 验证成功 -- 版本检查 -- 版本低于最新值 -- 转到UpdateState 这个版本检查的和决定下一个状态的过程是在LogonState中进行的,下一个状态的选择是由当前状态来决定。密码验证的过程使用了SRP6协议,具体过程就不多做描述,每个游戏使用的方式也都不大一样。而版本检查的过程就更无值得探讨的东西,一个if-else即可。 升级状态其实就是文件传输过程,文件发送完毕后通知客户端开始执行升级文件并关闭连接。世界选择状态则提供了一个列表给客户端,其中包括了所有游戏世界网关服务器的IP、PORT和当前负载情况。如果客户端一直连接着,则该状态会以每5秒一次的频率不停刷新列表给客户端,当然是否值得这样做还是有待商榷。 整个过程似乎都没有值得探讨的内容,但是,还没有完。当客户端选择了一个世界之后该怎么办?wow的做法是,当客户端选择一个游戏世界时,客户端会主动去连接该世界服的IP和PORT,然后进入这个游戏世界。与此同时,与登录服的连接还没有断开,直到客户端确实连接上了选定的世界服并且走完了排队过程为止。这是一个很必要的设计,保证了我们在因意外情况连接不上世界服或者发现世界服正在排队而想换另外一个试试时不会需要重新进行密码验证。 但是我们所要关注的还不是这些,而是客户端去连接游戏世界的网关服时服务器该如何识别我们。打个比方,有个不自觉的玩家不遵守游戏规则,没有去验证帐号密码就直接跑去连接世界服了,就如同一个不自觉的乘客没有换登机牌就直接跑到登机口一样。这时,乘务员会客气地告诉你要先换登机牌,那登机牌又从哪来?检票口换的,人家会先验明你的身份,确认后才会发给你登机牌。一样的处理过程,我们的登录服在验明客户端身份后,也会发给客户端一个登机牌,这个登机牌还有一个学名,叫做session key。 客户端拿着这个session key去世界服网关处就可正确登录了吗?似乎还是有个疑问,他怎么知道我这个key是不是造假的?没办法,中国的假货太多,我们不得不到处都考虑假货的问题。方法很简单,去找给他登机牌的那个检票员问一下,这张牌是不是他发的不就得了。可是,那么多的LoginServer,要一个个问下来,这效率也太低了,后面排的长队一定会开始叫唤了。那么,LoginServer将这个key存到数据库中,让网关服自己去数据库验证?似乎也是个可行的方案。 如果觉得这样给数据库带来了太大的压力的话,也可以考虑类似WorldServerMgr的做法,用一个临时的列表来保存,甚至可以将这个列表就保存到WorldServerMgr上,他正好是全区唯一的。这两种方案的本质并无差别,只是看你愿意将负载放在哪里。而不管在哪里,这个查询的压力都是有点大的,想想,全区所有玩家呢。所以,我们也可以试着考虑一种新的方案,一种不需要去全区唯一一个入口查询的方案。 那我们将这些session key分开存储不就得了。一个可行的方案是,让任意时刻只有一个地方保存一个客户端的session key,这个地方可能是客户端当前正连接着的服务器,也可以是它正要去连接的服务器。让我们来详细描述一下这个过程,客户端在LoginServer上验证通过时,LoginServer为其生成了本次会话的session key,但只是保存在当前的LoginServer上,不会存数据库,也不会发送给WorldServerMgr。如果客户端这时想要去某个游戏世界,那么他必须先通知当前连接的LoginServer要去的服务器地址,LoginServer将session key安全转移给目标服务器,转移的意思是要确保目标服务器收到了session key,本地保存的要删除掉。转移成功后LoginServer通知客户端再去连接目标服务器,这时目标服务器在验证session key合法性的时候就不需要去别处查询了,只在本地保存的session key列表中查询即可。 当然了,为了session key的安全,所有的服务器在收到一个新的session key后都会为其设一个有效期,在有效期过后还没来认证的,则该session key会被自动删除。同时,所有服务器上的session key在连接关闭后一定会被删除,保证一个session key真正只为一次连接会话服务。 但是,很显然的,wow并没有采用这种方案,因为客户端在选择世界服时并没有向服务器发送要求确认的消息。wow中的session key应该是保存在一个类似于WorldServerMgr的地方,或者如mangos一样,就是保存在了数据库中。不管是怎样一种方式,了解了其过程,代码实现都是比较简单的,我们就不再赘述了。 有关登录服的讨论或许该告一段落了吧。
Mangos预编译头文件及模块划分随想
其中game工程生成的库文件game.lib居然有近四百兆之巨,mangosd和realmd在连接这个库的时候也要花上好长一段时间。当然,mangos现在的代码量也确实不少了,这也就要考虑到大的工程项目的源代码管理及模块工程划分,至少,像mangos现在这样,代码全部放入game目录中,并且就做为一个大的工程的方式,其弊端是已经显现了。另外还有功能模块划分的问题,需要找一个功能的实现时,不知道该到哪块代码去找,而要扩展某部分功能时,也是无从下手。 模块划分使用最广泛也是最容易实现的应该算是按接口编程了,实现的方法不用我多说,程序员都知道。其好处也是显而易见的,定义了接口之后,接口的实现便可以作为一个独立模块,也就可以单独为一个工程了。 可以拿mangos处理玩家登录的过程来做个比方,现在mangos的做法是一个很长的顺序执行的过程,如果玩家在队伍中,则向队友发送上线消息,如果玩家有好友,则向好友发送上线消息,如果玩家有公会,则向会员发送上线消息,等等。这些代码都是直接调用各功能部分的代码来实现,如果这里考虑一下模块划分,并定义出相应的接口,那就可以改成调用好友模块的上线处理接口,调用组队模块的上线处理接口,调用会会模块的上线处理接口,等等。 有了接口后,这几个模块可以在另外的工程中实现,不用再混在game工程中。对象通过定义好的接口来调用,这样只要没有改动接口,模块的实现修改都不会影响到game工程。 还可以再进一步,在游戏逻辑的处理上再做一些解耦合。还是上面这个例子,玩家在登录时,先调用组队模块接口,再调用好友模块接口,再调用公会模块接口......这些顺序的执行过程将这些模块紧紧地耦合在了一起,当游戏逻辑变得越来越复杂时,类似的接口及调用数量会呈爆炸式的增长,这也将会成为另一个巨大的问题。 一个可行的方法是使用被称作事件或者信号的对象来实现解耦合。仍然拿上面的例子来说,当玩家登录成功时,玩家对象发出一个“玩家已登录”的事件或者信号,对此事件感兴趣的模块,会响应这个事件并且做出相应的逻辑处理,具体来说就是好友模块会向该玩家的好友广播上线消息,组队模块会向该玩家的队友广播上线消息,公会模块会向该玩家所在的公会广播会员上线消息,等等。注册感兴趣的事件及响应事件的处理过程都是在各独立模块内部完成,玩家对象本身并不知道也不需要知道有这么些过程。这样,想要删除或者扩展功能就比较的方便了。 sigslot这个开源库就提供了我们所要的这项功能。在玩家对象内部定义一个Signal对象,功能模块从has_slot派生,并且将自己连接到玩家对象的signal对象上,这样当玩家对象的signal对象被emit时便会调用到该模块内。在收到这个信号时你可能还需要一些参数,至少应该知道到底是谁登录了吧,没关系,signal中可以带任意多个参数,完全由你来控制,但遗憾的是他的slot不支持返回值。如果你不能容忍这样大的一个功能缺失的话,boost::signal或许可以满足你的要求,但太过于复杂的东西我一向不大喜欢,boost就属于这一类,虽然他非常的强大。 还有一个可考虑的选择是FastDelegate,不过你得自己做一些封装才能实现我们上面提到的类似功能。虽然FastDelegate基本上只是实现了一个安全的回调函数的功能,但是自己封装出来的东西或许更适合你的需求,也可以试一试。
游戏对象的实现 (上)
在大部分的MMOG中,游戏对象的类型都大同小异,主要有物品、生物、玩家等。比如在wow中,通过服务器发下来的GUID我们可以了解到,游戏中有9大类对象,包括物品(Item)、背包(Container)、生物(Unit)、玩家(Player)、游戏对象(GameObject)、动态对象(DynamicObject)、尸体(Corpse)等。 在mangos的实现中,对象使用类继承的方式,由Object基类定义游戏对象的公有接口及属性,包括GUID的生成及管理、构造及更新UpdateData数据的虚接口、设置及获取对象属性集的方法等。然后分出了两类派生对象,一是Item,另一是WorldObject。Item即物品对象,WorldObject顾名思义,为世界对象,即可添加到游戏世界场景中的对象,该对象类型定义了纯虚接口,也就是不可被实例化,主要是在Object对象的基础上又添加了坐标设置或获取的相关接口。 Item类型又派兵出了一类Bag对象,这是一种特殊的物品对象,其本身具有物品的所有属性及方法,但又可作为新的容器类型,并具有自己特有的属性和方法,所以实现上采用了派生。mangos在实现时对Bag的类型定义做了点小技巧,Item的类型为2,Bag的类型为6,这样在通过位的方式来表示类型时,Bag类型也就同时属于Item类型了。虽然只是很小的一个技巧,但在很多地方却带来了极大的便利。 从WorldObject派生出的类型就有好几种了,Unit、GameObject、DynamicObject和Corpse。Unit为所有生物类型的基类,同WorldObject一样,也不可被实例化。它定义了生物类型的公有属性,如种族、职业、性别、生命、魔法等,另外还提供了相关的一些操作接口。游戏中实际的生物对象类型为Creature,从Unit派生,另外还有一类派生对象Player为玩家对象。Player与Creature在实现上最大的区别是玩家的操作由客户端发来的消息驱动,而Creature的控制是由自己定义的AI对象来驱动,另外Player内部还包括了很多的逻辑系统实现。 另外还有两类特殊的Creature,Pet和Totem,其对象类型仍然还是生物类,只是实现上与会有些特殊的东西需要处理,所以在mangos中将其作为独立的派生类,只是实现上的一点处理。另外在GameObject中也实现有派生对象,最终的继承关系图比较简单,就不麻烦地去画图了。 从我所了解的早期游戏实现来看,大部分的游戏对象结构都是采用的类似这种方式。可能与早期对面向对象的理解有关,当面向对象的概念刚出来时,大家认为继承就是面向对象的全部,所以处处皆对象,处处皆继承。 类实现的是一种封装,虽然从云风那里出来的弃C++而转投C的声音可能会影响一部分人,但是,使用什么语言本身就是个人喜好及团队整体情况决定的。我们所要的也是最终的实现结果,至于中间的步骤,完全看个人。还是用云风的话说,这只是一种信仰问题,我依然采用我所熟悉的C++,下面的描述也是如此。 随着面向对象技术的深入,以及泛型等概念的相继提出,软件程序结构方面的趋势也有了很大改变。C++大师们常说的话中有一句是这样说的,尽是采用组合而不是继承。游戏对象的实现也有类似的转变,趋向于以组合的方式来实现游戏对象类型,也就是实现一个通用的entity类型,然后以脚本定义的方式组合出不同的实际游戏对象类型。 描述的有些抽象,具体实现下一篇来仔细探讨下。
游戏对象的实现 (下)
在游戏编程精粹四有三篇文章讲到了实体以及实体管理的实现方案,其中一篇文章说到了实体管理系统的四大要素:定义实体怎样沟通的实体消息,实现一实体类代码和数据的实体代码,维护已经注册在案的实体类列表,和用来创建、管理、发送消息的实体管理器。 关于实体消息的内容之前讨论事件机制的时候做过一点说明,其实这也就是按接口调用和按消息驱动的区别,现在mangos的做法是完全的接口调用,所以引擎内部就没有任何的实体消息。实体代码实现和实体管理器是我们重点要讨论的内容。 另有一篇文章也提到了使用类继续的方式实现游戏对象的两大问题,一是它要求系统中的所有对象都必须从一个起点衍生而成,也就是说所有对象类在编译的时候已经确定,这可能是一个不受欢迎的限制,如果开发者决定添加新的对象类,则必须要对基类有所了解,方能支持新类。另一个问题在于所有的对象类都必须实现同样的一些底层函数。 对于第二个问题,可以通过接口继承的方式来避免基类的方法太多。在mangos的实现中就采用了类似的方法,从Object虚基类派生的Unit和WorldObject仍然还是不可实例化的类,这两种对象定义了不同的属性和方法,分来实现不同类型的对象。在游戏内部可以根据对象的实际类型来Object指针向下转型为Unit或WorldObject,以调用需要的接口。方法虽然不够OO,也还能解决问题。但是第一个问题是始终无法避免的。 所以我们便有了通用实体这么一个概念,其主要方法是将原来基类的接口进行分类,分到一个个不同的子类中,然后以对象组合的方式来生成我们所需要的实际游戏对象类型。这个组合的过程可以通过脚本定义的方式,这样便可以在运行时生成为同的对象类型,也就解决了上面提到的第一个问题。 通用实体的实现方法在目前的游戏引擎及开源代码中也可以看到影子。一个是BigWorld,从提供的资料来看,其引擎只提供了一个entity游戏对象,然后由游戏内容实现者通过xml和python脚本来自由定义不同类型的entity类型,每种类型可有不同的property和不同的方法。这样原来由基类定义的接口完全转移到脚本定义,具有非常强的灵活性。 另外还有一个是CEL中的entity实现。按照CEL的描述,entity可以是游戏中的任意对象,包括玩家可交互的对象,如钥匙、武器等,也可以包括不能直接交互的对象,如游戏世界,甚至任务链中的一部分等。entity本身并没有任何特性,具体的功能实现需要靠附加property来完成。简单来说,property才定义了entity可以做什么,至于该怎么做,那又是依靠behavior来定义。所以,最终在CEL中一个游戏对象其实是由entity组合了多个property及多个behavior而生成的。 但是CEL中的property与BigWorld中的property意义不大一样,在CEL中可定义的property其实是引擎内部要先提供的,比如其示例中所举的pcobject.mesh、pcmove.linear、pctools.inventory等,而BigWorld中的property是完全的自由定制。从这个角度来讲,其实可以把CEL中的property看作是游戏的逻辑系统,也就是我们上面所描述的,接口分类后所定义的子类。 由引擎内部提供可选择的property与BigWorld所采用的完全自由定制property其实本质上是相同的。在用BigWorld实现的游戏中,也不可能为每种游戏对象类型都完全从头定义property,基于代码利用的原则,也会先定义一些小类,然后在entity类型定义时以自定义property的方式来包含这些小类。当然,我没有使用过BigWorld,上面的描述也只是基于游戏实现的大原则所做出来的。 描述的依然有些抽象,我们可以用wow及mangos代码来说明一下。mangos中为object定义了一个属性集合,根据对象类型的不同,这个属性集的大小及保存数据也会有差异,另外游戏对象内部含有处理不同游戏逻辑的系统,如RestSystem、FloodFilterSystem、VariousSystem等等,在player.h中以接口组的方式进行定义。 如果要将这种结构改为我们描述的通用entity系统,可以让object只提供property注册和删除的接口,这里的property定义与CEL中的相同,放在mangos中也就是上面说的RestSystem、FloodFilterSystem、VariousSystem这些。然后也通过xml文件的方式定义我们所需要的游戏对象类型,如player,creature,item等,不同的对象类型可以选择加载不同的property,加载的原则是需要哪些功能就加载哪些property。 对象的定义按上面的方法完成后,对象的实现也需要做一些修改。以前客户端的消息是直接交由player来处理,AI也是直接调用creature的接口来完成一些功能,现在通用的entity内部已经没有任何可用的方法,所有的实现都转到了property中,所以需要由各个property实现自己来注册感兴趣的事件及消息,entity实现一个消息的转发,转给对此感兴趣的property来处理。其余的实现就没有什么不同了。 当然,我们再做一点扩展,让property不光由引擎来提供,用脚本本身也能定义property,并且可以通过xml来注册这些property,这样便实现了与BigWorld一样的完全自由特性。这其实也就是将很多用C++实现的功能转移到了python中,这种做法可作为参考,但不一定对所有人合适,至少在我看来,这样的实现也基本只能由程序员来做,所以让程序员选择自己最擅长的语言可能会更易于开发和调试。
游戏对象的实现 (补)
传统的游戏对象管理系统采用继承的方式来实现,例如,所有的子类都从CObject派生。大多数情况下,直接派生的也是抽象类,其中带一些功能而另一些子类则不带这些功能,比如可控制/不可控制,可动画/不可动画等。mangos的实现中基本就是这种情况,从Object直接派生的Unit和WorldObject都是不可直接实例化的类。 传统方法的问题在于无法应对需求的变化,如要求武器也有动画效果时就无法处理了。如果硬要是这样做,那随着需求的啬,很多的方法会被放到基类中,最终的结果是继承树变得越来越头重脚轻,这样的类会丧失它的内聚性,因为它们试图为所有对象完成所有的事。 就是说到最后,基类会有一个很长的接口列表,而很多的游戏对象类型根本不需要实现其中的一些甚至大部分接口,但是按照这种结构却又必须去实现。以至于于实现一个非常庞大的对象,而且想要修改一点功能会导致系统的大调整。 我们希望的系统是可以将现有的功能组合到新的对象中,并且在将新的功能添加到现有的对象中时不需要重构大量的代码和调整继承树的结构。 实现的方法就是从组件来创建一个对象。组件是一个包含所有相关数据成员和方法的类,它完成某个特定的任务。把几个组件组合在一起就可以创建一个新的对象。如把Entity组件、Render组件和Collectable组件组合在一起生成了一个Spoon对象。Entity组件让我们可以把对象放到游戏世界中,Render组件让我们可以为对象指定一个模型进行渲染,而Collectable组件让我们可以拾取这个对象。 关于组件的实现,所有的组件都从一个基础组件接口派生,可称其为IComponent。每个组件也有自己的接口定义,并且这个接口也需要从IComponent派生,类似于这样:IComponent -- ICmpRender -- CCmpRender 这里的每个组件也就是我在上一篇中所说的由引擎提供的属性,或者说在BigWorld中自己实现然后定义的属性,或者使用mangos中的定义,就是一个个的System,虽然mangos并没有将其完全做成组件,但是通过其代码注释可以看到,接口也是按功能组进行了分类,如果要拆分成组件也是比较方便的。 组件之间的通信有两种方法,一是用组件ID查询到组件接口指针,然后调用接口方法;二是使用消息的方式,向对象中所有组件发消息。在初始化的时候,每一个组件类型都会告诉对象管理器应该接收什么样的消息。 查询接口的方法也就是直接的方法调用,只不过接口不是全部在基类中,所以必须先查询到指定的组件然后才能调用其接口。消息的使用前面已经说过多次,其实现方案也有过说明。 最后是关于游戏对象功能的扩展和游戏对象的定义。需要扩展功能也就是需要实现一个新的组件,或者修改现在组件。在大多数情况下,扩展都不会引起结构的很大调整,受影响的最多只是使用到该组件的部分代码。 游戏对象定义可采用完全数据驱动的方式,使用xml或者脚本语言来定义对象类型,以及每个类型需要加载的组件。对象类型注册到对象管理器后,由管理器提供创建指定类型的对象的方法。数据驱动的方式能够让策划自由定义游戏对象类型,并且随时可自由创建新的对象类型。 |
游戏服务器结构讨论(转)
最新推荐文章于 2015-12-25 14:40:33 发布