开发教程系列:游戏服务器框架搭建
使⽤异步⾮阻塞编程,确实能获得很好的性能。但是在代码上,确⾮常不直观。因为任何⼀个可能阻塞的操作,都必须要要通过“回调”函数来链接。⽐如⼀个玩家登录,你需要先读数据库,然后读⼀个远程缓冲服务器(如redis),然后返回登录结果:⽤户名、等级……在这个过程⾥,有两个可能阻塞的操作,你就必须把这个登录的程序,分成三个函数来编写:⼀个是收到客户端数据包的回调,第⼆个是读取数据库后的回调,第三个是读取缓冲服务器后的回调。
这种情况下,代码被放在三个函数⾥,对于阅读代码的⼈来说,是⼀种负担。因为我们阅读代码,⽐如通过⽇志、coredump去查问题,往往会直接切⼊到某⼀个函数⾥。这个被切⼊阅读的函数,很可能就是⼀个回调函数,对于这个函数为什么会被调⽤,属于什么流程,单从这个函数的代码是很难理解的。
另外⼀个负担,是关于开发过程的。我们知道回调函数的代码,是需要“上下⽂”的,也就是发起回调时的数据状态的。为了让回调函数能获得发起函数的⼀个变量内容,我们就必须把这个变量内容放到某个“上下⽂”的变量中,然后传给回调函数。由于业务逻辑的变化,这种需要传递的上下⽂变量会不停的变化,反复的编写“放⼊”“取出”上下⽂的代码,也是⼀种重复的编码劳动。⽽且上下⽂本⾝的设置可能也不够安全,因为你⽆法预计,哪个回调函数会怎么样的修改这个上下⽂对象,这也是很多难以调试的BUG的来源。
为了解决这个问题,出现了所谓的协程技术。我们可以认为,协程技术提供给我们⼀种特殊的return语句:yield。这个语句会类似return ⼀样从函数中返回,但你可以⽤另外⼀个特殊的语句resume(id)来从新从yield语句下⽅开始运⾏代码。更重要的是,在resume之后,之前整个函数中的所有临时变量,都是可以继续访问的。
当然,做resume(id)的时候,肯定是在进程的所谓“主循环”中,⽽这个id参数,则代表了被中断了的函数。这种可以被中断的函数调⽤过程,就叫协程。⽽这个id,则是代表了协程的⼀个数字。异步调⽤的上下⽂变量,就被⾃动的以这个协程函数的“栈”所取代,也就是说,协程函数中的所有局部变量,都⾃动的成为了上下⽂的内容。这样就再也不⽤反复的编写“放⼊”“取出”上下⽂内容的代码了。
开发教程系列:游戏服务器框架搭建(三)-视⾓
游戏开发中,协程确实能⼤⼤的提⾼开发效率。因此我认为协程也应该是Game Server所应该具备的能⼒。特别是在处理业务逻辑的Handler的Process()函数,本⾝就应该是⼀个协程函数。所以我设计了⼀个CoroutineProcessor的类,为普通的Processor添加上协程的能⼒。——基于装饰器模式。这样任何的Processor::Process()函数,就⾃然的在⼀个协程之中。
因为有了协程的⽀持,那些可能产⽣阻塞⽽要求编写回调的功能,就可以统⼀的变成以协程使⽤的API了:
DataStore -> CoroutineDataStore
Cache -> CoroutineCache
Client -> CoroutineClient
开发教程系列:游戏服务器框架搭建(三)-视⾓
使⽤协程的API,就完全不需要各种Callback类型的参数了,完全提供⼀个返回结果⽤的输出参数即可。
/**
@brief DataStore 的具备协程能⼒的装饰器类型。
@attention 除了定义变量语句和 Update() 以外,其他的操作都需要在协程中调⽤。
/
class
CoroutineDataStore
:
public
Updateable {
public:
CoroutineDataStore(DataStore * data_store,
CoroutineSchedule * schedule);
virtual
~CoroutineDataStore();
int
Init(Config * cfg, std: :string * err_msg);
/*
读取⼀个数据对象,通过 key ,把数据放⼊到输出参数 value。
此函数会在调⽤过程中使⽤协程的 yield 出去。
/
int
Get(const std: :string & key,
Serializable * value);
/*
写⼊⼀个数据对象,写⼊ key ,value
写⼊结果从返回值获得,返回 0 表⽰成功,其他值表⽰失败。
此函数会在调⽤过程中使⽤协程的 yield 出去。
/
int
Put(const std: :string & key,
const
Serializable & value);
/*
删除⼀个数据对象,通过 key
写⼊结果从返回值获得,返回 0 表⽰成功,其他值表⽰失败。
此函数会在调⽤过程中使⽤协程的 yield 出去
*/
int
Remove(const std: :string & key);
int
Update();
private:
DataStore * data_store_;
CoroutineSchedule * schedule_;
};
复制代码
服务器对象管理
组件模型
⼀般来说服务器上,主要是运⾏各种各样处理请求的代码为主(通常叫Handler)。然⽽,我们也会有⼀些需要持续运⾏的逻辑代码,⽐如处理匹配玩家战⽃的逻辑,检查玩家是否超时发呆的逻辑,循环处理⽀付订单等等。这些代码的很多功能,同时还需要被各种Handler所调⽤。所以我们必须要有⼀种
能让所有的这些⾃定义代码,以⼀种标准的⽅式在进程中互相引⽤,以及管理⽣命周期的⽅法。
借鉴于Unity,我觉得使⽤所谓的组件模型是很好的。它的特点包括:
组件之间通过 Application::GetComponet(name) 的⽅式互相调⽤。以⼀个字符串作为索引,就可以⽅便的获得对于的对象。组件⾃⼰通过 Application::Register(com_obj) 注册到系统中去,注册的名字⾃⼰实现 string GetName() 的接⼝去提供。
每个组件有预定的⼏个回调函数,提供进程⽣命周期的调⽤机会。包括:
初始化:Init()
主循环更新:Update()
关闭:Close()
/**
代表⼀个应⽤程序组件,所有的应⽤程序组件应该继承此对象
/
class
Component
:
public
Updateable
{
public:
Component();
virtual
~Component();
/*
返回此组件的名字
@return 名字
*/
virtual std: :string GetName()
0;
/**
初始化过程会调⽤此⽅法
@param app 进程对象
@param cfg 配置
@return 返回 0 表⽰成功,其他表⽰失败
/
virtual
int
Init(Application * app,
Config * cfg);
/*
更新过程会调⽤此⽅法
@return 返回更新处理的负载,0 表⽰没有负载,负数表⽰出错或需要停⽌对此组件进⾏更新
/
virtual
int
Update();
/
*
应⽤停⽌时会调⽤此⽅法
应⽤停⽌时会调⽤此⽅法
@return 返回 0 表⽰可以退出,返回 >0 表⽰需要等待返回值的秒数,返回 < 0 表⽰出错退出
/
virtual
int
Stop()
{
return
0;
};
/*
设置组件被加⼊的应⽤程序,⽤于让组件继承者能简单的获取 Application 对象
@note 如果⼀个组件被加⼊多个不同的 Application,必须使⽤ @see Init() ⽅法来具体保存 Application 对象,
因为此处修改的成员对象 app_ 将是最后⼀次被添加进的 Application 对象。
@param app 要设置的 Application 对象。
*/
void set_app(Application * app) {
app_ = app;
}
protected:
Application * app_;
};
复制代码
Server对象
由于⼀个游戏服务器,所集成的功能实在是太多了,⽐如配置不同的协议、不同的处理器、提供数据库功能等等。要让这样⼀个服务器对象启动起来,需要⼤量的“组装代码”。为了节省这种代码,我设计了⼀个LocalServer的类型,作为⼀个Server模板,简化⽹络层的组装。使⽤者可以继承这个类,⽤来实现各种不同的Server。
class
LocalServerApp
:
public
Application
{
public:
LocalServerApp();
virtual
~LocalServerApp();
virtual
int
Init(Config * cfg = NULL);
virtual
int
Exit();
void set_transport(Transport * transport);
void set_protocol(Protocol * protocol);
void set_processor(Processor * processor);
private:
Transport * transport_;
Protocol * protocol_;
unity 教程Processor * processor_;
Server * server_;
};
复制代码
这个简单的类,可以通过setter⽅法来⾃定义⽹络层的组件,否则就是最常⽤的TCP,TLV,Echo这种服务器。⽽且这个类还是继承于Application的,这样可以让数据库或者其他的组件,也很⽅便的利⽤组件系统安装到服务器上。
集功能
需求分析
游戏常常是⼀个带状态的服务。所以集功能⾮常困难。
有⼀些框架,试图把状态从逻辑进程中搬迁出来,放在缓冲服务器中,但是往往满⾜不了性能需求。另外⼀些框架,则把集定义成⼀个固定的层次架构,通过复杂的消息转发规则,来试图“把请求发到装载状态的进程上”,但这导致了运维部署的巨⼤复杂性。
为了解决这些问题,我觉得有⼏个设计决策是必须要订⽴的:
使⽤SOA的模式:集中⼼的地址作为集的地址,通过服务名来分割逻辑
提供给⽤户⾃定义路由的接⼝:由于集中的进程都带有状态,要把请求发给哪个进程,并不能完全⾃动选择,所以必须要⽤户提供代码来选择
开发教程系列:游戏服务器框架搭建(三)-视⾓
作为SOA模式下的集,必须定义每个服务的“合同”格式。由于⼀个游戏服务器,可能存在各种不同的通信协议和编码协议,所以这个合同必须要能包含所有这些内容。在传统的RPC设计中,⽐如WebService,就采⽤了WSDL的格式,但是现在这种风格更多的被RESTful所取代。因此我决定使⽤类似URL类型的字符串来表述合同:
tcp://1.1.1.1:8888/tlv
复制代码
这样的合同描述,可以包含通信协议,IP地址和端⼝,编码协议三个部分,如果需要,还可以在PATH部分继续添加,如增加QueryString 等。
集中⼼

版权声明:本站内容均来自互联网,仅供演示用,请勿用于商业和其他非法用途。如果侵犯了您的权益请与我们联系QQ:729038198,我们将在24小时内删除。