С начала 2017-го года наша небольшая команда разрабатывает OpenSource-библиотеку RESTinio для встраивания HTTP-сервера в C++ приложения. К своему большому удивлению мы время от времени получаем вопросы из категории «А для чего может потребоваться встраиваемый HTTP-сервер на C++?» К сожалению, на простые вопросы отвечать сложнее всего. Иногда лучшим ответом является пример кода.

Пару месяцев назад мы затеяли небольшой демо-проект Shrimp, который наглядно демонстрирует типичный сценарий, под который «затачивается» наша библиотека. Демо-проект представляет из себя простой Web-сервис, который получает запросы на масштабирование хранящихся на сервере картинок и который отдает в ответ картинку нужного пользователю размера.

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

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

Работу на Shrimp-ом мы построили итеративным путем: сперва была сделана и описана самая простая версия, которая только масштабировала картинки. Затем мы устранили ряд недочетов первой версии и описали это во второй статье. Наконец дошли руки расширить функциональность Shrimp-а еще раз: добавилось преобразование картинок из одного формата в другой. О том, как это было сделано и пойдет речь в данной статье.

Поддержка target-format


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

curl "http://localhost:8080/my_picture.jpg?op=resize&max=1920"

то Shrimp отдаст картинку в том же самом формате JPG, в котором была исходная картинка.

Но если добавить в URL параметр target-format, то Shrimp преобразует картинку в указанный целевой формат. Например:

curl "http://localhost:8080/my_picture.jpg?op=resize&max=1920&target-format=webp"

В этом случае Shrimp отдаст картинку в формате webp.

Обновленный Shrimp поддерживает пять форматов изображений: jpg, png, gif, webp и heic (так же известный как HEIF). Поэкспериментировать с различными форматами можно на специальной web-страничке:



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

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

ImageMagick должен понимать разные форматы


ImageMagick для кодирования/декодирования изображений использует внешние библиотеки: libjpeg, libpng, libgif и т.д. Эти библиотеки должны быть установлены в системе до того, как ImageMagick будет сконфигурирован и собран.

Тоже самое должно произойти и для того, чтобы ImageMagick поддерживал форматы webp и heic: сперва нужно собрать и установить libwebp и libheif, потом уже конфигурировать и устанавливать ImageMagick. И если с libwebp все просто, то вот вокруг libheif пришлось поплясать с бубном. Хотя по прошествии времени, после того, как все в итоге собралось и заработало, уже и непонятно: а почему же пришлось прибегать к бубну, вроде бы все тривиально? ;)

В общем, если кто-то хочет подружить heic и ImageMagick, то придется установить:


Именно в таком порядке (возможно, придется еще поставить nasm, дабы x265 работал с максимальной скоростью). После чего при выдаче команды ./configure ImageMagick сможет найти все, что ему нужно для поддержки .heic-файлов.

Поддержка target-format в query string входящих запросов


После того, как мы подружили ImageMagick с форматами webp и heic, пришло время модифицировать код Shrimp-а. Прежде всего, нам нужно научиться распознавать аргумент target-format во входящих HTTP-запросах.

С точки зрения RESTinio это вообще не проблема. Ну появился в query string еще один аргумент, ну и что? А вот с точки зрения Shrimp-а ситуация оказалась несколько сложнее, поэтому усложнился и код функции, которая отвечала за разбор HTTP-запроса.

Дело в том, что раньше нужно было различать всего две ситуации:

  • пришел запрос вида "/filename.ext" без каких-либо других параметров. Значит нужно просто отдать файл «filename.ext» как он есть;
  • пришел запрос вида "/filename.ext?op=resize&...". В этом случае нужно отмасштабировать картинку из файла «filename.ext».

Но после добавления target-format нам нужно различать уже четыре ситуации:

  • пришел запрос вида "/filename.ext" без каких-либо других параметров. Значит нужно просто отдать файл «filename.ext» как он есть, без масштабирования и без перекодирования в другой формат;
  • пришел запрос вида "/filename.ext?target-format=fmt" без каких-либо других параметров. Значит взять изображение из файла «filename.ext» и перекодировать его в формат «fmt» с сохранением оригинальных размеров;
  • пришел запрос вида "/filename.ext?op=resize&..." но без target-format. В этом случае нужно отмасштабировать картинку из файла «filename.ext» и отдать ее в оригинальном формате;
  • пришел запрос вида "/filename.ext?op=resize&...&target-format=fmt". В этом случае нужно выполнить масштабирование, а затем перекодировать результат в формат «fmt».

В итоге функция для определения параметров запроса приняла следующий вид:

