请选择 进入手机版 | 继续访问电脑版

NoahFrame

 找回密码
 Register Now
搜索
热搜: redis mysql tutorial
查看: 935|回复: 0

第十章 NF架构-分布式服务器解决方案— 网络通信详解

[复制链接]

30

主题

111

帖子

632

积分

Administrator

Rank: 9Rank: 9Rank: 9

积分
632
发表于 2018-1-5 16:56:21 | 显示全部楼层 |阅读模式
NF(https://github.com/ketoo/NoahGameFrame)全称为 NoahFrame/NoahGameFrame。


NF最早为客户端设计,后来随着时代的变化,而为自己又转为服务器开发,故在吸收了众多引擎的优点后(包含Ogre的插件模式&模块化管理机制,Bigworld的数据管理&配置机制,类似MYGUI的接口层次设计),经过多年演化和实践,变成了一套游戏开发J解决方案。方案中包含开源的服务器架构,网络库(站在libevent的肩膀上),和unity3d的demo源码。现在NF已经在多个公司的多个项目中使用,其中包含知名产品 《全民无双》。


关键词


NoahGameFrame/NoahFrame/NF
集群/负载均衡/分布式
网关服务器 GateServer 心跳 多线程/线程池 开源网络框架/模型
一致性hash算法/ConsistentHash
游戏开发中的设计模式/数据结构
Socket Nagle/粘包/开源游戏服务器/ Game Server



前面讲解了不少NF的设计概念,NF作为服务器框架,其中重中之重的模块当属网络通信模块了,此模块设计的好坏,是否与主框架耦合过高,直接体现了设计者的设计能力以及代码质量。好在nf的插件式架构,netplugin也是严格遵守nf插件架构的面相接口编程思想,因此无需担忧netplugin和主框架耦合问题。


网络服务器中,互相通信依赖通信协议,否则谁也不认识谁,当前通信协议也好几种,常见的是udp, tcp, http 以及websocket的frame。服务器中常用的是tcp可靠传输协议,客户端链接服务器使用的协议常用有tcp, http 以及websocket。在这些协议中,又涉及大小端,粘包等技术细节需要处理,最重要的还有高负载,一个处理不好,就会使得服务器运行不稳定,这里就从头开始讲解一下NF的网络相关的知识。


NF使用的是libevent作为基础网络库,上层封装自己的netobject 和netmodule,把NFNetPlugin作为承载网络部分的插件。
其中重要的类有NFCNet, NFCNetModule 和 NFCNetClientModule, 这里就详细讲解为什么会这样设计以及他们的使用方法。


我自诩为程序设计师,非码农,因此我在设计一样事物的时候,总是会优先考虑抽象出通用的接口以方便后期替换,或者满足最少知识原则,里斯替换原则等, 最后就是集成在module内部供外接调用,而外界并不需要了解module内部的信息,以达到信息封装的目的。


因此net部分设计的时候,首先有NFCNet类提供基础功能,包含
1:初始化客户端以及服务器函数接口
2:帧执行接口
3:注册函数callback
4:发送消息等接口,需要各种接口方便后续业务编程
5:网络对象的管理接口( 这时候是不是需要设计NetObject了呢?)
粗略为:
  1. class NFINet
  2. {
  3. public:
  4.         virtual ~NFINet() {}

  5.     //need to call this function every frame to drive network library
  6.     virtual bool Execute() = 0;

  7.     virtual void Initialization(const char* strIP, const unsigned short nPort) = 0;
  8.     virtual int Initialization(const unsigned int nMaxClient, const unsigned short nPort, const int nCpuCount = 4) = 0;

  9.     virtual bool Final() = 0;

  10.     //send a message with out msg-head[auto add msg-head in this function]
  11.     virtual bool SendMsgWithOutHead(const int16_t nMsgID, const char* msg, const uint32_t nLen, const int nSockIndex = 0) = 0;

  12.     //send a message to all client[need to add msg-head for this message by youself]
  13.     virtual bool SendMsgToAllClient(const char* msg, const uint32_t nLen) = 0;

  14.     //send a message with out msg-head to all client[auto add msg-head in this function]
  15.     virtual bool SendMsgToAllClientWithOutHead(const int16_t nMsgID, const char* msg, const uint32_t nLen) = 0;

  16.     virtual bool CloseNetObject(const int nSockIndex) = 0;
  17.     virtual NetObject* GetNetObject(const int nSockIndex) = 0;
  18.     virtual bool AddNetObject(const int nSockIndex, NetObject* pObject) = 0;

  19.     virtual bool IsServer() = 0;

  20.     virtual bool Log(int severity, const char* msg) = 0;
  21. };
复制代码


里面包含了客户端服务器网络模块初始化、发送消息给某个具体的客户端、广播、连接事件和接收消息的处理回调等接口,适合于大部分C/S结构的通讯,其中提供通用抽象的NetObject和MsgHead意味着更容易维护以及修改扩展。


NF中何为NetObject网络对象呢,主要是nf为了屏蔽不同网络库之间的差异抽象出的一套系统,它类似linux中的文件描述符fd。任何一个连接连到服务器的时候,服务器NFCNet就会为此产生一个NetObject来代替它,通过NetObject可以与不同网络库之间建立联系,实现写缓存copy,读取缓存copy等功能。我们来看看NetObject的设计:
  1. <div yne-bulb-block="paragraph" style="line-height: 1.5;"><font size="3" face="Tahoma">class NetObject</font></div><div yne-bulb-block="paragraph" style="line-height: 1.5;"><font size="3" face="Tahoma">{</font></div><div yne-bulb-block="paragraph" style="line-height: 1.5;"><font size="3" face="Tahoma">    int AddBuff(const char* str, uint32_t nLen);</font></div><div yne-bulb-block="paragraph" style="line-height: 1.5;"><font size="3" face="Tahoma">    int CopyBuffTo(char* str, uint32_t nStart, uint32_t nLen);</font></div><div yne-bulb-block="paragraph" style="line-height: 1.5;"><font size="3" face="Tahoma">    int RemoveBuff(uint32_t nStart, uint32_t nLen);</font></div><div yne-bulb-block="paragraph" style="line-height: 1.5;"><font size="3" face="Tahoma">    const char* GetBuff();</font></div><div yne-bulb-block="paragraph" style="line-height: 1.5;"><font size="3" face="Tahoma">    int GetBuffLen() const;</font></div><div yne-bulb-block="paragraph" style="line-height: 1.5;"><font size="3" face="Tahoma">    //////////////////////////////////////////////////////////////////////////</font></div><div yne-bulb-block="paragraph" style="line-height: 1.5;"><font size="3" face="Tahoma">    int GetConnectKeyState() const;</font></div><div yne-bulb-block="paragraph" style="line-height: 1.5;"><font size="3" face="Tahoma">    void SetConnectKeyState(const int nState);</font></div><div yne-bulb-block="paragraph" style="line-height: 1.5;"><font size="3" face="Tahoma">    bool NeedRemove();</font></div><div yne-bulb-block="paragraph" style="line-height: 1.5;"><font size="3" face="Tahoma">    void SetNeedRemove(bool b);</font></div><div yne-bulb-block="paragraph" style="line-height: 1.5;"><font size="3" face="Tahoma">    const std::string& GetAccount() const;</font></div><div yne-bulb-block="paragraph" style="line-height: 1.5;"><font size="3" face="Tahoma">    void SetAccount(const std::string& strData);</font></div><div yne-bulb-block="paragraph" style="line-height: 1.5;"><font size="3" face="Tahoma">    int GetGameID() const;</font></div><div yne-bulb-block="paragraph" style="line-height: 1.5;"><font size="3" face="Tahoma">    void SetGameID(const int nData);</font></div><div yne-bulb-block="paragraph" style="line-height: 1.5;"><font size="3" face="Tahoma">    const NFGUID& GetUserID();</font></div><div yne-bulb-block="paragraph" style="line-height: 1.5;"><font size="3" face="Tahoma">    void SetUserID(const NFGUID& nUserID);</font></div><div yne-bulb-block="paragraph" style="line-height: 1.5;"><font size="3" face="Tahoma">
  2. </font></div><div yne-bulb-block="paragraph" style="line-height: 1.5;"><font size="3" face="Tahoma">    const NFGUID& GetClientID();</font></div><div yne-bulb-block="paragraph" style="line-height: 1.5;"><font size="3" face="Tahoma">    void SetClientID(const NFGUID& xClientID);</font></div><div yne-bulb-block="paragraph" style="line-height: 1.5;"><font size="3" face="Tahoma">    int GetRealFD();</font></div><div yne-bulb-block="paragraph" style="line-height: 1.5;"><font size="3" face="Tahoma">};</font></div>
复制代码


我们可以看到,NetObject的主要功能的对其进行缓存读写并建议维持连接状态和用户数据。
当我们收到消息后,会把数据写入到缓存,每次NFCNet执行execute函数的时候就会去读取目前有多少buff,进而取出并解析成合法的消息包并调用用户注册的callback接口,继由业务函数会处理此消息。


而其中NFMsgHead的设计,又让NF的网络模块很容易嵌入到旧的游戏系统中去,甚至轻松实现与旧系统的通信。


NFCNet里因为良好的设计思路和对接口的深入理解,可以非常快的实现自己的网络模块,下面我们分别来看如何快速实现TCP客户端和服务器,下面我们分别来看如何快速实现TCP客户端和服务器。
1. TCP-server (TestServer.cpp)
  1. class TestServerClass
  2. {
  3. public:
  4.     TestServerClass()
  5.     {
  6.         pNet = new NFCNet(this, &TestServerClass::ReciveHandler, &TestServerClass::EventHandler);
  7.         pNet->Initialization(1000, 8088);
  8.     }

  9.     void ReciveHandler(const int nSockIndex, const int nMsgID, const char* msg, const uint32_t nLen)
  10.     {
  11.         std::string str;
  12.         str.assign(msg, nLen);

  13.         pNet->SendMsgWithOutHead(nMsgID, msg, nLen, nSockIndex);
  14.         std::cout << " fd: " << nSockIndex << " msg_id: " << nMsgID /*<<  " data: " << str*/ << " thread_id: " << GetCurrentThreadId() << std::endl;
  15.     }

  16.     void EventHandler(const int nSockIndex, const NF_NET_EVENT e, NFINet* p)
  17.     {
  18.         std::cout << " fd: " << nSockIndex << " event_id: " << e << " thread_id: " << std::this_thread::get_id() << std::endl;
  19.     }

  20.     void Execute()
  21.     {
  22.         pNet->Execute();
  23.     }

  24. protected:
  25.     NFINet* pNet;
  26. };

  27. int main(int argc, char** argv)
  28. {
  29.     TestServerClass x;

  30.     while (1)
  31.     {
  32.         x.Execute();
  33.     }

  34.     return 0;
  35. }
复制代码


仅需要两个参数(最大连接数,监听端口)和两个回调函数(EventHandler, ReciveHandler),我们就可以实现开启一个tcp服务器并且接收客户端的数据


2. TCP-client (TestClient.cpp)
  1. class TestClientClass
  2. {
  3. public:
  4.     TestClientClass()
  5.     {
  6.         pNet = new NFCNet(this, &TestClientClass::ReciveHandler, &TestClientClass::EventHandler);
  7.         pNet->Initialization("127.0.0.1", 8088);
  8.         bConnected = false;
  9.     }

  10.     void ReciveHandler(const int nSockIndex, const int nMsgID, const char* msg, const uint32_t nLen)
  11.     {
  12.         std::string str;
  13.         str.assign(msg, nLen);

  14.         std::cout << " fd: " << nSockIndex << " msg_id: " << nMsgID /*<<  " data: " << str */ << " thread_id: " << std::this_thread::get_id() << std::endl;
  15.     };

  16.     void EventHandler(const int nSockIndex, const NF_NET_EVENT e, NFINet* p)
  17.     {
  18.         std::cout << " fd: " << nSockIndex << " event_id: " << e << " thread_id: " << std::this_thread::get_id() << std::endl;
  19.         if(e == NF_NET_EVENT_CONNECTED)
  20.         {
  21.             bConnected = true;
  22.         }
  23.     }

  24.     void Execute()
  25.     {
  26.         if(bConnected)
  27.         {
  28.             pNet->SendMsgWithOutHead(1, "1234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890", 100, 0);
  29.         }

  30.         pNet->Execute();
  31.     }

  32. protected:
  33.     NFINet* pNet;
  34.     bool bConnected;
  35. private:
  36. };

  37. int main(int argc, char** argv)
  38. {
  39.     std::list<TestClientClass*> list;

  40.     for (int i = 0; i < 1000; ++i)
  41.     {
  42.         TestClientClass* x = new TestClientClass();;
  43.         list.push_back(x);
  44.     }

  45.     while (1)
  46.     {
  47.         std::list<TestClientClass*>::iterator it = list.begin();
  48.         for (it; it != list.end(); ++it)
  49.         {
  50.             Sleep(1);

  51.             (*it)->Execute();
  52.         }
  53.     }

  54.     return 0;
  55. }
复制代码




TCP客户端只需要填入客户端IP和Port,以及两个事件回调函数(事件回掉:连接,断开连接;消息回掉:接受消息),即可发送数据到服务器进行通讯。

相信大家掌握了如此简单的TCP开发方式,NFNet设计成如此接口,主要还是想让客户端和服务器的接口保持相对一致。以上代码均在NFComm/NFNetPlugin目录.
这样对开发者来说,不进可以降低理解成本,还可以提升开发效率,而成本和效率一致是NF团队最为关注的事情,NF也正是为了这个目标而逐渐进步的。

基本的通信实现了,但是中实际项目开发中,基于用户体验以及产品容灾的产品指标,同样要实现几个额外的功能:
1:动态扩容--即要求能动态增加连接到新的服务器
2:自动重连--断线后,要求会自动重连,以便恢复连接
3:负载均衡--当某些消息无特定处理服务器的时候,要求按照一致性原则发送到同类型的服务器


基于以上目标,NF抽象了2个新module。NFINetModule 和 NFINetClientModule.
为什么要设计2套netmodule呢?这是因为,NF的架构设计中很多服务器在运行的时候,既作为服务器接收其他Server/Client的请求,也有作为客户端而去连接上层服务器,各位看官在nf的源码中,看到的各种netclient_plugin/netserver_plugin就是属于此类。


这2个module最重要的就是,实现了对于动态扩容,断线重连,以及负载方面的自动处理,而无需使用者关心这些需求的内部实现。


我们来看看NFINetClientModule内部的实现:






番外:




1:粘包-拆包问题
因为TCP/IP协议是基于流式传输,因此它存在粘包问题
什么是粘包问题?


TCP/IP协议基于字节流的传输服务,"流"意味着传输的数据是没有边界的。这不同于UDP提供基于消息的传输服务,其传输的数据是有边界的。TCP/IP的发送方无法保证对等方每次接收到的是一个完整的数据包。主机A向主机B发送两个数据包,主机B的接收情况可能是



产生粘包问题的原因有多种,比如主机发送端buff粘包,接受端buff粘包以及网络传输中粘包,但是在这里纠结粘包原因已经没有特别明显的意义,重要的是如何在接收端解决粘包问题(有兴趣的同学可以自行研究原因)。


粘包问题的最本质原因在与接收对等方无法分辨消息与消息之间的边界在哪,那么很明显的我们需要知道一个消息包的长度是多少;除此之外还需要知道,这个是个什么消息,以及它长啥样,因此一个消息包种还需要消息ID 和消息内容2个元素,因此总共最少需要有3个元素:
包ID
包长度
包内容


我们通过这3个元素,就可以解决TCP/IP协议的粘包问题,使接收端无论如何都可以识别来自发送端的消息内容,而NF中就是如此,使用这3个元素来解决TCP/IP的粘包问题。 有些同学可能会想,我使用有边界协议啊,比如udp 或者 websocket的frame。从设计上,这些协议是按照帧(包)发送的,自带边界,但是实际中并不那么如意。一个UDP包可以承载的传输内容为一个以太网(Ethernet)数据帧的MTU(最大传输单元),其中数据报的首部为20字节, IP数据报的数据区长度最大为1480字节,而这个1480字节就是用来放TCP传来的TCP报文段或UDP传来的UDP数据报的.   
又因为UDP数据报的首部8字节,所以UDP数据报的数据区最大长度为1472字节.   
这个1472字节就是我们可以使用的字节数,但是实际上,各路由器可能会设置这个MTU值为不同的值    
而在UDP编程的时候,也可能一个UDP包有多条消息,一样是要拆消息包,因此粘包处理拆包无论如何都少不了(在websocket中也需要有同样的机制),下面我们就来看看NF中如何处理拆包。




2:大小端问题
所谓大小端是指 我们的数据在物理内存中存放和访问的顺序,譬如说我们的32位系统下,以4字节对齐的方式存储访问。比如一个int型的数据,0x12345678 在以字节为单位的内存中是怎么存放的呢?


如何解决?




3:protocolbuf


当前即时通讯应用中最热门的通信协议无疑就是Google的Protobuf了,基于它的优秀性能和内容压缩率之高,大量的游戏以及主流的应用也早已在使用它。NF使用的协议也是基于protocol buf,利用PB描述NF需要的基础协议,然后大量适用在NF各种通信协议中,包含客户端<->服务器,服务器<->服务器,数据库存储等内容.


关于选择pb的理由:
首先,pb有自动的打包解包库,并提供中间描述语言进行设计消息体,大大大提高工作效率;
其次在移动互联网时代,手机流量、电量是最为有限的资源,而移动端的通讯则必须得直面这两个问题。
解决流量过大最基本的方法就是压缩通信内容,而数据压缩后流量减小带来的自然结果也就是省电和节约带宽流量,而pb在压缩方面做的非常不错,能有效节约流量带宽等资源。




4:NF中的基础协议
基于NF中已经把所有数据抽象为几类基础数据,因此在再吧数据传输给客户端的时候只需要根据基础数据抽象基础通信协议即可。
比如 NF中通用属性数据有int, float(double实现),string,object,vector2,vector3。因此普通的消息内容根据这几类基础数据设计为如下结构体:


message PropertyInt
{
    required bytes     property_name = 1;
    required int64      data = 2;
}


message PropertyFloat
{
    required bytes     property_name = 1;
    required float      data = 2;
}


message PropertyString
{
    required bytes     property_name = 1;
    required bytes     data = 2;
}


message PropertyObject
{
    required bytes     property_name = 1;
    required Ident      data = 2;
}


message PropertyVector2
{
    required bytes     property_name = 1;
    required Vector2      data = 2;
}


message PropertyVector3
{
    required bytes     property_name = 1;
    required Vector3      data = 2;
}


但是在一个用户数据信息内部,可能有大量的int类型数据或者string类型数据,因此我们还需要组合以上结构体,使其适用于复杂或者较多的数据传输,好在protocolbuf已经考虑到这一点:
message ObjectPropertyList
{
        required Ident  player_id = 1;
        repeated PropertyInt property_int_list = 2;
        repeated PropertyFloat property_float_list = 3;
        repeated PropertyString property_string_list = 4;
        repeated PropertyObject property_object_list = 5;
        repeated PropertyVector2 property_vector2_list = 6;
        repeated PropertyVector3 property_vector3_list = 7;
}


那么我们现在,ObjectPropertyList可以传输一切用户数据了。


当然,如果我们想一次性传输多个用户数据给客户端,则继续使用repeated关键字:
message MultiObjectPropertyList
{
        repeated ObjectPropertyList multi_player_property = 1;
}


同理,NF的另一种数据存储格式record也是利用protocol buf把数据类型划分为int, float(double实现),string,object,vector2,vector3等类型,再抽象成每一个record拥有多行数据,因此一个record二维表中定位数据主要是根据row 和col来定位:


message RecordInt//The base protocol can not be transfer directly
{
    required int32      row = 1;
        required int32      col = 2;
        required int64      data = 3;
}




message RecordFloat//The base protocol can not be transfer directly
{
    required int32      row = 1;
        required int32      col = 2;
        required float      data = 3;
}


message RecordString//The base protocol can not be transfer directly
{
    required int32      row = 1;
        required int32      col = 2;
        required bytes     data = 3;
}


message RecordObject//The base protocol can not be transfer directly
{
    required int32      row = 1;
        required int32      col = 2;
        required Ident      data = 3;
}


message RecordVector2//The base protocol can not be transfer directly
{
    required int32      row = 1;
        required int32      col = 2;
        required Vector2      data = 3;
}


message RecordVector3//The base protocol can not be transfer directly
{
    required int32      row = 1;
        required int32      col = 2;
        required Vector3      data = 3;
}


每次操作一次,一般是以row为单位操作,因此必须抽象出record中每一个row的数据
message RecordAddRowStruct//The base protocol can not be transfer directly
{
        required int32                                 row = 1;
        repeated RecordInt                        record_int_list = 2;
        repeated RecordFloat                record_float_list = 3;
        repeated RecordString                record_string_list = 4;
        repeated RecordObject                record_object_list = 5;
        repeated RecordVector2      record_vector2_list = 6;
        repeated RecordVector3      record_vector3_list = 7;
}


而一个record中,是有多行的,多row
message ObjectRecordBase//The base protocol can not be transfer directly
{
        required bytes  record_name = 1;
        repeated RecordAddRowStruct row_struct = 2;
}


而一个用户,是有多个record的
message ObjectRecordList
{
        required Ident  player_id = 1;
        repeated ObjectRecordBase record_list = 2;
}


如果想同时发送多个用户的record,则继续使用repeated关键字
message MultiObjectRecordList
{
        repeated ObjectRecordList multi_player_record = 1;
}


关键在于,以上协议的细节操作以及存储和通信打包解包,都已经有NF引擎代劳,使用者完全不用操心这些细节。


下一章我们回讲解,从如何启动服务器,到正常进入游戏的广播事项。














回复

使用道具 举报

您需要登录后才可以回帖 登录 | Register Now

本版积分规则

 

GMT+8, 2018-8-15 13:52 , Processed in 0.077930 second(s), 28 queries .

Powered by Discuz! X3.2

© 2001-2013 Comsenz Inc.

快速回复 返回顶部 返回列表