我们使虾更有用:将图像转码添加到其他格式



自2017年初以来,我们的小型团队一直在开发RESTinio OpenSource库,以将HTTP服务器嵌入C ++应用程序中。 令我们感到惊讶的是,我们有时会收到“为什么为什么需要嵌入式C ++ HTTP服务器?”这一类别的问题。 不幸的是,最简单的问题很难回答。 有时最好的答案是示例代码。

几个月前,我们启动了一个小型演示项目Shrimp ,该项目清楚地展示了一个典型的场景,在该场景下,我们的图书馆被“锐化了”。 该演示项目是一个简单的Web服务,它接收缩放服务器上存储的图像的请求,并返回用户所需大小的图片。

该演示项目的优点在于,首先,它需要与很久以前用C或C ++(在本例中为ImageMagick)编写并正常工作的代码进行集成。 因此,应该清楚为什么将HTTP服务器嵌入C ++应用程序才有意义。

其次,在这种情况下,需要异步处理请求,以便在缩放图像时HTTP服务器不会阻塞(这可能需要数百毫秒甚至几秒钟)。 正是由于我们找不到专门针对异步请求处理的健全的C ++嵌入式服务器,我们才开始RESTinio的开发。

我们迭代地在Shrimp上构建工作:首先,制作并描述了最简单的版本,仅缩放了图片。 然后,我们修复了第一个版本的许多缺点,并在第二篇文章中对此进行了描述 。 最后,我们再次扩展了Shrimp的功能:添加了图像从一种格式到另一种格式的转换。 有关如何完成的,将在本文中进行讨论。

目标格式支持


因此,在下一版本的Shrimp中,我们添加了以不同格式生成缩放图片的功能。 因此,如果您发出以下形式的虾请求:

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

然后虾将以与原始图像相同的JPG格式渲染图像。

但是,如果将target-format参数添加到URL,则Shrimp会将图像转换为指定的目标格式。 例如:

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

在这种情况下,Shrimp将以webp格式呈现图像。

更新的虾支持五种图像格式:jpg,png,gif,webp和heic(也称为HEIF)。 您可以在特殊的网页上尝试各种格式:



(在此页面上,无法选择heic格式,因为默认情况下普通台式机浏览器不支持此格式)。

为了支持Shrimp中的目标格式,需要稍微修改Shrimp代码(我们自己很惊讶,因为实际上所做的更改很少)。 但另一方面,我不得不玩ImageMagick的程序集,对此我们感到更加惊讶,因为 较早之前,我们不得不通过偶然的方式来处理这个厨房。 但是,让我们依次讨论所有内容。

ImageMagick必须了解不同的格式


ImageMagick使用外部库对图像进行编码/解码:libjpeg,libpng,libgif等。 在配置和构建ImageMagick之前,必须在系统上安装这些库。

为了使ImageMagick支持webp和heic格式,应该发生相同的事情:首先,您需要构建并安装libwebp和libheif,然后配置并安装ImageMagick。 如果使用libwebp一切都很简单,那么在libheif周围,我不得不和手鼓跳舞。 尽管过了一段时间,所有事情终于都收集起来并工作了,但现在还不清楚:为什么你不得不诉诸手鼓,一切似乎都很琐碎? ;)

通常,如果有人想与heic和ImageMagick交朋友,则必须安装:


它的顺序是这样(您可能必须安装nasm才能使x265以最快的速度工作)。 然后,在发出./configure命令时,ImageMagick将能够找到支持.heic文件所需的所有内容。

支持传入请求的查询字符串中的目标格式


在使用webp和heic格式使ImageMagick成为朋友之后,是时候修改Shrimp代码了。 首先,我们需要学习如何识别传入HTTP请求中的target-format参数。

从RESTinio的角度来看,这根本不是问题。 好吧,查询字符串中出现了另一个参数,那又如何呢? 但是从Shrimp的角度来看,情况变得更加复杂,因此负责解析HTTP请求的功能的代码也变得更加复杂。

事实是,在仅需要区分两种情况之前:

  • 发出的形式为“ /filename.ext”的请求没有任何其他参数。 因此,您只需要原样提供文件“ filename.ext”即可;
  • 请求以“ /filename.ext?op=resize&...”的形式出现。 在这种情况下,您需要从文件“ filename.ext”缩放图像。

但是在添加了目标格式之后,我们需要区分四种情况:

  • 发出的形式为“ /filename.ext”的请求没有任何其他参数。 因此,您只需要按原样提供文件“ filename.ext”,无需缩放即可将其转码为另一种格式;
  • 发出的格式为“ /filename.ext?target-format=fmt”的请求没有任何其他参数。 这意味着从文件“ filename.ext”中获取图像并将其转码为“ fmt”格式,同时保留原始大小;
  • 请求的格式为“ /filename.ext?op=resize&...”,但没有目标格式。 在这种情况下,您需要缩放文件“ 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的先前版本中,您无需对图像进行转码,使用请求参数看起来更加容易

