вторник, 29 октября 2019 г.

[prog.c++] Эволюция средств поддержки загрузки файлов на сервер в RESTinio

Некоторое время назад я в блоге показывал фрагменты экспериментов с парсером значений HTTP-полей в RESTinio (вот, например, последний из этих постов на данный момент). Все это было нужно не само по себе, а как часть более общей задачи. В частности, как часть предоставления пользователям RESTinio простого механизма обработки загружаемых на сервер файлов (т.н. file uploading). Ниже приведены примеры того, с чего поддержка file upload начиналась месяц назад и к чему удалось прийти на данный момент. Кому интересно, милости прошу под кат.

Итак, началось все с попытки добавить в набор штатных примеров RESTinio еще один пример под названием file_upload, в котором бы показывалось, как можно отослать клиенту простую форму с элементом типа "file" и затем обработать multi-part POST, в котором на сервер придет содержимое какого-то файла.

Соответственно, получился довольно объемный (и не очень корректный) код, который вряд ли можно было бы использовать в качестве примера того, как RESTinio следует использовать. Непосредственно за работу с multi-part телом запроса и обработку той части тела, которая содержит файл, отвечал вот этот фрагмент:

bool starts_with(
   const restinio::string_view_t & where,
   const restinio::string_view_t & what ) noexcept
{
   return where.size() >= what.size() &&
         0 == where.compare(0u, what.size(), what);
}

bool ends_with(
   const restinio::string_view_t & where,
   const restinio::string_view_t & what ) noexcept
{
   return where.size() >= what.size() && 0 == where.compare(
         where.size() - what.size(), what.size(), what);
}

std::string to_string(
   const restinio::string_view_t & what )
{
   return { what.data(), what.size() };
}

std::string get_boundary(
   const restinio::request_handle_t & req )
{
   // There should be content-type field.
   const auto content_type = req->header().value_of(
         restinio::http_field::content_type );

   std::string boundary;
   if( !restinio::http_field_parser::try_parse_field_value(
         content_type, ';',
         restinio::http_field_parser::expect( "multipart/form-data" ),
         restinio::http_field_parser::name_value(
               "boundary", boundary ) ) )
      throw std::runtime_error( "unable to parse content-type field: " +
            to_string( content_type ) );

   if( boundary.empty() )
      throw std::runtime_error( "empty 'boundary' in content-type field: " +
            to_string( content_type ) );

   std::string result;
   result.reserve( 2u + boundary.size() );
   result.append( "--" );
   result.append( boundary );

   return result;
}

struct line_from_buffer_t
{
   restinio::string_view_t m_line;
   restinio::string_view_t m_remaining_buffer;
};

line_from_buffer_t get_line_from_buffer(
   const restinio::string_view_t & buffer )
{
   const restinio::string_view_t eol{ "\r\n" };
   const auto pos = buffer.find( eol );
   if( restinio::string_view_t::npos == pos )
      throw std::runtime_error( "no lines with the correct EOL in the buffer" );

   return { buffer.substr( 0u, pos ), buffer.substr( pos + eol.size() ) };
}

void store_file_to_disk(
   const app_args_t & args,
   const std::string & file_name,
   restinio::string_view_t raw_content )
{
   const restinio::string_view_t content_terminator{ "\r\n" };
   if( ends_with( raw_content, content_terminator ) )
      raw_content = raw_content.substr( 0u,
            raw_content.size() - content_terminator.size() );

   std::ofstream dest_file;
   dest_file.exceptions( std::ofstream::failbit );
   dest_file.open( args.m_dest_folder + "/" + file_name,
         std::ios_base::out | std::ios_base::trunc | std::ios_base::binary );
   dest_file.write( raw_content.data(), raw_content.size() );
}

bool try_handle_body_fragment(
   const app_args_t & args,
   restinio::string_view_t fragment )
{
   // Process fields at the beginning of the fragment.
   restinio::optional_t< std::string > file_name;
   auto line = get_line_from_buffer( fragment );
   for(; !line.m_line.empty();
         line = get_line_from_buffer( line.m_remaining_buffer ) )
   {
      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;
         }
      }
   }

   if( file_name )
   {
      store_file_to_disk(
            args,
            to_string( *file_name ),
            line.m_remaining_buffer );

      return true;
   }

   return false;
}