void
add_transform_op_handler(
   const app_params_t & app_params,
   http_req_router_t & router,
   so_5::mbox_t req_handler_mbox )
{
   router.http_get(
      R"(/:path(.*)\.:ext(.{3,4}))",
         restinio::path2regex::options_t{}.strict( true ),
         [req_handler_mbox, &app_params]( auto req, auto params )
         {
            if( has_illegal_path_components( req->header().path() ) )
            {
               // Задан недопустимый путь к файлу.
               return do_400_response( std::move( req ) );
            }

            // Разбираем параметры запроса.
            const auto qp = restinio::parse_query( req->header().query() );
            const auto target_format = qp.get_param( "target-format"sv );

            // Пытаемся определить в каком формате отдать итоговое
            // изображение. Если задан target-format, то именно этот
            // аргумент определяет формат. Если же target-format не
            // задан, то используется исходный формат, заданный
            // расширением файла с изображением.
            const auto image_format = try_detect_target_image_format(
                  params[ "ext" ],
                  target_format );
            if( !image_format )
            {
               // Не смогли разобраться с форматом. Запрос не обслуживаем.
               return do_400_response( std::move( req ) );
            }

            if( !qp.size() )
            {
               // Нет никаких дополнительных аргументов, отдаем картинку как есть.
               return serve_as_regular_file(
                     app_params.m_storage.m_root_dir,
                     std::move( req ),
                     *image_format );
            }

            const auto operation = qp.get_param( "op"sv );
            if( operation && "resize"sv != *operation )
            {
               // Задана операция над изображением, но эта операция не resize.
               return do_400_response( std::move( req ) );
            }

            if( !operation && !target_format )
            {
               // В запросе должно быть либо op=resize,
               // либо же target-format=something.
               return do_400_response( std::move( req ) );
            }

            handle_resize_op_request(
                  req_handler_mbox,
                  *image_format,
                  qp,
                  std::move( req ) );

            return restinio::request_accepted();
   } );
}

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

Очередь запросов и кэш картинок с учетом target-format


Следующим моментом в реализации поддержки target-format стала работа к очередью ждущих запросов и кэшем готовых картинок в агенте a_transform_manager. Подробнее про эти штуки речь шла в предыдущей статье, но слегка напомним о чем речь.

Когда приходит запрос на преобразование картинки, то может оказаться, что готовая картинка с такими параметрами уже есть в кэше. В этом случае ничего делать не нужно, достаточно отослать в ответ изображение из кэша. Если же картинку нужно преобразовывать, то может оказаться, что свободных воркеров на данный момент нет и нужно подождать, пока таковой появится. Для этого информацию о запросе нужно поставить в очередь ожидания. Но при этом нужно проверять уникальность запросов — если у нас ждут обработки три одинаковых запроса (т.е. нужно одним и тем же образом преобразовать одну и ту же картинку), то следует всего лишь один раз обработать картинку, а результат обработки отдать в ответ на эти три запроса. Т.е. в очереди ожидания одинаковые запросы нужно группировать.

Ранее в Shrimp-е мы использовали несложный составной ключ для поиска в кэше картинок и в очереди ожидания: комбинация из исходного имени файла + параметры ресайзинга картинки. Сейчас же нужно было учесть два новых фактора:

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

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

Зачем такое двойное кэширование могло бы понадобиться? Дело в том, что при трансформации картинок двумя самыми дорогими по времени операциями являются ресайзинг и сериализация картинки в целевой формат. Поэтому, если бы мы получили запрос на масштабирование картинки example.jpg до размера 1920 по ширине и трансформации ее в формат webp, то мы могли бы сохранить в памяти два изображения: example_1920px_width.jpg и example_1920px_width.webp. Картинку example_1920px_width.webp мы бы отдавали при получении повторного запроса. А вот картинка example_1920px_width.jpg могла бы использоваться при получении запросов на масштабирование example.jpg до размера 1920 по ширине и трансформации ее в формат heic. Мы бы могли пропустить операцию ресайзинга и сделать только преобразование формата (т.е. уже готовое изображение example_1920px_width.jpg перекодировалось бы в формат heic).

Еще одна потенциальная возможность: когда приходит запрос на перекодирование изображение в другой формат без ресайзинга, то можно определить реальный размер картинки и использовать этот размер внутри составного ключа. Например, пусть example.jpg имеет размер 3000x2000 пикселей. Если мы следом получаем запрос на масштабирование example.jpg до 2000px по высоте, то мы можем сразу же определить, что картинка в таком размере у нас уже есть.

В теории все эти рассуждения заслуживают внимания. Но вот с практической точки зрения непонятно, насколько высока вероятность такого развития событий. Т.е. как часто мы будем получать запрос на масштабирование example.jpg до 1920px с конвертацией в webp, а затем запрос на такое же масштабирование этой же картинки, но с конвертацией в png? Не имея реальной статистики сложно сказать. Поэтому мы решили не усложнять себе жизнь в своем демо-проекте, а пойти сперва по самому простому пути. С расчетом на то, что если кому-то потребуются более продвинутые схемы кэширования, то это можно будет добавить впоследствии, отталкиваясь от реальных, а не вымышленных сценариев использования Shrimp-а.

