Научу писать фреймворк игрового сервера с нуля

внешний фреймворк

Эта статья была опубликована сообществом cloud+community

Автор: Хан Вэй

предисловие

Я не писал технической статьи почти год, потому что в этом году вложился в разработку некоторых конкретных игровых проектов. Эти новые игровые проекты ближе к тому, как разрабатываются независимые игры. Я чувствовал, что «наследственная» технология серверной инфраструктуры компании не подходит, поэтому я написал серверную платформу игры с нуля для повышения эффективности и гибкости разработки. Теперь, когда проект вот-вот будет запущен, я хотел бы, когда у меня будет время, подвести итоги процесса проектирования и реализации такого каркаса игрового сервера.

Базовой операционной средой этого фреймворка является Linux, написанный на C++. Для возможности запуска и использования в различных средах используется «старый» компилятор gcc 4.8, разработанный по спецификации C99.

нужно

Поскольку «более общий код — это более бесполезный код», в начале проектирования я думал, что вся система должна строиться по многоуровневому шаблону. По общим требованиям игровых серверов самые основные можно разделить на два слоя:

  1. Основные базовые функции: включая связь, постоянство и другие очень общие части, уделяя особое внимание производительности, простоте использования, масштабируемости и другим показателям.
  2. Логическая функция высокого уровня: включая специфическую игровую логику, которая будет иметь разный дизайн для разных игр.

img

Я хотел бы иметь в основном полную структуру «базовых базовых функций», которую можно было бы повторно использовать для нескольких разных игр. Поскольку цель состоит в том, чтобы разработать фреймворк игрового сервера, подходящий для разработки инди-игр. Итак, самый простой анализ требований:

функциональные требования

  1. Параллелизм: все серверные программы столкнутся с этой основной проблемой: как справиться с параллелизмом. Вообще говоря, технологий будет две: многопоточность и асинхронность. Многопоточное программирование больше соответствует привычкам человеческого мышления при кодировании, но оно создает проблему «блокировки». В асинхронной неблокирующей модели ситуация с выполнением программы относительно проста, и производительность оборудования может быть полностью использована, но проблема в том, что многие коды необходимо писать в виде «обратных вызовов», что сложно для сложного бизнеса. логика Очень громоздкая и очень плохо читаемая. Хотя эти две схемы имеют свои преимущества и недостатки, и некоторые люди надеются объединить эти две технологии для достижения своих собственных преимуществ, но я предпочитаю использовать асинхронные, однопоточные, неблокирующие методы планирования, потому что эта схема является наиболее понятной. и просто. . Чтобы решить проблему «обратного вызова», мы можем добавить другие уровни абстракции поверх него, такие как сопрограммы или добавление пулов потоков и других технологий для его улучшения.
  2. Связь: поддерживает режим запроса-ответа и режим уведомления (рассылка считается многоцелевым уведомлением). В игре есть много функций, таких как вход в систему, покупка и продажа и открытие рюкзаков, все из которых имеют четкие запросы и ответы. В большом количестве онлайн-игр местоположение, ХП и прочее нескольких клиентов нужно синхронизировать через сеть, что фактически является методом связи «активного уведомления».
  3. Постоянство: Доступ к объектам возможен. Формат игровых архивов очень сложен, но требования к его индексу часто заключаются в том, чтобы читать и записывать в соответствии с идентификатором игрока. На многих игровых консолях, таких как PlayStation, предыдущие архивы могут храниться на карте памяти аналогично «файлам». Таким образом, самым основным требованием сохранения игры является модель доступа «ключ-значение». Конечно, в игре будут более сложные постоянные требования, такие как списки лидеров, аукционные дома и т. д. Эти требования следует рассматривать дополнительно, и они не подходят для включения в базовый нижний слой общего назначения.
  4. 缓存:支持远程、分布式的对象缓存。游戏服务基本上都是“带状态”的服务,因为游戏要求响应延迟非常苛刻,基本上都需要利用服务器进程的内存来存放过程数据。 But the game data is often faster the change, the lower the value, such as experience points, gold coins, HP, and changes in level, equipment and other slower, the higher the value, this feature is very suitable to use a cache model иметь дело с.
  5. Сопрограммы: сопрограммы можно писать на C++, избегая большого количества функций обратного вызова для разделения кода. Это очень полезная функция для асинхронного кода, которая может значительно повысить удобочитаемость и эффективность разработки кода. В частности, многие низкоуровневые функции, связанные с вводом-выводом, снабжены API-интерфейсами сопрограмм, которые будут такими же простыми и удобными в использовании, как и синхронные API.
  6. Сценарии. Первоначальная идея состоит в том, чтобы поддерживать бизнес-логику, которую можно написать на Lua. Известно, что игровые требования быстро меняются, и написание бизнес-логики на языке сценариев может обеспечить эту поддержку. На самом деле скриптинг очень широко используется в игровой индустрии. Следовательно, поддержка скриптов также является очень важной возможностью фреймворка игрового сервера.
  7. Другие функции: включая таймеры, управление объектами на стороне сервера и многое другое. Эти функции очень часто используются, поэтому их тоже нужно включать в фреймворк, но уже есть много зрелых решений, так что просто выберите распространенную и простую для понимания модель. Например, для управления объектами я буду использовать компонентную модель, подобную Unity.

