贸易时代的总结(一)--总体设计和登陆服务器

来源:互联网 发布:java excel加水印文字 编辑:程序博客网 时间:2024/04/30 07:34
 

贸易时代的总结

大纲:

项目的历史背景

服务器的设计思路

服务器的技术

服务器的设计

服务器的改进

图形引擎myhoho及UI库的设计

客户端与服务器的集成

    在04年暑假的时候,我真正的用心玩了一款网络游戏《航海世纪》,在10月的时候,学校要把毕业设计的题目交上去,就决定自己也做一款网络游戏。当时开始研究网络游戏的设计,整理出一个关于服务器结构设计文章《我的MMORPG游戏服务器端的设计》。后来的《贸易时代》的系统设计,就是按照我总结出来的方法进行设计的。而游戏里的网络消息数据包的设计思路,参考了《航海世纪》的一些东西。所以我不再详细的说写系统结构的设计,里只是大概的介绍一下,有兴趣的可以自己看我写的文章。

    网络游戏一般采用C/S模式,网络游戏的设计重点,我认为在于Server端,也就是我们说的服务器。在服务器端的设计,我把服务器按照功能分为2个部分,一个负责游戏世界的处理,一个服务器服务器与客户端的通讯。在负责游戏世界的处理的服务器,我又按照功能分为地图服务器和逻辑服务器。这样划分的依据是他们处理的内容不同进行。当初的设计还考虑到系统的集群功能,可以把游戏的地图移动处理和游戏的逻辑处理都分别分摊到其它服务器里面去。但是做到最后,发现这样的设计也不是太好,主要是因为在处理一些游戏事件的时候需要两个服务器之间进行协同,这样势必要创建一定的网络游戏消息,在开始制作游戏的时候,因为需要系统的东西不是很多,所以没有太注意,到项目的后期,想增加一个功能的时候,就发现在处理船只沉没的时候,服务器需要传递很多同步数据,而且服务器各自在设置玩家数据的时候,也有很多重复的地方。如果今后还要再加点什么其它功能,那要同步的地方就实在是太多了,所以按照功能把服务器分为2个部分的设计还是存在缺陷的,如果让我重新再来,我会选择单服务器的设计,当然这个服务器还是要和连接服务器进行分离,因为游戏的逻辑处理和与玩家的通讯还是很好分开的,而且分开的话,也有利于逻辑服务器的设计。

登陆(连接)服务器的设计:

    在网络游戏里,其中一个很大的难点就是玩家与服务器的通讯,在Windos的服务器架构下,网络游戏服务器端采用的I/O模型,通常是完成端口。在项目开始时研究完成端口,感觉很难,根本看不懂,因为它在很多地方与以前写网络通讯软件时用的方法不同。但是当我分析过3个完成端口的程序后,基本了解的它的使用方法。而且在懂以后,回过头来看,其它完成端口的概念也不是很复杂,只要能清楚的了解几个函数的使用方法以及基本的处理框架流程,你就会发现它其实非常的简单。

    完成端口的一些需要理解的地方:

1。消息队列

2。工作线程

3。网络消息返回结构体

    一般我们在设计服务器端的时候,最关键的地方是如何分辩刚刚收到的网络数据是由那个玩家发送过来的,如果是采用消息事件驱动的话,是可以得到一个socket的值,然后再用这个值与系统里存在的socket进行比对,这样就可以得到是那位玩家发送过来的游戏消息。我在还没有使用完成端口的时候,就是使用这个方法。这样的设计有一个缺点就是每次收到数据的时候回浪费很多时间在于确定消息发送者身份上。但是在完成端口的设计里,我们可以采用一个取巧的方法进行设计。所以,这个问题很轻易的就结局了,而且系统开销也不是很大,关于完成端口,可以参考一下的文章:

《关于Winsock异步I/O模型中的事件模型》

http://search.csdn.net/Expert/topic/166/166227.xml?temp=.4639093

《手把手教你玩转SOCKET模型之重叠I/O篇》

http://blog.csdn.net/piggyxp/archive/2004/09/23/114883.aspx

《学习日记]IOCP的学习--初步理解》

http://www.gameres.com/bbs/showthread.asp?threadid=25898

《用完成端口开发大响应规模的Winsock应用程序》

http://www.xiaozhou.net/ReadNews.asp?NewsID=901

