游戏服务器框架的设计和实现是怎样的一个过程

游戏服务器框架的设计和实现是怎样的一个过程

首先介绍一下,这个框架的基本运行环境是Linux,用C++编写。 为了能够在各种环境下运行和使用,使用了“老”编译器gcc4.8,并按照C99规范进行开发。

需要

由于“代码越通用,代码的用处就越少”,所以在设计之初,我就认为整个系统应该采用分层的模式来构建。 根据游戏服务器的一般需求,最基本的可以分为两个层次:

底层基础功能:包括通信、持久化等非常通用的部分,重点关注性能、易用性、可扩展性等指标。

高层逻辑功能:包括具体的游戏逻辑,针对不同的游戏会有不同的设计。

我希望有一个基本完整的“底层基本功能”框架,可以在多个不同的游戏中复用。 由于目标是开发一个适合独立游戏开发的游戏服务器框架。 所以最基本的需求分析就是:

功能要求:

并发:所有的服务器程序都会遇到这个基本问题:如何处理并发任务。 一般来说有两种技术:多线程和异步。 多线程编程在编码上更符合人类的思维习惯2d游戏素材,但是却带来了“锁”的问题。 异步非阻塞模型的程序执行情况比较简单,可以充分利用硬件性能。 但问题是,很多代码需要以“回调”的形式编写,这对于复杂的业务逻辑来说显得有些困难。 非常麻烦而且可读性很差。 虽然这两种解决方案各有优缺点,并且有些人将这两种技术结合起来,希望能够发挥各自的优点,但我更喜欢使用异步、单线程和非阻塞调度,因为这种解决方案是最清晰和最清晰的。最简单。 。 为了解决“回调”问题,我们可以在其之上添加其他抽象层,例如协程或添加线程池等技术来改进。

通信:支持请求响应模式和通知模式通信(广播视为多目标通知)。 游戏有登录、买卖、打开背包等多种功能,都有明确的请求和响应。 在大量的网络游戏中,多个客户端的位置、血量等都需要通过网络进行同步。 事实上,它是一种“主动通知”的通信方式。

持久性:对象可以被访问。 游戏存档的格式非常复杂,但其索引要求往往是基于玩家ID的读写。 在许多游戏机(例如 PlayStation)上,以前的保存可以以类似的“文件”格式存储在存储卡中。 因此,游戏持久化最基本的要求就是key-value访问模型。 当然,游戏中还会有更复杂的持久化需求,比如排名、拍卖行等,这些需求应该单独对待,不适合包含在一个基本的通用底层中。

缓存:支持远程和分布式对象缓存。 游戏服务基本上是“有状态”服务。 由于游戏对响应延迟的要求非常高,所以基本上都需要使用服务器进程的内存来存储进程数据。 但游戏数据变化越快,数值就越低,比如经验值、金币、生命值等,而等级、装备等变化越慢,数值就越高。 这个特性非常适合使用缓存模型。 处理。

协程:可以使用C++编写协程代码,避免代码被大量的回调函数分割。 这对于异步代码来说是一个非常有用的特性,可以大大提高代码的可读性和开发效率。 特别是很多涉及IO的底层功能都提供了协程API,使用起来和同步API一样简单舒适。

脚本:最初的想法是支持使用Lua来编写业务逻辑。 游戏需求因快速变化而臭名昭著,用脚本语言编写业务逻辑可以提供这方面的支持。 事实上,脚本在游戏行业中有着广泛的应用。 因此,支持脚本也是游戏服务器框架非常重要的能力。

其他功能:包括定时器、服务器端对象管理等,这些功能非常常用,所以需要包含在框架中,但是已经有很多成熟的解决方案了,所以就选择一个通用且易于理解的模型。 例如,对于对象管理,我会使用类似于Unity的组件模型来实现。

非功能性需求

