
Récemment, il m'est arrivé de travailler sur une application qui était censée contrôler la vitesse de ses connexions sortantes. Par exemple, en se connectant à une URL, l'application doit se limiter à, disons, 200 Ko / s. Et la connexion à une autre URL - seulement 30 Ko / sec.
Le point le plus intéressant ici était de tester ces limites. J'avais besoin d'un serveur HTTP qui donnerait du trafic à une vitesse donnée, par exemple, 512 Ko / s. Ensuite, je pouvais voir si l'application résiste vraiment à la vitesse de 200 Ko / s ou si elle tombe à des vitesses plus élevées.
Mais où trouver un tel serveur HTTP?
Puisque j'ai quelque chose à voir avec le serveur HTTP RESTinio intégré aux applications C ++, je n'ai rien trouvé de mieux que de lancer rapidement un simple serveur de test HTTP sur mon genou qui peut envoyer un long flux de données sortantes au client.
À quel point ce serait simple et je voudrais le dire dans l'article. En même temps, découvrez dans les commentaires si c'est vraiment simple ou si je me trompe. En principe, cet article peut être considéré comme une continuation de l'article précédent sur RESTinio appelé "RESTinio est un serveur HTTP asynchrone. Asynchrone" . Par conséquent, si quelqu'un est intéressé à lire sur l'application réelle, quoique pas très sérieuse, de RESTinio, alors vous êtes le bienvenu au chat.
Idée générale
L'idée générale du serveur de test mentionné ci-dessus est très simple: lorsqu'un client se connecte au serveur et effectue une requête HTTP GET, un temporisateur est activé qui s'exécute une fois par seconde. Lorsque le temporisateur est déclenché, le bloc de données suivant d'une taille donnée est envoyé au client.
Mais tout est un peu plus compliqué
Si le client lit les données à un rythme plus lent que celui envoyé par le serveur, l'envoi de N kilo-octets par seconde n'est pas une bonne idée. Puisque les données vont commencer à s'accumuler dans le socket et cela ne mènera à rien de bon.
Par conséquent, lors de l'envoi de données, il est conseillé de contrôler l'état de préparation du socket pour l'écriture côté serveur HTTP. Tant que le socket est prêt (c'est-à-dire que trop de données ne s'y sont pas encore accumulées), vous pouvez envoyer une nouvelle portion. Mais s'il n'est pas prêt, vous devez attendre jusqu'à ce que la prise passe en état de préparation pour l'enregistrement.
Cela semble raisonnable, mais les opérations d'E / S sont cachées dans les abats de RESTinio ... Comment puis-je savoir si la prochaine donnée peut être écrite ou non?
Vous pouvez sortir de cette situation si vous utilisez des notificateurs après écriture , qui sont dans RESTinio. Par exemple, nous pouvons écrire ceci:
void request_handler(restinio::request_handle_t req) { req->create_response()
Le lambda transmis à la méthode done()
sera appelé lorsque RESTinio aura terminé d'écrire les données sortantes. Par conséquent, si le socket n'était pas prêt pour l'enregistrement pendant un certain temps, le lambda ne sera pas appelé immédiatement, mais une fois que le socket aura atteint son état correct et acceptera toutes les données sortantes.
En raison de l'utilisation de notificateurs après écriture, la logique du serveur de test sera la suivante:
- envoyer le prochain lot de données, calculer le moment où nous aurions besoin d'envoyer le prochain lot dans le cours normal des événements;
- nous accrochons après écriture le notifiant sur la partie suivante des données;
- lorsque la notification après écriture est appelée, nous vérifions si le prochain lot est arrivé. Si c'est le cas, lancez immédiatement l'envoi de la portion suivante. Si ce n'est pas le cas, armer la minuterie.
En conséquence, il s'avère que dès que l'enregistrement commence à ralentir, l'envoi de nouvelles données se met en pause. Et reprenez lorsque le socket est prêt à accepter de nouvelles données sortantes.
Et un peu plus compliqué: chunked_output
RESTinio prend en charge trois façons de générer une réponse à une demande HTTP . La méthode la plus simple, utilisée par défaut, ne convient pas dans ce cas, car J'ai besoin d'un flux presque infini de données sortantes. Et un tel flux, bien sûr, ne peut pas être set_body
à un seul appel à la méthode set_body
.
Par conséquent, le serveur de test décrit utilise ce que l'on appelle chunked_output . C'est-à-dire lors de la création d'une réponse, j'indique à RESTinio que la réponse sera formée en plusieurs parties. Ensuite, j'appelle périodiquement les méthodes append_chunk
pour ajouter la partie suivante à la réponse et flush
pour écrire les parties accumulées dans le socket.
Et regardons le code!
Il suffit peut-être que les premiers mots soient suffisants et il est temps de passer au code lui-même, qui se trouve dans ce référentiel . Commençons par la fonction request_processor
, qui est appelée pour traiter chaque requête HTTP valide. En même temps, explorons les fonctions appelées depuis request_processor
. Eh bien, nous verrons ensuite comment exactement request_processor
est mappé sur l'une ou l'autre des requêtes HTTP entrantes.
Fonction Request_processor et ses assistants
La fonction request_processor
est appelée pour traiter les requêtes HTTP GET dont j'ai besoin. Il est passé en arguments:
- Asio-shny io_context sur lequel tout le travail est effectué (il sera nécessaire, par exemple, pour armer les minuteries);
- la taille d'une partie de la réponse. C'est-à-dire si j'ai besoin de donner un flux sortant à un débit de 512 Ko / s, la valeur 512 Ko sera transmise comme paramètre;
- nombre de pièces en réponse. Dans le cas où le flux devrait avoir une longueur limitée. Par exemple, si vous souhaitez donner un flux à un débit de 512 Ko / s pendant 5 minutes, la valeur 300 sera transmise comme paramètre (60 blocs par minute pendant 5 minutes);
- Eh bien, la demande entrante elle-même pour le traitement.
Dans request_processor
, un objet est créé avec des informations sur la requête et ses paramètres de traitement, après quoi ce traitement commence:
void request_processor( asio_ns::io_context & ctx, std::size_t chunk_size, std::size_t count, restinio::request_handle_t req) { auto data = std::make_shared<response_data>( ctx, chunk_size, req->create_response<output_t>(), count); data->response_ .append_header(restinio::http_field::server, "RESTinio") .append_header_date_field() .append_header( restinio::http_field::content_type, "text/plain; charset=utf-8") .flush(); send_next_portion(data); }
Le type response_data
, contenant tous les paramètres liés à la demande, ressemble à ceci:
struct response_data { asio_ns::io_context & io_ctx_; std::size_t chunk_size_; response_t response_; std::size_t counter_; response_data( asio_ns::io_context & io_ctx, std::size_t chunk_size, response_t response, std::size_t counter) : io_ctx_{io_ctx} , chunk_size_{chunk_size} , response_{std::move(response)} , counter_{counter} {} };
Ici, il convient de noter que l'une des raisons de l'apparition de la structure response_data
est qu'un objet de type restinio::response_builder_t<restinio::chunked_output_t>
(à savoir, ce type est masqué derrière l'alias court response_t
) est un type mobile, mais pas un type copiable (par analogies avec std::unique_ptr
). Par conséquent, cet objet ne peut pas simplement être capturé dans une fonction lambda, qui s'enveloppe ensuite dans std::function
. Mais si vous placez l'objet de réponse dans une instance créée dynamiquement de response_data
, un pointeur intelligent vers l'instance reponse_data
déjà être capturé dans les fonctions lambda sans problème, puis enregistrez ce lambda dans std::function
.
Fonction Send_next_portion
La fonction send_next_portion
appelée à chaque fois qu'il est nécessaire d'envoyer la partie suivante de la réponse au client. Il ne se passe rien de compliqué, donc ça a l'air assez simple et concis:
void send_next_portion(response_data_shptr data) { data->response_.append_chunk(make_buffer(data->chunk_size_)); if(1u == data->counter_) { data->response_.flush(); data->response_.done(); } else { data->counter_ -= 1u; data->response_.flush(make_done_handler(data)); } }
C'est-à-dire envoyer la partie suivante. Et, si cette partie était la dernière, nous terminons le traitement de la demande. Et si ce n'est pas le dernier, un flush
est envoyé à la méthode flush
, qui est peut-être créée par la fonction la plus complexe de cet exemple.
Fonction make_done_handler
La fonction make_done_handler
responsable de la création d'un lambda qui sera transmis à RESTinio en tant que notificateur après écriture. Ce notifiant doit vérifier si l'enregistrement de la partie suivante de la réponse s'est terminé avec succès. Si oui, vous devez déterminer si la pièce suivante doit être envoyée immédiatement (c'est-à-dire qu'il y avait des "freins" dans la prise et le taux d'envoi ne peut pas être maintenu), ou après une pause. Si vous avez besoin d'une pause, elle est fournie via une minuterie d'armement.
En général, des actions simples, mais dans le code, vous obtenez lambda à l'intérieur de lambda, ce qui peut dérouter les gens qui ne sont pas habitués au C ++ "moderne". Ce qui n'est pas si peu d'années pour être qualifié de moderne;)
auto make_done_handler(response_data_shptr data) { const auto next_timepoint = steady_clock::now() + 1s; return [=](const auto & ec) { if(!ec) { const auto now = steady_clock::now(); if(now < next_timepoint) { auto timer = std::make_shared<asio_ns::steady_timer>(data->io_ctx_); timer->expires_after(next_timepoint - now); timer->async_wait([timer, data](const auto & ec) { if(!ec) send_next_portion(data); }); } else data->io_ctx_.post([data] { send_next_portion(data); }); } }; }
À mon avis, la principale difficulté de ce code provient des particularités de la création et du peloton de minuteries dans Asio. À mon avis, cela se révèle en quelque sorte trop verbeux. Mais il y en a vraiment un. Mais vous n'avez pas besoin d'attirer de bibliothèques supplémentaires.
Connexion d'un routeur de type express
Le send_next_portion
, send_next_portion
et make_done_handler
montré ci-dessus ont send_next_portion
fait constitué la toute première version de mon serveur de test, écrite littéralement en 15 ou 20 minutes.
Mais après quelques jours d'utilisation de ce serveur de test, il s'est avéré qu'il y avait un sérieux inconvénient: il renvoyait toujours le flux de réponse à la même vitesse. Compilé à une vitesse de 512 Ko / sec - donne tous les 512 Ko / sec. Recompilé à une vitesse de 20 Ko / sec - donnera à tout le monde 20 Ko / sec et rien d'autre. Ce qui était gênant, car il devenait nécessaire de pouvoir recevoir des réponses de différentes "épaisseurs".
Puis l'idée est venue: que se passe-t-il si la vitesse de retour est demandée directement dans l'URL? Par exemple, ils ont fait une demande à l' localhost:8080/
et ont reçu une réponse à une vitesse prédéterminée. Et si vous avez fait une demande à localhost:8080/128K
, alors ils ont commencé à recevoir une réponse à une vitesse de 128KiB / sec.
Ensuite, la pensée est allée encore plus loin: dans l'URL, vous pouvez également spécifier le nombre de parties individuelles dans la réponse. C'est-à-dire demande localhost:8080/128K/3000
produira un flux de 3000 pièces à une vitesse de 128KiB / sec.
Pas de problème. RESTinio a la possibilité d'utiliser un routeur de requêtes réalisé sous l'influence d'ExpressJS . En conséquence, il y avait une telle fonction pour décrire les gestionnaires pour les requêtes HTTP entrantes:
auto make_router(asio_ns::io_context & ctx) { auto router = std::make_unique<router_t>(); router->http_get("/", [&ctx](auto req, auto) { request_processor(ctx, 100u*1024u, 10000u, std::move(req)); return restinio::request_accepted(); }); router->http_get( R"(/:value(\d+):multiplier([MmKkBb]?))", [&ctx](auto req, auto params) { const auto chunk_size = extract_chunk_size(params); if(0u != chunk_size) { request_processor(ctx, chunk_size, 10000u, std::move(req)); return restinio::request_accepted(); } else return restinio::request_rejected(); }); router->http_get( R"(/:value(\d+):multiplier([MmKkBb]?)/:count(\d+))", [&ctx](auto req, auto params) { const auto chunk_size = extract_chunk_size(params); const auto count = restinio::cast_to<std::size_t>(params["count"]); if(0u != chunk_size && 0u != count) { request_processor(ctx, chunk_size, count, std::move(req)); return restinio::request_accepted(); } else return restinio::request_rejected(); }); return router; }
Ici, les gestionnaires de requêtes HTTP GET sont formés pour trois types d'URL:
- de la forme
http://localhost/
; - de la forme
http://localhost/<speed>[<U>]/
; - du formulaire
http://localhost/<speed>[<U>]/<count>/
Où la speed
est un nombre qui définit la vitesse et U
est un multiplicateur facultatif qui indique dans quelles unités la vitesse est définie. Ainsi, 128
ou 128b
signifie une vitesse de 128 octets par seconde. Et 128k
est de 128 kilo-octets par seconde.
Chaque URL a sa propre fonction lambda, qui comprend les paramètres reçus, si tout va bien, elle appelle la fonction request_processor
montrée ci-dessus.
La fonction d'assistance extract_chunk_size
la suivante:
std::size_t extract_chunk_size(const restinio::router::route_params_t & params) { const auto multiplier = [](const auto sv) noexcept -> std::size_t { if(sv.empty() || "B" == sv || "b" == sv) return 1u; else if("K" == sv || "k" == sv) return 1024u; else return 1024u*1024u; }; return restinio::cast_to<std::size_t>(params["value"]) * multiplier(params["multiplier"]); }
Ici, C ++ lambda est utilisé pour émuler des fonctions locales à partir d'autres langages de programmation.
Fonction principale
Reste à voir comment tout cela fonctionne dans la fonction principale:
using router_t = restinio::router::express_router_t<>; ... int main() { struct traits_t : public restinio::default_single_thread_traits_t { using logger_t = restinio::single_threaded_ostream_logger_t; using request_handler_t = router_t; }; asio_ns::io_context io_ctx; restinio::run( io_ctx, restinio::on_this_thread<traits_t>() .port(8080) .address("localhost") .write_http_response_timelimit(60s) .request_handler(make_router(io_ctx))); return 0; }
Que se passe-t-il ici:
- Comme je n'ai pas besoin d'un routeur de requêtes ordinaire ordinaire (qui ne peut rien faire du tout et met tout le travail sur les épaules du programmeur), je définis de nouvelles propriétés pour mon serveur HTTP. Pour ce faire, je prends les propriétés standard d'un serveur HTTP
restinio::default_single_thread_traits_t
(type restinio::default_single_thread_traits_t
) et indique qu'une instance de routeur de type express sera utilisée comme gestionnaire de requêtes. En même temps, pour contrôler ce qui se passe à l'intérieur, j'indique que le serveur HTTP utilise un vrai logger (par défaut, null_logger_t
utilisé qui n'enregistre rien du tout). - Étant donné que je dois armer les minuteries à l'intérieur des notificateurs après écriture, j'ai besoin d'une instance io_context avec laquelle je pourrais travailler. Par conséquent, je le crée moi-même. Cela me donne l'opportunité de passer un lien vers mon io_context dans la fonction
make_router
. - Il ne reste plus qu'à démarrer le serveur HTTP dans une version monothread sur le io_context que j'ai créé précédemment. La fonction
restinio::run
ne retournera le contrôle que lorsque le serveur HTTP aura terminé son travail.
Conclusion
L'article n'a pas montré le code complet de mon serveur de test, seulement ses principaux points. Le code complet, qui est légèrement plus grand en raison de typedefs supplémentaires et de fonctions auxiliaires, est un peu plus authentique. Vous pouvez le voir ici . Au moment de la rédaction du présent document, il s'agit de 185 lignes, y compris les lignes vides et les commentaires. Eh bien, ces 185 lignes sont écrites en quelques approches avec une durée totale d'à peine plus d'une heure.
J'ai aimé ce résultat et la tâche était intéressante. Concrètement, l'outil auxiliaire dont j'avais besoin a été rapidement obtenu. Et en ce qui concerne le développement ultérieur de RESTinio, certaines réflexions sont apparues.
En général, si quelqu'un d'autre n'a pas essayé RESTinio, je vous invite à essayer. Le projet lui-même vit sur GitHub . Vous pouvez poser une question ou exprimer vos suggestions dans le groupe Google ou ici dans les commentaires.