Allen voran geht es mir um die Vereinfachung der Softwareentwicklung auch auf Systemebene. Ich meine die folgenden Artikel zeigen zumindest mittel- und langfristig genau das Gegenteil und sind ein gutes Beispiel ausufernder Systemlandschaften durch selbstverschuldet aufgesetzte Komplexität.
- Wie lässt sich Ordnung in einen Haufen (Micro-)Services bringen?
- Wer Microservices richtig macht, braucht keine Workflow Engine und kein BPMN
- Microservices verbinden in Datenflüssen
Wer Microservice richtig richtig macht, braucht keine Workflow Engine, kein BPMN, kein ESB, kein Message Broker, keine Aktor-Modelle und Frameworks, keine Enterprise Application Server, kein Event-Sourcing, kein CQRS, kein Event-Driven Enterprise FooBar.
Im Kern geht es in den Artikeln um Interaktion zwischen verteilten Systemen aka Microservices und deren Ochestrierung bzw. Choreografie hin zu Prozessen bzw. Workflows.
Ich meine, für ein aufgabengerechtes Microservice Design werden lediglich Grundlagenkenntnisse in DDD/OOA, Consumer-Driven-Contracts und HTTP benötigt. Die praktische Umsetzungen begnügt sich mit einer leichtgewichtige HTTP Server Implementierung wie bsw. NodeJS, Flask oder Nancy.
Die pure Anzahl der genannten Lösungen, Konzepte und Technologien bringt mich leider in die Verlegenheit, nur einen Teil kommentieren zu können.
Tobias gibt sich mühe, aber ihm gelingt aus meiner Sicht weder die visuelle Darstellung noch die "Code nahe" Umsetzung der nötigen Prozessschritte und dessen Reihenfolge. Kein Wort zu Edge-Cases wie Race-Conditions, Message-Laufzeiten, Message-Versionen und Zustandshaltung. Mir bleibt unklar wann, welche Nachricht eintrifft und wie sich der interne Zustand wann und wie ändert. Eine lockere Grafik ist nett, aber ein Zustandsautomat hätte es aus meiner Sicht besser auf den Punkt gebracht. Mich überzeugen Ralfs visuelle Darstellung da schon eher.
Autonomy und Reduktion
Tobias verwendet nahezu inflationär Messages aka Events/Commands und Handler aka Microservices. Seine Grafik zeigt eindrucksvoll wie komplex ein Zusammenspiel für einen kleinen Workflow werden kann. Zumindest bemerkt er am Ende seines Artikels die Problematik und gibt am Ende ein passendes Fazit dazu ab. Unerwähnt bleibt leider auch hier die passende Architektur zur praktischen Umsetzung. Der Leser wird allein mit jeder Menge Messaging-FooBar zum "ausprobieren" gelassen.
Verteilte Systeme
Ralfs Beitrag geht eher den konzeptionellen Weg. Er versucht das Problem erst einmal "lokal" aber event getrieben lösen zu wollen. Das ist vom Ansatz her nicht schlecht, doch nur der kleinste Teil der Aufgabe. Ralf beschreibt Prozessfluss durch Events in einem lokalen "Lebensraum". Er blendet in seinen Erläuterungen die Kommunikation zwischen abgeschlossenen verteilten Systemen mittels "Das Microservicespezifische ist dann drumherum zu wickeln." und "Onion Architektur" einfach aus. Interessant, aber genau genommen der genau gegenteilige Designansatz zu Microservices.
Microservices werden von "außen" nach "innen" entworfen und implementiert.
Das heißt, die Konsumentenschnittstellen (API-Contract) und das Runtime-Environment eines Microservices sind weit "wichtiger" als die interne Implementierung. Ich meine, genau hier liegt auch der Wert einer Microservice Architektur. Es wird sich von Anfang an auf die Lauffähigkeit und einfache Verwendung im Gesamtsystem konzentriert. Am Anfang steht eben der "konsumentenfreundliche" und lauffähige API-Kontrakt als Consumer-Driven-Contract.
Microservices sind keine Nanoservices
Leider bleibt der Grund des gewählten Schnitts der Domänen, also Consistency Boundary und Bounded Context, in Tobias Design unklar. Z.B. die Stornierung einer Bestellung ist offensichtlich elementarer Teil des Bestellvorgangs. Warum in seiner beschriebenen Umsetzung nicht? Warum werden mehrere "Wrapper-Microservices" als "Event-Erzeuger" in seinen Grafiken sichtbar? In seinem Beispiel scheint es irgendwie in Ordnung zu sein, in der Realität haben diese -IMHO unnötigen- Entscheidung meist große Auswirkung auf die Komplexität und Wartbarkeit.
Microservices müssen eine "konsumentenfreundlich" Schnittstelle bzw. API haben
Natürlich können Daten als Events zwischen verteilten Funktionseinheiten aka Microservices "fließen". Letztlich ist jeder technisch angehauchte Ansatz, micro wie makro, darauf reduzierbar. Wie und warum verteilte Systeme und deren Kommunikation über die gewünschten Grenzen mit Konsumenten hinweg entstehen, muss neben der Geschäftsdomänenanalyse und Systemdesigns der Anfang einer konkreten konsumentengetriebenen API sein. Richtlinien und Regeln der Kommunikation entstehen bei genügender Akzeptanz und Breite aus der Notwendigkeit des gemeinsamen Erreichens eines Ziels heraus. Eine Kommunikationsschnittstelle muss demzufolge flexibel genug sein, um sehr viele unterschiedliche Entwicklungsrichtungen über die Zeit dauerhaft zu ermöglichen.
Kommunikation über C# Events sind es nicht. Workflow-Engines zu proprietär. Message Broker/Bus schon eher, leider jedoch nicht breit genug aufgestellt und meist zentralisiert organisiert. Microservice Architektur hat ein wichtiges Ziel: Entkopplung von Abhängigkeiten auf allen Ebenen. Bleibt End-2-End Kommunikation. Für mich das Mittel der Wahl. Nun kann man sich über die Kommunikationsprotokolle noch streiten. Ich für meinen Teil wähle HTTP und JSON. Sie sind einfach, flexibel und sehr weit verbreitet.
Microservices sind explorativ
Kleine oder große Bildchen mit Pfeilen sind ein Mittel der zwischenmenschlichen Kommunikation eines möglichen Designs. Da stimmen Ralf, Tobias und ich offenbar überein. Sicher mag es sehr stabile Geschäftsdomänen mit hohem Dokumentationsbedarf geben. Genau dafür gibt es dann auch UML, BPMN und Co. Die Konzepte, Empfehlungen, Regel und Richtlinien hinter Microservices bemühen sich jedoch, auf Domänen mit vielen, kleinen und schnelle Änderungen mittels Fail-Fast zu reagieren. Heißt, an den meisten Tagen wird ein autonomer Microservice schneller entwickelt und vollständig in Produktion gestellt als ein genaues Designdiagramm erstellt wurde.
Autonomie von Microservices
Tobias erwähnt am Anfang seines Artikels den Einsatz von Event-Souring. Neben der kritisch zu sehenden Systemrelevanz fehlt der Grund für diese Empfehlung. Der von Tobias häufiger erwähnte "Rückfall" in ein Pull-Model ist meist die logische Konsequenz einer übermäßig komplexen und schwer verständlichen asynchronen Kommunikationen zwischen verteilten Systemen. Wie Tobias den aktuellen Zustand des Bestellprozesses abfragen kann, ist dann wohl die Lösung des Event-Source Geheimnis. Zustandsabfrage wäre in einem Event-Driven Szenario ohne den Einsatz eines Event-Log nicht möglich und ist offensichtlich der Grund der Empfehlung. Wie sinnvoll damit umgegangen wird bleibt leider offen.
Kurzum bleiben zu viele praktische Herausforderungen an eine Event getriebene Architektur unausgesprochen auch wenn nur der Happy-Day geschildert wird. In diesem Punkt scheinen mir die Argumentation zu Workflow-Engines von Bernd Rücker, Daniel Meyer praxistauglicher und vermitteln den Schluss, dass es ohne Gehirnschnecke, Megainfrastruktur sowie technisch konzeptionellem Overkill bsw. zentrale Message-Broker und Event-Sourcing Datenbanken zumindest schnell und irgendwie einfach funktionieren kann. Ich teile den Einsatz einer oder mehrerer dedizierter oder integrierter Workflow-Engines zur Ochestrierung verteilter Systeme und das Big-Design-Upfront mittels BPMN zwar nicht, dennoch wurden ganz treffend die zunehmende Komplexität asynchroner Kommunikation und gute praxistaugliche Alternativen wie Datenreplikation angesprochen. Die Abbildung von koordinierten Geschäftsvorgängen ist Teil der Geschäftsdomäne, damit Teil des Bounded Context und damit Teil des Microservice. Autonomie und Datenhoheit auf allen Ebenen, einfache aber breite Kommunikationsprotokollunterstützung gepaart mit expliziten Input-, Output- und Fehler- Kontrakten entkoppeln das Gesamtsystem und reduzieren die Abstimmungsaufwände.
Als Einstieg wähle ich eine sehr einfache deklarative Form der Systembeschreibung mittels Resource-URLs, statt abstrakter und meist Herstellergetriebener UML/BPMN Diagramme oder Workflow-Engines. Von einer kleinen Design-Skizze abgesehen, lenkt nichts vom eigentlichen Zweck der "Konsumentengetriebenen API" Entwicklung ab. Nachfolgend ein erster HTTP/JSON-API basierter Systementwurf im JSON Format.
{
"orders_url" : "https://ecommerce.service.io/orders{/order_id}",
"reservations_url" : "https://ecommerce.service.io/reservations{/order_id}",
"payments_url" : "https://ecommerce.service.io/payments{/order_id}",
"shipments_url" : "https://ecommerce.service.io/shipments{/order_id}"
"status_url" : "https://ecommerce.service.io/status{/order_id}"
}
Diese Beschreibung enthält mit seinen impliziten Konventionen bereits so viele Informationen, um den geübten HTTP/RESTful API Entwickler eine ressourcenbasierte Vorstellung der involvierten Anwendungssysteme aka Microservices und erste Details der möglichen Operationen darauf zu geben. Oft kann mit einer ersten Implementierung der Konsumenten bereits begonnen werden. Bei ersten Integrationstests helfen Mocks der Kontrakt-Implementierungen. Diese Art der rudimentären Definition der Konsumenten-Kontrakte ist für den ersten Einstieg ausreichend. In die praktische Umsetzung müssen Schritt für Schritt und immer aus der Sicht und dem Nutzen der API Konsumenten die Kontrakte verfeinert werden. Da dies auf konzeptioneller Ebene in den genannten Artikeln bereits zu einem großem Teil geschehen ist, belasse ich es vorerst bei den Einstiegspunkten in die API.
Beispiel einer Microservice API Implementierung
service.get('/orders/:id?', (req,res) => {
res.send({});
});
service.post('/orders', (req,res) => {
res.status(201).send({});
});
Beispiel eines Konsumenten
request.get('https://ecommerce.service.io/orders')
.then(result => {
console.log(result);
});
Microservices Architekturen adressieren vor allem einen schnellen und flexiblen Integrationsprozess von verteilten Systemen.
In der Praxis (JavaScript) helfen zusätzliche Frameworks wie Nock oder Consumer-Contracts den Entwicklungsprozess und nötige Integrations-Tests schnell und einfach zu entkoppeln.
Wie angesprochen möchte ich zeigen, dass mit einfachsten Mitteln Ochestrierung oder auch Choreografie mehrerer Microservices hin zu einem komplexeren Prozess ohne weiteres möglich ist. Dabei braucht sich der ausschließliche Einsatz des HTTP-Protokolls hinter den genannten Lösungen wie Workflow-Engines, ESB oder Message-Broker in Punkto Stabilität, Zuverlässigkeit und Skalierung keinesfalls verstecken. HTTP ist zuverlässig, verständlich und durch seine Verbreitung leicht in jede Programmiersprache und Platform zu integrieren. Für die vorgestellte Lösung ist HTTP die einzige konzeptionelle und in seinen Implementierung in der jeweiligen Platform technologische Basis.
Da die Aufgabe meist vielschichtige Aspekte umfasst, beginne ich mit einer einfachen deklarativen Prozessbeschreibung (HTTP URLs und JSON) der einzelnen Schritte des Prozesses und der beteiligten Microservices. Es entsteht ein grob beschriebener endlicher Zustandsautomaten mit seinen Aktionen und Übergängen. Aktionen und Übergänge sind dabei immer nur einfache HTTP-URLs. Aktion werden von Konsumenten als aufgerufen, Übergänge werden als HTTP-Callbacks bzw. Webhooks an den jeweiligen Konsumenten gemeldet.
Vereinfacht dargestellt geht es um folgenden Bestellprozess.
Veranlasse Bestellung -> Veranlasse Reservierung -> Veranlasse Zahlung -> Veranlasse Versand
| | | | | |
Abbruch Abbruch Abbruch |--| Abbruch
Wiederholung
Auf konzeptioneller wie auch technischer Ebene empfehle ich das lesen von Railway Oriented Programming. Ein echter "eye opener" Flussorientierter und asynchroner Prozessmodellierung.
Folgend ein Beispiel für den Bestellprozess:
{
"create_order_line": [{
//Veranlasse Bestellung
"create_orders_url":"https://ecommerce.service.io/orders/create",
//Erfolgreich - veranlasse Reservierung
"orders_success_url": "https://ecommerce.service.io/reservations/{order_id}/create",
//Fehler - melde Abbruch der Bestellung
"orders_failure_url": "https://ecommerce.service.io/status/{order_id}/push",
},{
//Veranlasse Reservierung
"create_reservations_url": "https://ecommerce.service.io/reservations/{order_id}/create",
//Veranlasse Zahlung
"reservations_success_url": "https://ecommerce.service.io/payments/{order_id}/create",
//Fehler - Veranlasse Abbruch des Bestellprozess
"reservations_failure_url": "https://ecommerce.service.io/orders/{order_id}/abort"
},{
//Veranlasse Zahlung
"create_payments_url": "https://ecommerce.service.io/payments/{order_id}/create",
//Erfolgreich - veranlasse Versand
"payments_success_url": "https://ecommerce.service.io/shipments/{order_id}/create",
//Fehler - melde Zahlungen fehlgeschlaggen
"payments_failure_url": "https://ecommerce.service.io/status/{order_id}/push",
//Fehler - veranlasse Abbruch des Bestellprozess
"payments_abort_url": "https://ecommerce.service.io/orders/{order_id}/abort"
},{
//Versand veranlassen
"create_shipment_url": "https://ecommerce.service.io/shipments/{order_id}/create",
//Erfolgreich - veranlasse erfolgreichen Abschluss des Bestellprozesses
"shipments_success_url": "https://ecommerce.service.io/orders/{order_id}/complete",
//Fehler - veranlasse Abbruch des Bestellprozess
"shipments_failure_url": "https://ecommerce.service.io/orders/{order_id}/abort"
}]
}
Das Prinzip ist extrem einfach, immer gleich und kann individuell z.B. um HTTP-Verbs, Data-Payload und Meta-Daten ergänzt werden. Wer möchte, kann die Implementierung auch bis zur Umsetzungen eines Routing Slip weiter voran treiben.
Zusätzlich werden die HTTP Endpunkte der entsprechenden Microservice ergänzt und um die gewünschte Funktionalität in einer beliebigen Programmiersprache bzw. Runtime-Platform ergänzt.
/orders/create
/orders/{order_id}/complete
/orders/{order_id}/abort
/reservations/{order_id}/create
/reservations/{order_id}/abort
/payments/{order_id}/create
/payments/{order_id}/abort
/shipments/{order_id}/create
/shipments/{order_id}/abort
/status/{order_id}/push
Bei der Ochestrierung werden Microservices zu einem kombiniertem Prozess ausgeführt. Heißt, ein Konsument (z.B. UI oder Microservice) integriert, steuert und kontrolliert den Prozessfluß. Das könnte mit JavaScript als sequentialisierter asynchroner Promise-Flow z.B. so aussehen:
request(create_order_line.create_orders_url).catch(request(create_order_line.orders_failure_url))
.then(request(create_order_line.create_reservations_url).catch(request(create_order_line.reservations_failure_url)))
.then(request(create_order_line.create_payments_url).catch(request(create_order_line.payments_failure_url)))
.then(request(create_order_line.create_shipment_url).catch(request(create_order_line.shipments_failure_url)))
.then(request(create_order_line.shipments_success_url));
Natürlich ist auch eine parallele Ausführung möglich. Ja nach Gegebenheiten und Anforderungen an Geschwindigkeit, Latenz und Korrelation kann entschieden werden, welche Form passender ist.
var orderId = clientSideGeneratedOrderId();
Promise.all([
request(create_order_line.create_orders_url, orderId),
request(create_order_line.create_reservations_url, orderId),
request(create_order_line.create_payments_url, orderId,
request(create_order_line.create_shipment_url, orderId)
])
.then(request(create_order_line.orders_success_url, orderId)
.catch(error => {
//Fehlerkompensation durch Aufruf der entsprechenden failure HTTP-Callback bzw. Webhook URLs
})
Der Prozessfluss kann laut Deklaration schnell und einfach an ansprechender Stelle angepasst werden. Anpassungen müssen jedoch an allen Stellen einer Implementierung vorgenommen werden.
Ein anderer Ansatz ist die Choreografie. Hierbei hat jeder Microservice seine "eigene" Aufgabe in der Gesamtkomposition des Prozessflusses. Die Teildeklarationen werden als Teilaufgabe des Microservice implementiert. Jeder erfolgreiche oder fehlerhafte Prozessschritt ruft über die deklarierten success/failure Webhook-URLs den nächsten Microservice-HTTP-Endpunkt bis der Gesamtprozess erfolgreich oder fehlerhaft abgeschlossen ist. Wer möchte kann, wie im folgenden Beispiel, hierbei ebenfalls auf einen Fail-Fast mittels sequenzieller oder Long-Running mittels paralleler Ausführungsform wählen. Parallel stattfindene Zustandsänderungen müssen bei der parallelen Ausführung mittels der HTTP-Callback bzw. Webhook-URLs korreliert werden. Das ist natürlich etwas umständlicher und deshalb favorisiere ich die folgende sequentialisierte asynchone Ausführungsform.
//Orders Microservice
server.get('/orders/create', (req, res) => {
createOrder()
.then(orderId => request(create_order_line.orders_success_url, orderId))
.catch(_ => request(create_order_line.orders_failure_url)
.then(result => res.send(result));
});
//Orders Microservice
server.get('/orders/{order_id}/complete', (req, res) => {
var orderId = req.params.orderId;
completeOrder(orderId)
.then(result => res.send(result));
});
//Orders Microservice
server.get('/orders/{order_id}/abort', (req, res) => {
var orderId = req.params.orderId;
abortOrder(orderId)
.then(result => res.send(result));
});
//Reservations Microservice
server.get('/reservations/{order_id}/create', (req, res) => {
var orderId = req.params.orderId;
createReservation(orderId)
.then(result => request(create_order_line.reservations_success_url, orderId))
.catch(result => request(create_order_line.reservations_failure_url, orderId)
.then(result => res.send(result));
});
//Payments Microservice
server.get('/payments/{order_id}/create', (req, res) => {
var orderId = req.params.orderId;
createPayment(orderId)
.then(result => request(create_order_line.payments_success_url, orderId))
.catch(result => request(create_order_line.payments_failure_url, orderId)
.then(result => res.send(result));
});
//Shipments Microservice
server.get('/shipments/{order_id}/create', (req, res) => {
var orderId = req.params.orderId;
createReservation(orderId)
.then(result => request(create_order_line.shipments_success_url, orderId))
.catch(result => request(create_order_line.shipments_failure_url, orderId)
.then(result => res.send(result));
});
Schade, auch ich habe mich in diesem Artikel nur auf den "Happy-Day" bezogen. Ergänzungen oder weitere Workflows sind natürlich umsetzbar, aber aus meiner Sicht eher Teil einer Artikelserie. Zu viele wichtige Aspekte, wie Datenreplikation, Fail-Over, Retry etc. sollten aus meiner Sicht nicht unbeschrieben bleiben.
Vielleicht nicht unverbrüchlich, aber tugendhaft oder "eine Vision" von Entwicklung im Kleinen wie im Großen ist für mich:
A) Strikte Trennung von Logik und Seiteneffekten
A`) siehe A ;)
B) Ausdrucksstarke Tests
C) Monitoring
D) Automatisierung