
Cześć, Hub!
Nasza firma specjalizuje się w tworzeniu rozwiązań programowych klasy ERP, w których dużą część stanowią systemy transakcyjne o ogromnej ilości logiki biznesowej i obiegu dokumentów typu SEF. Nowoczesne wersje naszych produktów opierają się na technologiach JavaEE, ale także aktywnie eksperymentujemy z mikroserwisami. Jednym z największych problemów takich rozwiązań jest integracja różnych podsystemów związanych z pokrewnymi obszarami. Zadania integracyjne zawsze były dla nas dużym bólem głowy, niezależnie od stosowanych przez nas stylów architektonicznych, stosów technologicznych czy frameworków, jednak ostatnio zauważamy postępy w rozwiązywaniu tych problemów.
W artykule, który przedstawiamy, opowiem o doświadczeniach i architektonicznych poszukiwaniach NPO 'Krista' w tej dziedzinie. Rozważymy również przykład prostego rozwiązania problemu integracji z perspektywy programisty aplikacji i dowiemy się, co kryje się za tą prostotą.
Wyłączenie odpowiedzialności
Opisane w artykule rozwiązania architektoniczne i techniczne są oferowane na podstawie osobistego doświadczenia w kontekście konkretnych zadań. Rozwiązania te nie mają pretensji do uniwersalności i mogą okazać się nieoptymalne w innych warunkach użytkowania.
Co ma wspólnego BPM?
Aby odpowiedzieć na to pytanie, należy nieco zgłębić specyfikę stosowanych zadań naszych rozwiązań. Główna część logiki biznesowej w naszym typowym systemie transakcyjnym polega na wprowadzaniu danych do bazy danych przez interfejsy użytkownika, ręcznej i zautomatyzowanej weryfikacji tych danych, przechodzeniu ich przez pewien przepływ pracy, publikacji w innym systemie / bazie analitycznej / archiwum, formułowaniu raportów. Tak więc kluczową funkcją systemu dla klientów jest automatyzacja ich wewnętrznych procesów biznesowych.
Dla wygody w komunikacji używamy terminu 'dokument' jako pewnej abstrakcji zestawu danych, połączonych wspólnym kluczem, do którego można 'przypiąć' dany przepływ pracy.
Ale jak być z logiką integracyjną? Przecież zadanie integracji wynika z architektury systemu, który jest 'podzielony' na części NIE w wymaganiu klienta, lecz pod wpływem zupełnie innych czynników:
- pod wpływem prawa Conwaya;
- w wyniku ponownego wykorzystania subsystemów, wcześniej opracowanych dla innych produktów;
- na podstawie decyzji architekta, zgodnie z wymaganiami niefunkcjonalnymi.
Istnieje duża pokusa, aby oddzielić logikę integracyjną od logiki biznesowej głównego workflow, aby nie zanieczyszczać logiki biznesowej artefaktami integracyjnymi oraz uwolnić programistę aplikacji od potrzeby zagłębiania się w szczegóły architektonicznego krajobrazu systemu. Taka strategia ma swoje zalety, jednak praktyka pokazuje jej nieefektywność:
- rozwiązanie zadań integracyjnych zazwyczaj sprowadza się do najprostszych rozwiązań w postaci synchronizowanych wywołań z powodu ograniczonej liczby punktów rozszerzeń w realizacji głównego workflow (o wadach synchronizowanej integracji – nieco niżej);
- artefakty integracyjne i tak przenikają do głównej logiki biznesowej, gdy wymagana jest informacja zwrotna z innego subsystemu;
- programista aplikacji ignoruje integrację i może łatwo ją zepsuć, zmieniając workflow;
- system przestaje być całością z punktu widzenia użytkownika, stają się widoczne 'szwy' między subsystemami, pojawiają się zbędne operacje użytkownika, inicjujące transfer danych z jednego subsystemu do drugiego.
Inny sposób podejścia polega na traktowaniu interakcji integracyjnych jako nieodłącznej części podstawowej logiki biznesowej i workflow. Aby wymagania dotyczące kwalifikacji programistów aplikacji nie wzrosły w nieskończoność, tworzenie nowych interakcji integracyjnych powinno być łatwe i naturalne, z minimalnymi możliwościami wyboru metody rozwiązania. Jest to trudniejsze, niż się wydaje: narzędzie musi być wystarczająco potężne, aby zapewnić użytkownikowi niezbędną ilość opcji jego zastosowania, a jednocześnie nie pozwalać na "strzał w stopę". Istnieje wiele pytań, na które inżynier musi odpowiedzieć w kontekście zadań integracyjnych, ale nad którymi programista aplikacji nie powinien się zastanawiać w swojej codziennej pracy: granice transakcji, spójność, atomowość, bezpieczeństwo, skalowanie, rozkład obciążeń i zasobów, routowanie, marszalizowanie, rozprzestrzenianie i przełączanie kontekstów itp. Należy zaoferować programistom aplikacji wystarczająco proste szablony rozwiązań, w których już zawarte są odpowiedzi na wszystkie takie pytania. Te szablony powinny być wystarczająco bezpieczne: logika biznesowa zmienia się bardzo często, co zwiększa ryzyko popełnienia błędów, a cena błędów powinna pozostawać na wystarczająco niskim poziomie.
Ale co to ma wspólnego z BPM? Jest wiele sposobów realizacji workflow...
Rzeczywiście, w naszych rozwiązaniach bardzo popularna jest inna realizacja procesów biznesowych – poprzez deklaratywne określenie diagramu przejść stanów i podłączenie obsługujących z logiką biznesową do przejść. Przy tym stan, który określa bieżącą pozycję "dokumentu" w procesie biznesowym, jest atrybutem samego "dokumentu".

Tak wygląda proces na początku projektu
Popularność tej realizacji wynika z względnej prostoty i szybkości tworzenia liniowych procesów biznesowych. Jednak wraz z ciągłym skomplikowaniem systemów programowych, automatyzowana część procesu biznesowego rozrasta się i staje się bardziej złożona. Powstaje potrzeba dekompozycji, ponownego wykorzystania części procesów, a także rozgałęziania procesów, aby każda gałąź mogła być wykonywana równolegle. W takich warunkach narzędzie staje się niewygodne, a diagram przejść stanów traci informacyjność (interakcje integracyjne w ogóle nie są odzwierciedlone w diagramie).

