freeeyes 发表于 2011-5-20 10:53:22

多级缓冲的服务器数据服务机制实现(二)

昨天,写了一篇关于多级缓冲服务的文章。
那么今天,我们就来点实际的代码,完成以上的所有功能吧。
按照昨天的思路,我需要两个程序,一个是和客户端通讯的程序,这个程序我们姑且认为它就是游戏服务器,那么,与之对应的,还有一个专门负责和后来存储介质通讯的服务进程。
既然要做这道菜,先看看我们需要点什么佐料。
(1)一个共享内存的类,这个类提供给我们与共享内存交互的功能,对外的接口需要,获得一个内存指针地址,获得当前已有的数据个数,删除其中一个数据,得到自由的内存块个数等等。
(2)我需要一个提供MRU算法的类,用于管理我的有效的共享内存指针,并提供相应的替换算法。
(3)对应我的开源服务器,我需要实现一个dll(或者so),来处理玩家创建,登陆,离开,更新,查询操作。
(4)我需要一个IO的类,用于如果我在内存中命中不到数据的时候,可以从IO里进行查找获得数据。
(5)一个定时执行的类,用于数据进程定时刷入IO接口。
好的,让我们开始把。
对于(1),我需要组织一个支持windows和linux共享内存的接口。要能自动根据操作系统的不同选用不同的方法,并实现一个模板类,完成对共享内存的管理,对于我而言,每个数据都是一个T*,当共享内存创建的时候,我需要指定一个T的个数,我会根据这个sizeof(T)*nCount来创建一整块巨大的内存,从里面切分出不同的T*,这样没有内存碎片。同时,还需要共享内存提供一个"头"的数据空间,用来标明每个T*的使用状态,当然,可能聪明的你已经发现,我使用的是sizrof(T),这样是不是有问题呢?因为对于玩家而言,我可能有玩家的数据,还可能有各种的数组,比如装备格子,技能格子等。这里我要强调一下,为了保证我对数据的统一管理和检查,我要求玩家数据必须是定长的,也就是说对于玩家数据vector,dueue,map等STL容器以及带缓冲的容器是不被允许的,因为变长会导致出错后内存查找的困难。当然,你在逻辑计算过程中,可以使用这个。但是元数据一定是需要定长的。如果你有兴趣,可以尝试在这里改造成变长的。
我的共享内存实现,你可以查看CSMAccessObject类和ShareMemoryAPI类,基础知识。
关键借助这两个类,我实现了一个CSMPool。
里面包含了这样一个数据头结构:

//记录每个队列的数据容器
struct _SMBlock
{
T*   m_pT;         //数据对象
int    m_nID;          //数据当前编号
bool   m_blUse;      //是否在使用true是正在使用,false是没有使用
time_t m_ttUpdateTime; //DS服务器更新完成后回写的信息时间。
_SMBlock()
{
   m_pT    = NULL;
   m_nID   = 0;
   m_blUse = false;
}
};

这个结构就是一个完整的数据"头",这里我要解释一下,DS是啥,这个是我私自起的名字(DataServer服务进程的简称,就是我说的共享内存和IO同步所执行的进程)。在这里m_ttUpdateTime是由DS负责修改,当IO写入成功之后,需要更新这个变量,这样,只要比对t*中的时间戳和这个时间戳,我就知道哪些数据需要我更新到IO里面,因为很有可能,大部分数据在某时刻是不需要更新的。m_pT就是你的数据类,这个类实现是在PlayerObject.h里面,这里面有一个基类CObject是需要被填充的。而实际体class CPlayerData : public CObject
我先说说,CObject有什么关键性数据:

//数据结构体的基类
class CObject
{
public:
CObject() { m_blWrite = false; m_ttUpdateTime = time(NULL); };
virtual ~CObject() {};

void EnterWrite() { m_blWrite = true;}
void LeavelWrite() { m_ttUpdateTime = time(NULL); m_blWrite = false;}

bool GetWriteSate() { return m_blWrite; }

#define ENTERWRITE() EnterWrite(); //定义写入的宏
#define LEAVELWEITE() LeavelWrite(); //定义写完的宏

private:
bool m_blWrite; //写标记

public:
time_t m_ttUpdateTime; //数据更新时间,DS服务器会更具这个时间来决定是否更新。
};

这个类有一个更新写标记,这个写标记给DS使用的,当DS判断写标记正在写入的时候,就不会存储这些数据,等到下一次执行的时候在存储,同时当写标记完成的时候,基类自动更新m_ttUpdateTime这个时间戳,DS会比对_SMBlock.m_ttUpdateTime和T->m_ttUpdateTime的数值,看看需要不需要进行存储。继承这个类,当数据发生修改的时候,一定要套用写入宏,比如这样:

void Create(const char* pPlayerName)
{
ENTERWRITE();//标记写标记
//你要做的事情在这里做
sprintf_safe(m_szPlayerName, 50, "%s", pPlayerName);
m_nPlayerID = 0;
m_nLevel    = 1;
LEAVELWEITE();//释放写标记
};

