пятница, 27 сентября 2019 г.

[prog.c++] Двойственные чувства после достижения задуманного результата

Только что закончил некоторый кусок работы над RESTinio, в котором довелось погрузиться в метапрограммирование на C++ных шаблонах. Получилось сделать именно то, что задумывалось (попутно было доказано, что ранее сделанные предположения, действительно, оказались реализуемыми), но при взгляде на результат возникают двойственные чувства. Типа "а стоило ли оно того?"

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

Итак, потихонечку развитие RESTinio дошло до стадии, когда стало ощущаться отсутствие некоторых полезных вещей. Например, RESTinio позволяет получить доступ к значению HTTP-поля из заголовка (вроде поля "Content-Type"), но зато со значением этого поля уже нужно будет разбираться самостоятельно. Т.е. если вы берете поле "Content-Type", а там лежит "text/plain; charset=utf-8", то парсить эту строку вам нужно будет самим, вручную.

Подумалось, что это не есть хорошо и что RESTinio должно предоставлять что-то пользователю. Но что именно?

В результате было решено не делать в RESTinio полную поддержку полноценного разбора HTTP-полей, а предоставить пару простых функций, которые могут взять содержимое HTTP-поля и разбить его на отдельные значения. Так, из строки "text/plain; charset=utf-8" пользователь может с помощью RESTinio получить отдельные подстроки "text/plain" и "charset=utf-8". А с подстроками уже работать несколько проще.

Но тут возникает вопрос: а как именно должен выглядеть API для такого вот парсинга?

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

std::string name_value;
std::string filename_value;
if( restinio::http_field_parser::try_parse_whole_field(
      line.m_line,
      "content-disposition",
      ';',
      restinio::http_field_parser::expect( "form-data" ),
      restinio::http_field_parser::name_value( "name", name_value ),
      restinio::http_field_parser::name_value( "filename", filename_value ) ) )
{
   if( name_value == "file" )
   {
      file_name = filename_value;
   }
}

Здесь суть в том, что мне нужно распарсить содержимое HTTP-поля и взять оттуда значения двух фрагментов (которые внутри HTTP-поля имеют вид name=value). Для того, чтобы получить эти два значения я объявляю две переменные и уже затем вызываю функцию для парсинга HTTP-поля.

При вызове функции try_parse_whole_field я указываю из чего HTTP-поле должно состоять, а именно:

  • поле должно называться "content-disposition";
  • фрагменты этого поля должны отделяться друг от друга посредством точки с запятой;
  • у этого поля должно быть значение "form-data";
  • за значением поля ожидается фрагмент вида name=<какое-то-значение>. Вот это значение должно попасть в переменную name_value;
  • затем ожидается фрагмент вида filename=<какое-то-значение>. Вот это значение должно попасть в переменную filename_value.

Далее все просто -- если HTTP-поле удалось полностью разобрать с помощью описанных правил, то значение из фрагмента filename будет использоваться далее.

В общем-то простой API, для реализации которого из "больших" возможностей современного C++ используется разве что variadic templates, т.к. функция try_parse_whole_field шаблонная и может принимать разное количество аргументов.

Но такой вариант API мне лично не очень понравился. Все-таки веяние функционального программирования начинает сказываться даже на мне. Поэтому первая шутка, которая меня смущает, -- это то, что try_parse_whole_field не дает возможности получить сразу весь результат в виде одного объекта, из которого затем можно забирать нужные мне значения. Т.е. вместо того, чтобы создавать переменные name_value и filename_value до вызова try_parse_whole_field, я бы предпочел каким-то образом получить их как результат вызова try_parse_whole_field.

Второй момент, который мне не нравился -- это то, что простой алгоритм парсинга HTTP-поля модифицировал значения переменных. Т.е. если у меня есть HTTP-поле вида "Content-Disposition: form-data; name=user-id", то try_parse_whole_field вернет false, но, т.к. фрагмент "name=user-id" все-таки был найден, то переменная name_value получит значение "user-id". Хотя значение это никому не нужно. Это, конечно, не большая проблема, но если пытаться парсить последовательность HTTP-полей, то может оказаться, что в каких-то переменных окажется не нужный мусор. А чтобы избавиться от этой проблемы нужно было бы перейти на какой-то более сложный алгоритм парсинга с хранением где-то промежуточных значений. Что так же не вселяло в меня оптимизма.

В результате появилась идея возвращать из try_parse_whole_field объект std::tuple<bool, ...>, где первый элемент указывал бы на успешность парсинга, а последующие элементы содержали бы извлеченные из HTTP-поля значения.

Фокус был в том, чтобы по списку аргументов try_parse_whole_field вывести тип результирующего std::tuple. Т.к. в каких-то случаях это может быть std::tuple<bool, string, string>, а в каких-то -- просто std::tuple<bool>. И этот фокус удалось разгадать посредством шаблонного метапрограммирования (благо после C++11 манипуляции с typelist-ами стали доступны даже моему пониманию, хотя и не без некоторого труда).

Так что вызов try_parse_whole_field преобразился вот в такой:

const auto r = restinio::http_field_parser::try_parse_whole_field(
      line.m_line,
      "content-disposition",
      ';',
      restinio::http_field_parser::expect( "form-data" ),
      restinio::http_field_parser::name_value( "name" ),
      restinio::http_field_parser::name_value( "filename" ) );

