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

Продолжаем упарываться многоэтажными С++ными шаблонами в RESTinio: безопасная по типам альтернатива express-js роутеру

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


RESTinio, наш небольшой встраиваемый HTTP-сервер, продолжает развиваться. Одной из отличительных особенностей RESTinio является то, что в его реализации активнейшим образом используются многоэтажные C++ные шаблоны (о чем уже рассказывалось ранее: 1, 2).


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


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


easy_parser_router как альтернатива express-router-у


express-router и что с ним не так?


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


Подверженность ошибкам и опечаткам


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


router->http_get("/api/v1/books/:id",
   [](const auto & req, auto params) {
      const auto book_id = restinio::cast_to<std::uint64_t>(params["Id"]);
      ...
   });

Первая ошибка, которая может броситься в глаза, это опечатка в имени параметра "id". В маршруте этот параметр именуется как "id" (все буквы строчные), а при извлечении значения параметра используется имя "Id" (первая буква прописная).


Вторая ошибка — это отсутствие ограничений на значение параметра id. Вот в таком виде /api/v1/books/:id в качестве "id" может быть задано что угодно, хоть число, хоть идентификатор, хоть произвольный набор символов.


Соответственно, нужно этот пример кода переписать хотя бы вот так:


router->http_get(R"(/api/v1/books/:id(\d+))",
   [](const auto & req, auto params) {
      const auto book_id = restinio::cast_to<std::uint64_t>(params["id"]);
      ...
   });

Но и здесь еще не все, к сожалению.


Дело в том, что в качестве "id" может прийти очень длинная последовательность из цифр, которая не сможет быть успешно преобразована в значение 64-битового беззнакового целого.


Соответственно, более корректная правка должна была бы выглядеть, например, так:


router->http_get(R"(/api/v1/books/:id(\d{1,10}))",
   [](const auto & req, auto params) {
      const auto book_id = restinio::cast_to<std::uint64_t>(params["id"]);
      ...
   });

Самое плохое в вышеперечисленном то, что все эти проблемы проявляются только в run-time. Т.е., если мы недостаточно хорошо протестировали свой код, то ошибка может проявится только во время эксплуатации и только при определенном стечении обстоятельств.


Отсутствие прозрачности и безопасности по типам


Давайте представим себе, что реальная обработка входящих запросов делегируется в экземпляр объекта вот такого типа:


class api_v1_handler {
   ...
public:
   auto on_get_book(
         const restinio::request_handle_t & req,
         restinio::router::route_params_t params)
   {
      const auto book_id = restinio::cast_to<std::uint64_t>(params["id"]);
      ...
   }

   auto on_get_book_version(
         const restinio::request_handle_t & req,
         restinio::router::route_params_t params)
   {
      const auto book_id = restinio::cast_to<std::uint64_t>(params["id"]);
      const auto ver_id = restinio::cast_to<std::string>(params["version"]);
      ...
   }

   auto on_get_author_books(
         const restinio::request_handle_t & req,
         restinio::router::route_params_t params)
   {
      const auto author = restinio::cast_to<std::string>(params["author"]);
      ...
   }
   ...
};

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


Что открывает простую возможность ошибиться с вызовом обработчиков:


auto handler = std::make_shared<api_v1_handler>(...);
router->http_get(R"(/api/v1/books/:id(\d{1,10}))",
   [handler](const auto & req, auto params) {
      return handler->on_get_book_version(req, params);
   });
router->http_get(R"(/api/v1/books/:id(\d{1,10})/versions/:version)",
   [handler](const auto & req, auto params) {
      return handler->on_get_author_books(req, params);
   });
router->http_get(R"(/api/v1/:author)",
   [handler](const auto & req, auto params) {
      return handler->on_get_book(req, params);
   });

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


Конечно, никто не заставляет использовать одинаковый формат обработчиков запросов в классе api_v1_handler. Разработчик даже при использовании express-роутера может написать, например, вот так:


class api_v1_handler {
   ...
   auto on_get_book_version(
         const restinio::request_handle_t & req,
         std::uint64_t book_id,
         const std::string & ver_id) { ... }
   ...
};

auto handler = std::make_shared<api_v1_handler>(...);
router->http_get(R"(/api/v1/books/:id(\d{1,10})/versions/:version)",
   [handler](const auto & req, auto params) {
      return handler->on_get_book_version(req,
            restinio::cast_to<std::uint64_t>(params["id"]),
            restinio::cast_to<std::string>(params["version"]));
   });

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


easy_parser_router в качестве альтернативы


Начиная с версии 0.6.6 в RESTinio появилась альтернатива express-роутеру под названием easy_parser_router и сейчас мы посмотрим, как easy_parser_router позволит нам переписать показанные выше примеры.


