Camunda Business Process Management: DMN extended!

Keine Kommentare

Camunda bietet mit seiner Business Process Management Suite eine leichtgewichtige Open-Source-Plattform zur Modellierung und Automatisierung von Geschäftsprozessen. Hierfür bieten die Macher nicht nur ein rudimentäres, leicht und intuitiv zu bedienendes Modellierungswerkzeug an, sondern liefern gleich auch die entsprechende Workflow Engine mit. Unterstützt wird die weit verbreitete und bekannte Modellierungssprache BPMN (Business Process Model and Notation). Diese zeichnet sich nicht nur durch eine konkrete Syntax, d.h. eine für Menschen lesbare Darstellungsform aus, sondern ist auch von Grund auf für die maschinelle Ausführbarkeit konzipiert worden. Die angebotenen Sprachfeatures erlauben die Definition von Prozessmodellen jeglicher Komplexität und lassen hinsichtlich der Ausgestaltung kaum Wünsche offen. Sollen innerhalb eines Prozesses komplexe Entscheidungen abgebildet werden, bietet Camunda auch hierfür eine Lösung: eine Decision Engine, die als Eingabe ein DMN-Modell (Decision Model and Notation) erwartet. Hierbei handelt es sich um eine Entscheidungstabelle, über die sich auf Basis definierbarer Eingabe-Faktoren ein oder mehrere Ausgaben spezifizieren lassen. Die Definition der verschiedenen Faktoren ist auf Datentypen wie beispielsweise String, Integer, Boolean oder Date beschränkt. Obwohl für diese Datentypen eine Reihe an Vergleichsoperationen angeboten werden, kann es dennoch vorkommen, dass diese für den gegebenen Anwendungsfall nicht ausreichend sind.

Auch hierfür bietet Camunda eine Lösung: Innerhalb von DMN-Modellen kann mithilfe der Auszeichnungssprache FEEL (Friendly Enough Expression Language) eine Funktion vor der Evaluation einzelner Felder in einer Entscheidungstabelle ausgewertet werden. Neben den von Camunda angebotenen Funktionen existiert eine Schnittstelle, um weitere, eigene FEEL-Funktionen zu definieren. Ziel dieses Blogbeitrags ist es, eine eigene Funktion zur Evaluierung in einem DMN-Modell zu implementieren. Die notwendigen Schritte werden einzeln vorgestellt und mit Code-Auszügen veranschaulicht.

Camunda BPM DMN: Initiales Projekt-Setup

Zunächst benötigt man ein Projekt, das die Camunda-Umgebung enthält und konfiguriert. Im folgenden Beispiel wird als Basis ein Spring-Boot-Projekt mit Gradle und Kotlin über den Spring Initializr erstellt und als einzige Abhängigkeit die “H2 Database” angegeben, da Camunda den jeweiligen Workflow State in eine relationale Datenbank schreibt und ohne nicht lauffähig ist. Das Projekt und der in diesem Beitrag gezeigte Code sind auf GitHub hinterlegt.

Wurde das Projekt erzeugt und heruntergeladen, müssen in der build.gradle.kts-Datei noch die für Camunda notwendigen Abhängigkeiten ergänzt werden:

dependencies {
    [...]
    implementation("org.camunda.bpm.springboot:camunda-bpm-spring-boot-starter:3.3.3")
    implementation("org.camunda.bpm:camunda-engine-plugin-spin:7.11.0")
    implementation("org.camunda.bpm.extension.feel.scala:feel-engine-plugin:1.8.0")
}

Die Vorbereitungen für die Implementierung eigener FEEL-Funktionen ist hiermit abgeschlossen und es kann mit der Implementierung begonnen werden.

Implementieren einer Funktion

