Zum Inhalt springen

Microservice Architektur & Protokoll

Grundlagen

Unsere Microservices sind vollständig separate, vom Riddle Core abgetrennte Symfony Umgebungen, die mindestens über einen zentralen Message Controller zur Kommunikation und über das RiddleWebsocketProtocolBundle verfügen. Darüber hinaus ist die Gestaltung der Microservices nicht festgelegt und kann sich von Fall zu Fall unterscheiden.

Protokoll

Die Kommunikation zwischen den Komponenten WebSocket, Riddle Core und Microservices erfolgt über das RiddleWebsocketProtocolBundle. Dabei handelt es sich um deliminierte und newline-terminierte (\n) Nachrichten, deren Komponenten jeweils mit ‚=‘ getrennte Key-Value-Paare sind. Wir verwenden zwei verschiedene Nachrichtentypen, die sich durch ihren Anwendungszweck und ihr Trennzeichen unterscheiden:

Mathilde Message (Trennzeichen Tilde (~)): Nachrichten, die über den Riddle Core an einen Microservice weitergeleitet werden sollen und Antworten auf diese Nachrichten.

Hashimoto Message (Trennzeichen Hash (#)): Nachrichten von und zu Microservices

Ganz einfache, gültige Nachrichten können demnach wie folgt aussehen:
Hashimoto: key=value#key2=value2\n
Mathilde: key=value~key2=value2\n

Die Besonderheit der Mathilde Message liegt darin, dass sie eine Hashimoto Message zur Weiterleitung an einen Microservice enthalten kann. Diese Nachricht hat immer den Key ‚fwd‘, der Ziel-Microservice wird durch eine Zahl mit dem Key ’scope‘ bestimmt.

Beispiel: key=value~scope=1~fwd=key=value#key2=value2~key2=value2\n
Diese Mathilde Message enthält folgende Komponenten:
key: value
scope: 1
fwd: key=value#key2=value2
key2: value2

Wobei die in ‚fwd‘ enthaltene Nachricht eine Hashimoto Message darstellt, die wiederum folgende Komponenten enthält:
key: value
key2: value2

Damit ein im Value-Teil enthaltenes Trennzeichen nicht zum fehlerhaften Parsen der Nachricht führt, werden die Value-Teile immer URL-encoded.

Der Code des Protocol-Bundles übernimmt für uns diese ganzen Arbeiten. Um delimitieren, terminieren, URL-encoden/decoden und parsing braucht man sich beim Nutzen des Protokolls nicht zu kümmern. Das ganze ist wie folgt umgesetzt:

Jede Nachricht erbt von der Basis-Klasse „Message“. Diese nimmt eine Nachricht, einen Delimiter und einen Terminierer entgegen, wobei der Terminierer in unserem Fall immer „\n“ ist, und der Delimiter „~“ bzw. „#“. Beim Erstellen eines Objekts wird automatisch der Terminierer angehängt (falls nicht vorhanden) und die Nachricht wird mit dem Delimiter geteilt. Die einzelnen Teile werden in die Property „parts“ geschrieben, wobei der Terminierer weggelassen wird. Dann wird die Funktion „parse“ aufgerufen, die von der Child-Klasse implementiert werden muss. Das ist in unserem Fall MathildeMessage und HashimotoMessage. Dort wird zusätzlich jeweils eine Data-Klasse eingeführt, in der die Key-Value Paare der Nachricht auf Properties gemappt werden. Die Basis-Klasse „DataMapping“ stellt dazu die Funktion „map“ zur Verfügung, sowie die Funktion „buildDelimitedMessage“ um aus den Daten wieder eine Nachricht zu Erstellen sowie „toArray“ um die Daten in Array-Struktur zu überführen. Die Funktion „map“ arbeitet mit Setter-Methoden. Aus dem Key des Key-Value-Paars wird der Setter-Methodenname in Camel case abgeleitet ( ‚testProp‘ => ’setTestProp‘). Ist der Setter nicht vorhanden, schlägt der komplette Vorgang fehl. Ist ein Key in einer Nachricht vorhanden, dann muss die Data-Klasse also auch die passende Property und Setter-Methode enthalten. Mittels Type-Hinting in der Setter-Methode kann auch ein spezieller Datentyp vorausgesetzt werden.
Die Basis-Klasse „DataMapping“ enthält bereits einige Properties, die in jeder Nachricht vorhanden sein können:

id: Reservierte Property, mit der ein ID-System realisiert wird, um ansynchrone Antworten auf Nachrichten zu ermöglichen
messageType: 1 für Request, 2 für Response
commandId: Die Befehls-ID. Der Bereich 1-99 ist für externe Befehle reserviert, d.h. Befehle die vom Nutzer über das Frontend gesendet werden können. Der Bereich >100 ist für interne Befehle reserviert
success: Ob ein Befehl erfolgreich ausgeführt wurde oder nicht
reason: Der Grund warum ein Befehl nicht erfolgreich ausgeführt wurde

Die parse-Funktion erstellt also ein Objekt der Data-Klasse die in der Child-Klasse festgelegt ist und ruft die map-Funktion auf. Schlägt diese fehl, wird die Nachricht ungültig, was in der Property „valid“ reflektiert wird. War das Mapping erfolgreich, wird die Methode „validate“ aufgerufen, die von der Child-Klasse implementiert werden kann. In dieser Methode können die Daten auf inhaltliche Korrektheit geprüft werden. Dabei kann u.a. die Methode „hasProperties“ der Data-Klasse verwendet werden, um zu prüfen ob gewisse Properties mit einem Wert belegt wurden. Gibt die validate-Methode false zurück, wird die Nachricht ebenfalls ungültig.

Eine Besonderheit gibt es bei der Mathilde Message. Da diese wie bereits beschrieben eine Hashimoto Message enthalten kann, wird dies in den Methoden speziell berücksichtigt:
Wird eine Mathilde Message aus einem Message-String erzeugt, wird nach abgeschlossenem Mapping automatisch ein Hashimoto Message-Objekt daraus erzeugt und in „fwd“ gespeichert, womit der String überschrieben wird. Wird eine Mathilde Message aus Daten-Komponenten erzeugt, kann „fwd“ wahlweise einen String, ein Daten-Array, ein Objekt der Datenklasse der Hashimoto-Message oder ein Objekt der Hashimoto-Message enthalten. Egal in welchem Format, nach Abschluss des Parsings wird aus den enthaltenen Daten ein Hashimoto Message-Objekt erstellt und in „fwd“ geschrieben. Diese Typ-Unabhängigkeit ist in der Methode „build“ implementiert, die in HashimotoMessage und MathildeMessage enthalten ist. Diese Methode kann auch vom Nutzer verwendet werden, um Nachrichten aus einem der vier genannten Datentypen zu erzeugen.

Neue Protokoll-Klassen erstellen

Beim Erstellen eines neuen Microservices, sollte auch eine neue Protokoll-Klasse erstellt werden. Hierzu sind folgende Schritte notwendig:

1. Message-Klasse erstellen (Hashimoto + <MicroserviceSlug> + Message, bspw. „HashimotoMailerMessage“)

2. Data-Klasse erstellen (<MicroserviceSlug> + Data, bspw. „MailerData“)

3. In der Data-Klasse alle Properties (protected) und Setter-Getter-Methoden (public) anlegen

4. Data-Klasse in der Message-Klasse angeben, z.B.
public static $DATA_CLASS = „Riddle\WebSocketProtocol\Data\MailerData“

5. In der Klasse „Scope“ eine neue Scope-ID vergeben und samt Message-Klassenname eintragen

6. Falls gewünscht in validate-Methode auf inhaltliche Korrektheit prüfen

7. Falls gewünscht Factory-Methoden zum Erstellen von Message-Objekten aus Daten erstellen, z.B.

public static function buildSmtpRequestMessage(int $smtpId) :HashimotoMessage
{
    $data = new Data\MailerData();
    $data->setMessageType(1);
    $data->setCommandId(110);
    $data->setSmtpId($smtpId);

    return HashimotoMailerMessage::build($data, true);
}

Anschließend das Protokoll im Core und allen Microservices updaten. Im Core außerdem die passende Message-Handler Klasse anlegen (s.u.)


Schnittstelle Riddle Core <-> Microservices

Die Kommunikation zwischen dem Riddle Core und den Microservices findet über HTTP POST statt. Der Body wird als json gesendet. Folgende Daten sind enthalten:

success: Gibt den Erfolg der Nachrichtenübermittlung an, bezieht sich nicht auf die erfolgreiche Ausführung des Nachrichteninhalts
code: HTTP response code
reason: Grund warum die Nachrichtenübermittlung fehlgeschlagen ist
data: Daten der Nachricht, die mittels toArray-Methode der DataMapping-Klasse in Array-Struktur umgewandelt wurden

Zum Senden von Nachrichten an Microservices wird Gateway-Service im Microservice-Bundle verwendet. Die send-Methode des Service nimmt eine Scope (Ziel-Microservice) und eine HashimotoMessage entgegen.
Der Microservice empfängt die Nachricht mit dem notwendigen Message-Controller (Endpunkt /message).

Die Microservices können ebenfalls selbst initiierte Nachrichten an den Core senden. Dazu gibt es einen Core-Gateway-Service, der über die Methoden send und forward verfügt. Mit send werden Nachrichten direkt an den Core gesendet, mit forward werden Nachrichten über den Core an einen anderen Microservice weitergeleitet. Diese Nachrichten werden vom Core im Message-Controller des Microservice-Bundle empfangen (Endpunkt /internal/mathilde und /internal/hashimoto).

In den HTTP-Requests können drei besondere Header vorkommen:
X-Origin-Env: Wird vom Riddle Core bei Requests an einen Microservice gesetzt, damit der Microservice weiß von welcher Environment die Request kommt. Das ist wichtig, da wir nur einen Microservice für DEV und LIVE haben, es aber für DEV etliche verschiedene Environments / VHosts gibt.
X-Origin-Scope: Wird von Microservices bei Requests an den Core gesetzt, damit der Core weiß von welchem Microservice die Nachricht kommt. Somit kann der Core die zu verwendende Message-Klasse bestimmen und aus der Nachricht ein Hashimoto Message-Objekt erzeugen.
X-Forwarded-From: Erhält der Core von einem Microservice eine weiterzuleitende Nachricht, wird bei der Request an den Target-Microservice der Header mit der Scope des sendenden Microservice belegt. Der empfangende Microservice kann also überprüfen, von welchem Microservice die Nachricht stammt.

Message Handler

Um eine einfache Erweiterbarkeit und Wartbarkeit im Riddle Core herzustellen, ist es nicht vorgesehen dass Änderungen am Message-Controller oder Gateway-Service vorgenommen werden müssen. Stattdessen wird für jede Message-Klasse eine Handler-Klasse im Core erstellt (Message-Klasse + „Handler“, z.B. HashimotoMailerMessageHandler). Diese muss folgende Methoden des BaseHandlers implementieren:

handleEmbedForwardMessage(MathildeCoreMessage $message, bool $fromFallback):
Wird aufgerufen wenn im WebSocket bzw. im HTTP-Fallback eine externe Nachricht empfangen wird. False zurückgeben, falls Nachricht nicht weitergeleitet werden soll, oder die weiterzuleitende Nachricht. Hier können also Bedingungen geprüft werden und die Nachricht kann vor der Weiterleitung erweitert bzw. manipuliert werden.

handleInternalForwardMessage(MathildeCoreMessage $message):
Wie oben, nur handelt es sich hierbei um eine interne Nachricht, die von einem Microservice empfangen wurde

handleInternalMessage(HashimotoMessage $message):
Wird aufgerufen wenn eine Nachricht von einem Microservice empfangen wird, die direkt an den Riddle Core gerichtet ist. Die Nachricht muss dann in dieser Funktion verarbeitet werden. Es wird eine Antwort in Form einer HashimotoMessage als Rückgabe erwartet.

WebSocket

Der WebSocket ist mit Ratchet PHP gebaut, eine event-driven Websocket Library auf Basis von React PHP. Der Hauptcode zur Ausführung des WebSocket liegt in bin/websocket im Riddle Core-Repo. Das Script erwartet als Parameter den ausführenden vhost. Durch ein Lock-File wird sichergestellt, dass der WebSocket nur einmal läuft. Mit einem Interrupt-File kann die Ausführung des WebSocket jederzeit unterbrochen werden. Die Files befindet sich im var/flags Verzeichnis und sind über Riddle-Monika steuerbar.

Je nach vhost verwendet der WebSocket einen unterschiedlichen Port. Die Ports sind von außen nicht erreichbar, durch einen Reverse-Proxy wird aber der Endpunkt /ws/connect/1 auf den Port des Core-WebSocket umgeleitet.

Der WebSocket verwendet das MessageInterface „CoreWebSocket“ im WebsocketBundle des Riddle Core. Dort werden eingehende Nachrichten gehändelt und ggf. über den GatewayService des MicroserviceBundle an den Microservice weitergeleitet.

Für Clients die keine Unterstützung für WebSockets haben, existiert im WebsocketBundle ein FallbackController. Dieser nimmt über HTTP exakt die selben Daten entgegen wie der WebSocket.