灵活性:支持可更换的通信协议; 可更换的持久性设备(例如数据库); 可更换的缓存设备(例如memcached/redis); 作为静态库和头文件发布,无需对用户代码要求做太多工作。 游戏的运行环境比较复杂,尤其是不同的项目之间,可能会使用不同的数据库和不同的通信协议。 然而游戏本身的很多业务逻辑都是基于对象模型设计的,所以应该有一层模型能够将这些基于“对象”的底层功能全部抽象出来。 这样就可以基于一个底层开发多种不同的游戏。

部署便捷:支持灵活的配置文件、命令行参数、环境变量引用; 支持进程独立启动,不依赖数据库、消息队列中间件等设施。 一般来说,游戏至少会有三个运行环境,包括开发环境、内部测试环境以及外部测试或运行环境。 游戏版本更新往往需要更新多个环境。 因此程序开发,如何尽可能简化部署就成为一个非常重要的问题。 我认为一个好的服务端框架应该能够让服务端程序独立启动,无需配置和依赖,从而能够快速部署在开发、测试、演示环境中。 并且可以通过不同的配置文件或命令行参数在外部测试或集群下的运行环境中轻松启动。

性能:许多游戏服务器使用异步和非阻塞编程。 因为异步非阻塞可以大大提高服务器的吞吐量,并且可以清楚地控制并发多个用户任务下代码的执行顺序,从而避免多线程锁等复杂问题。 所以我也希望这个框架能够使用异步非阻塞作为基本的并发模型。 这样做的另一个好处是可以手动控制特定进程,充分利用多核CPU服务器的性能。 当然,异步代码的可读性会因为大量的回调函数而变得难以阅读。 幸运的是,我们还可以使用“协程”来改善这个问题。

可扩展性:支持服务器之间的通信、进程状态管理以及类似SOA的集群管理。 事实上,自动容灾和自动扩容的关键点在于业务流程的状态同步和管理。 我希望一个共同的底层能够通过统一的集中管理模型来管理所有服务器间的调用,让每个项目不再需要担心集群间的通信、寻址等问题。

需求明确了,基本的层次结构也就可以设计了:

实用干货1.png

最后,整体架构模块类似:

实用干货2.png

通讯模块

对于需要灵活可替换的协议能力的通信模块,必须按照一定的级别进一步划分。 对于游戏来说,最底层的通信协议一般使用TCP和UDP。 服务器之间还使用消息队列中间件等通信软件。 框架必须具有支持这些通信协议的能力。 因此,设计了一个关卡:运输。

在协议层面,最基本的需求包括“分包”、“分发”和“对象序列化”。 如果要支持“请求-响应”模式,还需要在协议中带上“序列号”数据来对应“请求”和“响应”。 另外,游戏通常是“会话”应用,即一系列的请求都会被视为一个“会话”,这就需要协众有SessionID这样的数据。 为了满足这些需求,设计了一个层次:协议。

通过以上两个层次,就可以完成最基本的协议层能力了。 然而,我们在编程中往往希望业务数据的协议封装能够自动成为对象,因此在处理消息体时,需要一个可选的附加层,将字节数组转换为对象。 所以我设计了一个特殊的处理器:ObjectProcessor来标准化通信模块中对象序列化和反序列化的接口。

实用干货3.png

运输

该层的设立是为了统一各种底层传输协议。 最基本的协议应该支持TCP和UDP。 关于通信协议的抽象,其实很多底层库都做得非常好,比如Linux的socket库,其读写API甚至可以和文件读写通用。 C#的Socket库介于TCP和UDP之间,其API几乎完全相同。 然而,由于它作为游戏服务器,它经常连接到一些特殊的“访问层”,例如一些代理服务器或一些消息中间件。 这些 API 多种多样。 另外,在HTML5游戏(如微信小游戏)和部分网页游戏领域,有使用HTTP服务器作为游戏服务器的传统(如使用WebSocket协议),这需要完全不同的传输层。

异步模型下服务器传输层的基本使用顺序:

在主循环中,不断尝试读取哪些数据可用

如果上一步返回的数据已经到达,则读取数据