Im Folgenden soll nun als Beispiel eine Funktion implementiert werden, die als Eingabe-Parameter einen String erwartet und dann true zurückgibt, wenn der String mit einem Vokal beginnt, andernfalls false. Die Camunda-FEEL-Erweiterung bietet hierfür die Klasse JavaFunction an, die als ersten Parameter eine Liste von Strings erwartet, welche die Eingangsparameter definiert. Der zweite Parameter ist die konkrete Implementierung der Funktion und enthält entsprechend die gewünschte Logik. Da wir das Projekt auf Kotlin aufgesetzt haben, die Camunda-FEEL-Erweiterung aber in Scala entwickelt wurde, müssen wir ein paar Besonderheiten beachten. Dies betrifft in erster Linie vor allem die Verwendung spezifischer Wrapper-Objekte for Datentypen wie String, List und Boolean. Doch schauen wir uns die Funktion einmal genauer an:

import org.camunda.feel.interpreter.Val
import org.camunda.feel.interpreter.ValBoolean
import org.camunda.feel.interpreter.ValList
import org.camunda.feel.interpreter.ValString
import org.camunda.feel.spi.JavaFunction
import scala.collection.JavaConverters.collectionAsScalaIterable

val stringStartsWithVowel:JavaFunction = JavaFunction(listOf("input")) { args ->
    val inputString = args.get(0) as ValString
    val listOfVowels = listOf(
            ValString("a"),
            ValString("e"),
            ValString("i"),
            ValString("o"),
            ValString("u")
    )
    val feelList = ValList(collectionAsScalaIterable<Val>(listOfVowels).toList())
    ValBoolean(feelList.items().contains(ValString.apply(inputString.value().substring(0, 1).toLowerCase())))
}

Im Falle der Beispielfunktion erwarten wir genau einen Eingangsparameter, den wir input benannt haben. Da wir die Funktion in Kotlin implementieren, ist die Liste von Eingabeparametern optional (sie muss nicht einmal disjunkt sein) und könnte leer sein. In Scala könnte man statt über einen Index-basierten Zugriff die einzelnen Parameter mit Namen abgreifen. Der Lesbarkeit und Dokumentation halber füllen wir die Liste jedoch mit sinnvollen Werten bzw. Namen. Auf den konkreten Wert dieses Parameters greifen wir nun in Zeile 1 zu und führen auch einen Typecast durch. In den folgenden Zeilen wird eine Liste von Vokalen definiert, gegen die wir den Eingabeparameter in Zeile 10 prüfen. Zeile 9 ist für die Konvertierung des List-Typs in den ValList-Typ erforderlich.

Und was ist mit Unit-Tests?

Natürlich soll die Funktion nicht erst im Produktivsystem getestet werden. Fangen wir also mit einem positiven Unit-Test-Fall an und gehen davon aus, dass unsere Funktion, wenn wir den String „Another one bites the dust“ eingeben, true zurückgibt:

import org.junit.Assert.assertTrue
import org.junit.Assert.assertFalse
import org.camunda.feel.interpreter.ValBoolean
import org.camunda.feel.interpreter.ValString
import org.junit.Test

class CustomFeelFunctionTest {
    @Test
    fun `Postive case for stringStartsWithVowel`() {
        val stringThatStartsWithVowel = ValString("Another one bites the dust")
        var actual = stringStartsWithVowel.function.apply(listOf(stringThatStartsWithVowel)) as ValBoolean
        assertTrue(actual.value())
    }
}

Wie bereits erwähnt, müssen wir hier einige Scala-spezifische Datentypen verwenden und können beispielsweise nicht String verwenden, sondern müssen auf ValString zurückgreifen. Analog ist die erwartete Antwort unserer Funktion nicht vom Typ Boolean, sondern ein ValBoolean. Wenn wir diesen Test ausführen, werden wir sehen, dass unsere Logik bereits funktioniert. Der Vollständigkeit halber wollen wir noch einen Negativ-Test hinzufügen:

@Test
fun `Negative case for stringStartsWithVowel`() {
    val stringThatDoesNotStartWithVowel = ValString("The quick brown fox")
    val actual = stringStartsWithVowel.function.apply(listOf(stringThatDoesNotStartWithVowel)) as ValBoolean
    assertFalse(actual.value())
}