《理解I/O Completion Port》

http://dev.gameres.com/Program/Control/IOCP.htm

几个关键函数的说明:

http://msdn.microsoft.com/library/en-us/fileio/fs/postqueuedcompletionstatus.asp?frame=true

http://msdn.microsoft.com/library/en-us/fileio/fs/createiocompletionport.asp?frame=true

http://msdn.microsoft.com/library/en-us/fileio/fs/getqueuedcompletionstatus.asp?frame=true

http://msdn.microsoft.com/library/en-us/winsock/winsock/wsarecv_2.asp?frame=true

如果你能认真的搞清楚上面的东西,我估计你离理解完成端口就只有一步了。剩下的这一步就是自己编码实现一个下了。有些时候,看得懂了不一定会实际应用,不实实在在的写一点程序,验证一下你的想法,是不会真正搞清楚原理的。

不过除非你想深入的研究网络技术,否则只要知道怎么用就可以了,剩下的就是寻找一个合适的别人封装好的类来使用。这样可以节省你很多的事件,当然拿来的东西最好有源代码,这样如果发生什么问题,你也好确定是在那个地方出错,要改或者扩充功能都会方便很多。当然,还要注意人家的版权,最好在引用别人代码的地方加一些小小的注解,这样用不了多少时间,而且对你,对原作者都有好处^_^。

不过在完成端口上我还是没有成为拿来主义者,还是自己封装了完成端口的操作,原因找到的源代码代码封装的接口函数我怎么看怎么觉得别扭,所以最后还是自己封装了一个完成端口,有兴趣的可以去看我的源代码,里面有很详细的注解。而且就我看来,要拿我封装的完成端口类使用起来还是很简单的。使用的时候,只要继承我的CIOCP,然后,根据需要覆盖3个虚函数(OnAccept,OnRead,OnClose)就可以了,最多是在连接函数里,需要用一个函数去设置一下完成端口信息。当然,我封装的类稍微简单了一些,如果要拿来响应大规模连接,还是存在很多的问题,但是如果只是针对少量连接,还是可以应付的。

对于客户端的I/O模型,我就没有那么用心的去寻找什么好的解决方案,采用了一个最简单的,最原始的阻塞线程的方法做。原理很简单:创建一个sockt,把socket设置为阻塞,连接服务器成功后,启动一个线程,在线程里面用recv()等待服务器发过来的消息。在我的代码里,也是把阻塞线程的方法封装成一个类,在使用的时候,先继承TClientSocket,然后覆盖(重载)里面的OnRead()函数,并在里面写入一些处理收到数据后的操作代码。在用的时候,只要connect成功,系统就会自动启动一个接收线程,一旦有数据就触发刚才覆盖的OnRead函数。这个类我也不是完全直接写的,在里面使用了别人的一些代码,主要是让每个类都能把线程封装起来,这样在创建不同的类的实体的时候,每个类的实体自己都会有一个单独的数据接收线程。

当然除了阻塞线程的方法,比较常用的还有就是用消息事件的方法收取数据了。我刚开始的时候,也是采用这个方法(以前用过^_^),但是后来发现不太好封装,最后采用阻塞线程的方法,这样做还有一个好处可以让我的代码看起来更加舒服一些。不过就我分析《航海世纪》客户端采用的是消息事件的I/O模型。其它的网络游戏就不太清楚了,我想也应该是采用消息事件方式的吧。。

    我记得在gameres上看到过某人写的一篇关于完成端口的笔记,他在篇末结束的时候,提出一个思考题:我们在学习完成端口的时候,都知道它是用于server端的操作,而且很多文章也是这样写的,但是不知道有没有考虑过,用完成端口做客户端来使用?

    其实这个问题很好回答,答案是OK。拿IOCP做客户端也是可行的,就以封装的IOCP为例,只要在继承原来的CIOCP类的基础上,再写一个Connect(char * ip, int port)的函数,就可以实现客户端的要求了。

bool CIOCPClient::Connect(char *ip, int port)

