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

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

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

Для тех же, кто про данную разработку слышит в первый раз: это наша попытка сделать встраиваемый в C++ приложения HTTP(S)/WebSocket сервер, который бы обладал и большой гибкостью, и нормальной производительностью, освобождал бы пользователя от рутины, но не прятал бы абсолютно все детали "под капот", и удовлетворял бы нашим представлениям о том, как подобные вещи должны выглядеть...

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

Что нового в 0.7.0

Переход на C++17

В версии 0.7.0 мы перешли с C++14 на C++17. Вероятно, это не самое лучшее из наших решений, ведь кто-то все еще вынужден оставаться на C++14 не имея возможности обновиться до C++17, однако мы для себя больше не видели смысла держаться за C++14.

Выгода от перехода на C++17 заключалась прежде всего в том, что удалось избавиться от таких зависимостей, как optional-lite, string_view-lite и variant-lite, т.к. теперь это все доступно в стандартной библиотеке. Так что остается сказать большое спасибо Martin Moene за его труд по написанию и сопровождению этих библиотек, они нам здорово помогали в течении шести лет, но дальше мы пойдем с stdlib ????

Хотя осталась зависимость от expected-lite, но с ней придется жить еще долго. Если уж мы на 17-ые плюсы перебрались только в 2023-ем, то перехода на C++23 нужно будет подождать еще лет пять-шесть, а то и девять-десять ????

Выгода от 17-го стандарта проявилась еще и в том, что в ряде мест мы смогли выбросить сложные (и не очень) шаблонные конструкции в пользу простых if constexpr и fold expressions.

Так что дальше пойдем уже в рамках C++17. Если кого-то это расстраивает, то уж простите за откровенность, но за поддержку C++14 нам никто не платит.

Переход на llhttp, Catch2 v3 и modern CMake

Изначально RESTinio использовал nodejs/http-parser в качестве парсера HTTP-запросов. Но несколько лет назад его развитие и поддержка прекратились. Посему в версии 0.7.0 мы переехали на nodejs/llhttp. Собственно, этот переезд и был главной мотивацией для выпуска версии 0.7.0.

Заодно мы обновили у себя Catch2. Эта библиотека начиная с версии 3.0 уже не является header-only и требует компиляции.

Переход на Catch2 v3 привел к тому, что мы перестали пользоваться для разработки RESTinio самодельной системой сборки. Т.е. раньше основная работа происходила посредством MxxRu, а CMake был резервной системой. Но начиная с v.0.7.0 основная и единственная система -- это CMake (при всей моей нелюбви к сему "продукту", но что поделать). Ну а раз так, что CMake-скрипты у себя мы постарались обновить и осовременить. Очень надеюсь, что подключить RESTinio в проект посредством CMake теперь будет проще.

Кстати говоря, от MxxRu мы пока полностью не отказались, MxxRu продолжает использоваться для управления зависимостями. Так что если вы захотите взять RESTinio из репозитория и что-то там поправить, то кроме CMake вам пока еще потребуются и Ruby, и MxxRu.

Возможно, когда-нибудь в светлом будущем мы переползем на vcpkg или Conan. А может и комитет к тому времени родит что-нибудь стандартное... Ну мало ли, а вдруг?

Добавление цепочек асинхронных обработчиков

Первой причиной появления версии 0.7.0 стал переход на llhttp. А второй причиной оказался незакрытый гештальт по добавлению в RESTinio аналога ExpressJS-овского middleware.

В версии 0.6.13 уже был сделал первый шаг в этом направлении: были добавлены цепочки синхронных обработчиков. Это оказалось очень полезно, но местами очень не хватало возможности выстроить в цепочку именно асинхронные обработчики. И мы попытались эту задачу решить в рамках работ над 0.7.0.

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

Для асинхронных цепочек используется та же самая схема, что и для синхронных: пользователь описывает в traits специальный тип request-handler, а при запуске сервера перечисляются нужные обработчики. Например, если у нас априори известное и жестко фиксированное количество стадий обработки запроса:

struct my_traits : public restinio::default_traits_t {
   // Говорим, что в качестве request-handler-а будет цепочка
   // из трех асинхронных обработчиков.
   using request_handler_t = restinio::async_chain::fixed_size_chain_t<3>;
};

restinio::run(restinio::on_this_thread<my_traits>()
   .port(...)
   .address(...)
   // Ровно три обработчика должно быть передано в request_handler().
   .request_handler(
      first_handler,
      second_handler,
      third_handler)
   ...);

