包含对象名字的游戏id_教你从头写游戏服务器框架(3)

Tencent Cloud Coffee Class

面向开发者人群,打造圈层技术影响力。

7d65387c8158be5cb1078fdebb4918f4.png

    使用异步非阻塞编程,确实能获得很好的性能。但是在代码上,确非常不直观。因为任何一个可能阻塞的操作,都必须要要通过“回调”函数来链接。比如一个玩家登录,你需要先读数据库,然后读一个远程缓冲服务器(如 redis),然后返回登录结果:用户名、等级……在这个过程里,有两个可能阻塞的操作,你就必须把这个登录的程序,分成三个函数来编写:一个是收到客户端数据包的回调,第二个是读取数据库后的回调,第三个是读取缓冲服务器后的回调。

这种情况下,代码被放在三个函数里,对于阅读代码的人来说,是一种负担。因为我们阅读代码,比如通过日志、coredump 去查问题,往往会直接切入到某一个函数里。这个被切入阅读的函数,很可能就是一个回调函数,对于这个函数为什么会被调用,属于什么流程,单从这个函数的代码是很难理解的。

另外一个负担,是关于开发过程的。我们知道回调函数的代码,是需要“上下文”的,也就是发起回调时的数据状态的。为了让回调函数能获得发起函数的一个变量内容,我们就必须把这个变量内容放到某个“上下文”的变量中,然后传给回调函数。由于业务逻辑的变化,这种需要传递的上下文变量会不停的变化,反复的编写“放入”“取出”上下文的代码,也是一种重复的编码劳动。而且上下文本身的设置可能也不够安全,因为你无法预计,哪个回调函数会怎么样的修改这个上下文对象,这也是很多难以调试的 BUG 的来源。

为了解决这个问题,出现了所谓的协程技术。我们可以认为,协程技术提供给我们一种特殊的 return 语句:yield。这个语句会类似 return 一样从函数中返回,但你可以用另外一个特殊的语句 resume(id) 来从新从 yield 语句下方开始运行代码。更重要的是,在 resume 之后,之前整个函数中的所有临时变量,都是可以继续访问的。

    当然,做 resume(id) 的时候,肯定是在进程的所谓“主循环”中,而这个 id 参数,则代表了被中断了的函数。这种可以被中断的函数调用过程,就叫协程。而这个 id ,则是代表了协程的一个数字。异步调用的上下文变量,就被自动的以这个协程函数的“栈”所取代,也就是说,协程函数中的所有局部变量,都自动的成为了上下文的内容。这样就再也不用反复的编写“放入”“取出”上下文内容的代码了。

657c155d906dccf69771bbb5aedbc74f.png

小编使用了 https://github.com/Tencent/Pebble/tree/master/src/common 项目下的 coroutine.cpp/.h 作为协程的实现者。

游戏开发中,协程确实能大大的提高开发效率。因此小编认为协程也应该是 Game Server 所应该具备的能力。特别是在处理业务逻辑的 Handler 的 Process() 函数,本身就应该是一个协程函数。所以小编设计了一个 CoroutineProcessor 的类,为普通的 Processor 添加上协程的能力。——基于装饰器模式。这样任何的 Processor::Process() 函数,就自然的在一个协程之中。

因为有了协程的支持,那些可能产生阻塞而要求编写回调的功能,就可以统一的变成以协程使用的 API 了:

  1. DataStore -> CoroutineDataStore

  2. Cache -> CoroutineCache

  3. Client -> CoroutineClient

37275d3179171aeed37bdae6a222a0bb.png

使用协程的 API,就完全不需要各种 Callback 类型的参数了,完全提供一个返回结果用的输出参数即可。

组件模型

一般来说服务器上,主要是运行各种各样处理请求的代码为主(通常叫 Handler)。然而,我们也会有一些需要持续运行的逻辑代码,比如处理匹配玩家战斗的逻辑,检查玩家是否超时发呆的逻辑,循环处理支付订单等等。这些代码的很多功能,同时还需要被各种 Handler 所调用。所以我们必须要有一种能让所有的这些自定义代码,以一种标准的方式在进程中互相引用,以及管理生命周期的方法。

借鉴于 Unity, 小编觉得使用所谓的组件模型是很好的。它的特点包括:

  1. 组件之间通过 Application::GetComponet(name) 的方式互相调用。以一个字符串作为索引,就可以方便的获得对于的对象。组件自己通过 Application::Register(com_obj) 注册到系统中去,注册的名字自己实现 string GetName() 的接口去提供。

  2. 每个组件有预定的几个回调函数,提供进程生命周期的调用机会。包括:

  • 初始化:Init()

  • 主循环更新:Update()

  • 关闭:Close()

Server 对象

由于一个游戏服务器,所集成的功能实在是太多了,比如配置不同的协议、不同的处理器、提供数据库功能等等。要让这样一个服务器对象启动起来,需要大量的“组装代码”。为了节省这种代码,小编设计了一个 LocalServer 的类型,作为一个 Server 模板,简化网络层的组装。使用者可以继承这个类,用来实现各种不同的 Server。

这个简单的类,可以通过 setter 方法来自定义网络层的组件,否则就是最常用的 TCP, TLV, Echo 这种服务器。而且这个类还是继承于 Application 的,这样可以让数据库或者其他的组件,也很方便的利用组件系统安装到服务器上。

需求分析

游戏常常是一个带状态的服务。所以集群功能非常困难。

有一些框架,试图把状态从逻辑进程中搬迁出来,放在缓冲服务器中,但是往往满足不了性能需求。另外一些框架,则把集群定义成一个固定的层次架构,通过复杂的消息转发规则,来试图“把请求发到装载状态的进程上”,但这导致了运维部署的巨大复杂性。

为了解决这些问题,小编觉得有几个设计决策是必须要订立的:

  1. 使用 SOA 的模式:集群中心的地址作为集群的地址,通过服务名来分割逻辑

  2. 提供给用户自定义路由的接口:由于集群中的进程都带有状态,要把请求发给哪个进程,并不能完全自动选择,所以必须要用户提供代码来选择

234856dbc4f84478286ffd1141b9d7b6.png

作为 SOA 模式下的集群,必须定义每个服务的“合同”格式。由于一个游戏服务器,可能存在各种不同的通信协议和编码协议,所以这个合同必须要能包含所有这些内容。在传统的 RPC 设计中,比如 WebService ,就采用了 WSDL 的格式,但是现在这种风格更多的被 RESTful 所取代。因此小编决定使用类似 URL 类型的字符串来表述合同:

这样的合同描述,可以包含通信协议,IP地址和端口,编码协议三个部分,如果需要,还可以在 PATH 部分继续添加,如增加 QueryString 等。

集群中心

根据之前的设计,集群中心地址,即事集群的地址。而集群中心,为了避免单点故障,自己也必须是一个集群。能符合这个要求的可用开源软件,非 ZooKeeper 莫属。

所以小编直接把集群中心的功能,使用 ZooKeeper 来实现。虽然 ZooKeeper 的 API 设计也足够优秀了,但是作为异步非阻塞的框架,还是必须要做一层封装和抽象。在编译 C 的 ZK 客户端 API 时,也碰到了一个讨厌的问题,就是这个 API 使用了一个旧版本的测试框架库 cppunit-devel ,在新版本的 Linux 发行版 CentOS 和 Ubuntu 中,直接从源安装的版本都和这个版本不兼容,没办法只好去官网上下载 cppunit-1.13 的源代码来编译安装。

为了方便使用 ZooKeeper ,小编先实现了一个 ZooKeeperMap 的类,属于 cache 模块的 DataMap 的子类,用以完成标准的 Key-Value 存取。实际上在这里是为了完成链接 ZooKeeper 和初始化的功能。

如前文的合同所设计,当获得一个“合同”字符串的时候,是需要“构造”出一个使用对应合同的客户端对象的。不同的协议对应着不同类型的对象,在这里就需要一种类似“反射”生成对象的技术。对于没有这种反射能力的 C++ 来说,小编添加了一个“注册”模板方法,这个模板方法会把注册的类的构造工厂方法,记录到一个 map 里面。当然,这对于注册的类的构造器是有要求,需要有无参数构造器,或者是带“字符串,数字”构造器。当然,如果写错了也不要紧,只是不能编译成功而已。这也是静态绑定的好处之一了。

整个集群中心,最核心的接口其实就三个:

  1. 注册一个合同,包括提供的“服务名”和“合同”,这个合同内容必须是能让客户端访问到自己的通信地址。

  2. 查询合同,通过输入“服务名”,获得所有提供这个服务的合同列表

  3. 通过合同构建客户端,得到的客户端对象就是可以发送请求给对应合同的服务提供进程。

服务器间通信

在上面所说的集群中心功能中,最后一项“获得客户端”的方法,是需要用户输入一个 Router 类型的对象的。其原因就是,游戏服务器往往都是带状态,所以必须要让调用者有办法选择具体的服务提供者。比如游戏中的聊天功能,一般都支持“组队聊天”的功能,这个功能,需要把消息转发到不同的服务器进程上,因为队伍中的玩家可能登录在不同的服务器上。那么,如果玩家本身登录的规则,就是根据自己的 ID 做某种哈希去选择服务器进程的,那么,这个聊天功能,只要让 Router 对象也按同样的哈希方法去选择服务器进程,就能正确的发送消息了。当然了,根据某种类似“服务器进程ID”去选择服务器,也是一种路由方式,可以写入 Router 中去。

路由器的写法非常简单,也附带了一个 route_param 用来帮助传递一些路由选择所需的数据。当然你也可以构造多个不同的 Router 子类对象,用对象成员属性来携带更复杂的路由参数。

当我们选择出了合同,就可以利用 Center 的功能去发起服务请求了。下面是单元测试的部分代码,展示了如何在服务器之间调用服务:

在一般的异步编程中,访问集群中的服务,需要两个回调(组赛)过程,一个是通过集群中心查询合同,一个是请求服务。这样显然会让代码分散在不同的函数中,阅读起来非常不方便。所以小编又使用了协程功能,封装了集群和客户端的能力,让整个过程可以用同步代码的写法来完成。

写到这里,基本上关于一个游戏服务器框架的主体功能设计,都基本完成了。但是,一个游戏中还包含了很多不同的能力需要考虑。比如说排行榜、拍卖行、战斗记录日志等等,这个功能往往不能靠上文所述的 key-value 数据能力简单解决。而需要额外直接的对一些特殊的设施,比如 redis/MySQL 直接编程,这些部分,也只能放在框架之外处理了。也许以后,小编会总结出更好的抽象层,能把带排序、模糊搜索、大容量记录的功能,一起放入框架的想法。另外,对于 cache 模块(缓冲),使用一致的 API 风格,去操作真正的分布式缓冲,还是一个未能很好解决的课题。虽然 Orcal Conherence 提供了很好的参考方案,但是限于时间和精力,也只能用简单的二级缓存来部分模拟其能力,这一方面也是值得去深入研究的部分。

总结一下,游戏服务器框架,其实基本能力也非常简单:

  1. 网络功能:提供请求响应、通知两种能力即可组合大部分功能

  2. 缓存功能:提供二级缓存的远程缓冲功能,也可以满足很多需求

  3. 持久化功能:以 key-value 方式的存储足以满足很多用户存档的需求

对于现代服务器系统,需要增加的能力还有:

  1. 集群功能:可以用 SOA 但自定义路由的方式,提供集群服务

  2. 协程功能:避免大量异步回调的代码阅读问题

  3. 组件功能:给框架一个结合不同体系代码的接口

全文完。

070645ba41cad54b6ffeb9b632935e9d.png

本文图片来自腾讯云,未经允许,禁止转载。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值