нефункциональные требования

  1. Гибкость: поддержка альтернативных коммуникационных протоколов, альтернативных постоянных устройств (таких как базы данных), альтернативных устройств кэширования (таких как memcached/redis), опубликованных в виде статических библиотек и заголовочных файлов без особых требований к пользовательскому коду. Операционная среда игры сложна, особенно между разными проектами, которые могут использовать разные базы данных и разные протоколы связи. Но большая часть бизнес-логики самой игры разработана на основе объектной модели, поэтому должна быть модель, которая может абстрагировать все эти базовые функции, основанные на «объектах». Таким образом, можно разработать несколько разных игр на основе набора базовых слоев.
  2. Удобство развертывания: поддерживает гибкие файлы конфигурации, параметры командной строки и ссылки на переменные среды; поддерживает запуск отдельного процесса, не полагаясь на базы данных, промежуточное ПО очереди сообщений и другие средства. Как правило, игры будут иметь как минимум три набора операционных сред, включая среду разработки, внутреннюю среду тестирования и внешнюю среду тестирования или операционную среду. Обновление версии игры часто требует обновления нескольких сред. Таким образом, как максимально упростить развертывание становится очень важным вопросом. Я думаю, что хорошая серверная структура должна позволять запускать эту серверную программу независимо, без настройки и зависимостей, чтобы соответствовать быстрому развертыванию в средах разработки, тестирования и демонстрации. И его можно легко запустить в кластерной внешней тестовой или операционной среде с помощью различных файлов конфигурации или параметров командной строки.
  3. Производительность: Многие игровые серверы запрограммированы асинхронно, без блокировки. Потому что асинхронная неблокировка может повысить пропускную способность сервера и четко контролировать порядок выполнения кода в рамках одновременных задач нескольких пользователей, что позволяет избежать сложных проблем, таких как многопоточные блокировки. Так что в этой структуре я также надеюсь использовать асинхронную неблокирующую модель в качестве базовой модели параллелизма. У этого есть еще одно преимущество, то есть вы можете вручную управлять определенными процессами, чтобы в полной мере использовать производительность серверов с многоядерными процессорами. Конечно, читабельность асинхронного кода станет трудночитаемой из-за большого количества callback-функций, к счастью, мы также можем использовать «корутины», чтобы решить эту проблему.
  4. Масштабируемость: поддерживает связь между серверами, управление состоянием процессов и управление кластером по типу SOA. По сути, ключевым моментом автоматического аварийного восстановления и автоматического расширения является синхронизация состояния и управление сервисным процессом. Я надеюсь, что общий нижний уровень сможет управлять всеми межсерверными вызовами через единую централизованную модель управления, чтобы каждый проект больше не заботился о межкластерном общении, адресации и других вопросах.

После определения требований можно также разработать базовую иерархию:

уровень Функция ограничение
логический уровень Реализовать более конкретную бизнес-логику Может вызывать весь низкоуровневый код, но в основном должен полагаться на интерфейсный уровень.
Уровень реализации Реализация различных специфических протоколов связи, устройств хранения и других функций Удовлетворить уровень интерфейса нижнего уровня для выполнения реализации и запретить тому же уровню вызывать друг друга.
интерфейсный слой Определяет основное использование каждого модуля, чтобы изолировать конкретную реализацию и дизайн, тем самым обеспечивая возможность замены друг друга. Код между этим уровнем может вызывать друг друга, но запрещается вызывать код верхнего уровня.
слой инструментов Обеспечить общие функции библиотеки инструментов C++, такие как обработка журналов/json/ini/datetime/string и т. д. Он не должен вызывать другие уровни кода, а также не должен вызывать другие модули на том же уровне.
сторонняя библиотека Предоставляет такие функции, как redis/tcaplus или другие готовые функции, которые имеют тот же статус, что и «уровень инструментов». Он не должен вызывать другие слои кода или даже изменять исходный код.

В итоге общий архитектурный модуль выглядит так:

иллюстрировать коммуникация процессор тайник Упорство
Реализация функции TcpUdpKcpTlvLine JsonHandlerObjectProcessor SessionLocalCacheRedisMapRamMapZooKeeperMap FileDataStoreRedisDataStroe
Определение интерфейса TransferProtocol ServerClientProcessor DataMapSerializable DataStore
библиотека инструментов ConfigLOGJSONCoroutine

Коммуникационный модуль

Для коммуникационного модуля он должен иметь возможность гибкого и заменяемого протокола, и он должен быть дополнительно разделен в соответствии с определенным уровнем. Для игр протоколы связи самого низкого уровня обычно используют TCP и UDP. Между серверами также используется программное обеспечение для связи, такое как промежуточное программное обеспечение очереди сообщений. Платформа должна иметь возможность поддерживать эти протоколы связи. Таким образом, уровень разработан как:Transport

На уровне протокола самые основные требования включают «субподряд», «распределение» и «сериализацию объекта». Если вы хотите поддерживать режим «запрос-ответ», вам также необходимо нести в протоколе данные «серийного номера», чтобы они соответствовали «запросу» и «ответу». Кроме того, игры обычно представляют собой приложения типа «сеанс», то есть серия запросов будет рассматриваться как «сеанс», для чего Xiezhong должен иметь аналогичныеSession IDэти данные. Чтобы удовлетворить эти потребности, иерархия разработана как:Protocol

С двумя вышеуказанными уровнями можно реализовать самые основные возможности уровня протокола. Однако мы часто надеемся, что протокольный пакет бизнес-данных может автоматически стать объектом в программировании, поэтому при обработке тела сообщения требуется необязательный дополнительный уровень для преобразования массива байтов в объект. Поэтому я разработал специальный процессор:ObjectProcessor, стандартизировать интерфейс сериализации и десериализации объектов в коммуникационном модуле.

войти уровень Функция выход
data Transport коммуникация buffer
buffer Protocol субподряд Message
Message Processor распределение object
object модуль обработки иметь дело с Бизнес-логика

Transport

Этот уровень предназначен для унификации различных базовых транспортных протоколов и должен поддерживать TCP и UDP на самом базовом уровне. На самом деле, для абстрагирования коммуникационных протоколов хорошо поработали многие низкоуровневые библиотеки, такие как библиотека сокетов Linux, чей API чтения и записи можно даже использовать вместе с чтением и записью файлов. Библиотека Socket C# находится между TCP и UDP, и ее API почти такой же. Однако из-за роли игровых серверов многие из них также будут иметь доступ к некоторым специальным «уровням доступа», таким как некоторые прокси-серверы или некоторое промежуточное программное обеспечение для сообщений.Эти API-интерфейсы различаются. Кроме того, в играх HTML5 (таких как мини-игры WeChat) и некоторых веб-играх существует традиция использования HTTP-серверов в качестве игровых серверов (например, с использованием протокола WebSocket), что требует совершенно другого транспортного уровня.

Базовая последовательность использования транспортного уровня сервера в асинхронной модели:

  1. В основном цикле продолжайте пытаться прочитать, какие данные доступны для чтения
  2. Если данные поступают на предыдущем шаге, прочитать данные
  3. После считывания обработки данных нужно отправить данные, затем записать данные в сеть

В соответствии с приведенными выше тремя характеристиками, можно резюмировать базовый интерфейс:

class Transport {
public:   
   /**
    * 初始化Transport对象,输入Config对象配置最大连接数等参数,可以是一个新建的Config对象。
    */   
   virtual int Init(Config* config) = 0;

   /**
    * 检查是否有数据可以读取,返回可读的事件数。后续代码应该根据此返回值循环调用Read()提取数据。
    * 参数fds用于返回出现事件的所有fd列表,len表示这个列表的最大长度。如果可用事件大于这个数字,并不影响后续可以Read()的次数。
    * fds的内容,如果出现负数,表示有一个新的终端等待接入。
    */
   virtual int Peek(int* fds, int len) = 0;

   /**
    * 读取网络管道中的数据。数据放在输出参数 peer 的缓冲区中。
    * @param peer 参数是产生事件的通信对端对象。
    * @return 返回值为可读数据的长度,如果是 0 表示没有数据可以读,返回 -1 表示连接需要被关闭。
    */
   virtual int Read( Peer* peer) = 0;

   /**
    * 写入数据,output_buf, buf_len为想要写入的数据缓冲区,output_peer为目标队端,
    * 返回值表示成功写入了的数据长度。-1表示写入出错。
    */
   virtual int Write(const char* output_buf, int buf_len, const Peer& output_peer) = 0;

   /**
    * 关闭一个对端的连接
    */
   virtual void ClosePeer(const Peer& peer) = 0;

   /**
    * 关闭Transport对象。
    */
   virtual void Close() = 0;

}