读取数据并处理后,如果需要发送数据,则将数据写入网络。

基于以上三个特点,可以总结出一个基本的接口:

实用干货4.png

在上面的定义中,您可以看到需要有一个 Peer 类型。 该类型旨在表示通信的客户端(对等)对象。 在一般的Linux系统中,我们一般用fd(File Description)来表示。 但因为在框架中,我们还需要为每个客户端建立接收数据的缓冲区,以及记录通信地址等功能,所以我们基于fd封装了这样一个类型。 这也有助于在不同的客户端模型中封装UDP通信。

实用干货5.png

使用UDP协议的游戏特点:一般来说,UDP是无连接的,但是对于游戏来说,肯定需要一个明确的客户端,所以不能简单地使用UDP套接字的fd来代表客户端,这导致上层代码无法简单地使用UDP套接字的fd来表示客户端。 UDP 和 TCP 之间保持一致。 因此,这里使用抽象层Peer来解决这个问题。 这也可以用在使用某种消息队列中间件的情况下,因为这些中间件也可能会复用一个fd,甚至可能不是使用fd API来开发的。

上述传输定义对于 TCP 实现者来说非常容易完成。 但对于UDP实现者来说,需要考虑如何充分利用Peer,尤其是Peer.fd_数据。 我在实现的时候,使用了一套虚拟的fd机制,通过一个客户端的IPv4地址到int的对应Map,为上层提供区分客户端的功能。 在Linux上,这些IO可以使用epoll库来实现。 在 Peek() 函数中读取 IO 事件,并在 Read()/Write() 中填写套接字调用。

另外,为了实现服务器之间的通信,还需要设计一个与Tansport对应的类型:Connector。 该抽象基类用于在客户端模型中向服务器发起请求。 它的设计与 Transport 类似。 除了Linux环境下的Connecotr之外,我还实现了C#下的代码,以便Unity开发的客户端可以方便的使用。 由于.NET本身支持异步模型,因此其实现并不需要花费太多精力。

实用干货6.png

协议

对于通信“协议”来说,它其实包含很多含义。 在众多的需求中,我定义的协议层只希望完成四个最基本的能力:

打包:从流层中分割单个数据单元,或将多个数据“片段”拼凑成完整数据单元的能力。 一般来说,要解决这个问题,需要在协议头中添加“长度”字段。

请求响应对应:这对于异步非阻塞通信模式来说是一个非常重要的功能。 因为同时可能会发出许多请求,并且响应不会按特定顺序到达。 如果协议头有一个唯一的“序列号”字段,它就可以对应哪个响应属于哪个请求。

会话持久化:由于游戏底层网络可能会使用UDP或者HTTP等非持久连接传输方式,所以要逻辑上维护会话,不能简单依赖传输层。 另外,我们都希望程序具有抵抗网络抖动和断线重连的能力,因此保持会话就成了一个共同的需求。 我根据Web服务领域中的session函数设计了一个Session函数。 通过在协议中添加会话ID等数据,可以相对简单地维护会话。

分发:游戏服务器必须包含多种不同的业务逻辑,因此需要多种不同数据格式的协议包,以便转发相应格式的数据。

除了上述三个功能之外,实际上还有很多能力需要在协议层进行处理。 最典型的就是对象序列化功能,还有压缩、加密等。 我之所以没有把对象序列化能力放在Protocol中,是因为对象序列化中的“对象”本身就是一个业务逻辑关联性很强的概念。 在C++中,没有完整的“对象”模型,也缺乏原生反射支持游戏开发框架,因此无法通过“对象”的抽象概念轻易划分代码层次。 但我还设计了一个 ObjectProcessor,以更高级别的形式将对象序列化支持集成到框架中。 这个Processor可以自定义对象序列化方式,让开发者可以选择任意的“编码解码”能力,而不需要依赖底层的支持。