В итоге в обновленной версии Shrimp-а мы слегка расширили ключ, добавив в него еще и такой параметр, как целевой формат:

class resize_request_key_t
{
   std::string m_path;
   image_format_t m_format;
   resize_params_t m_params;

public:
   resize_request_key_t(
      std::string path,
      image_format_t format,
      resize_params_t params )
      :  m_path{ std::move(path) }
      ,  m_format{ format }
      ,  m_params{ params }
   {}

   [[nodiscard]] bool
   operator<(const resize_request_key_t & o ) const noexcept
   {
      return std::tie( m_path, m_format, m_params )
            < std::tie( o.m_path, o.m_format, o.m_params );
   }

   [[nodiscard]] const std::string &
   path() const noexcept
   {
      return m_path;
   }

   [[nodiscard]] image_format_t
   format() const noexcept
   {
      return m_format;
   }

   [[nodiscard]] resize_params_t
   params() const noexcept
   {
      return m_params;
   }
};

Т.е. запрос на ресайзинг example.jpg до размера 1920px с конвертацией в png отличается от такого же ресайзинга, но с конвертацией в webp или heic.

Но главный фокус прячется в новой реализации класса resize_params_t, который и определяет новые размеры отмасштабированной картинки. Ранее этот класс поддерживал три варианта: задана только ширина, задана только высота или задана длинная сторона (высота или ширина выясняется по актуальному размеру картинки). Соответственно, метод resize_params_t::value() всегда возвращал какое-то реальное значение (что это за значение определялось методом resize_params_t::mode()).

А вот в новом Shrimp-е добавился еще один режим — keep_original, который означает, что масштабирование не выполняется и картинка отдается в своем исходном размере. Для поддержки этого режима в resize_params_t пришлось внести некоторые изменения. Во-первых, теперь метод resize_params_t::make() определяет, используется ли режим keep_original (считается, что этот режим используется если не определен ни один из параметров width, height и max в query string входящего запроса). Это позволило не переписывать функцию handle_resize_op_request(), которая и толкает на исполнение запрос на масштабирование картинки.

Во-вторых, метод resize_params_t::value() теперь можно вызывать не всегда, а только когда режим масштабирования отличается от keep_original.

Но самое важное, что resize_params_t::operator<() продолжил работать так, как это и задумывалось.

Благодаря всем этим изменениям в a_transform_manager и кэш отмасштабированных изображений, и очередь ждущих запросов, остались теми же самыми. Но зато сейчас в этих структурах данных хранится информация о разнообразных запросах. Так, ключ {«example.jpg», «jpg», keep_original} будет отличаться и от ключа {«example.jpg», «png», keep_original}, и от ключа {«example.jpg», «jpg», width=1920px}.

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

Поддержка target-format в a_transformer


Ну и финальный шаг в поддержке target-format — это расширение логики работы агента a_transformer таким образом, чтобы картинка, возможно уже отмасштабированная, затем преобразовывалась к целевому формату.

Сделать это оказалось проще всего, достаточно было лишь расширить код метода a_transform_t::handle_resize_request():

[[nodiscard]]
a_transform_manager_t::resize_result_t::result_t
a_transformer_t::handle_resize_request(
   const transform::resize_request_key_t & key )
{
   try
   {
      m_logger->trace( "transformation started; request_key={}", key );

      auto image = load_image( key.path() );

      const auto resize_duration = measure_duration( [&]{
            // Реальный ресайзинг требуется только если режим
            // масштабирования отличается от keep_original.
            if( transform::resize_params_t::mode_t::keep_original !=
                  key.params().mode() )
            {
               transform::resize(
                     key.params(),
                     total_pixel_count,
                     image );
            }
         } );
      m_logger->debug( "resize finished; request_key={}, time={}ms",
            key,
            std::chrono::duration_cast<std::chrono::milliseconds>(
                  resize_duration).count() );

      image.magick( magick_from_image_format( key.format() ) );

      datasizable_blob_shared_ptr_t blob;
      const auto serialize_duration = measure_duration( [&] {
               blob = make_blob( image );
            } );
      m_logger->debug( "serialization finished; request_key={}, time={}ms",
            key,
            std::chrono::duration_cast<std::chrono::milliseconds>(
                  serialize_duration).count() );

      return a_transform_manager_t::successful_resize_t{
            std::move(blob),
            std::chrono::duration_cast<std::chrono::microseconds>(
                  resize_duration),
            std::chrono::duration_cast<std::chrono::microseconds>(
                  serialize_duration) };
   }
   catch( const std::exception & x )
   {
      return a_transform_manager_t::failed_resize_t{ x.what() };
   }
}

