Integration im BPM-Stil

Integration im BPM-Stil

Hallo, Habr!

Unser Unternehmen ist auf die Entwicklung von ERP-Softwarelösungen spezialisiert, wobei ein wesentlicher Teil transaktionale Systeme mit umfangreicher GeschĂ€ftslogik und Dokumentenverkehr im Stil der elektronischen Dokumentenverwaltung ausmacht. Die modernen Versionen unserer Produkte basieren auf JavaEE-Technologien, jedoch experimentieren wir auch aktiv mit Mikrodiensten. Eines der problematischsten Bereiche solcher Lösungen ist die Integration verschiedener Teilsysteme, die zu angrenzenden DomĂ€nen gehören. Die Integrationsaufgaben haben uns immer große Kopfschmerzen bereitet, unabhĂ€ngig von den von uns verwendeten Architekturmustern, Technologie-Stacks und Frameworks, jedoch gab es in letzter Zeit Fortschritte bei der Lösung solcher Aufgaben.

In dem vorliegenden Artikel werde ich ĂŒber die Erfahrungen und architektonischen Überlegungen der NPO "Krista" auf diesem Gebiet berichten. Außerdem werden wir ein einfaches Beispiel fĂŒr eine Integrationsaufgabe aus der Sicht eines Anwendungsentwicklers betrachten und herausfinden, was hinter dieser Einfachheit steckt.

Haftungsausschluss

Die in diesem Artikel beschriebenen architektonischen und technischen Lösungen basieren auf meiner persönlichen Erfahrung im Kontext spezifischer Aufgaben. Diese Lösungen erheben keinen Anspruch auf Universalisierung und können unter anderen Bedingungen suboptimal sein.

Was hat BPM damit zu tun?

Um diese Frage zu beantworten, mĂŒssen wir etwas tiefer in die spezifischen Anwendungsaufgaben unserer Lösungen eintauchen. Der Hauptteil der GeschĂ€ftslogik in unserem typischen Transaktionssystem besteht aus der Eingabe von Daten in die Datenbank ĂŒber BenutzeroberflĂ€chen, der manuellen und automatisierten PrĂŒfung dieser Daten, der DurchfĂŒhrung in einem bestimmten Workflow, der Veröffentlichung in ein anderes System / eine Analysedatenbank / ein Archiv sowie der Erstellung von Berichten. Somit ist die SchlĂŒsselrolle 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 Datensammlung, die durch einen gemeinsamen SchlĂŒssel verbunden ist und an die ein bestimmter Workflow "gebunden" werden kann.
Aber wie steht es um die Integrationslogik? Denn die Aufgabe der Integration wird durch die Architektur des Systems hervorgerufen, die nicht auf Kundenanforderung, sondern durch ganz andere Faktoren "zerstĂŒckelt" ist:

  • Unter dem Einfluss des Conway-Gesetzes;
  • als Ergebnis der Wiederverwendung von Subsystemen, die zuvor fĂŒr andere Produkte entwickelt wurden;
  • auf Beschluss des Architekten, basierend auf 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 den Anwendungsentwickler von der Notwendigkeit zu befreien, sich mit den Besonderheiten der architektonischen Landschaft des Systems auseinanderzusetzen. Dieser Ansatz hat einige Vorteile, doch die Praxis zeigt, dass er ineffektiv ist:

  • Die Lösung von Integrationsproblemen fĂŒhrt in der Regel zu den einfachsten Varianten in Form von synchronen Aufrufen aufgrund der begrenzten Erweiterungspunkte in der Umsetzung des Haupt-Workflows (zu den Nachteilen der synchronen Integration – weiter unten);
  • Integrationsartefakte dringen dennoch in die HauptgeschĂ€ftslogik ein, wenn RĂŒckmeldungen aus einem anderen Subsystem erforderlich sind;
  • Der Anwendungsentwickler ignoriert die Integration und kann diese leicht brechen, indem er den Workflow verĂ€ndert.
  • Das System hört auf, aus Sicht des Benutzers ein Gesamtes zu sein, und die "NĂ€hte" zwischen den Teilsystemen werden sichtbar, wĂ€hrend ĂŒberflĂŒssige Benutzeroperationen zur Initiierung des Datenaustauschs zwischen den Teilsystemen auftreten.

Ein alternativer Ansatz besteht darin, Integrationsinteraktionen als wesentlichen Bestandteil der grundlegenden GeschĂ€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 mĂŒhelos erfolgen, mit minimalen Möglichkeiten zur Wahl der LösungsansĂ€tze. Dies zu erreichen ist komplizierter als es scheint: Das Werkzeug muss leistungsfĂ€hig genug sein, um dem Benutzer eine ausreichende Auswahl an Anwendungsmöglichkeiten zu bieten, und gleichzeitig verhindern, dass man sich selbst in Schwierigkeiten bringt. Es gibt viele Fragen, die Ingenieure im Kontext von Integrationsaufgaben beantworten mĂŒssen, ĂŒber die Anwendungsentwickler in ihrem TagesgeschĂ€ft nicht nachdenken sollten: Transaktionsgrenzen, Konsistenz, AtomaritĂ€t, Sicherheit, Skalierbarkeit, Lastverteilung und -ressourcen, Routing, Marshalling, Verbreitung und Kontextwechsel usw. Anwendungsentwicklern sollten ausreichend einfache Lösungsmuster angeboten werden, die bereits Antworten auf all diese Fragen beinhalten. Diese Muster sollten ausreichend sicher sein: Die GeschĂ€ftslogik Ă€ndert sich sehr hĂ€ufig, was die Risiken von Fehlern erhöht, und die Kosten von Fehlern sollten auf einem ausreichend niedrigen Niveau bleiben.

Aber was hat BPM damit zu tun? Es gibt viele Möglichkeiten zur Umsetzung von Workflows...
In der Tat erfreut sich eine andere Form der GeschĂ€ftsprozessimplementierung in unseren Lösungen großer Beliebtheit – durch die deklarative Vorgabe von ZustandsĂŒbergangsdiagrammen und die Anbindung von Handlern mit GeschĂ€ftslogik fĂŒr die ÜbergĂ€nge. Dabei ist der Zustand, der die aktuelle Position des „Dokuments“ im GeschĂ€ftsprozess bestimmt, ein Attribut des „Dokuments“ selbst.

Integration im BPM-Stil
So sieht der Prozess zu Beginn des Projekts aus.

