Русский
Русский
English
Статистика
Реклама

Http-сервер

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

12.11.2020 10:07:10 | Автор: admin

Увидело свет очередное обновление небольшой библиотеки для встраивания асинхронного 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, при всех своих достоинствах, слишком уж низкоуровневый.

Подробнее..

RESTinio-0.6.13 последний большой релиз RESTinio в 2020 и, вероятно, последний в ветке 0.6

29.12.2020 12:11:44 | Автор: admin


RESTinio это относительно небольшая C++14 библиотека для внедрения HTTP/WebSocket сервера в C++ приложения. Мы старались сделать RESTinio простой в использовании, с высокой степенью кастомизации, с приличной производительностью. И, вроде бы, пока что это получается.


Ранее здесь уже были статьи про RESTinio, но в них речь больше шла о том, что и как было сделано в потрохах библиотеки. Сегодня же хочется рассказать о том, что появилось в свежей версии RESTinio, и зачем это появилось. А так же сказать несколько слов о том, почему этот релиз, скорее всего, станет последним большим обновлением в рамках ветки 0.6. И о том, чего хотелось бы достичь при работе над веткой 0.7.


Кому интересно, милости прошу под кат.


Главная фича версии 0.6.13: цепочки из синхронных обработчиков


Главной целью, которую мы преследовали начиная в 2017-ом году проект RESTinio, было упрощение написания HTTP-точек входа в C++ приложения. И одним из способов такого упрощения было заимствование лучшего из того, что нас окружало. В частности, в RESTinio мы сделали аналог роутера запросов из ExpressJS. В итоге express_router стал чуть ли не наиболее востребованной из возможностей RESTinio.


Но в ExpressJS кроме роутера есть еще важная штука: middleware. И вот её-то мы изначально в RESTinio и не стали переносить.


Сперва эта функциональность нам была не нужна. Но по мере взросления RESTinio мы стали сталкиваться с ситуациями, в которых что-то похожее на middleware из ExpressJS было бы полезным. А раз так, то захотелось эти самые middleware поиметь и в RESTinio.


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


Цепочки из синхронных обработчиков


Итак, начиная с версии 0.6.13 обработчики запросов в RESTinio можно выстраивать в цепочки. И такие обработчики будут последовательно вызываться для обработки очередного запроса. Движение по цепочке от обработчика до обработчика происходит пока все они возвращают специальное значение not_handled. Если же какой-то из обработчиков возвращает accepted или rejected, то обработка запроса прекращается и оставшиеся в цепочке обработчики не вызываются.


Давайте представим себе, что прежде чем начать обрабатывать запрос, нам нужно сделать три действия:


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

Каждое из этих действий теперь может быть представлено в виде отдельного обработчика запросов:


auto incoming_req_logger(const restinio::request_handle_t & req){  ... // Логируем запрос.  // Разрешаем запустить следующий обработчик в цепочке.  return restinio::request_not_handled();  }auto mandatory_fields_checker(const restinio::request_handle_t & req){  ... // Выполняем нужные проверки.  if(!ok) {    // Отсылаем отрицательный ответ и прерываем цепочку.    return req->create_response(restinio::status_bad_request())      ...      .done(); // Здесь возвращается accepted.  }  // Разрешаем запустить следующий обработчик в цепочке.  return restinio::request_not_handled();  }auto permissions_checker(const restinio::request_handle_t & req){  ... // Проверяем пользователя и его права.  if(!ok) {    // Отсылаем отрицательный ответ и прерываем цепочку.    return req->create_response(restinio::status_unauthorized())      ...      .done(); // Здесь возвращается accepted.  }  // Разрешаем запустить следующий обработчик в цепочке.  return restinio::request_not_handled();}auto actual_processor(const restinio::request_handle_t & req){  ... // Основная обработка запроса.  return restinio::request_accepted();}

Для того, чтобы выстроить эти обработчики в цепочку мы должны сделать несколько шагов.


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


Итак, поскольку количество элементов в цепочке у нас строго фиксировано, то используем fixed_size_chain_t:


// Этот заголовочный файл нужно подключать явным образом.#include <restinio/sync_chain/fixed_size.hpp>...struct my_traits : public restinio::default_traits_t {  using request_handler_t = restinio::sync_chain::fixed_size_chain_t<4>;};

Во-вторых, саму цепочку обработчиков нужно сформировать и отдать серверу при старте:


restinio::run(restinio::on_this_thread<my_traits>()  .port(...)  .address(...)  .request_handler(    // Перечисляем обработчики в порядке их вызова.    incoming_req_logger,    mandatory_fields_checker,    permissions_checker,    actual_processor)  ...);

Вот, собственно, и все.


Почему цепочка из синхронных обработчиков?


RESTinio строился с прицелом именно на асинхронную обработку запросов. Но добавленные в версию 0.6.13 цепочки обработчиков отрабатывают только синхронно. Почему так?


Тут надо зайти издалека.


Начнем с того, что для RESTinio любой обработчик запроса выглядит как синхронный.


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


Соответственно, когда RESTinio вызывает на этой рабочей нити обработчик запроса, то нить, фактически, блокируется до тех пор, пока обработчик не завершит свою работу. Поэтому-то для RESTinio все обработчики синхронные. Как только вызванный обработчик возвращает управление назад, RESTinio получает возможность продолжить свою работу. При этом RESTinio, по большому счету, все равно, что возвратил обработчик: rejected или accepted.


Разница между синхронностью и асинхронностью важна для программиста, который пишет обработчик запроса. Программист может либо полностью сформировать ответ прямо внутри обработчика (т.е. выполнить create_response()...done()) и тогда обработка будет синхронной. Либо же может делегировать обработку на какой-то другой рабочий контекст, где в конце-концов и будет вызван done().


Так вот, суть в том, что когда обработчик запроса возвращает accepted, то RESTinio не знает, был ли запрос уже обработан полностью. Или же обработка запроса была кому-то делегирована. Или же часть обработки была выполнена сразу, а оставшаяся часть отложена на какое-то время.


Для RESTinio факт возврата accepted из обработчика запросов означает, что пользователь взял на себя ответственность за дальнейшую судьбу запроса. И RESTinio не может строить предположений о том, в каком состоянии находится запрос.


Теперь вернемся к цепочкам.


Цепочка выглядит для RESTinio всего как один обработчик. Собственно, показанный выше fixed_size_chain_t это объект, метод которого и вызывается RESTinio. А уже внутри этого метода происходит последовательный вызов заданных пользователем обработчиков. Сам RESTinio про этот последовательный вызов ничего не знает, для RESTinio никакой последовательности нет вообще.


Предположим, что один из обработчиков вернул не not_handled, не rejected, а accepted. В каком состоянии находится запрос?


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


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


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


Значит ли это, что внутри цепочки нельзя делегировать обработку кому-то еще?


Нет. Обработчик может делегировать обработку запроса на какую-то другую нить. Но после этого обработчик должен возвратить accepted и цепочка будет прервана.


Можно ли сделать цепочку из асинхронных обработчиков?


Есть ощущение, что можно. В принципе. Но в рамках работ над версией 0.6.13 и при сохранении совместимости в рамках ветки 0.6 у меня не получилось придумать такого способа. Есть одна смутная и не до конца оформившаяся идея, только вот она требует изменения API RESTinio.


Обмен данными между обработчиками в цепочке


Выстраивание обработчиков в цепочку дело не хитрое. Гораздо сложнее организовать обмен данными между стадиями обработки запросов.


Давайте представим себе, что у нас есть следующая цепочка обработчиков:


  • authentification_handler, который проверяет наличие параметров аутентификации клиента и выполняет аутентификацию;
  • permissions_checker, который проверяет, есть ли у пользователя права на доступ к запрашиваемому ресурсу;
  • admin_access_logger, который логирует доступ пользователя к административным ресурсам;
  • actual_processor, который выполняет обработку запроса.

Первый обработчик должен породить некий объект user_permissions, в котором будет содержаться идентификатор пользователя и информация о его правах. Далее этот объект должен использоваться в permissions_checker-е (для проверки возможности доступа к ресурсу) и в admin_access_logger (для фиксации в журнале).


Соответственно, возникает вопрос, как созданный внутри authentification_handler объект сделать доступным в последующих обработчиках?


При поиске ответа на этот вопрос было рассмотрено несколько вариантов. В работу пошел вариант, который позволяет встроить пользовательские данные внутрь RESTinio-вского объекта request_t.


Выглядит это следующим образом.


Сперва пользователь определяет некую структуру/класс, экземпляры которой и должны встраиваться в объект-запрос:


struct user_permissions {...};...// Вот эта структура должна быть добавлена в каждый запрос.struct per_request_data {  user_permissions user_info_;  ... // Возможно, что-то еще.};

Далее нужно создать тип т.н. extra-data-factory, т.е. фабрики для этой самой дополнительной информации:


struct my_extra_data_factory {  // Внутри extra-data-factory должен быть тип с именем data_t.  using data_t = per_request_data;  // А также вот такой фабричный метод.  void make_within(restinio::extra_data_buffer_t<data_t> buf) {    new(buf.get()) data_t{};  }};

Затем нужно указать тип нашей фабрики в свойствах сервера:


struct my_traits : public restinio::default_traits_t {  using extra_data_factory_t = my_extra_data_factory;};

Ну и, самое, важное: теперь у обработчиков запросов поменяется формат. Вместо аргумента типа restinio::request_handle_t они будут получать restinio::generic_request_handle_t<per_request_data>:


restinio::request_handling_status_t authentification_handler(  const restinio::generic_request_handle_t<per_request_data> & req);restinio::request_handling_status_t permissions_checker(  const restinio::generic_request_handle_t<per_request_data> & req);restinio::request_handling_status_t admin_access_logger(  const restinio::generic_request_handle_t<per_request_data> & req);restinio::request_handling_status_t actual_processor(  const restinio::generic_request_handle_t<per_request_data> & req);

Собственно, это все.


Если наша фабрика не содержит внутри себя никаких данных и является DefaultConstructible типом, то ее экземпляр при запуске сервера даже создавать не нужно она будет создана автоматически. Но вот если фабрика представляет из себя stateful-объект, который требует инициализации, то пользователю придется создать ее самостоятельно. Например:


// Пусть у каждого запроса будет собственный поток для журналирования.struct per_request_data {   std::shared_ptr<log_stream> log_;   per_request_data(std::shared_ptr<log_stream> log)      : log_{std::move(log)}   {}};// За создание этих потоков будет отвечать фабрика.class my_extra_data_factory {   std::shared_ptr<logger> logger_;public:   using data_t = per_request_data;   my_extra_data_factory(std::shared_ptr<logger> logger)      : logger_{std::move(logger)}   {}   void make_within(restinio::extra_data_buffer_t<data_t> buf) {      new(buf.get()) data_t{         std::make_shared<log_stream>(logger_)      };   }};struct my_traits : public restinio::default_traits_t {   using extra_data_factory_t = my_user_data_factory;};auto logger = std::make_shared<logger>(...);// Фабрику нужно будет вручную создать перед запуском сервера.restinio::run(restinio::on_thread_pool<my_traits>(16)   .port(...)   .address(...)   // Вот мы создаем фабрику и передаем её RESTinio.   .extra_data_factory(std::make_shared<my_user_data_factory>(logger))   .request_handler(...));

Внутри обработчика запросов доступ к дополнительным данным можно получить посредством метода extra_data у объекта generic_request_t:


restinio::request_handling_status_t authentification_handler(  const restinio::generic_request_handle_t<per_request_data> & req){  ... // Производим аутентификацию.  if(!ok) {    // Шлем отрицательный ответ.    return req->create_response(...)...done();  }  else {    // Сохраняем информацию о пользователе внутри запроса.    req->extra_data().user_info_ = user_permissions{...};    return restinio::request_not_handled();  }}restinio::request_handling_status_t permissions_checker(  const restinio::generic_request_handle_t<per_request_data> & req){  // Запрашиваем информацию о пользователе с предыдущего шага.  const auto & user_info = req->extra_data().user_info_;  ... // Работа с информацией о пользователе.}

Дополнительная информация, generic_request_t<Extra_Data> и совместимость со старым кодом


По сути, начиная с версии 0.6.13, RESTinio работает уже с двумя новыми типами: шаблонным классом generic_request_t<Extra_Data> и шаблонным псевдонимом generic_request_handle_t<Extra_Data> (который есть std::shared_ptr<generic_request_t<Extra_Data>>).


А для того, чтобы такое кардинальное нововведение не поломало ранее написанный код, старые имена request_t и request_handle_t теперь являются всего лишь псевдонимами для generic_request_t<no_extra_data_factory_t::data_t> и generic_request_handle_t<no_extra_data_factory_t::data_t>, где no_extra_data_factory_t это новый тип для фабрики по умолчанию.


В restinio::traits_t, restinio::default_traits_t и restinio::default_single_thread_traits_t именно no_extra_data_factory_t используется в качестве extra_data_factory_t. Поэтому старый код, который использует имена request_t и request_handle_t, сохраняет свою работоспособность и требует только лишь перекомпиляции.


extra-data и express-/easy_parser_router


Выше уже говорилось, что express_router, сделанный по мотивам ExpressJS, является одной из наиболее востребованных возможностей RESTinio. При этом express_router вводит собственный формат для обработчиков запросов. Соответственно, появление extra-data для запроса сказалось и на express_router-е.


Если программист хочет использовать extra-data с запросами, которые обрабатываются посредством express_router-а, то ему нужно явно указать express_router-у тип фабрики extra-data. Например:


struct my_extra_data_factory { ... };struct my_traits : public restinio::default_traits_t {  using extra_data_factory_t = my_extra_data_factory;  using request_handler_t = restinio::router::express_router_t<    restinio::router::std_regex_engine_t,    extra_data_factory_t>;};

Вот после этого первым аргументом в обработчик запроса для express_router вместо request_handle_t будет generic_request_handle_t<my_traits::extra_data_factory_t::data_t>.


Тоже самое относится и к easy_parser_router:


struct my_traits : public restinio::default_traits_t {  using extra_data_factory_t = my_extra_data_factory;  using request_handler_t = restinio::router::easy_parser_router_t<    extra_data_factory_t>;};

Зачем делать RESTinio-0.7?


Теперь можно сказать несколько слов о том, почему же время жизни ветки 0.6 подходит к концу и зачем начинать ветку 0.7.


Причин несколько.


Во-первых, RESTinio с самого начала базировался на библиотеке http-parser. Но теперь, кажется, эта библиотека остается без поддержки. Соответственно, какую бы замену для http-parser мы не приготовили (стороннюю или же собственную), это скажется на списке зависимостей RESTinio. Что является достаточным поводом, чтобы сменить номер версии.


Во-вторых, в коде RESTinio уже обнаружилось несколько просчетов, которые было бы желательно исправить. Но исправления поломали бы совместимость, поэтому пока что эти исправления не вносились. Однако, рано или поздно с этим нужно было бы что-то делать. Так почему бы не сейчас?


В-третьих, есть ряд хотелок, воплощение которых в жизнь, как представляется, слишком затратно в рамках ветки 0.6 с оглядкой на совместимость. К таким хотелкам можно отнести следующие:


  • поддержка не только http/1.1, но и http/2, а затем и http/3;
  • дополнительный режим работы, в котором RESTinio не загружает весь запрос в память перед вызовом обработчика, а вызывает обработчик по мере загрузки отдельных частей запроса;
  • поддержка цепочки асинхронных обработчиков.

Как по мне, так вместе все это образует достаточно увесистый набор причин для того, чтобы оставить ветку 0.6 как есть и пойти вперед.


А есть ли у вас какие-то пожелания к RESTinio?


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


Могу уверить, что любые конструктивные соображения, замечания и предложения будут самым внимательным образом рассмотрены и приняты к сведению.


Сразу скажу, что клиента на базе RESTinio мы за свой счет не потянем. Об этом не просите :( Если только просьба не будет подкреплена материально ;)


В общем, приглашаю всех желающих высказать свои соображения о функциональности, которую хотелось бы видеть в RESTinio, в Issues или Discussions на GitHub. Или в Google-группу. Ну или можно прямо сюда, в комментарии.


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


2020-й год подходит к концу. Год был, мягко говоря, непростой. Тем не менее, RESTinio живет и развивается. Если не ошибаюсь, нам удалось выкатить порядка десятка релизов и опубликовать здесь несколько посвященных RESTinio статей.


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


Так что хочу сказать большое спасибо всем, кто интересуется RESTinio. Ваше внимание очень важно для нас.


И, конечно же, огромное спасибо всем, кто использует RESTinio. Без вас у этого проекта бы не было развития.


Ну и с наступающим Новым Годом!

Подробнее..

Проект arataga реальный пример использования SObjectizer и RESTinio для работы с большим количеством HTTP-соединений

18.01.2021 12:13:17 | Автор: admin

В последние 4.5 года я много рассказывал на Хабре про такие OpenSource проекты, как SObjectizer и RESTinio. Но вот об использовании SObjectizer и/или RESTinio в реальных проектах пока еще ни разу не удавалось поговорить (была лишь одна статья от стороннего автора).

Причина простая: мы не можем обсуждать те проекты, в которых мы сами применяли SObjectizer/RESTinio, ибо NDA. Равно как и не можем рассказывать о тех чужих проектах, о которых узнали в частном общении. Так что с наглядными примерами использования SObjectizer/RESTinio в реальной жизни всегда была напряженка.

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

К счастью или к несчастью, но далеко не самый удачный 2020-й год предоставил нам возможность показать как же выглядит реальный проект, в разработке которого SObjectizer и RESTinio активно используются. И в данной статье я попробую рассказать о том, как и для чего SObjectizer и RESTinio применяются в arataga, исходники которого можно найти на GitHub.

Тем, кто хочет больше узнать об arataga и причинах его появления на GitHub-е, рекомендую прочитать этот блог-пост. Здесь же в двух словах скажу лишь, что arataga -- это socks5+http/1.1 прокси-сервер, который затачивался под использование в следующих условиях (перечислены те из них, которые актуальны с точки зрения внутренней архитектуры arataga):

  • много точек входа, счет идет на тысячи (8 тысяч нужно было поддерживать сразу). У каждой точки входа уникальное сочетание IP+port;

  • подключения на одну точку входа могут идти с темпом в несколько десятков в секунду, параллельно на одной точке входа могут "висеть" сотни подключений;

  • одновременно могут существовать десятки тысяч подключений, общий темп появления новых подключений на всех точках входа может быть от 1500 в секунду;

  • аутентификация клиентов должна укладываться в единицы миллисекунд;

  • обновленная конфигурация и новые списки пользователей, которым разрешена работа с прокси-сервером, периодически доставляется через HTTP POST на специальный административный HTTP-вход.

Далее в статье я сосредоточусь на архитектурных и технических моментах, не затрагивая тему "зачем это все было нужно?"

Вероятно, для того, чтобы лучше понимать изложенное ниже, нужно иметь общие представления о SObjectizer-овских агентах, диспетчерах и почтовых ящиках (mbox-ах). Старая обзорная статья может в этом помочь (хотя сам SObjectizer за это время несколько изменился).

Тезисно о принятых проектных и архитектурных решениях

Многопроцессность или многопоточность?

Прежде всего нам предстояло сделать выбор между реализацией arataga в виде мультипроцессного или мультипоточного приложения.

В случае мультипроцессного приложения потребовалось бы иметь процесс-родитель, который бы запускал N дочерних процессов-исполнителей. Процесс-родитель отвечал бы за управление дочерними процессами и за распределение конфигурации/списков пользователей между дочерними процессами. Дочерние же процессы открывали бы точки входа и обслуживали бы входящие соединения от клиентов и исходящие к целевым хостам.

Каждый процесс при этом мог бы быть простым однопоточным процессом (может быть за исключением родительского, в котором могло бы потребоваться больше одного рабочего потока).

Достаточно простая и надежная схема, почти что в духе unix way. Но связываться с ней не очень хотелось из-за большого количества IPC-взаимодействия между родительским и дочерними процессами: в одну сторону должна идти управляющая информация, в другую -- диагностическая.

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

Плюс к тому, у нас в руках был большой молоток, SObjectizer, с помощью которого разрабатывать многопоточные приложения гораздо проще, чем при ручной работе с std::thread, std::mutex и std::condition_variable и т.п. Если выразить основные сущности приложения в виде SObjectizer-овских агентов, распределить этих агентов должным образом по рабочим нитям и организовать их взаимодействие на базе асинхронных сообщений, то ужасы многопоточности можно спокойно обойти стороной. Что, собственно, в очередной раз и произошло.

Сколько рабочих потоков нужно?

Здесь все было понятно изначально, еще даже до начала разработки arataga, поскольку старый прокси-сервер, на замену которого arataga и разрабатывался, использовал модель thread-per-connection. Когда счет одновременно живущих соединений идет на десятки тысяч, то thread-per-connection не есть хорошо.

Поэтому об использовании в arataga схемы thread-per-connection речь не шла вообще. Сразу же был взят курс на применение чего-то похожего на thread-per-core.

Было решено под обслуживание подключений отводить не все доступные вычислительные ядра, а использовать формулу (nCPU-2), где nCPU -- это количество ядер.

Так, если на машине 8 ядер, то в arataga будет создано 6 рабочих потоков, которые будут отвечать за обслуживание соединений. Т.е. работой с трафиком будут заняты 6 ядер. Два оставшихся ядра будут доступны для других рабочих потоков внутри arataga. Плюс и самой ОС нужно что-то оставить, а то не очень приятно удаленно подключаться к работающему серверу по ssh, когда у него все ядра загружены под 100%

Что будет агентом, а что не будет?

Изначально в работе arataga выделялось несколько типов операций, которые должны были выполняться на разных рабочих контекстах:

  • разбор конфигурации. Обработка конфигурации для нескольких тысяч точек входа может занимать некоторое время (например, 5ms). Сюда входит и парсинг/валидация конфигурации, и сохранение ее локальной копии, и удаление устаревших точек входа, которых нет в новой конфигурации, и создание новых точек входа, добавленных в обновленной конфигурации;

  • разбор списка пользователей и формирование структур для представления этого списка в памяти. Это также может занимать некоторое время, поскольку списки пользователей могут насчитывать несколько десятков тысяч строк;

  • обработка точек входа. Т.е. создание серверных сокетов на заданных IP+port, прием новых подключений с учетом ограничений на максимальное количество параллельных подключений и т.д.;

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

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

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

Но этот подход меня лично не вдохновлял по нескольким причинам.

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

Во-вторых, не очень понятно, в чем смысл использовать здесь агента. Взаимодействие с сетью предполагалось делать с использованием Asio и асинхронных операций. Т.е., если вызывается Asio-шный async_read_some, то когда-то Asio вызовет для этой операции completion_handler. И если мне нужно передать результат async_read_some агенту, то в completion_handler нужно будет отослать агенту сообщение, которое агент обработает. Что, вообще-то говоря, не бесплатно, т.к. сперва на каком-то io_context будет запланирован вызов completion_handler, а затем, когда до completion_handler дойдет очередь, будет запланирован вызов обработчика события у агента. И только затем когда-то будет вызван этот самый обработчик. Что наводит на мысль "а не слишком ли много приходится платить за концептуальную чистоту?" Тем более, что по прошлому опыту я знал, что когда операции чтения/записи сокета доставляются до агента в виде сообщений, то как-то кардинально это работу с сетью не упрощает. Может быть даже наоборот.

Добавим сюда еще и то, что регистрация/дерегистрация агентов в SObjectizer все-таки не самая дешевая операция (это связано с механизмом коопераций и гарантиями транзакционности операции регистрации). Конечно, если принимать новые подключения с темпом в 2000-3000 в секунду, то "тормоза" SObjectizer-а здесь не проявятся, SObjectizer может регистрировать/дерегистрировать кооперации с гораздо более высоким темпом. Но все-таки у частого создания/удаления агентов есть своя цена, и если ее можно не платить, то лучше ее не платить.

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

Для чего планировалось использовать RESTinio?

Изначально предполагалось, что посредством RESTinio будет реализован административный HTTP-вход. Причем не в виде SObjectizer-овского агента, а просто единственная отдельная нить, на которой будет работать RESTinio-сервер.

Библиотека RESTinio, действительно, была использована таким очевидным образом. А вот о паре менее очевидных способов применения RESTinio в arataga речь пойдет ниже.

Погрузимся в подробности

Некоторые детали использования SObjectizer

В этой части бегло пройдемся по некоторым моментам использования SObjectizer в коде arataga. Выбор чисто субъективный, мне показалось, что именно они наиболее показательны и/или не обычны.

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

Агент startup_manager и "глобальный таймер"

Работа arataga начинается с запуска агента startup_manager. Этот агент отвечает за последовательный запуск основных "компонентов" arataga: сперва стартует агент user_list_processor, затем агент config_processor, затем уже запускается отдельная нить с RESTinio-сервером для административного HTTP-входа.

Каждый из запускаемых startup_manager агентов должен затем подтвердить свой успешный запуск. Если этого не произошло за отведенное время, то startup_manager прерывает работу приложения вообще.

В агенте startup_manager можно обнаружить один непривычный трюк: запуск при старте периодического сообщения one_second_timer_t, которое отсылается раз в секунду на специальный mbox.

В какой-то мере это преждевременная оптимизация.

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

Обычно это выполняется посредством отложенного сообщения: агент отсылает самому себе отложенное на 1 секунду сообщение, а когда оно приходит, то проверяет времена жизни подключений, времена отсутствия активностей в них и т.д. После чего шлет себе следующее отложенное сообщение. Или же вместо перепосылки отложенных сообщений один раз запускает периодическое сообщение.

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

Ну и зачем, спрашивается, это делать, если достаточно всего одного сообщения, которое будет отсылаться в отдельный mbox один раз в секунду? И на которое смогут подписаться столько получателей, сколько потребуется.

Вот как раз для того, чтобы каждый агент, обслуживающий точку входа, не запускал свое собственное периодическое сообщение для контроля тайм-аутов, агент startup_manager делает это посредством one_second_timer_t.

Понятие io_thread и ее реализация на базе asio_one_thread-диспетчера

Выше говорилось, что в реализации arataga было решено придерживаться модели, похожей на thread-per-core, при этом (nCPU-2) рабочих потока будет выделено под выполнение операций с сетью. Такие рабочие нити получили условное название io_thread (далее io_thread == "I/O нить" и поэтому "io_thread" будет иметь женский род).

Т.к. работа с сетью в arataga работа идет посредством Asio, то для следования идее thread-per-core было решено сделать так, чтобы на каждой io_thread работал свой собственный экземпляр asio::io_context.

Т.е. нам нужна была рабочая нить, которая создает экземпляр asio::io_context, а затем вызывает для него run(). После чего на этой нити нужно было бы еще как-то разместить и агентов, реализующих точки входа. И чтобы одна и та же нить обслуживала и операции Asio, и события SObjectizer-овских агентов.

В arataga для этих целей просто используются экземпляры asio_one_thread-диспетчера из so5extra. Каждый такой диспетчер -- это отдельный рабочий поток (т.е. io_thread).

Агент config_processor, о котором речь пойдет ниже, создает N экземпляров asio_one_thread-диспетчеров, а затем просто привязывает к этим экземплярам агентов, обслуживающих точки входа.

Агенты authentificator и dns_resolver, их дублирование на каждой io_thread

Подключающихся к arataga клиентов нужно аутентифицировать. Эта задача была поручена отдельному агенту authentificator. Когда агенту, обслуживающему точку входа, нужно аутентифицировать клиента, то отсылается сообщение агенту authentificator, который выполняет аутентификацию и шлет назад результат аутентификации.

Еще агентам, которые обслуживают точки входа, нужно выполнять резолвинг доменных имен. За эту операцию отвечает еще один специальный агент -- dns_resolver. Тут используется подобная схема: агент-точка-входа отсылает агенту dns_resolver сообщение, а тот присылает назад результат поиска доменного имени.

Мне показалось, что если уж мы пытаемся следовать модели thread-per-core, то логичным было бы сделать отдельную копию агентов dns_resolver и authentificator для каждой из io_thread. Поэтому эти агенты создаются сразу же вслед за очередным asio_one_thread-диспетчером и привязываются к только что созданному диспетчеру.

Распределение агентов по диспетчерам

В результате получилась следующая картинка:

У агентов config_processor и user_list_processor есть собственные рабочие потоки, которые реализуются посредством штатного диспетчера SObjectizer под названием one_thread.

Каждая io_thread представлена отдельным диспетчером asio_one_thread из so5extra. И на каждом таком диспетчере работает сразу несколько (десятков, сотен, тысяч) агентов acl_handler.

Агент config_processor

Агент config_processor отвечает за обработку конфигурации arataga, поддержание списка существующих точек входа, их распределение между io_threads, и за создание/удаление точек входа при изменении конфигурации.

При своем старте config_processor пытается прочитать локальную копию конфигурационного файла. Если это удалось, то config_processor принимает поднятую из конфига информацию за текущую конфигурацию и создает описанные в ней точки входа (регистрирует агентов acl_handler).

Если же локальной копии конфигурации нет или же прочитать ее не удалось, то config_processor ждет, пока придет новая конфигурация от административного HTTP-входа.

Когда от административного HTTP-входа поступает новая конфигурация, что после ее успешной обработки config_processor рассылает уведомления об изменении параметров arataga на отдельный multi-producer/multi-consumer mbox. Так изменения в конфигурации становятся доступны всем, кто в них заинтересован.

В работе config_processor с acl_handler есть важная особенность: сейчас в arataga агент acl_handler намертво привязывается к конкретному asio_one_thread и живет на этом asio_one_thread до тех пор, пока не будет удален.

Эта особенность может привести к ситуации, когда из конфигурации удалили, скажем, 1000 точек входа, и в результате на одной из io_threads осталось заметно меньше работающих агентов, чем на других io_threads.

Эта диспропорция может быть устранена при добавлении новых точек входа. В этом случае config_processor при создании новых агентов acl_handler будет привязывать их к тем диспетчерам asio_one_thread, на которых сейчас меньше всего живых acl_handler.

Но если новые точки входа не создаются, то диспропорция останется и config_processor не будет перераспределять старые acl_handler между диспетчерами asio_one_thread. По двум причинам:

  • во-первых, в SObjectizer нет возможности перепривязать агента с одного диспетчера на другой. Поэтому к какому диспетчеру агента изначально привязали, на таком он и будет продолжать работать;

  • во-вторых, в каждом агенте acl_handler может существовать множество Asio-шных объектов, завязанных на конкретный экземпляр asio::io_context. Если перемещать содержимое acl_handler с одной io_thread на другую, то нужно будет и переконструировать эти Asio-шные объекты.

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

Агент user_list_processor

Агент user_list_processor отвечает за работу со списком пользователей.

При своем старте user_list_processor пытается прочитать локальную копию списка пользователей. Если это удалось, то локальная копия становится актуальным списком. Если не удалось, то user_list_processor ждет поступления нового списка от административного HTTP-входа.

Когда от административного HTTP-входа прилетает новый список пользователей, то user_list_processor пытается его обработать. И, если это получается успешно, то обновленный список рассылается на отдельный multi-producer/multi-consumer mbox на которые подписаны агенты authentificator. Что позволяет authentificator-ам получать обновленные списки пользователей сразу после того, как user_list_processor завершит их обработку.

Агент acl_handler

Агент acl_handler является, наверное, самым сложным и объемным агентом в arataga (hpp-файл, cpp-файл). Его задача -- это создать серверный сокет для точки входа, принимать и обслуживать подключения к этому серверному сокету.

В связи с этим в рамках данной статьи можно выделить несколько аспектов в реализации acl_handler.

Во-первых, acl_handler инициирует асинхронные I/O операции с помощью Asio, в частности, вызывает async_accept у Asio-шного asio::ip::tcp::acceptor. В async_accept передается completion-handler в виде лямбды, где напрямую вызываются методы агента. Такие вызовы безопасны потому, что агент привязан к asio_one_thread диспетчеру. Этот диспетчер специально создан для того, чтобы на одном рабочем контексте можно было работать и с агентами, и с Asio. Если бы acl_handler привязывался к какому-то другому диспетчеру (например, к штатному one_thread-диспетчеру), то этого делать было бы нельзя.

Во-вторых, логика у acl_handler достаточно тривиальная, но даже и здесь нашлось место для применения иерархического конечного автомата (которые в SObjectizer-е уже довольно давно поддерживаются):

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

В-третьих, агент acl_handler не занимается сам обслуживанием принятых подключений и обработкой протоколов socks5/http. Вместо этого для каждого соединения создается т.н. connection_handler. Это объект, в который отдается принятое подключение и который уже с этим подключением непосредственно работает. Т.е. читает из него данные, пытается определить протокол, пытается обслуживать подключение согласно того или иного протокола.

Т.е. когда в результате async_connect у acl_handler появляется новый asio::ip::tcp::socket, то создается объект, реализующий интерфейс connection_handler_t, и новый socket отдается этому только что созданному connection_handler-у. Больше про новое подключение acl_handler ничего не знает. Далее acl_handler лишь хранит у себя умный указатель на connection_handler и лишь время от времени дергает у connection_handler-а метод on_timer (тот самый "глобальный таймер" о котором речь шла выше). А вот connection_handler дальше живет своей собственной и весьма непростой жизнью.

Вообще говоря, как раз тот кусок arataga, который относится к connection_handler-ам, является самым мудреным в проекте. Вероятно, далеко не самым удачным. И, скорее всего, именно он мог бы стать первым кандидатом на рефакторинг по итогам опытной эксплуатации. Поскольку первая версия arataga создавалась в высоком темпе, в условиях сжатых сроков, то ничего лучше сходу придумать не удалось. Возможно, здесь следовало бы применить stackful-короутины. Но экспериментировать с ними не было времени. Так что, если история с arataga получит продолжение (что вряд ли, но вдруг), к этому куску arataga нужно будет сделать еще один, более вдумчивый подход. Пока что получилось то, что получилось :(

Использование retained_mbox для распространения конфигурации

Примечательным моментом использования SObjectizer-овских mbox-ов в arataga является применение retained_mbox из so5extra для распространения информации о текущей конфигурации и списках пользователей.

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

В arataga это свойство retained_mbox используется для того, чтобы агенты authentificator сразу же могли получить текущий список пользователей. Сценарий работы такой:

  • стартует агент user_list_processor. Он читает локальную копию списка пользователей и отсылает сообщение с этим списком в retained_mbox. На этом этапе подписчиков у retained_mbox еще нет, но нас это не волнует;

  • затем стартует агент config_processor, который читает локальную копию конфигурации и создает io_threads вместе с authentificator-ами;

  • каждый запущенный authentificator должен получить список пользователей, для чего authentificator-ы подписываются на соответствующее сообщение из retained_mbox-а;

  • поскольку в retained_mbox уже есть сообщение со списком пользователей, то это сообщение сразу же отсылается каждому новому подписчику.

Благодаря этому authentificator-у при старте не нужно ни у кого ничего явно запрашивать.

Правда, платить за это нужно тем, что authentificator вынужден делать подписку в so_evt_start, а не в предназначенном для этого so_define_agent.

Некоторые детали использования RESTinio

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

Первое применение RESTinio, самое очевидное: административный HTTP-вход

Очевидное применение, ради которого RESTinio и подтягивался в проект arataga, -- это реализация административного HTTP-входа. Как раз здесь простота асинхронной обработки входящих запросов посредством RESTinio и была нужна с самого начала.

В этом месте RESTinio используется классическим способом. Запускается RESTinio-сервер, этот сервер принимает входящие запросы, выполняет их первичную валидацию. После чего пересылает запрос во внутрь arataga и ждет ответа. Когда ответ сформирован, то RESTinio отсылает его клиенту.

Упомянуть здесь можно разве лишь то, что для взаимодействия RESTinio- и SObjectizer-частей приложения были сделаны вспомогательные интерфейсы (раз, два). Когда startup_manager запускает RESTinio-сервер, то отдает серверу реализацию интерфейса requests_mailbox_t. RESTinio-сервер через этот интерфейс передает в SObjectizer-часть arataga входящие запросы. И каждый входящий запрос сопровождается реализацией интерфейса replier_t. Посредством этого интерфейса реальные обработчики запроса могут отвечать RESTinio-серверу.

Два эти интерфейса скрывают от RESTinio-части существование SObjectizer-части arataga и наоборот. Что мне показалось полезным. Хотя бы плане сокращения времени компиляции отдельных cpp-файлов.

Второе применение RESTinio, не очевидное, но вполне ожидаемое: работа с HTTP-заголовками при проксировании HTTP-соединений

Второе применение стало для меня лично неожиданным, хотя оно было вполне себе логичным. Дело в том, что применить RESTinio в его текущем виде для проксирования HTTP-соединений не представляется возможным в силу нынешних архитектурных особенностей RESTinio.

Поэтому работу с проксируемыми HTTP-соединениями в arataga пришлось делать "с нуля". И одной из составляющей этой работы является разбирательство с HTTP-заголовками в запросах/ответах, а также с содержимым некоторых из них.

И вот когда у меня руки дошли до обработки HTTP-заголовков в проксируемых соединениях, то я внезапно (c) осознал, что в RESTinio уже есть инструментарий для этого. Чем и воспользовался, хотя изначально о таком даже и не думал.

Третье применение RESTinio, совсем не очевидное: синтаксический разбор конфигурации

А вот третье, совсем не очевидное, применение RESTinio не то, чтобы было сразу мной задумано. Но такой вариант начал просматриваться чуть ли не с самого начала.

В arataga нужно было парсить два типа конфигурационных файлов (собственно конфигурация и список пользователя). У каждого из которых был свой, уже устоявшийся к тому времени синтаксис.

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

acl.io.chunk_size 16kib # Вместо 16384timeout.authentification 1200ms # Вместо 1200timeout.connect_target 7s # Вместо 7000

Ну и для того, чтобы не делать парсинг конфигов вручную только штатными средствами стандартной библиотеки C++ (очень куцей в этом плане), нужно было задействовать какой-то генератор парсеров. А зачем тащить в проект еще что-то, если нечто подобное уже есть в RESTinio?

Так что easy_parser из RESTinio, который изначально появился в RESTinio для упрощения разбора HTTP-заголовков, пригодился в arataga и для разбора конфигурации.

Любопытный, на мой взгляд, пример использования easy_parser-а из RESTinio можно найти здесь. Именно этот transfer_speed_p() используется для реализации команд конфигурации вроде:

bandlim.in 850kibbandlim.out 700kib

Заключение

Реальные проекты, подобные arataga, являются отличным полигоном, на котором разработчик библиотеки может увидеть насколько удобна его библиотека вне тепличных условий уютного манямирка. Как правило, при использовании библиотеки в новом проекте отлично проявляются просчеты в дизайне библиотеки и/или же обнажаются подводные камни, о которых ты пока что даже и не подозревал. Из чего затем вырастают вполне себе конкретные идеи о том, куда следует двигаться дальше.

С RESTinio это сработало на 100%. Опыт применения RESTinio в arataga дал пинка под за толчок в сторону разработки пусть приблизительного, но аналога middleware из ExpressJS. Первый результат уже доступен в 0.6.13. И, если пойти на слом API в ветке 0.7, то можно будет поддержать и цепочки асинхронных обработчиков.

Но еще более важным итогом использования RESTinio в arataga стало изменение лично моих взглядов на позиционирование RESTinio в мире аналогичных фреймворков для C++.

Так что для RESTinio испытание arataga оказалось исключительно полезным.

А вот с SObjectizer-ом получилось скучно и обыденно. Он просто работал. Ничего не пришлось подкручивать или добавлять. Кой-какие мысли о потенциально привлекательных направлениях дальнейшего движения появились. Но пока не вполне оформившиеся и не имеющие однозначно четкого подтверждения того, что это реально необходимо делать.

Я же надеюсь, что данная статья даст читателям, которые с любопытством посматривают на RESTinio и/или SObjectizer(+so5extra), но пока что сами эти проекты не пробовали, возможность взглянуть на реальный код, написанный с применением наших инструментов. И сделать собственные выводы о том, нравится ли увиденное или нет.

Что касается перспектив самих RESTinio и SObjectizer/so5extra, то тут все не так однозначно и без внешней поддержки, боюсь, их развитие приостановится. Впрочем, это уже совсем другая история

Подробнее..

Развитие проекта arataga пара рефакторингов по результатам натурных испытаний

27.05.2021 10:19:54 | Автор: admin

OpenSource-проект arataga -- это работающий прототип производительного socks5+http/1.1 прокси-сервера. Реализован arataga на базе Asio, SObjectizer и RESTinio. Об arataga уже рассказывалось несколько месяцев назад именно как о хорошем примере того, как выглядит реальный код на SObjectizer-е. Ведь одно дело повествовать о сильных сторонах SObjectizer-а с иллюстрациями из игрушечных примеров. Совсем другое -- иметь возможность показать почти что продакшен-код.

За время, прошедшее с первой публикации, удалось погонять arataga под более серьезной нагрузкой. Можно сказать, повезло провести первые натурные испытания.

Эти испытания выявили пару узких мест, устранение которых, как мне показалось, можно привести в качестве хороших примеров применения возможностей SObjectizer-а в ситуациях "вот здесь не то, что нам хотелось бы, и это нужно как можно быстрее исправить".

В частности, мы сегодня поговорим о том, что взаимодействие агентов друг с другом только посредством асинхронных сообщений -- это не догма. И что диспетчеры SObjectizer-а влияют на безопасность многопоточного программирования не менее серьезно, чем этот самый обмен сообщениями.

Собственная реализация взаимодействия с DNS

Первым узким местом, которые выявили натурные испытания, стала процедура резолвинга доменных имен.

Отказ от Asio-шного async_resolve

В первой версии arataga ради экономии времени мы не стали делать реализацию взаимодействия с DNS посредством UDP. Вместо этого использовали средства для асинхронного резолвинга, которые доступны в Asio из коробки.

Как я понял, Asio для выполнения async_resolve использует дополнительную рабочую нить, на которой делает обычные синхронные обращения к ОС для преобразования доменного имени в набор IP-адресов. И, если одно такое обращение "притормозит", то будут приостановлены и все последующие обращения к async_resolve. Кроме того, если нам нужно выполнить резолвинг сразу нескольких имен, то параллельно мы этого сделать не можем. Резолвинг будет выполняться последовательно.

В итоге, когда потребовалось обслуживать множество параллельных преобразований доменных имен в IP-адреса Asio-шный async_resolve буквально "вставал колом". Некоторым клиентам результат операции async_resolve приходилось ждать по несколько десятков секунд (максимальные значения, насколько я помню, колебались в районе от 35 до 40 секунд, в зависимости от нагрузки).

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

Работа с DNS на уровне агентов: было и стало

Не думаю, что есть смысл рассказывать о том, как именно было реализовано общение с DNS-серверами посредством UDP-датаграмм, поскольку вся эта работа сосредоточена внутри одного агента a_namesever_interactor_t и никто деталей этой работы не видит.

Гораздо интереснее рассказывать о том, как резолвинг доменных имен представлен на уровне взаимодействия агентов в arataga.

Первоначальная схема была тривиальной: был некий интерфейсный почтовый ящик (mbox) и два сообщения -- resolve_request_t и resolve_reply_t. За этим почтовым ящиком скрывался единственный агент a_dns_resolver_t, который и отвечал за процедуру резолвинга доменных имен: держал кэш уже обработанных имен, организовывал список ждущих своей очереди доменных имен, дергал async_resolve и обрабатывал результаты резолвинга.

Важный момент, на который хотелось бы обратить внимание -- это то, что само существование dns_resolver было неизвестно другим частям arataga. Механизм резолвинга доменных имен был практически в буквальном смысле "черным ящиком". Просто есть некий mbox, в который нужно отсылать сообщения resolve_request. В каждом resolve_request передается mbox, на который затем прилетает resolve_reply. И это все, что требовалось знать.

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

Новая же реализация потребовала двух разных агентов вместо одного.

Во-первых, это агент interactor::a_nameserver_interactor_t, который непосредственно общается с DNS-серверами по UDP. Агент nameserver_interactor отвечает за преобразование конкретного запроса на резолвинг в соответствующую UDP-датаграмму и обработку ответов от DNS-серверов.

Во-вторых, это агент lookup_conductor::a_conductor_t, который принимает входящие resolve_request, проверяет наличие результатов в кэше, выстраивает в очередь запросы, для которых нужен резолвинг, и отправляет команды агенту nameserver_interactor.

А вот с conductor все не так просто :)

И самое любопытное здесь то, что на самом деле conductor-ов два.

Первоначально планировалось использовать всего один экземпляр conductor. Который бы обрабатывал и IPv4, и IPv6. По аналогии с тем, как это делал dns_resolver из первых версий arataga: dns_resolver в случае успешного результата получал список IP-адресов, в котором присутствовали и IPv4, и IPv6 адреса. И когда приходил resolve_request_t для получения IPv4 адреса, то dns_resolver выбирал из списка IPv4 адрес. А когда приходил resolve_request для IPv6 адреса, то dns_resolver искал в списке IPv6 адрес (либо же конвертировал в IPv6 один из IPv4 адресов).

Такая схема была принята потому что Asio-шный async_resolver мог вернуть в случае успеха список с двумя типами адресов.

И при рефакторинге взаимодействия с DNS-серверами предполагалось, что таким же образом будет работать и nameserver_interactor.

Но в процессе тестирования nameserver_interactor с реальными DNS-серверами выяснилось, что работает либо запрос ресурсной записи типа A, либо запрос ресурсной записи типа AAAA. Но вот если отослать в одной UDP-датаграмме сразу два запроса (и для A, и для AAAA), то ответ придет только на один из них.

Посему имея на руках почти готовые реализации conductor и nameserver_interactor нужно было что-то оперативно решать. Понятно, что при обращении к nameserver_interactor придется явно указывать тип IP-адреса (IPv4 или IPv6). Но не понятно, как быть с кэшированием результатов и выстраиванием запросов в очереди.

Усложнять conductor и делать в нем отдельные кэши/очереди для разных типов IP-адресов? Вести общий кэш, в котором будут хранится адреса обоих типов и инициировать запрос к nameserver_interactor, если адресов нужного типа в кэше нет?

Тратить дополнительное время на рефакторинг conductor не хотелось, поэтому в модном и молодежном стиле "фигак-фигак и в продакшен" был применен метод грубой силы: вместо одного conductor-а запускается сразу два. Один обслуживает запросы для IPv4, второй -- запросы для IPv6. И оба они подписываются на сообщения resolve_request из интерфейсного mbox-а.

Вопрос был лишь в том, чтобы разрулить поток resolve_request между этими двумя агентами. Нужно был как-то сделать так, чтобы один агент получал только запросы для IPv4, а второй -- только запросы для IPv6.

Сделать это оказалось совсем не сложно. Были применены фильтры доставки для сообщений.

Фильтры доставки -- это предикаты, которые агент может "навесить" на сообщения из конкретного multi-consumer mbox-а. Когда сообщение отсылается в mbox, то mbox обращается к предикату с вопросом "можно ли доставлять конкретно этот экземпляр сообщения до твого владельца?" Если предикат говорит, что можно, то сообщение доставляется. Если нет, то игнорируется.

Благодаря фильтрам доставки агента conductor не пришлось переделывать для того, чтобы запускать его в двух экземплярах. Все, что потребовалось сделать -- это добавить установку фильтра перед подпиской:

voida_conductor_t::so_define_agent(){// We want to receive only requests for our IP-version.so_set_delivery_filter(m_incoming_requests_mbox,[ip_ver = m_ip_version]( const resolve_request_t & req ) {return ip_ver == req.m_ip_version;} );so_subscribe( m_incoming_requests_mbox ).event( &a_conductor_t::on_resolve );

Каков результат этих изменений?

Главный результат -- это возможность параллельного выполнения множества операций резолвинга имен без формирования длинных очередей и долгого ожидания в этих очередях. В итоге по тайм-ауту отваливаются только те запросы, на которые не отвечают сами DNS-сервера. А это на практике происходит очень и очень редко. Так что количество соединений, не получивших обслуживания из-за тайм-аутов DNS, сократилось драматически.

Таймеры для acl_handler-ов

Первые запуски arataga под реальной нагрузкой показали, что потребление ресурсов у arataga не настолько низкое, как хотелось бы. Что и не удивительно, т.к. arataga все еще в стадии прототипа и под высокой тестов было совсем мало, так что узких мест там наверняка множество.

Одно из них оказалось прямо на поверхности, не пришлось даже глубоко копать.

Дело в том, что внутри arataga есть специальное сообщение one_second_timer, которое генерируется раз в секунду и которое используется acl_handler-ами для разных целей: и для контроля тайм-аутов текущих операций, и для пересчета лимитов по подключениям.

Дабы не тратить время при выпуске первой версии arataga была использована самая простая схема: каждый acl_handler просто подписывается на one_second_timer. Что означает, что когда это сообщение возникает, то оно доставляется всем подписчикам.

Т.е., если у нас создано 15K acl_handler-ов, то раз в секунду у них у всех запускается обработчик one_second_timer. Что и увеличивает потребление CPU.

Проблема в том, что далеко не у всех из этих 15K acl_handler-ов в настоящий момент есть необходимость реагировать на one_second_timer. У части acl_handler-ов не будет вообще подключений, которые нужно обслуживать. И доставлять one_second_timer до таких acl_handler-ов нет смысла.

Только вот если агент подписался на сообщение, то сообщение к нему будет доставлено. Даже если сейчас агент в нем и не заинтересован.

Поэтому встал вопрос о том, как сделать так, чтобы на таймер реагировали только те acl_handler-ы, которые в таймере сейчас заинтересованы. И сделать это дешево.

Какие подходы нельзя назвать хорошими?

Фильтры доставки

Можно было бы попробовать задействовать фильтры доставки (о которых речь уже шла выше). Так, фильтр доставки при отсылке сообщения проверял бы наличие принятых подключений у acl_handler-а и, если подключений нет, то запрещал бы доставку сообщения.

Тут сразу несколько проблем.

Во-первых, отсылка сообщения one_second_timer идет с контекста специальной нити таймера, которой управляет сам SObjectizer. И на этой же нити в процессе отсылки сообщения запускаются фильтры доставки. Следовательно, если фильтр доставки хочет обратиться к каким-то потрохам acl_handler-а, то эти потроха должны быть как-то защищены с точки зрения thread-safety. Например, посредством mutex-а. Что отнюдь не бесплатно. И затрудняет реализацию самого acl_handler-а, т.к. внутри агента нужно заботиться о thread-safety, хотя агенты как раз и нужны, чтобы такими вещами не заниматься.

Во-вторых, если у нас много acl_handler-ов, то запуск фильтров доставки для каждого них -- это ведь такая же лишняя работа, как и доставка one_second_timer. Да, это может быть чуть дешевле. Но именно что чуть.

Так что фильтры доставки, наверное, могли бы немного снизить актуальность проблемы, но точно не устранили бы ее.

Подписка/отписка на/от one_second_timer

Если нужно доставлять one_second_timer только до агентов, которые обслуживают принятые подключения, то напрашивается простой и логичный подход: пусть acl_handler при принятии первого подключения создает подписку на one_second_timer, а при потере последнего подключения -- отписывается от one_second_timer.

Это нормальный подход. По крайней мере он идеологически правильный и не ведет к каким-либо проблемам с thread-safety.

Но его нельзя назвать совсем уж дешевым. Ведь подписка на mbox -- это захват нескольких объектов синхронизации (в mbox-е и в агенте-подписчике) + несколько аллокаций памяти на создание объектов-подписок.

А хотелось бы, чтобы работа с one_second_timer была еще дешевле.

Использованный подход

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

Введено понятие timer_provider. Это интерфейс объекта, который должен присутствовать на каждой io_thread в единственном числе. Его задачей является периодический вызов метода on_timer у привязанных к этой же io_thread объектов timer_consumer. Но не у всех timer_consumer, а только у тех, кто заявил о себе timer_provider-у. Т.е., когда у timer_consumer появляется потребность в обработке таймера, он вызывает у timer_provider-а метод activate_consumer, а когда такая потребность исчезает -- вызывает метод deactivate_consumer. Таким образом у timer_provider есть список активных timer_consumer-ов.

За интерфейсом timer_provider скрывается простой агент, который подписывается на one_second_timer. Получив это сообщение timer_provider просто бежит по своему списку активных timer_consumer-ов и вызывает у них on_timer.

timer_consumer-ами являются acl_handler-ы. Когда у acl_handler-а (он же timer_consumer) появляется первое принятое подключение, то acl_handler добавляет себя в список к timer_provider-у. А когда подключений не остается acl_handler вычеркивает себя из списка timer_provider-а.

Фокус в том, что все взаимодействующие в рамках одной io_thread timer_provider и timer_consumer-ы привязанны к одному и тому же диспетчеру. Что гарантирует, что они работают на одной и той же рабочей нити. А значит могут обращаться друг к другу не боясь приключений с thread-safety.

Это отличный пример того, как при работе с SObjectizer-ом отказаться от взаимодействия с агентами только посредством асинхронного взаимодействия и выполнять синхронные вызовы методов агентов, но при этом не иметь проблем с thread-safety. Ведь диспетчеры в SObjectizer-е и были введены как раз для того, чтобы программист мог управлять тем где и как работают его агенты. И, если у программиста есть возможность директивно привязать своих агентов к конкретной нити, то почему бы этим не пользоваться. По крайней мере в условиях, когда хочется выжимать максимум.

Защита от повисших указателей

Сейчас timer_provider и timer_consumer хранят указатели друг на друга. Но, при этом агенты, реализующие данные интерфейсы, принадлежат разным кооперациям. Т.е. уничтожаться они могут в разном порядке.

Когда у acl_handler-а (т.е. у timer_consumer-а) вызывается so_evt_finish (это последнее событие для агента перед дерегистрацией), то acl_handler вычеркивает себя из списка timer_provider-а. Так что у timer_provider-а не может остаться повисших указателей на timer_consumer-ы (в теории, по крайней мере).

Но что с самим указателем на timer_provider? Как сделать так, чтобы он оставался валидным до тех пор, пока жив хотя бы один acl_handler?

Очень просто: кооперация с агентом timer_provider выступает в качестве родительской для всех коопераций с acl_handler-aми. В этом случае SObjectizer гарантирует, что родительская кооперация не будет уничтожена пока есть хотя бы одна живая дочерняя кооперация. Т.е. указатель на timer_provider внутри timer_consumer-ов будет оставаться валидным.

Каков результат этих изменений?

В зависимости от конфигурации, количества ACL внутри arataga и входящей нагрузки расход CPU на тестовых прогонах снизился от 1.5 до 4-х раз.

Заключение

Для нас arataga получился интересным полигоном для еще одних натурных испытаний SObjectizer и RESTinio. Да еще и с возможностью открыто показать как выглядит реальный код на SObjectizer-е.

Надеюсь, что читателям, которые следят за SObjectizer, было интересно. Если есть какие-то вопросы или непонятные моменты, то я с удовольствием отвечу на них в комментариях.

В завершение хочу сказать вот что: не смотря на то, что пока у нас нет ресурсов на дальнейшее развитие своего OpenSource, тем не менее проекты не заброшены. Если кто-то находит ошибки, мы их исправляем. Если у кого-то возникают вопросы, то стараемся помочь. Иногда даже выпускаем обновления с мелкими улучшениями.

Так что, если у кого-то есть сомнения о том, брать или не брать SObjectizer/RESTinio/json-dto в работу, то отбросьте их и попробуйте. В случае сложностей или проблем смело обращайтесь к нам, без помощи не оставим.

Подробнее..

Перевод Пара мыслей о геттерах и сеттерах в C

11.06.2021 18:10:18 | Автор: admin

Эта статья посвящена геттерам и сеттерам в C++. Приношу свои извинения, но речь пойдет не о корутинах. К слову, в ближайшее время появится вторая часть про пулы потоков.

TL;DR: геттеры и сеттеры не очень хорошо подходят для структуроподобных объектов.

Введение

В этой статье я лишь высказываю свое личное мнение, я не преследую цели кого-нибудь обидеть или задеть, я просто собираюсь объяснить, почему и когда стоит или не стоит, использовать геттеры и сеттеры. Буду очень рад любым дискуссиям в комментариях.

Следует сразу прояснить, что когда я говорю о геттере, я подразумеваю функцию, которая просто что-то возвращает, а когда я говорю о сеттере, я подразумеваю функцию, которая просто изменяет одно внутреннее значение, не выполняя никаких проверок или других дополнительных вычислений.

Производительность и геттеры

Допустим, у нас есть простая структура с обычными геттерами и сеттерами:

class PersonGettersSetters {  public:    std::string getLastName() const { return m_lastName; }    std::string getFirstName() const { return m_firstName; }    int getAge() const {return m_age; }        void setLastName(std::string lastName) { m_lastName = std::move(lastName); }    void setFirstName(std::string firstName) { m_firstName = std::move(firstName); }    void setAge(int age) {m_age = age; }  private:    int m_age = 26;    std::string m_firstName = "Antoine";    std::string m_lastName = "MORRIER";    };

Сравним эту версию с версией без геттеров и сеттеров.

struct Person {    int age = 26;    std::string firstName = "Antoine";    std::string lastName = "MORRIER";};

Она намного лаконичнее и надежнее. Здесь мы не можем, например, верну фамилию вместо имени.

Оба кода полностью функциональны. У нас есть класс Person с именем (firstName), фамилией (lastName) и возрастом (age). Однако предположим, что нам нужна функция, которая возвращает некоторую сводку по конкретному человеку.

std::string getPresentation(const PersonGettersSetters &person) {  return "Hello, my name is " + person.getFirstName() + " " + person.getLastName() +  " and I am " + std::to_string(person.getAge());}std::string getPresentation(const Person &person) {  return "Hello, my name is " + person.firstName + " " + person.lastName + " and I am " + std::to_string(person.age);}

Версия без геттеров выполняет эту задачу на 30% быстрее, чем версия с геттерами. Почему? Из-за возврата по значению в геттере. При возврате по значению создается копия, что снижает производительность. Давайте сравним производительность person.getFirstName(); и person.firstName.

Как видите, прямой доступ к полю имени без геттера эквивалентен noop.

Геттер по константной ссылке

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

class PersonGettersSetters {  public:    const std::string &getLastName() const { return m_lastName; }    const std::string &getFirstName() const { return m_firstName; }    int getAge() const {return m_age; }        void setLastName(std::string lastName) { m_lastName = std::move(lastName); }    void setFirstName(std::string firstName) { m_firstName = std::move(firstName); }    void setAge(int age) {m_age = age; }  private:    int m_age = 26;    std::string m_firstName = "Antoine";    std::string m_lastName = "MORRIER";    };

Так как мы получаем ту же производительность, что и в лаконичной версии, мы можем на этом успокоиться, не так ли? Прежде чем отвечать на этот вопрос, попробуйте выполнить этот код.

PersonGettersSetters make() {    return {};   }int main() {    auto &x = make().getLastName();         std::cout << x << std::endl;        for(auto x : make().getLastName()) {        std::cout << x << ",";       }}

Вы можете заметить некоторые странные символы, выведенные в консоли. Но почему? Что произошло, когда мы сделали make().getLastName()?

  1. Вы создаете экземпляр Person.

  2. Вы получаете ссылку на фамилию.

  3. Вы удаляете экземпляр Person.

И вот у нас есть висячая ссылка! Это может привести к крашам (в лучшем случае) или чему-то еще более худшему, чему-то, что можно найти только в фильмах ужасов.

Чтобы предупредить это, мы должны ввести ref-qualified функции.

class PersonGettersSetters {  public:    const std::string &getLastName() const & { return m_lastName; }    const std::string &getFirstName() const & { return m_firstName; }        std::string getLastName() && { return std::move(m_lastName); }    std::string getFirstName() && { return std::move(m_firstName); }        int getAge() const {return m_age; }        void setLastName(std::string lastName) { m_lastName = std::move(lastName); }    void setFirstName(std::string firstName) { m_firstName = std::move(firstName); }    void setAge(int age) {m_age = age; }      private:    int m_age = 26;    std::string m_firstName = "Antoine";    std::string m_lastName = "MORRIER";    };

Вот новое решение, которое будет работать везде. Вам нужно два геттера. Один для lvalue и один для rvalue (как xvalue, так и для prvalue).

Проблемы с сеттерами

Тут особо нечего сказать. Если вы хотите добиться максимальной производительности, вы должны написать один сеттер, который принимает lvalue, и один, который принимает rvalue. Однако, как правило, достаточно иметь всего один сеттер, который принимает перемещаемое значение. Тем не менее, вам придется расплатиться за это дополнительным move. Однако таким образом у вас не получится производить небольшие изменения в переменных. Вы должны заменять всю переменную целиком. Если вы просто хотите заменить одну букву A в имени на D, то вы не сможете сделать это с помощью сеттеров. Однако с помощью прямого доступа так делать можно.

А как насчет иммутабельных переменных?

Кто-то может посоветовать вам просто сделать атрибут члена const. Однако меня это решение не устраивает. Создание константы предотвратит move-семантику и приведет к ненужному копированию.

У меня нет волшебного решения, которое я мог бы предложить вам прямо сейчас. Тем не менее, мы можем написать обертку, которую мы можем назвать immutable<T>. Эта обертка должна быть:

  1. Constructible

  2. Так как она immutable, она не должна быть assignable

  3. Она может быть copy constructible или move constructible

  4. Она должна быть конвертируемой в const T&, будучи lvalue

  5. Она должна быть конвертируемой в T, будучи rvalue

  6. Она должна использоваться, как и другие оболочки, с помощью оператора * или оператора ->.

  7. Получить адрес базового объекта должно быть легко.

Вот небольшая реализация:

#define FWD(x) ::std::forward<decltype(x)>(x)template <typename T>struct AsPointer {    using underlying_type = T;    AsPointer(T &&v) noexcept : v{std::move(v)} {}    T &operator*() noexcept { return v; }    T *operator->() noexcept { return std::addressof(v); }    T v;};template <typename T>struct AsPointer<T &> {    using underlying_type = T &;    AsPointer(T &v) noexcept : v{std::addressof(v)} {}    T &operator*() noexcept { return *v; }    T *operator->() noexcept { return v; }    T *v;};template<typename T>class immutable_t {  public:    template <typename _T>    immutable_t(_T &&t) noexcept : m_object{FWD(t)} {}    template <typename _T>    immutable_t &operator=(_T &&) = delete;    operator const T &() const &noexcept { return m_object; }    const T &operator*() const &noexcept { return m_object; }    AsPointer<const T &> operator->() const &noexcept { return m_object; }    operator T() &&noexcept { return std::move(m_object); }    T operator*() &&noexcept { return std::move(m_object); }    AsPointer<T> operator->() &&noexcept { return std::move(m_object); }    T *operator&() &&noexcept = delete;    const T *operator&() const &noexcept { return std::addressof(m_object); }    friend auto operator==(const immutable_t &a, const immutable_t &b) noexcept { return *a == *b; }    friend auto operator<(const immutable_t &a, const immutable_t &b) noexcept { return *a < *b; }  private:    T m_object;};

Таким образом, для иммутабельного объекта Person вы можете просто написать:

struct ImmutablePerson {    immutable_t<int> age = 26;    immutable_t<std::string> firstName = "Antoine";    immutable_t<std::string> lastName = "MORRIER";};

Заключение

Я бы не сказал, что геттеры и сеттеры - это зло. Однако, когда вам не нужно делать что-либо еще в геттере и сеттере, достижение максимальной производительности, безопасности и гибкости подводит вас к написанию:

  • 3-х геттеров (или даже 4-х): const lvalue, rvalue, const rvalue и, по вашему усмотрению, для неконстантного lvalue (даже если это уже просто очень странно звучит, так как проще использовать прямой доступ)

  • 1 сеттер (или 2, если вы хотите выжать максимальную производительность).

Это по большому счету шаблон, который подходит практически для всего.

Некоторые люди могут вам сказать, что геттеры и сеттеры обеспечивают инкапсуляцию, но это не так. Инкапсуляция - это не просто делать атрибуты приватными. Речь идет о сокрытии внутренностей от пользователей, а в структуроподобных объектах вы редко хотите что-либо скрывать.

Мой совет: когда у перед вами структуроподобный объект, просто не используйте геттеры и сеттеры, а используйте публичный/прямой доступ. Проще говоря, если вам не нужен сеттер для поддержания инвариантности, вам не нужен приватный атрибут.

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

Ну а что думаете вы? Используете ли вы геттеры и сеттеры? И почему?


Перевод материала подготовлен в рамках курса "C++ Developer. Basic". Всех желающих приглашаем на двухдневный онлайн-интенсив HTTPS и треды в С++. От простого к прекрасному. В первый день интенсива мы настроим свой http-сервер и разберем его что называется от и до. Во второй день произведем все необходимые замеры и сделаем наш сервер супер быстрым, что поможет нам понять на примере, чем же все-таки язык С++ лучше других. Регистрация здесь

Подробнее..

Многопоточный HTTP-сервер с ThreadPoolом и конечным автоматом

23.05.2021 12:20:57 | Автор: admin

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

Проблемы решения "один поток = один клиент"

Проблемы, которые описаны ниже, справедливы как для потоков, так и для процессов, поэтому "один поток = один клиент" также можно расценивать как и "один процесс - один клиент" в данном контексте.

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

Проблема вторая - один поток занят только одним клиентом. В связи с этим мы получаем неэффективное использование ресурсов. (поток может простаивать, пока ждёт события от клиента)

Плюсом ко всему этому нужно понимать, что создание потока (или процесса) - это довольно тяжелая операция, и иногда она требует затрат больше, чем само обслуживание клиента.

Решение, которое я приведу ниже, закрывает эти проблемы.

Решение есть

Первая проблема решается тем, что количество потоков мы сделаем независимым от количества наших клиентов. Количеством потоков мы управляем сами (оно может устанавливаться как статистически, при запуске сервера, так и динамически, подстраиваясь под нагрузку, например).

Вторая проблема закрывается тем, что потоки не прикреплены к клиентам. Фактически, обслуживание клиента разбивается на задачи, которые решают потоки. Таким образом, мы избавляемся от простоев потоков, если работа для них есть.

Конечный автомат

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

На каждое состояние у нас есть свой хэндлер. Рассмотрим пример. У клиента четыре состояния: readRequest, generateResponse, sendResponse и closeConnection (чтение запроса, создание ответа, отправка ответа и закрытие соединения, соответственно). На каждое состояние мы имеем хэндлер. readRequest читает и парсит запрос и, в зависимости от успеха чтения и парсинга (например, в зависимости от того, что вернула функция чтения запроса), переключает состояние либо на generateResponse, либо на closeConnection. generateResponse отвечает за генерацию ответа и переключает состояние клиента на sendResponse. sendResponse отправляет ответ клиенту и либо возвращет клиента на состояние readRequest, либо переключает на closeConnection. closeConnection, в свою очередь, просто отключает клиента и удаляет его.

Этот примитивный пример показывает суть принципа. Мы можем добавлять новые состояния клиентов (причем в коде делается это довольно просто: мы просто реализуем новый метод) и переключать их как угодно в зависимости от условий. Вы можете с легкостью разбивать состояние на два отдельных, если чувствуете в этом необходимость. В нашем примере парсинг запроса включен в состояние readRequest и его можно вынести в отдельное состояние - parsingRequest, например.

Это довольно гибкое решение с точки зрения архитектуры. Обратите внимание, что конечный автомат в целом может существовать и без потоков, но с ними ведь все интереснее:)

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

ThreadPool (или пул потоков)

Пул потоков как таковым пулом потоков не является. Скорее он представляет собой пул (в виде очереди, например) задач для этих потоков.

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

Если наш клиент отправил нам только часть запроса, поток в сервере формата "один клиент=один поток" будет ожидать оставшуюся часть запроса. (то есть простаивать) В нашем же случае поток обработает часть запроса и пойдет обрабатывать следующих клиентов, если такие есть (простаивания потока не происходит).

Статья, которая мне в свое время помогла разобраться в пуле потоков. Я реализовывал все это в более упрощенном варианте, но это лишний раз доказывает, что тут есть где разгуляться и шаблон можно с легкостью подстроить под свои задачи:)

Заключение

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

В этот раз я в общих чертах рассказал только про суть подхода. Если вам интересно увидеть продолжение статьи уже с практической частью (подходы к реализации и их подводные камни) - дайте знать об этом:)

На этом все. Делитесь своими вариантами, предложениями, дополнениями и критикой в комментариях! Благодарю за прочтение:)

Несколько полезных ссылок:

http://personeltest.ru/aways/habr.com/ru/post/260065/

http://personeltest.ru/aways/habr.com/ru/company/latera/blog/273283/

http://www.aosabook.org/en/nginx.html

Подробнее..

Категории

Последние комментарии

  • Имя: Макс
    24.08.2022 | 11:28
    Я разраб в IT компании, работаю на арбитражную команду. Мы работаем с приламы и сайтами, при работе замечаются постоянные баны и лаги. Пацаны посоветовали сервис по анализу исходного кода,https://app Подробнее..
  • Имя: 9055410337
    20.08.2022 | 17:41
    поможем пишите в телеграм Подробнее..
  • Имя: sabbat
    17.08.2022 | 20:42
    Охренеть.. это просто шикарная статья, феноменально круто. Большое спасибо за разбор! Надеюсь как-нибудь с тобой связаться для обсуждений чего-либо) Подробнее..
  • Имя: Мария
    09.08.2022 | 14:44
    Добрый день. Если обладаете такой информацией, то подскажите, пожалуйста, где можно найти много-много материала по Yggdrasil и его уязвимостях для написания диплома? Благодарю. Подробнее..
© 2006-2024, personeltest.ru