针对目标格式量身定制的请求队列和图像缓存


实现目标格式支持的下一点是在a_transform_manager代理中处理等待请求队列和现成图像的缓存。 我们在上一篇文章中更详细地讨论了这些事情,但让我们稍微提醒一下它的含义。

当图像转换请求到达时,可能会发现具有此类参数的完成图像已在缓存中。 在这种情况下,您无需执行任何操作,只需从缓存中发送图像作为响应即可。 如果需要转换图片,则可能表明目前没有免费的工作人员,您需要等待直到图片出现。 为此,必须将请求信息排队。 但同时,有必要检查请求的唯一性-如果我们有三个相同的请求在等待处理(即我们需要以相同的方式转换相同的图像),那么我们应该只处理一次图像,并给出处理结果作为响应对这三个请求。 即 在等待队列中,必须将相同的请求分组。

在Shrimp的早期,我们使用了一个简单的组合键来搜索图像缓存和等待队列: 原始文件名+图像大小调整选项的组合 。 现在,必须考虑两个新因素:

  • 首先,目标图像格式(即原始图像可以是jpg,结果图像可以是png);
  • 其次,缩放图片可能不是必需的。 发生这种情况的情况下,客户端仅命令将图像从一种格式转换为另一种格式,但保留了图像的原始大小。

我必须说,这里我们走了最简单的道路,而没有尝试以某种方式优化任何东西。 例如,一个可以尝试建立两个缓存:一个以原始格式存储图像,但按比例缩放到所需的大小,第二个,将缩放后的图像转换为目标格式。

为什么需要这种双重缓存? 事实是,在转换图片时,时间上两个最昂贵的操作是将图片调整大小并将其序列化为目标格式。 因此,如果我们收到了将example.jpg图像缩放到1920宽度并将其转换为webp格式的请求,则可以在内存中存储两个图像:example_1920px_width.jpg和example_1920px_width.webp。 当我们收到第二个请求时,我们将提供一个example_1920px_width.webp图片。 但是,在接收将example.jpg缩放为1920宽度并将其转换为heic格式的请求时,可以使用example_1920px_width.jpg图片。 我们可以跳过调整大小的操作,而只进行格式转换(即,完成的图像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参数,则认为使用了该模式)。 这使我们不必重写handle_resize_op_request()函数,该函数将按比例缩放要执行的图片的请求。

其次,现在不总是可以调用resize_params_t :: value()方法,而仅在缩放模式不同于keep_original时才可以调用。

但是最重​​要的是resize_params_t ::运算符<()继续按预期工作。

由于a_transform_manager中的所有这些更改,缩放后的图像缓存和等待请求的队列都保持不变。 但是现在,有关各种查询的信息存储在这些数据结构中。 因此,键{“ example.jpg”,“ jpg”,keep_original}与键{“ example.jpg”,“ png”,keep_original}以及键{“ example.jpg”,“ jpg”,宽度= 1920px}。

事实证明,在简化诸如resize_params_t和resize_params_key_t这样的简单数据结构的定义时,我们避免了更改更复杂的结构,例如生成的图像的缓存和等待请求的队列。

支持a_transformer中的目标格式


好了,支持目标格式的最后一步是扩展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继续存储适合的图像。 但是,在随后的Image :: write()调用期间,将考虑magick()方法设置的值。

其次,更新的版本记录了将图像序列化为指定格式所花费的时间。 现在,新版本的Shrimp分别修复了缩放所花费的时间以及转换为目标格式所花费的时间。

代理a_transformer_t的其余部分未进行任何更改。

ImageMagick并行化


默认情况下,ImageMagic内置OpenMP支持。 即 可以并行处理ImageMagick执行的图像上的操作。 您可以使用环境变量MAGICK_THREAD_LIMIT来控制ImageMagick在这种情况下使用的工作流程数量。

例如,在我的测试机上,值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_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中,我们使演示项目达到了可接受的状态。 想要观看和实验的人可以在BitBucketGitHub上找到Shrimp的源文本。 您还可以在那里找到Dockerfile来为您的实验构建Shrimp。

总的来说,我们通过启动此演示项目实现了我们设定的目标。 为了进一步开发RESTinio和SObjectizer,出现了许多想法,其中一些已经找到了体现。 因此,虾是否会进一步发展完全取决于问题和愿望。 如果有的话,虾可以扩大。 如果不是这样,那么Shrimp将仍然是一个演示项目和一个试验场,用于试验RESTinio和SObjectizer的新版本。

最后,我要特别感谢aensidhe的帮助和建议,如果没有他们的帮助和建议,我们与铃鼓的舞蹈将变得更长而悲伤。

Source: https://habr.com/ru/post/zh-CN420353/


All Articles