Die Beliebtheit dieser Umsetzung beruht auf der relativen Einfachheit und der Geschwindigkeit der Erstellung linearer GeschĂ€ftsprozesse. Mit der stĂ€ndigen KomplexitĂ€t von Softwaresystemen wĂ€chst jedoch der automatisierte Teil des GeschĂ€ftsprozesses und wird komplizierter. Es entsteht die Notwendigkeit zur Dekomposition, zur Wiederverwendung von Prozessteilen sowie zur Verzweigung von Prozessen, sodass jeder Zweig parallel ausgefĂŒhrt wird. In einem solchen Umfeld wird das Werkzeug unpraktisch, und das ZustandsĂŒbergangsdiagramm verliert an Aussagekraft (Integrationsinteraktionen werden auf dem Diagramm ĂŒberhaupt nicht angezeigt).

Integration im BPM-Stil
So sieht der Prozess nach mehreren Iterationen der Anforderungsdefinition aus.

Die Lösung bestand in der Integration der Engine jBPM in einige Produkte mit den komplexesten GeschÀftsprozessen. Kurzfristig war diese Lösung durchaus erfolgreich: Es wurde möglich, komplexe GeschÀftsprozesse umzusetzen und gleichzeitig ein informatives und aktuelles Diagramm in der Notation BPMN2.

Integration im BPM-Stil
einer kleinen Teil des komplexen GeschÀftsprozesses.

Langfristig hat die Lösung jedoch die Erwartungen nicht erfĂŒllt: Der hohe Aufwand fĂŒr die Erstellung von GeschĂ€ftsprozessen mithilfe visueller Werkzeuge ermöglichte es nicht, akzeptable ProduktivitĂ€tswerte zu erreichen, und das Tool selbst wurde eines der unbeliebtesten bei den Entwicklern. Auch die interne Struktur der Engine wurde kritisiert, was zur Entstehung vieler „Patchwork-Lösungen“ und „Behelfslösungen“ fĂŒhrte.

Der Hauptvorteil der Anwendung von jBPM war das Bewusstsein fĂŒr den Nutzen und die Nachteile eines eigenen persistenten Zustands fĂŒr eine Instanz eines GeschĂ€ftsprozesses. Zudem haben wir die Möglichkeit einer prozessorientierten Herangehensweise zur Implementierung komplexer Integrationsprotokolle zwischen verschiedenen Anwendungen festgestellt, wobei asynchrone Interaktionen durch Signale und Nachrichten genutzt werden. Der vorhandene persistente Zustand spielt dabei eine entscheidende Rolle.

Zusammenfassend lĂ€sst sich sagen: Der prozessorientierte Ansatz im BPM-Stil 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 einzufĂŒgen und die Möglichkeit der visuellen Darstellung des realisierten Prozesses in einer dafĂŒr geeigneten Notation zu bewahren.

Nachteile synchroner Aufrufe als Integrationsmuster

Unter synchroner Integration versteht man einen einfachsten blockierenden Aufruf. Ein Subsystem fungiert als Serverseite und stellt eine API mit der erforderlichen Methode bereit. Das andere Subsystem fungiert als Clientseite und fĂŒhrt zur richtigen Zeit den Aufruf mit der Erwartung des Ergebnisses aus. Je nach Architektur des Systems können die Client- und Serverseiten entweder in einer Anwendung und einem Prozess oder in verschiedenen plaziert werden. Im zweiten Fall ist es erforderlich, eine bestimmte RPC-Implementierung anzuwenden und das Marshalling von Parametern und Ergebnissen des Aufrufs sicherzustellen.

Integration im BPM-Stil

Ein solches Integrationsmuster weist eine Vielzahl von Nachteilen auf, wird aber aufgrund seiner Einfachheit in der Praxis sehr hĂ€ufig verwendet. Die Geschwindigkeit der Implementierung ist verlockend und fĂŒhrt dazu, dass es immer wieder unter 'dringenden' Fristen eingesetzt wird, wobei die Lösung als technische Schuld verbucht wird. Es kommt jedoch auch vor, dass unerfahrene Entwickler es unbewusst anwenden, ohne sich ĂŒber die negativen Folgen im Klaren zu sein.

Neben den offensichtlichsten Verbesserungen der KohĂ€renz von Teilsystemen gibt es auch weniger offensichtliche Probleme mit dem "Schneiden" und "Dehnen" von Transaktionen. TatsĂ€chlich, wenn die GeschĂ€ftslogik Änderungen vornimmt, können Transaktionen nicht vermieden werden, und Transaktionen blockieren ihrerseits bestimmte Ressourcen der Anwendung, die von diesen Änderungen betroffen sind. Das heißt, solange ein Teilsystem nicht auf eine Antwort von einem anderen wartet, kann es die Transaktion nicht abschließen und die Sperren aufheben. Dies erhöht das Risiko von verschiedenen Effekten erheblich:

  • die ReaktionsfĂ€higkeit des Systems leidet, Benutzer warten lange auf Antworten auf ihre Anfragen;
  • der Server antwortet ĂŒberhaupt nicht mehr auf Benutzeranfragen wegen eines ĂŒberfĂŒllten Threadpools: die meisten Threads sind durch die Sperrung der von der Transaktion genutzten Ressource blockiert;
  • Deadlocks treten auf: die Wahrscheinlichkeit ihres Auftretens hĂ€ngt stark von der Dauer der Transaktionen, der Menge der in die Transaktion involvierten GeschĂ€ftslogik und den Sperren ab;
  • Timeout-Fehler der Transaktionen treten auf;
  • Der Server „stĂŒrzt“ aufgrund von OutOfMemory ab, wenn eine Aufgabe die Verarbeitung und Änderung großer Datenmengen erfordert, und die Verwendung von synchronen Integrationen erschwert das Aufteilen der Verarbeitung in leichtere Transaktionen erheblich.

Aus architektonischer Sicht fĂŒhrt die Verwendung von blockierenden Aufrufen bei der Integration zu einem Verlust der Kontrolle ĂŒber die QualitĂ€t einzelner Teilsysteme: Es ist unmöglich, die QualitĂ€tsziele eines Teilsystems unabhĂ€ngig von den QualitĂ€tskennzahlen eines anderen Teilsystems zu erfĂŒllen. Wenn die Teilsysteme von verschiedenen Teams entwickelt werden, wird dies zu einem großen Problem.

Es wird noch interessanter, wenn die integrierten Teilsysteme in verschiedenen Anwendungen vorhanden sind und synchron Änderungen von beiden Seiten erforderlich sind. Wie lĂ€sst sich die TransaktionalitĂ€t dieser Änderungen gewĂ€hrleisten?

