
Hallo, Habr!
Unser Unternehmen ist auf die Entwicklung von ERP-Softwarelösungen spezialisiert, wobei ein GroĂteil aus transaktionalen Systemen mit einer enormen Menge an GeschĂ€ft Logik und Dokumentenverkehr Ă la EAD besteht. Die modernen Versionen unserer Produkte basieren auf JavaEE-Technologien, aber wir experimentieren auch aktiv mit Mikrodiensten. Eines der problematischsten Bereiche solcher Lösungen ist die Integration verschiedener Teilsysteme, die zu benachbarten DomĂ€nen gehören. Integrationsaufgaben haben uns immer groĂe Kopfschmerzen bereitet, unabhĂ€ngig von den verwendeten Architekturstilen, technologischen Stacks und Frameworks. In letzter Zeit gibt es jedoch Fortschritte bei der Lösung solcher Aufgaben.
In dem Artikel, den ich Ihnen prĂ€sentiere, werde ich ĂŒber die Erfahrungen und architektonischen Forschungen von NPO «Krista» in diesem Bereich berichten. Wir werden auch ein einfaches Beispiel fĂŒr die Lösung einer Integrationsaufgabe aus der Sicht eines Anwendungsentwicklers betrachten und herausfinden, was hinter dieser Einfachheit steckt.
Haftungsausschluss
Die in dem Artikel beschriebenen architektonischen und technischen Lösungen basieren auf meinen persönlichen Erfahrungen im Kontext spezifischer Aufgaben. Diese Lösungen beanspruchen keine UniversalitÀt und können sich unter anderen Nutzungbedingungen als nicht optimal erweisen.
Was hat BPM damit zu tun?
Um diese Frage zu beantworten, mĂŒssen wir etwas tiefer in die spezifischen Anwendungsprobleme unserer Lösungen eintauchen. Der GroĂteil der GeschĂ€ft Logik in unserem typischen transaktionalen System besteht aus der Dateneingabe in die Datenbank ĂŒber BenutzeroberflĂ€chen, der manuellen und automatisierten PrĂŒfung dieser Daten, der DurchfĂŒhrung durch einen bestimmten Workflow, der Veröffentlichung in ein anderes System / Analyse-Datenbank / Archiv, der Erstellung von Berichten. Daher ist die SchlĂŒssel-Funktion des Systems fĂŒr die Kunden die Automatisierung ihrer internen GeschĂ€ftsprozesse.
Zur Vereinfachung verwenden wir in der Kommunikation den Begriff âDokumentâ als eine gewisse Abstraktion einer Datenmenge, die durch einen gemeinsamen SchlĂŒssel verbunden ist, an den ein bestimmter Workflow âangehĂ€ngtâ werden kann.
Wie sieht es aber mit der Integrationslogik aus? SchlieĂlich entsteht die Integrationsaufgabe durch die Architektur des Systems, die nicht auf die Anforderungen des Kunden zugeschnitten ist, sondern durch ganz andere Faktoren beeinflusst wird:
- unter dem Einfluss des Conway-Gesetzes;
- durch die Wiederverwendung von Teilsystemen, die zuvor fĂŒr andere Produkte entwickelt wurden;
- nach Entscheidung des Architekten, basierend auf den nicht-funktionalen Anforderungen.
Es gibt eine groĂe Versuchung, die Integrationslogik von der GeschĂ€ftslogik des Haupt-Workflows zu trennen, um die GeschĂ€ftslogik nicht mit Integrationsartefakten zu belasten und dem Anwendungsentwickler die Notwendigkeit zu ersparen, sich mit den Besonderheiten der Systemarchitektur auseinanderzusetzen. Diese Herangehensweise hat zwar einige Vorteile, jedoch zeigt die Praxis ihre IneffektivitĂ€t:
- die Lösung von Integrationsproblemen tendiert oft zu den einfachsten Optionen in Form von synchronen Aufrufen aufgrund der begrenzten Erweiterungspunkte in der Implementierung des Haupt-Workflows (zu den Nachteilen synchroner Integration â weiter unten);
- Integrationsartefakte dringen dennoch in die HauptgeschĂ€ftslogik ein, wenn RĂŒckmeldungen aus einem anderen Teilsystem erforderlich sind;
- der Anwendungsentwickler ignoriert die Integration und kann sie leicht brechen, indem er den Workflow Àndert;
- das System hört auf, aus der Sicht des Benutzers ein Ganzes zu sein, es werden 'NĂ€hte' zwischen den Teilsystemen sichtbar, es entstehen ĂŒberflĂŒssige Benutzeroperationen, die die DatenĂŒbertragung von einem Teilsystem zum anderen einleiten.
Ein anderer Ansatz besteht darin, Integrationsinteraktionen als wesentlichen Bestandteil der HauptgeschĂ€ftslogik und des Workflows zu betrachten. Damit die Anforderungen an die Qualifikation der Anwendungsentwickler nicht ins Unermessliche steigen, sollte die Erstellung neuer Integrationsinteraktionen einfach und unkompliziert erfolgen, mit minimalen Wahlmöglichkeiten fĂŒr die Lösungsmethoden. Dies ist schwieriger zu realisieren, als es scheint: Das Werkzeug muss leistungsstark genug sein, um dem Benutzer eine ausreichende Anzahl an Anwendungsmöglichkeiten zu bieten, dabei jedoch verhindern, dass er sich "ins eigene Fleisch schneidet". Es gibt viele Fragen, die ein Ingenieur im Kontext von Integrationsaufgaben beantworten muss, ĂŒber die sich der Anwendungsentwickler in seiner alltĂ€glichen Arbeit jedoch keine Gedanken machen sollte: Transaktionsgrenzen, Konsistenz, AtomizitĂ€t, Sicherheit, Skalierung, Last- und Ressourcenverteilung, Routing, Marshalling, Kontextverbreitung und -wechsel usw. Es sollten den Anwendungsentwicklern ausreichend einfache Lösungsvorlagen angeboten werden, die bereits Antworten auf all diese Fragen enthalten. Diese Vorlagen sollten ausreichend sicher sein: Die GeschĂ€ftslogik Ă€ndert sich sehr hĂ€ufig, was die FehleranfĂ€lligkeit erhöht, und die Kosten fĂŒr Fehler mĂŒssen auf einem ausreichend niedrigen Niveau gehalten werden.
Aber was hat BPM damit zu tun? Es gibt doch viele Möglichkeiten, Workflows zu implementieren âŠ
In unseren Lösungen ist tatsĂ€chlich eine andere Implementierung von GeschĂ€ftsprozessen sehr beliebt â ĂŒber die deklarative Definition von ZustandsĂŒbergangsdiagrammen und die Anbindung von Handlern mit GeschĂ€ftslogik an die ĂbergĂ€nge. Dabei ist der Zustand, der die aktuelle Position des âDokumentsâ im GeschĂ€ftsprozess bestimmt, ein Attribut des âDokumentsâ selbst.

So sieht der Prozess zu Beginn des Projekts aus.
Die Beliebtheit einer solchen Umsetzung beruht auf der relativen Einfachheit und Geschwindigkeit bei der Erstellung linearer GeschĂ€ftsprozesse. Mit der fortwĂ€hrenden KomplexitĂ€t von Softwaresystemen wĂ€chst jedoch der automatisierte Teil des GeschĂ€ftsprozesses und wird komplizierter. Es entsteht die Notwendigkeit fĂŒr Dekomposition, Wiederverwendung von Prozessbestandteilen sowie fĂŒr das Verzweigen von Prozessen, damit jeder Zweig parallel ausgefĂŒhrt werden kann. Unter diesen Bedingungen wird das Werkzeug unpraktisch, und das ZustandsĂŒbergangsdiagramm verliert an InformativitĂ€t (Integrationsinteraktionen sind auf dem Diagramm ĂŒberhaupt nicht abzubilden).

So sieht der Prozess nach mehreren Iterationen zur KlÀrung der Anforderungen aus
Eine Lösung fĂŒr diese Situation war die Integration der Engine in einige Produkte mit den komplexesten GeschĂ€ftsprozessen. Kurzfristig hatte diese Lösung einen gewissen Erfolg: Es wurde möglich, komplexe GeschĂ€ftsprozesse umzusetzen und dabei ein ausreichend informatives und aktuelles Diagramm in der Notation .