Tak wygląda proces po kilku iteracjach doprecyzowania wymagań.
Wyjściem z tej sytuacji była integracja silnika z niektórymi produktami o najbardziej skomplikowanych procesach biznesowych. W krótkim okresie rozwiązanie to odniosło pewien sukces: pojawiła się możliwość realizacji skomplikowanych procesów biznesowych przy zachowaniu dostatecznie informacyjnego i aktualnego diagramu w notacji .

Mała część skomplikowanego procesu biznesowego.
W dłuższej perspektywie rozwiązanie to nie spełniło oczekiwań: wysoki koszt tworzenia procesów biznesowych za pomocą narzędzi wizualnych nie pozwolił na osiągnięcie akceptowalnych wskaźników produktywności, a samo narzędzie stało się jednym z najbardziej znienawidzonych wśród programistów. Do wewnętrznej struktury silnika również zgłaszano zastrzeżenia, co doprowadziło do powstania licznych „łatek” i „kuli”.
Główną pozytywną stroną zastosowania jBPM było uświadomienie sobie korzyści i szkód płynących z posiadania własnego stanu persystentnego w instancji procesu biznesowego. Zauważyliśmy również możliwość zastosowania podejścia procesowego do realizacji skomplikowanych protokołów integracji między różnymi aplikacjami przy użyciu asynchronicznych interakcji za pomocą sygnałów i wiadomości. Posiadanie stanu persystentnego odgrywa w tym kluczową rolę.
Na podstawie powyższego można wyciągnąć wnioski: podejście procesowe w stylu BPM pozwala nam rozwiązywać szeroki zakres zadań związanych z automatyzacją stale komplikujących się procesów biznesowych, harmonijnie włączać do tych procesów aktywności integracyjne i zachowywać możliwość wizualnego przedstawienia zrealizowanego procesu w odpowiedniej dla tego notacji.
Wady synchronnych wywołań jako wzorca integracji
Pod synchronizowaną integracją rozumie się najprostsze blokujące wywołanie. Jedna podsystem pełni rolę strony serwera i udostępnia API z odpowiednią metodą. Druga podsystem pełni rolę strony klienta i w odpowiednim momencie wykonuje wywołanie z oczekiwaniem na wynik. W zależności od architektury systemu strony klienta i serwera mogą znajdować się albo w tej samej aplikacji i procesie, albo w różnych. W drugim przypadku konieczne jest zastosowanie odpowiedniej implementacji RPC i zapewnienie marshalling parametrów i wyniku wywołania.

Ten wzorzec integracji ma dość dużą liczbę wad, ale jest bardzo szeroko stosowany w praktyce ze względu na swoją prostotę. Szybkość realizacji przyciąga i sprawia, że jest stosowany wciąż na nowo w warunkach "palących" terminów, zapisując rozwiązanie w długu technicznym. Jednak zdarza się, że niedoświadczeni programiści stosują go nieświadomie, po prostu nie zdając sobie sprawy z negatywnych konsekwencji.
Oprócz najbardziej oczywistego zwiększenia powiązań między podsystemami, istnieją również mniej oczywiste problemy z "rozpływaniem" i "rozciąganiem" transakcji. Faktycznie, jeśli logika biznesowa wprowadza jakieś zmiany, to nie da się obejść bez transakcji, a transakcje z kolei blokują określone zasoby aplikacji, dotknięte tymi zmianami. To znaczy, że dopóki jeden podsystem nie doczeka się odpowiedzi od drugiego, nie będzie w stanie zakończyć transakcji i zdjąć blokad. Zwiększa to znacznie ryzyko wystąpienia różnorodnych efektów:
- tracona jest responsywność systemu, użytkownicy długo czekają na odpowiedzi na zapytania;
- serwer całkowicie przestaje odpowiadać na zapytania użytkowników z powodu przepełnionego puli wątków: większość wątków "stanęła" na blokadzie zasobu zajętego transakcją;
- zaczynają pojawiać się martwe blokady: prawdopodobieństwo ich wystąpienia w dużej mierze zależy od długości transakcji, ilości biznesowej logiki zaangażowanej w transakcje i blokad;
- pojawiają się błędy przekroczenia limitu czasu transakcji;
- serwer "przestaje działać" z powodu OutOfMemory, jeśli zadanie wymaga przetwarzania i zmiany dużych ilości danych, a obecność synchronnych integracji znacznie utrudnia podział przetwarzania na bardziej "lekkie" transakcje.
Z architektonicznego punktu widzenia użycie blokujących wywołań podczas integracji prowadzi do utraty kontroli nad jakością poszczególnych subsystemów: nie można zapewnić docelowych wskaźników jakości jednego subsystemu niezależnie od wskaźników jakości drugiego subsystemu. Jeśli subsystemy są rozwijane przez różne zespoły, stanowi to poważny problem.
Wszystko staje się jeszcze ciekawsze, jeśli integrowane subsystemy znajdują się w różnych aplikacjach i trzeba wprowadzić synchroniczne zmiany z dwóch stron. Jak zapewnić transakcyjność tych zmian?
Jeśli zmiany wprowadzane są w osobnych transakcjach, to konieczne będzie zapewnienie niezawodnej obsługi wyjątków i kompensacji, co całkowicie niweluje podstawową zaletę synchronicznych integracji – prostotę.
Przychodzą mi na myśl również transakcje rozproszone, ale nie stosujemy ich w naszych rozwiązaniach: trudno zapewnić niezawodność.
Saga jako rozwiązanie problemu transakcji
Wraz ze wzrostem popularności mikrousług wzrasta również zapotrzebowanie na .
Ten wzorzec doskonale rozwiązuje powyższe problemy długotrwałych transakcji, a także rozszerza możliwości zarządzania stanem systemu z perspektywy logiki biznesowej: kompensacja po nieudanej transakcji może nie cofnąć systemu do stanu początkowego, ale zapewnić alternatywną trasę przetwarzania danych. Pozwala to również uniknąć ponownego wykonywania kroków przetwarzania danych, które zakończyły się sukcesem podczas kolejnych prób doprowadzenia procesu do 'dobrego' zakończenia.
Co ciekawe, w systemach monolitycznych ten wzorzec jest również aktualny, jeśli chodzi o integrację słabo powiązanych subsystemów, a negatywne efekty spowodowane długotrwałymi transakcjami i odpowiednimi blokadami zasobów są zauważalne.
W odniesieniu do naszych procesów biznesowych w stylu BPM wdrożenie 'Sag' okazuje się bardzo łatwe: poszczególne kroki 'Sagi' mogą być zadane w postaci aktywności wewnątrz procesu biznesowego, a trwały stan procesu biznesowego określa również wewnętrzny stan 'Sagi'. To oznacza, że nie potrzebujemy dodatkowego mechanizmu koordynacyjnego. Wystarczy broker wiadomości z wsparciem dla gwarancji 'at least once' w roli transportu.
Ale takie rozwiązanie ma swoją 'cenę':
- logika biznesowa staje się coraz bardziej złożona: należy uwzględnić rekompensaty;
- konieczne będzie rezygnowanie z pełnej spójności, co może być szczególnie wrażliwe dla systemów monolitycznych;
- architektura staje się nieco bardziej skomplikowana, pojawia się dodatkowa potrzeba wprowadzenia brokera wiadomości;
- będą potrzebne dodatkowe narzędzia do monitorowania i administracji (choć ogólnie to nawet dobrze: jakość obsługi systemu wzrośnie).
Dla systemów monolitycznych zasadność użycia wzorca „Saga” nie jest tak oczywista. Dla mikrousług i innych SOA, gdzie prawdopodobnie już istnieje broker, a pełna spójność została poświęcona na początku projektu, korzyści z użycia tego wzorca mogą znacznie przewyższyć wady, zwłaszcza w przypadku wygodnego API na poziomie logiki biznesowej.
Enkapsulacja logiki biznesowej w mikrousługach
Gdy zaczęliśmy eksperymentować z mikrousługami, pojawiło się uzasadnione pytanie: gdzie umieścić domenową logikę biznesową w odniesieniu do usługi zapewniającej trwałość danych domenowych?
Patrząc na architekturę różnych BPMS, może wydawać się sensowne oddzielić logikę biznesową od trwałości: stworzyć warstwę platformowych i niezależnych od domeny mikrousług, które tworzą środowisko i kontener do wykonania domenowej logiki biznesowej, a trwałość danych domenowych zrealizować jako oddzielną warstwę z bardzo prostymi i lekkimi mikrousługami. Procesy biznesowe w takim przypadku pełnią orkiestrację usług warstwy trwałości.