Wenn Änderungen in separaten Transaktionen vorgenommen werden, ist es notwendig, eine zuverlĂ€ssige Fehlerbehandlung und Kompensation zu gewĂ€hrleisten, was den Hauptvorteil synchroner Integrationen – die Einfachheit – vollstĂ€ndig neutralisiert.

Auch verteilte Transaktionen kommen in den Sinn, aber wir verwenden sie nicht in unseren Lösungen: Es ist schwierig, ZuverlÀssigkeit zu gewÀhrleisten.

Das Saga-Pattern als Lösung fĂŒr das Transaktionsproblem

Mit der wachsenden Beliebtheit von Microservices gewinnt Saga-Pattern.

Dieses Pattern löst die zuvor genannten Probleme langer Transaktionen hervorragend und erweitert die Möglichkeiten des Statusmanagements auf Seiten der GeschĂ€ftslogik: Eine Kompensation nach einer fehlgeschlagenen Transaktion kann das System nicht in seinen Ursprungszustand zurĂŒckversetzen, sondern einen alternativen Datenverarbeitungsweg bereitstellen. Dadurch wird auch vermieden, erfolgreich abgeschlossene Verarbeitungsschritte bei erneuten Versuchen zur Erreichung eines 'guten' Abschlusses zu wiederholen.

Interessanterweise ist dieses Pattern auch in monolithischen Systemen relevant, wenn es um die Integration schwach gekoppelt Subsysteme geht und negative Effekte beobachtet werden, die durch lange Transaktionen und die damit verbundenen Ressourcenblockaden verursacht werden.

In Bezug auf unsere BPM-Ă€hnlichen GeschĂ€ftsprozesse ist die Implementierung von „Sagas“ sehr einfach: Die einzelnen Schritte einer „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 bedeutet, dass wir keinen zusĂ€tzlichen Koordinierungsmechanismus benötigen. Es wird lediglich ein Nachrichtenbroker mit UnterstĂŒtzung fĂŒr „at least once“ Garantien als Transport benötigt.

Aber auch diese Lösung hat ihren „Preis“:

  • Die GeschĂ€ftslogik wird komplexer: RĂŒckerstattungen mĂŒssen berĂŒcksichtigt werden;
  • Es muss auf full consistency verzichtet werden, was besonders problematisch fĂŒr monolithische Systeme sein kann;
  • Die Architektur wird etwas komplizierter, es besteht zusĂ€tzlich Bedarf an einem Nachrichtenbroker;
  • ZusĂ€tzliche Überwachungs- und Verwaltungstools werden erforderlich sein (obwohl das insgesamt sogar positiv ist: Die ServicequalitĂ€t des Systems wird steigen).

FĂŒr monolithische Systeme ist die Rechtfertigung fĂŒr die Nutzung von "Sagas" nicht so offensichtlich. Bei Microservices und anderen SOA, in denen wahrscheinlich bereits ein Broker vorhanden ist und die vollstĂ€ndige Konsistenz bereits zu Beginn des Projekts geopfert wurde, kann der Nutzen der Verwendung dieses Musters die Nachteile erheblich ĂŒberwiegen, insbesondere wenn eine benutzerfreundliche API auf der Ebene der GeschĂ€ftslogik vorhanden ist.

Kapselung der GeschÀftslogik in Microservices

Als wir begannen, mit Microservices zu experimentieren, stellte sich die berechtigte Frage: Wo platziert man die domÀnenspezifische GeschÀftslogik in Bezug auf den Dienst, der die Persistenz der domÀnenspezifischen Daten gewÀhrleistet?

Wenn man sich die Architektur verschiedener BPMS ansieht, scheint es sinnvoll, die GeschĂ€ftslogik von der Persistenz zu trennen: Ein Layer von plattformunabhĂ€ngigen und domĂ€nenunabhĂ€ngigen Microservices zu schaffen, die eine Umgebung und einen Container fĂŒr die AusfĂŒhrung der domĂ€nenspezifischen GeschĂ€ftslogik bilden, wĂ€hrend die Persistenz der domĂ€nenspezifischen Daten in einem separaten Layer aus sehr einfachen und leichten Microservices realisiert wird. In diesem Fall orchestrieren die GeschĂ€ftsprozesse die Dienste des Persistenz-Layers.

Integration im BPM-Stil

Dieser Ansatz hat einen großen Vorteil: Die FunktionalitĂ€t der Plattform kann beliebig erweitert werden, und nur die entsprechenden Schichten der Plattform-Mikroservices werden dadurch „dicker“. GeschĂ€ftsprozesse aus allen DomĂ€nen erhalten sofort die Möglichkeit, neue Funktionen der Plattform zu nutzen, sobald sie aktualisiert werden.

Eine detailliertere Analyse hat wesentliche Nachteile dieses Ansatzes aufgezeigt:

  • Der plattformbasierte Dienst, der die GeschĂ€ftslogik fĂŒr mehrere DomĂ€nen ausfĂŒhrt, birgt große Risiken als single point of failure. HĂ€ufige Änderungen der GeschĂ€ftslogik erhöhen das Risiko von Fehlern, die zu SystemausfĂ€llen fĂŒhren, die sich auf das gesamte System ausbreiten;
  • Leistungsprobleme: Die GeschĂ€ftslogik arbeitet ĂŒber eine enge und langsame Schnittstelle mit ihren Daten:
    • Daten mĂŒssen unnötig oft marshalliert und ĂŒber den Netzwerk-Stack ĂŒbertragen werden;
    • Der DomĂ€nendienst gibt oft mehr Daten zurĂŒck, als die GeschĂ€ftslogik fĂŒr die Verarbeitung benötigt, da die Möglichkeiten zur Parameterisierung von Anfragen auf der Ebene der externen API des Dienstes unzureichend sind;
    • Mehrere unabhĂ€ngige Teile der GeschĂ€ftslogik können dieselben Daten zur Verarbeitung erneut anfordern (dieses Problem kann gemildert werden, indem Sitzungs-Komponenten hinzugefĂŒgt werden, die Daten cachen, was jedoch die Architektur zusĂ€tzlich erschwert und Probleme mit der AktualitĂ€t der Daten und der Cache-Invalidierung schafft);
  • Transaktionsprobleme:
    • GeschĂ€ftsprozesse mit persistentem Zustand, dessen Speicherung durch den Plattformdienst erfolgt, können von den Domain-Daten abweichen, und einfache Lösungen fĂŒr dieses Problem sind nicht in Sicht;
    • Auslagerung der Sperrung von Domain-Daten außerhalb der Transaktion: Wenn die Domain-GeschĂ€ftslogik Änderungen vornehmen muss, nachdem die GĂŒltigkeit der aktuellen Daten ĂŒberprĂŒft wurde, muss die Möglichkeit eines konkurrierenden DatenĂ€nderungsprozesses ausgeschlossen werden. Eine externe Daten-Sperrung kann helfen, diese Aufgabe zu lösen, bringt jedoch zusĂ€tzliche Risiken mit sich und beeintrĂ€chtigt die GesamtreliabilitĂ€t des Systems;
  • ZusĂ€tzliche Komplikationen bei der Aktualisierung: In einigen FĂ€llen mĂŒssen der Persistenzdienst und die GeschĂ€ftslogik synchron oder in strenger Reihenfolge aktualisiert werden.

Letztendlich mussten wir zu den Grundlagen zurĂŒckkehren: die Domain-Daten und die Domain-GeschĂ€ftslogik in einen einzigen Microservice zu kapseln. Dieser Ansatz erleichtert die Wahrnehmung des Microservice als ganzheitlichen Bestandteil des Systems und vermeidet die oben genannten Probleme. Dies ist jedoch nicht ohne Kosten verbunden:

  • es wird eine Standardisierung der APIs fĂŒr die Interaktion mit der GeschĂ€ftslogik benötigt (insbesondere zur UnterstĂŒtzung der BenutzeraktivitĂ€ten im Rahmen der GeschĂ€ftsprozesse) und der API-Plattformdienste; es erfordert eine sorgfĂ€ltigere Handhabung von API-Änderungen, sowie der direkten und rĂŒckwĂ€rtskompatibilitĂ€t;
  • es mĂŒssen zusĂ€tzliche Runtime-Bibliotheken hinzugefĂŒgt werden, um den Betrieb der GeschĂ€ftslogik in jedem solchen Microservice zu gewĂ€hrleisten, was neue Anforderungen an diese Bibliotheken mit sich bringt: Leichtgewichtigkeit und minimale transitive AbhĂ€ngigkeiten;
  • Entwickler von GeschĂ€ftslogik mĂŒssen die Versionen von Bibliotheken im Auge behalten: Wenn ein Mikrodienst lĂ€ngere Zeit nicht gewartet wurde, hat er wahrscheinlich veraltete Bibliotheken. Dies kann unerwartete Hindernisse beim HinzufĂŒgen neuer Funktionen schaffen und könnte eine Migration der alten GeschĂ€ftslogik dieses Dienstes auf neue Versionen von Bibliotheken erforderlich machen, wenn zwischen den Versionen inkompatible Änderungen vorgenommen wurden.

Integration im BPM-Stil

Eine Schicht von Plattformdiensten ist in einer solchen Architektur ebenfalls vorhanden, jedoch bildet diese Schicht nicht den Container zur AusfĂŒhrung der domĂ€nenspezifischen GeschĂ€ftslogik, sondern lediglich deren Umgebung, indem sie unterstĂŒtzende „Plattform“-Funktionen bereitstellt. Eine solche Schicht ist notwendig, um nicht nur die Leichtgewichtigkeit der domĂ€nenbasierten Mikrodienste zu bewahren, sondern auch um die Verwaltung zu zentralisieren.

Zum Beispiel erzeugen benutzerdefinierte AktivitÀten in GeschÀftsprozessen Aufgaben. Wenn der Benutzer jedoch mit Aufgaben arbeitet, sollte er alle Aufgaben aus verschiedenen DomÀnen in einer gemeinsamen Liste sehen können. Daher muss es einen entsprechenden Plattform-Service zur Registrierung von Aufgaben geben, der von der spezifischen GeschÀftslogik der DomÀne entkoppelt ist. Die Kapselung der GeschÀftslogik in einem solchen Kontext zu bewahren, ist durchaus problematisch und stellt einen weiteren Kompromiss dieser Architektur dar.

Integration von GeschÀftsprozessen aus der Sicht des Anwendungsentwicklers

Wie bereits erwÀhnt, muss der Anwendungsentwickler von den technischen und ingenieurtechnischen Merkmalen der Interaktion mehrerer Anwendungen abstrahiert werden, um eine hohe ProduktivitÀt der Entwicklung gewÀhrleisten zu können.

Lassen Sie uns versuchen, eine recht anspruchsvolle Integrationsaufgabe zu lösen, die speziell fĂŒr diesen Artikel entworfen wurde. Es wird eine "Spiel"-Aufgabe sein, die drei Anwendungen umfasst, von denen jede einen bestimmten DomĂ€nennamen definiert: "app1", "app2", "app3".

In jeder Anwendung laufen GeschĂ€ftsprozesse, die ĂŒber eine Integrationsbus "Ball spielen". Die Nachrichten mit dem Namen "Ball" ĂŒbernehmen die Rolle des Balls.

Spielregeln:

  • Der erste Spieler ist der Initiator. Er lĂ€dt andere Spieler zum Spiel ein, beginnt das Spiel und kann es jederzeit beenden;
  • Die anderen Spieler erklĂ€ren ihre Teilnahme am Spiel, "lernen" einander und den ersten Spieler kennen;
  • Haben sie den Ball angenommen, 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 mit jedem Pass dieses Spielers abnimmt. Wenn die Energie aufgebraucht ist, scheidet der Spieler aus dem Spiel aus und erklĂ€rt seinen RĂŒckzug;
  • Wenn ein Spieler alleine geblieben ist, erklĂ€rt er sofort seinen RĂŒckzug;
  • Wenn alle Spieler ausgeschieden sind, erklĂ€rt der erste Spieler das Spiel fĂŒr beendet. Hat er zuvor das Spiel verlassen, bleibt er trotzdem als Beobachter, um das Spiel zu beenden.

Um diese Aufgabe zu lösen, werde ich unsere DSL fĂŒr GeschĂ€ftsprozesse verwenden, die es ermöglicht, die Logik in Kotlin kompakt und mit minimalem Boilerplate zu beschreiben.

Im App1 wird der GeschÀftsprozess des ersten Spielers (der gleichzeitig der Initiator des Spiels ist) laufen:

Klasse 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 ist eine Prozessinstanz: 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 Prozessmodell-Deklaration: wird einmal erstellt, von allen
// Instanzen des entsprechenden Klassenprozesses verwendet
val initialPlayerModel = processModel(name = "InitialPlayer",
                                                     version = 1) {

    // Nach den Regeln ist der erste Spieler der Initiator des Spiels und muss der einzige sein
    uniqueConstraint = UniqueConstraints.singleton

    // Wir erklÀren 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} beigetreten ...")
    }
    val waitPlayerOut = signalWait("PlayerOut") { signal ->
        players.remove(PlayerInfo(
                signal.data!!,
                signal.sender.domain,
                signal.sender.processInstanceId))
        println("... Spieler ${signal.data} ist raus ...")
    }
    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 den Prozessgraphen aus den erklÀrten 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)
    }

    // Wir hĂ€ngen zusĂ€tzliche Handler fĂŒr das Logging an die AktivitĂ€ten
    sendNewGameSignal.onExit { println("Lasst uns spielen!") }
    sendStopGameSignal.onExit { println("Stop!") }
    sendPlayerOut.onExit { println("$playerName: Ich bin raus!") }
}

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 AusfĂŒhrung der GeschĂ€ftslogik kann der angegebene Code ein objekthaftes Modell des GeschĂ€ftsprozesses liefern, das in Form eines Diagramms visualisiert werden kann. FĂŒr den Visualisierer haben wir bisher keine Umsetzung realisiert, 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):

Integration im BPM-Stil

Die Anwendung app2 wird den GeschÀftsprozess eines anderen Spielers umfassen:

class RandomPlayer

import ru.krista.bpm.ProzessInstanz
import ru.krista.bpm.runtime.ProzessImpl
import ru.krista.bpm.runtime.dsl.prozessModell
import ru.krista.bpm.runtime.instance.NachrichtSendenInstanz

data class SpielerInfo(val name: String, val domain: String, val id: String)

class SpielerListe: ArrayList<SpielerInfo>()

class ZufallsSpieler : ProzessImpl<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 alleSpielerDraußen: Boolean by persistent(false)
    var schussZĂ€hler: Int = 0

    val eigenerSpieler: SpielerInfo
        get() = SpielerInfo(spielerName, env.eventDispatcher.domainName, id)
}

val zufallsSpielerModell = prozessModell<ZufallsSpieler>(name = "ZufallsSpieler", 
                                                   version = 1) {

    val warteAufNeuesSpielSignal = signalWarten<String>("NeuesSpiel")
    val warteAufStopSpielSignal = signalWarten<String>("StopSpiel")
    val sendeSpielerBeitritt = signal<String>("SpielerBeitritt") {
        signalDaten = { spielerName }
    }
    val sendeSpielerDraußen = signal<String>("SpielerDraußen") {
        signalDaten = { spielerName }
    }
    val warteAufSpielerBeitritt = signalWartenBenutzerdefiniert<String>("SpielerBeitritt") {
        ereignisBedingung = { signal ->
            signal.sender.prozessInstanzId != prozess.id 
                && !prozess.spieler.any { signal.sender.prozessInstanzId == it.id}
        }
        handler = { signal ->
            spieler.add(SpielerInfo(
                    signal.daten!!,
                    signal.sender.domain,
                    signal.sender.prozessInstanzId))
        }
    }
    val warteAufSpielerDraußen = signalWarten<String>("SpielerDraußen") { signal ->
        spieler.remove(SpielerInfo(
                signal.daten!!,
                signal.sender.domain,
                signal.sender.prozessInstanzId))
        alleSpielerDraußen = spieler.isEmpty()
    }
    val sendeHandshake = nachrichtSenden<String>("Handshake") {
        nachrichtDaten = { spielerName }
        aktivierung = {
            empfÀngerDomain = prozess.spieler.last().domain
            empfÀngerProzessInstanzId = prozess.spieler.last().id
        }
    }
    val empfangeHandshake = nachrichtWarten<String>("Handshake") { nachricht ->
        if (!spieler.any { nachricht.sender.prozessInstanzId == it.id}) {
            spieler.add(SpielerInfo(
                    nachricht.daten!!, 
                    nachricht.sender.domain, 
                    nachricht.sender.prozessInstanzId))
        }
    }
    val werfeBall = nachrichtSenden<Int>("Ball") {
        nachrichtDaten = { schussZĂ€hler + 1 }
        aktivierung = { wÀhleNÀchstenSpieler() }
        beimEintritt { energie -= 1 }
    }
    val warteAufBall = nachrichtWartenDaten<Int>("Ball") {
        schussZĂ€hler = it
    }

    starteVon(warteAufNeuesSpielSignal)
            .gabelung("hauptGabelung") {
                nÀchste(sendeSpielerBeitritt)
                        .zweig("hauptSchleife") {
                            fallsWahr { energie < 5 || alleSpielerDraußen }
                                    .nĂ€chste(sendeSpielerDraußen)
                                    .nÀchste(warteAufBall)
                            fallsSonst()
                                    .nÀchste(warteAufBall)
                                    .nÀchste(werfeBall)
                                    .schleife()
                        }
                nÀchste(warteAufSpielerBeitritt).nÀchste(sendeHandshake).nÀchste(warteAufSpielerBeitritt)
                nĂ€chste(warteAufSpielerDraußen).nĂ€chste(warteAufSpielerDraußen)
                nÀchste(empfangeHandshake).nÀchste(empfangeHandshake)
                nÀchste(warteAufStopSpielSignal).beenden()
            }

    sendeSpielerBeitritt.beimVerlassen { println("$spielerName: Ich bin hier!") }
    sendeSpielerDraußen.beimVerlassen { println("$spielerName: Ich bin draußen!") }
}

private fun NachrichtSendenInstanz<ZufallsSpieler, Int>.wÀhleNÀchstenSpieler() {
    val spieler = if (prozess.spieler.isNotEmpty()) 
        prozess.spieler.random() 
    else 
        prozess.eigenerSpieler
    empfÀngerDomain = spieler.domain
    empfÀngerProzessInstanzId = spieler.id
    println("Schritt ${prozess.schussZĂ€hler + 1}: " +
            "${prozess.spielerName} >>> ${spieler.name}")
}

Diagramm:

Integration im BPM-Stil

In der App app3 wird der Spieler ein etwas anderes Verhalten aufweisen: Statt zufÀllig den nÀchsten Spieler auszuwÀhlen, agiert er nach dem Round-Robin-Algorithmus:

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: I'm here!") }
    sendPlayerOut.onExit { println("$playerName: I'm out!") }
}

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("Step ${process.shotCounter + 1}: " +
            "${process.playerName} >>> ${player.name}")
}

