Увидело свет очередное обновление небольшой библиотеки для встраивания асинхронного HTTP-сервера в C++ приложения: RESTinio-0.6.12. Хороший повод рассказать о том, как в этой версии с помощью C++ных шаблонов был реализован принцип "не платишь за то, что не используешь".
Заодно в очередной раз можно напомнить о RESTinio, т.к. временами складывается ощущение, что многие C++ники думают, что для встраивания HTTP-сервера в современном C++ есть только Boost.Beast. Что несколько не так, а список существующих и заслуживающих внимания альтернатив приведен в конце статьи.
О чем речь пойдет сегодня?
Изначально библиотека RESTinio никак не ограничивала количество подключений к серверу. Поэтому RESTinio, приняв очередное новое входящее подключение, сразу же делала новый вызов accept() для принятия следующего. Так что если вдруг на какой-то RESTinio-сервер придет сразу 100500 подключений, то RESTinio не заморачиваясь постарается принять их все.
На такое поведение до сих пор никто не жаловался. Но в wish-list-е фича по ограничению принимаемых подключений маячила. Вот дошли руки и до нее.
В реализации были использованы C++ные шаблоны, посредством которых выбирается код, который должен или не должен использоваться. Об этом-то мы сегодня и поговорим.
шаблонного класса, который параметризуется типом 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 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 имеет смысл упомянуть, как минимум, следующие разработки (в алфавитном порядке):
- C++ REST SDK от Microsoft;
- drogon;
- Lithium (бывший Silicon Framework);
- oat++;
- Pistache;
- proxygen от Facebook;
- RestBed;
- serverd;
- Simple-Web-Server.
Ну и не забудем про возможности фреймворка POCO.
Так что есть из чего выбирать. И, если вам не нужна экстремальная производительность и тотальный контроль за всеми аспектами, плюс вы хотите обойтись минимумом усилий, то есть смысл сперва рассмотреть альтернативы Boost.Beast. Потому что Beast, при всех своих достоинствах, слишком уж низкоуровневый.
Источник: Хабр / Интересные публикации