至于压缩、加密等功能,确实可以在Protocol层实现游戏开发框架,甚至可以作为抽象层添加到Protocol中。 或许只有一层协议层还不足以支持如此丰富的功能。 有必要像Apache Mina一样设计一个“协议层”。 但为了简单起见,我认为最好在特别需要的地方添加额外的Protocol实现类,比如添加一个“具有压缩功能的TLV协议类型”之类的。

消息本身被抽象为一种称为Message的类型,该类型具有“服务名称”和“会话ID”两个消息头字段,以完成“分发”和“会话持久化”功能。 消息体放在一个字节数组中,记录字节数组的长度。

实用干货7.png

基于之前设计的“请求响应”和“通知”两种通信模式,需要设计三种消息类型继承自Message。 它们是:Request(请求包)、Response(响应包)、Notice(通知包)。

Request类和Response类都有记录序列号的seq_id字段,但Notice没有。 Protocol 类负责将缓冲区字节数组转换为 Message 的子类对象。 因此,需要为三个Message子类型实现相应的Encode()/Decode()方法。

实用干货8.png

这里需要注意的一点是,由于C++没有内存垃圾回收和反射能力,所以在解释数据时,无法一步将char[]转换为子类对象,而必须分两步处理。

首先,使用 DecodeBegin() 返回要解码的数据属于哪个子类型。 同时,分包工作完成,通过返回值告知调用者是否收到了完整的包。

以相应的类型为参数调用Decode(),具体将数据写入相应的输出变量。

关于Protocol的具体实现子类,我首先实现了一个LineProtocol,这是一个基于文本ASCII编码的非常松散的协议,字段用空格分隔,用回车符分包。 用来测试这个框架是否可行。 因为这样可以直接使用telnet工具来测试协议的编解码。 然后我根据TLV(Type Length Value)方法设计了一个二进制协议。 粗略定义如下:

协议分包:​​[消息类型:int:2][消息长度:int:4][消息内容:字节:消息长度]

消息类型值:

0x00错误

0x01 请求

0x02 响应

0x03 通知

实用干货9.png

名为 TlvProtocol 的类型完成了该协议的实现。

处理器

处理器层是我设计的一个抽象层,用于与具体的业务逻辑接口。 主要通过输入参数Request和Peer获取客户端的输入数据,然后通过Server类的Reply()/Inform()返回Response和Notice消息。 事实上,Transport 和 Protocol 的子类都属于 net 模块,而 Processor 和 Server/Client 等各种功能类型则属于另一个处理器模块。 这样设计的原因是所有处理器模块的代码都期望单向依赖于net模块的代码,但反之则不然。

Processor基类很简单,它只是一个处理函数回调函数入口Process():

实用干货10.png

设计完Transport/Protocol/Processor这三个通信处理级别之后,需要一个将这三个级别结合起来的代码,这就是Server类。 该类在Init()中时,需要上述三类子类作为参数,组合成具有不同功能的服务器,例如:

实用干货11.png

Server类型还需要一个Update()函数,该函数被用户进程的“主循环”不断调用,以驱动整个程序。 这个Update()函数的内容很清楚:

检查网络上是否有数据需要处理(通过Transport对象)

如果有数据,则对其进行解码(通过 Protocol 对象)

解码成功后,业务逻辑被分发调用(通过Processor对象)

此外,Server还需要处理一些额外的功能,例如维护会话缓存池(Session)以及提供发送Response和Notice消息的接口。 当这些任务完成后,整个系统就可以作为一个比较“通用”的网络消息服务器框架了。 剩下的工作就是添加各种传输/协议/处理器子类。

实用干货12.png

有了服务器类型,肯定还有客户端类型。 Client类型的设计与Server类似,但它不使用Transport接口作为传输层,而是使用Connector接口。 然而,Protocol的抽象层是完全可重用的。 Client不需要Processor形式的回调,而是直接传入接口对象ClientCallback,接收到数据消息后发起回调。

实用干货13.png

至此,客户端和服务端的基本设计就完成了,可以直接通过编写测试代码来检查它们是否运行正常。

文章来源:https://jiaocheng.hxsd.com/course/content/9042