Как стать автором
Обновить

Новая функциональность в RESTinio и опять с помощью C++ных шаблонов

Время на прочтение10 мин
Количество просмотров2.7K

Увидело свет очередное обновление небольшой библиотеки для встраивания асинхронного HTTP-сервера в C++ приложения: RESTinio-0.6.12. Хороший повод рассказать о том, как в этой версии с помощью C++ных шаблонов был реализован принцип "не платишь за то, что не используешь".



Заодно в очередной раз можно напомнить о RESTinio, т.к. временами складывается ощущение, что многие C++ники думают, что для встраивания HTTP-сервера в современном C++ есть только Boost.Beast. Что несколько не так, а список существующих и заслуживающих внимания альтернатив приведен в конце статьи.


О чем речь пойдет сегодня?


Изначально библиотека RESTinio никак не ограничивала количество подключений к серверу. Поэтому RESTinio, приняв очередное новое входящее подключение, сразу же делала новый вызов accept() для принятия следующего. Так что если вдруг на какой-то RESTinio-сервер придет сразу 100500 подключений, то RESTinio не заморачиваясь постарается принять их все.


На такое поведение до сих пор никто не жаловался. Но в wish-list-е фича по ограничению принимаемых подключений маячила. Вот дошли руки и до нее.


В реализации были использованы C++ные шаблоны, посредством которых выбирается код, который должен или не должен использоваться. Об этом-то мы сегодня и поговорим.


Проблема


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


Проблема состояла в том, что в RESTinio есть две сущности: постоянно живущий объект Acceptor, который принимает новые подключения, и временно живущие объекты Connection, которые существуют пока соединение используется для взаимодействия с клиентом. При этом Acceptor порождает Connection, но далее Connection живет своей собственной жизнью и Acceptor ничего больше о Connection не знает. В том числе не знает когда Connection умирает.


Таким образом, нужно было установить какую-то связь между Acceptor-ом и Connection, чтобы Acceptor мог вести корректный подсчет живых на данный момент соединений.


Фактор "не платишь за то, что не используешь"


Итак, в Acceptor нужно добавить функциональность подсчета количества живых подключений, причем этот подсчет должен быть thread safe, если RESTinio работает в многопоточном режиме. А в Connection нужно добавить обратную ссылку на Acceptor, которая будет использоваться когда Connection разрушается.


Это дополнительные действия и дополнительные расходы. Пусть и небольшие, но если кто-то использует RESTinio в условиях, когда нет смысла ограничивать количество подключений, то зачем нести эти расходы? Правильно, незачем.


Поэтому новая функциональность должна включаться в RESTinio только если пользователь явно пожелал ее использовать. В остальных же случаях добавление ограничения на количество подключений не должно вести к возникновению накладных расходов (либо же эти расходы должны быть сведены к минимуму).


Решение


Ограничение на количество подключений включается/выключается через traits


Как и практически все остальное, включение connection_count_limiter-а в RESTinio осуществляется посредством свойств (traits) сервера. Для того, чтобы connection_count_limiter заработал нужно определить свой класс свойств, в котором должен быть статический constexpr член use_connection_count_limiter выставленный в true:


struct my_traits : public restinio::default_traits_t {
   static constexpr bool use_connection_count_limiter = true;
};

Если теперь задать максимальное количество параллельных подключений и запустить RESTinio-сервер с my_traits в качестве свойств сервера, то RESTinio начнет считать и ограничивать количество подключений:


restinio::run(
   restinio::on_thread_pool<my_traits>(16)
      .max_parallel_connections(1000u)
      .request_handler(...)
);

Тут используется простой фокус: в предоставляемых RESTinio типах default_traits_t и default_single_thread_traits_t уже есть use_connection_count_limiter, который содержит значение false. Что означает, что connection_count_limiter работать не должен.


Если же пользователь наследуется от default_traits_t (или от default_single_thread_traits_t) и определяет use_connection_count_limiter в своем типе свойств, то пользовательское значение перекрывает старое значение от RESTinio. Но когда пользователь в своем типе свойств не определят свой собственный use_connection_count_limiter, то остается виден use_connection_count_limiter из базового типа.


Таким образом RESTinio ожидает, что в traits всегда есть use_connection_count_limiter. И в зависимости от значения use_connection_count_limiter уже определяются типы, которые реализуют подсчет количества соединений. Ну или ничего не делают, если use_connection_count_limiter равен false.


Что происходит, если пользователь задает use_connection_count_limiter=true?


Актуальный connection_count_limiter


Если пользователь задает use_connection_count_limiter со значением true, то в объекте Acceptor должен появится объект connection_count_limiter, который и будет заниматься подсчетом количества подключений и разрешением/запрещением вызова accept-ов.


Однако, тут нужно учесть, что RESTinio-сервер может работать в двух режимах:


  • однопоточном. Все, включая I/O и обработку принятых запросов, выполняется на одной единственной рабочей нити. В этом случае RESTinio не использует механизмов обеспечения thread safety. Соответственно, и connection_count_limiter-у незачем применять реальный mutex для защиты своих внутренностей;
  • многопоточном. И I/O, и обработка принятых запросов может выполняться на разных рабочих нитях. Например, RESTinio сразу запускается на пуле рабочих потоков, на котором выполняются I/O операции и работают обработчики запросов. Либо же RESTinio работает на одной рабочей нити, а реальная обработка запросов делегируется какой-то другой рабочей нити (пулу рабочих нитей). Либо же RESTinio работает на одном пуле рабочих нитей, а обработка запросов делегируется на другой пул рабочих нитей. В этом случае RESTinio задействует механизм strand-ов из Asio для обеспечения thread safety. А connection_count_limiter должен использовать mutex, чтобы не допустить порчи собственных данных когда его начнут дергать из разных нитей.

Поэтому реализация connection_count_limiter-а выполнена в виде шаблонного класса, который параметризуется типом mutex. А нужная реализация выбирается благодаря специализации шаблона:


template< typename Strand >
class connection_count_limiter_t;

template<>
class connection_count_limiter_t< noop_strand_t >
   :  public connection_count_limits::impl::actual_limiter_t< null_mutex_t >
{
   using base_t = connection_count_limits::impl::actual_limiter_t< null_mutex_t >;

public:
   using base_t::base_t;
};

template<>
class connection_count_limiter_t< default_strand_t >
   :  public connection_count_limits::impl::actual_limiter_t< std::mutex >
{
   using base_t = connection_count_limits::impl::actual_limiter_t< std::mutex >;

public:
   using base_t::base_t;
};

Тип strand-а задается в traits, поэтому достаточно параметризовать connection_count_limiter_t типом traits::strand_t и автоматически получается либо версия для однопоточного, либо версия для многопоточного режимов.


Экземпляр connection_count_limiter-а теперь содержится в объекте Acceptor и Acceptor обращается к этому connection_count_limiter-у для того, чтобы узнать, можно ли делать очередной вызов accept. А connection_count_limiter либо разрешает вызвать accept, либо нет.


Объект connection_count_limiter получает уведомления от разрушаемых объектов Connection. Если connection_count_limiter видит, что вызовы accept были заблокированы, а сейчас появилась возможность возобновить прием новых подключений, то connection_count_limiter отсылает нотификацию Acceptor-у. И получив эту нотификацию Acceptor возобновляет вызовы accept.


А уведомления о разрушении объектов Connection к connection_count_limiter приходят благодаря объектам connection_lifetime_monitor, о которых речь пойдет дальше.


Актуальный connection_lifetime_monitor


В Acceptor-е есть connection_count_limiter который должен узнавать о моментах разрушения объектов Connection.


Очевидным решением было бы реализовать информирование connection_count_limiter-а прямо в деструкторе Connection. Но дело в том, что в RESTinio Connection может преобразовываться в WS_Connection в случае перевода соединения в режим WebSocket-а. Так что аналогичное информирование потребовалось бы делать и в деструкторе WS_Connection-а.


Дублировать же одну и ту же функциональность в двух разных местах не хотелось, поэтому задача информирования connection_count_limiter-а об исчезновении подключения была делегирована новому объекту connection_lifetime_monitor.


Это Noncopyable, но зато Movable объект, который создается внутри Connection. Соответственно, и разрушается он вместе с объектом Connection.


Если же Connection преобразуется в WS_Connection, то экземпляр connection_lifetime_monitor перемещается из Connection в WS_Connection. И затем разрушается уже вместе с владеющим WS_Connection.


Т.е. итоговая схема такая:


  • в Acceptor-е живет connection_count_limiter;
  • когда Acceptor принимает новое подключение, то вместе с новым Connection создается и новый экземпляр connection_lifetime_monitor;
  • когда Connection умирает, то разрушается и connection_lifetime_monitor;
  • умирающий connection_lifetime_monitor информирует connection_count_limiter о том, что количество соединений уменьшилось.

Если Connection преобразуется в WS_Connection, то ничего принципиально не меняется, просто актуальную информацию о живом соединении начинает держать у себя connection_lifetime_monitor из WS_Connection.


Подчеркнем, что connection_lifetime_monitor вынужден держать у себя внутри указатель на connection_count_limiter. Иначе он не сможет дернуть connection_count_limiter при своем разрушении.


Фиктивные connection_count_limiter и connection_lifetime_monitor


Выше было показано, что стоит за connection_count_limiter и connection_lifetime_monitor в случае, когда ограничение на количество подключений задано.


Если же пользователь задает use_connection_count_limiter равным false, то понятия connection_count_limiter и connection_lifetime_monitor остаются. Но теперь это фиктивные connection_count_limiter и connection_lifetime_monitor, которые, по сути, ничего не делают. Например, фиктивный connection_lifetime_monitor ничего внутри себя не хранит.


Тем не менее, внутри Acceptor-а все еще живет экземпляр connection_count_limiter, пусть даже и фиктивный. А внутри Connection (и WS_Connection) есть пустой connection_lifetime_monitor.


Можно было, конечно, попробовать упороться шаблонами по полной программе и постараться избавиться от присутствия пустого connection_lifetime_monitor в Connection. Но, имхо, наличие лишнего байта в Connection (WS_Connection) не стоит сложности кода, который позволяет от этого байта избавиться. Тем более, что в C++20 добавили атрибут no_unique_address, так что со временем эта проблема должна решиться гораздо более простым и наглядным способом. Впрочем, если для кого-то дополнительный байт в Connection — это реальная проблема, то откройте Issue, будем ее решать :)


Выбор подходящих connection_count_limiter и connection_lifetime_monitor


После того, как появились актуальный и фиктивные реализации connection_count_limiter и connection_lifetime_monitor осталось научиться выбирать между ними в зависимости от содержимого traits. Делается это так:


template< typename Traits >
struct connection_count_limit_types
{
   using limiter_t = typename std::conditional
      <
         Traits::use_connection_count_limiter,
         connection_count_limits::connection_count_limiter_t<
               typename Traits::strand_t >,
         connection_count_limits::noop_connection_count_limiter_t
      >::type;

   using lifetime_monitor_t =
         connection_count_limits::connection_lifetime_monitor_t< limiter_t >;
};

Т.е. для того, чтобы получить актуальный тип connection_count_limiter-а достаточно написать что-то вроде:


typename connection_count_limit_types<traits>::limiter_t

Хранение ограничения на количество подключений в server_settings


Осталось рассмотреть еще один небольшой момент: параметры для RESTinio сервера хранятся в server_settings_t<Traits> и, по хорошему, надо бы сделать так, чтобы ограничение на количество подключений нельзя было задавать, если в traits use_connection_count_limiter выставлен в false.


Тут используется фокус, к которому мы уже прибегали раньше:


  • создается шаблонный тип, который должен использоваться в качестве примеси (mixin);
  • у этого шаблонного типа есть специализация для фиктивного connection_count_limiter-а;
  • этот шаблонный тип подмешивается в качестве базы в server_settings_t.

В самом же server_settings_t делается метод max_parallel_connections, который содержит внутри static_assert. Этот static_assert ведет к ошибке компиляции, если в Traits запрещено использовать ограничение на количество подключений. Такой подход, имхо, ведет к более понятным сообщениям об ошибках, нежели отсутствие метода max_parallel_connections когда use_connection_count_limiter равен false.


Вместо заключения


RESTinio продолжает развивается по мере наших сил и возможностей. Некоторые планы по дальнейшему развитию есть. Но как-то углубляться в них не хочется из-за суеверных соображений. Уж очень жизненным оказывается афоризм про озвучивание планов и Господа Бога. Такое ощущение, что он срабатывает в 99% случаев :)


Что можно точно сказать, так это то, что мы внимательно прислушиваемся к пожеланиям. Если вам чего-то не хватает в RESTinio, то расскажите нам об этом. Либо прямо здесь, в комментариях, либо на GitHub-е через Issues.


HTTP-клиент в RESTinio?


Время от время мы сталкиваемся с сожалениями потенциальных пользователей о том, что RESTinio реализует только сервер, но не имеет функциональности HTTP-клиента.


Тут все просто. Мы делали RESTinio под конкретные сценарии использования. И это были сценарии использования RESTinio для реализации HTTP-входа в C++ приложения. Клиент нам не был нужен.


Вероятно, реализация клиента в RESTinio может быть добавлена.


Вероятно.


С определенностью сложно сказать, т.к. эту тему мы никогда глубоко не прорабатывали. Если бы кто-то рискнул профинансировать эту работу, то можно было бы всерьез за реализацию клиента взяться. Но за собственный счет мы этот объем просто не поднимем. Поэтому HTTP-клиента в RESTinio нет.


Bonus track: Так Boost.Beast-ом ли единым?


Действительно очень часто на просторах Интернета на вопрос "А что есть в C++ для реализации HTTP-сервера" отвечают Boost.Beast. К моему удивлению часто все еще вспоминают CROW, который уже несколько лет как мертв.


Какие-то другие варианты встречаются довольно редко. Хотя их не так уж и мало. Кроме нашего RESTinio имеет смысл упомянуть, как минимум, следующие разработки (в алфавитном порядке):



Ну и не забудем про возможности фреймворка POCO.


Так что есть из чего выбирать. И, если вам не нужна экстремальная производительность и тотальный контроль за всеми аспектами, плюс вы хотите обойтись минимумом усилий, то есть смысл сперва рассмотреть альтернативы Boost.Beast. Потому что Beast, при всех своих достоинствах, слишком уж низкоуровневый.

Теги:
Хабы:
Всего голосов 13: ↑13 и ↓0+13
Комментарии9

Публикации

Истории

Работа

Программист C++
104 вакансии
QT разработчик
4 вакансии

Ближайшие события

15 – 16 ноября
IT-конференция Merge Skolkovo
Москва
22 – 24 ноября
Хакатон «AgroCode Hack Genetics'24»
Онлайн
28 ноября
Конференция «TechRec: ITHR CAMPUS»
МоскваОнлайн
25 – 26 апреля
IT-конференция Merge Tatarstan 2025
Казань