Так что внешне все выглядит очень похоже на то, что было сделано в версии 0.6.13 три года назад.

Кардинальные отличия цепочки асинхронных обработчиков от цепочки синхронных скрываются в деталях.

Во-первых, вот эти вот first_handler, second_handler и third_handler уже не являются полноценными обработчиками в полном смысле этого слова. В случае async_chain это "планировщики" (schedulers) реальных обработчиков.

Подразумевается, что у пользователя будет два набора рабочих нитей: один для RESTinio (здесь RESTinio будет выполнять I/O операции и парсинг входящих запросов), второй -- для прикладной обработки. И задачей программиста является передача информации об очередном входящем запросе с рабочей нити RESTinio на рабочую нить прикладной обработки.

Например, это может быть сделано посредством каких-либо очередей сообщений. Что-то вроде:

ext_lib::message_queue<restinio::async_chain::unique_async_handling_controller_t<>> first_queue;
ext_lib::message_queue<restinio::async_chain::unique_async_handling_controller_t<>> second_queue;
... // Третья очередь и т.д.

const auto first_handler = [&first_queue](auto controller) {
    first_queue.push(std::move(controller));
    return restinio::async_chain::ok();
  };
const auto second_handler = [&second_queue](auto controller) {
    second_queue.push(std::move(controller));
    return restinio::async_chain::ok();
  };
... // Третий планировщик и т.д.

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

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

Во-вторых, фактическая обработка запроса с точки зрения RESTinio выполняется непонятно когда и непонятно где.

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

ext_lib::message_queue<restinio::async_chain::unique_async_handling_controller_t<>> first_queue;
... // Вторая, третья очередь и т.д.

std::thread first_actual_handler{
  [&first_queue]()
  {
    while(ext_lib::has_items == first_queue.wait_if_empty()) {
      auto controller = std::move(first_queue.top());
      first_queue.pop();

      // Берем очередной запрос.
      const auto req = controller->request_handle();
      ... // И обрабатываем его.

      ??? // А вот здесь должен быть какой-то фокус.
    }
  }
};

Соответственно, поскольку фактическая обработка запроса для RESTinio не видна, то есть вопрос: а как RESTinio поймет, что i-ый обработчик в цепочке завершил свои манипуляции над запросом и пора запускать (i+1)-й обработчик?

К сожалению, никак.

Это задача прикладного программиста "толкнуть" на исполнение следующий обработчик. Для чего в коде актуального обработчика нужно вызвать функцию next() отдав ей экземпляр контроллера:

auto controller = std::move(first_queue.top());
first_queue.pop();

// Берем очередной запрос.
const auto req = controller->request_handle();
... // И обрабатываем его.

// Толкаем на выполнение следующий обработчик.
next(std::move(controller)); // А вот и главный фокус.

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

Если какой-то обработчик функцию next() не вызовет по ошибке, то по истечении тайм-аута RESTinio закроет соединение, из которого запрос был получен.

Получается следующая схема работы:

  • RESTinio разбирает входящий запрос и вызывает request_handler, которым является async_chain;

  • async_chain получает от RESTinio запрос, создает объект-контроллер и вызывает первый планировщик в запросе, отдавая планировщику объект-контроллер в единоличное пользование.

  • первый планировщик ставит объект-контроллер в какую-то очередь к первому актуальному обработчику и возвращает управление async_chain-у;

  • async_chain возвращает RESTinio значение accepted и RESTinio понимает, что пользователь взял ответственность за обработку запроса на себя. После чего RESTinio приступает к обработке следующего запроса;

  • первый актуальный обработчик извлекает объект-контроллер из очереди, проводит свою часть обработки запроса и, если нужно "дернуть" следующий обработчик в цепочке, вызывает функцию next();

  • внутри next() вызывается второй планировщик, которому объект-контроллер отдается в единоличное пользование;

  • второй планировщик ставит объект-контроллер в какую-то очередь ко второму актуальному обработчику;

  • и т.д.

Посмотреть как это может выглядеть на практике можно в новом штатном примере в составе RESTinio

Резюмируя можно сказать, что в случае использования цепочки асинхронных обработчиков пользователь должен:

  • подготовить набор очередей для передачи объектов-контроллеров;

  • подготовить набор рабочих нитей, на которых будет происходить фактическая обработка запросов. Эти рабочие нити будут извлекать объекты-контроллеры из очередей, производить обработку и вызывать функцию next() для запуска следующего обработчика в цепочке;

  • подготовить набор функций-планировщиков, которые будут помещать объект-контроллер в нужную очередь;

  • сформировать из функций-планировщиков экземпляр async_chain и отдать этот async_chain RESTinio в качестве request_handler-а.

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

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

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

Можно обратить внимание, что в примере с синхронными обработчиками и в create_auth_handler, и в create_request_handler используется обычный express-like роутер. Что позволяет лаконично и понятно описать какие типы запросов и для каких URL подлежат обработке:

// Внутри  create_auth_handler:
auto router = std::make_shared< express_router_t >();

router->http_get( "/stats", auth_checker );
router->http_get( "/admin", auth_checker );

// -----

// Внутри create_request_handler:
auto router = std::make_shared< express_router_t >();

router->http_get( "/", []( const auto & req, const auto & ) {...} );
router->http_get( "/json", []( const auto & req, const auto & ) {...} );
router->http_get( "/html", []( const auto & req, const auto & ) {...} );
router->http_get( "/stats", []( const auto & req, const auto & ) {...} );
router->http_get( "/admin", []( const auto & req, const auto & ) {...} );

Тогда как в примере с асинхронными обработчиками использовать express_router_t в auth_performer::on_do_processing не получилось ????

Проблема в том, что для асинхронной цепочки нужен объект-контроллер, а передать этот контроллер в express_router нельзя. Не было это предусмотрено при разработке express- и easy_parser_router.

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

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

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

Избавление от лишних зависимостей

Раньше мы в зависимостях подтягивали RapidJSON+json_dto для "работы" с JSON, и Clara для обработки аргументов командной строки.

Только вот все это использовалось очень эпизодически и только в тестах/примерах/бенчмарках. Т.е. для основной функциональности RESTinio ничего из этого не требовалось.

Так что от всех этих зависимостей в версии 0.7.0 мы избавились, тем более, что дальнейшая разработка Clara прекращена, так что ее все равно пришлось бы на что-то менять.

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

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

Совместимость ветки 0.7 с веткой 0.6

Смена номера версии с 0.6 на 0.7 означает, что мы решились на слом совместимости между ветками.

Прежде всего это касается подключения RESTinio к вашему проекту.

Если вы используете RESTinio через vcpkg и Conan, то здесь, надеюсь, особо ничего не меняется.

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

Так что в смысле подключения RESTinio к проекту изменений много и они ломающие.

А вот в смысле API RESTinio поменялось не так уж и много. Так что, надеюсь, в этом плане переход на RESTinio-0.7 с 0.6 должен быть гораздо проще.

Документация

Мы обновили Developers Guide для RESTinio.

Местами дополнили и расширили описания в сгенерированной через Doxygen справке по API RESTinio. Но вот касательно того, что генерируется через Doxygen у меня есть просьба к тем, кто собирается попробовать, или уже пробует, или даже использует RESTinio:

  • К сожалению, сгенерированная Doxygen-ом документация по API RESTinio местами оставляет желать лучшего. Этот момент мы изначально упустили, теперь же стараемся уделять этому аспекту больше внимания. Но сразу подтянуть уровень всех Doxygen комментариев мы не можем ???? Поэтому если вы столкнулись с тем, что для какого-то класса/метода/функции описание не информативно или нуждается в дополнительных пояснениях/примерах, то дайте нам знать. Такая обратная связь поможет нам сконцентрироваться на наиболее востребованных фрагментах.

Почему пришлось ждать три года?

Тут все просто: мы развиваем RESTinio за свои кровные и к концу 2020-го у нас тупо кончились на это собственные средства. Мы попытались найти стороннее финансирование для своего OpenSource, но это не получилось. Пришлось поставить открытые проекты, включая RESTinio, на паузу.

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

Не смотря на паузу проект заброшен не был

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

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

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

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

Что дальше?

О будущем, с учетом прошлого опыта, приходится рассуждать философски ????

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

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

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

Заключение

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

Огромное спасибо всем, кто попробовал RESTinio. И отдельные самые теплые слова благодарности тем, кто дал нам обратную связь. Ведь некоторые фичи RESTinio появились, буквально, благодаря комментариям на Хабре.

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

Комментарии (2)


  1. NN1
    14.11.2023 07:16
    +2

    В кои-то веки прекрасная техническая статья на Хабре ????


    1. eao197 Автор
      14.11.2023 07:16

      Большое спасибо за лестную оценку.