Сперва маленький пример с единственным целочисленным параметром "id":


namespace epr = restinio::router::easy_parser_router;
router->http_get(
   epr::path_to_params(
      "/api/v1/books/",
      epr::non_negative_decimal_number_p<std::uint64_t>()),
   [](const auto & req, std::uint64_t book_id) {
      ...
   });

Здесь указывается, что маршрут, на который вешается обработчик HTTP GET запроса, состоит из подстроки api/v1/books/ и неотрицательного 64-битного целого числа, которое и является параметром для обработчика запроса. Соответственно поэтому обработчик получает всего два параметра:


  • первый — это ссылка на restinio::request_handle_t, которая обязательно передается во все обработчики запросов;
  • второй — это то самое 64-битовое беззнаковое целое число, которое играет роль идентификатора.

Причем тут можно отметить еще и тот факт, что если в URL в качестве book_id будет передана слишком длинная последовательность цифр, которую невозможно корректно преобразовать к uint64_t, то данный маршрут "не сработает" и обработчик вызван не будет.


Пример с классом api_v1_handler будет выглядеть несколько объемнее. Прежде всего поменяются прототипы методов класса api_v1_handler:


class api_v1_handler {
   ...
public:
   using book_id_type = std::uint64_t;

   auto on_get_book(
         const restinio::request_handle_t & req,
         book_id_type book_id) { ... }

   auto on_get_book_version(
         const restinio::request_handle_t & req,
         book_id_type book_id,
         const std::string & ver_id) { ... }

   auto on_get_author_books(
         const restinio::request_handle_t & req,
         const std::string & author) { ... }
   ...
};

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


А вот так будет выглядеть указание маршрутов и вызов обработчиков:


namespace epr = restinio::router::easy_parser_router;

auto book_id_p = epr::non_negative_decimal_number_p<api_v1_handler::book_id_type>();
auto ver_id_p = epr::path_fragment_p();
auto author_p = epr::path_fragment_p();

auto handler = std::make_shared<api_v1_handler>(...);
router->http_get(
   epr::path_to_params("/api/v1/books/", book_id_p),
   [handler](const auto & req, auto book_id) {
      return handler->on_get_book(req, book_id);
   });
router->http_get(
   epr::path_to_params("/api/v1/books/", book_id_p, "/versions/", ver_id_p),
   [handler](const auto & req, auto book_id, const auto & ver_id) {
      return handler->on_get_book_version(req, book_id, ver_id);
   });
router->http_get(
   epr::path_to_params("/api/v1/", author_p),
   [handler](const auto & req, const auto & author) {
      return handler->on_get_author_books(req, author);
   });

Что уже не позволяет просто так вызвать on_get_book там, где требуется on_get_author_books.


Весь фокус в path_to_params. Или в path_to_tuple


Вся магия, касающаяся нового easy_parser_router-а, скрыта во вспомогательной функции path_to_params, использование которой мы уже видели выше. Это variadic template функция, которая принимает на вход либо строковые фрагменты, либо т.н. producer-ы.


Из своих аргументов path_to_params строит специальный объект-парсер. Этот парсер знает, как разобрать значение path из URL, на который пришел входящий HTTP-запрос.


В процессе парсинга URL могут производится значения находящихся внутри path параметров. Так, каждый переданный в path_to_params объект-producer производит строго одно значение. Поэтому, если в path_to_params передан один producer, то в результате успешного парсинга будет произведено одно значение параметра. Если передано два producer-а, то два. Если три, то три. И т.д. Если же в path_to_params не передано ни одного producer-а, а это возможно, например, вот в таком случае:


router->http_get(epr::path_to_params("/"), [](const auto & req) {...});

то в процессе разбора не будет сгенерировано вообще ни одного значения.


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


Вторая же задача возвращенного path_to_params объекта — это вызов обработчика запросов с передачей обработчику разобранных значений в качестве отдельных параметров. Т.е., если в path_to_params было передано два producer-а, вот как тут:


router->http_get(
   epr::path_to_params("/api/v1/books/", book_id_p, "/versions/", ver_id_p),
   [handler](const auto & req, auto book_id, const auto & ver_id) {
      return handler->on_get_book_version(req, book_id, ver_id);
   });

то значения, сгенерированные этими producer-ами, пойдут в обработчик как два отдельных аргумента. Поэтому здесь и показана лямбда-функция с тремя аргументами: первый из них — это обязательная ссылка на restinio::request_handle_t, а два последующих — это значения от producer-ов.


Для случая, когда получение параметров из маршрута в виде раздельных параметров по каким-то причинам нежелательно, есть аналог path_to_params под названием path_to_tuple. Отличие path_to_tuple от path_to_params состоит в том, что path_to_tuple передает все сгенерированные producer-ами значения в обработчик в виде одного единственного тупла. Например:


router->http_get(
   epr::path_to_tuple("/api/v1/books/", book_id_p, "/versions/", ver_id_p),
   [handler](const auto & req, std::tuple<std::uint64_t, std::string> params) {
      return handler->on_get_book_version(req, std::get<0>(params), std::get<1>(params));
   });

Что означает easy_parser в названии easy_parser_router?


Некоторое время назад, при добавлении в RESTinio средств разбора значений HTTP-заголовков, внутри RESTinio появился небольшой вспомогательный инструмент под названием easy_parser. Это простая реализация нисходящего рекурсивного парсера на базе Parsing Expression Grammar (PEG), которая позволяет выражать PEG-правила в виде C++ного DSL. Ну, например, вот такое правило:


limit = "limit" [SPACE] ":" [SPACE] NUMBER SPACE "bytes"

которое описывает строки вида "limit:4096 bytes" или "limit: 4096 bytes", посредством easy_parser-а может быть записано следующим образом:


using namespace restinio::easy_parser;
auto parser = produce<unsigned int>(
   exact("limit"),
   maybe(space()),
   symbol(':'),
   maybe(space()),
   non_negative_decimal_number_p<unsigned int>(),
   space(),
   exact("bytes"));

Эта конструкция создает парсер выражения, который, если парсинг завершается успешно, производит значение типа unsigned int.


Новый easy_parser_router в RESTinio-0.6.6 реализован на базе easy_parser-а. Поэтому в easy_parser_router можно задействовать все возможности easy_parser-а и если пользователю потребуется разбирать более-менее сложные параметры в URL, то пользователю придется погрузиться в дебри easy_parser-а.


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


В двух словах суть: сервер принимает GET-запросы на URL вида /, /<size> и /<size>/<count>, где <size> и <count> задают размер и количество отсылаемых в ответ блоков данных. Например, URL /512k/1024 означает, что в ответ сервер должен отослать 1024 блока по 512KiB каждый. А URL /1200/500 означает, что в ответ сервер должен отослать 500 блоков по 1200 байт.


При использовании express-роутера связанный с обработкой параметров в URL код имел следующий вид:


std::size_t extract_chunk_size(const restinio::router::route_params_t & params) {
   const auto multiplier = [](const auto sv) noexcept -> std::size_t {
      if(sv.empty() || "B" == sv || "b" == sv) return 1u;
      else if("K" == sv || "k" == sv) return 1024u;
      else return 1024u*1024u;
   };

   return restinio::cast_to<std::size_t>(params["value"]) *
         multiplier(params["multiplier"]);
}
...
auto router = std::make_unique<router_t>();

router->http_get("/", [&ctx](auto req, auto) {...});

router->http_get(
         R"(/:value(\d+):multiplier([MmKkBb]?))",
         [&ctx](auto req, auto params) {

      const auto chunk_size = extract_chunk_size(params);
      ...
   });

router->http_get(
         R"(/:value(\d+):multiplier([MmKkBb]?)/:count(\d+))",
         [&ctx](auto req, auto params) {

      const auto chunk_size = extract_chunk_size(params);
      const auto count = restinio::cast_to<std::size_t>(params["count"]);
      ...
   });

Тогда как посредством easy_parser_router-а эта же цель достигается следующим образом:


using namespace restinio::router::easy_parser_router;

auto router = std::make_unique<router_t>();

struct distribution_params
{
   std::size_t chunk_size_{100u*1024u};
   std::size_t count_{10000u};
};
struct chunk_size { std::uint32_t c_{1u}, m_{1u}; };

router->http_get(
   path_to_params(
      produce<distribution_params>(
         exact("/"),
         maybe(
            produce<chunk_size>(
               non_negative_decimal_number_p<std::uint32_t>()
                  >> &chunk_size::c_,
               maybe(
                  produce<std::uint32_t>(
                     alternatives(
                        caseless_symbol_p('b') >> just_result(1u),
                        caseless_symbol_p('k') >> just_result(1024u),
                        caseless_symbol_p('m') >> just_result(1024u * 1024u)
                     )
                  ) >> &chunk_size::m_
               )
            ) >> convert(
                  [](auto cs) { return std::size_t{cs.c_} * cs.m_; })
               >> &distribution_params::chunk_size_,
            maybe(
               exact("/"),
               non_negative_decimal_number_p<std::size_t>()
                  >> &distribution_params::count_
            )
         )
      )
   ),
   [&ctx](const auto & req, const auto & params ) { ... });

Т.е. вместо трех обработчиков для разных URL задается обработчик для одного URL. А этот URL записывается следующим PEG-правилом:


path = "/" [NUMBER [((B|b) | (K|k) | (M|m))] ["/" NUMBER]]

Многоэтажное C++ное выражение требуется для того, чтобы в результате разбора этого PEG-правила получить объект типа distribution_params, внутри которого будут два значения: размер одного блока в байтах и общее количество блоков. При этом большую часть C++ного выражения занимает обработка ситуации, когда для размера блока указывается множитель в виде суффикса b, k или m.


При первом знакомстве с easy_parser все это, конечно, выглядит дико, нечитаемо и непонятно. Но...


Во-первых, со временем привыкаешь. Понятное дело, что это так себе отговорка (стокгольмский синдром, все дела).Тем не менее, по мере накопления опыта такой вариант записи PEG-правил становится привычным и в нем начинаешь четко видеть "вот здесь у нас будет формироваться вот такое значение, которое состоит вот из этого и этого".


Во-вторых, на разработку самого easy_parser, а также на прикручивание его к easy_parser_router-у было потрачено много времени и сил. И представленный результат — это лучшее из того, что удалось придумать. Так что, с одной стороны, don't shoot the pianist… А с другой стороны, если у кого-то есть идеи, как достичь такого же эффекта более простым DSL в рамках C++14, то поделитесь, пожалуйста.


Вдаваться в то, что скрывается за всеми этими produce, maybe, alternatives, just_result, convert и пр. не буду. Этому можно посвятить отдельную объемную статью. Так что если кому-то интересно, то отпишитесь в комментариях. Если желающих погрузиться в возможности easy_parser-а наберется достаточно, то такая статья будет написана. Ну а кто не хочет ждать такой статьи, тот может попробовать заглянуть в официальную документацию.


Несколько деталей реализации из под капота easy_parser-а и easy_parser_router-а


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


Шаблоны result_value_wrapper и result_wrapper_for


Основная идея easy_parser-а состоит в том, что в результате разбора PEG-правила производится какое-то значение. Поэтому главная составляющая DSL easy_parser-а — это шаблонная функция produce:


template<typename Target_Type, typename... Clauses>
auto produce(Clauses &&... clauses);

Где параметр Target_Type задается программистом вручную и указывает тип значения, которое программист хочет получить в результате парсинга. А вот типы Clauses выводятся компилятором.


Если не вдаваться в лишние подробности, то когда программист пишет в коде что-то вроде:


// Грамматика:
// kv = key "->" value
// key = NUMBER
// value = NUMBER
struct KV{ int key; int value; };
produce<KV>(
   decimal_number_p<int>() >> &KV::key,
   exact("->"),
   decimal_number_p<int>() >> &KV::value);

то это раскрывается в приблизительно такой псевдокод:


expected<KV, parsing_error_t>
try_produce_KV_(impl::input_t & from) {
   KV result_value;
   {
      impl::decimal_number_producer_t<int> p;
      const auto r = p.try_produce(from);
      if(!r) return make_unexpected(r.error());
      impl::field_setter_consumer_t<&KV::key> consumer;
      consumer.consume(result_value, *r); // result_value.key = *r;
   }
   {
      impl::exact_clause_t c{"->"};
      const auto r = c.try_process(from, result_value);
      if(r) return make_unexpected(*r);
   }
   {
      impl::decimal_number_producer_t<int> p;
      const auto r = p.try_produce(from);
      if(!r) return make_unexpected(r.error());
      impl::field_setter_consumer_t<&KV::value> consumer;
      consumer.consume(result_value, *r); // result_value.value = *r;
   }
   return result_value;
}

Т.е. там, где используется produce<KV>(clauses...), там создается временный объект типа KV, который модифицируется при обработке clauses. Если обработка всех clauses завершилась без ошибок, то этот временный объект возвращается как результат работы produce<KV>.


Все это достаточно тривиально до тех пор, пока в вызове produce<T> тип T — это что-то вроде int или long, или какая-то структура. Но в качестве T запросто может использоваться и контейнер. Например, можно написать что-то вроде:


// Грамматика:
// keys_values = (kv [","])+
// kv = key "->" value
// key = NUMBER
// value = NUMBER
struct KV{ int key; int value; };
produce<std::vector<KV>>(
   repeat(1, N,
      produce<KV>(
         decimal_number_p<int>() >> &KV::key,
         exact("->"),
         decimal_number_p<int>() >> &KV::value
      ) >> to_container(),
      maybe(exact(","))
   ));

Т.е. здесь мы в процессе разбора строим экземпляры структуры KV, которые затем укладываются в результирующий контейнер типа std::vector<KV>.


И вот как раз с укладыванием значений в контейнер при разработке DSL-я для easy_parser_router-а вышла закавыка.


Внезапно (с) выяснилось, что есть контейнеры, которые, образно говоря, "хранят свое текущее состояние", поэтому для них операция "добавить еще один элемент в конец контейнера" выполняется элементарно. Это, например, такие контейнеры, как std::vector и std::string.


Но есть и std::array, у которого нет такого понятия, как текущее количество элементов внутри. Поэтому если программист пишет у себя что-то вроде:


produce<std::array<char, 8>>(
   repeat(8, 8, hexdigit_p() >> to_container()));

То возникает непростой вопрос: а как узнать, по какому индексу нужно сохранять очередной символ в результирующем std::array<char, 8>?


Если внутри produce<std::array<char, 8>> хранить только экземпляр std::array<char, 8>, то никак не узнать.


Поэтому для таких контейнеров, как std::array нужно хранить не только сам контейнер, но и какую-то сопутствующую информацию. А вот для std::vector или std::string никакой сопутствующей информации хранить не нужно.


Шаблон result_value_wrapper пришлось ввести в easy_parser как раз для того, чтобы produce<T>(clauses...) понял, что именно будет хранится внутри produce пока будет идти разбор clauses. Т.е. вызов produce<T>(...) на самом деле раскрывается во что-то вроде:


expected_t<T, parsing_error_t>
try_produce_T_(impl::input_t & from) {
   typename result_value_wrapper<T>::wrapped_type result_value;
   ...
   return result_value_wrapper<T>::unwrap_value(result_value);
}

В easy_parser-е для result_value_wrapper есть несколько специализаций. Например, специализация для std::vector:


template< typename T, typename... Args >
struct result_value_wrapper< std::vector< T, Args... > >
{
   using result_type = std::vector< T, Args... >;
   using value_type = typename result_type::value_type;
   using wrapped_type = result_type;

   static void
   as_result( wrapped_type & to, result_type && what )
   {
      to = std::move(what);
   }

   static void
   to_container( wrapped_type & to, value_type && what )
   {
      to.push_back( std::move(what) );
   }

   RESTINIO_NODISCARD
   static result_type &&
   unwrap_value( wrapped_type & v )
   {
      return std::move(v);
   }
};

И, соответственно, специализация для std::array:


namespace impl
{

template< typename T, std::size_t S >
struct std_array_wrapper
{
   std::array< T, S > m_array;
   std::size_t m_index{ 0u };
};

} /* namespace impl */

template< typename T, std::size_t S >
struct result_value_wrapper< std::array< T, S > >
{
   using result_type = std::array< T, S >;
   using value_type = typename result_type::value_type;
   using wrapped_type = impl::std_array_wrapper< T, S >;

   static void
   as_result( wrapped_type & to, result_type && what )
   {
      to.m_array = std::move(what);
      to.m_index = 0u;
   }

   static void
   to_container( wrapped_type & to, value_type && what )
   {
      if( to.m_index >= S ) throw exception_t(...);

      to.m_array[ to.m_index ] = std::move(what);
      ++to.m_index;
   }

   RESTINIO_NODISCARD
   static result_type &&
   unwrap_value( wrapped_type & v )
   {
      return std::move(v.m_array);
   }
};

Благодаря таким специализациям внутри produce<std::vector<T>>(...) будет создаваться просто std::vector<T>, а вот внутри produce<std::array<T, 10>>(...) будет создаваться impl::std_array_wrapper<T, 10>.


Однако, только шаблона result_value_wrapper для решения проблемы со специфическими контейнерами оказалось недостаточно. Давайте еще раз посмотрим на показанный выше псевдокод реализации produce:


expected<KV, parsing_error_t>
try_produce_KV_(impl::input_t & from) {
   typename result_value_wrapper<KV>::wrapped_type result_value;
   {
      impl::decimal_number_producer_t<int> p;
      const auto r = p.try_produce(from);
      if(!r) return make_unexpected(r.error());
      impl::field_setter_consumer_t<&KV::key> consumer;
      consumer.consume(result_value, *r); // (1)
   }
   ...
}

В точке (1) в метод consume передается ссылка на result_value_wrapper<KV>::wrapped_type. И в общем случае consume не представляет себе, что это такое. Может это просто ссылка на KV, а может быть это ссылка на какую-то обертку вокруг KV. И методу consume нужно разобраться что с этим делать.


Для этих целей существует "обратная" метафункция под названием result_wrapper_for, которая по типу wrapped_type выводит соответствующий тип result_value_wrapper. Что позволяет в методах consume писать вот так:


template< typename Target_Type, typename Value >
void
consume( Target_Type & dest, Value && src ) const
{
   using W = typename result_wrapper_for<Target_Type>::type;
   W::as_result( dest, std::forward<Value>(src) );
}

Соответственно, если какая-то специализация result_value_wrapper вводит свой особый тип wrapped_type, отличный от result_type, то делается и специализация для result_wrapper_for:


template< typename T, std::size_t S >
struct result_wrapper_for< impl::std_array_wrapper<T, S> >
{
   using type = result_value_wrapper< std::array< T, S > >;
};

Получается, что если в consume<Target_Type, Value>() в качестве Target_Type попадает impl::std_array_wrapper<T, S> (который служит в качестве wrapped_type для std::array), то consume понимает, что нужно воспользоваться услугами result_value_wrapper<std::array<T, S>> для работы с этим Target_Type.


transformer_proxy


В easy_parser есть ряд сущностей, которые разделены на следующие категории:


  • producer производит какое-то значение;
  • transformer преобразует произведенное producer-ом значение. При этом transformer-ы могут быть объединены в цепочку, например, producer() >> transformer_one() >> transformer_two() >> transformer_three(). Т.е. transformer должен использоваться в выражениях >>, где слева от него стоит либо producer, либо transformer. А справа может располагаться либо другой transformer, либо consumer;
  • consumer "потребляет" произведенное producer-ом значение. Например, сохраняет его куда-нибудь. При этом consumer должен использоваться в выражениях >> где слева стоит либо producer, либо transformer, а вот справа уже нет ничего больше. Т.е. consumer всегда замыкает цепочку >>;
  • clause. Когда пользователь описывает цепочку producer() >> ... >> consumer(), то в итоге получается clause, внутри которого значение и производится, и потребляется.

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


Пока в easy_parser-е были реализованы только простые преобразователи значений (вроде to_lower для строки) с определением result_type для классов, реализующих преобразователи значений, сложностей не возникало.


Грабли встретились когда руки дошли до преобразователя convert (его также можно было бы назвать и map): пользователь задает собственную функцию (функтор), которую нужно вызвать для преобразования значения. Пример применения convert уже можно было видеть выше:


produce<chunk_size>(
   non_negative_decimal_number_p<std::uint32_t>()
      >> &chunk_size::c_,
   maybe(
      produce<std::uint32_t>(
         alternatives(
            caseless_symbol_p('b') >> just_result(1u),
            caseless_symbol_p('k') >> just_result(1024u),
            caseless_symbol_p('m') >> just_result(1024u * 1024u)
         )
      ) >> &chunk_size::m_
   )
) >> convert( // (1)
      [](auto cs) { return std::size_t{cs.c_} * cs.m_; })
   >> &distribution_params::chunk_size_,

Здесь в точке (1) в convert передается обобщенная лямбда, которая ждет на вход chunk_size, а возвращает std::size_t.


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


Т.е. нельзя сделать функцию convert вида:


template<typename Callable>
SomeTransformerType convert(Callable && f) {...}

которая бы возвращала именно тип transformer-а.


Нельзя потому, что для этого самого гипотетического SomeTransformerType нет возможности определить тип result_type. Этот тип станет доступным не в момент передачи лямбды в convert, а позже. Когда результат работы convert будет связываться с некоторым producer-ом.


Для решения этой проблемы в easy_parser пришлось ввести еще одну категорию сущностей: transformer_proxy.


Transformer_proxy не является transfomer-ом, но зато знает, как сделать конкретного transformer-а в момент, когда transformer_proxy связывается с конкретным producer-ом.


После появление понятия transformer_proxy список перегруженных operator>> пополнился еще одной перегрузкой:


template<
   typename P,
   typename T,
   typename S = std::enable_if_t<
         is_producer_v<P> & is_transformer_proxy_v<T>,
         void > >
RESTINIO_NODISCARD
auto
operator>>(P producer, T transformer_proxy )
{
   auto real_transformer = transformer_proxy.template make_transformer< 
         typename P::result_type >();

   using transformator_type = std::decay_t< decltype(real_transformer) >;

   using producer_type = transformed_value_producer_t< P, transformator_type >;

   return producer_type{ std::move(producer), std::move(real_transformer) };
};

И это сделало возможным реализацию функции convert, т.к. сейчас она возвращает transformer_proxy, хранящий лямбда-функцию внутри себя. И когда этот transformer_proxy попадает в показанный выше operator>>, то прокси-объект в своем методе make_transformer уже может сформировать вполне себе конкретный тип transformer-а с определенным result_type внутри:


template< typename Converter >
class convert_transformer_proxy_t : public transformer_proxy_tag
{
   Converter m_converter;

public :
   ...
   template< typename Input_Type >
   RESTINIO_NODISCARD
   auto
   make_transformer() const &
   {
      using output_type = std::decay_t<
            decltype(m_converter(std::declval<Input_Type&&>())) >;

      return convert_transformer_t< output_type, Converter >{ m_converter };
   }
   ...
};

Здесь output_type внутри make_transformer — это как раз и есть определение result_type для шаблона convert_transformer_t.


Один из трюков при обработке параметров path_to_params/path_to_tuple


Хоть easy_parser_router и базируется на easy_parser, но для упрощения описания маршрутов пришлось сделать для easy_parser_router отдельный DSL в виде функций path_to_params и path_to_tuple. Потому, что описывать маршруты вот в таком виде:


router->http_get(
   path_to_params("/"),
   [](const auto & req) {...});

router->http_get(
   path_to_params("/api/v1/books/", non_negative_decimal_number_p<int>()),
   [](const auto & req, int book_id) {...});

заметно проще, чем как-то так:


router->http_get(
   route_to_params(produce<std::tuple<>>(
      exact_p("/") >> just_result(std::tuple<>{}))),
   [](const auto & req) {});

router->http_get(
   route_to_params(produce<std::tuple<int>>(
      exact("/api/v1/books/"),
      non_negative_decimal_number_p<int>() >> to_tuple<0>())),
  [](const auto & req, int book_id) {...});

Соответственно, чтобы сделать более-менее читабельный DSL нужно пробежаться по типам аргументов функции path_to_params (path_to_tuple) и:


  • определить тип результирующего std::tuple, в котором и будут накапливаться формируемые в процессе парсинга URL значения параметров. Для этого нужно учесть result_type для всех producer-ов, которые были переданы в path_to_params (path_to_tuple);
  • сформировать список типов для clauses из которых должен быть сформирован реальный парсер URL.

Например, если есть вызов:


path_to_params("/")

То в качестве результирующего тупла для значений параметров должен использоваться std::tuple<>. А в качестве списка типов для clauses что-то вроде type_list<exact_fragment_clause_t>.


Тогда как в случае:


path_to_params(
   "/api/v1/books/",
   non_negative_decimal_number_p<int>(),
   "/versions/",
   path_fragment_p())

результирующим туплом для значений окажется std::tuple<int, std::string>, а список типов для clauses будет чем-то вроде: type_list<exact_fragment_clause_t, tuple_item_consumer_t<0, non_negative_decimal_number_producer_t<int>>, exact_fragment_clause_t, tuple_item_consumer_t<1, path_fragment_producer_t>>.


Для решения двух этих задач в реализации easy_parser_router существует вот такой класс (который играет роль продвинутой метафункции):


template< typename... Args >
struct dsl_processor
{
   static_assert( 0u != sizeof...(Args), "Args can't be an empty list" );

   using arg_types = meta::transform_t<
         dsl_details::special_decay, meta::type_list<Args...> >;

   using result_tuple = dsl_details::detect_result_tuple_t< arg_types >;

   using clauses_tuple = dsl_details::make_clauses_types_t< arg_types >;
};

Все содержимое этой метафункции мы рассматривать не будем, а заострим внимание на двух аспектах: получение типов arg_types и result_tuple.


Тип arg_types — это список типов аргументов, но типов, которые избавлены от модификаторов типа const/volatile и ссылочности. Грубо говоря, если в Args для dsl_processor есть const T&, то в arg_types он должен быть заменен на тип T.


Значение arg_types формируется посредством мета-функции transform, работающей над списками типов. Эта метафункция применяет переданный ей предикат (в данном случае это dsl_details::special_decay) к каждому элементу исходного списка типов. Результатом является список типов, порожденный предикатом.


В реализации meta::transform_t нет ничего выдающегося, благо в C++14 для работы со списками типов не нужно подтаскивать в свой проект сторонние библиотеки (вроде Boost-а).


А вот про вывод типа result_tuple можно и поговорить.


Итак, для получения result_tuple следует пробежаться по списку типов аргументов функции path_to_params (path_to_tuple) и для тех типов, которые являются producer-ами, собрать в единый список их result_type. Делается это так:


template< typename Args_Type_List >
struct detect_result_tuple
{
   using type = meta::rename_t<
         typename result_tuple_detector<
               Args_Type_List,
               meta::type_list<> >::type,
         std::tuple >;
};

template< typename Args_Type_List >
using detect_result_tuple_t = typename detect_result_tuple<Args_Type_List>::type;

Метафункция detect_result_tuple обращается к еще одной метафункции result_tuple_detector, которая как раз и формирует список нужных типов. Но result_tuple_detector возвращает type_list<T...>, тогда как нам нужен std::tuple<T...>. Поэтому detect_result_tuple берет этот type_list<T...> и превращает его в std::tuple<T...> посредством простой вспомогательной метафункции rename.


Соответственно, все самое интересное происходит внутри result_tuple_detector. Посмотрим на нее поближе:


template< typename From, typename To >
struct result_tuple_detector;

template<
   template<class...> class From,
   typename... Sources,
   template<class...> class To,
   typename... Results >
struct result_tuple_detector< From<Sources...>, To<Results...> >
{
   using type = typename result_tuple_detector<
         meta::tail_of_t< Sources... >,
         typename add_type_if_necessary<
               meta::head_of_t< Sources... >,
               To< Results... > >::type
      >::type;
};

template<
   template<class...> class From,
   template<class...> class To,
   typename... Results >
struct result_tuple_detector< From<>, To<Results...> >
{
   using type = To<Results...>;
};

Для тех, кто как и я общается с метапрограммированием в C++ на "Вы, Вашевысокоблагородь..." поясню, что фокус здесь в двух специализациях шаблона result_tuple_detector. Вторая, самая простая, специализация:


template<
   template<class...> class From,
   template<class...> class To,
   typename... Results >
struct result_tuple_detector< From<>, To<Results...> >
{
   using type = To<Results...>;
};

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


А вот первая, более сложная специализация, как раз и делает основную работу:


template<
   template<class...> class From,
   typename... Sources,
   template<class...> class To,
   typename... Results >
struct result_tuple_detector< From<Sources...>, To<Results...> >
{
   using type = typename result_tuple_detector<
         meta::tail_of_t< Sources... >,
         typename add_type_if_necessary<
               meta::head_of_t< Sources... >,
               To< Results... > >::type
      >::type;
};

Работа ее заключается в следующем: когда исходный список типов еще не пуст, то нужно рекурсивно "вызвать" саму себя, при этом в качестве нового исходного списка типов будет использоваться хвост текущего исходного списка, а в качестве нового результирующего списка типов будет результат вспомогательной функции add_type_if_necessary<H, R_List>. В эту вспомогательную функцию в качестве аргументов передается голова текущего исходного списка типов и текущий результирующий список. Возвращает add_type_if_necessary<H, R_List> либо тот же самый результирующий список (R_List), если H не является типом producer-а. Либо же возвращается обновленный список, в котором к R_List добавлен H::result_type, если H является типом producer-а.


Выглядит же реализация add_type_if_necessary следующим образом:


template< typename H, typename R, bool Is_Producer >
struct add_type_if_necessary_impl;

template<
   typename H,
   template<class...> class To,
   typename... Results >
struct add_type_if_necessary_impl< H, To<Results...>, false >
{
   using type = To<Results...>;
};

template<
   typename H,
   template<class...> class To,
   typename... Results >
struct add_type_if_necessary_impl< H, To<Results...>, true >
{
   using type = To<Results..., typename H::result_type>;
};

// Adds type H to type list R if H is a producer.
template< typename H, typename R >
struct add_type_if_necessary
   : add_type_if_necessary_impl< H, R, ep::impl::is_producer_v<H> >
{};

где is_producer_v — это метафункция-предикат из easy_parser, которая определяет, является ли тип типом producer-а или нет.


Заключение


easy_parser_router — это эксперимент...


… результат которого станет известен лишь по прошествии времени. Мне бы хотелось, чтобы результат был положительным, поскольку раз уж C++ компилятор позволяет отлавливать некоторые ошибки программиста в compile-time, то странно было бы эти не воспользоваться.


С другой стороны, такой безопасный по типам роутер HTTP-запросов в C++ — это что-то новенькое, странное и непривычное. По крайней мере я сходу не смогу вспомнить чего-нибудь подобного в живых альтернативах RESTinio.


И подобное нововведение могут принять негативно, т.к. нужно будет описывать маршруты совсем по-другому. А текущий easy_parser — это не самая простая штука. Хотя мы и старались сделать его настолько простым в изучении и использовании, насколько это возможно для нас.


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


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


Я не настоящий сварщик


Так получилось, что связанная с easy_parser и easy_parser_router функциональность реализована в RESTinio мной. А мне хоть еще и приходится писать код, но уже давно это только часть того, чем мне доводится заниматься.


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


Наверняка можно сделать проще, удобнее и мощнее. У меня вышло вот так. У вас может получиться лучше.


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


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


Это все


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


Если у кого-то есть вопросы, связанные с RESTinio, easy_parser и/или easy_parser_router, то я постараюсь на них ответить в комментариях.


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

Теги:
Хабы:
+19
Комментарии9

Публикации

Изменить настройки темы

Истории

Работа

Программист C++
128 вакансий
QT разработчик
7 вакансий

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

Weekend Offer в AliExpress
Дата20 – 21 апреля
Время10:00 – 20:00
Место
Онлайн
Конференция «Я.Железо»
Дата18 мая
Время14:00 – 23:59
Место
МоскваОнлайн