По сравнению с предыдущей версией здесь есть два принципиальных дополнения.

Во-первых, вызов по-настоящему магического метода image.magick() после выполнения ресайзинга. Этот метод указывает ImageMagick-у результирующий формат изображения. При этом представление картинки в памяти не меняется — ImageMagick продолжает ее хранить так, как ему это удобно. Но зато заданное методом magick() значение будет учтено при последующем вызове Image::write().

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

В остальном же агент a_transformer_t не претерпел никаких изменений.

Распараллеливание работы ImageMagick-а


По умолчанию ImageMagic собирается с поддержкой OpenMP. Т.е. есть возможность распараллелить операции над изображениями, которые выполняет сам ImageMagick. Управлять количеством рабочих потоков, которые задействует в этом случае ImageMagick, можно посредством переменной окружения MAGICK_THREAD_LIMIT.

Например, на своей тестовой машине со значением MAGICK_THREAD_LIMIT=1 (т.е. без реального распараллеливания) я получаю следующие результаты:

curl "http://localhost:8080/DSC08084.jpg?op=resize&max=2400" -v > /dev/null
> GET /DSC08084.jpg?op=resize&max=2400 HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/7.47.0
> Accept: */*
> 
< HTTP/1.1 200 OK
< Connection: keep-alive
< Content-Length: 2043917
< Server: Shrimp draft server
< Date: Wed, 15 Aug 2018 11:51:24 GMT
< Last-Modified: Wed, 15 Aug 2018 11:51:24 GMT
< Access-Control-Allow-Origin: *
< Access-Control-Expose-Headers: Shrimp-Processing-Time, Shrimp-Resize-Time, Shrimp-Encoding-Time, Shrimp-Image-Src
< Content-Type: image/jpeg
< Shrimp-Image-Src: transform
< Shrimp-Processing-Time: 1323
< Shrimp-Resize-Time: 1086.72
< Shrimp-Encoding-Time: 236.276

Время, затраченное на ресайзинг указывается в заголовке Shrimp-Resize-Time. В данном случае это 1086.72ms.

А вот если на этой же машине задать MAGICK_THREAD_LIMIT=3 и запустить Shrimp, то получаем уже другие значения:

curl "http://localhost:8080/DSC08084.jpg?op=resize&max=2400" -v > /dev/null
> GET /DSC08084.jpg?op=resize&max=2400 HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/7.47.0
> Accept: */*
> 
< HTTP/1.1 200 OK
< Connection: keep-alive
< Content-Length: 2043917
< Server: Shrimp draft server
< Date: Wed, 15 Aug 2018 11:53:49 GMT
< Last-Modified: Wed, 15 Aug 2018 11:53:49 GMT
< Access-Control-Allow-Origin: *
< Access-Control-Expose-Headers: Shrimp-Processing-Time, Shrimp-Resize-Time, Shrimp-Encoding-Time, Shrimp-Image-Src
< Content-Type: image/jpeg
< Shrimp-Image-Src: transform
< Shrimp-Processing-Time: 779.901
< Shrimp-Resize-Time: 558.246
< Shrimp-Encoding-Time: 221.655

Т.е. время ресайзинга сократилось до 558.25ms.

Соответственно, раз уж ImageMagick предоставляет возможность распараллеливать вычисления, то можно этой возможностью пользоваться. Но при этом желательно иметь возможность управлять тем, сколько рабочих нитей под себя заберет Shrimp. В предшествующих версиях Shrimp-а воздействовать на то, сколько рабочих потоков создает Shrimp, было нельзя. А в обновленной версии Shrimp-а это можно делать. Либо через переменные среды, например:

SHRIMP_IO_THREADS=1 SHRIMP_WORKER_THREADS=3 MAGICK_THREAD_LIMIT=4 shrimp.app -p 8080 -i ...

Либо через аргументы командной строки, например:

MAGICK_THREAD_LIMIT=4 shrimp.app -p 8080 -i ... --io-threads 1 --worker-threads 4

Значения, заданные через командную строку имеют более высокий приоритет.

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

Заключение


Пожалуй, в этой версии Shrimp-а мы довели свой демо-проект до приемлемого состояния. Желающие посмотреть и поэкспериментировать могут найти исходные тексты Shrimp-а на BitBucket-е или GitHub-е. Там же можно найти и Dockerfile, чтобы собрать Shrimp для своих экспериментов.

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

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

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


  1. pvsur
    17.08.2018 13:14

    А чем вам boost::beast не угодил?


    1. eao197 Автор
      17.08.2018 13:30

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

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