16. Aug. 2021

Camunda und Kotlin – perfekte Symbiose?

Die Vorteile von Camunda und Kotlin ergänzen sich in der Theorie perfekt. Wie sieht das in der Paxis aus? Ein Beispiel.
Managing Consultant

Autor:in

Martin Miro Mrsic

20210816_Camunda_Preview

Implementierung eines Beispielprozesses mit Camunda, Kotlin und Spring Boot

Die Vorteile von Camunda und Kotlin sollten sich der Theorie nach perfekt ergänzen und somit ein erfolgreiches Arbeiten an Projekten ermöglichen. Genau dies soll anhand eines Beispiels evaluiert werden.

Camunda

Die Camunda Platform bildet ein mächtiges und zugleich leichtgewichtiges Werkzeug zur Automatisierung von Workflow- und Entscheidungsprozessen. Mit seiner weitreichenden Open-Source-Lizenz ist es auch für Evaluierungen oder ein prototypisches Vorgehen prädestiniert.

Mit dem Camunda Modeler lassen sich Prozesse und Entscheidungstabellen in den weitverbreiteten Standards BPMN und DMN definieren und mit Camunda Platform Run unkompliziert ausführen. Mit dem Initializr sind auf Maven basierende Entwicklungs-Projekte mühelos eingerichtet.

Zudem verspricht Camunda durch die offene und skalierbare Architektur, die REST-Schnittstelle und die einfache Integrierbarkeit von beliebten Frameworks wie z. B. Spring vielfältige Einsatzmöglichkeiten auch bei Anbindung von Legacy-Systemen

Kotlin

Kotlin bietet durch die Nutzung der JVM eine sehr gute Integration mit bestehendem Java-Code. Interessante Spracheigenschaften wie die Co-Routinen sowie kompakter Code schaffen zudem optimale Voraussetzungen zum Einsatz in jeder Art von Projekt von Proof-of-Concept- bis hin zu Microservices-Anwendungen beliebiger Größe.

Zusammenspiel

An einem Beispielprozess soll evaluiert werden, ob sich die oben beschriebenen Vorteile in der Praxis bestätigen.

Der Beispielprozess: Einkaufen mit Camunda

Der verwendete Beispielprozess soll auch anspruchsvollere Konzepte wie bspw. eingebettete Subprozesse samt Fehlerbehandlung verwenden, zugleich aber auch einfach zu verstehen sein. Die Wahl fiel auf die Modellierung des Prozesses zum Einkaufen von Waren durch eine Person in einem Laden.

Dazu trifft die Person Vorbereitungen für den Einkauf, indem sie die Einkaufsliste erstellt, über das Zahlungsmittel und ggf. über das Pfand für den Einkaufswagen entscheidet und jeweils zur Verwendung bereitlegt.

Erst anschließend erfolgt die Durchführung des Einkaufs indem ggf. ein Einkaufswagen benutzt wird, die gewünschten Waren gesucht, ausgewählt und den Regalen entnommen werden. Schließlich dürfen die Waren bezahlt werden und ggf. wird noch eine neue Einkaufsliste erstellt, falls nicht alle Waren erworben werden konnten.

Die grobe Aufteilung dieses Einkaufsprozesses ist in Abbildung 1 dargestellt: Wenn die Bedingung für einen Einkauf gegeben ist, wird dieser zunächst vorbereitet und anschließend durchgeführt, falls die Vorbereitung fehlerlos durchgeführt werden konnte.

20210816_Camunda_Beispielprozess im Ueberblick

Neben dem erfolgreichen Ende des Prozesses (Endzustand Shopping completed) sind drei weitere Endzustände vorgesehen:

  1. Die Vorbereitung des Einkaufs kann scheitern und der Prozess daher im Endzustand Shopping preparation failed
  2. Die eigentliche Durchführung des Einkaufs kann scheitern (Endzustand Shopping failed).
  3. Der Einkauf kann abgeschlossen werden, jedoch ohne einige der ursprünglich gewünschten Waren erworben zu haben (Endzustand Still missing goods).

Im bewährten Top-Down-Verfahren werden die beiden abstrakten Schritte im Prozess im Folgenden als Sub-Prozesse einzeln definiert.

Einkaufsvorbereitung

Die Einkaufsvorbereitung zeichnet sich durch das Erstellen einer Einkaufsliste und das Vorbereiten des Zahlungsmittels aus. Optional kann zusätzlich auch das Vorbereiten für das Pfand eines Einkaufswagens hinzukommen. Bei den Vorbereitungsschritten fürs Zahlungsmittel und das Pfand kann es jeweils zu Fehlern kommen, die an den Vaterprozess propagiert werden und dort zum Endzustand Shopping preparation failed führen.