Ein kleiner Teil eines komplexen GeschÀftsprozesses
Auf lange Sicht erfĂŒllte die Lösung jedoch nicht die Erwartungen: Der hohe Aufwand fĂŒr die Erstellung von GeschĂ€ftsprozessen ĂŒber visuelle Werkzeuge lieĂ keine akzeptablen ProduktivitĂ€tskennzahlen erreichen, und das Werkzeug selbst wurde zu einem der unbeliebtesten unter den Entwicklern. Auch an der internen Struktur der Engine gab es Kritik, die zur Entstehung vieler 'Patches' und 'KreuzstĂŒtzen' fĂŒhrte.
Der Hauptvorteil der Anwendung von jBPM war das Bewusstsein fĂŒr den Nutzen und die Nachteile des Vorhandenseins eines eigenen persistenten Zustands fĂŒr ein GeschĂ€ftsprozess-Exemplar. AuĂerdem erkannten wir die Möglichkeit der Anwendung des prozessorientierten Ansatzes zur Umsetzung komplexer Integrationsprotokolle zwischen verschiedenen Anwendungen unter Verwendung asynchroner Interaktionen durch Signale und Nachrichten. Der vorhandene persistente Zustand spielt hier eine entscheidende Rolle.
Auf Grundlage des Gesagten lĂ€sst sich schlieĂen: Der prozessorientierte Ansatz im Stil von BPM ermöglicht es uns, ein breites Spektrum an Aufgaben zur Automatisierung zunehmend komplexer werdender GeschĂ€ftsprozesse zu lösen, IntegrationsaktivitĂ€ten harmonisch in diese Prozesse einzupassen und die Möglichkeit der visuellen Darstellung des umgesetzten Prozesses in der dafĂŒr geeigneten Notation zu bewahren.
Nachteile synchroner Aufrufe als Integrationsmuster
Unter synchroner Integration versteht man den einfachsten blockierenden Aufruf. Ein Teilsystem fungiert als Server-Seite und stellt eine API mit der benötigten Methode zur VerfĂŒgung. Das andere Teilsystem fungiert als Client-Seite und fĂŒhrt zum richtigen Zeitpunkt den Aufruf mit dem Warten auf das Ergebnis aus. Je nach Architektur des Systems können die Client- und Server-Seiten entweder in einer Anwendung und einem Prozess oder in verschiedenen platziert sein. Im zweiten Fall ist es erforderlich, eine bestimmte Implementierung von RPC anzuwenden und das Marshalling von Parametern und Ergebnis des Aufrufs zu gewĂ€hrleisten.

Ein solches Integrationsmuster hat eine Vielzahl von Nachteilen, wird jedoch in der Praxis aufgrund seiner Einfachheit sehr hĂ€ufig verwendet. Die Geschwindigkeit der Implementierung ist verlockend und fĂŒhrt dazu, dass es immer wieder unter "dringenden" Terminen eingesetzt wird, wobei die Lösung in technische Schulden ĂŒbertragen wird. Oft kommt es auch vor, dass unerfahrene Entwickler es unbewusst anwenden, ohne sich der negativen Konsequenzen bewusst zu sein.
Neben der offensichtlichsten Erhöhung der Kopplung von Teilsystemen gibt es auch weniger offensichtliche Probleme mit der "Zerstreuung" und "Dehnung" von Transaktionen. TatsĂ€chlich, wenn die GeschĂ€ftslogik Ănderungen vornimmt, sind Transaktionen unvermeidbar, und Transaktionen blockieren ihrerseits bestimmte Ressourcen der Anwendung, die von diesen Ănderungen betroffen sind. Das bedeutet, dass ein Teilsystem, bis es eine Antwort vom anderen erhĂ€lt, die Transaktion nicht abschlieĂen und die Blockierungen aufheben kann. Dies erhöht erheblich das Risiko verschiedener Effekte:
- Die ReaktionsfÀhigkeit des Systems geht verloren, Benutzer warten lange auf Antworten auf Anfragen;
- Der Server hört insgesamt auf, auf Benutzeranfragen zu reagieren, da der Pool der Threads ĂŒberfĂŒllt ist: Die meisten Threads sind aufgrund der Blockierung einer Ressource, die von der Transaktion benötigt wird, "blockiert";
- Es beginnen Deadlocks aufzutreten: Die Wahrscheinlichkeit ihres Auftretens hÀngt stark von der Dauer der Transaktionen, der Anzahl der in die Transaktion involvierten GeschÀftslogik und der Blockierungen ab;
- Es treten TransaktionszeitĂŒberschreitungsfehler auf;
- Der Server "stĂŒrzt ab" wegen OutOfMemory, wenn die Aufgabe die Verarbeitung und Ănderung groĂer Datenmengen erfordert, und das Vorhandensein synchroner Integrationen erschwert die Aufteilung der Verarbeitung in leichtere Transaktionen.
Aus architektonischer Sicht fĂŒhrt die Verwendung blockierender Aufrufe bei der Integration zu einem Verlust der Kontrolle ĂŒber die QualitĂ€t einzelner Teilsysteme: Es ist nicht möglich, die Zielwerte der QualitĂ€t eines Teilsystems unabhĂ€ngig von den QualitĂ€tswerten eines anderen Teilsystems zu gewĂ€hrleisten. Wenn Teilsysteme von unterschiedlichen Teams entwickelt werden, stellt dies ein groĂes Problem dar.
Es wird noch interessanter, wenn die integrierten Teilsysteme sich in verschiedenen Anwendungen befinden und synchronisierte Ănderungen von beiden Seiten vorgenommen werden mĂŒssen. Wie kann die Transaktionssicherheit dieser Ănderungen gewĂ€hrleistet werden?
Wenn Ănderungen in getrennten Transaktionen vorgenommen werden, ist es erforderlich, eine zuverlĂ€ssige Verarbeitung von Ausnahmen und Kompensationen sicherzustellen, was den Hauptvorteil synchroner Integrationen â die Einfachheit â vollstĂ€ndig negiert.
Es kommen auch verteilte Transaktionen in den Sinn, die wir jedoch in unseren Lösungen nicht verwenden: Es ist schwierig, die ZuverlÀssigkeit sicherzustellen.
âSagaâ als Lösung fĂŒr das Transaktionsproblem
Mit der wachsenden PopularitÀt von Mikrodiensten gewinnt .
Dieser Entwurf löst hervorragend die oben genannten Probleme langfristiger Transaktionen und erweitert die Möglichkeiten der Zustandsverwaltung des Systems aus der Sicht der GeschĂ€ftslogik: Eine Kompensation nach einer fehlgeschlagenen Transaktion kann das System nicht in den ursprĂŒnglichen Zustand zurĂŒckversetzen, sondern einen alternativen Datenverarbeitungsweg gewĂ€hrleisten. Dadurch wird auch verhindert, dass erfolgreich abgeschlossene Schritte der Datenverarbeitung bei erneutem Versuch wiederholt werden.
Interessanterweise ist dieses Muster auch in monolithischen Systemen relevant, wenn es um die Integration schwach gekoppelter Teilsysteme geht und negative Effekte auftreten, die durch langfristige Transaktionen und entsprechende Ressourcensperrungen verursacht werden.
In Bezug auf unsere GeschĂ€ftsprozesse im BPM-Stil ist die Implementierung von âSagasâ sehr einfach: Einzelne Schritte der âSagaâ können als AktivitĂ€ten innerhalb des GeschĂ€ftsprozesses definiert werden, und der persistente Zustand des GeschĂ€ftsprozesses bestimmt auch den internen Zustand der âSagaâ. Das heiĂt, wir benötigen keinen zusĂ€tzlichen Koordinationsmechanismus. Es wird nur ein Nachrichtenbroker mit UnterstĂŒtzung fĂŒr âat least onceâ-Garantien als Transport benötigt.
Doch auch diese Lösung hat ihren eigenen âPreisâ:
- Die GeschĂ€ftslogik wird komplexer: Es mĂŒssen Kompensationen bearbeitet werden;
- Es wird erforderlich sein, auf volle Konsistenz zu verzichten, was fĂŒr monolithische Systeme besonders empfindlich sein kann;
- Die Architektur wird etwas komplizierter, es entsteht ein zusÀtzlicher Bedarf an einem Nachrichtenbroker;
- ZusĂ€tzliche Mittel zur Ăberwachung und Verwaltung werden benötigt (obwohl das insgesamt sogar gut ist: Die ServicequalitĂ€t des Systems wird steigen).
FĂŒr monolithische Systeme ist die Rechtfertigung der Verwendung von "Sagas" nicht so offensichtlich. FĂŒr Mikrodienste und andere SOA, bei denen wahrscheinlich bereits ein Broker vorhanden ist und volle Konsistenz bereits zu Beginn des Projekts geopfert wurde, kann der Nutzen der Anwendung dieses Musters die Nachteile erheblich ĂŒberwiegen, besonders wenn eine praktische API auf der Ebene der GeschĂ€ftslogik vorhanden ist.
Kapselung der GeschÀftslogik in Mikrodiensten
Als wir begannen, mit Mikrodiensten zu experimentieren, stellte sich die berechtigte Frage: Wo soll die domain-spezifische GeschÀftslogik in Bezug auf den Service platziert werden, der die Persistenz der domain-spezifischen Daten gewÀhrleistet?
Wenn man sich die Architektur verschiedener BPMS ansieht, erscheint es sinnvoll, die GeschĂ€ftslogik von der Persistenz zu trennen: eine Schicht von plattform- und domain-unabhĂ€ngigen Mikrodiensten zu schaffen, die die Umgebung und den Container fĂŒr die AusfĂŒhrung der domain-spezifischen GeschĂ€ftslogik bilden, wĂ€hrend die Persistenz der domain-spezifischen Daten in einer separaten Schicht aus sehr einfachen und leichtgewichtigen Mikrodiensten gestaltet wird. In diesem Fall orchestrieren die GeschĂ€ftsprozesse die Services der Persistenzschicht.