{

        // 连接服务器

    if (!bInit)

        if (!Init())

            return false;

    // 初始化连接socket

    SOCKET m_socket = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);

    if (m_socket == SOCKET_ERROR)

        return false;

    // 填写服务器地址信息

    sockaddr_in ClientAddr;

    ClientAddr.sin_family = AF_INET;

    ClientAddr.sin_port = htons(port);   

    ClientAddr.sin_addr.s_addr = inet_addr(ip);

    // 绑定监听端口

    bind(m_socket, (SOCKADDR *)&ClientAddr, sizeof(ClientAddr));

    if (connect(m_socket, (SOCKADDR *)&ClientAddr, sizeof(ClientAddr)) == SOCKET_ERROR)

        return false;

    this->m_workThread = true;

    g_hwThread = CreateThread(NULL, 0, WorkThread, (LPVOID)this, 0, &m_wthreadID); // 创建工作线程用来处理完成端口消息的

    this->SetIoCompletionPort(m_socket, NULL); // 设置完成端口监听的socket

    return true;

}

    前面一段是用来连接服务器,所有的客户端程序都是要这样做的,当连接成功后,m_socket就是我们想要的用于与服务器端通讯的socket,然后,我们启动工作线程,并使用SetIoCompletionPort来设置完成端口监听的socket。只要在原来的基础上增加一个函数,就可以把用于服务器的ICOP变成用于客户端的IOCP。

    在收到网络数据以后,下一步就是根据需要,把收到的网络数据包转变为游戏消息数据包。在转换之前,首先是要从收到的网络数据里面提取出有效的消息。这里为什么说是要提取有效部分?其主要原因是,我们创建的游戏消息数据,在进行网络传输的时候,不是以消息的长度来传的,而是根据系统在接收到发送数据请求的时候,根据实际情况来发送的。例如我这里有一条很长的游戏消息,有3k,但是系统一次只能发送1k的数据,所以,我们的游戏消息,只能把我们的游戏消息分为3个包,分3次发送,这样在我们接收消息的时候,就会触发3次OnRead,而这3次OnRead收到的数据都不是一次完整的游戏消息。所以,我们在收到网络数据后,要先和上一次收到的网络数据进行合并,然后再在里面提取出有效的游戏消息,并在提取后,把已经提取的部分删除。我在这里把这一步操作封装到一个类里CBuftoMsg。这里顺便说明一下:一条游戏消息的网络数据包是以0x00EEEE(16进制)为结束标记(《航海世纪》的做法)。

struct TMessage

{

    char * p;       // 消息头所在的位置

    long len;       // 整个消息的长度

};

class CBuftoMsg

{

protected:

    char msgbuf[BUF_LEN];  

    char * buf_end;

    char * buf_begin;

    int buf_len;

public:

    CBuftoMsg(void);

    TMessage getMessage(void);

    void cleanup_buf(void);

    bool AddMsgBuf(const char *, int);

    int tag;

};

CBuftoMsg::CBuftoMsg(void)

{

    buf_begin = msgbuf;

    buf_end = msgbuf;

    buf_len = 0;

}

TMessage CBuftoMsg::getMessage()

{

    char * p    = buf_begin;

    TMessage result;

    result.len = 0;

    result.p    = NULL;

    while(p <= buf_begin + buf_len - 2)

    {

        if ( *p == 0x00)

        {

            const static char ce = 0xEE;

            if (*(p + 1) == ce)

                if(*(p + 2) == ce)

                {

                    // 每条消息都是以 00 EE EE 为结束标志

                    result.p    = buf_begin;

                    result.len = p - buf_begin + 3;

                    buf_begin   = p + 3;

                    buf_end     = buf_begin + buf_len;

                    buf_len -= result.len;

                    break;

                }

        }

        p++;

    }

    return result;

}

void CBuftoMsg::cleanup_buf()

{

    if (buf_len < BUF_LEN)

    {

        if (buf_len == 0)

        {

            buf_begin   = msgbuf;

            buf_end     = msgbuf;

        }

        else

        {

            memmove(msgbuf, buf_end - buf_len, buf_len);

            buf_begin = msgbuf;

            buf_end = buf_end - buf_len;

        }

    }

    else

    {

        // 加入缓冲区的数据过多,要抛弃原来的内容

        buf_begin   = msgbuf;

        buf_end     = msgbuf;

        buf_len     = 0;

    }

}

bool CBuftoMsg::AddMsgBuf(const char * buf, int len)

{

    if (len < 1)

        return false;

    bool result = true;

    buf_len += len;

    if (buf_len >= BUF_LEN)     // 如果缓冲区装满了则直接把原来的缓冲区清空再重新复制数据

    {

        this->cleanup_buf();   

        result = false;

    }

    memcpy(buf_begin, buf, len);

    return result;

}

我在这里把 CBuftoMsg 的代码贴出来,主要是因为,我在写本文的时候,发现一个惊天动地的bug,有兴趣的读者可以自己去找一下。不过一开始写代码的时候,还不是这样的,当初的代码bug比这个还要多,问题还要严重,严重到经常让服务器程序莫名其妙的崩溃,而且这个问题,一直到5月份,系统在进行集成测试的时候才发现并解决(还没有彻底解决,至少目前我还发现了bug,),以前一直都没有怎么注意到这个问题,而且我们还把因为这个bug造成的问题,归结到线程的互斥上去^_^!

我的登陆服务器,除了基本的处理网络数据包以外,还负责玩家系统的登陆验证,这部分东西不是很复杂,在我的程序里,只是简单的从ini文件里读取玩家的信息而已,有兴趣的自己去看我的代码(不过这部分远还没有真正的完善,存在很多问题)。

除了登陆验证以外,在登陆程序还负责进行消息转发,就是把客户端的消息分别发送到不同的服务器。如果当初设计的是一个逻辑服务器,这个功能就可以简单很多,只要发送到一个服务器里就可以了。现在的要发到2个服务器,所以还需要对收到的游戏消息进行分类。为了方便,我对原来定义消息的ID进行了分类,所以,在GameMessageID.h文件里定义的游戏消息对应的ID编号不是顺序编排的。不过也因为这样,在现在看来,这样的设计,有一些不太好。在整个系统里,存在有4个主体,他们之间互相发送,就用了12组的数据,为了方便计算,我把一个变量的范围分为16个不同的区域,这样每个区域只有16个值可以用(我这里是用char类型256/16=16)。在加上用另外一个变量表示逻辑上上的分类(目前按照功能分了12组,有登陆、贸易、银行、船厂等)这样对于贸易这个类型的游戏消息,从客户端发送到逻辑服务器上,只能有16中可能性,如果要发送更多消息,可能要增加另外一个逻辑分类:贸易2^_^!当初这样的设计只是想简化一下系统的处理过程,不过却造成了系统的扩充困难,要解决也不是没有办法,把类型分类的变量由char类型,改为int类型,这样对一个变量分区,在范围上会款很多,而且不会造成逻辑分类上的困扰,但是,这样存在一个弊端就是就是每条网络消息数据包的长度增加了一点点。不要小看这一个字节的变量,现在设计的一条游戏消息头的长度是10个字节,如果把char改为int,无形中就增加了3个字节,在和原来的比较,这样每条消息在消息头部分,就多出23%,也就是我们100M的网络现在只能利用77%而已。

    ^_^呵呵看出什么问题没有?

    没有,那我告诉你,有一个概念被偷换了,消息头的数据不等于整条游戏的消息数据,所以,消息头部分虽然多出了23%,但是整条游戏消息并不会增加这么多,最多增加17%,最少应该不会操作5%。平均起来,应该在10%左右(游戏消息里,很多消息的实际部分可能就一个int变量而已)。不过,就算是10%,也占用了带宽。

    ^_^呵呵还看出什么问题没有?

    ^_^先去读一下我的代码,再回头看看,上面的论述还有什么问题。

    实际上,每条游戏消息由:消息头、消息实体、结束标记组成,其中固定的是消息头和结束标记,所以,实际上一条实际上游戏消息的数据包,最多比原来的多15%,平均起来,应该是8%~10%的增量而异。

    好了,不在这个计算细节上扣太多精力了。要解决这个问题,要么是增加网络数据的发送量,要么,就是调整游戏结构,例如,把两个功能服务器合并为一个服务器,这样服务器的对象实体就由原来的4个分为3个,两两间的通讯,就由原来的12路缩减为6路,只要分8个区域就ok了。这样每个逻辑分类就有32条游戏消息可以使用。当然,如果进一步合并服务器,把服务器端都合并到一个程序,那就不用分类了^_^!

    在登陆服务器目录下,还有一组mynet.h/mynet.cpp的文件,是我当初为服务器端设计的函数,封装的是消息事件网络响应模型。只不过封装得不是怎么好,被抛弃不用了,有兴趣的可以去看看,反正我是不推荐看的。只不过是在这里说明一下整个工程目录的结构而已。