这样就能最大程度的保证数据的完整性,尽量减少存入半截数据的风险。
我的CPlayerData类只是举一个例子,当然,你可以为这个类提供更多的变量和方法。根据你的需求而定。
好了,话说回来。我的CSMPool是个什么样子呢?

class CSMPool
{
private:
//记录每个队列的数据容器
struct _SMBlock
{
};
public:
CSMPool()
{
};
~CSMPool()
{
};
bool Init(SMKey key, int nMaxCount)   //根据一个key打开或者新建指定的共享内存单元,并指定块数。
{
};
void Close()   //当共享内存需要关闭的时候需要做的一些事情。
{
}
T* NewObject()   //获得一个新的T*(CPlayerData指针)
{
};
bool DeleteObject(T* pData)//删除一个没用的T*,这不是真的删除了共享内存,只是将此块内存指针归还给free指针列表。
{
};
int GetFreeObjectCount()//得到共享内存池中可用的空闲内存块的个数
{
}
int GetUsedObjectCount()//得到共享内存池中已有的内存块得个数
{
};
T* GetUsedObject(int nIndex)//根据ID得到相应的内存块指针
{
};
const time_t GetObjectHeadTimeStamp(T* pData)   //得到_SMBlock时间戳
{
};
bool SetObjectHeadTimeStamp(T* pData)   //修改指定的_SMBlock时间戳,只有DS会干
{
}

(2)MRU算法
CMapTemplate类的实现,这部分代码我改进了当初我写的MRU代码文章,添加了几个函数和扩展了一些函数参数来满足我的需求。具体可以参考MapTemplate.h
(3)对应我的PruenessScopeServer框架,我只需要创建一个dll工程,引用一些头文件就可以完全不用框架代码了。具体引用的头文件在IObject目录里面,这样,我可以无视PruenessScopeServer是否存在,只要专心开发我的业务逻辑即可。
当然,规范还是要有的,具体看看我的PlayerPool.cpp,里面90%的代码写法都是固定的。可以和开源框架中的Base的dll工程比较一下,呵呵,唯一不同的就是,我这个类需要支持以下处理方法。
#define COMMAND_PLAYINSERT 0x1010   //用户数据创建
#define COMMAND_PLAYUPDATE 0x1011   //用户数据更新
#define COMMAND_PLAYDELETE 0x1012   //用户数据删除
#define COMMAND_PLAYSEACH0x1013   //用户查询
#define COMMAND_PLAYLOGIN0x1014   //用户登陆
#define COMMAND_PLAYLOGOFF 0x1015   //用户离开
客户端会给我以上的调用,那么,我们来根据以上的方法去实现代码吧。
对应以上需求,我定义了:

CPlayerData* Do_PlayerInsert(const char* pPlayerNick);
bool         Do_PlayerUpdate(CPlayerData* pPlayerData);
bool         Do_PlayerDelete(const char* pPlayerNick);
CPlayerData* Do_PlayerSearch(const char* pPlayerNick);
CPlayerData* Do_PlayerLogin(const char* pPlayerNick);
bool         Do_PlayerLogOff(const char* pPlayerNick);

以上的方法来实现对这些命令的处理。具体方法可以参考PlayerPoolCommand.cpp的实现。这里就不多说了。
(4)对于共享内存和介质之间的操作。
为了举例,我不用数据库,使用文件来说明,假设我的文件就是我的数据源。当然,你可以用你的数据库引擎替代这里的实现。

bool DeletePlayer(const char* pPlayerNick); //删除一个用户数据文件
bool SavePlayer(CPlayerData* pPlayerData); //保存创建用户的数据
CPlayerData* GetPlayer(const char* pPlayerNick); //这里在IO里面查找,找到了就new一个CPlayerData对象出来,返回给上层,由上层用完负责删除

这里你可以填充你的代码。
(5)这里因为我用的是ACE的框架,所以自然也就用ACE的定时器,比较手熟,当然,也可以用你自己喜欢的定时器替换。

typedef ACE_Thread_Timer_Queue_Adapter<ACE_Timer_Heap> ActiveTimer;
//定时器处理类(处理定时数据更新)
class CTimeHeart : public ACE_Event_Handler
{
public:
CTimeHeart();
~CTimeHeart();
void Init();
virtual int handle_timeout(const ACE_Time_Value &tv, const void *arg);
private:
CSMPool<CPlayerData> m_UserPool;   //共享内存池
CIOData m_IOData;                  //IO数据接口
bool    m_blRunState;                //处理是否正在运行
SMKey   m_key;                     //共享内存Key
};
class CTimeManager
{
public:
CTimeManager(void);
~CTimeManager(void);
void Init();
bool Start(int nTimeIntervel);
void KillTimer();
private:
ActiveTimer m_ActiveTimer;
CTimeHeartm_TimeHeart;
int         m_nTimerID;
};

代码很简单,其实是我最喜欢的,因为越简单的代码,出错机会越低。
以上完整代码如下:我在window7+VS2005下测试通过,linux版本还没测试,等有时间我在上面编译一下,我相信会很顺利的。
好了,把PlayerPool编译一下,生成dll,然后再框架的配置文件main.conf里面添加
ModuleString=PlayerPool.dll
行了,PurenessScopeServer框架启动就会加载PlayerPool模块,并把相关PlayerPool的消息给它。就这么简单,简单吧。

洋洋洒洒写了这么多,就是为了举个例子,当然,我希望你能够在我的例子上,加上你的想法,并把它改的更加高效,这才是进步。如果愿意,你也可以在这里分享给大家你的改进结果。
我一直认为,技术这种东西,是可以后天学习的。但是信仰,才会使我们走的更远。
学习就是这样,先走别人走过的路,然后根据自己的感悟和习惯,融合成属于自己的实现,这样的程序,才是优秀的程序,把你的思维和理解留在代码的字里行间,并让那些追逐梦想的后者,从中获益。只有敢于让别人踩在你的肩膀上,信念才会传承,而路也会越走越远,不是吗?

代码如下:



测试用例:

wesom 发表于 2011-5-20 18:02:59

good job...

wesom 发表于 2011-5-21 10:43:25

整体来说这里的PlayerPool更像是一个DB cache,只不过内存中的数据结构以固定的形式存储
看完还是有2点疑问,
1."把共享内存一份玩家数据,分为头+体",这里我没有看懂,如果并发更新该数据,能避免同步开销而不使用RWLock?除非还是序列化访问
2.PlayerPool主要是解决玩家登陆的缓冲机制,其实如果更通用些,可以使用内存数据库,如memcached,初始化时通过脚本从db导入所需数据,这样会更好些

freeeyes 发表于 2011-5-21 21:30:53

我来回答你的问题。
1.我之所以分为头和体,是为了保证DS和服务器同时修改同一位置的内存不存在而设计的,只要我保证任意时刻数据修改不在一个点上,内存就不会出问题。具体代码,请看_SMBlock在DS和游戏服务器上的不同作用。
2.我的这套东西和memcache是不同的,主要体现在这几方面。
(1)memcached是一种key - value的对应关系,其遵守集群服务的原则,也就是说,任何对memcached的访问实际都是面对一台专门的memcached服务器或者memcache的集群。在一些通用的分布式计算中,memcache主要作为内存统一管理而存在。而在我的测试下,如果仅仅是小型应用,memcached有时候不得不面临IO通讯的节点,那么,在某些应用下,是否需要这样的成本支出呢?当然,你可以说,我完全可以把内存数据库放在服务器上,起到一样的效果。是的,不过,既然我说的是多级缓冲的服务器,这个多重的含义就是,可以有一个数据源服务器提供DataReaDServer,其他的游戏服务器可以从DataReaDServer作为CIOData的入口,同时子层的DS,则是定时将新数据刷回给DataReaDServer所在的数据缓冲层。这样就实现了,一个或者N个数据中心,带动一片逻辑运算服务器的功能。
(2)根据我对memcached的理解,mem服务器可以做多个实例,从而保证多个key-value的池。比如我有一个mem实例可以是用户池,一个mem实例可以是用户好友关系。但是,对于key-value的某些使用访问控制,比如获得当前已存在的key-value个数,限制池大小,以及遍历key-value结构都是有开发量的,另外,因为mem的key-value很灵活,支持不同的数据类型,当数据访问很多的时候,内存碎片一样会产生并影响系统的稳定性。而我这个需要固定类型,这一点限制了很大的灵活性。但是却能换取大块内存的占有性,尽量杜绝碎片。同时,我可以提供我所需要对这个内存池的遍历,访问的接口,便于扩展。
(3)从节约成本来看,在某些应用下,可能这样的结构更加适合资源较少的应用。memcache支持数据生存周期,但是有时候有些数据的周期,并不是服务器能控制的。
我其实也在大量使用memcached做一些网格计算的开发,比如最近的项目我就用python + ICE + memcached做一个大世界的手机移动网游。在我看来,不同的应用可能面向不同吧。过一段时间我也会把这部分思想分享出来(现在还在试验阶段)。不过在我看来,以集群换"大世界"的概念,是要付出很大的硬件成本的,同时面对大量的学习和开发成本。而对于某些应用,这些成本是可以不耗费的。
以上是我个人观点,欢迎讨论。

evilswords 发表于 2011-5-24 15:50:48

共享内存怎么和分布式的服务器构架相结合合适?

wesom 发表于 2011-6-1 11:18:16

多db,多 (ws - share mem - ds)即是分布式啦,呵呵

tengmo535 发表于 2011-6-7 11:26:34

看到版主的开源ACE框架代码,和现在的游戏开发思想,我只觉得我从没上过大学、从没学过编程、毕业后这10个月的工作纯粹是个渣!唉...
页: [1]
查看完整版本: 多级缓冲的服务器数据服务机制实现(二)