虾:使用ImageMagic ++,SObjectizer和RESTinio在现代C ++中缩放和共享HTTP图像



前言


我们的小型团队正在为C ++开发人员开发两个开源工具-SObjectizer actor框架和RESTinio嵌入式HTTP服务器。 但是,我们经常会遇到一些非同寻常的问题:

  • 哪些功能要添加到库中,哪些要保留在“外部”?
  • 如何清楚地说明使用图书馆的“思想上正确的”方法?

当在实际项目中使用我们的开发过程中出现此类问题的答案时,当开发人员向我们提出投诉或愿望清单时,这是很好的。 由于满足了用户的愿望,我们为工具提供了功能,这些功能由生活本身决定,而不是“从手指中抽出”。

但是,信息到达我们的地方远非用户面临的所有问题和困难。 而且,我们不能总是在公共资料中使用收到的信息,尤其是代码示例。

因此,有时我们会为自己思考一些小问题,解决这些问题时我们不得不将其从工具开发人员转变为用户。 这使我们可以用不同的眼光看待自己的工具,并自己了解什么是好,什么不好,什么缺失,什么太多。

今天,我们只想讲述一个这样的“小”任务,其中SObjectizer和RESTinio自然地融合在一起。

图片的缩放和分发。 为什么要这样呢?


作为我们自己的一个小型演示任务,我们选择了一个HTTP服务器,该服务器根据请求分发缩放的图像。 您将图像放在某个目录中,启动HTTP服务器,以以下形式向其发出请求:

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

作为回报,您会得到一张长边缩放为1920像素的图片。

之所以选择这个任务,是因为它完美地展示了我们一次开始开发RESTinio的场景:在C或C ++中,有一个长时间运行和调试的代码,您需要在该代码上附加HTTP输入并开始响应传入的请求。 同时,这一点很重要,请求的应用程序处理可能会花费大量时间,因此直接在IO上下文中提取应用程序代码是无利可图的。 HTTP服务器应该是异步的:接受并解析HTTP请求,将解析后的请求提供给某个地方以进行进一步的应用程序处理,继续为下一个HTTP请求提供服务,并在某人准备好此响应后返回将响应返回给HTTP请求。

这正是在处理缩放图像请求时发生的情况。 HTTP服务器能够在不到一毫秒的时间内完成其直接工作(即读取数据,解析HTTP请求)。 但是缩放图片可能要花费数十,数百甚至数千毫秒。

而且,由于缩放一张图片需要花费大量时间,因此您需要确保在缩放图片时HTTP服务器可以继续工作。 为此,我们需要分散HTTP服务器的工作并将图像缩放到不同的工作上下文。 在简单的情况下,这些将是不同的工作线程。 好吧,由于我们生活在多核处理器中,因此我们将有几个工作线程。 其中一些将处理HTTP请求,一些将与图像一起使用。

事实证明,要通过HTTP分发可伸缩图像,我们需要重用长期编写的有效C / C ++代码(在本例中为ImageMagic ++),并异步处理HTTP请求,并在多个工作流程中执行请求的应用程序处理。 在我们看来,对于RESTinio和SObjectizer来说,这是一项出色的任务。

我们决定将演示项目命名为虾。

虾原样


虾做什么?


虾作为控制台应用程序运行,打开并侦听指定的端口,接收并处理以下形式的HTTP GET请求:

 /<image>.<ext> /<image>.<ext>?op=resize&<side>=<value> 

其中:

  • image是要缩放的图像文件的名称。 例如,my_picture或DSCF0069;
  • ext是虾支持的扩展之一(jpg,jpeg,png或gif);
  • 一侧表示已设置尺寸的一侧。 它可以具有width的值,在这种情况下,将对图片进行缩放以使所得的宽度等于指定的值,并在保持宽高比的同时自动选择图片的高度。 或height的值,在这种情况下,高度会发生缩放。 最大值(在这种情况下,长边是有限的)由虾本身确定长边是高还是宽。
  • value是发生缩放的大小。

如果在URL中仅指定文件名,而没有进行大小调整操作,则虾只在响应中返回原始图像。 如果指定了调整大小操作,则虾将更改请求图像的大小并给出缩放的版本。

