昨天和人闲扯,谈到了 MMORPG 客户端的网络消息应该基于怎样的模型。依稀记得很早写过我的观点,但是 blog 上却找不到。那么今天补上这么一篇吧。
我认为,MMO 类游戏,服务器扮演的角色是虚拟的世界,一切的状态变化都是在游戏服务器仲裁和演化的。而客户端的角色本质上是一个状态呈现器,把玩家视角看到的虚拟世界的状态,通过网络消息呈现出来。所以、在设计客户端的网络消息分发框架时,应围绕这个职责来设计。
客户端发起的请求分两种:一种是通知服务器,我扮演的角色状态发生了改变,请求服务器仲裁;另一种是,我期望获取服务器对象的最新状态。后一种有时以服务器主动推送来解决,也可以用主动请求,两者主要的区别在于流量控制。
其实、对于客户端作为状态呈现这个角色而言,请求和回应之间的关系并没有什么紧密联系,并不适合使用基于 RPC 的网络模型。这个无关乎 RPC 的实现是用 callback 还是用 coroutine 。
这是因为,当服务器的状态改变时,客户端关心的应该是这类事情发生时,我应该如何呈现;而不是具体到某个请求发出后,收到回应我应该怎么处理。RPC 把请求和回应紧密耦合在一起,让回应的处理流程强依赖请求时的上下文,这样容易引起不必要的状态管理。
比如,我看到我们公司的一个项目中曾经出过这样的 bug :UI 界面上点击一个按钮,用 callback 的形式发起了一次 RPC 调用;在回应的 callback 函数中对 UI 界面上的元素做了一系列的修改。可是在回应的网络消息收到时, UI 界面已经关闭了,由于处于节约内存的考虑,还触发了一些对象销毁流程,结果因为操控不存在的对象造成了 bug 。
你可以从别的角度来看待这个 bug 。比如说应该建立一个更稳固的 UI 框架,做更严谨的生命期管理。但我认为,根源问题就是没有把请求和回应解耦造成的。
我们应该这样看待按钮点击事件:它只是触发了一个事件在服务器上运行,这个事件导致了某个服务器上的对象状态改变了,而回应包只是通知了状态改变的结果。客户端真正要做的是怎样正确呈现这个结果。
如果我们把客户端处理网络包的流程都归纳成类似的模型,统一成一个简单的框架就很容易了。
客户端根据收到的网络包进行相应的处理,无论这个网络包是服务器的推送、还是对之前客户端发起请求的回应。在这个框架下,只关心来了一个网络包后的类别,根据这个类别来决定要做哪类事情。处理流程是关联在消息类别上的,而不是关联在单个请求上的。
对于请求回应的处理,其实和推送的处理并没有本质的不同。只是实现上略有差别。对应回应包的处理,处理流程得到的参数不仅仅是网络包,还需要有对应的请求数据(也就是客户端当初自己发起请求的参数)和这个事件绑定的客户端本地对象。
通常我们会用一个 session 号来关联回应包和请求包,在发起请求时,不仅把请求内容打包发给服务器,同时还把它记录在本地,用 session 关联起来;这样在收到请求时,可以根据 session 找回当初请求的参数,以及请求的类型,这样就可以不必让服务器推送完整的状态值,客户端可以自己找到匹配的内容。
在大部分情况下,我们还需要在发起请求时,给这个 session 绑定一个本地的对象。虽然请求本身肯定有这个信息(否则服务器就不知道该请求到底操作呢什么东西),但额外的一个本地对象使用起来更方便,可以用来携带少量本地状态信息。在回应抵达时,直接操作这个绑定对象写起来更方便。
如果用 lua 来实现,看起来大致是这样的:
send_request(obj, req_type, req_data)
-- 发起一个请求,请求类型为 req type ,参数是 req data ,绑定对象为 obj 。
function net.req_type(obj, req_data, resp_data)
-- 定义一个函数来处理 req_type 这种类型的消息,可以获得发起请求时的 obj 和 req data 以及服务器的回应 resp data 。