20210816_Camunda_Einkaufsvorbereitung als BPMN Ansicht

Die einzelnen Aktivitäten des Sub-Prozesses wurden im Beispiel als Servicetasks realisiert, um eine flexible Implementierung zu ermöglichen.

Folgende Prozessvariablen werden in der Vorbereitungsphase erstellt oder geändert:

Prozessschritt Variablenname und -typ Variablenbedeutung
Create shopping list shoppingList List<String> Liste mit Namen der zu kaufenden Waren
shoppingCartNeeded
Boolean
Ob ein Einkaufswagen notwendig oder gewünscht ist, um die Waren zu transportieren
Prepare means of payment meansOfPaymentOptions
List<Enum<MeansOfPayment>
Von der kaufenden Person vorbereitete Bezahlungsoptionen
Prepare shopping cart deposit shoppingCartDeposit
ShoppingCartDeposit
Vorbereiteter Einkaufswagenpfand (Geldstück, Chip, …)


Einkaufsdurchführung

Beim Durchführen des Einkaufs nimmt die handelnde Person ggf. den Einkaufswagen, sucht die Waren zusammen und zahlt diese anschließend. Sollten dabei unüberbrückbare Schwierigkeiten auftreten, würde der Einkauf abgebrochen und der Fehlerendzustand Cancel shopping des Sub-Prozesses führte schließlich dazu, den Gesamtprozess im Zustand Shopping failed enden zu lassen.

Ist der Einkauf hingegen erfolgreich, wird nach dem Bezahlen geprüft, ob auch alle Waren von der Einkaufsliste erworben werden konnte. Falls dies nicht der Fall sein sollte, wird eine neue Liste erstellt, welche die von der ursprünglichen Liste nicht erworbenen Waren enthält.

Dieser Umstand wird über den Subprozess-Endzustand Missing goods eskaliert und führte im Prozess schließlich zum Endzustand Still missing goods, was an alle interessierten (anderen) Prozesse signalisiert wird. Sind hingegen alle Waren erworben worden, endet der Prozess in Shopping completed.

20210816_Camunda_Einkaufsdurchfuehrung als BPMN Ansicht

Abbildung 3 zeigt den zweiten Subprozess Perform shopping mit seinen Detailschritten: Alle Aktivitäten sind für die Implementierung als Servicetasks gekennzeichnet worden und folgende Prozessvariablen werden erstellt, geändert oder gelesen (kursiv):

Prozessschritt Variablenname Variablenbedeutung
Take shopping cart shoppingCartDeposit Vorbereiteter Einkaufswagenpfand (s.o.)
Choose goods shoppingList Einkaufsliste (s.o.)
goods List<String> Derzeit mitgeführte Waren im Laden/an der Kasse
Pay goods goods s.o.
meansOfPaymentOptions Von der kaufenden Person akzeptierte Formen der Bezahlung
meansOfPayment List<Enum<MeansOfPayment> Durchgeführte Form der Bezahlung
bill String Rechnung für die gekauften Waren
allGoodsBought
Boolean
Ob alle Waren der Einkaufsliste gekauft wurden
Create new shopping list shoppingList List<String> Liste der Waren, die nach Abschluss des Einkaufs weiterhin gekauft werden sollen

Der so mit dem Camunda Modeler erstellte Prozess kann direkt mit Camunda Platform Run ausgeführt werden. Wenn – wie im Beispiel – allerdings Prozessvariablen verwendet werden, sind diese bei einem solchen Start nicht definiert und es kommt zur entsprechenden Fehlermeldung.

Aus diesem Grund werden im Folgenden Beispiel-Implementierungen für die im Prozess referenzierten Service-Tasks vorgenommen, welche die notwendigen Prozessvariablen mit validen Werten füllen.

Die Beispielimplementierung: Kotlin, die Schöne

In einem Projekt wird klassischerweise für jeden Service-Task eine Klasse implementiert, die in der einzigen Methode der JavaDelegate-Schnittstelle den vom Camunda-Code komplett abgekoppelten (und per CDI-Framework injizierten) Business-Service aufruft. Dadurch werden Abhängigkeiten der Business-Implementierung zum Camunda-Framework vermieden.

Für die Implementierung in diesem Beispielprojekt wurde folgender pragmatischer Ansatz gewählt: Die Camunda Delegate Expressions wurden als Named-Annotations an Kotlin-Implementierungsklassen von Kotlin-Interfaces realisiert, wobei jedes der Interfaces von JavaDelegate abgeleitet wurde. Dadurch ist zwar eine Abhängigkeit der „Business“-Klasse zum Camunda-Framework vorhanden, diese fällt aber nicht ins Gewicht, da die Implementierungen der Klassen dieses Beispiels naturgemäß eher Spielcharakter haben.