同时,虾在内存中保存缩放图像的缓存。 如果重复请求具有相同调整大小参数的图片(该图片已在缓存中),则返回来自缓存的值。 如果高速缓存中没有图片,则从磁盘读取图片,将其缩放,存储在缓存中并作为响应返回。

缓存会定期清除。 自上次访问以来,已在缓存中驻留了一个多小时的图片被推出。 另外,如果缓存超过其最大大小(在演示项目中为100Mb),则最早的图片也会从缓存中抛出。

我们准备好了一个页面 ,任何人都可以尝试对虾进行操作:



在此页面上,您可以设置图像大小,然后单击“调整大小”。 将使用相同的参数向虾服务器发出两个请求。 最有可能的是,第一个请求将是唯一的(即,在缓存中尚无具有此类调整大小参数的缓存),因此第一个请求将花费一些时间来实际缩放图像。 第二个请求很可能会在缓存中找到已经缩放的图片,并立即将其提供。

可以判断是否从缓存中给出了图片,或者是否确实通过图片下方的文字缩放了图片。 例如,文本“已转换(114.0ms)”表示图片已缩放,缩放操作花费了114毫秒。

虾是怎么做的?


Shrimp是运行三组工作线程的多线程应用程序:

  1. 运行HTTP服务器的工作线程池。 在该池上,将提供新的连接,接收并解析传入的请求,生成并发送响应。 HTTP服务器通过RESTinio库实现。
  2. 一个单独的工作线程,在该线程上运行transform_manager SObjectizer代理。 该代理处理从HTTP服务器收到的请求,并维护缩放图像的缓存。
  3. SObjectizer代理在其上工作的转换器的线程池。 他们使用ImageMagic ++执行图像的实际缩放。

事实证明以下工作方案:



HTTP服务器接受传入的请求,对其进行解析,然后检查其正确性。 如果此请求不需要调整大小操作,则HTTP服务器本身将通过sendfile操作处理该请求。 如果请求需要调整大小操作,则将请求异步发送到transform_manager代理。

transform_manager代理从HTTP服务器接收请求,检查缓存中是否已缩放图片。 如果缓存中有图片,则transform_manager立即为HTTP服务器生成响应。 如果没有图片,则transform_manager发送一个请求以将图片缩放到其中一个转换器代理。 当缩放结果来自转换器时,结果存储在缓存中,并为HTTP服务器生成答案。

转换器代理接收来自transform_manager的请求,对其进行处理,然后将转换结果返回给transform_manager代理。

虾在引擎盖下有什么?


可以在以下存储库中找到本文描述的虾的最低版本的源代码: BitBucketGitHub 上的虾样本

有很多代码,尽管在大多数情况下,在此版本的虾中,该代码非常简单。 但是,将精力集中在实现的某些方面是有意义的。

使用C ++ 17和最新的编译器版本


在实现虾的过程中,我们决定使用C ++ 17和最新版本的编译器,尤其是GCC 7.3和8.1。 该项目是大量研究。 因此,在这样一个项目的框架中对C ++ 17的实际认识是自然的并且是允许的。 鉴于现在和现在的更广泛的开发都集中在实际的工业应用上,我们被迫回顾相当古老的编译器,并可能使用C ++ 14,甚至只是C ++ 11的一部分。

我必须说C ++ 17给人留下了深刻的印象。 似乎我们没有在虾代码中使用太多来自第17个标准的创新,但是它们产生了积极的效果:[[nodiscard]]属性,std ::可选/ std :: variant / std ::文件系统直接“开箱即用,而不是来自外部依赖项,结构化绑定(如果是constexpr),为std :: visit组装lambda的visitor的功能……单独地,这些都是琐事,但它们共同产生了强大的累积效应。

因此,我们在开发虾时获得的第一个有用结果是:C ++ 17值得切换到它。

使用RESTinio工具的HTTP服务器


可能虾的最简单部分是HTTP服务器和HTTP GET请求处理程序( http_server.hpphttp_server.cpp )。

接收和调度传入的请求