Ansonsten unterscheidet sich das Verhalten des Spielers nicht vom vorherigen, daher Àndert sich das Diagramm nicht.

Jetzt ist ein Test erforderlich, um das alles auszufĂŒhren. Ich werde nur den Code des Tests angeben, um den Artikel nicht mit Boilerplate zu ĂŒberladen (tatsĂ€chlich habe ich die zuvor fĂŒr die Integrationstestung anderer GeschĂ€ftsprozesse erstellte Testumgebung 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 ein wenig warten, bis die Spieler sich "kennenlernen".
    // Schlafen durch sleep - eine schlechte Lösung, aber die einfachste. 
    // Machen Sie das nicht in ernsthaften Tests!
    Thread.sleep(1000);
    // Spiel starten und Benutzereingaben 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;
}

Wir starten den Test und schauen uns das Protokoll an:

Konsole Ausgabe

Der SchlĂŒssel lock://app1/process/InitialPlayer wurde gesperrt.
Lass uns spielen!
Der SchlĂŒssel lock://app1/process/InitialPlayer wurde freigegeben.
Spieler2: Ich bin hier!
Spieler3: Ich bin hier!
Spieler4: Ich bin hier!
Spieler5: Ich bin hier!
... Spieler Player2 beitreten ...
... Spieler Player4 beitreten ...
... Spieler Player3 beitreten ...
... Spieler Player5 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 Player2 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 Player4 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 Player5 ist raus ...
Schritt 122: Spieler3 >>> Spieler5
Schritt 123: Spieler5 >>> Spieler1
Spieler5: Ich bin raus!
Schritt 124: Spieler1 >>> Spieler3
... Spieler Player5 ist raus ...
Schritt 125: Spieler3 >>> Spieler1
Schritt 126: Spieler1 >>> Spieler3
Spieler1: Ich bin raus!
... Spieler Player1 ist raus ...
Schritt 127: Spieler3 >>> Spieler3
Spieler3: Ich bin raus!
Schritt 128: Spieler3 >>> Spieler3
... Spieler Player3 ist raus ...
Spieler3: Ich bin raus!
Stop!
Schritt 129: Spieler3 >>> Spieler3
Spieler3: Ich bin raus!

Aus alledem lassen sich mehrere wichtige Schlussfolgerungen ziehen:

  • Mit den richtigen Werkzeugen können Anwendungsentwickler Integrationsinteraktionen zwischen Anwendungen erstellen, 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 integriert wird. Die Schwierigkeit der Aufgabe lĂ€sst sich jedoch nicht verbergen, weshalb die Lösung einer schwierigen Aufgabe im Code entsprechend aussehen wird;
  • Bei der Entwicklung der Integrationslogik muss unbedingt die eventual consistency sowie das Fehlen der LinearitĂ€t in der ZustandsĂ€nderung aller Integrationsbeteiligten berĂŒcksichtigt werden. Dies zwingt dazu, die Logik zu verkomplizieren, um sie unempfindlich gegenĂŒber der Reihenfolge der eintreffenden externen Ereignisse zu gestalten. In unserem Beispiel muss der Spieler erst an dem Spiel teilnehmen, nachdem er seinen RĂŒcktritt erklĂ€rt hat: andere Spieler werden weiterhin den Ball zu ihm passen, bis die Information ĂŒber seinen RĂŒcktritt alle Beteiligten erreicht und verarbeitet ist. Diese Logik ergibt sich nicht aus den Spielregeln und ist ein Kompromiss innerhalb der gewĂ€hlten Architektur.

Als NÀchstes werden wir verschiedene Feinheiten unserer Lösung, Kompromisse und andere Aspekte besprechen.

Alle Nachrichten – in einer Warteschlange

Alle integrierbaren Anwendungen arbeiten mit einer integrierten Schnittstelle, die als externer Broker, einer BPMQueue fĂŒr Nachrichten und einem BPMTopic fĂŒr Signale (Ereignisse) dargestellt wird. Das Durchleiten aller Nachrichten ĂŒber eine einzige Warteschlange ist in der Tat ein Kompromiss. Auf der Ebene der GeschĂ€ftslogik können nun beliebig viele neue Nachrichtentypen eingefĂŒhrt werden, ohne die Struktur des Systems zu Ă€ndern. Das ist eine erhebliche Vereinfachung, bringt jedoch auch gewisse Risiken mit sich, die uns im Kontext unserer typischen Aufgaben nicht allzu bedeutend erschienen.

Integration im BPM-Stil

Es gibt jedoch einen wichtigen Punkt: Jede Anwendung filtert ihre "eigenen" Nachrichten bereits beim Eingang 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 soll. Dies sollte die DurchsatzkapazitĂ€t der Schnittstelle erhöhen, aber die GeschĂ€ftslogik muss jetzt mit DomĂ€nennamen umgehen: FĂŒr die Adressierung von Nachrichten ist dies erforderlich, fĂŒr Signale wĂŒnschenswert.

GewÀhrleistung der ZuverlÀssigkeit der Schnittstelle