В приведенном выше определении вы можете видеть, что должен быть тип Peer. Этот тип предназначен для представления клиентского (равноправного) объекта связи. В общих системах Linux мы обычно используем fd (описание файла) для представления. Но т.к. во фреймворке нам также необходимо установить буферную область для приема данных для каждого клиента, и записать адрес связи и другие функции, то мы инкапсулируем такой тип на основе fd. Это также способствует инкапсуляции связи UDP в модели различных клиентов.

///@brief 此类型负责存放连接过来的客户端信息和数据缓冲区
class Peer {
public:	
    int buf_size_;      ///< 缓冲区长度
    char* const buffer_;///< 缓冲区起始地址
    int produced_pos_;  ///< 填入了数据的长度
    int consumed_pos_;  ///< 消耗了数据的长度

    int GetFd() const;
    void SetFd(int fd);    /// 获得本地地址
    const struct sockaddr_in& GetLocalAddr() const;
    void SetLocalAddr(const struct sockaddr_in& localAddr);    /// 获得远程地址

    const struct sockaddr_in& GetRemoteAddr() const;
    void SetRemoteAddr(const struct sockaddr_in& remoteAddr);

private:
    int fd_;                            ///< 收发数据用的fd
    struct sockaddr_in remote_addr_;    ///< 对端地址
    struct sockaddr_in local_addr_;     ///< 本端地址
};

Особенности игры по протоколу UDP: Вообще говоря, UDP не требует установления соединения, но для игр определенно необходимо иметь чистый клиент, поэтому невозможно просто использовать fd сокета UDP для представления клиента, что делает код верхнего уровня невозможным. просто Согласовано между UDP и TCP. Поэтому здесь используется уровень абстракции Peer, который как раз близок к этой задаче. Это также можно использовать в тех случаях, когда используется какое-то промежуточное ПО очереди сообщений, потому что, возможно, это промежуточное ПО тоже мультиплексируется с fd и может даже не разрабатываться с использованием API fd.

Приведенное выше определение транспорта очень легко выполнить разработчикам TCP. Но разработчикам UDP необходимо подумать, как использовать Peer, особенно данные Peer.fd_. Когда я его реализовывал, я использовал виртуальный механизм fd, чтобы предоставить верхнему уровню функцию различения клиентов через соответствующую карту от IPv4-адреса клиента до int. В Linux все эти операции ввода-вывода могут быть реализованы с использованием библиотеки epoll, чтения событий ввода-вывода в функции Peek() и заполнения вызовов сокетов в функциях Read()/Write().

Кроме того, чтобы реализовать связь между серверами, также необходимо разработать тип, соответствующий Tansport: Connector. Этот абстрактный базовый класс используется для выполнения запросов к серверу в клиентской модели. Его дизайн похож на Transport. В дополнение к Connecotr в среде Linux я также реализовал код на C#, чтобы клиенты, разработанные с помощью Unity, могли легко его использовать. Поскольку .NET изначально поддерживает асинхронную модель, ее реализация не требует больших усилий.

/**
 * @brief 客户端使用的连接器类,代表传输协议,如 TCP 或 UDP
 */
class Connector {

public:    virtual ~Connector() {}    
 
    /**
     * @brief 初始化建立连接等
     * @param config 需要的配置
     * @return 0 为成功
     */
    virtual int Init(Config* config) = 0;

    /**
     * @brief 关闭
     */
    virtual void Close() = 0;

    /**
     * @brief 读取是否有网络数据到来
     * 读取有无数据到来,返回值为可读事件的数量,通常为1
     * 如果为0表示没有数据可以读取。
     * 如果返回 -1 表示出现网络错误,需要关闭此连接。
     * 如果返回 -2 表示此连接成功连上对端。
     * @return 网络数据的情况
     */
    virtual int Peek() = 0;

    /**
     * @brief 读取网络数 
     * 读取连接里面的数据,返回读取到的字节数,如果返回0表示没有数据,
     * 如果buffer_length是0, 也会返回0,
     * @return 返回-1表示连接需要关闭(各种出错也返回0)
     */
    virtual int Read(char* ouput_buffer, int buffer_length) = 0;

    /**
     * @brief 把input_buffer里的数据写入网络连接,返回写入的字节数。
     * @return 如果返回-1表示写入出错,需要关闭此连接。
     */
   virtual int Write(const char* input_buffer, int buffer_length) = 0;

protected:
    Connector(){}
};

Protocol

Для коммуникационного «протокола» он на самом деле содержит много значений. Среди многих требований уровень протокола, который я определил, надеется только на выполнение четырех самых основных возможностей:

  1. Пакетизация: возможность отделять отдельные блоки данных от уровня потоковой передачи или объединять несколько «фрагментированных» данных в полный блок данных. Как правило, чтобы решить эту проблему, вам нужно добавить поле «длина» в заголовок протокола.
  2. Соответствие запроса ответа: Это очень важная функция для асинхронного неблокирующего режима связи. Потому что в один момент может быть отправлено много запросов, и ответы будут приходить в произвольном порядке. Если в заголовке протокола есть уникальное поле «порядковый номер», оно может соответствовать тому, какой ответ относится к какому запросу.
  3. Удержание сеансов: из-за базовой сети игры, не постоянные методы передачи подключения, такие как UDP или HTTP, могут использоваться, поэтому для поддержания сеанса логически вы не можете просто полагаться на транспортный слой. Кроме того, мы все надеемся, что в программе есть возможность сопротивляться сетевому джиттеру и отключению и переподключению, поэтому поддержание сеанса стало распространенным требованием. Ссылаясь на функцию сеанса в области веб-сервисов, я разработал функцию сеанса, добавляя такие данные, как идентификатор сеанса к протоколу, это относительно просто для поддержания сеанса.
  4. Распространение: игровой сервер должен содержать несколько разных бизнес-логик, поэтому для пересылки данных в соответствующем формате требуются пакеты протоколов в нескольких разных форматах данных.

В дополнение к вышеперечисленным трем функциям на самом деле есть еще много возможностей, которые предполагается обрабатывать на протокольном уровне, наиболее типичными из которых являются функция сериализации объектов, а также функции сжатия и шифрования. Причина, по которой я не включил возможность сериализации объектов в протокол, заключается в том, что «объект» в сериализации объектов сам по себе является концепцией с очень сильной корреляцией с бизнес-логикой. В C++ нет ни полной «объектной» модели, ни встроенной поддержки отражения, поэтому невозможно просто разделить уровень кода с помощью абстрактного понятия «объект». Но я также разработал ObjectProcessor, чтобы включить в структуру поддержку сериализации объектов в форме более высокого уровня. Этот процессор — это метод, который может настраивать сериализацию объектов, чтобы разработчики могли выбирать любые возможности «кодирования и декодирования», не полагаясь на базовую поддержку.

Что касается таких функций, как сжатие и шифрование, то они действительно могут быть реализованы на протокольном уровне или даже добавлены к протоколу в качестве уровня абстракции, может оказаться, что одного протокольного уровня недостаточно для поддержки таких богатых функций. разработать модель «цепочки вызовов». Но для простоты я думаю, что было бы хорошо добавить дополнительный класс реализации протокола, где необходимы особые потребности, такие как добавление «типа протокола TLV с функцией сжатия» или тому подобное.

Само сообщение абстрагируется в тип с именем Message, который имеет два поля заголовка сообщения «имя службы» и «идентификатор сеанса» для выполнения функций «распространения» и «сохранения сеанса». Тело сообщения помещается в массив байтов, и записывается длина массива байтов.

enum MessageType {
    TypeError, ///< 错误的协议
    TypeRequest, ///< 请求类型,从客户端发往服务器
    TypeResponse, ///< 响应类型,服务器收到请求后返回
    TypeNotice  ///< 通知类型,服务器主动通知客户端
};

///@brief 通信消息体的基类
///基本上是一个 char[] 缓冲区
struct Message {
public:
    static int MAX_MAESSAGE_LENGTH;
    static int MAX_HEADER_LENGTH;
  
    MessageType type;  ///< 此消息体的类型(MessageType)信息

    virtual ~Message();    virtual Message& operator=(const Message& right);

    /**
     * @brief 把数据拷贝进此包体缓冲区
     */
    void SetData(const char* input_ptr, int input_length);

    ///@brief 获得数据指针
    inline char* GetData() const{
        return data_;
    }

     ///@brief 获得数据长度
    inline int GetDataLen() const{
        return data_len_;
    }
 
    char* GetHeader() const;
    int GetHeaderLen() const;

protected:
    Message();
    Message(const Message& message);
 
private:
    char* data_;                  // 包体内容缓冲区
    int data_len_;                // 包体长度

};

В соответствии с двумя ранее разработанными режимами связи «запрос-ответ» и «уведомление», необходимо разработать три типа сообщений для наследования от Message, а именно:

  • Пакет запроса запроса
  • Ответный пакет ответа
  • Пакет уведомлений об уведомлениях

Оба класса Request и Response имеют поле seq_id, которое записывает порядковый номер, а Notice — нет. Класс Protocol отвечает за преобразование массива байтов буфера в объект подкласса Message. Следовательно, соответствующие методы Encode()/Decode() должны быть реализованы для трех подтипов Message.

class Protocol {

public:
    virtual ~Protocol() {
    }

    /**
     * @brief 把请求消息编码成二进制数据
     * 编码,把msg编码到buf里面,返回写入了多长的数据,如果超过了 len,则返回-1表示错误。
     * 如果返回 0 ,表示不需要编码,框架会直接从 msg 的缓冲区读取数据发送。
     * @param buf 目标数据缓冲区
     * @param offset 目标偏移量
     * @param len 目标数据长度
     * @param msg 输入消息对象
     * @return 编码完成所用的字节数,如果 < 0 表示出错
     */
    virtual int Encode(char* buf, int offset, int len, const Request& msg) = 0;

    /**
     * 编码,把msg编码到buf里面,返回写入了多长的数据,如果超过了 len,则返回-1表示错误。
     * 如果返回 0 ,表示不需要编码,框架会直接从 msg 的缓冲区读取数据发送。
     * @param buf 目标数据缓冲区
     * @param offset 目标偏移量
     * @param len 目标数据长度
     * @param msg 输入消息对象
     * @return 编码完成所用的字节数,如果 < 0 表示出错
     */
    virtual int Encode(char* buf, int offset, int len, const Response& msg) = 0;

    /**
     * 编码,把msg编码到buf里面,返回写入了多长的数据,如果超过了 len,则返回-1表示错误。
     * 如果返回 0 ,表示不需要编码,框架会直接从 msg 的缓冲区读取数据发送。
     * @param buf 目标数据缓冲区
     * @param offset 目标偏移量
     * @param len 目标数据长度
     * @param msg 输入消息对象
     * @return 编码完成所用的字节数,如果 < 0 表示出错
     */
    virtual int Encode(char* buf, int offset, int len, const Notice& msg) = 0;

    /**
     * 开始编码,会返回即将解码出来的消息类型,以便使用者构造合适的对象。
     * 实际操作是在进行“分包”操作。
     * @param buf 输入缓冲区
     * @param offset 输入偏移量
     * @param len 缓冲区长度
     * @param msg_type 输出参数,表示下一个消息的类型,只在返回值 > 0 的情况下有效,否则都是 TypeError
     * @return 如果返回0表示分包未完成,需要继续分包。如果返回-1表示协议包头解析出错。其他返回值表示这个消息包占用的长度。
     */
    virtual int DecodeBegin(const char* buf, int offset, int len,
                            MessageType* msg_type) = 0;

    /**
     * 解码,把之前DecodeBegin()的buf数据解码成具体消息对象。
     * @param request 输出参数,解码对象会写入此指针
     * @return 返回0表示成功,-1表示失败。
     */
    virtual int Decode(Request* request) = 0;

    /**
     * 解码,把之前DecodeBegin()的buf数据解码成具体消息对象。
     * @param request 输出参数,解码对象会写入此指针
     * @return 返回0表示成功,-1表示失败。
     */
    virtual int Decode(Response* response) = 0;

    /**
     * 解码,把之前DecodeBegin()的buf数据解码成具体消息对象。
     * @param request 输出参数,解码对象会写入此指针
     * @return 返回0表示成功,-1表示失败。
     */
    virtual int Decode(Notice* notice) = 0;protected:

    Protocol() {
    }

};

Здесь следует отметить, что, поскольку C++ не имеет возможности собирать мусор в памяти и отражать, при интерпретации данных невозможно преобразовать char[] в объект подкласса за один шаг, но его необходимо обрабатывать за два шага. .

  1. Сначала передайте DecodeBegin(), чтобы вернуть, к какому подтипу принадлежат декодируемые данные. В то же время работа по субподряду завершена, и вызывающая сторона информируется возвращаемым значением о том, был ли пакет полностью получен.
  2. Вызовите Decode() с соответствующим параметром типа, чтобы специально записать данные в соответствующую выходную переменную.

Для конкретного подкласса реализации протокола я сначала реализовал LineProtocol, который является очень неточным протоколом, основанным на текстовом кодировании ASCII, разделении полей пробелами и подразделении с возвратом каретки. Используется для проверки работоспособности фреймворка. Потому что таким образом кодек протокола можно протестировать напрямую через инструмент telnet. Затем я разработал бинарный протокол по методу TLV (Type Length Value). Примерное определение выглядит следующим образом:

Пакет протокола: [тип сообщения: целое число: 2] [длина сообщения: целое число: 4] [содержимое сообщения: байты: длина сообщения]

Значение типа сообщения:

  • 0x00 Error
  • 0x01 Request
  • 0x02 Response
  • 0x03 Notice
Тип упаковки поле детали кодирования
Request наименование услуги [поле:целое:2][длина:целое:2][содержание строки:символы:длина сообщения]
серийный номер [поле:int:2][целочисленное содержимое:int:4]
идентификатор сессии [поле:int:2][целочисленное содержимое:int:4]
тело сообщения [поле:целое:2][длина:целое:2][содержание строки:символы:длина сообщения]
Response наименование услуги [поле:целое:2][длина:целое:2][содержание строки:символы:длина сообщения]
серийный номер [поле:int:2][целочисленное содержимое:int:4]
идентификатор сессии [поле:int:2][целочисленное содержимое:int:4]
тело сообщения [поле:целое:2][длина:целое:2][содержание строки:символы:длина сообщения]
Notice наименование услуги [поле:целое:2][длина:целое:2][содержание строки:символы:длина сообщения]
тело сообщения [поле:целое:2][длина:целое:2][содержание строки:символы:длина сообщения]

Тип с именем TlvProtocol реализует этот протокол.

Processor

Уровень процессора — это абстрактный уровень, который я разработал для взаимодействия с конкретной бизнес-логикой.Он в основном получает входные данные клиента через входные параметры Request и Peer, а затем возвращает сообщения Response и Notice через Reply()/Inform( ) класса Сервер. На самом деле подклассы Transport и Protocol принадлежатnetмодули и различные типы функций, такие как процессор и сервер/клиент, принадлежат другомуprocessorмодуль. Причина этого дизайна состоит в том, чтобы надеяться, что всеprocessorОдносторонние зависимости кода модуляnetкод модуля, но обратное не выполняется.

Базовый класс Processor очень прост, это запись функции обратного вызова функции-обработчика.Process():

///@brief 处理器基类,提供业务逻辑回调接口

class Processor {

public:
    Processor();
    virtual ~Processor();
 
    /**
     * 初始化一个处理器,参数server为业务逻辑提供了基本的能力接口。
     */
    virtual int Init(Server* server, Config* config = NULL);

    /**
     * 处理请求-响应类型包实现此方法,返回值是0表示成功,否则会被记录在错误日志中。
     * 参数peer表示发来请求的对端情况。其中 Server 对象的指针,可以用来调用 Reply(),
     * Inform() 等方法。如果是监听多个服务器,server 参数则会是不同的对象。
     */
    virtual int Process(const Request& request, const Peer& peer,
                        Server* server);

    /**
     * 关闭清理处理器所占用的资源
     */
    virtual int Close();
};

После разработки трех уровней обработки связи (транспорт/протокол/процессор) вам понадобится код, объединяющий эти три уровня, то есть класс сервера. Когда этот класс находится в Init(), ему нужны подклассы трех вышеуказанных типов в качестве параметров для объединения в серверы с различными функциями, такими как:

TlvProtocol tlv_protocol;   //  Type Length Value 格式分包协议,需要和客户端一致
TcpTransport tcp_transport; // 使用 TCP 的通信协议,默认监听 0.0.0.0:6666
EchoProcessor echo_processor;   // 业务逻辑处理器
Server server;  // DenOS 的网络服务器主对象
server.Init(&tcp_transport, &tlv_protocol, &echo_processor);    // 组装一个游戏服务器对象:TLV 编码、TCP 通信和回音服务

Для типа Server также требуется функция Update(), которая позволяет «основному циклу» пользовательского процесса непрерывно вызывать для управления работой всей программы. Содержимое этой функции Update() очень явное:

  1. Проверить, есть ли в сети данные для обработки (через объект Transport)
  2. Если есть данные, они будут декодированы (через объект Protocol)
  3. После успешного декодирования выполняется вызов распределения бизнес-логики (через объект Processor)

Кроме того, Сервер также должен выполнять некоторые дополнительные функции, такие как поддержка пула буферов сеанса (Session) и предоставление интерфейса для отправки сообщений Response и Notice. Когда эти задачи будут выполнены, всю систему можно будет использовать как более «универсальную» структуру сервера сетевых сообщений. Осталось только добавить различные подклассы Transport/Protocol/Processor.

class Server {

public:
    Server();
    virtual ~Server();
 
    /**
     * 初始化服务器,需要选择组装你的通信协议链
     */
    int Init(Transport* transport, Protocol* protocol, Processor* processor, Config* config = NULL);

    /**
     * 阻塞方法,进入主循环。
     */
    void Start();

    /**
     * 需要循环调用驱动的方法。如果返回值是0表示空闲。其他返回值表示处理过的任务数。
     */
    virtual int Update();
    void ClosePeer(Peer* peer, bool is_clear = false); //关闭当个连接,is_clear 表示是否最终整体清理

    /**
     * 关闭服务器
     */
    void Close();

    /**
     * 对某个客户端发送通知消息,
     * 参数peer代表要通知的对端。
     */
    int Inform(const Notice& notice, const Peer& peer);

    /**
     * 对某个  Session ID 对应的客户端发送通知消息,返回 0 表示可以发送,其他值为发送失败。
     * 此接口能支持断线重连,只要客户端已经成功连接,并使用旧的 Session ID,同样有效。
     */
    int Inform(const Notice& notice, const std::string& session_id);

    /**
     * 对某个客户端发来的Request发回回应消息。
     * 参数response的成员seqid必须正确填写,才能正确回应。
     * 返回0成功,其它值(-1)表示失败。
     */
    int Reply(Response* response, const Peer& peer);

    /**
     * 对某个 Session ID 对应的客户端发送回应消息。
     * 参数 response 的 seqid 成员系统会自动填写会话中记录的数值。
     * 此接口能支持断线重连,只要客户端已经成功连接,并使用旧的 Session ID,同样有效。
     * 返回0成功,其它值(-1)表示失败。
     */
    int Reply(Response* response, const std::string& session_id);

    /**
     * 会话功能
     */
    Session* GetSession(const std::string& session_id = "", bool use_this_id = false);
    Session* GetSessionByNumId(int session_id = 0);
    bool IsExist(const std::string& session_id);
   
};

С типом сервера также должен быть тип клиента. Конструкция типа Client аналогична конструкции Server, но вместо использования интерфейса Transport в качестве транспортного уровня используется интерфейс Connector. Однако уровень абстракции протокола можно полностью использовать повторно. Клиент не нуждается в обратном вызове в виде Processor, а напрямую передает в интерфейс объект ClientCallback, который инициирует обратный вызов после получения сообщения с данными.

class ClientCallback {

public:
  
    ClientCallback() {
    }
    virtual ~ClientCallback() {
         // Do nothing
    }

    /**
     *  当连接建立成功时回调此方法。
     * @return 返回 -1 表示不接受这个连接,需要关闭掉此连接。
     */
    virtual int OnConnected() {
        return 0;
    }

    /**
     * 当网络连接被关闭的时候,调用此方法
     */
    virtual void OnDisconnected() {        // Do nothing
    }

    /**
     * 收到响应,或者请求超时,此方法会被调用。
     * @param response 从服务器发来的回应
     * @return 如果返回非0值,服务器会打印一行错误日志。
     */
    virtual int Callback(const Response& response) {
        return 0;
    }

    /**
     * 当请求发生错误,比如超时的时候,返回这个错误
     * @param err_code 错误码
     */
    virtual void OnError(int err_code){
        WARN_LOG("The request is timeout, err_code: %d", err_code);
    }

    /**
     * 收到通知消息时,此方法会被调用
     */
    virtual int Callback(const Notice& notice) {
        return 0;
    }

    /**
     * 返回此对象是否应该被删除。此方法会被在 Callback() 调用前调用。
     * @return 如果返回 true,则会调用 delete 此对象的指针。
     */
    virtual bool ShouldBeRemoved() {
        return false;
    }
};

class Client : public Updateable {
 
public:
    Client();    virtual ~Client();

     /**
     * 连接服务器
     * @param connector 传输协议,如 TCP, UDP ...
     * @param protocol 分包协议,如 TLV, Line, TDR ...
     * @param notice_callback 收到通知后触发的回调对象,如果传输协议有“连接概念”(如TCP/TCONND),建立、关闭连接时也会调用。
     * @param config 配置文件对象,将读取以下配置项目:MAX_TRANSACTIONS_OF_CLIENT 客户端最大并发连接数; BUFFER_LENGTH_OF_CLIENT客户端收包缓存;CLIENT_RESPONSE_TIMEOUT 客户端响应等待超时时间。
     * @return 返回 0 表示成功,其他表示失败
     */
    int Init(Connector* connector, Protocol* protocol,
             ClientCallback* notice_callback = NULL, Config* config = NULL);

    /**
     * callback 参数可以为 NULL,表示不需要回应,只是单纯的发包即可。
     */
    virtual int SendRequest(Request* request, ClientCallback* callback = NULL);

    /**
     * 返回值表示有多少数据需要处理,返回-1为出错,需要关闭连接。返回0表示没有数据需要处理。
     */
    virtual int Update();
    virtual void OnExit();
    void Close();
    Connector* connector() ;
    ClientCallback* notice_callback() ;
    Protocol* protocol() ;
};

На этом базовый дизайн клиента и сервера завершен, и вы можете напрямую проверить, нормально ли работает, написав тестовый код.

Эта статья была опубликована Tencent Cloud + Community на различных каналах, и все права принадлежат автору.

Чтобы узнать больше о свежих технических сухих товарах, вы можете подписаться на насСообщество Tencent Cloud Technology — официальный аккаунт сообщества Yunjia и аккаунт Zhihu Institution