Dieser Ansatz hat einen groĂen Vorteil: Man kann die FunktionalitĂ€t der Plattform beliebig erweitern, und die entsprechende Schicht der plattform-spezifischen Mikrodienste wird dadurch nur 'dicker'. GeschĂ€ftsprozesse aus jeder Domain erhalten sofort die Möglichkeit, die neue FunktionalitĂ€t der Plattform zu nutzen, sobald sie aktualisiert wird.
Eine detaillierte Ausarbeitung hat erhebliche Nachteile dieses Ansatzes aufgezeigt:
- Der plattformbasierte Service, der die GeschĂ€ftslogik vieler Domains gleichzeitig ausfĂŒhrt, trĂ€gt hohe Risiken als einzelner Punkt des Fehlers. HĂ€ufige Ănderungen der GeschĂ€ftslogik erhöhen das Risiko von Fehlern, die zu Störungen fĂŒhren, die sich auf das gesamte System ausbreiten;
- Leistungsprobleme: Die GeschĂ€ftslogik arbeitet ĂŒber eine enge und langsame Schnittstelle mit ihren Daten.
- Daten werden erneut marshalliert und durch den Netzwerk-Stack gepumpt;
- Der Domainservice gibt oft mehr Daten zurĂŒck, als fĂŒr die GeschĂ€ftslogik zur Verarbeitung erforderlich ist, aufgrund unzureichender Parametrisierungsmöglichkeiten der Anfragen auf der Ebene der externen API des Dienstes;
- Mehrere unabhĂ€ngige Teile der GeschĂ€ftslogik können dieselben Daten erneut abfragen (dies kann durch das HinzufĂŒgen von Sitzungsbestandteilen, die Daten cachen, abgemildert werden, was jedoch die Architektur zusĂ€tzlich kompliziert und Probleme mit der AktualitĂ€t der Daten und der Invalidierung des Caches schafft);
- Transaktionsprobleme:
- GeschĂ€ftsprozesse mit persistentem Zustand, dessen Speicherung vom Plattformdienst verwaltet wird, werden von den Domain-Daten in Konflikt gebracht, und einfache Lösungen fĂŒr dieses Problem sind nicht in Sicht;
- Verlagerung der Sperrung von Domain-Daten auĂerhalb der Transaktion: Wenn die Domain-GeschĂ€ftslogik Ănderungen vornehmen muss, nachdem sie die Richtigkeit der aktuellen Daten ĂŒberprĂŒft hat, muss die Möglichkeit einer konkurrierenden Ănderung der verarbeiteten Daten ausgeschlossen werden. Eine externe Datensperre kann helfen, das Problem zu lösen, bringt jedoch zusĂ€tzliche Risiken mit sich und verringert die Gesamtnutzerfreundlichkeit des Systems;
- ZusĂ€tzliche Komplikationen bei der Aktualisierung: In einigen FĂ€llen mĂŒssen der Persistenzdienst und die GeschĂ€ftslogik synchron oder in strikter Reihenfolge aktualisiert werden.
Letztendlich musste ich zu den Wurzeln zurĂŒckkehren: Die Domain-Daten und die Domain-GeschĂ€ftslogik in einem Microservice zu kapseln. Dieser Ansatz vereinfacht die Wahrnehmung des Microservices als einheitliche Komponente innerhalb des Systems und vermeidet die oben genannten Probleme. Das hat jedoch seinen Preis:
- Es ist eine Standardisierung der API erforderlich, um mit der GeschĂ€ftslogik zu interagieren (insbesondere um BenutzeraktivitĂ€ten in den GeschĂ€ftsprozessen zu gewĂ€hrleisten) und um die API der Plattformdienste; es ist ein sorgfĂ€ltigerer Umgang mit API-Ănderungen, direkter und umgekehrter KompatibilitĂ€t erforderlich;
- Es ist die HinzufĂŒgung zusĂ€tzlicher Runtime-Bibliotheken erforderlich, um die FunktionalitĂ€t der GeschĂ€ftslogik in jedem solchen Microservice sicherzustellen, und dies fĂŒhrt zu neuen Anforderungen an diese Bibliotheken: Leichtgewichtigkeit und minimale transitive AbhĂ€ngigkeiten;
- Die Entwickler der GeschĂ€ftslogik mĂŒssen die Versionen der Bibliotheken im Auge behalten: Wenn ein Mikrodienst lange nicht aktualisiert wurde, enthĂ€lt er wahrscheinlich eine veraltete Version der Bibliotheken. Dies kann ein unerwartetes Hindernis fĂŒr die Implementierung neuer Funktionen darstellen und gegebenenfalls eine Migration der alten GeschĂ€ftslogik dieses Dienstes auf neue Versionen der Bibliotheken erfordern, wenn zwischen den Versionen inkompatible Ănderungen vorgenommen wurden.

