
La semaine dernière,
nous avons parlé de notre petit projet de démonstration, Shrimp , qui montre clairement comment vous pouvez utiliser les bibliothèques C ++
RESTinio et
SObjectizer dans des conditions plus ou moins similaires. Shrimp est une petite application C ++ 17 qui, via RESTinio, accepte les requêtes HTTP pour la mise à l'échelle des images et sert ces requêtes en mode multi-thread via SObjectizer et ImageMagick ++.
Le projet s'est avéré plus qu'utile pour nous. La tirelire de Wishlist pour l'extension des fonctionnalités de RESTinio et SObjectizer s'est considérablement réapprovisionnée. Quelque chose qui a même été incorporé dans une
version très
récente de RESTinio-0.4.7 . Nous avons donc décidé de ne pas nous attarder sur la toute première et la plus triviale version de Shrimp, mais de faire une ou deux itérations supplémentaires autour de ce projet. Si quelqu'un s'intéresse à quoi et comment nous avons fait pendant cette période, vous êtes le bienvenu sous cat.
En tant que spoiler: nous parlerons de la façon dont nous nous sommes débarrassés du traitement parallèle de demandes identiques, de la façon dont nous avons ajouté la journalisation à Shrimp à l'aide de l' excellente bibliothèque spdlog , et également effectué une commande pour forcer la réinitialisation du cache des images transformées.
v0.3: contrôle du traitement parallèle de requêtes identiques
La toute première version de Shrimp, décrite dans un article précédent, contenait une sérieuse simplification: il n'y avait aucun contrôle sur le fait que la même demande soit actuellement traitée ou non.
Imaginez que pour la première fois Shrimp reçoive une demande du formulaire "/demo.jpg?op=resize&max=1024". Il n'y a pas encore une telle image dans le cache d'images transformé, donc la demande est en cours de traitement. Le traitement peut prendre un temps considérable, disons, quelques centaines de millisecondes.
Le traitement de la demande n'est pas encore terminé et Shrimp reçoit à nouveau la même demande "/demo.jpg?op=resize&max=1024", mais d'un autre client. Il n'y a pas encore de résultat de transformation dans le cache, donc cette demande sera également traitée.
Ni la première ni la deuxième demande n'ont encore été traitées, et Shrimp peut à nouveau recevoir la même demande "/demo.jpg?op=resize&max=1024". Et cette demande sera également traitée. Il s'avère que la même image est mise à l'échelle à la même taille en parallèle plusieurs fois.
Ce n'est pas bon. Par conséquent, la première chose que nous avons décidé chez Shrimp était de se débarrasser d'un montant aussi sérieux. Nous l'avons fait en raison de deux conteneurs difficiles dans l'agent transform_manager. Le premier conteneur est une file d'attente de demandes de transformateur gratuites. Il s'agit d'un conteneur nommé m_pending_requests. Le deuxième conteneur stocke les demandes qui ont déjà été traitées (c'est-à-dire que des transformateurs spécifiques ont été affectés à ces demandes). Il s'agit d'un conteneur nommé m_inprogress_requests.
Lorsque transform_manager reçoit la requête suivante, il vérifie la présence de l'image finie dans le cache d'images transformées. S'il n'y a pas d'image convertie, les conteneurs m_inprogress_requests et m_pending_requests sont vérifiés. Et s'il n'y a aucune demande avec de tels paramètres dans l'un de ces conteneurs, alors seulement une tentative est effectuée pour placer la demande dans la file d'attente m_pending_requests. Cela ressemble
à ceci :
void a_transform_manager_t::handle_not_transformed_image( transform::resize_request_key_t request_key, sobj_shptr_t<resize_request_t> cmd ) { const auto store_to = [&](auto & queue) { queue.insert( std::move(request_key), std::move(cmd) ); }; if( m_inprogress_requests.has_key( request_key ) ) {
Il a été dit plus haut que m_inprogress_requests et m_pending_requests sont des conteneurs difficiles. Mais quel est le truc?
L'astuce est que ces conteneurs combinent les propriétés à la fois d'une file d'attente FIFO régulière (dans laquelle l'ordre chronologique d'ajout d'éléments est préservé) et multimap, c'est-à-dire Conteneur associatif dans lequel plusieurs valeurs peuvent être mappées sur une seule clé.
Il est important de conserver l'ordre chronologique, car les éléments les plus anciens de m_pending_requests doivent être vérifiés périodiquement et supprimés de m_pending_requests les demandes pour lesquelles le délai maximal est dépassé. Un accès efficace aux éléments par clé est nécessaire à la fois pour vérifier la présence de demandes identiques dans les files d'attente et pour que toutes les demandes en double puissent être supprimées de la file d'attente à la fois.
Chez Shrimp, nous avons utilisé
notre petit conteneur à ces fins. Cependant, si Boost était utilisé dans Shrimp, Boost.MultiIndex pourrait être utilisé. Et, probablement, au fil du temps, une recherche efficace dans m_pending_requests devra être organisée selon d'autres critères, puis Boost.MultiIndex dans Shrimp devra être activé.
v0.4: journalisation avec spdlog
Nous avons essayé de laisser la première version de Shrimp aussi simple et compacte que possible. Pour cette raison, dans la première version de Shrimp, nous n'avons pas utilisé la journalisation. Généralement.
D'une part, cela a permis de garder le code de la première version concis, ne contenant que la logique métier Shrimp nécessaire. Mais, d'autre part, le manque d'exploitation complique à la fois le développement de la crevette et son fonctionnement. Par conséquent, dès que nous avons mis la main dessus, nous avons immédiatement traîné dans Shrimp une excellente bibliothèque C ++ moderne pour la journalisation -
spdlog . La respiration est immédiatement devenue plus facile, bien que le code de certaines méthodes ait augmenté en volume.
Par exemple, le code ci-dessus de la méthode handle_not_transformed_image () avec journalisation commence à ressembler à
ceci :
void a_transform_manager_t::handle_not_transformed_image( transform::resize_request_key_t request_key, sobj_shptr_t<resize_request_t> cmd ) { const auto store_to = [&](auto & queue) { queue.insert( std::move(request_key), std::move(cmd) ); }; if( m_inprogress_requests.has_key( request_key ) ) {
Configuration des enregistreurs spdlog
La connexion à Shrimp se fait sur la console (c'est-à-dire dans le flux de sortie standard). En principe, on pourrait suivre un chemin très simple et créer dans Shrimp la seule instance du spd-logger. C'est-à-dire on pourrait appeler
stdout_color_mt (ou
stdout_logger_mt ), puis transmettre cet enregistreur à toutes les entités de Shrimp. Mais nous sommes allés un peu plus compliqué: nous avons créé manuellement le soi-disant récepteur (c'est-à-dire le canal où spdlog produira les messages générés), et pour les entités Shrimp, ils ont créé des enregistreurs distincts attachés à ce récepteur.
Il y a un point subtil avec la configuration des enregistreurs dans spdlog: par défaut, l'enregistreur ignore les messages avec des niveaux de gravité de trace et de débogage. À savoir, ils s'avèrent être plus utiles lors du débogage. Par conséquent, dans make_logger, nous activons par défaut la journalisation pour tous les niveaux, y compris le suivi / débogage.
Étant donné que chaque entité de Shrimp a son propre enregistreur avec son propre nom, nous pouvons voir qui fait quoi dans le journal:

Traçage de SObjectizer avec spdlog
Les temps de journalisation, qui sont effectués dans le cadre de la logique métier principale d'une application SObjectizer, ne sont pas suffisants pour déboguer l'application. Il n'est pas clair pourquoi une action est lancée dans un agent, mais n'est pas réellement effectuée dans un autre agent. Dans ce cas, le mécanisme msg_tracing intégré à SObjectizer aide beaucoup (dont nous avons parlé
dans un article séparé ). Mais parmi les implémentations standard de msg_tracing pour SObjectizer, il n'y en a pas une qui utilise spdlog. Nous ferons nous-mêmes cette implémentation pour Shrimp
class spdlog_sobj_tracer_t : public so_5::msg_tracing::tracer_t { std::shared_ptr<spdlog::logger> m_logger; public: spdlog_sobj_tracer_t( std::shared_ptr<spdlog::logger> logger ) : m_logger{ std::move(logger) } {} virtual void trace( const std::string & what ) noexcept override { m_logger->trace( what ); } [[nodiscard]] static so_5::msg_tracing::tracer_unique_ptr_t make( spdlog::sink_ptr sink ) { return std::make_unique<spdlog_sobj_tracer_t>( make_logger( "sobjectizer", std::move(sink) ) ); } };
Nous voyons ici l'implémentation de l'interface spéciale SObjectizer tracer_t, dans laquelle l'essentiel est la méthode virtuelle trace (). C'est lui qui effectue le traçage des internes de SObjectizer au moyen de spdlog.
Ensuite, cette implémentation est installée en tant que traceur lors du démarrage de SObjectizer:
so_5::wrapped_env_t sobj{ [&]( so_5::environment_t & env ) {...}, [&]( so_5::environment_params_t & params ) { if( sobj_tracing_t::on == sobj_tracing ) params.message_delivery_tracer( spdlog_sobj_tracer_t::make( logger_sink ) ); } };
RESTinio trace via spdlog
En plus de suivre ce qui se passe à l'intérieur du SObjectizer, il peut parfois être très utile de suivre ce qui se passe à l'intérieur de RESTinio. Dans la version mise à jour de Shrimp, une telle trace est également ajoutée.
Cette trace est implémentée via la définition d'une classe spéciale qui peut effectuer la journalisation dans RESTinio:
class http_server_logger_t { public: http_server_logger_t( std::shared_ptr<spdlog::logger> logger ) : m_logger{ std::move( logger ) } {} template< typename Builder > void trace( Builder && msg_builder ) { log_if_enabled( spdlog::level::trace, std::forward<Builder>(msg_builder) ); } template< typename Builder > void info( Builder && msg_builder ) { log_if_enabled( spdlog::level::info, std::forward<Builder>(msg_builder) ); } template< typename Builder > void warn( Builder && msg_builder ) { log_if_enabled( spdlog::level::warn, std::forward<Builder>(msg_builder) ); } template< typename Builder > void error( Builder && msg_builder ) { log_if_enabled( spdlog::level::err, std::forward<Builder>(msg_builder) ); } private: template< typename Builder > void log_if_enabled( spdlog::level::level_enum lv, Builder && msg_builder ) { if( m_logger->should_log(lv) ) { m_logger->log( lv, msg_builder() ); } } std::shared_ptr<spdlog::logger> m_logger; };
Cette classe n'est héritée de rien, car le mécanisme de journalisation dans RESTinio est basé sur une programmation généralisée, et non sur l'approche orientée objet traditionnelle. Cela vous permet de vous débarrasser complètement de toute surcharge dans les cas où la journalisation n'est pas du tout nécessaire (nous avons
traité ce sujet plus en détail lorsque nous avons
parlé d'utiliser des modèles dans RESTinio ).
Ensuite, nous devons indiquer que le serveur HTTP utilisera la classe http_server_logger_t indiquée ci-dessus comme enregistreur. Cela se fait en clarifiant les propriétés du serveur HTTP:
struct http_server_traits_t : public restinio::default_traits_t { using logger_t = http_server_logger_t; using request_handler_t = http_req_router_t; };
Eh bien, il ne reste plus rien à faire - créez une instance spécifique du spd-logger et envoyez cet enregistreur au serveur HTTP créé:
auto restinio_logger = make_logger( "restinio", logger_sink, restinio_tracing_t::off == restinio_tracing ? spdlog::level::off : log_level ); restinio::run( asio_io_ctx, shrimp::make_http_server_settings( thread_count.m_io_threads, params, std::move(restinio_logger), manager_mbox_promise.get_future().get() ) );
v0.5: réinitialisation forcée du cache d'image transformé
Dans le processus de débogage de Shrimp, une petite chose a été découverte qui était un peu gênante: pour vider le contenu du cache d'image transformé, vous deviez redémarrer l'intégralité de Shrimp. Cela semblerait insignifiant, mais désagréable.
Si c'est désagréable, alors vous devriez vous débarrasser de cette lacune. Heureusement, ce n'est pas du tout difficile.
Tout d'abord, nous allons définir une autre URL dans Shrimp à laquelle vous pouvez envoyer des requêtes HTTP DELETE: "/ cache". En conséquence, nous accrocherons notre gestionnaire sur cette URL:
std::unique_ptr< http_req_router_t > make_router( const app_params_t & params, so_5::mbox_t req_handler_mbox ) { auto router = std::make_unique< http_req_router_t >(); add_transform_op_handler( params, *router, req_handler_mbox ); add_delete_cache_handler( *router, req_handler_mbox ); return router; }
où la fonction add_delete_cache_handler () ressemble à ceci:
void add_delete_cache_handler( http_req_router_t & router, so_5::mbox_t req_handler_mbox ) { router.http_delete( "/cache", [req_handler_mbox]( auto req, auto ) { const auto qp = restinio::parse_query( req->header().query() ); auto token = qp.get_param( "token"sv ); if( !token ) { return do_403_response( req, "No token provided\r\n" ); }
Un peu bavard, mais rien de compliqué. La chaîne de requête de la requête doit avoir un paramètre de jeton. Ce paramètre doit contenir une chaîne avec une valeur spéciale pour le jeton d'administration. Vous ne pouvez réinitialiser le cache que si la valeur du jeton du paramètre token correspond à ce qui a été défini lors du lancement de Shrimp. S'il n'y a pas de paramètre de jeton, la demande de traitement n'est pas acceptée. S'il y a un jeton, alors l'agent transform_manager, propriétaire du cache, reçoit un message de commande spécial, en exécutant lequel l'agent transform_manager lui-même répondra à la requête HTTP.
Deuxièmement, nous implémentons le nouveau gestionnaire de messages delete_cache_request_t dans l'agent transform_manager_t:
void a_transform_manager_t::on_delete_cache_request( mutable_mhood_t<delete_cache_request_t> cmd ) { m_logger->warn( "delete cache request received; " "connection_id={}, token={}", cmd->m_http_req->connection_id(), cmd->m_token ); const auto delay_response = [&]( std::string response_text ) { so_5::send_delayed< so_5::mutable_msg<negative_delete_cache_response_t> >( *this, std::chrono::seconds{7}, std::move(cmd->m_http_req), std::move(response_text) ); }; if( const char * env_token = std::getenv( "SHRIMP_ADMIN_TOKEN" );
Il y a deux points ici qui devraient être clarifiés.
Le premier point dans l'implémentation de on_delete_cache_request () est la vérification de la valeur du jeton elle-même. Le jeton d'administration est défini via la variable d'environnement SHRIMP_ADMIN_TOKEN. Si cette variable est définie et que sa valeur correspond à la valeur du paramètre de jeton de la demande HTTP DELETE, le cache est effacé et une réponse positive à la demande est immédiatement générée.
Et le deuxième point dans l'implémentation de on_delete_cache_request () est le retard forcé d'une réponse négative à HTTP DELETE. Si la mauvaise valeur du jeton d'administration est arrivée, vous devez retarder la réponse à HTTP DELETE afin de ne pas vouloir sélectionner la valeur du jeton par force brute. Mais comment faire ce retard? Après tout, appeler std :: thread :: sleep_for () n'est pas une option.
C'est là que les messages en attente de SObjectizer viennent à la rescousse. Au lieu de générer immédiatement une réponse négative dans on_delete_cache_request (), l'agent transform_manager s'envoie simplement un message negative_delete_cache_response_t en attente. Le temporisateur SObjectizer comptera le temps défini et remettra ce message à l'agent une fois le délai spécifié écoulé. Et maintenant, dans le gestionnaire negative_delete_cache_response_t, vous pouvez déjà générer immédiatement une réponse à la demande HTTP DELETE:
void a_transform_manager_t::on_negative_delete_cache_response( mutable_mhood_t<negative_delete_cache_response_t> cmd ) { m_logger->debug( "send negative response to delete cache request; " "connection_id={}", cmd->m_http_req->connection_id() ); do_403_response( std::move(cmd->m_http_req), std::move(cmd->m_response_text) ); }
C'est-à-dire il s'avère que le scénario suivant:
- Le serveur HTTP reçoit une demande HTTP DELETE, convertit cette demande en un message delete_cache_request_t à l'agent transform_manager;
- l'agent transform_manager reçoit le message delete_cache_request_t et génère immédiatement une réponse positive à la demande ou s'envoie un message en attente negative_delete_cache_response_t;
- transform_manager reçoit un message negative_delete_cache_response_t et génère immédiatement une réponse négative à la requête HTTP DELETE correspondante.
Fin de la deuxième partie
À la fin de la deuxième partie, il est tout à fait naturel de se poser la question: "Et ensuite?"
De plus, il y aura probablement une autre itération et une autre mise à jour de notre projet de démonstration. Je voudrais faire une chose telle que convertir une image d'un format à un autre. Disons que sur le serveur, l'image est en jpg, et après la transformation, elle est envoyée au client en webp.
Il serait également intéressant de joindre une "page" séparée avec l'affichage des statistiques actuelles sur le travail de la crevette. Tout d'abord, c'est juste curieux. Mais, en principe, une telle page peut également être adaptée aux besoins du suivi de la viabilité des crevettes.
Si quelqu'un d'autre a des suggestions sur ce que j'aimerais voir dans Shrimp ou dans des articles sur Shrimp, alors nous serons heureux d'entendre des réflexions constructives.
Par ailleurs, je tiens à souligner un aspect de la mise en œuvre de la crevette, qui nous a quelque peu surpris. Il s'agit d'une utilisation active des messages mutables lors de la communication entre eux et avec le serveur HTTP. Habituellement, dans notre pratique, c'est le contraire qui se produit - le plus souvent, les données sont échangées via des messages immunitaires. Ce n'est pas le cas ici. Cela suggère que nous avons sciemment écouté les souhaits des utilisateurs en temps voulu et ajouté des messages modifiables à SObjectizer. Donc, si vous souhaitez voir quelque chose dans RESTinio ou SObjectizer, n'hésitez pas à partager vos idées. Nous sommes sûrs d'écouter les bons.
Eh bien, et en conclusion, je tiens à remercier tous ceux qui ont pris le temps de parler de la première version de Shrimp, à la fois ici sur Habré et à travers d'autres ressources. Je vous remercie!
À suivre ...