Auch hier werden wir nach erneutem Ausführen der Tests sehen, dass die Funktion für den Eingabewert “The quick brown fox” das korrekte Resultat liefert. Um den Rahmen dieses Beitrags nicht mit einer Vielzahl an Testkonstellationen zu sprengen, zeige ich nun, wie die implementierte Funktion der Camunda-Engine bekannt gemacht werden kann, um sie letztlich auch in einem DMN-Modell verwenden zu können.

Camunda FEEL mit Funktion bekannt machen

Um die Funktion einsetzen zu können, muss neben der JavaFunction auch ein CustomFeelFunctionProvider implementiert werden. Hierfür leiten wir eine Klasse von der Camunda FEEL-Erweiterung angebotenen Klasse org.camunda.feel.spi.JavaFunctionProvider ab und implementieren die notwendigen Methoden:

import org.camunda.feel.spi.JavaFunction
import org.camunda.feel.spi.JavaFunctionProvider
import java.util.Optional
import kotlin.collections.HashMap

class CustomFeelFunctionProvider : JavaFunctionProvider() {
    override fun resolveFunction(functionName: String): Optional<JavaFunction> {
        return Optional.ofNullable(functions.get(functionName))
    }

    companion object {
        private val functions = HashMap<String, JavaFunction>()

        init {
            functions.put("stringStartsWithVowel", stringStartsWithVowel)
        }
    }
}

In dieser Klasse wird eine statische Map mit unserer Funktion gefüllt. Der Schlüssel der Map entspricht dem späteren Funktionsnamen, den man in DMN-Modellen verwenden kann.

Leider reicht dies noch nicht aus; es muss eine Datei mit dem vorgegebenen Namen org.camunda.feel.spi.CustomFunctionProvider unter src/main/resources/META-INF/services abgelegt werden. In diese Datei wird nun der FQCN jeder Klasse eingetragen, die als FunctionProvider der FEEL-Erweiterung fungieren sollen. Hier gilt: nur ein FQCN pro Zeile!

Als letzter Schritt muss nun noch die FEEL-Erweiterung als solche an der Camunda-Engine registriert werden. Dies ist über eine Spring-Bean einfach in nur zwei Zeilen möglich:

@Bean
fun feelScalaPlugin(): ProcessEnginePlugin = CamundaFeelEnginePlugin()

Nun ist die Integration in Camunda abgeschlossen und es können Integrationstests geschrieben werden!

Integrationstests der FEEL-Funktion

Da die von uns programmierte Funktion in einem DMN-Modell verwendet werden soll, erstellen wir für den Integrationstest eine einfache Entscheidungstabelle und legen diese im Ordner src/test/resources/bpmn ab. Dieser Speicherort ist von der Camunda-Engine vorgegeben, da alle in diesem Ordner befindlichen BPMN- und DMN-Modelle automatisch von der Engine beim Start eingelesen werden. Unsere Entscheidungstabelle erwartet als Eingabeparameter eine Variable mit dem Namen SENTENCE vom Typ String. Der Ausgabeparameter heißt startsWithVowel und ist vom Typ Boolean.

<?xml version="1.0" encoding="UTF-8"?>
<definitions xmlns="http://www.omg.org/spec/DMN/20151101/dmn.xsd" xmlns:biodi="http://bpmn.io/schema/dmn/biodi/1.0" id="Definitions_01d1liz" name="DRD" namespace="http://camunda.org/schema/1.0/dmn" exporter="Camunda Modeler" exporterVersion="3.1.0">
 <decision id="DT_DMN" name="Decision">
   <extensionElements>
     <biodi:bounds x="157" y="81" width="180" height="80" />
   </extensionElements>
   <decisionTable id="decisionTable_1" hitPolicy="FIRST">
     <input id="input_1">
       <inputExpression id="inputExpression_1" typeRef="string">
         <text>SENTENCE</text>
       </inputExpression>
     </input>
     <output id="output_1" label="Starts With Vowel" name="startsWithVowel" typeRef="boolean" />
     <rule id="DecisionRule_09cwpik">
       <inputEntry id="UnaryTests_0byqx1i">
         <text>stringStartsWithVowel(cellInput)</text>
       </inputEntry>
       <outputEntry id="LiteralExpression_1230wy7">
         <text>true</text>
       </outputEntry>
     </rule>
     <rule id="DecisionRule_1ar4y7f">
       <inputEntry id="UnaryTests_1sf7gpl">
         <text></text>
       </inputEntry>
       <outputEntry id="LiteralExpression_0a595oz">
         <text>false</text>
       </outputEntry>
     </rule>
   </decisionTable>
 </decision>