In einer solchen Architektur ist auch eine Schicht plattformbasierter Dienste vorhanden, aber diese Schicht bildet nicht mehr einen Container zur AusfĂŒhrung der domĂ€nenspezifischen GeschĂ€ftslogik, sondern lediglich deren Umgebung, indem sie unterstĂŒtzende "plattformspezifische" Funktionen bereitstellt. Eine solche Schicht ist nicht nur erforderlich, um die Leichtigkeit der domĂ€nenspezifischen Mikrodienste zu bewahren, sondern auch zur Zentralisierung des Managements.
Zum Beispiel erzeugen BenutzeraktivitĂ€ten in GeschĂ€ftsprozessen Aufgaben. Wenn Benutzer jedoch mit Aufgaben arbeiten, mĂŒssen sie Aufgaben aus allen DomĂ€nen in einer allgemeinen Liste sehen können, sodass ein entsprechender plattformbasierter Dienst zur Registrierung von Aufgaben erforderlich ist, der von der domĂ€nenspezifischen GeschĂ€ftslogik befreit ist. Die Kapselung der GeschĂ€ftslogik in einem solchen Kontext aufrechtzuerhalten, ist ziemlich schwierig, und dies ist ein weiterer Kompromiss dieser Architektur.
Integration von GeschÀftsprozessen aus der Perspektive eines Anwendungsentwicklers
Wie bereits erwÀhnt, sollte der Anwendungsentwickler von den technischen und ingenieurtechnischen Besonderheiten der Implementierung der Interaktion mehrerer Anwendungen abstrahiert sein, um eine gute ProduktivitÀt der Entwicklung erwarten zu können.
Lassen Sie uns versuchen, eine recht anspruchsvolle Integrationsaufgabe zu lösen, die speziell fĂŒr diesen Artikel konzipiert wurde. Es wird eine "Spiel"-Aufgabe mit drei Anwendungen sein, wobei jede von ihnen einen bestimmten DomĂ€nennamen definiert: "app1", "app2", "app3".
Innerhalb jeder Anwendung werden GeschĂ€ftsprozesse gestartet, die beginnen, ĂŒber einen Integrationsbus "Ball zu spielen". In der Rolle des Balls fungieren Nachrichten mit dem Namen "Ball".
Spielregeln:
- Der erste Spieler â der Initiator. Er lĂ€dt andere Spieler zum Spiel ein, beginnt das Spiel und kann es jederzeit beenden;
- Andere Spieler erklÀren ihre Teilnahme am Spiel, "lernen" einander und den ersten Spieler kennen;
- Nach dem Empfang des Balls wĂ€hlt der Spieler einen anderen teilnehmenden Spieler aus und ĂŒbergibt ihm den Ball. Die Gesamtzahl der PĂ€sse wird gezĂ€hlt;
- Jeder Spieler hat "Energie", die bei jedem Pass des Balls von diesem Spieler abnimmt. Nach Erschöpfung der Energie scheidet der Spieler aus dem Spiel aus und kĂŒndigt seinen RĂŒcktritt an;
- Wenn der Spieler allein ist, kĂŒndigt er sofort seinen RĂŒcktritt an;
- Wenn alle Spieler ausgeschieden sind, erklĂ€rt der erste Spieler das Spiel fĂŒr beendet. Wenn er vorher aus dem Spiel ausgeschieden ist, bleibt er da, um das Spiel zu beobachten und es abzuschlieĂen.
Um diese Aufgabe zu lösen, werde ich unsere DSL fĂŒr GeschĂ€ftsprozesse nutzen, die es ermöglicht, die Logik kompakt in Kotlin mit minimalem Boilerplate zu beschreiben.
Im Programm app1 wird der GeschĂ€ftsprozess des ersten Spielers (der Initiator des Spiels) ausgefĂŒhrt:
class InitialPlayer
import ru.krista.bpm.ProcessInstance
import ru.krista.bpm.runtime.ProcessImpl
import ru.krista.bpm.runtime.constraint.UniqueConstraints
import ru.krista.bpm.runtime.dsl.processModel
import ru.krista.bpm.runtime.dsl.taskOperation
import ru.krista.bpm.runtime.instance.MessageSendInstance
data class PlayerInfo(val name: String, val domain: String, val id: String)
class PlayersList : ArrayList()
// Diese Klasse stellt eine Prozessinstanz dar: sie kapselt ihren internen Zustand
class InitialPlayer : ProcessImpl(initialPlayerModel) {
var playerName: String by persistent("Player1")
var energy: Int by persistent(30)
var players: PlayersList by persistent(PlayersList())
var shotCounter: Int = 0
}
// Dies ist die Deklaration des Prozessmodells: wird einmal erstellt, von allen
// Instanzen des entsprechenden Klassenprozesses verwendet
val initialPlayerModel = processModel(name = "InitialPlayer",
version = 1) {
// Laut Regeln ist der erste Spieler der Initiator des Spiels und muss der einzige sein
uniqueConstraint = UniqueConstraints.singleton
// Wir deklarieren die AktivitÀten, aus denen der GeschÀftsprozess besteht
val sendNewGameSignal = signal("NewGame")
val sendStopGameSignal = signal("StopGame")
val startTask = humanTask("Start") {
taskOperation {
processCondition { players.size > 0 }
confirmation { "${players.size} Spieler haben sich verbunden. Beginnen wir?" }
}
}
val stopTask = humanTask("Stop") {
taskOperation {}
}
val waitPlayerJoin = signalWait("PlayerJoin") { signal ->
players.add(PlayerInfo(
signal.data!!,
signal.sender.domain,
signal.sender.processInstanceId))
println("... Spieler ${signal.data} tritt bei ...")
}
val waitPlayerOut = signalWait("PlayerOut") { signal ->
players.remove(PlayerInfo(
signal.data!!,
signal.sender.domain,
signal.sender.processInstanceId))
println("... Spieler ${signal.data} ist drauĂen ...")
}
val sendPlayerOut = signal("PlayerOut") {
signalData = { playerName }
}
val sendHandshake = messageSend("Handshake") {
messageData = { playerName }
activation = {
receiverDomain = process.players.last().domain
receiverProcessInstanceId = process.players.last().id
}
}
val throwStartBall = messageSend("Ball") {
messageData = { 1 }
activation = { selectNextPlayer() }
}
val throwBall = messageSend("Ball") {
messageData = { shotCounter + 1 }
activation = { selectNextPlayer() }
onEntry { energy -= 1 }
}
val waitBall = messageWaitData("Ball") {
shotCounter = it
}
// Jetzt konstruieren wir das Prozessdiagramm aus den deklarierten AktivitÀten
startFrom(sendNewGameSignal)
.fork("mainFork") {
next(startTask)
next(waitPlayerJoin).next(sendHandshake).next(waitPlayerJoin)
next(waitPlayerOut)
.branch("checkPlayers") {
ifTrue { players.isEmpty() }
.next(sendStopGameSignal)
.terminate()
ifElse().next(waitPlayerOut)
}
}
startTask.fork("afterStart") {
next(throwStartBall)
.branch("mainLoop") {
ifTrue { energy < 5 }.next(sendPlayerOut).next(waitBall)
ifElse().next(waitBall).next(throwBall).loop()
}
next(stopTask).next(sendStopGameSignal)
}
// FĂŒgen wir zusĂ€tzliche Handler fĂŒr Protokollierung zu den AktivitĂ€ten hinzu
sendNewGameSignal.onExit { println("Lasst uns spielen!") }
sendStopGameSignal.onExit { println("Stop!") }
sendPlayerOut.onExit { println("$playerName: Ich bin drauĂen!") }
}
private fun MessageSendInstance.selectNextPlayer() {
val player = process.players.random()
receiverDomain = player.domain
receiverProcessInstanceId = player.id
println("Schritt ${process.shotCounter + 1}: " +
"${process.playerName} >>> ${player.name}")
}Neben der Umsetzung der GeschÀftslogik kann der angegebene Code ein Objektmodell des GeschÀftsprozesses bereitstellen, das in Form eines Diagramms visualisiert werden kann. Einen Visualisierer haben wir bisher noch nicht umgesetzt, daher mussten wir etwas Zeit mit dem Zeichnen verbringen (hier habe ich die BPMN-Notation in Bezug auf die Verwendung von Gates leicht vereinfacht, um die Konsistenz des Diagramms mit dem angegebenen Code zu verbessern):

Die Anwendung app2 wird GeschÀftsprozesse eines anderen Spielers beinhalten:
class RandomPlayer
import ru.krista.bpm.ProcessInstance
import ru.krista.bpm.runtime.ProcessImpl
import ru.krista.bpm.runtime.dsl.processModel
import ru.krista.bpm.runtime.instance.MessageSendInstance
data class SpielerInfo(val name: String, val domain: String, val id: String)
class SpielerListe: ArrayList<SpielerInfo>()
class ZufallsSpieler : ProcessImpl<ZufallsSpieler>(zufallsSpielerModell) {
var spielerName: String by input(persistent = true,
defaultValue = "ZufallsSpieler")
var energie: Int by input(persistent = true, defaultValue = 30)
var spieler: SpielerListe by persistent(SPIELERLISTE())
var alleSpielerDraussen: Boolean by persistent(false)
var schussZaehler: Int = 0
val selbstSpieler: SpielerInfo
get() = SpielerInfo(spielerName, env.eventDispatcher.domainName, id)
}
val zufallsSpielerModell = processModel<ZufallsSpieler>(name = "ZufallsSpieler",
version = 1) {
val warteAufNeuesSpielSignal = signalWait<String>("NeuesSpiel")
val warteAufStopSpielSignal = signalWait<String>("StopSpiel")
val sendeSpielerBeitritt = signal<String>("SpielerBeitritt") {
signalData = { spielerName }
}
val sendeSpielerDraussen = signal<String>("SpielerDraussen") {
signalData = { spielerName }
}
val warteAufSpielerBeitritt = signalWaitCustom<String>("SpielerBeitritt") {
eventCondition = { signal ->
signal.sender.processInstanceId != process.id
&& !process.spieler.any { signal.sender.processInstanceId == it.id}
}
handler = { signal ->
spieler.add(SpielerInfo(
signal.data!!,
signal.sender.domain,
signal.sender.processInstanceId))
}
}
val warteAufSpielerDraussen = signalWait<String>("SpielerDraussen") { signal ->
spieler.remove(SpielerInfo(
signal.data!!,
signal.sender.domain,
signal.sender.processInstanceId))
alleSpielerDraussen = spieler.isEmpty()
}
val sendeHandschlag = messageSend<String>("Handschlag") {
messageData = { spielerName }
activation = {
receiverDomain = process.spieler.last().domain
receiverProcessInstanceId = process.spieler.last().id
}
}
val empfangenHandschlag = messageWait<String>("Handschlag") { message ->
if (!spieler.any { message.sender.processInstanceId == it.id}) {
spieler.add(SpielerInfo(
message.data!!,
message.sender.domain,
message.sender.processInstanceId))
}
}
val werfenBall = messageSend<Int>("Ball") {
messageData = { schussZaehler + 1 }
activation = { waehleNaechstenSpieler() }
onEntry { energie -= 1 }
}
val warteAufBall = messageWaitData<Int>("Ball") {
schussZaehler = it
}
startFrom(warteAufNeuesSpielSignal)
.fork("hauptGabel") {
next(sendeSpielerBeitritt)
.branch("hauptSchleife") {
ifTrue { energie < 5 || alleSpielerDraussen }
.next(sendeSpielerDraussen)
.next(warteAufBall)
ifElse()
.next(warteAufBall)
.next(werfenBall)
.loop()
}
next(warteAufSpielerBeitritt).next(sendeHandschlag).next(warteAufSpielerBeitritt)
next(warteAufSpielerDraussen).next(warteAufSpielerDraussen)
next(empfangenHandschlag).next(empfangenHandschlag)
next(warteAufStopSpielSignal).terminate()
}
sendeSpielerBeitritt.onExit { println("$spielerName: Ich bin hier!") }
sendeSpielerDraussen.onExit { println("$spielerName: Ich bin raus!") }
}
private fun MessageSendInstance<ZufallsSpieler, Int>.waehleNaechstenSpieler() {
val spieler = if (process.spieler.isNotEmpty())
process.spieler.random()
else
process.selbstSpieler
receiverDomain = spieler.domain
receiverProcessInstanceId = spieler.id
println("Schritt ${process.schussZaehler + 1}: " +
"${process.spielerName} >>> ${spieler.name}")
}Diagramm:

In der Anwendung app3 gestalten wir den Spieler mit einem anderen Verhalten: Anstelle einer zufÀlligen Auswahl des nÀchsten Spielers wird er nach dem Round-Robin-Algorithmus handeln:
class RoundRobinPlayer
import ru.krista.bpm.ProcessInstance
import ru.krista.bpm.runtime.ProcessImpl
import ru.krista.bpm.runtime.dsl.processModel
import ru.krista.bpm.runtime.instance.MessageSendInstance
data class PlayerInfo(val name: String, val domain: String, val id: String)
class PlayersList: ArrayList()
class RoundRobinPlayer : ProcessImpl(roundRobinPlayerModel) {
var playerName: String by input(persistent = true,
defaultValue = "RoundRobinPlayer")
var energy: Int by input(persistent = true, defaultValue = 30)
var players: PlayersList by persistent(PlayersList())
var nextPlayerIndex: Int by persistent(-1)
var allPlayersOut: Boolean by persistent(false)
var shotCounter: Int = 0
val selfPlayer: PlayerInfo
get() = PlayerInfo(playerName, env.eventDispatcher.domainName, id)
}
val roundRobinPlayerModel = processModel(
name = "RoundRobinPlayer",
version = 1) {
val waitNewGameSignal = signalWait("NewGame")
val waitStopGameSignal = signalWait("StopGame")
val sendPlayerJoin = signal("PlayerJoin") {
signalData = { playerName }
}
val sendPlayerOut = signal("PlayerOut") {
signalData = { playerName }
}
val waitPlayerJoin = signalWaitCustom("PlayerJoin") {
eventCondition = { signal ->
signal.sender.processInstanceId != process.id
&& !process.players.any { signal.sender.processInstanceId == it.id}
}
handler = { signal ->
players.add(PlayerInfo(
signal.data!!,
signal.sender.domain,
signal.sender.processInstanceId))
}
}
val waitPlayerOut = signalWait("PlayerOut") { signal ->
players.remove(PlayerInfo(
signal.data!!,
signal.sender.domain,
signal.sender.processInstanceId))
allPlayersOut = players.isEmpty()
}
val sendHandshake = messageSend("Handshake") {
messageData = { playerName }
activation = {
receiverDomain = process.players.last().domain
receiverProcessInstanceId = process.players.last().id
}
}
val receiveHandshake = messageWait("Handshake") { message ->
if (!players.any { message.sender.processInstanceId == it.id}) {
players.add(PlayerInfo(
message.data!!,
message.sender.domain,
message.sender.processInstanceId))
}
}
val throwBall = messageSend("Ball") {
messageData = { shotCounter + 1 }
activation = { selectNextPlayer() }
onEntry { energy -= 1 }
}
val waitBall = messageWaitData("Ball") {
shotCounter = it
}
startFrom(waitNewGameSignal)
.fork("mainFork") {
next(sendPlayerJoin)
.branch("mainLoop") {
ifTrue { energy < 5 || allPlayersOut }
.next(sendPlayerOut)
.next(waitBall)
ifElse()
.next(waitBall)
.next(throwBall)
.loop()
}
next(waitPlayerJoin).next(sendHandshake).next(waitPlayerJoin)
next(waitPlayerOut).next(waitPlayerOut)
next(receiveHandshake).next(receiveHandshake)
next(waitStopGameSignal).terminate()
}
sendPlayerJoin.onExit { println("$playerName: Ich bin hier!") }
sendPlayerOut.onExit { println("$playerName: Ich bin raus!") }
}
private fun MessageSendInstance.selectNextPlayer() {
var idx = process.nextPlayerIndex + 1
if (idx >= process.players.size) {
idx = 0
}
process.nextPlayerIndex = idx
val player = if (process.players.isNotEmpty())
process.players[idx]
else
process.selfPlayer
receiverDomain = player.domain
receiverProcessInstanceId = player.id
println("Schritt ${process.shotCounter + 1}: " +
"${process.playerName} >>> ${player.name}")
}Ansonsten unterscheidet sich das Verhalten des Spielers nicht von dem vorherigen, daher bleibt das Diagramm unverÀndert.
Jetzt wird ein Test benötigt, um das alles auszufĂŒhren. Ich fĂŒhre nur den Code des Tests an, um den Artikel nicht mit Boilerplate zu ĂŒberladen (tatsĂ€chlich habe ich die zuvor erstellte Testumgebung fĂŒr die Integration anderer GeschĂ€ftsprozesse verwendet):
testGame()
@Test
public void testGame() throws InterruptedException {
String pl2 = startProcess(app2, "RandomPlayer", playerParams("Player2", 20));
String pl3 = startProcess(app2, "RandomPlayer", playerParams("Player3", 40));
String pl4 = startProcess(app3, "RoundRobinPlayer", playerParams("Player4", 25));
String pl5 = startProcess(app3, "RoundRobinPlayer", playerParams("Player5", 35));
String pl1 = startProcess(app1, "InitialPlayer");
// Jetzt mĂŒssen wir etwas warten, bis sich die Spieler "kennenlernen".
// Warten mit sleep ist eine schlechte Lösung, aber die einfachste.
// Machen Sie das nicht in ernsthaften Tests!
Thread.sleep(1000);
// Das Spiel starten, while die BenutzeraktivitĂ€t schlieĂen
assertTrue(closeTask(app1, pl1, "Start"));
app1.getWaiting().waitProcessFinished(pl1);
app2.getWaiting().waitProcessFinished(pl2);
app2.getWaiting().waitProcessFinished(pl3);
app3.getWaiting().waitProcessFinished(pl4);
app3.getWaiting().waitProcessFinished(pl5);
}
private Map playerParams(String name, int energy) {
Map params = new HashMap();
params.put("playerName", name);
params.put("energy", energy);
return params;
}Test starten, Log ansehen:
Konsole Ausgabe
Der SchlĂŒssel lock://app1/process/InitialPlayer wurde gesperrt
Lass uns spielen!
Der SchlĂŒssel lock://app1/process/InitialPlayer wurde entsperrt
Spieler2: Ich bin hier!
Spieler3: Ich bin hier!
Spieler4: Ich bin hier!
Spieler5: Ich bin hier!
... Spieler Spieler2 beitreten ...
... Spieler Spieler4 beitreten ...
... Spieler Spieler3 beitreten ...
... Spieler Spieler5 beitreten ...
Schritt 1: Spieler1 >>> Spieler3
Schritt 2: Spieler3 >>> Spieler5
Schritt 3: Spieler5 >>> Spieler3
Schritt 4: Spieler3 >>> Spieler4
Schritt 5: Spieler4 >>> Spieler3
Schritt 6: Spieler3 >>> Spieler4
Schritt 7: Spieler4 >>> Spieler5
Schritt 8: Spieler5 >>> Spieler2
Schritt 9: Spieler2 >>> Spieler5
Schritt 10: Spieler5 >>> Spieler4
Schritt 11: Spieler4 >>> Spieler2
Schritt 12: Spieler2 >>> Spieler4
Schritt 13: Spieler4 >>> Spieler1
Schritt 14: Spieler1 >>> Spieler4
Schritt 15: Spieler4 >>> Spieler3
Schritt 16: Spieler3 >>> Spieler1
Schritt 17: Spieler1 >>> Spieler2
Schritt 18: Spieler2 >>> Spieler3
Schritt 19: Spieler3 >>> Spieler1
Schritt 20: Spieler1 >>> Spieler5
Schritt 21: Spieler5 >>> Spieler1
Schritt 22: Spieler1 >>> Spieler2
Schritt 23: Spieler2 >>> Spieler4
Schritt 24: Spieler4 >>> Spieler5
Schritt 25: Spieler5 >>> Spieler3
Schritt 26: Spieler3 >>> Spieler4
Schritt 27: Spieler4 >>> Spieler2
Schritt 28: Spieler2 >>> Spieler5
Schritt 29: Spieler5 >>> Spieler2
Schritt 30: Spieler2 >>> Spieler1
Schritt 31: Spieler1 >>> Spieler3
Schritt 32: Spieler3 >>> Spieler4
Schritt 33: Spieler4 >>> Spieler1
Schritt 34: Spieler1 >>> Spieler3
Schritt 35: Spieler3 >>> Spieler4
Schritt 36: Spieler4 >>> Spieler3
Schritt 37: Spieler3 >>> Spieler2
Schritt 38: Spieler2 >>> Spieler5
Schritt 39: Spieler5 >>> Spieler4
Schritt 40: Spieler4 >>> Spieler5
Schritt 41: Spieler5 >>> Spieler1
Schritt 42: Spieler1 >>> Spieler5
Schritt 43: Spieler5 >>> Spieler3
Schritt 44: Spieler3 >>> Spieler5
Schritt 45: Spieler5 >>> Spieler2
Schritt 46: Spieler2 >>> Spieler3
Schritt 47: Spieler3 >>> Spieler2
Schritt 48: Spieler2 >>> Spieler5
Schritt 49: Spieler5 >>> Spieler4
Schritt 50: Spieler4 >>> Spieler2
Schritt 51: Spieler2 >>> Spieler5
Schritt 52: Spieler5 >>> Spieler1
Schritt 53: Spieler1 >>> Spieler5
Schritt 54: Spieler5 >>> Spieler3
Schritt 55: Spieler3 >>> Spieler5
Schritt 56: Spieler5 >>> Spieler2
Schritt 57: Spieler2 >>> Spieler1
Schritt 58: Spieler1 >>> Spieler4
Schritt 59: Spieler4 >>> Spieler1
Schritt 60: Spieler1 >>> Spieler4
Schritt 61: Spieler4 >>> Spieler3
Schritt 62: Spieler3 >>> Spieler2
Schritt 63: Spieler2 >>> Spieler5
Schritt 64: Spieler5 >>> Spieler4
Schritt 65: Spieler4 >>> Spieler5
Schritt 66: Spieler5 >>> Spieler1
Schritt 67: Spieler1 >>> Spieler5
Schritt 68: Spieler5 >>> Spieler3
Schritt 69: Spieler3 >>> Spieler4
Schritt 70: Spieler4 >>> Spieler2
Schritt 71: Spieler2 >>> Spieler5
Schritt 72: Spieler5 >>> Spieler2
Schritt 73: Spieler2 >>> Spieler1
Schritt 74: Spieler1 >>> Spieler4
Schritt 75: Spieler4 >>> Spieler1
Schritt 76: Spieler1 >>> Spieler2
Schritt 77: Spieler2 >>> Spieler5
Schritt 78: Spieler5 >>> Spieler4
Schritt 79: Spieler4 >>> Spieler3
Schritt 80: Spieler3 >>> Spieler1
Schritt 81: Spieler1 >>> Spieler5
Schritt 82: Spieler5 >>> Spieler1
Schritt 83: Spieler1 >>> Spieler4
Schritt 84: Spieler4 >>> Spieler5
Schritt 85: Spieler5 >>> Spieler3
Schritt 86: Spieler3 >>> Spieler5
Schritt 87: Spieler5 >>> Spieler2
Schritt 88: Spieler2 >>> Spieler3
Spieler2: Ich bin raus!
Schritt 89: Spieler3 >>> Spieler4
... Spieler Spieler2 ist raus ...
Schritt 90: Spieler4 >>> Spieler1
Schritt 91: Spieler1 >>> Spieler3
Schritt 92: Spieler3 >>> Spieler1
Schritt 93: Spieler1 >>> Spieler4
Schritt 94: Spieler4 >>> Spieler3
Schritt 95: Spieler3 >>> Spieler5
Schritt 96: Spieler5 >>> Spieler1
Schritt 97: Spieler1 >>> Spieler5
Schritt 98: Spieler5 >>> Spieler3
Schritt 99: Spieler3 >>> Spieler5
Schritt 100: Spieler5 >>> Spieler4
Schritt 101: Spieler4 >>> Spieler5
Spieler4: Ich bin raus!
... Spieler Spieler4 ist raus ...
Schritt 102: Spieler5 >>> Spieler1
Schritt 103: Spieler1 >>> Spieler3
Schritt 104: Spieler3 >>> Spieler1
Schritt 105: Spieler1 >>> Spieler3
Schritt 106: Spieler3 >>> Spieler5
Schritt 107: Spieler5 >>> Spieler3
Schritt 108: Spieler3 >>> Spieler1
Schritt 109: Spieler1 >>> Spieler3
Schritt 110: Spieler3 >>> Spieler5
Schritt 111: Spieler5 >>> Spieler1
Schritt 112: Spieler1 >>> Spieler3
Schritt 113: Spieler3 >>> Spieler5
Schritt 114: Spieler5 >>> Spieler3
Schritt 115: Spieler3 >>> Spieler1
Schritt 116: Spieler1 >>> Spieler3
Schritt 117: Spieler3 >>> Spieler5
Schritt 118: Spieler5 >>> Spieler1
Schritt 119: Spieler1 >>> Spieler3
Schritt 120: Spieler3 >>> Spieler5
Schritt 121: Spieler5 >>> Spieler3
Spieler5: Ich bin raus!
... Spieler Spieler5 ist raus ...
Schritt 122: Spieler3 >>> Spieler5
Schritt 123: Spieler5 >>> Spieler1
Spieler5: Ich bin raus!
Schritt 124: Spieler1 >>> Spieler3
... Spieler Spieler5 ist raus ...
Schritt 125: Spieler3 >>> Spieler1
Schritt 126: Spieler1 >>> Spieler3
Spieler1: Ich bin raus!
... Spieler Spieler1 ist raus ...
Schritt 127: Spieler3 >>> Spieler3
Spieler3: Ich bin raus!
Schritt 128: Spieler3 >>> Spieler3
... Spieler Spieler3 ist raus ...
Spieler3: Ich bin raus!
Stop!
Schritt 129: Spieler3 >>> Spieler3
Spieler3: Ich bin raus!Aus all dem kann man mehrere wichtige Schlussfolgerungen ziehen:
- Mit den notwendigen Werkzeugen können Entwickler Integrationsinteraktionen zwischen Anwendungen schaffen, ohne von der GeschÀftslogik abzuweichen;
- Die KomplexitÀt der Integrationsaufgabe, die Ingenieurkompetenzen erfordert, kann innerhalb des Frameworks verborgen werden, wenn dies von Anfang an in die Architektur des Frameworks eingebaut wird. Die Schwierigkeit der Aufgabe kann jedoch nicht verborgen werden, weshalb die Lösung einer schwierigen Aufgabe im Code entsprechend aussehen wird;
- Bei der Entwicklung der Integrationslogik muss unbedingt die eventually consistency und das Fehlen der LinearitĂ€t der ZustandsĂ€nderungen aller Integrationsbeteiligten berĂŒcksichtigt werden. Dies zwingt dazu, die Logik zu komplizieren, um sie unempfindlich gegenĂŒber der Reihenfolge des Auftretens externer Ereignisse zu machen. In unserem Beispiel muss der Spieler erst nach seiner AnkĂŒndigung des Austritts aus dem Spiel am Spiel teilnehmen: Die anderen Spieler werden weiterhin den Ball zu ihm passen, bis die Information ĂŒber seinen Austritt alle Teilnehmer erreicht und verarbeitet hat. Diese Logik ergibt sich nicht aus den Spielregeln und ist eine Kompromisslösung im Rahmen der gewĂ€hlten Architektur.
Lassen Sie uns nun ĂŒber verschiedene Feinheiten unserer Lösung, Kompromisse und andere Aspekte sprechen.
Alle Nachrichten â in einer Warteschlange
Alle integrierten Anwendungen arbeiten mit einer einzigen Integrationsbus, die in Form eines externen Brokers, einer Warteschlange BPMQueue â fĂŒr Nachrichten und einem Topic BPMTopic â fĂŒr Signale (Ereignisse) dargestellt wird. Alle Nachrichten ĂŒber eine Warteschlange zu leiten, ist selbst ein Kompromiss. Auf Ebene der GeschĂ€ftslogik können nun beliebig viele neue Nachrichtentypen eingefĂŒhrt werden, ohne Ănderungen an der Systemstruktur vorzunehmen. Dies ist eine erhebliche Vereinfachung, birgt jedoch bestimmte Risiken, die uns im Kontext unserer typischen Aufgaben nicht so bedeutend erschienen.

Es gibt jedoch einen Punkt, der zu beachten ist: Jede Anwendung filtert ihre 'eigenen' Nachrichten bereits beim Eingang aus der Warteschlange nach ihrem DomĂ€nennamen. Der DomĂ€nenname kann auch in Signalen angegeben werden, wenn die 'Sichtbarkeit' des Signals auf eine einzige Anwendung beschrĂ€nkt werden muss. Dies soll die Durchsatzrate des Busses erhöhen, aber die GeschĂ€ftslogik muss jetzt mit den DomĂ€nennamen arbeiten: fĂŒr die Adressierung von Nachrichten ist dies zwingend erforderlich, fĂŒr Signale wĂŒnschenswert.
GewÀhrleistung der ZuverlÀssigkeit des Integrationsbusses
Die ZuverlÀssigkeit setzt sich aus mehreren Aspekten zusammen:
- Der gewĂ€hlte Nachrichtenbroker ist ein kritischer Bestandteil der Architektur und ein einziger Ausfallpunkt: Er sollte ausreichend ausfallsicher sein. Es sollten nur bewĂ€hrte Implementierungen verwendet werden, die gut unterstĂŒtzt werden und eine groĂe Community haben;
- Die hohe VerfĂŒgbarkeit des Nachrichtenbrokers muss sichergestellt werden, weshalb er physisch von den integrierten Anwendungen getrennt sein sollte (die hohe VerfĂŒgbarkeit von Anwendungen mit geschĂ€ftlicher Logik ist erheblich schwieriger und kostspieliger zu gewĂ€hrleisten);
- Der Broker muss 'at least once'-Zustellgarantien bieten. Dies ist eine zwingende Anforderung fĂŒr die zuverlĂ€ssige Funktion des Integrationsbusses. Garantien auf 'exactly once'-Basis sind nicht erforderlich: GeschĂ€ftsprozesse sind in der Regel nicht empfindlich gegenĂŒber der wiederholten Zustellung von Nachrichten oder Ereignissen, und bei besonderen Aufgaben, in denen dies wichtig ist, ist es einfacher, zusĂ€tzliche ĂberprĂŒfungen in die GeschĂ€ftslogik einzufĂŒgen, als stĂ€ndig ausreichend 'teure' Garantien zu verwenden;
- Das Senden von Nachrichten und Signalen muss in eine allgemeine Transaktion mit der Ănderung des Status von GeschĂ€ftsprozessen und DomĂ€nendaten eingebunden werden. Bevorzugt wĂ€re die Verwendung des Musters , was jedoch eine zusĂ€tzliche Tabelle in der Datenbank und einen Relayer erfordert. In JEE-Anwendungen kann dieser Aspekt mit einem lokalen JTA-Manager vereinfacht werden, jedoch muss die Verbindung zum gewĂ€hlten Broker in der Lage sein, im ;
- Modus zu arbeiten. Die Handler fĂŒr eingehende Nachrichten und Ereignisse mĂŒssen ebenfalls innerhalb der Transaktion zur Ănderung des Status des GeschĂ€ftsprozesses arbeiten: Wenn eine solche Transaktion zurĂŒckgerollt wird, muss auch der Empfang der Nachricht annulliert werden;
- Nachrichten, deren Zustellung aufgrund von Fehlern nicht möglich war, mĂŒssen in einem separaten Speicher abgelegt werden. (Dead Letter Queue). Wir haben dafĂŒr einen separaten Plattform-Mikroservice erstellt, der solche Nachrichten in seinem Speicher ablegt, sie nach Attributen indiziert (fĂŒr schnelle Gruppierung und Suche) und eine API bereitstellt, um sie anzusehen, erneut an die Zieladresse zu senden und Nachrichten zu löschen. Systemadministratoren können mit diesem Dienst ĂŒber ihre WeboberflĂ€che arbeiten;
- In den Broker-Einstellungen muss die Anzahl der Wiederholungsversuche und die Verzögerungen zwischen den Lieferungen eingestellt werden, um die Wahrscheinlichkeit zu verringern, dass Nachrichten in die DLQ gelangen (die optimalen Parameter zu bestimmen ist praktisch unmöglich, aber man kann empirisch vorgehen und sie im Verlauf des Betriebs anpassen);
- Der DLQ-Speicher muss kontinuierlich ĂŒberwacht werden, und das Ăberwachungssystem sollte die Systemadministratoren benachrichtigen, damit sie bei auftretenden unzustellbaren Nachrichten so schnell wie möglich reagieren können. Dies wird helfen, die âSchadenszoneâ eines aufgetretenen Fehlers oder einer fehlerhaften GeschĂ€ftslogik zu verringern;
- Die Integrationsbus sollte unempfindlich gegenĂŒber temporĂ€rer Abwesenheit von Anwendungen sein: Abonnements fĂŒr das Topic sollten dauerhaft sein, und der Anwendungs-Domainname sollte eindeutig sein, damit wĂ€hrend der Abwesenheit der Anwendung ihre Nachrichten aus der Warteschlange nicht von jemand anderem verarbeitet werden;
Sichere Thread-Sicherheit der GeschÀftslogik
Einem einzigen Instanz des GeschĂ€ftsprozesses können gleichzeitig mehrere Nachrichten und Ereignisse zugehen, deren Verarbeitung parallel gestartet wird. Gleichzeitig muss fĂŒr den Anwendungsentwickler alles einfach und thread-sicher sein.
Die GeschÀftslogik des Prozesses verarbeitet jedes externe Ereignis, das auf diesen GeschÀftsprozess Einfluss hat, einzeln. Solche Ereignisse können sein:
- der Start einer Instanz des GeschÀftsprozesses;
- eine Benutzeraktion, die sich auf AktivitÀten innerhalb des GeschÀftsprozesses bezieht;
- das Eintreffen einer Nachricht oder eines Signals, auf das die Instanz des GeschÀftsprozesses abonniert ist;
- das Auslösen eines Timers, der von der Instanz des GeschÀftsprozesses gesetzt wurde;
- eine Steuerungseinwirkung ĂŒber die API (z. B. das Not-Aus des Prozesses).
Jedes solche Ereignis kann den Zustand eines GeschĂ€ftsprozess-Exemplars Ă€ndern: Einige AktivitĂ€ten können beendet und andere können beginnen, die Werte der persistenten Eigenschaften können sich Ă€ndern. Das SchlieĂen einer beliebigen AktivitĂ€t kann zur Aktivierung einer oder mehrerer nachfolgender AktivitĂ€ten fĂŒhren. Diese können ihrerseits auf das Eintreffen anderer Ereignisse warten oder, wenn sie keine zusĂ€tzlichen Daten benötigen, in derselben Transaktion abgeschlossen werden. Vor dem SchlieĂen der Transaktion wird der neue Zustand des GeschĂ€ftsprozesses in der Datenbank gespeichert, wo er auf das Eintreffen des nĂ€chsten externen Ereignisses wartet.
Persistente Daten des GeschĂ€ftsprozesses, die in einer relationalen Datenbank gespeichert sind, stellen einen sehr praktischen Synchronisierungspunkt fĂŒr die Verarbeitung dar, wenn SELECT FOR UPDATE verwendet wird. Wenn einer Transaktion es gelungen ist, den Zustand des GeschĂ€ftsprozesses aus der Datenbank zu erhalten, um ihn zu Ă€ndern, kann keine andere Transaktion gleichzeitig denselben Zustand fĂŒr eine andere Ănderung abrufen, und nach Abschluss der ersten Transaktion erhĂ€lt die zweite garantiert bereits den geĂ€nderten Zustand.
Durch die Verwendung von pessimistischen Sperren auf der Seite der DBMS erfĂŒllen wir alle notwendigen Anforderungen , und wir bewahren die Möglichkeit zur Skalierung der Anwendung mit der GeschĂ€ftslogik, indem wir die Anzahl der laufenden Exemplare erhöhen.
Eine weitere Problematik sind pessimistische Sperren, die uns Deadlocks drohen, daher sollte SELECT FOR UPDATE in der Tat mit einem vernĂŒnftigen Timeout versehen werden, um Deadlocks in einigen offensichtlichen FĂ€llen der GeschĂ€ftslogik zu vermeiden.
Ein weiteres Problem ist die Synchronisierung des Starts des GeschĂ€ftsprozesses. Solange kein Exemplar des GeschĂ€ftsprozesses existiert, gibt es auch seinen Zustand nicht in der Datenbank, weshalb die beschriebene Methode nicht geeignet ist. Wenn die Einzigartigkeit des Exemplars des GeschĂ€ftsprozesses in einem bestimmten Umfang sichergestellt werden muss, ist ein gewisser Synchronisationsmechanismus erforderlich, der mit der Prozessklasse und dem entsprechenden Umfang assoziiert ist. Um dieses Problem zu lösen, verwenden wir einen anderen Mechanismus fĂŒr Sperren, der es erlaubt, eine Sperre fĂŒr eine beliebige Ressource zu erhalten, die durch einen SchlĂŒssel im URI-Format ĂŒber einen externen Dienst angegeben ist.
In unseren Beispielen enthÀlt der GeschÀftsprozess InitialPlayer die ErklÀrung
uniqueConstraint = UniqueConstraints.singletonDaher enthĂ€lt das Protokoll Nachrichten ĂŒber das Sperren und Freigeben des entsprechenden SchlĂŒssels. FĂŒr andere GeschĂ€ftsprozesse gibt es solche Nachrichten nicht: uniqueConstraint ist nicht festgelegt.
Probleme bei GeschÀftsprozessen mit persistentem Zustand
Manchmal hilft der vorhandene persistente Zustand nicht nur, sondern stört auch bei der Entwicklung erheblich.
Die Probleme beginnen, wenn Ănderungen an der GeschĂ€ftslogik und/oder dem Modell des GeschĂ€ftsprozesses vorgenommen werden mĂŒssen. Nicht jede solche Ănderung ist mit dem alten Zustand der GeschĂ€ftsprozesse kompatibel. Wenn in der Datenbank viele 'lebende' Instanzen vorhanden sind, kann das Einbringen inkompatibler Ănderungen groĂe Schwierigkeiten verursachen, mit denen wir oft bei der Verwendung von jBPM konfrontiert waren.
Je nach Tiefe der Ănderungen kann man auf zwei Wegen vorgehen:
- einen neuen Typ von GeschĂ€ftsprozess erstellen, um inkompatible Ănderungen am alten zu vermeiden, und ihn anstelle des alten bei der Erstellung neuer Instanzen verwenden. Alte Instanzen werden 'wie bisher' weiterarbeiten;
- den persistenten Zustand der GeschÀftsprozesse bei Aktualisierung der GeschÀftslogik migrieren.
Der erste Weg ist einfacher, hat aber seine EinschrÀnkungen und Nachteile, wie zum Beispiel:
- Duplizierung der GeschÀftslogik in vielen Modellen von GeschÀftsprozessen, Erhöhung des Umfangs der GeschÀftslogik;
- oft ist ein sofortiger Ăbergang zur neuen GeschĂ€ftslogik erforderlich (im Bereich der Integrationsaufgaben fast immer);
- der Entwickler weià nicht, wann es möglich ist, veraltete Modelle zu löschen.
In der Praxis verwenden wir beide AnsÀtze, haben aber eine Reihe von Entscheidungen getroffen, um uns das Leben zu erleichtern:
- In der Datenbank wird der persistente Zustand des GeschĂ€ftsprozesses in einer leicht lesbaren und verarbeitbaren Form gespeichert: im JSON-Format. Dies ermöglicht Migrationen sowohl innerhalb der Anwendung als auch extern. Im Ă€uĂersten Fall kann man es auch manuell anpassen (insbesondere bei der Entwicklung wĂ€hrend des Debuggings);
- Die integrative GeschĂ€ftslogik verwendet keine Namen von GeschĂ€ftsprozessen, damit die Implementierung eines der beteiligten Prozesse jederzeit gegen eine neue mit einem neuen Namen (z. B. 'InitialPlayerV2') ersetzt werden kann. Die Bindung erfolgt ĂŒber die Namen von Nachrichten und Signalen;
- Das Prozessmodell hat eine Versionsnummer, die wir erhöhen, wenn wir inkompatible Ănderungen an diesem Modell vornehmen, und diese Nummer wird zusammen mit dem Zustand des Prozessinstanz gespeichert;
- Der persistente Zustand des Prozesses wird zuerst aus der Datenbank in ein praktisches Objektmodell eingelesen, mit dem das Migrationsverfahren arbeiten kann, wenn sich die Versionsnummer des Modells geÀndert hat;
- Das Migrationsverfahren wird neben der GeschĂ€ftslogik platziert und "faul" fĂŒr jede Instanz des GeschĂ€ftsprozesses aufgerufen, wenn sie aus der Datenbank wiederhergestellt wird;
- Wenn der Zustand aller Prozessinstanzen schnell und synchron migriert werden muss, kommen klassischere Datenbankmigrationlösungen zum Einsatz, aber dabei muss mit JSON gearbeitet werden.
Brauchen wir ein weiteres Framework fĂŒr GeschĂ€ftsprozesse?
Die in diesem Artikel beschriebenen Lösungen haben es uns ermöglicht, unser Leben erheblich zu vereinfachen, den Umfang der Fragen, die auf der Ebene der Anwendungsentwicklung gelöst werden können, zu erweitern und die Ideen zur Auslagerung von GeschĂ€ftslogik in Mikrodienste attraktiver zu gestalten. Dazu wurde viel Arbeit geleistet, ein sehr "leichtgewichtiges" Framework fĂŒr GeschĂ€ftsprozesse entwickelt und Dienstkomponenten zur Lösung der angegebenen Probleme im Kontext eines breiten Spektrums anwendungsbezogener Aufgaben erstellt. Wir haben den Wunsch, diese Ergebnisse zu teilen, die Entwicklung allgemeiner Komponenten unter einer freien Lizenz öffentlich zugĂ€nglich zu machen. Dies erfordert bestimmte Anstrengungen und Zeit. Ein VerstĂ€ndnis fĂŒr die Nachfrage nach solchen Lösungen könnte fĂŒr uns einen zusĂ€tzlichen Anreiz darstellen. In dem vorgeschlagenen Artikel wird sehr wenig Augenmerk auf die Möglichkeiten des Frameworks selbst gelegt, aber einige davon sind aus den prĂ€sentierten Beispielen ersichtlich. Sollten wir unser Framework doch veröffentlichen, wird ihm ein eigener Artikel gewidmet. Bis dahin wĂ€ren wir dankbar, wenn Sie uns ein kurzes Feedback geben und die Frage beantworten:
Nur registrierte Benutzer können an der Umfrage teilnehmen. .
Brauchen wir ein weiteres Framework fĂŒr GeschĂ€ftsprozesse?
18,8%Ja, wir suchen schon lange nach etwas Ăhnlichem3
12,5%Ich wĂŒrde gerne mehr ĂŒber Ihre Umsetzung erfahren, könnte nĂŒtzlich sein2
6,2%Wir verwenden eines der bestehenden Frameworks, denken aber ĂŒber einen Wechsel nach1
18,8%Wir verwenden eines der bestehenden Frameworks, sind damit zufrieden3
18,8%Wir kommen ohne Frameworks aus3
25,0%Wir schreiben unser eigenes4
16 Benutzer haben abgestimmt. 7 Benutzer haben sich enthalten.
Quelle: habr.com
