Automatischer Weichensteller
Webservices mit C++, Teil 3: Routing von Anfragen
Nicht der Triebfahrzeugführer entscheidet darüber, an welchem Gleis der Zug einfährt, sondern der Fahrdienstleiter im Stellwerk. Ein zentral gesteuertes Routing von Anfragen soll nun der in den beiden vorausgegangenen Teilen entstandene Webservice dazulernen. Wir zeigen, wie der Code dabei an Ordnung gewinnt.
Dem Beispiel-Webservice aus den beiden vorangehenden Teilen dieser Serie mangelt es ein wenig an Übersichtlichkeit, obwohl er nur ein paar Hundert Zeilen umfasst. Es fängt damit an, dass er Anfragen in einer if/else-Kaskade nach URL-Pfad und HTTP-Methode unterscheidet und den jeweils passenden Codeblock dazu ausführt [1, 2]. Bei nur zwei, drei möglichen Optionen mag das gerade so noch angehen, aber wenn es mehr werden, verliert man schnell den Überblick.
Obendrein geschieht das alles in einer einzigen Methode, und zwar der Klasse http_worker, die verantwortlich für das Abwickeln sämtlicher Verbindungen zeichnet. Auf einem hochauflösenden Monitor im Hochkantformat kann man den Code der Klasse nur bei einem Schriftgrad von 5 Punkt komplett sehen, was für sich schon ein Grund ist, die Schere anzusetzen, um den einen oder anderen Codeschnipsel woanders hinzuschieben. Schwerer wiegt jedoch, dass der http_worker funktional undurchsichtig ist, weil er gleich drei fundamentale Aufgaben erledigt: Anfragen annehmen, routen und behandeln.
Die Definition des Routings, also welcher Endpunkt welche Aktion auslösen soll, ist an zentraler Stelle viel besser aufgehoben: in der main()-Funktion des Webservice, dort wo auch die http_worker, deren Threads und der I/O-Kontext ins Leben gerufen werden. Und sämtliche Aktionen gliedert man sinnvollerweise als Handler-Funktionen in eine oder mehrere Dateien aus. Wenn man dann dem http_worker die Routing-Informationen mitgibt, muss der in seiner Methode process_request() nur noch den Router mit der Weiterleitung der Anfrage beauftragen, aber nicht mehr selbst darüber entscheiden. Hat der Router einen Eintrag für die Kombination aus HTTP-Methode und URL-Pfad gespeichert, ruft er den dafür registrierten Handler auf. Dieser liefert eine Antwort zurück, die der http_worker an den Client sendet. Ist kein zur Anfrage passender Handler registriert, meldet sich der Router selbst zurück: mit dem Klassiker HTTP-Status-Code 404 („not found“).
Im Detail
So weit die Sicht von ziemlich weit oben. Zoomt man an den Code heran, sieht man Folgendes in der Datei main.cpp (siehe Repository, Branch „part3“):
trip::router router; router .post(std::regex("/prime"), handle_prime{})
Ein Router vom Typ trip::router entscheidet anhand der Anfrage, wohin er sie zum Beantworten weiterleiten soll. Das Regelwerk für die Fallunterscheidungen bringt man dem Router über Methodenaufrufe bei: post() zum Registrieren von Handlern für HTTP-POST-Requests, get() für GET-Requests, head() für HEAD und so weiter. Die Methode erwartet zwei Parameter: Der erste ist ein regulärer Ausdruck, der das Muster vorgibt, zu dem der Anfragepfad passen soll. Der zweite ist ein Handler, den der Router immer dann – und nur dann – mit dem HTTP-Request-Objekt und dem regulären Ausdruck als Parameter aufruft, wenn der Pfad aufs Muster passt. Der Handler handle_prime beherbergt den in Teil 2 dieser Serie erwähnten Code mit dem Miller-Rabin-Primzahltest.
Ein solcher Handler könnte eine freistehende Funktion sein, ist er aber nicht, weil es möglich sein soll, ihn außer den beiden genannten Parametern noch mit weiteren Daten zu füttern, die nicht von der Anfrage abhängen. Gemeint sind Daten, die wie der Router in main() deklariert werden, zum Beispiel ein fixer Wert oder ein Objekt, das Datenbankzugriffe verwaltet. Man sagt auch, dass der Handler einen Zustand erhält.
Objekte als Funktionen
In C++ eignen sich dafür sogenannte Funktionsobjekte. Dabei handelt es sich um struct- oder class-Definitionen, die den Funktionsoperator operator() implementieren. Die Definition der Schnittstelle gibt trip::handler mit einer abstrakten Methode vor (siehe trip/handler.hpp):
namespace trip { struct handler { virtual response operator()( request const &, std::regex const &) = 0; }; }
Nach dieser Vorgabe kann man nun den tatsächlichen Handler implementieren, der dazu von trip::handler ableitet. Diesen Handler bringt man sinnvollerweise losgelöst vom http_worker in einer eigenen Datei unter (siehe handlers.cpp):
struct handle_prime : trip::handler { trip::response operator()( trip::request const &req, std::regex const ®ex) { /* hier etwas Sinnvolles mit der Anfrage in req und dem regulären Ausdruck in regex anstellen */ } };
Er erhält keinen Zustand, ließe sich aber leicht dahingehend erweitern. Wie das geht, zeigt handle_countdown():
struct handle_countdown: trip::handler { handle_countdown(int counter) : counter(counter) {} trip::response operator()( trip::request const &, std::regex const &) { return trip::response{ trip::status::ok, std::to_string(counter--), "text/plain"}; } private: int counter; };
Nach dem Registrieren dieses Handlers zum Beispiel mit
router .get(std::regex("/countdown"), handle_countdown{10})
würde der Webservice bei jedem Aufruf der URL /countdown den Zähler um 1 dekrementieren und das Ergebnis als reinen Text (text/plain) zurückgeben. Statt eines festen Wertes wie 10 könnte man dem Handler-Objekt etwa auch ein Datenbankobjekt mitgeben, das es wie den counter als internen Zustand speichert, oder irgendwas anderes.
Achtung: Wenn wie in unserem Beispiel-Webservice die Handler parallel laufen können, weil der asio::io_context in mehreren Threads lebt, sollte der Code Zugriffe auf gemeinsam genutzte Variablen mit einem boost::asio::strand oder std::mutex serialisieren – aber das ist Stoff für einen anderen Artikel.
Die Rückgabe aller Handler ist ein Objekt vom Typ trip::response, das den HTTP-Status-Code (z. B. 200 für OK) enthält sowie die Antwort an den Client in body und den MIME-Type dieser Daten (siehe response_request.hpp):
namespace trip { struct response { http::status status; std::string body; std::string mime_type; }; }
Routing
Damit ist klar, was der Router passend zu einer URL aufruft und wie die Antwort aussieht, aber nicht, wie er einen Handler aufruft und wie er ihn registriert. Aufschluss gibt ein Blick in die Klasse router (siehe Listing unten, zu finden in der Datei trip/router.hpp).
Wie Sie weiter oben schon gesehen haben, erhält eine Methode wie post() einen regulären Ausdruck für den Pfad, der matchen muss, damit der Handler im zweiten Parameter aufgerufen wird. Daraus und aus der HTTP-Methode (hier POST) baut sie ein route-Objekt, das sie einer Liste (std::vector) hinzufügt. Zum Schluss gibt sie eine Referenz auf das router-Objekt zurück, sodass man Registrierungen à la
router .post("/prime", ...) .get("/countdown", ...) .get(...)
bequem kaskadieren kann.
Die Methode execute() ist die einzige des Routers, mit der der http_worker in Kontakt kommt. In process_request() ruft er sie mit dem Request-Objekt auf, das über target() den URL-Pfad mit sämtlichen Parametern und einem eventuellen Fragment (beispielsweise „?foo=bar&baz=1#fragment“) zurückgibt. Die Parameter müssen abgetrennt werden, damit der eigentliche Pfad übrig bleibt, gegen den der reguläre Ausdruck matchen soll. Das erledigt boost::urls::parse_origin_form(). Falls beim Parsen ein Fehler aufgetreten ist, kehrt execute() mit einer Fehlerantwort zurück.
Sonst durchläuft eine Schleife sämtliche registrierten Routen; passt eine, ruft sie den dazu registrierten Handler auf. Passt keine, kehrt execute() mit einem „not found“-Fehler zurück.
Warum eigentlich Regex?
Vielleicht haben Sie sich schon gefragt, warum der Router die Request-Pfade nicht einfach als Strings matcht, schließlich ist das eine billigere Operation als das Matchen regulärer Ausdrücke. Das hängt damit zusammen, dass der Webservice auch auf Pfade wie /mult/23/42 reagieren können soll, wobei die beiden Zahlen für beliebige ganze Dezimalzahlen stehen. Und der Handler, der ja vom Router den regulären Ausdruck übergeben bekommt, soll darüber die beiden Zahlen extrahieren können. Genau das haben wir dem Webservice beigebracht. Dazu definiert der Webservice die Route
router.get(std::regex( "/mult/(-?\\d+)/(-?\\d+)"), handle_mult{})
Der Handler extrahiert die beiden Zahlen mit dem regulären Ausdruck in re und dem Pfad in path wie folgt:
std::smatch match; std::regex_match(path, match, re);
Anschließend wandelt er mit
long a = boost::lexical_cast<long long>(match[1]); long b = boost::lexical_cast<long long>(match[2]);
die Matches in long-Werte um, multipliziert sie miteinander und gibt das Produkt zurück. In der Datei handlers.hpp sehen Sie den Handler inklusive Fehlerbehandlungen mit try/catch, die im obigen Beispiel der Übersichtlichkeit halber fehlen.
Finito
Nach dieser umfangreichen Umarbeitung des Webservice ist die Dreifaltigkeit aufgelöst: Der http_worker verwaltet eingehende Requests, ein Router stellt die Weichen für die Anfrage und der Handler am Ende des angesteuerten Gleises verarbeitet sie. Das Ergebnis landet wieder im http_worker, der es an den Aufrufer sendet.
Was zur maximalen Ordnung noch fehlt: jeden Handler in einer eigenen Datei unterzubringen. Für das Beispielprojekt haben wir das nicht für nötig erachtet. In realen Projekten könnte das allerdings sinnvoll sein. (ola@ct.de)
Quellcode bei GitHub: ct.de/ywwu