if( std::get<0>(r) && "file" == std::get<1>(r) )
{
   file_name = std::get<2>(r);
}

И вот такой получившийся результат сильно меня озадачил :(

С одной стороны, вышло именно то, что и требовалось.

Но, с другой стороны, я бы не сказал, что читабельность получившегося результата меня устраивает. Как по мне, так такой вариант и хуже читается, и может быть подвержен ошибкам при сопровождении. Допустим, изменился список фрагментов, которые мы ожидаем внутри HTTP-поля, а индексы при последующих вызовах std::get не поменялись... И ищи потом ошибки в run-time.

Несколько лучше этот же код выглядит в C++17, где можно использовать structured binding:

const auto & [success, field_name, filename_field_value] =
   restinio::http_field_parser::try_parse_whole_field(
      line.m_line,
      "content-disposition",
      ';',
      restinio::http_field_parser::expect( "form-data" ),
      restinio::http_field_parser::name_value( "name" ),
      restinio::http_field_parser::name_value( "filename" ) );

if( success && "file" == field_name )
{
   file_name = filename_field_value;
}

Это все равно довольно-таки хрупкий код, поскольку он чувствителен к изменению списка параметров при вызове try_parse_whole_field. Но зато у него читабельность сильно лучше. Хотя для достижения такой читабельности требуется уже не C++14, а C++17.

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


Если кому-то интересно, как формируется результирующий тип std::tuple, то здесь используется подход с формированием typelist-а. Нужно пробежаться по аргументам try_parse_whole_string и для каждого из них понять, возвращает ли аргумент значение или нет. Если возвращает, то в формируемый typelist добавляется тип возвращаемого значения (пока это только std::string). Если же аргумент не требует возврата значения (как в случаях с exact("form-data")), то формируемый typelist остается без изменений.

Ну а когда сформированный typelist получен, нужно всего лишь взять его содержимое и перенести в std::tuple.

Пока в черновом виде в коде это выглядит вот так:

namespace meta {

namespace mp = restinio::utils::metaprogramming;

template<typename T>
using to_tuple_t = mp::rename_t<T, std::tuple>;

template<typename T>
struct is_void_value_type
{
   static constexpr bool value =
         std::is_same<voidtypename T::value_type>::value;
};

template<typename T>
struct detect_increment
{
   static constexpr std::size_t value =
         is_void_value_type<T>::value ? 0u : 1u;
};

template<typename T>
struct type_list_maker;

template<template<class...> class L>
struct type_list_maker< L<> >
{
   using type = mp::type_list<>;
};

template<
   template<class...> class L,
   typename... Rest>
struct type_list_maker< L<Rest...> >
   : type_list_maker< mp::tail_of_t<Rest...> >
{
   using base_type = type_list_maker< mp::tail_of_t<Rest...> >;
   using T = mp::head_of_t<Rest...>;

   using type = typename std::conditional<
         is_void_value_type<T>::value,
         typename base_type::type,
         mp::put_front_t<typename T::value_type, typename base_type::type>
      >::type;
};

template<typename... Rest>
using make_type_list_t =
      typename type_list_maker< mp::type_list<Rest...> >::type;

template<typename... Fragments>
using result_type_detector_t =
      to_tuple_t<
            mp::put_front_t<
                  bool,
                  make_type_list_t<Fragments...>
            >
      >;

/* namespace meta */

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

2 комментария:

Grigory Demchenko комментирует...

Оба варианта так себе. Если ты знаешь токены и как распарсить, то просто берешь и парсишь все подряд, возвращая map. Либо парсишь в 2 прохода: разбиваешь на части, а потом к каждой части применяешь парсинг name=value. Значения возвращаешь как string_view.

eao197 комментирует...

@Grigory Demchenko

map не есть хорошая идея. Во-первых, map предполагает, что элементы будут однотипные. Например, только строки. Между тем, внутри HTTP-полей могут быть значения разных типов. Обычно строки, но могут быть и списки строк, а так же целочисленные или вещественные значения. Или даже можно сразу поле преобразовывать из строки в datetime какой-нибудь.

Во-вторых, map -- это все-таки ощутимый удар по производительности. Создать пустой tuple с тремя std::string внутри, а потом наполнить эти string-и все-таки эффективнее, чем создать сперва пустой map, затем в нем динамически создать ноды со string-ами, а потом еще и потратить время на доступ к этим нодам, а потом еще и потратить время на раздельное удаление каждой из нод. Сложно сказать, насколько критично будет такой пеннальти в реальных кейсах, но все-таки для REST API C++ выбирают тогда, когда производительность терять не хочется.

Во-вторых, все-таки поиск значений по map-у после парсинга так же ведет к созданию хрупкого кода. Опечатаешься в названии ключа при доступе после парсинга и получишь ошибку только в run-time.

> Либо парсишь в 2 прохода: разбиваешь на части, а потом к каждой части применяешь парсинг name=value. Значения возвращаешь как string_view.

Там не так все просто. С одной стороны, далеко не все фрагменты в HTTP-полях имеют формат name=value. С другой стороны, value может быть задано как quoted string, внутри которой будут применяться escape-последовательности. И такое значение должно быть трансформировано в нормальную строку. Соответственно, эту строку придется возвращать как string, а не как string_view.