Integracja w stylu BPM

Integracja w stylu BPM

Witaj, Habr!

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”.

Integracja w stylu BPM
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).

Integracja w stylu BPM
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.

Integracja w stylu BPM
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.

Integracja w stylu BPM

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.

Integracja w stylu BPM

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.

Integracja w stylu BPM

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):

Integracja w stylu BPM

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:

Integracja w stylu BPM

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;
}

Uruchommy test i spójrzmy na dziennik:

wyjście konsoli

Взята блокировка ключа lock://app1/process/InitialPlayer
Let's play!
Снята блокировка ключа lock://app1/process/InitialPlayer
Player2: I'm here!
Player3: I'm here!
Player4: I'm here!
Player5: I'm here!
... join player Player2 ...
... join player Player4 ...
... join player Player3 ...
... join player Player5 ...
Step 1: Player1 >>> Player3
Step 2: Player3 >>> Player5
Step 3: Player5 >>> Player3
Step 4: Player3 >>> Player4
Step 5: Player4 >>> Player3
Step 6: Player3 >>> Player4
Step 7: Player4 >>> Player5
Step 8: Player5 >>> Player2
Step 9: Player2 >>> Player5
Step 10: Player5 >>> Player4
Step 11: Player4 >>> Player2
Step 12: Player2 >>> Player4
Step 13: Player4 >>> Player1
Step 14: Player1 >>> Player4
Step 15: Player4 >>> Player3
Step 16: Player3 >>> Player1
Step 17: Player1 >>> Player2
Step 18: Player2 >>> Player3
Step 19: Player3 >>> Player1
Step 20: Player1 >>> Player5
Step 21: Player5 >>> Player1
Step 22: Player1 >>> Player2
Step 23: Player2 >>> Player4
Step 24: Player4 >>> Player5
Step 25: Player5 >>> Player3
Step 26: Player3 >>> Player4
Step 27: Player4 >>> Player2
Step 28: Player2 >>> Player5
Step 29: Player5 >>> Player2
Step 30: Player2 >>> Player1
Step 31: Player1 >>> Player3
Step 32: Player3 >>> Player4
Step 33: Player4 >>> Player1
Step 34: Player1 >>> Player3
Step 35: Player3 >>> Player4
Step 36: Player4 >>> Player3
Step 37: Player3 >>> Player2
Step 38: Player2 >>> Player5
Step 39: Player5 >>> Player4
Step 40: Player4 >>> Player5
Step 41: Player5 >>> Player1
Step 42: Player1 >>> Player5
Step 43: Player5 >>> Player3
Step 44: Player3 >>> Player5
Step 45: Player5 >>> Player2
Step 46: Player2 >>> Player3
Step 47: Player3 >>> Player2
Step 48: Player2 >>> Player5
Step 49: Player5 >>> Player4
Step 50: Player4 >>> Player2
Step 51: Player2 >>> Player5
Step 52: Player5 >>> Player1
Step 53: Player1 >>> Player5
Step 54: Player5 >>> Player3
Step 55: Player3 >>> Player5
Step 56: Player5 >>> Player2
Step 57: Player2 >>> Player1
Step 58: Player1 >>> Player4
Step 59: Player4 >>> Player1
Step 60: Player1 >>> Player4
Step 61: Player4 >>> Player3
Step 62: Player3 >>> Player2
Step 63: Player2 >>> Player5
Step 64: Player5 >>> Player4
Step 65: Player4 >>> Player5
Step 66: Player5 >>> Player1
Step 67: Player1 >>> Player5
Step 68: Player5 >>> Player3
Step 69: Player3 >>> Player4
Step 70: Player4 >>> Player2
Step 71: Player2 >>> Player5
Step 72: Player5 >>> Player2
Step 73: Player2 >>> Player1
Step 74: Player1 >>> Player4
Step 75: Player4 >>> Player1
Step 76: Player1 >>> Player2
Step 77: Player2 >>> Player5
Step 78: Player5 >>> Player4
Step 79: Player4 >>> Player3
Step 80: Player3 >>> Player1
Step 81: Player1 >>> Player5
Step 82: Player5 >>> Player1
Step 83: Player1 >>> Player4
Step 84: Player4 >>> Player5
Step 85: Player5 >>> Player3
Step 86: Player3 >>> Player5
Step 87: Player5 >>> Player2
Step 88: Player2 >>> Player3
Player2: I'm out!
Step 89: Player3 >>> Player4
... player Player2 is out ...
Step 90: Player4 >>> Player1
Step 91: Player1 >>> Player3
Step 92: Player3 >>> Player1
Step 93: Player1 >>> Player4
Step 94: Player4 >>> Player3
Step 95: Player3 >>> Player5
Step 96: Player5 >>> Player1
Step 97: Player1 >>> Player5
Step 98: Player5 >>> Player3
Step 99: Player3 >>> Player5
Step 100: Player5 >>> Player4
Step 101: Player4 >>> Player5
Player4: I'm out!
... player Player4 is out ...
Step 102: Player5 >>> Player1
Step 103: Player1 >>> Player3
Step 104: Player3 >>> Player1
Step 105: Player1 >>> Player3
Step 106: Player3 >>> Player5
Step 107: Player5 >>> Player3
Step 108: Player3 >>> Player1
Step 109: Player1 >>> Player3
Step 110: Player3 >>> Player5
Step 111: Player5 >>> Player1
Step 112: Player1 >>> Player3
Step 113: Player3 >>> Player5
Step 114: Player5 >>> Player3
Step 115: Player3 >>> Player1
Step 116: Player1 >>> Player3
Step 117: Player3 >>> Player5
Step 118: Player5 >>> Player1
Step 119: Player1 >>> Player3
Step 120: Player3 >>> Player5
Step 121: Player5 >>> Player3
Player5: I'm out!
... player Player5 is out ...
Step 122: Player3 >>> Player5
Step 123: Player5 >>> Player1
Player5: I'm out!
Step 124: Player1 >>> Player3
... player Player5 is out ...
Step 125: Player3 >>> Player1
Step 126: Player1 >>> Player3
Player1: I'm out!
... player Player1 is out ...
Step 127: Player3 >>> Player3
Player3: I'm out!
Step 128: Player3 >>> Player3
... player Player3 is out ...
Player3: I'm out!
Stop!
Step 129: Player3 >>> Player3
Player3: I'm out!

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.

Integracja w stylu BPM

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.

Zapewnienie niezawodności magistrali integracyjnej

Niezawodność składa się z kilku punktów:

  • 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:

  1. 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”;
  2. 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.

Źródło: www.habr.com

Dodaj komentarz