Хабрахабр

Делаем Shrimp еще полезнее: добавляем перекодирование картинок в другие форматы

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

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

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

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

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

Итак, в очередной версии 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.

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

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

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

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

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

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

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

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

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

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

Ну появился в query string еще один аргумент, ну и что? С точки зрения RESTinio это вообще не проблема. А вот с точки зрения 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 )
))", 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_1920px_width.webp мы бы отдавали при получении повторного запроса. Поэтому, если бы мы получили запрос на масштабирование картинки example.jpg до размера 1920 по ширине и трансформации ее в формат webp, то мы могли бы сохранить в памяти два изображения: example_1920px_width.jpg и 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()).

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

Во-вторых, метод 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() }; }
}

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

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

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

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

По умолчанию 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.

Но при этом желательно иметь возможность управлять тем, сколько рабочих нитей под себя заберет Shrimp. Соответственно, раз уж ImageMagick предоставляет возможность распараллеливать вычисления, то можно этой возможностью пользоваться. А в обновленной версии 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

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

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

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

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

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

Показать больше

Похожие публикации

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»