本质上,虾HTTP服务器的所有基本逻辑都集中在此功能中:

 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 opt_image_format = image_format_from_extension( params[ "ext" ] ); if( !opt_image_format ) { return do_400_response( std::move( req ) ); } if( req->header().query().empty() ) { return serve_as_regular_file( app_params.m_storage.m_root_dir, std::move( req ), *opt_image_format ); } const auto qp = restinio::parse_query( req->header().query() ); if( "resize" != restinio::value_or( qp, "op"sv, ""sv ) ) { return do_400_response( std::move( req ) ); } handle_resize_op_request( req_handler_mbox, *opt_image_format, qp, std::move( req ) ); return restinio::request_accepted(); } ); } 

此函数使用RESTinio ExpressJS路由器准备HTTP GET请求处理程序。 当HTTP服务器收到GET请求(其URL属于给定的正则表达式)时,将调用指定的lambda函数。

这个lambda函数对请求的正确性进行了一些简单的检查,但是,其工作主要取决于一个简单的选择:如果未设置resize,则将使用有效的系统sendfile以原始形式返回请求的图片。 如果设置了调整大小模式,那么将生成一条消息并将其发送到transform_manager代理:

 void handle_resize_op_request( const so_5::mbox_t & req_handler_mbox, image_format_t image_format, const restinio::query_string_params_t & qp, restinio::request_handle_t req ) { try_to_handle_request( [&]{ auto op_params = transform::resize_params_t::make( restinio::opt_value< std::uint32_t >( qp, "width" ), restinio::opt_value< std::uint32_t >( qp, "height" ), restinio::opt_value< std::uint32_t >( qp, "max" ) ); transform::resize_params_constraints_t{}.check( op_params ); std::string image_path{ req->header().path() }; so_5::send< so_5::mutable_msg<a_transform_manager_t::resize_request_t>>( req_handler_mbox, std::move(req), std::move(image_path), image_format, op_params ); }, req ); } 

事实证明,HTTP服务器已经接受了resize-request,并通过异步消息将其提供给transform_manager代理,并继续处理其他请求。

与sendfile共享文件


如果HTTP服务器检测到对原始图片的请求,而没有进行大小调整操作,则服务器会立即通过sendfile操作发送该图片。 与之相关的主要代码如下(该功能的完整代码可以在存储库中找到):

 [[nodiscard]] restinio::request_handling_status_t serve_as_regular_file( const std::string & root_dir, restinio::request_handle_t req, image_format_t image_format ) { const auto full_path = make_full_path( root_dir, req->header().path() ); try { auto sf = restinio::sendfile( full_path ); ... return set_common_header_fields_for_image_resp( file_stat.st_mtim.tv_sec, resp ) .append_header( restinio::http_field::content_type, image_content_type_from_img_format( image_format ) ) .append_header( http_header::shrimp_image_src, image_src_to_str( http_header::image_src_t::sendfile ) ) .set_body( std::move( sf ) ) .done(); } catch(...) {} return do_404_response( std::move( req ) ); } 

此处的关键点是调用restinio :: sendfile() ,然后将此函数返回的值传递给set_body()。

restinio :: sendfile()函数使用系统API创建文件上传操作。 当此操作传递给set_body()时,RESTinio理解到restinio :: sendfile()中指定的文件内容将用于HTTP响应的正文。 然后,它使用系统API将此文件的内容写入TCP套接字。

实现图像缓存


transform_manager代理存储已转换图像的缓存,在缩放后将图像放置在该缓存中。 此缓存是一个简单的自制容器,可通过两种方式访问​​其内容:

  1. 通过键搜索元素(类似于在标准容器std :: map和std :: unordered_map中的操作)。
  2. 通过访问最早的缓存项。

当我们需要检查缓存中图像的可用性时,使用第一种访问方法。 第二个是当我们从缓存中删除最旧的图片时。

我们没有开始在Internet上搜索可用于这些目的的东西。 Boost.MultiIndex可能在这里非常合适。 但是我不想仅仅为了MultiIndex而拖动Boost,所以我们在我的膝盖上进行了琐碎的实现 。 似乎有效;)

transform_manager中的待处理请求队列


在我们最简单的对虾实现中,尽管transform_manager代理的大小相当不错(一个hpp文件大约为250行,一个cpp文件大约为270行),但在我们看来,它却显得微不足道。

对代理程序代码的复杂性和数量作出重大贡献的要点之一是,transform_manager中不仅存在已转换图像的缓存,而且还存在未决请求队列。