Die ZuverlÀssigkeit setzt sich aus mehreren Aspekten zusammen:

  • Der ausgewĂ€hlte Nachrichtenbroker ist ein kritischer Bestandteil der Architektur und ein Einzelpunkt des Fehlers: Er muss ausreichend ausfallsicher sein. Es sollten nur bewĂ€hrte Implementierungen mit solidem Support und einer großen Community verwendet werden;
  • Es muss eine hohe VerfĂŒgbarkeit des Nachrichtenbrokers gewĂ€hrleistet sein, wofĂŒr er physisch von den integrierten Anwendungen getrennt sein muss (eine hohe VerfĂŒgbarkeit von Anwendungen mit geschĂ€ftlicher Logik ist wesentlich komplizierter und teurer sicherzustellen);
  • Der Broker muss Garantien fĂŒr die Zustellung "at least once" bieten. Dies ist eine zwingende Anforderung fĂŒr einen zuverlĂ€ssigen Betrieb des Integrationsbusses. Bei Garantien der Ebenen "exactly once" besteht keine Notwendigkeit: GeschĂ€ftsprozesse sind in der Regel nicht empfindlich gegenĂŒber der Mehrfachanlieferung von Nachrichten oder Ereignissen, und bei speziellen Aufgaben, in denen dies wichtig ist, ist es einfacher, eine zusĂ€tzliche ÜberprĂŒfung in die GeschĂ€ftslogik einzufĂŒgen, als stĂ€ndig ausreichend "teure" Garantien zu verwenden;
  • Die Sendung von Nachrichten und Signalen sollte in die gesamte Transaktion zur ZustandsĂ€nderung der GeschĂ€ftsprozesse und der DomĂ€nendaten einbezogen werden. Bevorzugt wĂ€re der Einsatz eines Musters Transaktionale Outbox, aber es erfordert eine zusĂ€tzliche Tabelle in der Datenbank und einen Repeater. In JEE-Anwendungen kann dieser Aspekt durch die Verwendung eines lokalen JTA-Managers vereinfacht werden, jedoch muss die Verbindung zu dem ausgewĂ€hlten Broker im Modus arbeiten können XA;
  • Eingehende Nachrichten und Ereignisse mĂŒssen ebenfalls mit der Transaktion zur Änderung des GeschĂ€ftsprozesszustands arbeiten: Wenn eine solche Transaktion zurĂŒckgesetzt wird, muss auch der Empfang der Nachricht storniert werden;
  • Nachrichten, die aufgrund von Fehlern nicht zugestellt werden konnten, mĂŒssen in einem separaten Speicher abgelegt werden DLQ (Dead Letter Queue). Zu diesem Zweck haben wir einen separaten plattformbasierten Mikrodienst erstellt, der solche Nachrichten in seinem Speicher speichert, sie nach Attributen indiziert (fĂŒr schnelle Gruppierung und Suche) und eine API fĂŒr die Anzeige, das erneute Senden an die Zieladresse und das Löschen von Nachrichten bereitstellt. Systemadministratoren können ĂŒber ihre Webschnittstelle mit diesem Dienst arbeiten;
  • In den Broker-Einstellungen sollte die Anzahl der Wiederholungsversuche und die Verzögerungen zwischen den Lieferungen angepasst werden, um die Wahrscheinlichkeit zu verringern, dass Nachrichten in die DLQ gelangen (die optimalen Parameter zu berechnen, ist praktisch unmöglich, aber man kann empirisch handeln und sie wĂ€hrend des Betriebs anpassen);
  • Der DLQ-Speicher sollte kontinuierlich ĂŒberwacht werden, und das Überwachungssystem sollte die Systemadministratoren benachrichtigen, damit sie so schnell wie möglich auf unzustellbare Nachrichten reagieren können. Dies wird helfen, die "Zonen des Einflusses" eines aufgetretenen Fehlers oder einer GeschĂ€ftslogik-Fehlfunktion zu verringern;
  • Die Integrationsbus sollte unempfindlich gegenĂŒber vorĂŒbergehenden AusfĂ€llen von Anwendungen sein: die Abonnements fĂŒr das Thema sollten dauerhaft sein, und der Anwendungsname sollte einzigartig sein, damit wĂ€hrend der Abwesenheit der Anwendung ihre Nachrichten aus der Warteschlange nicht von jemand anderem verarbeitet werden.

GewÀhrleistung der Thread-Sicherheit der GeschÀftslogik

Einzelne Instanzen von GeschĂ€ftsprozessen können mehrere Nachrichten und Ereignisse gleichzeitig empfangen, deren Verarbeitung parallel gestartet wird. FĂŒr den Anwendungsentwickler muss alles jedoch einfach und thread-sicher sein.

Die GeschÀftslogik des Prozesses verarbeitet jedes externe Ereignis, das diesen GeschÀftsprozess beeinflusst, einzeln. Solche Ereignisse können sein:

  • der Start einer Instanz des GeschĂ€ftsprozesses;
  • eine Benutzeraktion, die sich auf eine AktivitĂ€t innerhalb des GeschĂ€ftsprozesses bezieht;
  • der Eingang einer Nachricht oder eines Signals, auf das die Instanz des GeschĂ€ftsprozesses aboniert ist;
  • das Auslösen eines von der Instanz des GeschĂ€ftsprozesses festgelegten Timers;
  • steuernde Eingriffe ĂŒber die API (z. B. ein Notstop des Prozesses).

Jedes Ereignis kann den Zustand einer Instanz des GeschÀftsprozesses Àndern: Einige AktivitÀten können beendet und andere gestartet werden, und die Werte persistenter Eigenschaften können sich Àndern. Der Abschluss einer AktivitÀt kann die Aktivierung einer oder mehrerer nachfolgender AktivitÀten zur Folge haben. Diese können wiederum auf das Eintreffen anderer Ereignisse warten oder, falls keine zusÀtzlichen Daten benötigt werden, im gleichen Transaktionsrahmen abgeschlossen werden. Vor dem Abschluss der Transaktion wird der neue Zustand des GeschÀftsprozesses in der Datenbank gespeichert, wo er auf das Eintreffen des nÀchsten externen Ereignisses warten wird.

Persistente Daten des GeschĂ€ftsprozesses, die in eine relationale Datenbank gespeichert sind, bieten einen praktischen Synchronisationspunkt fĂŒr die Verarbeitung, wenn SELECT FOR UPDATE verwendet wird. Wenn es einer Transaktion gelingt, den Zustand des GeschĂ€ftsprozesses aus der Datenbank abzurufen, um ihn zu Ă€ndern, kann keine andere Transaktion parallel denselben Zustand fĂŒr eine andere Änderung abrufen. Nach Abschluss der ersten Transaktion erhĂ€lt die zweite Transaktion garantiert den bereits geĂ€nderten Zustand.

Durch den Einsatz von pessimistischen Sperren auf Datenbankseite erfĂŒllen wir alle notwendigen Anforderungen ACID, wĂ€hrend wir gleichzeitig die Möglichkeit zur Skalierung der Anwendung mit GeschĂ€ftslogik durch Erhöhung der Anzahl der gestarteten Instanzen beibehalten.

Allerdings birgt die Verwendung pessimistischer Sperren die Gefahr von Deadlocks. Daher sollte SELECT FOR UPDATE tatsĂ€chlich mit einem vernĂŒnftigen Timeout begrenzt werden, um im Falle von Deadlocks in offensichtlichen FĂ€llen in der GeschĂ€ftslogik gewappnet zu sein.

