RESTinio ist ein asynchroner HTTP-Server. Ein einfaches Beispiel aus der Praxis: Als Antwort eine große Datenmenge zurückgeben


Kürzlich habe ich zufällig an einer Anwendung gearbeitet, die die Geschwindigkeit ihrer ausgehenden Verbindungen steuern sollte. Wenn Sie beispielsweise eine Verbindung zu einer URL herstellen, sollte sich die Anwendung auf beispielsweise 200 KB / s beschränken. Und eine Verbindung zu einer anderen URL herstellen - nur 30 KB / s.


Der interessanteste Punkt hier war das Testen dieser Einschränkungen. Ich brauchte einen HTTP-Server, der Datenverkehr mit einer bestimmten Geschwindigkeit liefert, z. B. 512 KB / s. Dann konnte ich sehen, ob die Anwendung der Geschwindigkeit von 200 KB / s wirklich standhält oder ob sie auf höhere Geschwindigkeiten herunterfällt.


Aber wo bekommt man so einen HTTP-Server?


Da ich etwas mit dem in C ++ - Anwendungen eingebetteten RESTinio- HTTP-Server zu tun habe, habe ich nichts Besseres gefunden, als schnell einen einfachen HTTP-Testserver auf mein Knie zu werfen, der einen langen Strom ausgehender Daten an den Client senden kann.


Wie einfach es wäre und möchte im Artikel erzählen. Finden Sie gleichzeitig in den Kommentaren heraus, ob dies wirklich einfach ist oder ob ich mich selbst täusche. Im Prinzip kann dieser Artikel als Fortsetzung des vorherigen Artikels über RESTinio mit dem Namen "RESTinio ist ein asynchroner HTTP-Server. Asynchron" betrachtet werden . Wenn jemand daran interessiert ist, über die reale, wenn auch nicht sehr ernsthafte Anwendung von RESTinio zu lesen, sind Sie bei cat willkommen.


Allgemeine Idee


Die allgemeine Idee des oben erwähnten Testservers ist sehr einfach: Wenn ein Client eine Verbindung zum Server herstellt und eine HTTP-GET-Anforderung ausführt, wird ein Timer aktiviert, der einmal pro Sekunde ausgeführt wird. Wenn der Timer ausgelöst wird, wird der nächste Datenblock einer bestimmten Größe an den Client gesendet.


Aber alles ist etwas komplizierter


Wenn der Client die Daten langsamer liest als der Server sendet, ist es keine gute Idee, nur einmal pro Sekunde N Kilobyte zu senden. Da sich die Daten im Socket ansammeln und dies zu nichts Gutem führt.


Daher ist es beim Senden von Daten ratsam, die Bereitschaft des Sockets zum Schreiben auf der HTTP-Serverseite zu steuern. Solange der Socket bereit ist (dh zu viele Daten haben sich noch nicht darin angesammelt), können Sie einen neuen Teil senden. Wenn Sie nicht bereit sind, müssen Sie warten, bis der Socket für die Aufnahme bereit ist.


Es klingt vernünftig, aber E / A-Operationen sind in den Innereien von RESTinio versteckt ... Wie kann ich herausfinden, ob die nächsten Daten geschrieben werden können oder nicht?


Sie können aus dieser Situation herauskommen, wenn Sie After-Write-Benachrichtigungen verwenden , die sich in RESTinio befinden. Zum Beispiel können wir dies schreiben:


void request_handler(restinio::request_handle_t req) { req->create_response() //   . ... //   . .done([](const auto & ec) { ... //         . }); } 

Das an die Methode done() Lambda wird aufgerufen, wenn RESTinio das Schreiben ausgehender Daten abgeschlossen hat. Wenn der Socket einige Zeit nicht zur Aufzeichnung bereit war, wird das Lambda nicht sofort aufgerufen, sondern nachdem der Socket seinen ordnungsgemäßen Zustand erreicht hat und alle ausgehenden Daten akzeptiert.


Aufgrund der Verwendung von After-Write-Benachrichtigungen lautet die Logik des Testservers wie folgt:


  • Senden Sie den nächsten Datenstapel und berechnen Sie die Zeit, zu der der nächste Datenstapel im normalen Verlauf der Ereignisse gesendet werden muss.
  • Wir hängen den Notifier nach dem Schreiben an den nächsten Teil der Daten.
  • Wenn die After-Write-Benachrichtigung aufgerufen wird, prüfen wir, ob der nächste Stapel eingetroffen ist. Wenn dies der Fall ist, starten Sie sofort das Senden des nächsten Teils. Wenn dies nicht der Fall ist, spannen Sie den Timer.

Infolgedessen stellt sich heraus, dass das Senden neuer Daten unterbrochen wird, sobald die Aufnahme langsamer wird. Und fahren Sie fort, wenn der Socket bereit ist, neue ausgehende Daten zu akzeptieren.


Und etwas komplizierter: chunked_output


RESTinio unterstützt drei Möglichkeiten, um eine Antwort auf eine HTTP-Anfrage zu generieren . Die einfachste Methode, die standardmäßig verwendet wird, ist in diesem Fall nicht geeignet, weil Ich brauche einen fast endlosen Strom ausgehender Daten. Und ein solcher Stream kann natürlich nicht an einen einzelnen Aufruf der set_body Methode zurückgegeben werden.


Daher verwendet der beschriebene Testserver den sogenannten chunked_output . Das heißt, Beim Erstellen einer Antwort gebe ich RESTinio an, dass die Antwort in Teilen gebildet wird. Dann rufe ich einfach regelmäßig die append_chunk Methoden auf, um den nächsten Teil zur Antwort hinzuzufügen, und flush , um die akkumulierten Teile in den Socket zu schreiben.


Und schauen wir uns den Code an!


Vielleicht reicht es aus, dass die einleitenden Wörter ausreichen und es Zeit ist, zum Code selbst überzugehen, der sich in diesem Repository befindet . Beginnen wir mit der Funktion request_processor , die aufgerufen wird, um jede gültige HTTP-Anforderung zu verarbeiten. Lassen Sie uns gleichzeitig die Funktionen untersuchen, die von request_processor . Dann werden wir sehen, wie genau request_processor der einen oder anderen eingehenden HTTP-Anforderung zugeordnet ist.


Request_processor-Funktion und ihre Helfer


Die Funktion request_processor wird aufgerufen, um die von mir benötigten HTTP-GET-Anforderungen zu verarbeiten. Es wird als Argument übergeben:


  • Asio-shny io_context, an dem alle Arbeiten ausgeführt werden (dies wird beispielsweise zum Spannen von Timern benötigt);
  • die Größe eines Teils der Antwort. Das heißt, Wenn ich einen ausgehenden Stream mit einer Geschwindigkeit von 512 KB / s geben muss, wird der Wert 512 KB als dieser Parameter übergeben.
  • Anzahl der Teile als Antwort. Falls der Stream eine begrenzte Länge haben sollte. Wenn Sie beispielsweise 5 Minuten lang einen Stream mit einer Geschwindigkeit von 512 KB / s senden möchten, wird der Wert 300 als dieser Parameter übergeben (5 Minuten lang 60 Blöcke pro Minute).
  • Nun, die eingehende Anfrage selbst zur Bearbeitung.

Innerhalb von request_processor wird ein Objekt mit Informationen über die Anforderung und ihre Verarbeitungsparameter erstellt. request_processor diese Verarbeitung:


 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); } 

Der Typ response_data , der alle mit der Anforderung verbundenen Parameter enthält, sieht folgendermaßen aus:


 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} {} }; 

Hierbei ist zu beachten, dass einer der Gründe für das Auftreten der Struktur response_data besteht, dass ein Objekt vom Typ restinio::response_builder_t<restinio::chunked_output_t> ( restinio::response_builder_t<restinio::chunked_output_t> dieser Typ ist hinter dem kurzen Alias response_t verborgen) ein beweglicher Typ ist, jedoch kein kopierbarer Typ (von Analogien zu std::unique_ptr ). Daher kann dieses Objekt nicht einfach in einer Lambda-Funktion erfasst werden, die sich dann in die std::function . Wenn Sie das reponse_data in einer dynamisch erstellten Instanz von response_data , kann der intelligente Zeiger auf die Instanz reponse_data bereits problemlos in Lambda-Funktionen erfasst und dieses Lambda dann in std::function reponse_data werden.


Send_next_portion Funktion


Die Funktion send_next_portion jedes Mal aufgerufen, wenn der nächste Teil der Antwort an den Client gesendet werden muss. Darin passiert nichts Kompliziertes, daher sieht es recht einfach und prägnant aus:


 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)); } } 

Das heißt, Sende den nächsten Teil. Und wenn dieser Teil der letzte war, schließen wir die Bearbeitung der Anfrage ab. Und wenn nicht letzteres, wird ein flush an die flush Methode gesendet, die möglicherweise durch die komplexeste Funktion dieses Beispiels erstellt wird.


Funktion make_done_handler


Die Funktion make_done_handler für die Erstellung eines Lambda verantwortlich, das als After-Write-Notifier an RESTinio übergeben wird. Dieser Benachrichtiger sollte prüfen, ob die Aufzeichnung des nächsten Teils der Antwort erfolgreich abgeschlossen wurde. Wenn ja, müssen Sie herausfinden, ob der nächste Teil sofort gesendet werden soll (dh es gab "Bremsen" in der Steckdose und die Übertragungsrate kann nicht beibehalten werden) oder nach einer Pause. Wenn Sie eine Pause benötigen, wird diese über einen Spann-Timer bereitgestellt.


Im Allgemeinen einfache Aktionen, aber der Code erzeugt Lambda innerhalb des Lambda, was Leute verwirren kann, die nicht an das "moderne" C ++ gewöhnt sind. Welches ist nicht so wenige Jahre, um als modern bezeichnet zu werden;)


 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); }); } }; } 

Meiner Meinung nach liegt die Hauptschwierigkeit in diesem Code in den Besonderheiten der Erstellung und des Zuges von Timern in Asio. Meiner Meinung nach ist es irgendwie zu ausführlich. Aber das gibt es wirklich. Sie müssen jedoch keine zusätzlichen Bibliotheken anziehen.


Anschließen eines Express-ähnlichen Routers


Der request_processor gezeigte request_processor , send_next_portion und make_done_handler send_next_portion die allererste Version meines make_done_handler , die buchstäblich in 15 oder 20 Minuten geschrieben wurde.


Nach ein paar Tagen mit diesem Testserver stellte sich jedoch heraus, dass er einen schwerwiegenden Nachteil aufwies: Der Antwortstrom wurde immer mit der gleichen Geschwindigkeit zurückgegeben. Mit einer Geschwindigkeit von 512 KB / s kompiliert - ergibt alle 512 KB / s. Mit einer Geschwindigkeit von 20 KB / s neu kompiliert - gibt jedem 20 KB / s und sonst nichts. Was war unpraktisch, weil es wurde notwendig, Antworten unterschiedlicher "Dicke" erhalten zu können.


Dann kam die Idee: Was ist, wenn die Rückgabegeschwindigkeit direkt in der URL angefordert wird? Zum Beispiel stellten sie eine Anfrage an localhost:8080/ und erhielten eine Antwort mit einer vorgegebenen Geschwindigkeit. Und wenn Sie eine Anfrage an localhost:8080/128K , wurde eine Antwort mit einer Geschwindigkeit von 128KiB / s empfangen.


Dann ging der Gedanke noch weiter: In der URL können Sie auch die Anzahl der Einzelteile in der Antwort angeben. Das heißt, localhost:8080/128K/3000 Anforderung localhost:8080/128K/3000 erzeugt einen Stream von 3000 Teilen mit einer Geschwindigkeit von 128KiB / s.


Kein Problem. RESTinio kann einen Abfrage-Router verwenden, der unter dem Einfluss von ExpressJS erstellt wurde . Infolgedessen gab es eine solche Funktion zum Beschreiben von Handlern für eingehende HTTP-Anforderungen:


 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; } 

Hier werden HTTP-GET-Anforderungshandler für drei Arten von URLs gebildet:


  • der Form http://localhost/ ;
  • der Form http://localhost/<speed>[<U>]/ ;
  • der Form http://localhost/<speed>[<U>]/<count>/

Dabei ist speed eine Zahl, die Geschwindigkeit definiert, und U ist ein optionaler Multiplikator, der angibt, in welchen Einheiten die Geschwindigkeit eingestellt ist. 128 oder 128b bedeutet also eine Geschwindigkeit von 128 Bytes pro Sekunde. Und 128k sind 128 Kilobyte pro Sekunde.


Jede URL hat eine eigene Lambda-Funktion, die die empfangenen Parameter versteht. Wenn alles in Ordnung ist, ruft sie die request_processor gezeigte Funktion request_processor auf.


Die extract_chunk_size wie folgt:


 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"]); } 

Hier wird C ++ Lambda verwendet, um lokale Funktionen aus anderen Programmiersprachen zu emulieren.


Hauptfunktion


Es bleibt abzuwarten, wie dies alles in der Hauptfunktion abläuft:


 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; } 

Was ist hier los:


  1. Da ich keinen normalen regulären Router für Anforderungen benötige (der überhaupt nichts kann und die gesamte Arbeit auf die Schultern des Programmierers legt), definiere ich neue Eigenschaften für meinen HTTP-Server. Dazu nehme ich die Standardeigenschaften eines Single-Threaded-HTTP-Servers (Typ restinio::default_single_thread_traits_t ) und restinio::default_single_thread_traits_t an, dass eine Instanz eines Express-ähnlichen Routers als Anforderungshandler verwendet wird. Gleichzeitig null_logger_t ich darauf hin, dass der HTTP-Server einen echten Logger verwendet (standardmäßig wird null_logger_t verwendet null_logger_t der überhaupt nichts null_logger_t ), um zu steuern, was im Inneren geschieht.
  2. Da ich die Timer in den After-Write-Benachrichtigungen spannen muss, benötige ich eine io_context-Instanz, mit der ich arbeiten kann. Deshalb erstelle ich es selbst. Dies gibt mir die Möglichkeit, in der Funktion make_router einen Link zu meinem io_context zu make_router .
  3. Es bleibt nur, um den HTTP-Server in einer Single-Threaded-Version auf dem zuvor erstellten io_context zu starten. Die Funktion restinio::run gibt die Kontrolle nur zurück, wenn der HTTP-Server seine Arbeit beendet hat.

Fazit


Der Artikel zeigte nicht den vollständigen Code meines Testservers, sondern nur dessen Hauptpunkte. Der vollständige Code, der aufgrund zusätzlicher Typedefs und Zusatzfunktionen etwas größer ist, ist etwas authentischer. Sie können es hier sehen . Zum Zeitpunkt des Schreibens sind dies 185 Zeilen, einschließlich Leerzeilen und Kommentare. Nun, diese 185 Zeilen sind in einigen Ansätzen mit einer Gesamtdauer von kaum mehr als einer Stunde geschrieben.


Dieses Ergebnis hat mir gefallen und die Aufgabe war interessant. In der Praxis war das von mir benötigte Hilfswerkzeug schnell erhältlich. In Bezug auf die Weiterentwicklung von RESTinio tauchten einige Gedanken auf.


Wenn jemand anderes RESTinio nicht ausprobiert hat, lade ich Sie im Allgemeinen ein, es zu versuchen. Das Projekt selbst lebt auf GitHub . Sie können eine Frage stellen oder Ihre Vorschläge in der Google-Gruppe oder direkt hier in den Kommentaren äußern.

Source: https://habr.com/ru/post/de462349/


All Articles