Nachfolgend die Implementierung für die zufällige Auswahl eines Einkaufswagenpfands aus einer vorgegebenen Menge. Dazu wurden Optionen in Form von Chips oder Münzen definiert. Wegen der Implementierung von Serializable kann Camunda die eingebaute Java-Serialisierung verwenden und es sind keine zusätzlichen Serialisierungs-Bibliotheken während der Laufzeit notwendig.

/** Abstract super-class for all shopping cart deposit variants. */
sealed class ShoppingCartDeposit(open val diameter: Double, open val thickness: Double) : Serializable


/** A chip representing a [ShoppingCartDeposit]. */
data class ShoppingCartChip(val color: Color, override val diameter: Double, override val thickness: Double) :
ShoppingCartDeposit(diameter, thickness)


/** A coin which may be used as [ShoppingCartDeposit]. */
data class Coin(val value: Int, val unit: String, override val diameter: Double, override val thickness: Double) :
ShoppingCartDeposit(diameter, thickness)


Durch die Benennung der Methodenparameter kann der Code in Kotlin sehr leserlich gestaltet werden und auch die Variation der Parameterreihenfolge ist kein Problem:

/** All supported shopping cart deposit options. */
val cartDepositOptions = listOf(
ShoppingCartChip( Color.YELLOW, diameter = 23.25, thickness = 2.0),
ShoppingCartChip( Color.RED, diameter = 24.0, thickness = 2.21),
Coin(1, "Euro", diameter = 23.25, thickness = 2.33),
Coin(50, "Eurocent", thickness = 2.38, diameter = 24.25)
)


Auch die zufällige Auswahl einer der Optionen für das Pfand kann (an-)sprechend implementiert werden:

/** A dummy [PrepareShoppingCartDepositTask] filling [Process.Variables.CART_DEPOSIT] with a random value. */
@Named("prepareShoppingCartDepositTask")
class PrepareRandomShoppingCartDeposit : PrepareShoppingCartDepositTask {

override fun execute(execution: DelegateExecution) {
execution.setVariable( Process.Variables.CART_DEPOSIT, cartDepositOptions.random())
}
}


Bei der Behandlung von Null-Werten kann der Kotlin-Compiler zunächst anhand der JavaDelegate-Methode execute nicht selbstständig entscheiden, dass der Parameter execution von der Engine immer gesetzt wird und ein Null-Wert für den Parameter somit nicht zulässig ist. Beim Implementieren sollte darauf geachtet werden, dass als Typ des Parameters DelegationExecution anstelle von DelegationExecution? verwendet wird.

Nachdem für alle im Prozess referenzierten Servicetasks derartige Beispielimplementierungen erstellt wurden, kann die Applikation z. B. mittels Spring Boot hochgefahren und im Camunda-Cockpit anschließend eine Prozessinstanz gestartet werden.

Im Log der Anwendung erscheinen bei der Beispielimplementierung bspw. Ausgaben wie folgt für einen Prozesslauf, der erfolgreich abgeschlossen wurde:

INFO Created shopping list: [Pencil, Stock pot, Fork, Tomatoes, Table]
INFO Shopping cart needed: true
INFO Prepared payment options: [CASH, CREDIT_CARD]
INFO Prepared shopping cart deposit: Coin(value=50, unit=Eurocent, diameter=24.25, thickness=2.38)
INFO Shopping cart mandatory because of pandemic restrictions: false
INFO Shopping cart taken
INFO Found item 'Pencil' in store, now carrying: [Pencil]
INFO Found item 'Stock pot' in store, now carrying: [Pencil, Stock pot]
INFO Found item 'Fork' in store, now carrying: [Pencil, Stock pot, Fork]
INFO Found item 'Tomatoes' in store, now carrying: [Pencil, Stock pot, Fork, Tomatoes]
INFO Found item 'Table' in store, now carrying: [Pencil, Stock pot, Fork, Tomatoes, Table]
INFO Paying goods [Pencil, Stock pot, Fork, Tomatoes, Table] with CREDIT_CARD
INFO Got bill: Bill #544908117
INFO All goods bought: true

Bei mehreren Läufen variieren die gesetzten Werte der Prozessvariablen und damit manchmal auch der Weg entlang der BPM-Aktivitäten und das Endergebnis der jeweiligen Prozessinstanzen.

Das Testen: Szenarien, Szenarien

Sowohl Camunda als auch Spring und Spring Boot stellen mehrere und teilweise umfangreiche Hilfsbibliotheken zur Verfügung, die die Erstellung von Testfällen unterstützen.

