EventBus und Microservices - a Case Study: Part IV
In der Informationsflut gehen die Wegweiser unter. (Hans-Jürgen Quadbeck-Seeger)
Die Artikel dieser Reihe
Einleitung
Im dritten Teil der Artikelserie ging es um das transaktionale Senden von Nachrichten; das Ergebnis war ein durchaus pragmatischer Ansatz: wir müssen mit mehrfachen Event-Nachrichten leben (lernen).
In diesem Teil geht es nun insbesondere um die Empfängerseite - aber werfen wir zunächst einen Blick auf den Gang der Diskussionen.
Allgemein
Eine Vielzahl von Ideen wurden diskutiert, ein ganz heißer Kandidat war der sog. Modulare Monolith (hier ein interessanter Artikel dazu) mit den Kommunikationsmustern Shared Database und Direct Call. Mit Shared Database und den damit verbundenen Views auf die Tabellen für externen, lesenden Zugriff hatte man nicht die besten Erfahrungen gemacht und Direct Calls hatten oft zu starken Abhängigkeiten zwischen den einzelnen Services innerhalb des Modularen Monolithen geführt.
Aber unabhängig davon, ob die einzelnen Services des Systems nur zusammen oder einzeln deploybar sind, die Kommunikation stand im Vordergrund.
Neu ist die Idee eines Event-Busses wahrlich nicht, auch fand sich die Idee in dem kundenseitigen System wieder, nur hatte man sich für eine Vielzahl von Punkt-zu-Punkt Verbindungen - vulgo Queues - entschieden und den entscheidenden Punkt eines Publish-Subscribe Kommunikationsmodells - vulgo Topics - schlicht außer Betracht gelassen. Als Gründe hierfür wurden (u.a.) genannt:
“Jeder kann ein Topic abonnieren (vulgo: subscriben), wir haben keinerlei Kontrolle wer, was empfängt“
“Die Empfänger gehen in Nachrichten unter“
“Es werden Nachrichten verschickt, die nur genau einen Empfänger haben“
Betrachten wir die genannten Punkte mal etwas differenzierter.
In Hinblick auf die Kontroller der Subscriber - ja, das kann man so sehen. Aber ist es nicht ein wesentlicher Vorteil in Hinblick auf die Erweiterbarkeit der Systeme, wenn man duch das Abonnieren eines Nachrichtenkanals leicht zusätzliche Funktionalität realisieren kann? Bislang war im Fokus der Betrachtung lediglich der Nachrichtenaustausch zwischen dem Produktionssystem und dem Abrechnungssystem. Es ist doch mehr als wahrscheinlich, dass eine Event-Nachricht vom Typ “Produktionauftrag beendet“ für andere Systeme auch von Interesse sein wird.
Vielleicht ist es ja in Zukunft zielführend, das Produktionssystem und das Versenden voneinander zu trennen oder bspw. ein Beauskunftungssystem hinsichtlich Kundenaufträgen zu etablieren. Und in der Tat gab es dieses System, es aggregierte alle entsprechenden Daten aus den Einzelsystemen zu einer übergreifenden Sicht - es war bezüglich der Daten weder aktuell noch hinsichtlich der Zugriffe performant.Sofern man die Befürchtung hat, dass der / die Empfänger in Nachrichten untergehen, macht es nicht Sinn, die (kontinierlich) eintreffenden Nachrichten dahingehend zu filtern, ob sie überhaupt von Interesse sind?
Bei näherer Analyse der Nachrichten die “genau einen Empfänger“ haben zeigte sich sehr schnell, dass es bei diesen Nachrichten eher um Aufrufe mit entsprechenden Antworten handelte.
Einschub: verschiedene Nachrichtentypen
Es mag überraschen, aber es ist gar nicht so selten, dass der Unterschied zwischen einer Command-Nachricht und einer Event-Nachricht nicht klar getrennt wird.
Ein Command ist eine Nachricht von einem System an ein anderes, um eine Aktion auszuführen. Sie stellt eine mögliche Zukunft dar und kann validiert, genehmigt, abgelehnt, bearbeitet und beantwortet werden. Im Grunde genommen ist es eine Punkt-zu-Punkt Verbindung zweier Systeme.
Ein Ereignis ist eine Nachricht, die darüber informiert, dass etwas in der Vergangenheit bereits geschehen ist. Ein Ereignis stellt die Vergangenheit dar und ändert sich nie, wenn es einmal geschehen ist.
Die Konsequenzen
Es wird nicht überraschen, dass diese Trennung innerhalb des existierenden Gesamtsystems nicht vorgenommen wurde und in Konsequenz eine Vielzahl von Punkt-zu-Punkt Verbindungen zwischen den einzelnen Systemen aufgebaut worden war. Und da entsprechende Verbindungen (konkret: JMS-Queues) sowohl für das Empfangen als auch für das Senden etabliert wurden, ist leicht vorstellbar, dass die Anzahl der Verbindungen förmlich explodierte und der Betrieb schlussendlich kaum noch wusste, wie ihm geschah.
Das Nachverfolgen einzelner Aufrufketten und die Suche nach ausbleibenden Nachrichten gestaltete sich zu einem Quell steter Freude und um das Chaos zu vervollständigen arbeitete man auch noch redundant mit Fail-Over Strategien beim Ausfall einzelner Nachrichtenkanäle und getrennter Speicherung der Nachrichten pro Nachrichtenkanal. Herzlichen Glückwunsch; das der Betrieb und der Support kochte war nur nachvollziehbar.
Besser noch, der Großteil der Ressourcen waren in der Behebung der unweigerlich gebunden, das Ticketaufkommen wuchs schneller als die Abarbeitung möglich war; an die Entwicklung notwendiger Features war kaum noch zu denken.
Der Ansatz
… trenne Command-Nachrichten von Event-Nachrichten; frage Dich, ob für Command-Nachrichten ein asynchroner Ansatz zielführen ist (seltenst). Warum eigentlich nicht REST hierfür nutzen. Das Problem “Es werden Nachrichten verschickt, die nur genau einen Empfänger haben“ sollte somit vom Tisch sein.
Bei Event-Nachrichten verhält es sich grundsätzlich anders, hier wird der Sender seltensts warten bis die Nachricht alle Empfänger erreicht hat; wahrscheinlich muss man empfängerseitig auch gar nicht wissen, wer die Empfänger genau sind. Asynchrones Messaging scheint hier das Mittel der Wahl zu sein.
Der Event-Bus
Ein Event Bus ist ein Router, der Ereignisse empfängt und sie an null oder mehr Ziele weiterleitet. Event Busse eignen sich hervorragend für die Weiterleitung von Ereignissen aus vielen Quellen an viele Ziele.
— Amazon
Ein kurzer Abriss bezüglich Event-driven Architetur und Request/Reply Architektur:
Bei der herkömmlichen Request/Reply Architektur ist der Client blockiert, bis er eine Antwort vom Server erhält. Bei der Event-Driven Architektur reagiert das System in Echtzeit auf Ereignisse
In der traditionellen Request-Response-Architektur sind Client und Server eng miteinander verbunden. Bei der ereignisgesteuerten Architektur sind die verschiedenen Teile des Systems lose gekoppelt, was eine größere Flexibilität und eine einfachere Wartung ermöglicht.
Bei der traditionellen Anfrage-Antwort-Architektur wird das System häufig von einer zentralen Behörde kontrolliert. Bei der ereignisgesteuerten Architektur basiert das System auf reaktiver Programmierung, was eine größere Autonomie und Unabhängigkeit der verschiedenen Teile des Systems ermöglicht.
Beschränken wir uns also auf die Events, nutzen für Commands REST, dann kann man eine mögliche Zielarchitektur wie folgt darstellen.
Somit ist ein erster Schritt getan, bleiben noch die Dinge wie: “Die Empfänger gehen in Nachrichten unter“.
Im vorhergehenden Artikel wurde festgehalten, dass es zielführend ist, mit ggf. mehrfach eintreffenden Event-Nachrichten zu leben. Das hört sich jetzt nicht wirklich nach der Lösung auf der Empfängerseite an, fürchtet man doch, dass man ohnehin in Event-Nachrichten untergeht.
Vielleicht ist es ja zielführend hier zu unterscheiden:
ist die Nachricht für mich von Interesse und wenn ja
habe ich die Nachricht schon erhalten
Ersteres ist das Filtern von Nachrichten, zweiteres beschreibt idempotentes Verhalten des Empfängers auf Basis einer Doublettenprüfung.
Filtern von Event-Nachrichten
Jede (mir bekannte) Messaging-Technologie hält Features bereit Nachrichten zu filtern. Rufen wir uns die Struktur der Event-Nachrichten ins Gedächtnis:
{
"messageHeader" : {
"TRANSACTION_ID" : "19d2f155-9fa0-4707-bc71-307c2665163f",
"CREATED_WHEN" : "2024-05-19T15:33:12.502379",
"EVENT_CLASSIFICATION" : "PRODUCTION_ORDER_FINISHED",
"REPLY_TO" : "Some JMS destination or URI",
"MESSAGE_DATATYPE" : "JSON",
"MESSAGE_SCHEMA_URI" : "Some URI to a schema",
"SENDER_IDENTIFIER" : "PRODUCTION",
"EVENT_ID" : "97a0d238-6e55-42d0-b978-868ff409650f",
},
"messageBody" : {
"ProductQuantity" : 99,
"DomainId" : "25",
"DomainClass" : "de.brockhaus.production.domain.entity.product.ProductionOrder",
"ProductId" : "PRODUCT_456",
"CustomerId" : "CUSTOMERID_123"
}
}
Die EVENT_CLASSIFICATION ist der ideale Kandidat um nach Nachrichten zu filtern die von Interesse für das Empfängersystem sind.
Am Beispiel von JMS könnte das so aussehen:
das senderseitige Setzen der JMSMessageProperties:
// create a JMS message object with payload
TextMessage jmsMsg = session.createTextMessage(payload);
// set JMS Header properties
jmsMsg.setStringProperty("TRANSACTION_ID", eventMsg.getMessageHeader().get(MessageHeaderProperties.TRANSACTION_ID));
jmsMsg.setStringProperty("EVENT_CLASSIFICATION", eventMsg.getMessageHeader().get(MessageHeaderProperties.EVENT_CLASSIFICATION));
jmsMsg.setStringProperty("EVENT_ID", eventMsg.getMessageHeader().get(MessageHeaderProperties.EVENT_ID));
das Filtern auf der Empfängerseite:
// message selector
private static final String FILTER_CRITERIA = "EVENT_CLASSIFICATION = 'PRODUCTION_ORDER_FINISHED'";
...
MessageConsumer consumer = session.createConsumer(receiveDestination, FILTER_CRITERIA);
Summa summarum nimmt dieses System nur Nachrichten an die entsprechend klassifiziert sind - die Nachrichten, die für das individuelle System von Interesse sind.
Bleibt also noch der Aspekt der mehrfach übermittelten Nachrichten.
The term idempotent is used in mathematics to describe a function that produces the same result if it is applied to itself, i.e. f(x) = f(f(x)). In Messaging this concepts translates into a message that has the same effect whether it is received once or multiple times.
— Gregor Hohpe
Idempotente Empfänger und Nachrichten-Doubletten
"[…] event-driven architectures should be designed to handle duplicates without side effects. An application is defined "idempotent" if processing duplicate inputs doesn’t change the final result of the application."
--- Amazon AWS
Idempotente Empfänger stellen keine große Herausforderung dar. Wenn wir uns an das Senden von Events erinnern, dann hatte jeder Event eine eigene Event-ID anhand derer sich doppelt gesendete Events leicht identifizieren lassen; ignoriert man doppelte Nachrichten, dann hat man das gewünschte, idempotente Verhalten.
Technisch gesehen stellt die Prüfung auf bereits erhaltene Nachrichten kein wirkliches Problem dar. Es empfiehlt sich ein Blick auf Apache Camel (letztendlich die Software zum Buch EAI-Patterns). Am Ende des Tages könnte eine mögliche Implementierung einen verteilten Cache (bspw. Terracotta/EhCache oder Infinispan) nutzen innerhalb dessen alle empfangenen EventIDs gehalten werden und gegen den die Prüfung auf Duplikate läuft. Dieser Ansatz kombiniert mit einem Interceptor - et voila.
Trennung von Empfang und Verarbeitung
Sollte die Idee aufkommen, das Empfangen einer Event-Nachricht und die Reaktion hierauf - in unserem Beispiel das Erstellen einer Kundenrechnung - nicht voneinander zu trennen, dann werden sich entsprechende Probleme sehr schnell einstellen.
Es ist um ein Vielfaches besser, die Nachrichten, die dem Empfänger interessieren (und nicht bereits Doubletten sind) sofort zu speichern und in einem nachgelagerten Schritt erst zu bearbeiten.
Aufmerksame Leser werden zurecht einwenden: das sollte man der Messaging- / Integrations-Middleware überlassen (bspw. dem persistent Message Store der JMS-Implementierung oder Kafka) und nicht noch einmal auf der Empfängerseite implementieren. Die Erfahrungen aus einem anderen Projekt (Bestellung hoheitliche Dokumente in einem Bundesland) hat gezeigt, dass es deutlich besser ist, das Original der eingegangenen Nachricht für einen bestimmten Zeitraum vorzuhalten um ggf. die nachgelagerte Verarbeitung wiederholt aufzusetzen oder um schlicht und einfach die Originalnachricht und deren Empfang belegen zu können.
Zusammenfassung und Ausblick
Es waren kontroverse Diskussionen mit den beteiligten Gruppen, viele Aspekte und Alternativen wurden diskutiert. Im Kreuzfeuer der Kritik standen insbesondere die Messaging Middleware (im konkreten Fall das “veraltete“ ActiveMQ), die Datenhaltung generell sowie die Architektur des Systems als einzelne Services.
Schlussendlich erschien den Beteiligten der obig skizzierte Weg gangbar und die Probleme aus doppelten Nachrichten sowie der Filterung der Nachrichten zielführend.
Somit war ein erster Schritt erreicht:
Die Anwendung des Patterns “Transactional Outbox“, das Vermeiden des Dual Write Problems sowie das Filtern und das DeDup von Event-Nachrichten ermöglichen eine zuverlässige Kommunikation. Das Trennen von Command- und Event-Nachrichten respektive des (unnötigen) Request/Reply-Musters über JMS reduziert die Anzahl der JMS-Queues zugunsten von synchronen REST-Calls; im Grunde braucht man nur genau ein Topic um die Event-Nachrichten zu versenden und zu empfangen.
Bleiben wir realistisch, damit ist ein Teil der Herausforderungen vom Tisch, es bleiben noch ausreichend übrig. Im nächsten Teil dreht es sich um die Technologien, wie Event-Nachrichten übermittelt werden können. Da wurden in den Diskussionen unselige Wiedergänger wie der ESB bemüht, das alte Schlachtroß JMS aber auch Apache Kafka kommt zum Zug.
Bleiben Sie mir gewogen.