我们的变压器代理数量有限(原则上,它们的数量应大致对应于可用处理核心的数量)。 如果同时发出的请求多于免费变换器,那么我们可以立即对请求做出否定响应,也可以将请求排队。 然后,当出现可用的转换器时,将其从队列中取出。

在虾中,我们使用等待请求的队列,定义如下:

 struct pending_request_t { transform::resize_request_key_t m_key; sobj_shptr_t<resize_request_t> m_cmd; std::chrono::steady_clock::time_point m_stored_at; pending_request_t( transform::resize_request_key_t key, sobj_shptr_t<resize_request_t> cmd, std::chrono::steady_clock::time_point stored_at ) : m_key{ std::move(key) } , m_cmd{ std::move(cmd) } , m_stored_at{ stored_at } {} }; using pending_request_queue_t = std::queue<pending_request_t>; pending_request_queue_t m_pending_requests; static constexpr std::size_t max_pending_requests{ 64u }; 

收到请求后,我们会将其放入队列中,并确定接收请求的时间。 然后,我们会定期检查此请求的超时是否已到期。 确实,原则上,可能会发生一堆“沉重”请求较早到达,处理时间过长的情况。 无休止地等待免费的转换器出现是错误的,最好在一段时间后向客户端发送否定响应,这意味着服务现在过载。

未决请求队列也有大小限制。 如果队列已经达到最大大小,那么我们将立即拒绝处理该请求,并告诉客户端我们超载。

有一个与待处理请求队列有关的重要点,我们将在本文的结论中重点介绍这一点。

键入sobj_shptr_t并重用消息实例


在确定等待请求队列的类型以及transform_manager某些方法的签名时,您可以看到sobj_shptr_t类型的使用。 有必要详细说明它是什么类型以及为什么使用它。

最重要的是,transform_manager从HTTP服务器接收一个请求,作为resize_request_t消息:

 struct resize_request_t final : public so_5::message_t { restinio::request_handle_t m_http_req; std::string m_image; image_format_t m_image_format; transform::resize_params_t m_params; resize_request_t( restinio::request_handle_t http_req, std::string image, image_format_t image_format, transform::resize_params_t params ) : m_http_req{ std::move(http_req) } , m_image{ std::move(image) } , m_image_format{ image_format } , m_params{ params } {} }; 

并且我们必须做一些事情来将该信息存储在等待请求的队列中。 例如,您可以创建一个新的resize_request_t实例,并将接收到的消息中的值移入其中。

您可以回想起SObjectizer中的消息本身是动态创建的对象。 这不是一个简单的对象,而是在其中包含一个链接计数器。 在SObjectizer中,有一种针对此类对象的特殊类型的智能指针-intrusive_ptr_t。

即 我们无法为等待的请求队列制作一份resize_request_t的副本,但是我们可以简单地将指向现有resize_request_t实例的智能指针放入此队列中。 我们做什么。 为了不至于在各处都写上相当外来的名称so_5 :: intrusive_ptr_t,我们输入别名:

 template<typename T> using sobj_shptr_t = so_5::intrusive_ptr_t<T>; 

对客户端的异步响应


我们说过HTTP请求是异步处理的。 并且我们在上面展示了HTTP服务器如何通过异步消息将查询发送到transform_manager代理。 但是,对HTTP请求的响应会如何?

响应也异步提供。 例如,在transform_manager代码中,您可以看到以下内容:

 void a_transform_manager_t::on_failed_resize( failed_resize_t & /*result*/, sobj_shptr_t<resize_request_t> cmd ) { do_404_response( std::move(cmd->m_http_req) ); } 

如果由于某种原因无法缩放图像,则此代码会对HTTP请求产生否定响应。 响应是在do_404_response帮助函数中生成的,其代码可以表示如下:

 auto do_404_response( restinio::request_handle_t req ) { auto resp = req->create_response( 404, "Not Found" ); resp.append_header( restinio::http_field_t::server, "Shrimp draft server" ); resp.append_header_date_field(); if( req->header().should_keep_alive() ) resp.connection_keep_alive(); else resp.connection_close(); return resp.done(); } 

do_404_response()的第一个关键点是,此函数是在transform_manager代理的工作上下文上而不是在HTTP服务器的工作上下文上调用的。

第二个关键点是在完全形成的resp对象上对done()方法的调用。 具有HTTP响应的所有异步魔术都在这里发生。 done()方法获取在resp中准备的所有信息,并将其异步发送到HTTP服务器。 即 HTTP服务器将resp对象的内容排队后,将立即发生do_404_response()的返回。

HTTP服务器在其工作上下文中将检测到新HTTP响应的存在,并将开始执行必要的操作以将响应发送到适当的客户端。

类型datasizable_blob_t


需要澄清的另一点很重要,因为如果不了解RESTinio的复杂性,这可能是难以理解的。 乍一看,我们谈论的是奇怪类型的datasizeable_blob_t的存在,其定义如下:

 struct datasizable_blob_t : public std::enable_shared_from_this< datasizable_blob_t > { const void * data() const noexcept { return m_blob.data(); } std::size_t size() const noexcept { return m_blob.length(); } Magick::Blob m_blob; //! Value for `Last-Modified` http header field. const std::time_t m_last_modified_at{ std::time( nullptr ) }; }; 

为了解释为什么需要这种类型,您需要显示如何通过转换后的图片形成HTTP响应:

 void serve_transformed_image( restinio::request_handle_t req, datasizable_blob_shared_ptr_t blob, image_format_t img_format, http_header::image_src_t image_src, header_fields_list_t header_fields ) { auto resp = req->create_response(); set_common_header_fields_for_image_resp( blob->m_last_modified_at, resp ) .append_header( restinio::http_field::content_type, image_content_type_from_img_format( img_format ) ) .append_header( http_header::shrimp_image_src, image_src_to_str( image_src ) ) .set_body( std::move( blob ) ); for( auto & hf : header_fields ) { resp.append_header( std::move( hf.m_name ), std::move( hf.m_value ) ); } resp.done(); } 

我们注意对set_body()的调用:指向datasizable_blob_t实例的智能指针直接发送到那里。 怎么了

事实是, RESTinio支持几种用于形成HTTP响应正文的选项 。 最简单的方法是将std :: string类型的实例传递给set_body(),RESTinio会将该字符串的值保存在resp对象中。

但是有时候,set_body()的值应一次在多个答案中重用。 例如,在虾中,当虾收到几个相同图像转换请求时,就会发生这种情况。 . RESTinio set_body() :
 template<typename T> auto set_body(std::shared_ptr<T> body); 

T : data() size(), , RESTinio .

shrimp- Magick::Blob. Magic::Blob data, size(), length(). - datasizable_blob_t, RESTinio Magick::Blob.

transform_manager


transform_manager :

  • , ;
  • transformer-.

transform_manager . .

, :

 struct clear_cache_t final : public so_5::signal_t {}; struct check_pending_requests_t final : public so_5::signal_t {}; 

, :

 void a_transform_manager_t::so_define_agent() { so_subscribe_self() .event( &a_transform_manager_t::on_resize_request ) .event( &a_transform_manager_t::on_resize_result ) .event( &a_transform_manager_t::on_clear_cache ) .event( &a_transform_manager_t::on_check_pending_requests ); } void a_transform_manager_t::on_clear_cache( mhood_t<clear_cache_t> ) {...} void a_transform_manager_t::on_check_pending_requests( mhood_t<check_pending_requests_t> ) {...} 

SObjectizer .

:

 void a_transform_manager_t::so_evt_start() { m_clear_cache_timer = so_5::send_periodic<clear_cache_t>( *this, clear_cache_period, clear_cache_period ); m_check_pending_timer = so_5::send_periodic<check_pending_requests_t>( *this, check_pending_period, check_pending_period ); } 

— timer_id, send_periodic(). , timer_id. , send_periodic() , . a_transform_manager_t :

 so_5::timer_id_t m_clear_cache_timer; so_5::timer_id_t m_check_pending_timer; 


shrimp-. , , RESTinio SObjectizer - - , HelloWorld. .

, transform_manager . , . , . . , , .

transform_manager. , — .

shrimp- « », . , . , , shrimp .

shrimp- . stay tuned.

- shrimp-, RESTinio SObjectizer-, . , shrimp — , - shrimp- - , resize, , .

待续...

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


All Articles