Taka metoda ma jeden bardzo duży plus: można w nieskończoność rozwijać funkcjonalność platformy, a 'przeładowanie' tego będzie dotyczyć tylko odpowiedniej warstwy platformowych mikrousług. Procesy biznesowe z dowolnej domeny natychmiast zyskują możliwość korzystania z nowej funkcjonalności platformy, jak tylko zostanie ona zaktualizowana.
Dokładniejsza analiza ujawniła znaczące wady takiego podejścia:
- usługa platformowa wykonująca logikę biznesową dla wielu domen niesie ze sobą duże ryzyko jako jedyny punkt awarii. Częste zmiany w logice biznesowej zwiększają ryzyko wystąpienia błędów, które prowadzą do awarii rozprzestrzeniających się na cały system;
- problemy z wydajnością: logika biznesowa operuje na swoich danych przez wąski i wolny interfejs:
- dane będą dodatkowo przetwarzane i przesyłane przez stos sieciowy;
- usługa domenowa często zwraca więcej danych, niż potrzebuje logika biznesowa do przetworzenia, z powodu niewystarczających możliwości parametryzacji zapytań na poziomie zewnętrznego API usługi;
- kilka niezależnych części logiki biznesowej może wielokrotnie ponownie żądać tych samych danych do przetworzenia (można złagodzić ten problem, dodając komponenty sesyjne, które pamiętają dane, ale to dodatkowo komplikuje architekturę i stwarza problemy z aktualnością danych i unieważnieniem pamięci podręcznej);
- problemy z transakcyjnością:
- procesy biznesowe z persistentnym stanem, za który odpowiada usługa platformowa, mogą być niezgodne z danymi domenowymi, a prostych sposobów rozwiązania tego problemu nie przewiduje się;
- wyjęcie blokady danych domenowych poza transakcję: jeśli logika biznesowa domeny musi wprowadzić zmiany, najpierw wykonując weryfikację poprawności aktualnych danych, należy wykluczyć możliwość konkurencyjnej zmiany przetwarzanych danych. Zewnętrzna blokada danych może pomóc rozwiązać problem, ale takie rozwiązanie wiąże się z dodatkowymi ryzykami i zmniejsza ogólną niezawodność systemu;
- dodatkowe trudności przy aktualizacji: w niektórych przypadkach, usługa przechowywania i logika biznesowa muszą być aktualizowane synchronicznie lub w ścisłej sekwencji.
Ostatecznie trzeba było wrócić do korzeni: inkapsulować dane domenowe i logikę biznesową w jednym mikroserwisie. Takie podejście upraszcza postrzeganie mikroserwisu jako integralnego komponentu w systemie i nie generuje powyższych problemów. To również nie jest bezpłatne:
- wymagana jest standaryzacja API do interakcji z logiką biznesową (w szczególności, aby zapewnić aktywności użytkowników w ramach procesów biznesowych) oraz API usług platformowych; konieczne jest również bardziej staranne podejście do zmian API, pełnej i niepełnej zgodności;
- wymagana jest dodanie dodatkowych bibliotek runtime do zapewnienia funkcjonowania logiki biznesowej w ramach każdego takiego mikroserwisu, co stwarza nowe wymagania dla tych bibliotek: lekkość i minimum zależności przejściowych;
- Deweloperzy logiki biznesowej muszą monitorować wersje bibliotek: jeśli jakiś mikrosersw nie był od dłuższego czasu rozwijany, to prawdopodobnie będzie zawierał przestarzałą wersję bibliotek. To może stać się nieoczekiwanym przeszkodą w dodawaniu nowej funkcji i może wymagać migracji starej logiki biznesowej tego serwisu na nowe wersje bibliotek, jeśli między wersjami nastąpiły niezgodne zmiany.

W warstwie usług platformowych w takiej architekturze również jest obecna, ale ta warstwa nie tworzy kontenera do wykonywania domenowej logiki biznesowej, lecz jedynie jej otoczenie, zapewniając pomocnicze funkcje »platformowe«. Taka warstwa jest potrzebna nie tylko w celu zachowania lekkości domenowych mikrosersów, ale także do centralizacji zarządzania.
Na przykład, aktywności użytkowników w procesach biznesowych generują zadania. Jednak, pracując z zadaniami, użytkownik powinien widzieć zadania ze wszystkich domen na wspólnej liście, co oznacza, że musi istnieć odpowiednia platformowa usługa rejestracji zadań, oczyszczona z domenowej logiki biznesowej. Zachowanie enkapsulacji logiki biznesowej w takim kontekście jest dość problematyczne i to kolejny kompromis tej architektury.
Integracja procesów biznesowych oczami programisty aplikacji
Jak już wcześniej wspomniano, programista aplikacji powinien być abstrahowany od technicznych i inżynieryjnych aspektów realizacji interakcji kilku aplikacji, aby można było oczekiwać dobrej wydajności produkcji.
Spróbujmy rozwiązać dość trudne zadanie integracyjne, specjalnie wymyślone na potrzeby artykułu. Będzie to »gry« z udziałem trzech aplikacji, z których każda definiuje pewną domenę: »app1«, »app2«, »app3«.
Wewnątrz każdej aplikacji uruchamiane są procesy biznesowe, które zaczynają »grać w piłkę« za pośrednictwem szyny integracyjnej. Rolą piłki będą wiadomości o nazwie »Ball«.
Zasady gry:
- pierwszy gracz – inicjator. Zaprasza innych graczy do gry, rozpoczyna grę i może ją zakończyć w dowolnym momencie;
- inni gracze zgłaszają swoje uczestnictwo w grze, »poznają« się nawzajem i pierwszego gracza;
- przyjmując piłkę, gracz wybiera innego uczestniczącego gracza i przekazuje mu piłkę. Prowadzony jest rachunek ogólnej liczby podań;
- Każdy gracz ma "energię", która maleje z każdą podaniem piłki przez tego gracza. Po wyczerpaniu energii gracz opuszcza grę, ogłaszając swoje odejście;
- jeśli gracz zostaje sam, natychmiast ogłasza swoje odejście;
- kiedy wszyscy gracze opuszczają grę, pierwszy gracz ogłasza zakończenie gry. Jeśli wcześniej opuścił grę, obserwuje ją, aby ją zakończyć.
Aby rozwiązać to zadanie, skorzystam z naszego DSL do procesów biznesowych, który pozwala na zwięzłe opisanie logiki w Kotlinie, z minimalną ilością kodu pomocniczego.
W aplikacji app1 będzie działać proces biznesowy pierwszego gracza (inicjatora gry):
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()
// To jest klasa instancji procesu: enkapsuluje jej wewnętrzny stan
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
}
// To jest deklaracja modelu procesu: tworzona jest raz, używana przez wszystkie
// instancje procesu odpowiedniej klasy
val initialPlayerModel = processModel(name = "InitialPlayer",
version = 1) {
// Zgodnie z zasadami, pierwszy gracz jest inicjatorem gry i musi być jedynym
uniqueConstraint = UniqueConstraints.singleton
// Deklarujemy aktywności, z których składa się proces biznesowy
val sendNewGameSignal = signal("NewGame")
val sendStopGameSignal = signal("StopGame")
val startTask = humanTask("Start") {
taskOperation {
processCondition { players.size > 0 }
confirmation { "Połączyło się ${players.size} graczy. Zaczynamy?" }
}
}
val stopTask = humanTask("Stop") {
taskOperation {}
}
val waitPlayerJoin = signalWait("PlayerJoin") { signal ->
players.add(PlayerInfo(
signal.data!!,
signal.sender.domain,
signal.sender.processInstanceId))
println("... dołączył gracz ${signal.data} ...")
}
val waitPlayerOut = signalWait("PlayerOut") { signal ->
players.remove(PlayerInfo(
signal.data!!,
signal.sender.domain,
signal.sender.processInstanceId))
println("... gracz ${signal.data} jest na zewnątrz ...")
}
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
}
// Teraz konstruujemy graf procesu z ogłoszonych aktywności
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)
}
// Dodajemy dodatkowe obsługiwacze do aktywności dla logowania
sendNewGameSignal.onExit { println("Zaczynajmy grać!") }
sendStopGameSignal.onExit { println("Stop!") }
sendPlayerOut.onExit { println("$playerName: Jestem na zewnątrz!") }
}
private fun MessageSendInstance.selectNextPlayer() {
val player = process.players.random()
receiverDomain = player.domain
receiverProcessInstanceId = player.id
println("Krok ${process.shotCounter + 1}: " +
"${process.playerName} >>> ${player.name}")
}Oprócz realizacji logiki biznesowej, przedstawiony kod potrafi zwracać obiektowy model procesu biznesowego, który można zwizualizować w formie diagramu. Wizualizator nie został jeszcze zaimplementowany, dlatego musiałem poświęcić trochę czasu na narysowanie (tutaj nieco uprościłem notację BPMN w zakresie użycia bramek, aby poprawić spójność diagramu z przedstawionym kodem):

Aplikacja app2 będzie zawierać proces biznesowy innego gracza:
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 PlayerInfo(val name: String, val domain: String, val id: String)
class PlayersList: ArrayList()
class RandomPlayer : ProcessImpl(randomPlayerModel) {
var playerName: String by input(persistent = true,
defaultValue = "RandomPlayer")
var energy: Int by input(persistent = true, defaultValue = 30)
var players: PlayersList by persistent(PlayersList())
var allPlayersOut: Boolean by persistent(false)
var shotCounter: Int = 0
val selfPlayer: PlayerInfo
get() = PlayerInfo(playerName, env.eventDispatcher.domainName, id)
}
val randomPlayerModel = processModel(name = "RandomPlayer",
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() {
val player = if (process.players.isNotEmpty())
process.players.random()
else
process.selfPlayer
receiverDomain = player.domain
receiverProcessInstanceId = player.id
println("Step ${process.shotCounter + 1}: " +
"${process.playerName} >>> ${player.name}")
}Diagram:

W aplikacji app3 zmodyfikujemy gracza, aby miał nieco inne zachowanie: zamiast losowego wyboru następnego gracza, będzie działał według algorytmu round-robin:
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}")
}W pozostałych kwestiach zachowanie gracza nie różni się od poprzedniego, więc wykres pozostaje taki sam.
Teraz potrzebny jest test, aby to wszystko uruchomić. Przytoczę tylko kod samego testu, aby nie zaśmiecać artykułu boilerplate'em (w rzeczywistości skorzystałem z wcześniej stworzonego środowiska testowego do testowania integracji innych procesów biznesowych):
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");
// Teraz musimy trochę poczekać, aż gracze "zapoznają się" ze sobą.
// Czekanie przez sleep to złe rozwiązanie, ale najprostsze.
// Nie róbcie tak w poważnych testach!
Thread.sleep(1000);
// Uruchamiamy grę, zamykając aktywność użytkownika
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;
}Uruchamiamy test, sprawdzamy logi:
wyjście konsoli
Blokada klucza lock://app1/process/InitialPlayer została nałożona
Zagrajmy!
Blokada klucza lock://app1/process/InitialPlayer została zdjęta
Gracz 2: Jestem tutaj!
Gracz 3: Jestem tutaj!
Gracz 4: Jestem tutaj!
Gracz 5: Jestem tutaj!
... dołącz gracz Gracz 2 ...
... dołącz gracz Gracz 4 ...
... dołącz gracz Gracz 3 ...
... dołącz gracz Gracz 5 ...
Krok 1: Gracz 1 >>> Gracz 3
Krok 2: Gracz 3 >>> Gracz 5
Krok 3: Gracz 5 >>> Gracz 3
Krok 4: Gracz 3 >>> Gracz 4
Krok 5: Gracz 4 >>> Gracz 3
Krok 6: Gracz 3 >>> Gracz 4
Krok 7: Gracz 4 >>> Gracz 5
Krok 8: Gracz 5 >>> Gracz 2
Krok 9: Gracz 2 >>> Gracz 5
Krok 10: Gracz 5 >>> Gracz 4
Krok 11: Gracz 4 >>> Gracz 2
Krok 12: Gracz 2 >>> Gracz 4
Krok 13: Gracz 4 >>> Gracz 1
Krok 14: Gracz 1 >>> Gracz 4
Krok 15: Gracz 4 >>> Gracz 3
Krok 16: Gracz 3 >>> Gracz 1
Krok 17: Gracz 1 >>> Gracz 2
Krok 18: Gracz 2 >>> Gracz 3
Krok 19: Gracz 3 >>> Gracz 1
Krok 20: Gracz 1 >>> Gracz 5
Krok 21: Gracz 5 >>> Gracz 1
Krok 22: Gracz 1 >>> Gracz 2
Krok 23: Gracz 2 >>> Gracz 4
Krok 24: Gracz 4 >>> Gracz 5
Krok 25: Gracz 5 >>> Gracz 3
Krok 26: Gracz 3 >>> Gracz 4
Krok 27: Gracz 4 >>> Gracz 2
Krok 28: Gracz 2 >>> Gracz 5
Krok 29: Gracz 5 >>> Gracz 2
Krok 30: Gracz 2 >>> Gracz 1
Krok 31: Gracz 1 >>> Gracz 3
Krok 32: Gracz 3 >>> Gracz 4
Krok 33: Gracz 4 >>> Gracz 1
Krok 34: Gracz 1 >>> Gracz 3
Krok 35: Gracz 3 >>> Gracz 4
Krok 36: Gracz 4 >>> Gracz 3
Krok 37: Gracz 3 >>> Gracz 2
Krok 38: Gracz 2 >>> Gracz 5
Krok 39: Gracz 5 >>> Gracz 4
Krok 40: Gracz 4 >>> Gracz 5
Krok 41: Gracz 5 >>> Gracz 1
Krok 42: Gracz 1 >>> Gracz 5
Krok 43: Gracz 5 >>> Gracz 3
Krok 44: Gracz 3 >>> Gracz 5
Krok 45: Gracz 5 >>> Gracz 2
Krok 46: Gracz 2 >>> Gracz 3
Krok 47: Gracz 3 >>> Gracz 2
Krok 48: Gracz 2 >>> Gracz 5
Krok 49: Gracz 5 >>> Gracz 4
Krok 50: Gracz 4 >>> Gracz 2
Krok 51: Gracz 2 >>> Gracz 5
Krok 52: Gracz 5 >>> Gracz 1
Krok 53: Gracz 1 >>> Gracz 5
Krok 54: Gracz 5 >>> Gracz 3
Krok 55: Gracz 3 >>> Gracz 5
Krok 56: Gracz 5 >>> Gracz 2
Krok 57: Gracz 2 >>> Gracz 1
Krok 58: Gracz 1 >>> Gracz 4
Krok 59: Gracz 4 >>> Gracz 1
Krok 60: Gracz 1 >>> Gracz 4
Krok 61: Gracz 4 >>> Gracz 3
Krok 62: Gracz 3 >>> Gracz 2
Krok 63: Gracz 2 >>> Gracz 5
Krok 64: Gracz 5 >>> Gracz 4
Krok 65: Gracz 4 >>> Gracz 5
Krok 66: Gracz 5 >>> Gracz 1
Krok 67: Gracz 1 >>> Gracz 5
Krok 68: Gracz 5 >>> Gracz 3
Krok 69: Gracz 3 >>> Gracz 4
Krok 70: Gracz 4 >>> Gracz 2
Krok 71: Gracz 2 >>> Gracz 5
Krok 72: Gracz 5 >>> Gracz 2
Krok 73: Gracz 2 >>> Gracz 1
Krok 74: Gracz 1 >>> Gracz 4
Krok 75: Gracz 4 >>> Gracz 1
Krok 76: Gracz 1 >>> Gracz 2
Krok 77: Gracz 2 >>> Gracz 5
Krok 78: Gracz 5 >>> Gracz 4
Krok 79: Gracz 4 >>> Gracz 3
Krok 80: Gracz 3 >>> Gracz 1
Krok 81: Gracz 1 >>> Gracz 5
Krok 82: Gracz 5 >>> Gracz 1
Krok 83: Gracz 1 >>> Gracz 4
Krok 84: Gracz 4 >>> Gracz 5
Krok 85: Gracz 5 >>> Gracz 3
Krok 86: Gracz 3 >>> Gracz 5
Krok 87: Gracz 5 >>> Gracz 2
Krok 88: Gracz 2 >>> Gracz 3
Gracz 2: Wyszedłem!
Krok 89: Gracz 3 >>> Gracz 4
... gracz Gracz 2 wyszedł ...
Krok 90: Gracz 4 >>> Gracz 1
Krok 91: Gracz 1 >>> Gracz 3
Krok 92: Gracz 3 >>> Gracz 1
Krok 93: Gracz 1 >>> Gracz 4
Krok 94: Gracz 4 >>> Gracz 3
Krok 95: Gracz 3 >>> Gracz 5
Krok 96: Gracz 5 >>> Gracz 1
Krok 97: Gracz 1 >>> Gracz 5
Krok 98: Gracz 5 >>> Gracz 3
Krok 99: Gracz 3 >>> Gracz 5
Krok 100: Gracz 5 >>> Gracz 4
Krok 101: Gracz 4 >>> Gracz 5
Gracz 4: Wyszedłem!
... gracz Gracz 4 wyszedł ...
Krok 102: Gracz 5 >>> Gracz 1
Krok 103: Gracz 1 >>> Gracz 3
Krok 104: Gracz 3 >>> Gracz 1
Krok 105: Gracz 1 >>> Gracz 3
Krok 106: Gracz 3 >>> Gracz 5
Krok 107: Gracz 5 >>> Gracz 3
Krok 108: Gracz 3 >>> Gracz 1
Krok 109: Gracz 1 >>> Gracz 3
Krok 110: Gracz 3 >>> Gracz 5
Krok 111: Gracz 5 >>> Gracz 1
Krok 112: Gracz 1 >>> Gracz 3
Krok 113: Gracz 3 >>> Gracz 5
Krok 114: Gracz 5 >>> Gracz 3
Krok 115: Gracz 3 >>> Gracz 1
Krok 116: Gracz 1 >>> Gracz 3
Krok 117: Gracz 3 >>> Gracz 5
Krok 118: Gracz 5 >>> Gracz 1
Krok 119: Gracz 1 >>> Gracz 3
Krok 120: Gracz 3 >>> Gracz 5
Krok 121: Gracz 5 >>> Gracz 3
Gracz 5: Wyszedłem!
... gracz Gracz 5 wyszedł ...
Krok 122: Gracz 3 >>> Gracz 5
Krok 123: Gracz 5 >>> Gracz 1
Gracz 5: Wyszedłem!
Krok 124: Gracz 1 >>> Gracz 3
... gracz Gracz 5 wyszedł ...
Krok 125: Gracz 3 >>> Gracz 1
Krok 126: Gracz 1 >>> Gracz 3
Gracz 1: Wyszedłem!
... gracz Gracz 1 wyszedł ...
Krok 127: Gracz 3 >>> Gracz 3
Gracz 3: Wyszedłem!
Krok 128: Gracz 3 >>> Gracz 3
... gracz Gracz 3 wyszedł ...
Gracz 3: Wyszedłem!
Stop!
Krok 129: Gracz 3 >>> Gracz 3
Gracz 3: Wyszedłem!Z tego wszystkiego można wyciągnąć kilka ważnych wniosków:
- przy użyciu odpowiednich narzędzi programiści aplikacji mogą tworzyć interfejsy integracyjne między aplikacjami bez odrywania się od logiki biznesowej;
- złożoność (complexity) zadania integracyjnego, wymagającego umiejętności inżynieryjnych, można ukryć wewnątrz frameworka, o ile zostanie to początkowo uwzględnione w architekturze frameworka. Trudności (difficulty) zadania nie można ukryć, dlatego rozwiązanie trudnego zadania w kodzie będzie odpowiednio widoczne;
- podczas opracowywania logiki integracyjnej należy koniecznie uwzględnić eventual consistency oraz brak liniowości w zmianie stanu wszystkich uczestników integracji. Zmusza to do skomplikowania logiki, aby stała się ona niewrażliwa na kolejność wystąpienia zdarzeń zewnętrznych. W naszym przykładzie gracz zmuszony jest uczestniczyć w grze dopiero po tym, jak ogłosi swoje wyjście z gry: inni gracze nadal będą podawać mu piłkę, dopóki informacja o jego wyjściu nie dotrze i nie zostanie przetworzona przez wszystkich uczestników. Ta logika nie wynika z zasad gry i stanowi kompromisowe rozwiązanie w ramach wybranej architektury.
Następnie porozmawiamy o różnych niuansach naszego rozwiązania, kompromisach i innych zagadnieniach.
Wszystkie wiadomości – w jednej kolejce
Wszystkie integrowane aplikacje działają z jedną szyną integracyjną, która jest reprezentowana w postaci zewnętrznego brokera, jednej kolejki BPMQueue – dla wiadomości i jednego tematu BPMTopic – dla sygnałów (zdarzeń). Przepuszczanie wszystkich wiadomości przez jedną kolejkę samo w sobie jest kompromisem. Na poziomie logiki biznesowej można teraz wprowadzać dowolną liczbę nowych typów wiadomości, nie wprowadzając zmian w strukturze systemu. To znaczne uproszczenie, ale niesie ze sobą pewne ryzyka, które w kontekście naszych typowych zadań wydają się nie być tak znaczące.

Jest jednak jedna uwaga: każda aplikacja filtruje „swoje” wiadomości z kolejki już na etapie wejścia, według nazwy swojej domeny. Domena może być także określona w sygnałach, jeśli trzeba ograniczyć „widoczność” sygnału do jedynej aplikacji. To powinno zwiększyć przepustowość szyny, ale logika biznesowa teraz musi operować nazwami domen: dla adresowania wiadomości – obowiązkowo, dla sygnałów – pożądane.
Zapewnienie niezawodności szyny integracyjnej
Niezawodność składa się z kilku elementów:
- wybrany broker wiadomości – krytyczny komponent architektury i pojedynczy punkt awarii: musi być wystarczająco odporny na awarie. Należy używać tylko sprawdzonych rozwiązań, z dobrą obsługą i dużą społecznością;
- konieczne jest zapewnienie wysokiej dostępności brokera wiadomości, co wymaga, aby był fizycznie oddzielony od integrowanych aplikacji (zapewnienie wysokiej dostępności aplikacji z biznesową logiką jest znacznie bardziej skomplikowane i droższe);
- broker musi zapewnić gwarancje dostawy na poziomie „at least once”. To obowiązkowe wymaganie dla niezawodnego działania szyny integracyjnej. Nie ma potrzeby w garantowaniu poziomu „exactly once”: procesy biznesowe zazwyczaj nie są wrażliwe na wielokrotne przyjęcie wiadomości lub zdarzeń, a w szczególnych przypadkach, gdzie to jest ważne, łatwiej dodać dodatkową weryfikację w logice biznesowej, niż stale korzystać z wystarczająco „drogich” gwarancji;
- wysyłanie wiadomości i sygnałów należy zaangażować w ogólną transakcję ze zmianą stanu procesów biznesowych i danych domenowych. Preferowanym rozwiązaniem będzie zastosowanie wzorca , ale będzie wymagało dodatkowej tabeli w bazie danych i retransmitera. W aplikacjach JEE można uprościć ten aspekt przy użyciu lokalnego menedżera JTA, ale połączenie z wybranym brokerem musi móc działać w trybie ;
- obsługiwane wiadomości i zdarzenia również muszą działać z transakcją zmiany stanu procesu biznesowego: jeśli taka transakcja jest wycofywana, to również odbiór wiadomości musi być anulowany;
- wiadomości, których nie udało się dostarczyć z powodu błędów, należy przechowywać w osobnym repozytorium (Dead Letter Queue). Stworzyliśmy dla tego osobny mikroserwis platformowy, który przechowuje takie wiadomości w swoim repozytorium, indeksuje je według atrybutów (dla szybkiej grupowania i wyszukiwania) oraz udostępnia API do przeglądania, ponownego wysyłania na adres docelowy i usuwania wiadomości. Administratorzy systemu mogą pracować z tym serwisem przez swój interfejs internetowy;
- w ustawieniach brokera należy dostosować liczbę prób dostarczenia oraz opóźnienia między dostarczeniami, aby zmniejszyć ryzyko trafienia wiadomości do DLQ (wyliczenie optymalnych parametrów jest praktycznie niemożliwe, ale można działać empirycznie i dostosowywać je w trakcie eksploatacji);
- przechowalnia DLQ powinna być monitorowana w sposób ciągły, a system monitorowania powinien powiadamiać administratorów systemu, aby jak najszybciej reagować na pojawiające się niedostarczone wiadomości. To pozwoli zmniejszyć «strefę wpływu» powstałej awarii lub błędu logiki biznesowej;
- szyna integracyjna powinna być nieczuła na tymczasową niedostępność aplikacji: subskrypcje na temat powinny być trwałe, a nazwa domeny aplikacji powinna być unikalna, aby w czasie nieobecności aplikacji, jej wiadomości z kolejki nie zostały próbę przetworzyć przez kogoś innego.
Zabezpieczenie bezpieczeństwa wątkowego logiki biznesowej
Jednocześnie do tego samego egzemplarza procesu biznesowego może dotrzeć kilka wiadomości i zdarzeń, których przetwarzanie rozpocznie się równolegle. Jednocześnie dla programisty aplikacji wszystko powinno być proste i bezpieczne wielowątkowo.
Logika biznesowa procesu przetwarza każde zewnętrzne zdarzenie wpływające na ten proces biznesowy oddzielnie. Takimi zdarzeniami mogą być:
- uruchomienie egzemplarza procesu biznesowego;
- działanie użytkownika, związane z aktywnością w ramach procesu biznesowego;
- otrzymanie wiadomości lub sygnału, do którego subskrybował egzemplarz procesu biznesowego;
- wyzwolenie timera, ustawionego przez egzemplarz procesu biznesowego;
- kontrolujące działanie przez API (np. awaryjne przerwanie procesu).
Każde takie zdarzenie może zmienić stan instancji procesu biznesowego: jedne aktywności mogą się zakończyć, a inne rozpocząć, mogą się zmienić wartości właściwości trwałych. Zakończenie jakiejkolwiek aktywności może prowadzić do aktywacji jednej lub kilku następnych aktywności. Te z kolei mogą zatrzymać się na oczekiwanie na inne zdarzenia lub, jeśli nie potrzebują dodatkowych danych, mogą zakończyć się w tej samej transakcji. Przed zamknięciem transakcji nowy stan procesu biznesowego jest zapisywany w bazie danych, gdzie będzie czekał na wystąpienie następnego zdarzenia zewnętrznego.
Dane trwałe procesu biznesowego, zapisane w relacyjnej bazie danych, są bardzo wygodnym punktem synchronizacji przetwarzania, jeśli użyto SELECT FOR UPDATE. Jeśli jednej transakcji udało się uzyskać stan procesu biznesowego z bazy w celu jego zmiany, to żadna inna transakcja nie będzie mogła równolegle uzyskać tego samego stanu do innej zmiany, a po zakończeniu pierwszej transakcji druga z pewnością uzyska już zmieniony stan.
Używając pesymistycznych blokad po stronie SGBD, spełniamy wszystkie wymagane warunki , a także zachowujemy możliwość skalowania aplikacji z logiką biznesową poprzez zwiększenie liczby uruchomionych instancji.
Jednak pesymistyczne blokady niosą ze sobą ryzyko wystąpienia deadlocków, dlatego SELECT FOR UPDATE powinno być jednak ograniczone do rozsądnego czasu oczekiwania na wypadek wystąpienia deadlocków w jakichkolwiek rażących przypadkach logiki biznesowej.
Kolejnym problemem jest synchronizacja startu procesu biznesowego. Dopóki nie ma instancji procesu biznesowego, nie ma również jego stanu w bazie, dlatego opisana metoda się nie sprawdzi. Jeśli trzeba zapewnić unikalność instancji procesu biznesowego w określonym zakresie, konieczny będzie obiekt synchronizacji skojarzony z klasą procesu i odpowiednim zakresem. Aby rozwiązać ten problem, używamy innego mechanizmu blokad, który pozwala na zajęcie blokady dowolnego zasobu, określonego kluczem w formacie URI, przez zewnętrzny serwis.
W naszych przykładach proces biznesowy InitialPlayer zawiera deklarację
uniqueConstraint = UniqueConstraints.singletonDlatego w logu znajdują się informacje o zablokowaniu i zwolnieniu odpowiedniego klucza. W innych procesach biznesowych takich informacji nie ma: uniqueConstraint nie jest ustawiony.
Problemy procesów biznesowych z persystentnym stanem
Czasami istnienie persystentnego stanu nie tylko pomaga, ale również bardzo przeszkadza w rozwoju.
Problemy zaczynają się, gdy potrzebne są zmiany w logice biznesowej i/lub modelu procesu biznesowego. Nie każda taka zmiana okazuje się zgodna ze starym stanem procesów biznesowych. Jeśli w bazie danych znajduje się wiele 'żywych' instancji, wprowadzenie niekompatybilnych zmian może sprawić wiele problemów, z którymi często się borykaliśmy podczas korzystania z jBPM.
W zależności od głębokości zmian można podjąć dwie drogi:
- stworzyć nowy typ procesu biznesowego, aby uniknąć wprowadzania niekompatybilnych zmian w starym, i używać go zamiast starego przy uruchamianiu nowych instancji. Stare instancje będą kontynuowały działanie 'po staremu';
- migrować persystentny stan procesów biznesowych podczas aktualizacji logiki biznesowej.
Pierwsza droga jest prostsza, ale ma swoje ograniczenia i wady, na przykład:
- duplikacja logiki biznesowej w wielu modelach procesów biznesowych, zwiększenie objętości logiki biznesowej;
- często wymagane jest natychmiastowe przejście na nową logikę biznesową (w zakresie zadań integracyjnych – niemal zawsze);
- programista nie wie, kiedy można usunąć przestarzałe modele.
W praktyce używamy obu podejść, ale podjęliśmy szereg decyzji, aby uprościć sobie życie:
- w bazie danych persystentny stan procesu biznesowego jest przechowywany w czytelnym i łatwym do przetworzenia formacie: w wierszu formatu JSON. Umożliwia to wykonywanie migracji zarówno wewnątrz aplikacji, jak i na zewnątrz. W ostateczności można go również ręcznie poprawić (szczególnie przydatne w rozwoju w trakcie debugowania);
- logika biznesowa integracji nie używa nazw procesów biznesowych, aby w każdej chwili można było zastąpić implementację jednego z uczestniczących procesów nową, z nową nazwą (na przykład 'InitialPlayerV2'). Powiązania odbywają się przez nazwy wiadomości i sygnałów;
- Model procesu ma numer wersji, który zwiększamy, gdy wprowadzamy w tej modelu niekompatybilne zmiany, a ten numer jest przechowywany razem z stanem instancji procesu;
- Trwały stan procesu jest odczytywany z bazy najpierw do wygodnego modelu obiektowego, z którym może pracować procedura migracji, jeśli numer wersji modelu się zmienił;
- Procedura migracji znajduje się obok logiki biznesowej i jest wywoływana „leniwie” dla każdej instancji procesu biznesowego w momencie jego przywrócenia z bazy;
- Jeśli potrzebna jest szybka i synchroniczna migracja stanu wszystkich instancji procesu, stosowane są bardziej klasyczne rozwiązania do migracji baz danych, ale tam trzeba pracować z JSON-em.
Czy potrzebny jest jeszcze jeden framework do procesów biznesowych?
Opisana w artykule rozwiązania pozwoliły nam znacznie uprościć sobie życie, rozszerzyć zakres kwestii rozwiązywanych na poziomie aplikacji, uczynić bardziej atrakcyjnymi pomysły na wydzielanie logiki biznesowej do mikroserwisów. W tym celu wykonano wiele pracy, stworzono bardzo „lekki” framework do procesów biznesowych, a także komponenty pomocnicze do rozwiązywania wskazanych problemów w kontekście szerokiego kręgu zastosowań. Mamy ochotę podzielić się tymi wynikami, wnieść rozwój wspólnych komponentów do otwartego dostępu na zasadzie wolnej licencji. To będzie wymagało określonych wysiłków i czasu. Zrozumienie zapotrzebowania na takie rozwiązania mogłoby być dla nas dodatkowym impulsem. W proponowanym artykule poświęcono bardzo mało uwagi możliwościom samego frameworka, ale niektóre z nich są widoczne w przedstawionych przykładach. Jeśli zdecydujemy się na publikację naszego frameworka, zostanie mu poświęcony osobny artykuł. A tymczasem będziemy wdzięczni, jeśli zostawicie mały feedback, odpowiadając na pytanie:
Tylko zarejestrowani użytkownicy mogą brać udział w ankiecie. , proszę.
Czy potrzebny jest jeszcze jeden framework do procesów biznesowych?
18,8%Tak, od dłuższego czasu szukamy czegoś podobnego3
12,5%Ciekawi mnie, aby dowiedzieć się więcej o waszej realizacji, może się przydać2
6,2%Używamy jednego z istniejących frameworków, ale myślimy o zmianie1
18,8%Używamy jednego z istniejących frameworków, wszystko nam odpowiada3
18,8%Radziśmy sobie bez frameworka3
25,0%Pisaliśmy swój4
Zagłosowało 16 użytkowników. Wstrzymało się 7 użytkowników.
Źródło: habr.com