void save_file(
   const app_args_t & args,
   const restinio::request_handle_t & req )
{
   const auto boundary = get_boundary( req );

   const restinio::string_view_t eol{ "\r\n" };
   const restinio::string_view_t last_separator{ "--\r\n" };

   restinio::string_view_t body_view =
      [&boundary]( restinio::string_view_t body )
      {
         auto start = body.find( boundary );
         if( restinio::string_view_t::npos == start )
            throw std::runtime_error( "the first separator "
                  "isn't found in request body, boundary is: " +
                  boundary );
         return body.substr( start + boundary.size() );
      }( req->body() );

   while( body_view.size() > boundary.size()
         && !starts_with( body_view, last_separator ) )
   {
      if( starts_with( body_view, eol ) )
         body_view = body_view.substr( eol.size() );
      else
         throw std::runtime_error( "EOL is not found after a fragment-openning "
               "boundary" );

      const auto end = body_view.find( boundary );
      if( restinio::string_view_t::npos == end )
         throw std::runtime_error( "the next separator "
               "isn't found in request body, boundary is: " +
               boundary );

      if( try_handle_body_fragment( args, body_view.substr( 0u, end ) ) )
         break;

      body_view = body_view.substr( end + boundary.size() );
   }
}

Поскольку в стандартной библиотеке C++14 нет таких полезных вещей, как starts_with и ends_with, то их так же приходится делать самостоятельно, что добавляет объема коду.

Но все-таки основная часть приведенного выше фрагмента -- это прямая, незамысловатая и неправильная попытка разобраться с multi-part телом запроса, что называется, "в лоб", методом грубой силы.

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

Поэтому последовало несколько итераций над RESTinio и теперь пользователю потребуется написать вот столько кода:

void store_file_to_disk(
   const app_args_t & args,
   restinio::string_view_t file_name,
   restinio::string_view_t raw_content )
{
   std::ofstream dest_file;
   dest_file.exceptions( std::ofstream::failbit );
   dest_file.open(
         fmt::format( "{}/{}", args.m_dest_folder, file_name ),
         std::ios_base::out | std::ios_base::trunc | std::ios_base::binary );
   dest_file.write( raw_content.data(), raw_content.size() );
}

void save_file(
   const app_args_t & args,
   const restinio::request_handle_t & req )
{
   using namespace restinio::file_upload;

   const auto enumeration_result = enumerate_parts_with_files(
         *req,
         [&args]( const part_description_t & part ) {
            if"file" == part.name_parameter() )
            {
               restinio::string_view_t file_name =
                     part.filename_star_parameter() ?
                     *(part.filename_star_parameter()) :
                     *(part.filename_parameter());
               store_file_to_disk( args, file_name, part.body() );

               // There is no need to handle other parts.
               return handling_result_t::stop_enumeration;
            }

            // We expect only one part with name 'file'.
            // So if that part is not found yet there is some error
            // and there is no need to continue.
            return handling_result_t::terminate_enumeration;
         } );

   if( !enumeration_result || 1u != *enumeration_result )
      throw std::runtime_error( "file content not found!" );
}

При этом функция store_file_to_disk изменилась, разве что, косметически.

Вся рутина теперь перешла во вспомогательную функцию enumerate_parts_with_files из нового пространства имен restinio::file_upload. Эта функция берет весь входящий запрос, анализирует значение его заголовка Content-Type и, если там лежит multipart/form-data с параметром boundary, то пытается разбить тело полученного запроса на отдельные части. Если разделить удалось, то для каждой части происходит дополнительный разбор на набор HTTP-заголовков (полей) и body. Если это удалось, то для каждой части проверяется наличие и содержимое Content-Disposition. Если для очередной части Content-Disposition содержит значение form-data и параметры name + filename/filename*, то для такой части вызывается заданный пользователем обработчик, в котором уже можно делать содержимым этой части что угодно. В том числе и записывать на диск.

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

Хотя есть ощущение, что неправильно ограничиваться одной enumerate_parts_with_files вот в таком вот виде. Подозреваю, что использовать ее будет удобно разве что если пришел запрос, в котором нас интересуют только загружаемые на сервер файлы. А вот если в POST-запросе лежит еще какая-то дополнительная информация, например, значения других полей из отправленной ранее клиенту формы, то тут нужно иметь еще что-то в дополнение к enumerate_parts_with_files...

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

Кстати говоря, если кто-то что-то хочет увидеть в RESTinio, то можно сказать нам об этом. Хоть здесь, хоть в Issues на github-е, хоть в Google-группе.

Комментариев нет: