Pomelo(柚子)是一个可以让开发者便捷开发的游戏服务端框架。下面是其一些Pomelo的主要设计思想。
概述
在这一节中我们会展示Pomelo是什么。
在游戏服务端中通常有着各种各样的任务,形如客户端连接的管理、游戏世界状态的管理以及计算游戏的逻辑。不同的任务可能会又不同的资源需求,比如IO开销或者CPU开销等。但是在一个进程中处理所有的作业显然是非常不切实际的。所以将一个游戏服务端分割成一些简小服务端进程是一种趋势。每个服务端只需处理一种单一的服务,比如说连接服务、场景服务或者聊天服务。所有这些服务进程之间都有着互相的联系,从而构成整个大千游戏世界。
要从头开发一个基于上述模型的游戏服务端的话,必须消耗非常多的时间,而且处理起那些形如服务端资源规划、建立以及管理网络连接、进程间发送和接收消息等非常冗杂的作业也会显得非常力不从心。更糟糕的是,这些作业在开始一个新游戏的时候又得不断重复。所以就需要有一个救世主来把你从繁杂冗余重复的工作中拯救出来,比如说BigWorld引擎(非常著名和给力,同时也非常贵和复杂)。现在Pomelo给你提供来另一个选择(开源,简单,迅捷)来达到你的要求。
Pomelo框架包括以下的功能模块:
<center><small>Pomelo 结构</small></center>
- 服务端管理模块负责定义服务端类型,建立和监控所有服务进程。
- 网络模块是进程间通信的基础,也提供了RPC和频道(Channel)来隐藏所有的底层详情。
- 应用模块代表了关注配置和生命周期管理相关的服务流程的进程上下文环境。(求大神翻译这句)
服务器类型
Pomelo在为一个游戏服务器群集可定制所有服务端模板的基础上提供一个灵活的服务端类型系统。它将服务端类型分为两大类——前端服务端和后端服务端。
<center><small>Pomelo服务端类型</small></center>
总而言之,前端服务端是负责与客户端的通信然后把这些请求转发给后端服务端,而后端服务端就是实现游戏逻辑的战场了。
本质上,前端和后端服务端都是服务端容器。基于服务端容器,开发者可以根据自己的意愿自由定制他们的服务端类型。开发者拥有全部的权限来决定一个服务端需要提供哪种类型的服务。比如说,你如果在后端服务端码了聊天服务的代码那么你就可以弄出一个聊天服务端,又比如你在后端服务端码了状态服务的代码你也可以搞出一个状态服务端。
所以啊开发者们只需要在Pomelo上面掐指一算来决定需要多少前段服务端和后端服务端来堆成一坨游戏服务端,然后为这些服务端结点码上应有的代码。接着Pomelo就会开始滚动然后能滚起所有的服务进程。
<center><small>服务端容器</small></center>
客户端请求机制
这一节我们会讲一下服务端是如何处理客户端请求的。
请求和响应
在Pomelo里,消息将为氛围两类——请求和通知。这两者的不同点如下所示:
<center><small>请求和通知</small></center>
请求是双向消息,也就是说服务端接收到一个客户端消息的时候就得发回客户端一个响应消息。这个时候Pomelo就会射出一个与请求相关联的回调。客户端如下弄出一个请求:
pomleo.request('connector.helloHandler.ask',
{
msg: 'What is your name?'
}, function(resp)
{
// We can get the name from response
}
);
然后通知就是单向消息,服务端不需要提供相应。客户端需如下弄出一个请求:
pomelo.notify('connector.helloHandler.sayHi',
{
msg: 'Hi'
}
);
如何处理客户端消息
Pomelo将进程流*分为两部分:处理机和过滤器。处理机负责提供游戏逻辑,过滤器需要做前置和后续工作,比如日志和超时处理等。两者分工如下图所示:
前置过滤器
客户端而来的消息需要通过前置过滤器链来做一些前置处理,比如说验证当前玩家登录状态以及日志记录。前置过滤器的接口如下:
filter.before = function(msg, session, next)
msg是从客户端接收的消息对象;session是当前玩家的会话对象;next是接下去的流程的回调函数。
前置过滤器需要调用next函数来跑到前置过滤器链中的下一个前置过滤器。当它穿过了整条前置过滤器链时,它将最终扑进处理机的怀抱。如果next函数的第一个参数传入错误,那么说明蹦跶出了一个错误,就需要停止这个进程流,比如说这个玩家不自觉还没有登录。这时这个消息就需要被传进一个全局错误处理机(稍后再做解释)。
处理机
处理机是实现游戏逻辑的天堂。它的接口如下:
handler.methodName = function(msg, session, next)
参数跟前置过滤器差不多。处理一个请求消息时,处理机需要传进响应对象,这是一个简单的Json对象,将作为下一个回调函数的第二个参数。对于一个通知消息来说,只需要将第二个参数留空即可。
如果在处理的过程中又蹦跶出了一个错误,只需要传入一个错误对象给next函数的第一个参数即可,跟之前的前置过滤器一样。
错误处理机
错误处理机是一个可选项,它将处理全局错误——比如说未知错误的处理以及错误的报告。错误处理机如下设置:
app.set('errorHandler', handleFunc);
一个错误处理机的接口又该如下申明:
errorHandler = function(err, msg, resp, session, next)
err是前置过滤器或者处理机传给的错误对象;resp是处理机本来将要传给客户端的响应消息。剩下的参数跟之前讲的一样。
后续过滤器
前面讲的进程流最终会由后续过滤器来擦屁股。后续过滤器将负责后续的处理,比如释放请求上下文资源,记录该请求的处理时间。不过它不要修改响应消息,因为其实在进入这个后续过滤器链中之前,消息就已经被一股脑发送到客户端了。
后续过滤器接口声明如下:
filter.after = function(err, msg, resp, session, next)
所有的参数都跟之前说的一样。
会话
会话是一个保持玩家状态的键对对象,它可以是以这个玩家id作为键名。Pomelo拥有两种类型的会话:全局会话和本地会话。
全局会话是由与客户端直连的前端服务端生成的并且就位于前端服务端。这是存储玩家信息的全局地方。传客户端消息的时候它将弄出一个拷贝和客户端消息一起传给后端服务端。这时后端服务端就会得到一份会话拷贝,也就是本地会话了。
本地会话应该是一个只读的对象,至少只在本地是可读写的*。在本地会话的修改并不会影响到全局上的那位。如果你想将本地会话同步到全局会话的话就需要调用推送本地会话的方法。更多细节请参考API文档中的本地会话服务。
频道和广播
在这一节我们会搞一下服务器是如何把消息推送给客户端的。
频道
屎一样多的消息将要在游戏服务端被推送,比如当一个玩家在一个场景中从A点移动到B点的时候,服务器就需要推送一个AOI(Area of Interest)消息给周围的玩家。频道就是推送消息的这么个工具。
频道是一个玩家ID的合集。你可以把一个玩家ID加入到某个频道,同样也可以从中移除。如果你通过频道来推送消息,那么该频道中的所有成员都将收到这条消息。你可以创建任意多个频道来自定义消息推送区域,这些频道互相之间是独立的。
频道分类
Pomelo有两种频道:命名频道和匿名频道。
创建一个命名频道时,你需要给它指定一个频道名,这样它才会返回一个频道实例。命名频道并不会自动释放,你需要记得调用 channel.destroy
方法来释放它。命名频道通常用来保持长时间关系的订阅,比如说聊天服务。
匿名频道通过 channelService.pushMessageByUids
来使用的,没有频道名也没有频道实例返回。匿名频道用于那些频道成员变动频繁的或者推送的都是临时消息的时候,比如AOI消息。
频道的更多用法请参见API文档。
这两种频道本质上都是相同的,即使他们看起来是两个妈生的。首先,频道需要将连接至它们的前端服务端分组。然后它需要将消息连通玩家ID通过分组一起推送到各自的前端服务端,最后天女散花至各个客户端。
<center><small>频道广播</samll></center>
RPC框架
在这一节,我们将解决各服务端之间通信不打架的问题。
RPC的用途
进程间需要互相配合合作,它们之间的通信是非常重要且复杂的。Pomelo的RPC(Remote Procedure Call,远程过程调用)框架就是一个将进程们揉捏在一起的有效手段。
下面就是Pomelo的RPC框架需要思考的一些点。
- 路由规则。路由规则决定一条消息应该传给哪个进程。路由规则与消息类型不同,不过它也同样会被当前玩家状态或者其它一些东东影响。比如客户端的一个简单移动请求,当当前玩家处于场景1中,它就需要被路由给管理场景1的进程。如果玩家被传送到场景2,那么这之后的所有移动请求都需要被路由给场景2的进程中。而且不同的游戏这个规则是不同的。所以这里就需要一个开放的机制来让开发者好定义路由规则。
- 协议。不同游戏的服务端进程间的请求通信协议也是不同的。比如说有些服务端想要TCP协议,而有些则对UDP欲求不满。
Pomelo的RPC框架引入抽象层来简化和解决以上所述的问题。
RPC客户端
RPC客户端层体系结构如下:
<center><small>RPC客户端体系结构</small></center>
上图中RPC消息是一个文本消息,包含一个RPC请求的描述,它包括RPC请求类型、参数以及其它一些东西。Session是启动RPC请求的玩家状态的一个合集。
- 邮箱层——邮箱层解决了通信协议的问题。一个邮箱代表一个远程服务端,并且使用远程服务端ID作为该邮箱的ID,这样就能通过服务端ID非常方便地找到相关联的邮箱实例。邮箱实例涵盖了当前服务端与远程服务端的所有通信细节,比如说如何建立连接,该用何种协议,如何关闭连接等等。你可以实现不同的邮箱来让它们支持不同的协议,而且它们的通信协议能很方便地进行切换,因为你只需要在邮箱层选择合适的邮箱就可以了。
- 邮局层——邮局层维持了当前进程的所有邮箱实例。它会将RPC消息从上层通过邮箱ID传送给对应的邮箱实例。邮局层接收到一个决定对应一个远程服务端将要生成何种类型的邮箱的邮箱工厂函数,然后返回一个相关联的邮箱实例。对于一个服务端第一次尝试连接远程服务端时,它将询问邮箱工厂以生成一个邮箱实例。所以开发者需要通过邮箱工厂函数来自定义通信机制。
- 路由层——路由层用来提供路由规则。它接受一个路由函数,然后用它来通过上层的RPC消息和会话决定目标进程ID。然后这个ID就会通过邮局层,正如上文所述。
- 代理层——代理层提供了使调用远程方法看起来就像在调用本地方法并且隐藏所有RPC细节的本地代理实例。本地代理方法和远程方法的唯一区别在于它多了个会话(session)参数,包含了当前玩家的状态,这个参数在参数槽的第一个位置。下面是一个简单的例子:
远程服务:
remote.echo = function(msg, cb) {
// …
};
本地代理:
proxy.echo = function(session, msg, cb) {
// …
};
还有一个好办法来调用远程函数,那就是如果目标服务端ID直接可用,那么可以通过调用rpcInvoke*函数来实现。
RPC服务端
RPC服务端的层次如下:
<center><small>RPC服务端结构体系</small></center>
- 接收器层(受体层)——接收器层通过网络将远程服务导出。它将会监听一个端口,通过指定协议接收以及解析RPC消息。需要指出的是,受体得与远程的同行们的邮箱层进行合作,也就是说它们得用同样的协议来使它们与各自能进行无障碍通信。接收器同样是被接收器工厂函数定制的。而且接收器得将RPC消息传往上层。
- 调度层——调度层解析RPC消息,导出消息类型以及RPC参数,然后将RPC请求转发至目标远程服务。
- 远程服务层——远程服务层实现了开发者提供的服务逻辑,由Pomelo框架自动载入。
服务端扩展
这一节,洒家一起讨论讨论如何扩展服务端进程的能力。
正如之前提到的,我们创建了很多类型的服务端,每个服务端都有自己的牛叉之处。比如说,前段服务端有着从客户端接收消息的能力,后端服务端有着从前段服务端进贡的消息。那么,我们又该如何维护和重用这些厉害的地方呢?此外,我们该如何以非常优雅和灵动的方式来扩展这些进程的能力呢?
“合体(组合)”将会是一个非常合适的途径。Pomelo有着一个组件系统来达到这个目标。
组件
组件是啥
在Pomelo里面,组件是可重用的服务单位。一个组件实例提供了若干种服务。比如说处理机组件载入处理机代码后将会将客户端消息传给请求处理机。
一个组件实例要被注册进进程上下文(被称为应用),这样后者就会获得该实例提供的能力。组件实例可以通过应用与其它组件进行交互合作。比方说一个连接组件接收到一个客户端请求然后将它发送给应用,那么一个处理机组件等下就有可能从应用中获取这条消息。
组件系统模型如下所示:
<center><small>组件系统</small></center>
在代码里面,组件是一个非常简单的类,实现了一些必须的生命周期接口,应用需要触发生命周期每个阶段每个组件所需的回调函数。
<center><small>组件的生命周期</small></center>
-
start(cb)
——服务端在启动阶段被调用的开始生命周期。注意:各组件需要调用cb
函数来继续接下去的步骤。组件也可以传入一个粗偶的参数给cb
来表示当前组件启动失败,以此来使应用结束这个进程。 -
afterStart(cb)
——服务端在启动之后的生命周期阶段回调,需要在当前进程所有被注册组件启动之后调用。它给这些组件进行一些协作间初始化的机会。 -
stop(force, cb)
——服务端在停止生命周期阶段的回调,当服务端将要停止的时候调用。组件可以做一些清理作业,比如冲刷此周期中的数据到数据库。force参数如果为true的话表示所有的组件都需要被立即停止。
Pomelo的抽象级
基于组件系统,应用实际上是进程的骨干。它载入了所有的注册组件,鞭策它们穿越了整个生命周期。但是应用不能涉及到各组件的细节。所有的定制一个服务端进程的作业仅仅只是挑选必须的组件构成一个应用。所以应用是非常干净和灵活的,而组件的可重用性非常高。此外,组件系统最终将所有服务端类型弄进一个统一进程。
<center><small>Pomelo的抽象级</small></center>
怎么注册一个组件
如下:
app.load([name], comp, [opts])
-
name
——可选的组件名。命名的组件实例可以在被载入后通过app.components.name
来访问。 -
comp
——组件实例或者组件工厂函数。如果comp
是一个函数,那么应用将会把它当做一个工厂函数,并且让它返回一个组件实例。工厂函数有两个参数app
和opts
(看下文),并且它将返回一个组件实例。 -
opts
——可选项,它将被传入至组件工厂函数的第二个参数。
总结
我们将一整个服务端分尸成一些小块服务端,以此来清晰化结构体系和提高游戏服务端的可扩展性。然后我们将所有的服务类型综合成两种服务端容器即前段和后端服务端,通过这样的方式来简化模型。我们讨论了客户端到服务端、服务端到客户端以及服务端之间的消息流。最后,我们介绍了组件系统,把上面的所有物件合体构成一个有机的整体。总的看来,Pomelo提供了一个可扩展的灵活的框架来支持游戏服务端开发,并且赶走了所有的“躁点”和复杂的作业。
享受Pomelo的游戏开发过程吧,亲们!