</definitions>

Um DMN-Modelle auswerten zu können, benötigen wir den DecisionService,der Teil der Camunda-Engine ist. Über diesen Service können DMN-Modelle unabhängig von BPMN-Modellen getestet werden. Praktischerweise ist der DecisionService eine Spring-Bean und lässt sich daher mit herkömmlichen Mitteln ganz einfach in die Test-Klasse einbinden.
Auf der DecisionService-Instanz rufen wir die Methode evaluateDecisionTableByKey auf und übergeben dieser die ID des DMN-Modells (vgl. id Attribut im decision-Knoten in der XML) sowie einen Kontext. Der Kontext ist eine Map, die einen Variablennamen und dessen konkreten Inhalt verknüpft; in unserem Fall SENTENCE und als Wert beliebige Testdaten. Konkret sieht die der Integrationstest wie folgt aus:

import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.camunda.bpm.engine.DecisionService
import org.camunda.bpm.engine.variable.impl.value.PrimitiveTypeValueImpl.BooleanValueImpl
import org.junit.Test
import org.junit.runner.RunWith
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.test.context.junit4.SpringRunner

@RunWith(SpringRunner::class)
@SpringBootTest
class CustomFeelFunctionsIT {
    @Autowired
    lateinit var decisionService: DecisionService

    @Test
    fun `Postive case for stringStartsWithVowel`() {
        val context = HashMap<String, Any?>()
        context.set("SENTENCE", "Another one bites the dust")

        val decisionResult = decisionService.evaluateDecisionTableByKey("DT_DMN", context)
        assertTrue(decisionResult.getSingleEntryTyped<BooleanValueImpl>().value)
    }

    @Test
    fun `Negative case for stringStartsWithVowel`() {
        val context = HashMap<String, Any?>()
        context.set("SENTENCE", "The quick brown fox")

        val decisionResult = decisionService.evaluateDecisionTableByKey("DT_DMN", context)
        assertFalse(decisionResult.getSingleEntryTyped<BooleanValueImpl>().value)
    }
}

Starten wir nun diesen Test, wird ein Spring-Kontext mit Camunda-Engine hochgefahren, die die FEEL-Erweiterung beinhaltet. Anschließend wird das DMN-Modell eingelesen und über die Verwendung des Decision-Services und den gegebenen Kontexten ausgewertet. Die Integrationstests laufen durch und zeigen damit, dass die Einbindung der Funktion in die DMN funktioniert hat.

Ergebnis

Ziel war es, eine neue Funktion der FEEL-Sprache hinzuzufügen und diese in einem DMN-Modell zu verwenden. Leider ist der Weg in Teilen holprig, da eine Kombination aus Spring-typischen Konfigurationsschritten und Datei-basierter Konfiguration nötig ist. Doch letztlich ist es gelungen, die neue Funktion in der Camunda-Engine nutzbar zu machen. Ein weiterer Faktor konnte ebenfalls abgedeckt werden: Die Testbarkeit der Funktion ist sowohl im Unit-, als auch im Integrationstest gegeben. Leider ist der Umweg über Wrapper-Klassen wie ValString oder ValBoolean nötig, da die FEEL-Eweiterung in Scala implementiert ist.

Stephan Köninger

Stephan arbeitet als Software-Entwickler mit dem Schwerpunkt Web-Frontend-Entwicklung für die codecentric. Themen wie Java, Spring und Hibernate sind ihm nicht fremd, was ihn zu einem Full-Stack-Entwickler macht. Zudem ist das Thema Automatisierung sowie Testing ebenfalls wichtiger Bestandteil seines täglichen Arbeitens.

Kommentieren

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert.