找回密码
 立即注册
查看: 247|回复: 0

大型mmo服务器架构介绍----网络底层篇

[复制链接]
发表于 2022-12-3 10:58 | 显示全部楼层 |阅读模式
上一篇介绍线程架构,现在介绍网络底层是怎么在这个架构上工作的
首先网络io在windows下我们选择select,linux情况下我们使用epoll,这篇文章主要是使用epoll

首先聊聊内存对齐
内存对齐

#pragma pack(push)#pragma pack(4)struct PacketHead {    unsigned short MsgId;};#pragma pack(pop)
这段代码的含义是:自定义的协议头,并且这个PacketHead的结构体是4字节对齐,服务端和客户端都遵从这个协议头的结构。
在网络传输过程中,连续发送5条10KB的消息,在逻辑上认为它是一条一条发送的,但在真实的网络传输过程中却不是严格按照一条一条数据到达接收端的,可能一次收到5KB,也可能一次就收到15KB,这就是网络编程中常说的粘包问题。那么我们如何判断收到了一个完整协议呢?为了解决这个问题,需要在逻辑层手动为它加上一个协议头,这个协议头的定义是自由的,但最重要的一个数据是size,表示本协议的大小。
我们规定这个协议的格式遵循:
2字节(unsigned short) + 4字节(PacketHead) +body
其中开始的2字节是代表本协议的大小(至于本协议的大小包括不包括自己 看设计者怎么考虑了,两种都行,我们选择包含自己)。
收到网络数据的时候,首先缓冲区中年将已收到的数据大小与一个协议头的大小(2字节)相比较,如果小于一个协议头,就不处理,等到大于等于一个协议头的时候,再把协议头读出来,然后取到协议的size,如果缓冲区的size还没大于等于这个size就说明数据还没接受完,等到数据大于等于这个size了,就说明一个完整的协议发送完毕了,如果这个数据解析完了还剩下数据 ,那么剩下的数据就是下一个协议头,然后依旧按照之前的逻辑反复操作就行。


image.png

这篇文章讲pragma的 不懂的可以看看 https://www.cnblogs.com/yangguang-it/p/7392726.html
协议体


在早期的游戏编程中,协议内容一般是由程序员自定义一个结构类型。以登录为例,它的结构类型的定义可能如下:
struct AccountCheck{    unsigned short Version;    char Account[128];    char Password[128];};
结构类型定义完成之后,在代码中实现序列化,并转化为二进制串。这个结构类型一旦定义,客户端和服务端就必须使用相同的格式。当数据从客户端到达服务端时,以同样的规则反序列化,生成一个结构体(Struct)。
自定义结构类型有一定的优势,执行效率相对来说比较高,序列化与反序列化都是清晰可见的。但自定义结构类型有一个致命的缺点,当客户端和服务端协议结构不一致时,容易引起异常或者宕机,必须解决这类兼容问题。特别是对于在线游戏,有人对协议进行分析试探的时候,传来的协议可能是错误的。我们必须有一个根本的认识,从网络传来的协议任何时候都是不可靠的,它有可能是一个伪客户端。
另一方面,在上面自定义的结构类型的结构体中加了一个Version字段,随着游戏上线的时间增长,我们要修改原来的协议变得十分烦琐。因为既要考虑到旧的结构体,又要处理新的结构体。常用的办法就是增加Version字段,同一个协议的每一个不同的版本都需要处理。
现在不需要这么复杂的步骤了,有了一个可替代方案,就是Google提供的Protocol Buffer开源项目,简称Protobuf。Protobuf是跨平台的,并提供多种语言版本,也就是说,服务端和客户端的编程语言可以不一致,数据却可以通用。序列化和反序列化功能Protobuf都已经完成了,不需要我们过多关心,这样可以把编码的重心放在游戏逻辑上。
Packet具体实现

class Packet : public Buffer {public:    //Packet();    Packet(const int msgId, SOCKET socket);    ~Packet();    template<class ProtoClass>    ProtoClass ParseToProto()    {        ProtoClass proto;        proto.ParsePartialFromArray(GetBuffer(), GetDataLength());        return proto;    }    template<class ProtoClass>    void SerializeToBuffer(ProtoClass& protoClase)    {        auto total = protoClase.ByteSizeLong();        while (GetEmptySize() < total)        {            ReAllocBuffer();        }        protoClase.SerializePartialToArray(GetBuffer(), total);        FillData(total);    }    void Dispose() override;    void CleanBuffer();    char* GetBuffer() const;    unsigned short GetDataLength() const;    int GetMsgId() const;    void FillData(unsigned int size);    void ReAllocBuffer();    SOCKET GetSocket() const;private:    int _msgId;    SOCKET _socket;};
先聊成员:
1._msgId: protobuf对应的msgId
2._socket:packet主要是接收网络数据,那么必定是某个connect函数返回的socket。这个socket成员就与之对应

在讲成员函数之前,先看看这个类继承于Buffer对象,大概理解就是一个缓冲区。至于缓冲区干了什么,我们再看Buffer类是啥:
Buffer类

class Buffer :public IDisposable{public:    virtual unsigned int GetEmptySize();    void ReAllocBuffer(unsigned int dataLength);    unsigned int GetEndIndex() const    {        return _endIndex;    }    unsigned int GetBeginIndex() const    {        return _beginIndex;    }    unsigned int GetTotalSize() const    {        return _bufferSize;    }protected:    char* _buffer{ nullptr };    unsigned int _beginIndex{ 0 }; //     unsigned int _endIndex{ 0 };    unsigned int _bufferSize{ 0 }; // };
看关键成员:char* _buffer;
原来这个缓冲区就是一个char字符数组
并且有一个开始index,和结束index,同时还有一个已经存储数据的大小记录字段_bufferSize;再仔细看,原来这个是个环形的缓冲区,为什么是环形,后面仔细说明。

再看关键函数ReAllocBuffer
void Buffer::ReAllocBuffer(const unsigned int dataLength){    if (_bufferSize >= MAX_SIZE) {        std::cout << "Buffer::Realloc except!! " << std::endl;    }    char* tempBuffer = new char[_bufferSize + ADDITIONAL_SIZE];    unsigned int _newEndIndex;    if (_beginIndex < _endIndex)    {        ::memcpy(tempBuffer, _buffer + _beginIndex, _endIndex - _beginIndex);        _newEndIndex = _endIndex - _beginIndex;    }    else    {        if (_beginIndex == _endIndex && dataLength <= 0)        {            _newEndIndex = 0;        }        else         {            ::memcpy(tempBuffer, _buffer + _beginIndex, _bufferSize - _beginIndex);            _newEndIndex = _bufferSize - _beginIndex;            if (_endIndex > 0)            {                ::memcpy(tempBuffer + _newEndIndex, _buffer, _endIndex);                _newEndIndex += _endIndex;            }        }    }    _bufferSize += ADDITIONAL_SIZE;    delete[] _buffer;    _buffer = tempBuffer;    _beginIndex = 0;    _endIndex = _newEndIndex;    //std::cout << "Buffer::Realloc. _bufferSize:" << _bufferSize << std::endl;}
这是buffer长度再分配函数。
1.首先判断——bufferSize是否超出了最大长度MAX_SIZE(假设是1024)。
2.生成一个新的长为 _bufferSize + ADDITIONAL_SIZE(设定为10)的char类型数组
3.声明一个新的名为:_newEndIndex变量 (先不说原因 下面会讲)
4.如果beginIndex  < endIndex
如图所示

image.png

那么就将beginIndex到endIndex的数据拷贝到新的char*数组中,此时上面的newIndex就应该是:
_newEndIndex = _endIndex - _beginIndex;

image.png

原来第三点为什么要声明新的endIndex的原因是:因为在重新拷贝数据的时候,将内存重新整理过了

5.如果beginIndex  >= endIndex
如图所示:


image.png

这样就可以看出 原来这是环形的缓冲区,那么这个时候也需要进行数据的拷贝以及内存的重新的对齐


image.png

这里的处理方式是分段进行拷贝,先拷贝beginIndex的,再拷贝endIndex的。
最终结果和上面的一致:


image.png

剩下的步骤就不仔细说了,挺简单的。

这样我们就学习到了环形缓冲区的写法,学会之后,再回到pakcet类当中来。
packet类方法解析:

Packet::Packet(const int msgId, SOCKET socket){    _socket = socket;      _msgId = msgId;     CleanBuffer();    _bufferSize = DEFAULT_PACKET_BUFFER_SIZE;    _beginIndex = 0;    _endIndex = 0;    _buffer = new char[_bufferSize];}
这是构造函数主要干这几件事:注册msgId,赋值connectSocket,初始化buffer。

再看别的函数
Packet::~Packet(){    CleanBuffer();}void Packet::Dispose(){    _msgId = 0;    _beginIndex = 0;    _endIndex = 0;}void Packet::CleanBuffer(){    if (_buffer != nullptr)        delete[] _buffer;    _beginIndex = 0;    _endIndex = 0;    _bufferSize = 0;}char* Packet::GetBuffer() const{    return _buffer;}unsigned short Packet::GetDataLength() const{    return _endIndex - _beginIndex;}int Packet::GetMsgId() const{    return _msgId;}void Packet::FillData(const unsigned int size){    _endIndex += size;}void Packet::ReAllocBuffer(){    Buffer::ReAllocBuffer(_endIndex - _beginIndex);}SOCKET Packet::GetSocket() const{    return _socket;}
看完会发现,很简单。甚至没有读数据的逻辑,这是为什么呢?
那么就需要来介绍protobuf了

最关键的两个模板函数:
    template<class ProtoClass>    ProtoClass ParseToProto()    {        ProtoClass proto;        proto.ParsePartialFromArray(GetBuffer(), GetDataLength());        return proto;    }    template<class ProtoClass>    void SerializeToBuffer(ProtoClass& protoClase)    {        auto total = protoClase.ByteSizeLong();        while (GetEmptySize() < total)        {            ReAllocBuffer();        }        protoClase.SerializePartialToArray(GetBuffer(), total);        FillData(total);    }
说这两个方法之前 就需要说到protobuff的用法了。
首先假设和客户端需要规定一个消息,这个消息数据结构名叫TestMsg,里面有两个成员 一个msg,一个index


image.png

并且这个消息的msgId为1


image.png

那么在pakcet的封装中,MsgId就是对应的这个msgId,如果是1 那么packet的数据就可以解析成TestMsg的格式。但是如果不解析的话,那么这条数据将会是二进制的,怎么解析数据呢?
就用到了上面两个模板函数
当用户拿到了这个MsgId为1的数据 需要将这个packet反序列化成我们需要的TestMsg结构,只需要调用


image.png

这样protoObj就可以获取到 TestMsg的 id和index字段供我们使用了。

于此同时 另外一个函数是在发送方,想要发一个数据的时候,将这个数据打包成packet,并进行序列化


image.png

这样我们就不需要关心这个数据是怎么变成二进制的,全靠protobuf帮助就行了。

本帖子中包含更多资源

您需要 登录 才可以下载或查看,没有账号?立即注册

×
懒得打字嘛,点击右侧快捷回复 【右侧内容,后台自定义】
您需要登录后才可以回帖 登录 | 立即注册

本版积分规则

小黑屋|手机版|Unity开发者联盟 ( 粤ICP备20003399号 )

GMT+8, 2024-11-24 16:47 , Processed in 0.089302 second(s), 26 queries .

Powered by Discuz! X3.5 Licensed

© 2001-2024 Discuz! Team.

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