Nasza firma specjalizuje się w tworzeniu rozwiązań programowych klasy ERP, których lwią część zajmują systemy transakcyjne z ogromną ilością logiki biznesowej i obiegu dokumentów a la EDMS. Obecne wersje naszych produktów opieramy na technologiach JavaEE, ale aktywnie eksperymentujemy także z mikroserwisami. Jednym z najbardziej problematycznych obszarów takich rozwiązań jest integracja różnych podsystemów należących do sąsiednich domen. Problemy integracyjne zawsze przyprawiały nas o ogromny ból głowy, niezależnie od stylów architektonicznych, stosów technologii i frameworków, z których korzystamy, ale ostatnio nastąpił postęp w rozwiązywaniu takich problemów.
W artykule, na który zwracam uwagę, opowiem o doświadczeniach i badaniach architektonicznych, jakie NPO „Krista” posiada na wyznaczonym terenie. Przyjrzymy się także przykładowi prostego rozwiązania problemu integracji z punktu widzenia twórcy aplikacji i dowiemy się, co kryje się za tą prostotą.
Zrzeczenie się
Opisane w artykule rozwiązania architektoniczne i techniczne proponowane są przeze mnie na podstawie osobistych doświadczeń w kontekście konkretnych zadań. Rozwiązania te nie pretendują do miana uniwersalnych i mogą nie być optymalne w innych warunkach użytkowania.
Co ma z tym wspólnego BPM?
Aby odpowiedzieć na to pytanie, musimy nieco głębiej zagłębić się w specyfikę zastosowanych problemów naszych rozwiązań. Główną częścią logiki biznesowej w naszym typowym systemie transakcyjnym jest wprowadzanie danych do bazy danych poprzez interfejsy użytkownika, ręczna i automatyczna weryfikacja tych danych, przeprowadzenie ich poprzez jakiś przepływ pracy, publikacja w innym systemie/bazie analitycznej/archiwum, generowanie raportów . Zatem kluczową funkcją systemu dla klientów jest automatyzacja ich wewnętrznych procesów biznesowych.
Dla wygody w komunikacji używamy terminu „dokument” jako abstrakcji zbioru danych połączonych wspólnym kluczem, z którym można „powiązać określony przepływ pracy”.
Ale co z logiką integracji? Przecież zadanie integracyjne generowane jest przez architekturę systemu, która jest „pocięta” na części NIE na życzenie klienta, a pod wpływem zupełnie innych czynników:
podlega prawu Conwaya;
w wyniku ponownego wykorzystania podsystemów opracowanych wcześniej dla innych produktów;
według uznania architekta, w oparciu o wymagania pozafunkcjonalne.
Istnieje wielka pokusa, aby oddzielić logikę integracji od logiki biznesowej głównego przepływu pracy, aby nie zanieczyszczać logiki biznesowej artefaktami integracji i uchronić twórcę aplikacji przed koniecznością zagłębiania się w cechy krajobrazu architektonicznego systemu. Takie podejście ma wiele zalet, ale praktyka pokazuje jego nieskuteczność:
rozwiązywanie problemów integracyjnych zwykle sprowadza się do najprostszych opcji w postaci wywołań synchronicznych ze względu na ograniczone punkty rozszerzeń w realizacji głównego przepływu pracy (wady integracji synchronicznej omówiono poniżej);
artefakty integracji nadal przenikają do podstawowej logiki biznesowej, gdy wymagana jest informacja zwrotna z innego podsystemu;
twórca aplikacji ignoruje integrację i może ją łatwo przerwać zmieniając przepływ pracy;
system przestaje być z punktu widzenia użytkownika jedną całością, zauważalne stają się „szwy” pomiędzy podsystemami i pojawiają się zbędne operacje użytkownika, inicjujące przenoszenie danych z jednego podsystemu do drugiego.
Innym podejściem jest uznanie interakcji integracyjnych za integralną część podstawowej logiki biznesowej i przepływu pracy. Aby zapobiec gwałtownemu wzrostowi kwalifikacji programistów aplikacji, tworzenie nowych interakcji integracyjnych powinno być łatwe i niewymagające wysiłku, przy minimalnej możliwości wyboru rozwiązania. Jest to trudniejsze, niż się wydaje: narzędzie musi być na tyle potężne, aby zapewnić użytkownikowi wymaganą różnorodność opcji jego wykorzystania, nie pozwalając mu „strzelić sobie w stopę”. Jest wiele pytań, na które inżynier musi odpowiedzieć w kontekście zadań integracyjnych, ale o których twórca aplikacji nie powinien myśleć w swojej codziennej pracy: granice transakcji, spójność, atomowość, bezpieczeństwo, skalowanie, dystrybucja obciążenia i zasobów, routing, marshaling, konteksty dystrybucyjne, przełączające itp. Konieczne jest zaoferowanie twórcom aplikacji w miarę prostych szablonów rozwiązań, w których odpowiedzi na wszystkie tego typu pytania są już ukryte. Szablony te muszą być w miarę bezpieczne: logika biznesowa zmienia się bardzo często, co zwiększa ryzyko wprowadzenia błędów, koszt błędów musi pozostać na w miarę niskim poziomie.
Ale co ma z tym wspólnego BPM? Istnieje wiele możliwości wdrożenia przepływu pracy...
Rzeczywiście, w naszych rozwiązaniach bardzo popularna jest kolejna implementacja procesów biznesowych - poprzez deklaratywne zdefiniowanie diagramu przejść stanów i powiązanie procedur obsługi z logiką biznesową dla przejść. W tym przypadku stan określający aktualną pozycję „dokumentu” w procesie biznesowym jest atrybutem samego „dokumentu”.
Tak wygląda proces na początku projektu
Popularność tego wdrożenia wynika ze względnej prostoty i szybkości tworzenia liniowych procesów biznesowych. Jednakże w miarę jak systemy oprogramowania stają się coraz bardziej złożone, zautomatyzowana część procesu biznesowego rośnie i staje się coraz bardziej złożona. Istnieje potrzeba dekompozycji, ponownego wykorzystania części procesów, a także rozgałęzienia procesów, tak aby każdy oddział był realizowany równolegle. W takich warunkach narzędzie staje się niewygodne, a diagram przejść stanów traci swoją zawartość informacyjną (interakcje integracyjne nie są w ogóle odzwierciedlone na diagramie).
Tak wygląda proces po kilku iteracjach wyjaśniania wymagań.
Wyjściem z tej sytuacji była integracja silnika jBPM w niektóre produkty z najbardziej złożonymi procesami biznesowymi. Na krótką metę to rozwiązanie odniosło pewien sukces: stało się możliwe wdrażanie złożonych procesów biznesowych przy zachowaniu dość informacyjnego i odpowiedniego diagramu w notacji BPMN2.
Mała część złożonego procesu biznesowego
Na dłuższą metę rozwiązanie nie spełniło oczekiwań: duża pracochłonność tworzenia procesów biznesowych za pomocą narzędzi wizualnych nie pozwalała na osiągnięcie akceptowalnych wskaźników produktywności, a samo narzędzie stało się jednym z najbardziej nielubianych przez programistów. Pojawiły się również skargi na wewnętrzną konstrukcję silnika, co doprowadziło do pojawienia się wielu „łat” i „kul”.
Głównym pozytywnym aspektem stosowania jBPM była świadomość korzyści i szkód wynikających z posiadania własnego, trwałego stanu instancji procesu biznesowego. Dostrzegliśmy także możliwość zastosowania podejścia procesowego do wdrożenia złożonych protokołów integracji pomiędzy różnymi aplikacjami, wykorzystując asynchroniczne interakcje poprzez sygnały i komunikaty. Obecność stanu trwałego odgrywa w tym kluczową rolę.
Na podstawie powyższego możemy stwierdzić: Podejście procesowe w stylu BPM pozwala na rozwiązywanie szerokiego zakresu zadań w celu automatyzacji coraz bardziej złożonych procesów biznesowych, harmonijnego dopasowania działań integracyjnych do tych procesów i zachowania możliwości wizualnego zobrazowania realizowanego procesu w odpowiedniej notacji.
Wady wywołań synchronicznych jako wzorca integracji
Integracja synchroniczna odnosi się do najprostszego połączenia blokującego. Jeden podsystem działa po stronie serwera i udostępnia API za pomocą wymaganej metody. Inny podsystem pełni rolę klienta i we właściwym momencie wykonuje połączenie i czeka na wynik. W zależności od architektury systemu strona klienta i serwera mogą znajdować się albo w tej samej aplikacji i procesie, albo w różnych. W drugim przypadku musisz zastosować implementację RPC i zapewnić zestawienie parametrów i wyniku wywołania.
Ten wzorzec integracji ma dość duży zestaw wad, ale ze względu na swoją prostotę jest bardzo szeroko stosowany w praktyce. Szybkość wdrożenia urzeka i zmusza do wielokrotnego korzystania z niej w obliczu napiętych terminów, zapisując rozwiązanie jako dług techniczny. Ale zdarza się również, że niedoświadczeni programiści używają go nieświadomie, po prostu nie zdając sobie sprawy z negatywnych konsekwencji.
Oprócz najbardziej oczywistego wzrostu łączności podsystemów, istnieją również mniej oczywiste problemy związane z „rozrastaniem się” i „rozciąganiem” transakcji. Rzeczywiście, jeśli logika biznesowa wprowadza pewne zmiany, nie da się uniknąć transakcji, a transakcje z kolei blokują określone zasoby aplikacji, na które te zmiany mają wpływ. Oznacza to, że dopóki jeden podsystem nie zaczeka na odpowiedź drugiego, nie będzie w stanie sfinalizować transakcji i usunąć blokad. To znacznie zwiększa ryzyko różnych skutków:
Utrata responsywności systemu, użytkownicy długo czekają na odpowiedzi na zapytania;
serwer generalnie przestaje odpowiadać na żądania użytkowników z powodu przepełnienia puli wątków: większość wątków jest zablokowana na zasobie zajmowanym przez transakcję;
Zaczynają pojawiać się zakleszczenia: prawdopodobieństwo ich wystąpienia silnie zależy od czasu trwania transakcji, poziomu logiki biznesowej i blokad występujących w transakcji;
pojawiają się błędy przekroczenia limitu czasu transakcji;
serwer „zawodzi” z OutOfMemory, jeśli zadanie wymaga przetwarzania i zmiany dużych ilości danych, a obecność integracji synchronicznych sprawia, że bardzo trudno jest podzielić przetwarzanie na „lżejsze” transakcje.
Z architektonicznego punktu widzenia stosowanie blokowania wywołań podczas integracji prowadzi do utraty kontroli nad jakością poszczególnych podsystemów: niemożliwe jest zapewnienie docelowych wskaźników jakości jednego podsystemu w oderwaniu od wskaźników jakości innego podsystemu. Jeśli podsystemy są opracowywane przez różne zespoły, jest to duży problem.
Sprawa staje się jeszcze bardziej interesująca, jeśli integrowane podsystemy znajdują się w różnych aplikacjach i trzeba wprowadzać synchroniczne zmiany po obu stronach. Jak zapewnić transakcyjność tych zmian?
Jeśli zmiany zostaną wprowadzone w oddzielnych transakcjach, konieczne będzie zapewnienie niezawodnej obsługi wyjątków i kompensacji, co całkowicie eliminuje główną zaletę integracji synchronicznej – prostotę.
Przychodzą mi na myśl również transakcje rozproszone, ale w naszych rozwiązaniach ich nie stosujemy: trudno zapewnić niezawodność.
„Saga” jako rozwiązanie problemu transakcyjnego
Wraz z rosnącą popularnością mikrousług rośnie zapotrzebowanie na Wzór Sagi.
Wzorzec ten doskonale rozwiązuje powyższe problemy długich transakcji, a także rozszerza możliwości zarządzania stanem systemu od strony logiki biznesowej: rekompensata po nieudanej transakcji może nie przywrócić systemu do stanu pierwotnego, ale zapewnić alternatywna droga przetwarzania danych. Pozwala to również uniknąć powtarzania pomyślnie zakończonych etapów przetwarzania danych przy próbie doprowadzenia procesu do „dobrego” zakończenia.
Co ciekawe, w systemach monolitycznych ten schemat ma również znaczenie, jeśli chodzi o integrację luźno powiązanych podsystemów i obserwuje się negatywne skutki spowodowane długotrwałymi transakcjami i odpowiadającymi im blokadami zasobów.
W odniesieniu do naszych procesów biznesowych w stylu BPM okazuje się, że bardzo łatwo jest wdrożyć „Sagi”: poszczególne etapy „Sagi” można określić jako działania w ramach procesu biznesowego, a trwały stan procesu biznesowego również określa stan wewnętrzny „Sagi”. Oznacza to, że nie potrzebujemy żadnego dodatkowego mechanizmu koordynacji. Wszystko czego potrzebujesz to broker komunikatów, który obsługuje gwarancje „przynajmniej raz” jako transport.
Ale to rozwiązanie ma również swoją „cenę”:
logika biznesowa staje się bardziej złożona: należy wypracować rekompensatę;
konieczna będzie rezygnacja z pełnej spójności, która może być szczególnie wrażliwa w przypadku systemów monolitycznych;
Architektura staje się nieco bardziej skomplikowana i pojawia się dodatkowa potrzeba brokera komunikatów;
wymagane będą dodatkowe narzędzia monitorujące i administracyjne (choć ogólnie jest to dobre: wzrośnie jakość obsługi systemu).
W przypadku systemów monolitycznych uzasadnienie użycia „Sag” nie jest tak oczywiste. W przypadku mikroserwisów i innych SOA, gdzie najprawdopodobniej istnieje już broker, a pełna spójność jest poświęcana na początku projektu, korzyści z zastosowania tego wzorca mogą znacząco przeważyć nad wadami, zwłaszcza jeśli na logice biznesowej znajduje się wygodne API poziom.
Hermetyzacja logiki biznesowej w mikroserwisach
Kiedy zaczęliśmy eksperymentować z mikroserwisami, pojawiło się uzasadnione pytanie: gdzie umieścić logikę biznesową domeny w stosunku do usługi zapewniającej trwałość danych domeny?
Patrząc na architekturę różnych systemów BPMS, rozsądne może wydawać się oddzielenie logiki biznesowej od trwałości: utwórz warstwę niezależnych od platformy i domeny mikrousług, które tworzą środowisko i kontener do wykonywania logiki biznesowej domeny oraz zaprojektuj trwałość danych domeny jako osobna warstwa bardzo prostych i lekkich mikroserwisów. W tym przypadku procesy biznesowe koordynują usługi warstwy trwałości.
Takie podejście ma bardzo dużą zaletę: możesz dowolnie zwiększać funkcjonalność platformy i tylko odpowiednia warstwa mikroserwisów platformy stanie się przez to „gruba”. Procesy biznesowe z dowolnej domeny mogą od razu korzystać z nowej funkcjonalności platformy, gdy tylko zostanie ona zaktualizowana.
Bardziej szczegółowe badanie ujawniło istotne wady tego podejścia:
usługa platformy, która realizuje logikę biznesową wielu domen jednocześnie, niesie ze sobą ogromne ryzyko jako pojedynczy punkt awarii. Częste zmiany w logice biznesowej zwiększają ryzyko błędów prowadzących do awarii całego systemu;
problemy z wydajnością: logika biznesowa współpracuje z danymi poprzez wąski i powolny interfejs:
dane zostaną ponownie zebrane i przepompowane przez stos sieciowy;
usługa domenowa często będzie dostarczać więcej danych, niż jest to wymagane do przetworzenia logiki biznesowej ze względu na niewystarczające możliwości parametryzacji żądań na poziomie zewnętrznego API usługi;
kilka niezależnych elementów logiki biznesowej może wielokrotnie żądać tych samych danych do przetworzenia (problem ten można złagodzić, dodając komponenty sesji buforujące dane, ale to jeszcze bardziej komplikuje architekturę i stwarza problemy związane z istotnością danych i unieważnianiem pamięci podręcznej);
problemy z transakcją:
procesy biznesowe o stanie trwałym, który jest przechowywany przez usługę platformy, są niezgodne z danymi domenowymi i nie ma prostych sposobów rozwiązania tego problemu;
umieszczenie blokowania danych domeny poza transakcją: jeżeli logika biznesowa domeny wymaga wprowadzenia zmian po uprzednim sprawdzeniu poprawności aktualnych danych, należy wykluczyć możliwość konkurencyjnej zmiany przetwarzanych danych. Zewnętrzne blokowanie danych może pomóc w rozwiązaniu problemu, jednak takie rozwiązanie niesie ze sobą dodatkowe ryzyko i zmniejsza ogólną niezawodność systemu;
dodatkowe trudności przy aktualizacji: w niektórych przypadkach usługa trwałości i logika biznesowa muszą być aktualizowane synchronicznie lub w ścisłej kolejności.
Ostatecznie musieliśmy wrócić do podstaw: enkapsulować dane domeny i logikę biznesową domeny w jedną mikrousługę. Takie podejście upraszcza postrzeganie mikroserwisu jako integralnego elementu systemu i nie rodzi powyższych problemów. To również nie jest rozdawane za darmo:
Standaryzacja API jest wymagana w przypadku interakcji z logiką biznesową (w szczególności w celu zapewnienia działań użytkownika w ramach procesów biznesowych) i usług platformy API; wymaga większej uwagi przy zmianach API oraz zgodności z poprzednimi i starszymi wersjami;
konieczne jest dodanie dodatkowych bibliotek wykonawczych, aby zapewnić funkcjonowanie logiki biznesowej w ramach każdego takiego mikroserwisu, a to rodzi nowe wymagania dla takich bibliotek: lekkość i minimum zależności przechodnich;
programiści logiki biznesowej muszą monitorować wersje bibliotek: jeśli mikrousługa nie była sfinalizowana przez długi czas, najprawdopodobniej będzie zawierała przestarzałą wersję bibliotek. Może to stanowić nieoczekiwaną przeszkodę w dodaniu nowej funkcji i może wymagać migracji starej logiki biznesowej takiej usługi do nowych wersji bibliotek, jeśli między wersjami wystąpiły niezgodne zmiany.
W takiej architekturze obecna jest także warstwa usług platformowych, jednak warstwa ta nie stanowi już kontenera realizującego domenową logikę biznesową, a jedynie jej środowisko, dostarczające pomocniczych funkcji „platformowych”. Taka warstwa jest potrzebna nie tylko do utrzymania lekkiego charakteru mikroserwisów domenowych, ale także do centralizacji zarządzania.
Na przykład działania użytkownika w procesach biznesowych generują zadania. Jednak pracując z zadaniami, użytkownik musi zobaczyć na liście ogólnej zadania ze wszystkich domen, co oznacza, że musi istnieć odpowiednia usługa rejestracji zadań platformy, oczyszczona z logiki biznesowej domeny. Utrzymanie enkapsulacji logiki biznesowej w takim kontekście jest dość problematyczne i jest to kolejny kompromis tej architektury.
Integracja procesów biznesowych oczami twórcy aplikacji
Jak wspomniano powyżej, twórca aplikacji musi być oderwany od technicznych i inżynieryjnych cech wdrażania interakcji kilku aplikacji, aby można było liczyć na dobrą produktywność programowania.
Spróbujmy rozwiązać dość trudny problem integracji, wymyślony specjalnie na potrzeby artykułu. Będzie to zadanie typu „gra” obejmujące trzy aplikacje, z których każda definiuje określoną nazwę domeny: „app1”, „app2”, „app3”.
Wewnątrz każdej aplikacji uruchamiane są procesy biznesowe, które poprzez szynę integracyjną zaczynają „grać w piłkę”. Wiadomości o nazwie „Ball” będą działać jak piłka.
Zasady gry:
pierwszy gracz jest inicjatorem. Zaprasza innych graczy do gry, rozpoczyna grę i może ją zakończyć w dowolnym momencie;
pozostali gracze deklarują swój udział w grze, „poznają” siebie i pierwszego gracza;
po otrzymaniu piłki gracz wybiera innego uczestniczącego zawodnika i podaje mu piłkę. Liczona jest całkowita liczba transmisji;
Każdy zawodnik posiada „energię”, która maleje wraz z każdym podaniem przez niego piłki. Kiedy energia się wyczerpie, gracz opuszcza grę, ogłaszając swoją rezygnację;
jeśli gracz zostaje sam, natychmiast ogłasza swoje odejście;
Kiedy wszyscy gracze zostaną wyeliminowani, pierwszy gracz ogłasza koniec gry. Jeśli opuści grę wcześniej, pozostaje śledzić grę, aby ją ukończyć.
Aby rozwiązać ten problem, użyję naszego DSL do procesów biznesowych, co pozwala nam zwięźle opisać logikę w Kotlinie, przy minimalnej liczbie szablonów.
Proces biznesowy pierwszego gracza (czyli inicjatora gry) będzie działał w aplikacji app1:
klasa OriginPlayer
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<PlayerInfo>()
// Это класс экземпляра процесса: инкапсулирует его внутреннее состояние
class InitialPlayer : ProcessImpl<InitialPlayer>(initialPlayerModel) {
var playerName: String by persistent("Player1")
var energy: Int by persistent(30)
var players: PlayersList by persistent(PlayersList())
var shotCounter: Int = 0
}
// Это декларация модели процесса: создается один раз, используется всеми
// экземплярами процесса соответствующего класса
val initialPlayerModel = processModel<InitialPlayer>(name = "InitialPlayer",
version = 1) {
// По правилам, первый игрок является инициатором игры и должен быть единственным
uniqueConstraint = UniqueConstraints.singleton
// Объявляем активности, из которых состоит бизнес-процесс
val sendNewGameSignal = signal<String>("NewGame")
val sendStopGameSignal = signal<String>("StopGame")
val startTask = humanTask("Start") {
taskOperation {
processCondition { players.size > 0 }
confirmation { "Подключилось ${players.size} игроков. Начинаем?" }
}
}
val stopTask = humanTask("Stop") {
taskOperation {}
}
val waitPlayerJoin = signalWait<String>("PlayerJoin") { signal ->
players.add(PlayerInfo(
signal.data!!,
signal.sender.domain,
signal.sender.processInstanceId))
println("... join player ${signal.data} ...")
}
val waitPlayerOut = signalWait<String>("PlayerOut") { signal ->
players.remove(PlayerInfo(
signal.data!!,
signal.sender.domain,
signal.sender.processInstanceId))
println("... player ${signal.data} is out ...")
}
val sendPlayerOut = signal<String>("PlayerOut") {
signalData = { playerName }
}
val sendHandshake = messageSend<String>("Handshake") {
messageData = { playerName }
activation = {
receiverDomain = process.players.last().domain
receiverProcessInstanceId = process.players.last().id
}
}
val throwStartBall = messageSend<Int>("Ball") {
messageData = { 1 }
activation = { selectNextPlayer() }
}
val throwBall = messageSend<Int>("Ball") {
messageData = { shotCounter + 1 }
activation = { selectNextPlayer() }
onEntry { energy -= 1 }
}
val waitBall = messageWaitData<Int>("Ball") {
shotCounter = it
}
// Теперь конструируем граф процесса из объявленных активностей
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)
}
// Навешаем на активности дополнительные обработчики для логирования
sendNewGameSignal.onExit { println("Let's play!") }
sendStopGameSignal.onExit { println("Stop!") }
sendPlayerOut.onExit { println("$playerName: I'm out!") }
}
private fun MessageSendInstance<InitialPlayer, Int>.selectNextPlayer() {
val player = process.players.random()
receiverDomain = player.domain
receiverProcessInstanceId = player.id
println("Step ${process.shotCounter + 1}: " +
"${process.playerName} >>> ${player.name}")
}
Oprócz wykonywania logiki biznesowej, powyższy kod może wygenerować model obiektowy procesu biznesowego, który można zwizualizować w formie diagramu. Nie zaimplementowaliśmy jeszcze wizualizatora, więc musieliśmy poświęcić trochę czasu na jego narysowanie (tutaj nieco uprościłem notację BPMN dotyczącą użycia bramek, aby poprawić spójność diagramu z podanym kodem):
app2 będzie zawierać proces biznesowy drugiego gracza:
klasa 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<PlayerInfo>()
class RandomPlayer : ProcessImpl<RandomPlayer>(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<RandomPlayer>(name = "RandomPlayer",
version = 1) {
val waitNewGameSignal = signalWait<String>("NewGame")
val waitStopGameSignal = signalWait<String>("StopGame")
val sendPlayerJoin = signal<String>("PlayerJoin") {
signalData = { playerName }
}
val sendPlayerOut = signal<String>("PlayerOut") {
signalData = { playerName }
}
val waitPlayerJoin = signalWaitCustom<String>("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<String>("PlayerOut") { signal ->
players.remove(PlayerInfo(
signal.data!!,
signal.sender.domain,
signal.sender.processInstanceId))
allPlayersOut = players.isEmpty()
}
val sendHandshake = messageSend<String>("Handshake") {
messageData = { playerName }
activation = {
receiverDomain = process.players.last().domain
receiverProcessInstanceId = process.players.last().id
}
}
val receiveHandshake = messageWait<String>("Handshake") { message ->
if (!players.any { message.sender.processInstanceId == it.id}) {
players.add(PlayerInfo(
message.data!!,
message.sender.domain,
message.sender.processInstanceId))
}
}
val throwBall = messageSend<Int>("Ball") {
messageData = { shotCounter + 1 }
activation = { selectNextPlayer() }
onEntry { energy -= 1 }
}
val waitBall = messageWaitData<Int>("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<RandomPlayer, Int>.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 stworzymy gracza o nieco innym zachowaniu: zamiast losowo wybierać kolejnego gracza, będzie on działał według algorytmu round-robin:
klasa 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<PlayerInfo>()
class RoundRobinPlayer : ProcessImpl<RoundRobinPlayer>(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<RoundRobinPlayer>(
name = "RoundRobinPlayer",
version = 1) {
val waitNewGameSignal = signalWait<String>("NewGame")
val waitStopGameSignal = signalWait<String>("StopGame")
val sendPlayerJoin = signal<String>("PlayerJoin") {
signalData = { playerName }
}
val sendPlayerOut = signal<String>("PlayerOut") {
signalData = { playerName }
}
val waitPlayerJoin = signalWaitCustom<String>("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<String>("PlayerOut") { signal ->
players.remove(PlayerInfo(
signal.data!!,
signal.sender.domain,
signal.sender.processInstanceId))
allPlayersOut = players.isEmpty()
}
val sendHandshake = messageSend<String>("Handshake") {
messageData = { playerName }
activation = {
receiverDomain = process.players.last().domain
receiverProcessInstanceId = process.players.last().id
}
}
val receiveHandshake = messageWait<String>("Handshake") { message ->
if (!players.any { message.sender.processInstanceId == it.id}) {
players.add(PlayerInfo(
message.data!!,
message.sender.domain,
message.sender.processInstanceId))
}
}
val throwBall = messageSend<Int>("Ball") {
messageData = { shotCounter + 1 }
activation = { selectNextPlayer() }
onEntry { energy -= 1 }
}
val waitBall = messageWaitData<Int>("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<RoundRobinPlayer, Int>.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 przeciwnym razie zachowanie gracza nie różni się od poprzedniego, więc diagram się nie zmienia.
Teraz potrzebujemy testu, aby to wszystko uruchomić. Podam tylko kod samego testu, żeby nie zaśmiecać artykułu szablonami (w rzeczywistości stworzyłem wcześniej środowisko testowe, aby przetestować integrację innych procesów biznesowych):
gra testowa()
@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");
// Теперь нужно немного подождать, пока игроки "познакомятся" друг с другом.
// Ждать через sleep - плохое решение, зато самое простое.
// Не делайте так в серьезных тестах!
Thread.sleep(1000);
// Запускаем игру, закрывая пользовательскую активность
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<String, Object> playerParams(String name, int energy) {
Map<String, Object> params = new HashMap<>();
params.put("playerName", name);
params.put("energy", energy);
return params;
}
Z tego wszystkiego możemy wyciągnąć kilka ważnych wniosków:
dzięki niezbędnym narzędziom twórcy aplikacji mogą tworzyć interakcje integracyjne pomiędzy aplikacjami bez zakłócania logiki biznesowej;
złożoność zadania integracyjnego wymagającego kompetencji inżynierskich można ukryć w ramach, jeśli zostanie to początkowo uwzględnione w architekturze platformy. Trudności problemu nie da się ukryć, więc rozwiązanie trudnego problemu w kodzie będzie tak wyglądać;
Przy opracowywaniu logiki integracji należy koniecznie uwzględnić spójność ostateczną i brak linearyzowalności zmian stanu wszystkich uczestników integracji. Zmusza to do skomplikowania logiki, aby uczynić ją niewrażliwą na kolejność, w jakiej zachodzą zdarzenia zewnętrzne. W naszym przykładzie gracz zmuszony jest wziąć udział w grze po tym, jak zadeklaruje swoje wyjście z gry: pozostali gracze będą mu nadal podawać piłkę, dopóki nie dotrze informacja o jego wyjściu i nie zostanie przetworzona przez wszystkich uczestników. Logika ta nie wynika z reguł gry i jest rozwiązaniem kompromisowym w ramach wybranej architektury.
Następnie porozmawiamy o różnych zawiłościach naszego rozwiązania, kompromisach i innych kwestiach.
Wszystkie wiadomości znajdują się w jednej kolejce
Wszystkie zintegrowane aplikacje współpracują z jedną magistralą integracyjną, która prezentowana jest w postaci zewnętrznego brokera, jednym BPMQueue dla komunikatów i jednym tematem BPMTopic dla sygnałów (zdarzeń). Umieszczenie wszystkich wiadomości w jednej kolejce samo w sobie jest kompromisem. Na poziomie logiki biznesowej możesz teraz wprowadzić dowolną liczbę nowych typów wiadomości, bez konieczności dokonywania zmian w strukturze systemu. Jest to znaczne uproszczenie, jednak niesie ze sobą pewne ryzyka, które w kontekście naszych typowych zadań nie wydawały nam się aż tak istotne.
Jest tu jednak pewien niuans: każda aplikacja filtruje „swoje” wiadomości z kolejki przy wejściu, po nazwie swojej domeny. Dziedzinę można także określić w sygnałach, jeśli zachodzi potrzeba ograniczenia „zakresu widoczności” sygnału do jednej aplikacji. Powinno to zwiększyć przepustowość magistrali, ale logika biznesowa musi teraz działać z nazwami domen: w przypadku adresowania wiadomości – obowiązkowe, w przypadku sygnałów – pożądane.
Wybrany broker komunikatów jest krytycznym elementem architektury i pojedynczym punktem awarii: musi być wystarczająco odporny na błędy. Powinieneś używać tylko sprawdzonych wdrożeń, z dobrym wsparciem i dużą społecznością;
konieczne jest zapewnienie wysokiej dostępności brokera komunikatów, dla którego musi on być fizycznie oddzielony od integrowanych aplikacji (zapewnienie wysokiej dostępności aplikacji z zastosowaną logiką biznesową jest znacznie trudniejsze i droższe);
broker ma obowiązek udzielić „przynajmniej jednorazowo” gwarancji dostawy. Jest to obowiązkowy wymóg niezawodnej pracy magistrali integracyjnej. Nie ma potrzeby stosowania gwarancji na poziomie „dokładnie raz”: procesy biznesowe z reguły nie są wrażliwe na wielokrotne napływanie wiadomości lub zdarzeń, a w zadaniach specjalnych, gdzie jest to ważne, łatwiej jest dodać dodatkowe kontrole do firmy logika, niż stale korzystać z dość „drogich” „gwarancji”;
wysyłanie komunikatów i sygnałów musi wiązać się z całościową transakcją ze zmianami stanu procesów biznesowych i danych domenowych. Preferowaną opcją byłoby użycie wzoru Transakcyjna skrzynka nadawcza, ale będzie to wymagało dodatkowej tabeli w bazie danych i wzmacniacza. W aplikacjach JEE można to uprościć wykorzystując lokalnego menadżera JTA, jednak połączenie z wybranym brokerem musi działać w XA;
procedury obsługi przychodzących komunikatów i zdarzeń muszą także pracować z transakcją zmieniającą stan procesu biznesowego: jeśli taka transakcja zostanie wycofana, otrzymanie wiadomości musi zostać anulowane;
wiadomości, które nie mogły zostać dostarczone z powodu błędów, muszą być przechowywane w osobnym magazynie D.L.Q. (Kolejka martwych listów). W tym celu stworzyliśmy osobną mikrousługę platformy, która przechowuje takie wiadomości w swojej pamięci, indeksuje je według atrybutów (w celu szybkiego grupowania i wyszukiwania) oraz udostępnia API umożliwiające przeglądanie, ponowne wysyłanie na adres docelowy i usuwanie wiadomości. Administratorzy systemu mogą pracować z tą usługą poprzez swój interfejs sieciowy;
w ustawieniach brokera należy dostosować liczbę ponownych dostaw i opóźnienia pomiędzy dostawami, aby zmniejszyć prawdopodobieństwo przedostania się wiadomości do DLQ (obliczenie optymalnych parametrów jest prawie niemożliwe, ale można działać empirycznie i dostosowywać je w trakcie pracy );
Magazyn DLQ musi być stale monitorowany, a system monitorowania musi ostrzegać administratorów systemu, aby w przypadku wystąpienia niedostarczonych wiadomości mogli zareagować tak szybko, jak to możliwe. Zmniejszy to „obszar dotknięty” awarią lub błędem logiki biznesowej;
szyna integracyjna musi być niewrażliwa na chwilowy brak aplikacji: subskrypcje tematu muszą być trwałe, a nazwa domeny aplikacji musi być unikalna, aby w czasie nieobecności aplikacji ktoś inny nie próbował przetworzyć jej wiadomości z poziomu kolejka.
Zapewnienie bezpieczeństwa wątków logiki biznesowej
Ta sama instancja procesu biznesowego może otrzymać jednocześnie kilka komunikatów i zdarzeń, których przetwarzanie rozpocznie się równolegle. Jednocześnie dla twórcy aplikacji wszystko powinno być proste i bezpieczne dla wątków.
Logika biznesowa procesu przetwarza indywidualnie każde zdarzenie zewnętrzne, które wpływa na ten proces biznesowy. Takimi zdarzeniami mogą być:
uruchomienie instancji procesu biznesowego;
działanie użytkownika związane z aktywnością w procesie biznesowym;
otrzymanie komunikatu lub sygnału, do którego subskrybowana jest instancja procesu biznesowego;
wyzwalanie timera ustawionego przez instancję procesu biznesowego;
sterowanie akcją poprzez API (np. przerwanie procesu).
Każde takie zdarzenie może zmienić stan instancji procesu biznesowego: niektóre działania mogą się zakończyć, inne rozpocząć, a wartości trwałych właściwości mogą ulec zmianie. Zamknięcie dowolnej czynności może skutkować aktywacją jednej lub większej liczby poniższych czynności. Ci z kolei mogą przestać czekać na inne zdarzenia lub, jeśli nie potrzebują dodatkowych danych, mogą zakończyć tę samą transakcję. Przed zamknięciem transakcji nowy stan procesu biznesowego zapisywany jest w bazie danych, gdzie będzie on oczekiwał na wystąpienie kolejnego zdarzenia zewnętrznego.
Trwałe dane procesów biznesowych przechowywane w relacyjnej bazie danych są bardzo wygodnym punktem synchronizacji przetwarzania, jeśli użyjesz opcji SELECT FOR UPDATE. Jeżeli jednej transakcji udało się uzyskać stan procesu biznesowego z bazy do jego zmiany, to żadna inna transakcja równoległa nie będzie w stanie uzyskać tego samego stanu dla innej zmiany, a po zakończeniu pierwszej transakcji następuje druga gwarantowane otrzymanie już zmienionego stanu.
Stosując pesymistyczne blokady po stronie DBMS, spełniamy wszystkie niezbędne wymagania ACID, a także zachować możliwość skalowania aplikacji z logiką biznesową poprzez zwiększenie liczby uruchomionych instancji.
Jednak blokady pesymistyczne grożą nam zakleszczeniami, co oznacza, że opcja SELECT FOR UPDATE powinna nadal być ograniczona do rozsądnego limitu czasu na wypadek wystąpienia zakleszczeń w niektórych rażących przypadkach w logice biznesowej.
Kolejnym problemem jest synchronizacja rozpoczęcia procesu biznesowego. Chociaż nie ma instancji procesu biznesowego, w bazie danych nie ma stanu, więc opisana metoda nie będzie działać. Jeśli chcesz zapewnić unikalność instancji procesu biznesowego w określonym zakresie, będziesz potrzebować pewnego rodzaju obiektu synchronizacji powiązanego z klasą procesu i odpowiadającym mu zakresem. Aby rozwiązać ten problem, używamy innego mechanizmu blokującego, który pozwala nam zablokować dowolny zasób określony kluczem w formacie URI za pośrednictwem usługi zewnętrznej.
W naszych przykładach proces biznesowy OriginPlayer zawiera deklarację
uniqueConstraint = UniqueConstraints.singleton
Dlatego w logu znajdują się komunikaty o zabraniu i zwolnieniu blokady odpowiedniego klucza. Dla innych procesów biznesowych nie ma takich komunikatów: nie ustawiono UniqueConstraint.
Problemy procesów biznesowych o stanie trwałym
Czasami posiadanie trwałego stanu nie tylko pomaga, ale naprawdę utrudnia rozwój.
Problemy zaczynają się, gdy należy wprowadzić zmiany w logice biznesowej i/lub modelu procesu biznesowego. Nie każda taka zmiana jest zgodna ze starym stanem procesów biznesowych. Jeśli w bazie danych znajduje się wiele aktywnych instancji, wówczas wprowadzenie niezgodnych zmian może przysporzyć sporo kłopotów, z którymi często spotykamy się przy korzystaniu z jBPM.
W zależności od głębokości zmian możesz działać na dwa sposoby:
utwórz nowy typ procesu biznesowego, aby nie wprowadzać niezgodnych zmian w starym i używaj go zamiast starego podczas uruchamiania nowych instancji. Stare kopie będą nadal działać „jak dotychczas”;
migruj trwały stan procesów biznesowych podczas aktualizacji logiki biznesowej.
Pierwszy sposób jest prostszy, ale ma swoje ograniczenia i wady, na przykład:
powielanie logiki biznesowej w wielu modelach procesów biznesowych, zwiększając objętość logiki biznesowej;
Często wymagane jest natychmiastowe przejście na nową logikę biznesową (w zakresie zadań integracyjnych - prawie zawsze);
deweloper nie wie, w którym momencie przestarzałe modele można usunąć.
W praktyce stosujemy oba podejścia, jednak podjęliśmy szereg decyzji ułatwiających nam życie:
W bazie danych trwały stan procesu biznesowego przechowywany jest w czytelnej i łatwej do przetworzenia formie: w ciągu znaków w formacie JSON. Dzięki temu migracje można wykonywać zarówno wewnątrz aplikacji, jak i na zewnątrz. W ostateczności możesz to poprawić ręcznie (szczególnie przydatne w programowaniu podczas debugowania);
logika biznesowa integracji nie wykorzystuje nazw procesów biznesowych, dzięki czemu w dowolnym momencie istnieje możliwość zastąpienia realizacji jednego z uczestniczących procesów nowym o nowej nazwie (na przykład „InitialPlayerV2”). Powiązanie następuje poprzez nazwy komunikatów i sygnałów;
model procesu posiada numer wersji, który zwiększamy w przypadku wprowadzenia w tym modelu niezgodnych zmian i numer ten jest zapisywany wraz ze stanem instancji procesu;
trwały stan procesu jest najpierw odczytywany z bazy danych do wygodnego modelu obiektowego, z którym może współpracować procedura migracji w przypadku zmiany numeru wersji modelu;
procedura migracji umieszczona jest obok logiki biznesowej i dla każdej instancji procesu biznesowego w momencie jego przywrócenia z bazy nazywana jest „leniwą”;
jeśli chcesz szybko i synchronicznie przeprowadzić migrację stanu wszystkich instancji procesów, stosuje się bardziej klasyczne rozwiązania do migracji baz danych, ale musisz pracować z JSON.
Potrzebujesz innego frameworku dla procesów biznesowych?
Rozwiązania opisane w artykule pozwoliły znacząco uprościć nam życie, poszerzyć zakres zagadnień rozwiązywanych na poziomie tworzenia aplikacji oraz uatrakcyjnić ideę rozdzielenia logiki biznesowej na mikroserwisy. Aby to osiągnąć włożono wiele pracy, stworzono bardzo „lekki” framework dla procesów biznesowych, a także komponenty usług rozwiązujące zidentyfikowane problemy w kontekście szerokiego spektrum problemów aplikacyjnych. Chcielibyśmy podzielić się tymi wynikami i udostępnić rozwój wspólnych komponentów na wolnej licencji. Będzie to wymagało trochę wysiłku i czasu. Zrozumienie zapotrzebowania na tego typu rozwiązania mogłoby być dla nas dodatkową zachętą. W proponowanym artykule bardzo mało uwagi poświęcono możliwościom samego frameworka, choć niektóre z nich są widoczne na przedstawionych przykładach. Jeśli opublikujemy nasz framework, poświęcimy mu osobny artykuł. W międzyczasie będziemy wdzięczni, jeśli zostawisz nam swoją opinię, odpowiadając na pytanie:
W ankiecie mogą brać udział tylko zarejestrowani użytkownicy. Zaloguj się, Proszę.
Potrzebujesz innego frameworku dla procesów biznesowych?
18,8%Tak, szukałem czegoś takiego od dłuższego czasu
12,5%Chcę dowiedzieć się więcej o Twojej implementacji, może się przydać2
6,2%Korzystamy z jednego z istniejących frameworków, ale myślimy o zastąpieniu 1
18,8%Korzystamy z jednego z istniejących frameworków, wszystko jest w porządku3
18,8%radzimy sobie bez frameworków3
25,0%napisz swoje4
Głosowało 16 użytkowników. 7 użytkowników wstrzymało się od głosu.