Fürs die vorliegenden Beispielapplikation wurden zwei Ansätze von Test-Scopes verfolgt:

  1. Tests auf Camunda-Prozess-Ebene, wobei die vom Prozess referenzierten Servicetasks allesamt durch Mockito-Mocks ersetzt werden.
  2. Test für alle Schichten der Applikation inklusive Spring-Applikationskontext, in Spring Boot einfach ausführbar mittels der Annotation @SpringBootTest.

Beim ersten Ansatz können die Mocks flexibel konfiguriert werden und auch besondere Randbereiche des Wertespektrums sowie die Reaktion auf Exceptions getestet werden. Der Nachteil ist, dass nicht die wahre Implementierung getestet wird, sondern die spezifizierte Logik (oder das, was davon verstanden wurde).

Der zweite Ansatz nutzt die auch in Produktion verwendete Logik, kann aber u. U. langsamer sein und weniger flexibel.

Für beide Ansätze wurde die Camunda-Extension Camunda Platform Scenario verwendet, um die Prozessinstanzen zu starten.

Im Prozesstest lassen sich im Zusammenspiel mit Mockito ansprechende Testfälle schreiben, hier ein kurzes Beispiel:


/** Test whether starting the shopping process with valid default values results in a final BPMN state
* representing a success. */
@Test
fun testScenarioShoppingListEmpty() {
Scenario.run(shoppingProcess).startByKey( Process.NAME, Process.Variables.DEFAULT_VALUES).execute()
verify(shoppingProcess).hasCompleted( Process.ActivityIds.COMPLETED)
}


Für die Spring-Boot-Testfälle wurden die BPMN-Assert-Bibliothek verwendet, welche das Verhalten einer ausgeführten Prozessinstanz detailliert abfragen kann:

@Test
fun testStartProcessWithCartNeededButNotMandatory() {
val processInstance: ProcessInstance = processEngine.runtimeService.startProcessInstanceByKey(
Process.NAME, with(Variables) { mapOf(CART_NEEDED to true, CART_MANDATORY to false) }
)
assertThat(processInstance).isEnded
assertThat(processInstance).hasNotPassed(ActivityIds.SHOPPING_FAILED)
assertThat(processInstance).hasVariables(*Variables.ALL.toTypedArray())
}


Alles in allem gelingt die Erstellung der Testfälle schnell und mit den verwendeten Bibliotheken derart, dass sie gut lesbar und damit auch noch nach längerer Zeit und für neue Projektpersonen verständlich sind. Es ist allerdings darauf zu achten, dass die Mock-Instanzen nach einem Test auch wieder sauber aus der Umgebung entfernt werden. Fehler hierbei können sich an ganz anderer Stelle der Testausführung mit unverständlichen Exceptions äußern.

Evaluierung

Das Verbinden von Camunda und Kotlin machte – wie zu erwarten war – in diesem Beispielprojekt keinerlei Probleme. Unter Beachtung der Trennung zwischen den Aufgaben a) Camunda-Anbindung und b) Implementierung der Business-Logik, stehen so bei der Realisierung der Anbindungsschicht vielfältige Optionen bei der Kombination aus Java- und Kotlinklassen offen.

Es ist sowohl möglich, die Anbindung mit Java-Code zu realisieren als auch mit Kotlin-Code. Die aufgerufenen Service-Klassen können sowohl aus beiden JVM-Sprachen stammen als auch aus einer Bibliothek aus dem Klassenladepfad.

Beim Schreiben von Regressionstests mit Kotlin lassen sich die existierenden speziellen Java-Bibliotheken für Camunda-Assertions nutzen und zusätzlich die erweiterten Sprachfeatures von Kotlin wie z. B. Domain Specific Languages.

Fazit

Die Verwendung von Kotlin in der Anbindung der Camunda-Prozess-Engine an Business-Logik ist problemlos und intuitiv durchführbar.

Je schlanker diese Anbindungsschicht ist, desto weniger kommt die Kompaktheit von Kotlin-Code zu tragen. Da dies eine wünschenswerte Eigenschaft dieser Komponente ist, erscheint es nicht offensichtlich, Kotlin anstelle von Java zu verwenden.

Dennoch kann es sinnvoll sein, Kotlin zu bevorzugen, insbesondere natürlich wenn die einzubindenden Services bereits (teilweise) in Kotlin geschrieben sind.

Liegen diese jedoch ausschließlich in Java-Implementierungen vor und ist auch nicht geplant, in Zukunft Kotlin zu nutzen, spricht höchstens noch eine umfangreiche Testfall-Sammlung für die Anbindungsschicht für die Nutzung von Kotlin.

Der gesamte Code für die Beispielimplementierung steht in GitHub zur Verfügung.