Ein weiteres Problem ist die Synchronisation des Starts von GeschĂ€ftsprozessen. Solange keine Instanz des GeschĂ€ftsprozesses existiert, gibt es auch keinen Status in der Datenbank, weshalb die beschriebene Methode nicht geeignet ist. Wenn die Einzigartigkeit der Instanz des GeschĂ€ftsprozesses in einem bestimmten Scope gewĂ€hrleistet werden muss, ist ein Synchronisationsobjekt erforderlich, das mit der Prozessklasse und dem entsprechenden Scope assoziiert ist. Um dieses Problem zu lösen, verwenden wir einen anderen Sperrmechanismus, der es ermöglicht, eine Sperre auf eine beliebige Ressource zu setzen, die ĂŒber einen URI-SchlĂŒssel definiert ist, ĂŒber einen externen Dienst.

In unseren Beispielen enthÀlt der GeschÀftsprozess InitialPlayer die Deklaration

uniqueConstraint = UniqueConstraints.singleton

Daher erscheinen im Protokoll Nachrichten ĂŒber das Sperren und Freigeben des entsprechenden SchlĂŒssels. Bei anderen GeschĂ€ftsprozessen gibt es solche Nachrichten nicht: uniqueConstraint ist nicht festgelegt.

Probleme von GeschÀftsprozessen mit persistentem Zustand

Manchmal hilft ein persistenter Zustand nicht nur, sondern hindert auch die Entwicklung erheblich.
Probleme treten auf, 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 viele Probleme verursachen, mit denen wir oft bei der Verwendung von jBPM konfrontiert waren.

Je nach Tiefe der Änderungen gibt es zwei Möglichkeiten:

  1. einen neuen Typ von GeschĂ€ftsprozess zu erstellen, um keine inkompatiblen Änderungen am alten vorzunehmen und ihn bei der AusfĂŒhrung neuer Instanzen anstelle des alten zu verwenden. Die alten Instanzen werden weiterhin „wie gewohnt“ arbeiten;
  2. den persistenten Zustand der GeschÀftsprozesse bei der Aktualisierung der GeschÀftslogik zu migrieren.

Der erste Ansatz ist einfacher, hat jedoch seine EinschrÀnkungen und Nachteile, zum Beispiel:

  • die doppelte GeschĂ€ftslogik in vielen GeschĂ€ftsprozessmodellen, was zu einem Anstieg des Umfangs der GeschĂ€ftslogik fĂŒhrt;
  • hĂ€ufig ist ein sofortiger Übergang zur neuen GeschĂ€ftslogik erforderlich (insbesondere bei Integrationsaufgaben – nahezu immer);
  • der Entwickler weiß nicht, wann veraltete Modelle gelöscht werden können.

In der Praxis verwenden wir beide AnsÀtze, haben jedoch eine Reihe von Entscheidungen getroffen, um uns das Leben zu erleichtern:

  • In der Datenbank wird der persistente Zustand des GeschĂ€ftsprozesses in einem leicht lesbaren und leicht verarbeitbaren Format gespeichert: in einer JSON-Zeile. Dies ermöglicht Migrationen sowohl innerhalb der Anwendung als auch extern. Im Ă€ußersten Fall kann man auch manuell nachhelfen (besonders nĂŒtzlich wĂ€hrend der Entwicklung und beim Debugging);
  • Die IntegrationsgeschĂ€ftslogik verwendet keine Namen von GeschĂ€ftsprozessen, sodass jederzeit die Implementierung eines der beteiligten Prozesse durch 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. Diese Nummer wird zusammen mit dem Status der Prozesseinstanz gespeichert.
  • Der persistente Status des Prozesses wird zunĂ€chst in ein praktisches Objektmodell aus der Datenbank eingelesen, mit dem die Migrationsprozedur arbeiten kann, wenn sich die Versionsnummer des Modells geĂ€ndert hat.
  • Die Migrationsprozedur wird in der NĂ€he der GeschĂ€ftslogik platziert und „faul“ fĂŒr jede Instanz des GeschĂ€ftsprozesses zum Zeitpunkt ihrer Wiederherstellung aus der Datenbank aufgerufen.
  • Wenn der Status aller Prozesseinstanzen schnell und synchron migriert werden muss, kommen klassischere Datenbankmigrationstechniken zum Einsatz, wobei jedoch mit JSON gearbeitet werden muss.

Wird ein weiteres Framework fĂŒr GeschĂ€ftsprozesse benötigt?

Die in dem Artikel beschriebenen Lösungen haben es uns ermöglicht, unser Leben erheblich zu erleichtern, den Kreis der Fragen, die auf der Ebene der Anwendungsentwicklung gelöst werden, zu erweitern und Ideen zur Trennung der GeschĂ€ftslogik in Microservices attraktiver zu gestalten. DafĂŒr wurde viel Arbeit geleistet, ein sehr ‚leichtgewichtiger‘ Framework fĂŒr GeschĂ€ftsprozesse erstellt sowie Dienstkomponenten zur Lösung der genannten Probleme im Kontext einer breiten Palette von Anwendungsaufgaben entwickelt. Wir möchten diese Ergebnisse gerne teilen und die Entwicklung gemeinsamer Komponenten unter einer freien Lizenz veröffentlichen. Dies wird einige Anstrengungen und Zeit erfordern. 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 den Möglichkeiten des Frameworks nur wenig Aufmerksamkeit geschenkt, aber einige von ihnen sind aus den vorgestellten Beispielen ersichtlich. Falls wir unser Framework dennoch veröffentlichen, wird es einen eigenen Artikel dazu geben. Bis dahin wĂ€ren wir dankbar, wenn Sie uns ein kleines Feedback hinterlassen und die Frage beantworten:

Nur registrierte Benutzer können an der Umfrage teilnehmen. Bitte melden Sie sich an.Sind Sie an Contour interessiert?

Brauchen wir noch ein weiteres Framework fĂŒr GeschĂ€ftsprozesse?

  • 18,8%Ja, wir suchen schon lange nach etwas Ähnlichem.

  • 12,5%ich wĂŒrde gerne mehr ĂŒber Ihre Implementierung erfahren, das könnte nĂŒtzlich sein

  • 6,2%wir verwenden eines der bestehenden Frameworks, denken aber ĂŒber einen Wechsel nach

  • 18,8%wir verwenden eines der bestehenden Frameworks, sind damit zufrieden

  • 18,8%wir kommen ohne Framework aus

  • 25,0%wir entwickeln unser eigenes

16 Nutzer haben abgestimmt. 7 Nutzer haben sich enthalten.

Quelle: habr.com

Kaufen Sie zuverlĂ€ssiges Hosting fĂŒr Websites mit DDoS-Schutz, VPS VDS-Servern đŸ”„ Kaufen Sie zuverlĂ€ssiges Hosting fĂŒr Websites mit DDoS-Schutz, VPS VDS-Servern | ProHoster