Integrazione in stile BPM

Integrazione in stile BPM

Ciao, Habr!

La nostra azienda si specializza nello sviluppo di soluzioni software di classe ERP, in cui una grande parte è costituita da sistemi transazionali con un'enorme quantità di logica aziendale e flussi di documenti simili a SED. Le versioni moderne dei nostri prodotti si basano sulle tecnologie JavaEE, ma stiamo anche sperimentando attivamente con microservizi. Uno dei punti più problematici di queste soluzioni è l'integrazione di diversi sotto-sistemi appartenenti a domini correlati. I task di integrazione ci hanno sempre dato un'enorme testa di dolore, indipendentemente dagli stili architettonici, stack tecnologici e framework che abbiamo utilizzato, tuttavia recentemente si sono notati dei progressi nella risoluzione di tali problemi.

Nell'articolo che vi proponiamo, parlerò dell'esperienza e delle ricerche architettoniche di NPO "Krista" in questo campo. Esamineremo anche un esempio di soluzione semplice a un problema di integrazione dal punto di vista di uno sviluppatore applicativo e scopriremo cosa si nasconde dietro a questa semplicità.

Disclaimer

Le soluzioni architettoniche e tecniche descritte nell'articolo sono proposte da me sulla base della mia esperienza personale nel contesto di compiti specifici. Queste soluzioni non pretendono di essere universali e potrebbero non risultare ottimali in altre condizioni di utilizzo.

Cosa c'entra BPM?

Per rispondere a questa domanda è necessario approfondire un po' la specificità dei compiti applicativi delle nostre soluzioni. La maggior parte della logica aziendale nel nostro tipico sistema transazionale riguarda l'immissione di dati nel DB attraverso interfacce utente, il controllo manuale e automatizzato di questi dati, il loro passaggio attraverso un certo workflow, la pubblicazione in un altro sistema / database analitico / archivio, la generazione di rapporti. Pertanto, la funzione chiave del sistema per i clienti è l'automazione dei loro processi aziendali interni.

Per comodità, utilizziamo nel nostro dialogo il termine "documento" come un'astrazione di un insieme di dati collegati da una chiave comune, alla quale è possibile "associare" un determinato workflow.
Ma come si può gestire la logica di integrazione? Infatti, il compito di integrazione è generato dall'architettura del sistema, che è "scomposta" in parti NON per richiesta del cliente, ma sotto l'influenza di fattori completamente diversi:

  • sotto l'effetto della legge di Conway;
  • a seguito del riutilizzo di sottosistemi precedentemente sviluppati per altri prodotti;
  • secondo la decisione dell'architetto, basata su requisiti non funzionali.

C'è una forte tentazione di separare la logica di integrazione dalla logica di business del workflow principale, per non contaminare la logica di business con artefatti di integrazione e liberare lo sviluppatore applicativo dalla necessità di approfondire le peculiarità del paesaggio architetturale del sistema. Questo approccio presenta alcuni vantaggi, tuttavia la pratica dimostra la sua inefficacia:

  • la risoluzione delle problematiche di integrazione di solito si riduce alle opzioni più semplici sotto forma di chiamate sincrone a causa della limitatezza dei punti di estensione nell'implementazione del workflow principale (sui difetti dell'integrazione sincrona - più avanti);
  • gli artefatti di integrazione penetrano comunque nella logica di business principale quando è necessaria una retroazione da un'altra sottosistema;
  • lo sviluppatore applicativo ignora l'integrazione e può facilmente romperla, modificando il workflow;
  • il sistema smette di essere un'entità unica dal punto di vista dell'utente, diventano evidenti "giunzioni" tra le sottosistemi, sorgono operazioni utente ridondanti che avviano il trasferimento di dati da un sottosistema all'altro.

Un altro approccio è considerare le interazioni di integrazione come parte integrante della logica aziendale principale e del workflow. Affinché i requisiti di qualificazione degli sviluppatori applicativi non salgano alle stelle, la creazione di nuove interazioni di integrazione deve avvenire in modo semplice e disinvolto, con le minime possibilità di scelta del metodo di soluzione. Questo è più difficile di quanto sembri: lo strumento deve essere abbastanza potente da offrire all'utente un'ampia gamma di opzioni per il suo utilizzo, e al contempo non consente di "spararsi sui piedi". Ci sono molte domande a cui l'ingegnere deve rispondere nel contesto delle attività di integrazione, ma che lo sviluppatore applicativo non dovrebbe considerare nel suo lavoro quotidiano: confini delle transazioni, coerenza, atomicità, sicurezza, scalabilità, distribuzione dei carichi e delle risorse, routing, marshalling, diffusione e commutazione dei contesti, ecc. È necessario fornire agli sviluppatori applicativi modelli di soluzioni abbastanza semplici, nei quali siano già nascoste risposte a tutte queste domande. Questi modelli devono essere sufficientemente sicuri: la logica aziendale cambia molto frequentemente, il che aumenta il rischio di errori, e il costo degli errori deve rimanere a un livello accettabile.

Ma cosa c'entra il BPM? Ci sono molte opzioni per l'implementazione dei workflow…
In effetti, nelle nostre soluzioni è molto popolare un'altra implementazione dei processi aziendali: tramite la definizione dichiarativa di diagrammi di transizione di stato e il collegamento di gestori con la logica aziendale alle transizioni. In questo caso, lo stato che definisce la posizione attuale del "documento" nel processo aziendale è un attributo dello stesso "documento."

Integrazione in stile BPM
Ecco come appare il processo all'inizio del progetto

La popolarità di tale implementazione è dovuta alla relativa semplicità e velocità di creazione dei processi aziendali lineari. Tuttavia, con il costante aumento della complessità dei sistemi software, la parte automatizzata del processo aziendale si espande e diventa più complessa. Nasce la necessità di decomporre, riutilizzare parti dei processi e anche di diramare i processi, in modo che ogni ramo venga eseguito in parallelo. In tali condizioni, lo strumento diventa scomodo e il diagramma degli stati perde informatività (le interazioni di integrazione non si riflettono affatto nel diagramma).

Integrazione in stile BPM
Ecco come appare il processo dopo alcune iterazioni di affinamento dei requisiti

L'uscita da questa situazione è stata l'integrazione del motore jBPM in alcuni prodotti con i processi aziendali più complessi. A breve termine, questa soluzione ha avuto un certo successo: è stata creata la possibilità di implementare complessi processi aziendali mantenendo un diagramma sufficientemente informativo e attuale nella notazione BPMN2.

Integrazione in stile BPM
Una piccola parte di un processo aziendale complesso

A lungo termine, la soluzione non ha soddisfatto le aspettative: l'elevata complessità nella creazione di processi aziendali attraverso strumenti visivi non ha permesso di raggiungere indicatori di produttività accettabili, e lo strumento stesso è diventato uno dei meno amati tra gli sviluppatori. Ci sono state anche lamentele sul funzionamento interno del motore, che hanno portato alla creazione di numerosi "pannicelli" e "scorciatoie".

Il principale elemento positivo dell'utilizzo di jBPM è stata la consapevolezza dell'utilità e del danno derivanti dalla presenza di uno stato persistente proprio nell'istanza del processo aziendale. Abbiamo anche visto la possibilità di applicare l'approccio basato sui processi per realizzare complessi protocolli di integrazione tra varie applicazioni utilizzando interazioni asincrone tramite segnali e messaggi. La presenza di uno stato persistente gioca un ruolo fondamentale in questo.

Sulla base di quanto detto, si può concludere: l'approccio basato sui processi in stile BPM ci consente di affrontare un'ampia gamma di compiti di automazione dei processi aziendali sempre più complessi, integrando armoniosamente le attività di integrazione in questi processi e mantenendo la possibilità di visualizzare il processo attuato in una notazione appropriata.

Svantaggi delle chiamate sincrone come pattern di integrazione

Con l'integrazione sincrona si intende una semplice chiamata bloccante. Un sottosistema funge da lato server e espone un'API con il metodo richiesto. Un altro sottosistema funge da lato client e, al momento opportuno, effettua la chiamata in attesa del risultato. A seconda dell'architettura del sistema, i lati client e server possono essere ospitati nello stesso applicativo e processo, oppure in quelli distinti. Nel secondo caso, è necessario applicare una certa implementazione di RPC e garantire il marshalling dei parametri e del risultato della chiamata.

Integrazione in stile BPM

Tale pattern di integrazione presenta un ampio insieme di svantaggi, ma viene utilizzato molto comunemente nella pratica per la sua semplicità. La velocità di implementazione è allettante e porta a usarlo ripetutamente in situazioni di scadenze "critiche", accumulando un debito tecnico. Tuttavia, accade anche che sviluppatori inesperti lo utilizzino inconsapevolmente, senza rendersi conto delle conseguenze negative.

Oltre all'aumento della connettività tra sottosistemi, ci sono problemi meno evidenti legati alla "frammentazione" e all'"allungamento" delle transazioni. Infatti, se la logica aziendale apporta delle modifiche, non si può prescindere dalle transazioni, e queste, a loro volta, bloccano determinate risorse dell'applicazione coinvolte in tali modifiche. Ciò significa che finché un sottosistema non riceve una risposta dall'altro, non potrà completare la transazione e rimuovere i blocchi. Questo aumenta significativamente il rischio di vari effetti:

  • si perde la reattività del sistema, gli utenti attendono a lungo le risposte alle loro richieste;
  • il server smette completamente di rispondere alle richieste degli utenti a causa del pool di thread sovraccarico: la maggior parte dei thread sono "bloccati" su una risorsa occupata dalla transazione;
  • iniziano a verificarsi deadlock: la probabilità della loro insorgenza dipende fortemente dalla durata delle transazioni, dalla quantità di logica aziendale coinvolta nella transazione e dai blocchi;
  • compaiono errori di timeout della transazione;
  • il server va in crash per OutOfMemory, se la questione richiede l'elaborazione e la modifica di grandi volumi di dati, e la presenza di integrazioni sincrone complica notevolmente la suddivisione dell'elaborazione in transazioni più "leggere".

Dal punto di vista architettonico, l'uso di chiamate bloccanti nell'integrazione porta a una perdita di controllo sulla qualità delle singole sotto-sistemi: non è possibile garantire gli obiettivi di qualità di un sotto-sistema separatamente rispetto a quelli di un altro sotto-sistema. Se i sotto-sistemi sono sviluppati da team diversi, questo rappresenta un grande problema.

Tutto diventa ancora più interessante se i sotto-sistemi da integrare si trovano in applicazioni diverse e devono essere apportate modifiche sincronizzate da entrambe le parti. Come garantire la transazionalità di queste modifiche?

Se le modifiche vengono effettuate in transazioni separate, sarà necessario garantire un'adeguata gestione delle eccezioni e delle compensazioni, il che annulla completamente il principale vantaggio delle integrazioni sincrone: la semplicità.

Vengono in mente anche le transazioni distribuite, ma non le utilizziamo nelle nostre soluzioni: è difficile garantire l'affidabilità.

«Saga» come soluzione al problema delle transazioni

Con la crescente popolarità dei microservizi, il Saga Pattern.

Questo modello risolve perfettamente i problemi delle lunghe transazioni sopra menzionati, oltre ad ampliare le possibilità di gestione dello stato del sistema da parte della logica di business: la compensazione dopo una transazione non riuscita può non ripristinare il sistema allo stato iniziale, ma garantire un percorso alternativo per l'elaborazione dei dati. Ciò consente anche di non ripetere i passaggi di elaborazione dei dati già completati in tentativi successivi per portare il processo a una conclusione 'buona'.

È interessante notare che in sistemi monolitici questo modello è attuale anche quando si tratta di integrare sotto-sistemi scarsamente correlati e si osservano effetti negativi causati da lunghe transazioni e dalle relative blocchi delle risorse.

Per quanto riguarda i nostri processi aziendali nello stile BPM, risulta molto facile implementare le 'Sagi': i singoli passaggi della 'Saga' possono essere definiti come attività all'interno del processo aziendale, e lo stato persistente del processo aziendale definisce anche lo stato interno della 'Saga'. Non è necessario alcun meccanismo di coordinamento aggiuntivo. Sarà necessario solo un broker di messaggi con supporto per garanzie 'at least once' come trasporto.

Tuttavia, anche questa soluzione ha il suo 'prezzo':

  • la logica di business sta diventando più complessa: è necessario elaborare le compensazioni;
  • sarà necessario rinunciare alla full consistency, il che può essere particolarmente sensibile per i sistemi monolitici;
  • l'architettura diventa leggermente più complessa, sorge una necessità aggiuntiva di un broker di messaggi;
  • saranno necessari ulteriori strumenti di monitoraggio e amministrazione (anche se in generale questo è positivo: la qualità del servizio del sistema migliorerà).

Per i sistemi monolitici, la giustificazione per l'uso delle "Sag" non è così ovvia. Per microservizi e altri SOA, dove è probabile che ci sia già un broker, e la full consistency è stata sacrificata fin dall'inizio del progetto, i vantaggi dell'uso di questo modello possono superare notevolmente gli svantaggi, soprattutto se c'è un'API comoda a livello di logica di business.

Incapsulamento della logica di business nei microservizi

Quando abbiamo iniziato a sperimentare con i microservizi, è emersa una domanda ragionevole: dove posizionare la logica di business di dominio rispetto al servizio che garantisce la persistenza dei dati di dominio?

Guardando all'architettura di vari BPMS, può sembrare ragionevole separare la logica di business dalla persistenza: creare uno strato di microservizi di piattaforma e dominio-indipendenti, che forniscono l'ambiente e il contenitore per l'esecuzione della logica di business di dominio, mentre la persistenza dei dati di dominio viene gestita da uno strato separato di microservizi molto semplici e leggeri. In questo caso, i processi aziendali orchestrano i servizi dello strato di persistenza.

Integrazione in stile BPM

Questo approccio ha un grande vantaggio: è possibile ampliare la funzionalità della piattaforma quanto si vuole, e il corrispondente strato di microservizi di piattaforma sarà l'unico a 'ingrassare' per questo. I processi aziendali di qualsiasi dominio possono immediatamente utilizzare la nuova funzionalità della piattaforma, non appena viene aggiornata.

Un'analisi più dettagliata ha rivelato significativi svantaggi di questo approccio:

  • un servizio di piattaforma che esegue la logica di business di molti domini comporta grandi rischi come unico punto di falla. Le frequenti modifiche alla logica di business aumentano il rischio di errori che possono portare a fallimenti che si propagano all'intero sistema;
  • problemi di prestazioni: la logica di business lavora con i propri dati attraverso un'interfaccia ristretta e lenta:
    • i dati saranno ulteriormente elaborati e passati attraverso lo stack di rete;
    • il servizio a dominio spesso restituirà più dati di quanti ne richieda la logica di business per l'elaborazione, a causa di capacità insufficienti nella parametrizzazione delle richieste a livello del servizio API esterno;
    • diverse parti indipendenti della logica di business possono richiedere nuovamente gli stessi dati per l'elaborazione (è possibile mitigare questo problema aggiungendo componenti di sessione che memorizzano i dati in cache, ma questo complica ulteriormente l'architettura e crea problemi di attualità dei dati e invalidazione della cache);
  • problemi di transazionalità:
    • i processi aziendali con stato persistente, la cui archiviazione è gestita dal servizio di piattaforma, si disallineeranno dai dati a dominio, e non si prevedono soluzioni semplici per questo problema;
    • spostamento del blocco dei dati a dominio al di fuori della transazione: se la logica di business a dominio richiede di apportare modifiche, verificando preventivamente la correttezza dei dati attuali, è necessario escludere la possibilità di modifiche concorrenti sui dati elaborati. Un blocco esterno dei dati può aiutare a risolvere il problema, ma tale soluzione comporta rischi aggiuntivi e riduce l'affidabilità complessiva del sistema;
  • complessità aggiuntive durante l'aggiornamento: in alcuni casi, è necessario aggiornare il servizio di persistenza e la logica di business in modo sincrono o in una sequenza rigorosa.

Alla fine, è stato necessario tornare alle origini: incapsulare i dati a dominio e la logica di business a dominio in un unico microservizio. Questo approccio semplifica la percezione del microservizio come un componente integrato del sistema e non genera i problemi sopracitati. Anche questo ha un costo:

  • è necessaria la standardizzazione dell'API per l'interazione con la logica di business (in particolare, per garantire le attività degli utenti all'interno dei processi aziendali) e dei servizi API di piattaforma; è necessaria una maggiore attenzione alle modifiche dell'API, alla compatibilità diretta e inversa;
  • è necessaria l'aggiunta di ulteriori librerie runtime per garantire il funzionamento della logica di business all'interno di ciascun microservizio, e questo porta a nuove esigenze per tali librerie: leggerezza e un minimo di dipendenze transitive;
  • Gli sviluppatori della logica aziendale devono monitorare le versioni delle librerie: se un microservizio non viene aggiornato da tempo, probabilmente conterrà una versione obsoleta delle librerie. Questo può diventare un ostacolo inaspettato per l'aggiunta di nuove funzionalità e potrebbe richiedere la migrazione della vecchia logica aziendale di tale servizio alle nuove versioni delle librerie, se ci sono state modifiche incompatibili tra le versioni.

Integrazione in stile BPM

In questa architettura è presente anche uno strato di servizi di piattaforma, ma questo strato non crea un contenitore per l'esecuzione della logica aziendale, ma solo il suo ambiente, fornendo funzioni «di piattaforma» ausiliarie. Tale strato non è necessario solo per mantenere la leggerezza dei microservizi aziendali, ma anche per centralizzare la gestione.

Ad esempio, le attività degli utenti nei processi aziendali generano compiti. Tuttavia, lavorando con i compiti, l'utente deve vedere i compiti di tutti i domini in un elenco comune, e quindi deve esservi un servizio di piattaforma per la registrazione dei compiti, privo della logica aziendale di dominio. Mantenere l'incapsulamento della logica aziendale in questo contesto è piuttosto problematico, e questo è un altro compromesso di questa architettura.

Integrazione dei processi aziendali vista dallo sviluppatore applicativo

Come già detto, lo sviluppatore applicativo deve essere astratto dalle caratteristiche tecniche e ingegneristiche dell'implementazione dell'interazione tra le applicazioni, affinché si possa contare su una buona produttività nello sviluppo.

Proveremo a risolvere un compito di integrazione piuttosto complicato, appositamente inventato per questo articolo. Sarà un compito «di gioco» che coinvolge tre applicazioni, ciascuna delle quali definisce un certo nome di dominio: «app1», «app2», «app3».

All'interno di ciascuna applicazione vengono avviati processi aziendali che iniziano a «giocare a palla» attraverso un bus di integrazione. A fare da palla saranno dei messaggi con il nome «Ball».

Regole del gioco:

  • il primo giocatore è l'iniziatore. Invita gli altri giocatori a partecipare al gioco, inizia il gioco e può terminarlo in qualsiasi momento;
  • gli altri giocatori dichiarano la loro partecipazione al gioco, si «fanno conoscere» tra di loro e con il primo giocatore;
  • prendendo la palla, un giocatore sceglie un altro giocatore partecipante e gli passa la palla. Viene tenuto il conteggio totale dei passaggi;
  • ogni giocatore ha un'«energia» che diminuisce ad ogni passaggio della palla effettuato da quel giocatore. Una volta esaurita l'energia, il giocatore esce dal gioco dichiarando il proprio abbandono;
  • se il giocatore è rimasto solo, dichiara subito il suo abbandono;
  • quando tutti i giocatori sono usciti, il primo giocatore dichiara la fine del gioco. Se è uscito dal gioco in precedenza, rimane a guardare la partita per concluderla.

Per risolvere questo compito utilizzerò il nostro DSL per i processi aziendali, che consente di descrivere la logica in Kotlin in modo compatto, con il minimo di boilerplate.

Nell'app app1 funzionerà il processo aziendale del primo giocatore (che è anche l'iniziatore del gioco):

class InitialPlayer

import ru.krista.bpm.ProcessInstance
import ru.krista.bpm.runtime.ProcessImpl
import ru.krista.bpm.runtime.constraint.UniqueConstraints
import ru.krista.bpm.runtime.dsl.processModel
import ru.krista.bpm.runtime.dsl.taskOperation
import ru.krista.bpm.runtime.instance.MessageSendInstance

data class PlayerInfo(val name: String, val domain: String, val id: String)

class PlayersList : ArrayList()

// Questa è la classe dell'istanza del processo: incapsula il suo stato interno
class InitialPlayer : ProcessImpl(initialPlayerModel) {
    var playerName: String by persistent("Player1")
    var energy: Int by persistent(30)
    var players: PlayersList by persistent(PlayersList())
    var shotCounter: Int = 0
}

// Questa è la dichiarazione del modello di processo: viene creata una sola volta, utilizzata da tutti
// le istanze del processo della classe corrispondente
val initialPlayerModel = processModel(name = "InitialPlayer",
                                                     version = 1) {

    // Secondo le regole, il primo giocatore è l'iniziatore del gioco e deve essere l'unico
    uniqueConstraint = UniqueConstraints.singleton

    // Dichiarazione delle attività che compongono il processo aziendale
    val sendNewGameSignal = signal("NewGame")
    val sendStopGameSignal = signal("StopGame")
    val startTask = humanTask("Start") {
        taskOperation {
            processCondition { players.size > 0 }
            confirmation { "Si sono uniti ${players.size} giocatori. Iniziamo?" }
        }
    }
    val stopTask = humanTask("Stop") {
        taskOperation {}
    }
    val waitPlayerJoin = signalWait("PlayerJoin") { signal ->
        players.add(PlayerInfo(
                signal.data!!,
                signal.sender.domain,
                signal.sender.processInstanceId))
        println("... unito il giocatore ${signal.data} ...")
    }
    val waitPlayerOut = signalWait("PlayerOut") { signal ->
        players.remove(PlayerInfo(
                signal.data!!,
                signal.sender.domain,
                signal.sender.processInstanceId))
        println("... il giocatore ${signal.data} è uscito ...")
    }
    val sendPlayerOut = signal("PlayerOut") {
        signalData = { playerName }
    }
    val sendHandshake = messageSend("Handshake") {
        messageData = { playerName }
        activation = {
            receiverDomain = process.players.last().domain
            receiverProcessInstanceId = process.players.last().id
        }
    }
    val throwStartBall = messageSend("Ball") {
        messageData = { 1 }
        activation = { selectNextPlayer() }
    }
    val throwBall = messageSend("Ball") {
        messageData = { shotCounter + 1 }
        activation = { selectNextPlayer() }
        onEntry { energy -= 1 }
    }
    val waitBall = messageWaitData("Ball") {
        shotCounter = it
    }

    // Ora costruiamo il grafo del processo dalle attività dichiarate
    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)
    }

    // Aggiungiamo ai segnali ulteriori gestori per il logging
    sendNewGameSignal.onExit { println("Iniziamo a giocare!") }
    sendStopGameSignal.onExit { println("Fermati!") }
    sendPlayerOut.onExit { println("$playerName: Sono uscito!") }
}

private fun MessageSendInstance.selectNextPlayer() {
    val player = process.players.random()
    receiverDomain = player.domain
    receiverProcessInstanceId = player.id
    println("Passaggio ${process.shotCounter + 1}: " +
            "${process.playerName} >>> ${player.name}")
}

Oltre all'implementazione della logica aziendale, il codice fornito può generare un modello di business process che può essere visualizzato sotto forma di diagramma. Il visualizzatore non è ancora stato implementato, quindi ho dovuto spendere un po' di tempo a disegnare (qui ho semplificato leggermente la notazione BPMN riguardo all'uso dei gateway, per migliorare la coerenza del diagramma con il codice fornito):

Integrazione in stile BPM

L'applicazione app2 includerà il processo aziendale di un altro giocatore:

class RandomPlayer

import ru.krista.bpm.ProcessInstance
import ru.krista.bpm.runtime.ProcessImpl
import ru.krista.bpm.runtime.dsl.processModel
import ru.krista.bpm.runtime.instance.MessageSendInstance

data class PlayerInfo(val name: String, val domain: String, val id: String)

class PlayersList: ArrayList()

class RandomPlayer : ProcessImpl(randomPlayerModel) {

    var playerName: String by input(persistent = true, 
                                    defaultValue = "RandomPlayer")
    var energy: Int by input(persistent = true, defaultValue = 30)
    var players: PlayersList by persistent(PlayersList())
    var allPlayersOut: Boolean by persistent(false)
    var shotCounter: Int = 0

    val selfPlayer: PlayerInfo
        get() = PlayerInfo(playerName, env.eventDispatcher.domainName, id)
}

val randomPlayerModel = processModel(name = "RandomPlayer", 
                                                   version = 1) {

    val waitNewGameSignal = signalWait("NewGame")
    val waitStopGameSignal = signalWait("StopGame")
    val sendPlayerJoin = signal("PlayerJoin") {
        signalData = { playerName }
    }
    val sendPlayerOut = signal("PlayerOut") {
        signalData = { playerName }
    }
    val waitPlayerJoin = signalWaitCustom("PlayerJoin") {
        eventCondition = { signal ->
            signal.sender.processInstanceId != process.id 
                && !process.players.any { signal.sender.processInstanceId == it.id}
        }
        handler = { signal ->
            players.add(PlayerInfo(
                    signal.data!!,
                    signal.sender.domain,
                    signal.sender.processInstanceId))
        }
    }
    val waitPlayerOut = signalWait("PlayerOut") { signal ->
        players.remove(PlayerInfo(
                signal.data!!,
                signal.sender.domain,
                signal.sender.processInstanceId))
        allPlayersOut = players.isEmpty()
    }
    val sendHandshake = messageSend("Handshake") {
        messageData = { playerName }
        activation = {
            receiverDomain = process.players.last().domain
            receiverProcessInstanceId = process.players.last().id
        }
    }
    val receiveHandshake = messageWait("Handshake") { message ->
        if (!players.any { message.sender.processInstanceId == it.id}) {
            players.add(PlayerInfo(
                    message.data!!, 
                    message.sender.domain, 
                    message.sender.processInstanceId))
        }
    }
    val throwBall = messageSend("Ball") {
        messageData = { shotCounter + 1 }
        activation = { selectNextPlayer() }
        onEntry { energy -= 1 }
    }
    val waitBall = messageWaitData("Ball") {
        shotCounter = it
    }

    startFrom(waitNewGameSignal)
            .fork("mainFork") {
                next(sendPlayerJoin)
                        .branch("mainLoop") {
                            ifTrue { energy < 5 || allPlayersOut }
                                    .next(sendPlayerOut)
                                    .next(waitBall)
                            ifElse()
                                    .next(waitBall)
                                    .next(throwBall)
                                    .loop()
                        }
                next(waitPlayerJoin).next(sendHandshake).next(waitPlayerJoin)
                next(waitPlayerOut).next(waitPlayerOut)
                next(receiveHandshake).next(receiveHandshake)
                next(waitStopGameSignal).terminate()
            }

    sendPlayerJoin.onExit { println("$playerName: Sono qui!") }
    sendPlayerOut.onExit { println("$playerName: Sono fuori!") }
}

private fun MessageSendInstance.selectNextPlayer() {
    val player = if (process.players.isNotEmpty()) 
        process.players.random() 
    else 
        process.selfPlayer
    receiverDomain = player.domain
    receiverProcessInstanceId = player.id
    println("Fase ${process.shotCounter + 1}: " +
            "${process.playerName} >>> ${player.name}")
}

Diagramma:

Integrazione in stile BPM

Nell'app app3 faremo in modo che il giocatore si comporti in modo diverso: invece di scegliere casualmente il prossimo giocatore, agirà secondo l'algoritmo round-robin:

class RoundRobinPlayer

import ru.krista.bpm.ProcessInstance
import ru.krista.bpm.runtime.ProcessImpl
import ru.krista.bpm.runtime.dsl.processModel
import ru.krista.bpm.runtime.instance.MessageSendInstance

data class PlayerInfo(val name: String, val domain: String, val id: String)

class PlayersList: ArrayList()

class RoundRobinPlayer : ProcessImpl(roundRobinPlayerModel) {

    var playerName: String by input(persistent = true, 
                                    defaultValue = "RoundRobinPlayer")
    var energy: Int by input(persistent = true, defaultValue = 30)
    var players: PlayersList by persistent(PlayersList())
    var nextPlayerIndex: Int by persistent(-1)
    var allPlayersOut: Boolean by persistent(false)
    var shotCounter: Int = 0

    val selfPlayer: PlayerInfo
        get() = PlayerInfo(playerName, env.eventDispatcher.domainName, id)
}

val roundRobinPlayerModel = processModel(
        name = "RoundRobinPlayer", 
        version = 1) {

    val waitNewGameSignal = signalWait("NewGame")
    val waitStopGameSignal = signalWait("StopGame")
    val sendPlayerJoin = signal("PlayerJoin") {
        signalData = { playerName }
    }
    val sendPlayerOut = signal("PlayerOut") {
        signalData = { playerName }
    }
    val waitPlayerJoin = signalWaitCustom("PlayerJoin") {
        eventCondition = { signal ->
            signal.sender.processInstanceId != process.id 
                && !process.players.any { signal.sender.processInstanceId == it.id}
        }
        handler = { signal ->
            players.add(PlayerInfo(
                    signal.data!!, 
                    signal.sender.domain, 
                    signal.sender.processInstanceId))
        }
    }
    val waitPlayerOut = signalWait("PlayerOut") { signal ->
        players.remove(PlayerInfo(
                signal.data!!, 
                signal.sender.domain, 
                signal.sender.processInstanceId))
        allPlayersOut = players.isEmpty()
    }
    val sendHandshake = messageSend("Handshake") {
        messageData = { playerName }
        activation = {
            receiverDomain = process.players.last().domain
            receiverProcessInstanceId = process.players.last().id
        }
    }
    val receiveHandshake = messageWait("Handshake") { message ->
        if (!players.any { message.sender.processInstanceId == it.id}) {
            players.add(PlayerInfo(
                    message.data!!, 
                    message.sender.domain, 
                    message.sender.processInstanceId))
        }
    }
    val throwBall = messageSend("Ball") {
        messageData = { shotCounter + 1 }
        activation = { selectNextPlayer() }
        onEntry { energy -= 1 }
    }
    val waitBall = messageWaitData("Ball") {
        shotCounter = it
    }

    startFrom(waitNewGameSignal)
            .fork("mainFork") {
                next(sendPlayerJoin)
                        .branch("mainLoop") {
                            ifTrue { energy < 5 || allPlayersOut }
                                    .next(sendPlayerOut)
                                    .next(waitBall)
                            ifElse()
                                    .next(waitBall)
                                    .next(throwBall)
                                    .loop()
                        }
                next(waitPlayerJoin).next(sendHandshake).next(waitPlayerJoin)
                next(waitPlayerOut).next(waitPlayerOut)
                next(receiveHandshake).next(receiveHandshake)
                next(waitStopGameSignal).terminate()
            }

    sendPlayerJoin.onExit { println("$playerName: I'm here!") }
    sendPlayerOut.onExit { println("$playerName: I'm out!") }
}

private fun MessageSendInstance.selectNextPlayer() {
    var idx = process.nextPlayerIndex + 1
    if (idx >= process.players.size) {
        idx = 0
    }
    process.nextPlayerIndex = idx
    val player = if (process.players.isNotEmpty()) 
        process.players[idx] 
    else 
        process.selfPlayer
    receiverDomain = player.domain
    receiverProcessInstanceId = player.id
    println("Step ${process.shotCounter + 1}: " +
            "${process.playerName} >>> ${player.name}")
}

In altri aspetti, il comportamento del giocatore non è diverso da quello precedente, quindi il diagramma non cambia.

Ora è necessario un test per eseguire tutto questo. Fornirò solo il codice del test stesso, per non appesantire l'articolo con del boilerplate (in realtà ho utilizzato l'ambiente di test creato in precedenza per testare l'integrazione di altri processi aziendali):

testGame()

@Test
public void testGame() throws InterruptedException {
    String pl2 = startProcess(app2, "RandomPlayer", playerParams("Player2", 20));
    String pl3 = startProcess(app2, "RandomPlayer", playerParams("Player3", 40));
    String pl4 = startProcess(app3, "RoundRobinPlayer", playerParams("Player4", 25));
    String pl5 = startProcess(app3, "RoundRobinPlayer", playerParams("Player5", 35));
    String pl1 = startProcess(app1, "InitialPlayer");
    // Ora è necessario aspettare un po', affinché i giocatori si "conoscano" tra di loro.
    // Aspettare con sleep è una soluzione poco efficace, ma è la più semplice. 
    // Non fatelo in test seri!
    Thread.sleep(1000);
    // Avviamo il gioco, chiudendo l'attività dell'utente
    assertTrue(closeTask(app1, pl1, "Start"));
    app1.getWaiting().waitProcessFinished(pl1);
    app2.getWaiting().waitProcessFinished(pl2);
    app2.getWaiting().waitProcessFinished(pl3);
    app3.getWaiting().waitProcessFinished(pl4);
    app3.getWaiting().waitProcessFinished(pl5);
}

private Map playerParams(String name, int energy) {
    Map params = new HashMap();
    params.put("playerName", name);
    params.put("energy", energy);
    return params;
}

Avviamo il test, guardiamo il log:

console output

Blocco della chiave lock://app1/process/InitialPlayer
Giochiamo!
Rimozione del blocco della chiave lock://app1/process/InitialPlayer
Giocatore2: Ci sono!
Giocatore3: Ci sono!
Giocatore4: Ci sono!
Giocatore5: Ci sono!
... unisciti al giocatore Giocatore2 ...
... unisciti al giocatore Giocatore4 ...
... unisciti al giocatore Giocatore3 ...
... unisciti al giocatore Giocatore5 ...
Passo 1: Giocatore1 >>> Giocatore3
Passo 2: Giocatore3 >>> Giocatore5
Passo 3: Giocatore5 >>> Giocatore3
Passo 4: Giocatore3 >>> Giocatore4
Passo 5: Giocatore4 >>> Giocatore3
Passo 6: Giocatore3 >>> Giocatore4
Passo 7: Giocatore4 >>> Giocatore5
Passo 8: Giocatore5 >>> Giocatore2
Passo 9: Giocatore2 >>> Giocatore5
Passo 10: Giocatore5 >>> Giocatore4
Passo 11: Giocatore4 >>> Giocatore2
Passo 12: Giocatore2 >>> Giocatore4
Passo 13: Giocatore4 >>> Giocatore1
Passo 14: Giocatore1 >>> Giocatore4
Passo 15: Giocatore4 >>> Giocatore3
Passo 16: Giocatore3 >>> Giocatore1
Passo 17: Giocatore1 >>> Giocatore2
Passo 18: Giocatore2 >>> Giocatore3
Passo 19: Giocatore3 >>> Giocatore1
Passo 20: Giocatore1 >>> Giocatore5
Passo 21: Giocatore5 >>> Giocatore1
Passo 22: Giocatore1 >>> Giocatore2
Passo 23: Giocatore2 >>> Giocatore4
Passo 24: Giocatore4 >>> Giocatore5
Passo 25: Giocatore5 >>> Giocatore3
Passo 26: Giocatore3 >>> Giocatore4
Passo 27: Giocatore4 >>> Giocatore2
Passo 28: Giocatore2 >>> Giocatore5
Passo 29: Giocatore5 >>> Giocatore2
Passo 30: Giocatore2 >>> Giocatore1
Passo 31: Giocatore1 >>> Giocatore3
Passo 32: Giocatore3 >>> Giocatore4
Passo 33: Giocatore4 >>> Giocatore1
Passo 34: Giocatore1 >>> Giocatore3
Passo 35: Giocatore3 >>> Giocatore4
Passo 36: Giocatore4 >>> Giocatore3
Passo 37: Giocatore3 >>> Giocatore2
Passo 38: Giocatore2 >>> Giocatore5
Passo 39: Giocatore5 >>> Giocatore4
Passo 40: Giocatore4 >>> Giocatore5
Passo 41: Giocatore5 >>> Giocatore1
Passo 42: Giocatore1 >>> Giocatore5
Passo 43: Giocatore5 >>> Giocatore3
Passo 44: Giocatore3 >>> Giocatore5
Passo 45: Giocatore5 >>> Giocatore2
Passo 46: Giocatore2 >>> Giocatore3
Passo 47: Giocatore3 >>> Giocatore2
Passo 48: Giocatore2 >>> Giocatore5
Passo 49: Giocatore5 >>> Giocatore4
Passo 50: Giocatore4 >>> Giocatore2
Passo 51: Giocatore2 >>> Giocatore5
Passo 52: Giocatore5 >>> Giocatore1
Passo 53: Giocatore1 >>> Giocatore5
Passo 54: Giocatore5 >>> Giocatore3
Passo 55: Giocatore3 >>> Giocatore5
Passo 56: Giocatore5 >>> Giocatore2
Passo 57: Giocatore2 >>> Giocatore1
Passo 58: Giocatore1 >>> Giocatore4
Passo 59: Giocatore4 >>> Giocatore1
Passo 60: Giocatore1 >>> Giocatore4
Passo 61: Giocatore4 >>> Giocatore3
Passo 62: Giocatore3 >>> Giocatore2
Passo 63: Giocatore2 >>> Giocatore5
Passo 64: Giocatore5 >>> Giocatore4
Passo 65: Giocatore4 >>> Giocatore5
Passo 66: Giocatore5 >>> Giocatore1
Passo 67: Giocatore1 >>> Giocatore5
Passo 68: Giocatore5 >>> Giocatore3
Passo 69: Giocatore3 >>> Giocatore4
Passo 70: Giocatore4 >>> Giocatore2
Passo 71: Giocatore2 >>> Giocatore5
Passo 72: Giocatore5 >>> Giocatore2
Passo 73: Giocatore2 >>> Giocatore1
Passo 74: Giocatore1 >>> Giocatore4
Passo 75: Giocatore4 >>> Giocatore1
Passo 76: Giocatore1 >>> Giocatore2
Passo 77: Giocatore2 >>> Giocatore5
Passo 78: Giocatore5 >>> Giocatore4
Passo 79: Giocatore4 >>> Giocatore3
Passo 80: Giocatore3 >>> Giocatore1
Passo 81: Giocatore1 >>> Giocatore5
Passo 82: Giocatore5 >>> Giocatore1
Passo 83: Giocatore1 >>> Giocatore4
Passo 84: Giocatore4 >>> Giocatore5
Passo 85: Giocatore5 >>> Giocatore3
Passo 86: Giocatore3 >>> Giocatore5
Passo 87: Giocatore5 >>> Giocatore2
Passo 88: Giocatore2 >>> Giocatore3
Giocatore2: Sono uscito!
Passo 89: Giocatore3 >>> Giocatore4
... giocatore Giocatore2 è uscito ...
Passo 90: Giocatore4 >>> Giocatore1
Passo 91: Giocatore1 >>> Giocatore3
Passo 92: Giocatore3 >>> Giocatore1
Passo 93: Giocatore1 >>> Giocatore4
Passo 94: Giocatore4 >>> Giocatore3
Passo 95: Giocatore3 >>> Giocatore5
Passo 96: Giocatore5 >>> Giocatore1
Passo 97: Giocatore1 >>> Giocatore5
Passo 98: Giocatore5 >>> Giocatore3
Passo 99: Giocatore3 >>> Giocatore5
Passo 100: Giocatore5 >>> Giocatore4
Passo 101: Giocatore4 >>> Giocatore5
Giocatore4: Sono uscito!
... giocatore Giocatore4 è uscito ...
Passo 102: Giocatore5 >>> Giocatore1
Passo 103: Giocatore1 >>> Giocatore3
Passo 104: Giocatore3 >>> Giocatore1
Passo 105: Giocatore1 >>> Giocatore3
Passo 106: Giocatore3 >>> Giocatore5
Passo 107: Giocatore5 >>> Giocatore3
Passo 108: Giocatore3 >>> Giocatore1
Passo 109: Giocatore1 >>> Giocatore3
Passo 110: Giocatore3 >>> Giocatore5
Passo 111: Giocatore5 >>> Giocatore1
Passo 112: Giocatore1 >>> Giocatore3
Passo 113: Giocatore3 >>> Giocatore5
Passo 114: Giocatore5 >>> Giocatore3
Passo 115: Giocatore3 >>> Giocatore1
Passo 116: Giocatore1 >>> Giocatore3
Passo 117: Giocatore3 >>> Giocatore5
Passo 118: Giocatore5 >>> Giocatore1
Passo 119: Giocatore1 >>> Giocatore3
Passo 120: Giocatore3 >>> Giocatore5
Passo 121: Giocatore5 >>> Giocatore3
Giocatore5: Sono uscito!
... giocatore Giocatore5 è uscito ...
Passo 122: Giocatore3 >>> Giocatore5
Passo 123: Giocatore5 >>> Giocatore1
Giocatore5: Sono uscito!
Passo 124: Giocatore1 >>> Giocatore3
... giocatore Giocatore5 è uscito ...
Passo 125: Giocatore3 >>> Giocatore1
Passo 126: Giocatore1 >>> Giocatore3
Giocatore1: Sono uscito!
... giocatore Giocatore1 è uscito ...
Passo 127: Giocatore3 >>> Giocatore3
Giocatore3: Sono uscito!
Passo 128: Giocatore3 >>> Giocatore3
... giocatore Giocatore3 è uscito ...
Giocatore3: Sono uscito!
Fermati!
Passo 129: Giocatore3 >>> Giocatore3
Giocatore3: Sono uscito!

Da tutto ciò si possono trarre alcune conclusioni importanti:

  • in presenza degli strumenti necessari, gli sviluppatori applicativi possono creare interazioni di integrazione tra le applicazioni senza interrompere la logica di business;
  • la complessità (complexity) del compito di integrazione, che richiede competenze ingegneristiche, può essere nascosta all'interno del framework, se è progettato fin dall'inizio in questo modo. La difficoltà del compito (difficulty) non può essere nascosta, quindi la soluzione di un compito difficile nel codice apparirà di conseguenza;
  • nello sviluppo della logica di integrazione è necessario considerare la consistenza eventuale e l'assenza di linearizzabilità della modifica di stato di tutti i partecipanti all'integrazione. Questo costringe a complicare la logica, affinché non sia sensibile all'ordine degli eventi esterni. Nel nostro esempio, il giocatore è costretto a partecipare al gioco solo dopo aver dichiarato il suo ritiro: gli altri giocatori continueranno a passargli la palla, finché l'informazione sul suo ritiro non verrà ricevuta e elaborata da tutti i partecipanti. Questa logica non deriva dalle regole del gioco ed è una soluzione di compromesso all'interno dell'architettura scelta.

Successivamente parleremo delle varie sottigliezze della nostra soluzione, dei compromessi e di altri aspetti.

Tutti i messaggi – in una sola coda

Tutte le applicazioni integrate lavorano con un'unica bus di integrazione, rappresentata come un broker esterno, una coda BPMQueue – per i messaggi e un topic BPMTopic – per i segnali (eventi). Far passare tutti i messaggi attraverso una sola coda è di per sé un compromesso. A livello di logica di business, ora è possibile introdurre quanti più nuovi tipi di messaggi si desidera, senza apportare modifiche alla struttura del sistema. Questo è un notevole semplificazione, ma comporta alcuni rischi che nel contesto delle nostre attività tipiche ci sono sembrati non così significativi.

Integrazione in stile BPM

Tuttavia, qui c'è una sottigliezza: ogni applicazione filtra i propri messaggi dalla coda già in ingresso, in base al nome del proprio dominio. Inoltre, il dominio può essere specificato anche nei segnali, se è necessario limitare la "visibilità" del segnale a una sola applicazione. Questo dovrebbe aumentare la capacità del bus, ma la logica di business ora deve operare con i nomi dei domini: per l'indirizzamento dei messaggi - è essenziale, per i segnali - preferibile.

Assicurarsi dell'affidabilità del bus di integrazione

L'affidabilità si compone di diversi aspetti:

  • il broker di messaggi scelto è un componente critico dell'architettura e un singolo punto di guasto: deve essere sufficientemente resiliente. Dovrebbero essere utilizzate solo implementazioni collaudate nel tempo, con un buon supporto e una grande comunità;
  • è necessario garantire un'alta disponibilità del broker di messaggi, per cui deve essere fisicamente separato dalle applicazioni integrate (garantire un'alta disponibilità delle applicazioni con logica di business è significativamente più complesso e costoso);
  • il broker deve garantire la consegna con la garanzia "at least once". Questo è un requisito obbligatorio per il funzionamento affidabile del bus di integrazione. Non è necessaria la garanzia "exactly once": in genere i processi aziendali non sono sensibili alla ricezione ripetuta di messaggi o eventi, e per attività particolari in cui ciò è importante, è più semplice aggiungere un controllo supplementare nella logica di business piuttosto che utilizzare continuamente garanzie piuttosto "costose";
  • l'invio di messaggi e segnali deve essere coinvolto in una transazione complessiva con la modifica dello stato dei processi aziendali e dei dati di dominio. L'opzione preferibile sarà utilizzare il modello Transactional Outbox, ma richiederà l'esistenza di una tabella aggiuntiva nel database e di un retriever. Nelle applicazioni JEE, questo aspetto può essere semplificato utilizzando un gestore JTA locale, ma la connessione al broker scelto deve essere in grado di funzionare in modalità XA;
  • i gestori di messaggi e eventi in ingresso devono anche lavorare con la transazione di modifica dello stato del processo aziendale: se tale transazione viene annullata, anche la ricezione del messaggio deve essere annullata;
  • i messaggi che non possono essere consegnati a causa di errori devono essere archiviati in uno spazio di archiviazione separato DLQ (Coda di lettere morte). Abbiamo creato un microservizio platform separato che memorizza tali messaggi nel proprio archivio, li indicizza in base agli attributi (per una rapida aggregazione e ricerca) e espone un'API per visualizzare, reinviare all'indirizzo di destinazione ed eliminare i messaggi. Gli amministratori di sistema possono interagire con questo servizio attraverso la propria interfaccia web;
  • nelle impostazioni del broker è necessario regolare il numero di tentativi di consegna e le attese tra le consegne per ridurre la probabilità che i messaggi finiscano nella DLQ (calcolare i parametri ottimali è praticamente impossibile, ma è possibile agire empiricamente e regolarli durante l'uso);
  • l'archivio DLQ deve essere continuamente monitorato e il sistema di monitoring deve avvertire gli amministratori di sistema affinché possano reagire rapidamente in caso di messaggi non consegnati. Questo aiuterà a ridurre la "zona di danno" di un eventuale guasto o errore nella logica di business;
  • il bus di integrazione deve essere insensibile all'assenza temporanea delle applicazioni: le iscrizioni al topic devono essere durable e il nome di dominio dell'applicazione deve essere unico, in modo che durante l'assenza dell'applicazione nessun altro tenti di elaborare i messaggi dalla coda.

Garantire la thread-safety della logica di business

Allo stesso elenco di processi aziendali possono pervenire più messaggi ed eventi contemporaneamente, il cui trattamento verrà avviato in parallelo. Allo stesso tempo, per lo sviluppatore applicativo deve essere tutto semplice e thread-safe.

La logica di business del processo gestisce ogni evento esterno che influisce su questo processo aziendale singolarmente. Tali eventi possono includere:

  • l'avvio di un'istanza del processo aziendale;
  • l'azione di un utente relativa all'attività all'interno del processo aziendale;
  • l'arrivo di un messaggio o di un segnale al quale l'istanza del processo è iscritta;
  • l'attivazione di un timer impostato dall'istanza del processo aziendale;
  • un intervento di controllo tramite API (ad esempio, l'interruzione di emergenza del processo).

Ogni evento di questo tipo può modificare lo stato di un'istanza del processo aziendale: alcune attività possono concludersi e altre iniziare, i valori delle proprietà persistenti possono cambiare. La chiusura di qualsiasi attività può portare all'attivazione di una o più attività successive. Queste ultime, a loro volta, possono fermarsi in attesa di altri eventi oppure, se non hanno bisogno di ulteriori dati, possono concludersi nella stessa transazione. Prima di chiudere la transazione, il nuovo stato del processo aziendale viene salvato nel database, dove attenderà l'arrivo del prossimo evento esterno.

I dati persistenti del processo aziendale, salvati in un database relazionale, sono un punto di sincronizzazione molto conveniente per l'elaborazione, se si utilizza SELECT FOR UPDATE. Se una transazione riesce a ottenere lo stato del processo aziendale dal database per modificarlo, nessun'altra transazione può ottenere contemporaneamente lo stesso stato per un'altra modifica, e dopo il completamento della prima transazione, la seconda otterrà sicuramente lo stato già modificato.

Utilizzando blocchi pessimisti da parte del DBMS, soddisfaciamo tutti i requisiti necessari ACID, e manteniamo anche la possibilità di scalare l'applicazione con logica aziendale aumentando il numero di istanze avviate.

Tuttavia, i blocchi pessimisti ci espongono a deadlock, quindi è opportuno limitare SELECT FOR UPDATE a un timeout ragionevole nel caso si verifichino deadlock in alcuni casi evidenti nella logica aziendale.

Un altro problema è la sincronizzazione dell'avvio del processo aziendale. Finché non esiste un'istanza del processo aziendale, non esiste neppure il suo stato nel database, quindi il metodo descritto non è adatto. Se è necessario garantire l'unicità dell'istanza del processo aziendale in un determinato ambito, sarà necessario un oggetto di sincronizzazione, associato alla classe del processo e all'ambito corrispondente. Per risolvere questo problema, utilizziamo un altro meccanismo di blocchi, che consente di acquisire un blocco su una risorsa qualsiasi, specificata da una chiave nel formato URI, tramite un servizio esterno.

Nei nostri esempi, il processo aziendale InitialPlayer contiene una dichiarazione

uniqueConstraint = UniqueConstraints.singleton

Pertanto, nel log sono presenti messaggi relativi all'acquisizione e al rilascio del blocco della chiave corrispondente. Non ci sono messaggi di questo tipo per altri processi aziendali: uniqueConstraint non è definito.

Problemi nei processi aziendali con stato persistente

A volte la presenza di uno stato persistente non solo aiuta, ma può anche risultare molto ostica nello sviluppo.
I problemi iniziano quando è necessario apportare modifiche alla logica aziendale e/o al modello del processo aziendale. Non ogni modifica risulta compatibile con il vecchio stato dei processi aziendali. Se nel database ci sono molti esemplari "viventi", l'introduzione di modifiche incompatibili può causare numerosi problemi, che abbiamo spesso affrontato utilizzando jBPM.

A seconda della portata delle modifiche, si possono seguire due strade:

  1. creare un nuovo tipo di processo aziendale per non apportare modifiche incompatibili al vecchio e usarlo al posto del precedente per avviare nuovi esemplari. Gli esemplari vecchi continueranno a funzionare "come prima";
  2. migrare lo stato persistente dei processi aziendali durante l'aggiornamento della logica aziendale.

La prima strada è più semplice, ma ha le sue limitazioni e svantaggi, per esempio:

  • duplicazione della logica aziendale in molti modelli di processo aziendale, aumento del volume della logica aziendale;
  • spesso è necessario un passaggio immediato alla nuova logica aziendale (per quanto riguarda i compiti di integrazione – quasi sempre);
  • lo sviluppatore non sa quando può eliminare modelli obsoleti.

Nella pratica, utilizziamo entrambi gli approcci, ma abbiamo preso alcune decisioni per semplificare la nostra vita:

  • nel database, lo stato persistente del processo aziendale è salvato in un formato facilmente leggibile e lavorabile: in stringhe di formato JSON. Questo consente di eseguire migrazioni sia all'interno dell'applicazione che all'esterno. In caso estremo, è possibile anche modificarlo manualmente (particolarmente utile durante lo sviluppo e il debug);
  • la logica aziendale di integrazione non utilizza i nomi dei processi aziendali, in modo che in qualsiasi momento possa essere sostituita l'implementazione di uno dei processi coinvolti con una nuova, con un nuovo nome (ad esempio, "InitialPlayerV2"). Il collegamento avviene tramite nomi di messaggi e segnali;
  • Il modello di processo ha un numero di versione, che aumentiamo se apportiamo modifiche incompatibili a questo modello, e questo numero viene mantenuto insieme allo stato dell'istanza del processo;
  • Lo stato persistente del processo viene letto dal database prima in un modello oggetto conveniente, con cui può lavorare la procedura di migrazione, se il numero di versione del modello è cambiato;
  • La procedura di migrazione è posizionata vicino alla logica di business e viene chiamata "pigramente" per ogni istanza del processo aziendale al momento del suo ripristino dal database;
  • Se è necessario migrare lo stato di tutte le istanze del processo in modo rapido e sincrono, si applicano soluzioni più classiche per la migrazione del database, ma qui si deve lavorare con JSON.

È necessario un altro framework per i processi aziendali?

Le soluzioni descritte nell'articolo ci hanno permesso di semplificarci notevolmente la vita, di espandere il raggio delle questioni trattate a livello di sviluppo applicativo e di rendere più attraenti le idee di estrazione della logica aziendale in microservizi. A questo scopo è stato fatto molto lavoro, è stato creato un framework "leggero" per i processi aziendali, oltre a componenti di servizio per risolvere i problemi evidenziati nel contesto di un ampio spettro di compiti applicativi. Abbiamo il desiderio di condividere questi risultati e di rendere accessibile lo sviluppo di componenti comuni sotto licenza libera. Questo richiederà sforzi e tempo. La comprensione della domanda per tali soluzioni potrebbe diventare per noi uno stimolo aggiuntivo. Nell'articolo proposto viene dedicata poca attenzione alle possibilità del framework stesso, ma alcune di esse sono visibili negli esempi presentati. Se decidiamo di pubblicare il nostro framework, sarà dedicato un articolo separato. Nel frattempo, saremo grati se ci lascerete un piccolo feedback rispondendo alla domanda:

Solo gli utenti registrati possono partecipare al sondaggio. Accedi, per favore.

È necessario un altro framework per i processi aziendali?

  • 18,8%Sì, stiamo cercando qualcosa di simile da tempo

  • 12,5%È interessante sapere di più sulla vostra implementazione, potrebbe esserci utile

  • 6,2%Usiamo uno dei framework esistenti, ma stiamo pensando a un cambiamento

  • 18,8%Usiamo uno dei framework esistenti, ci soddisfa

  • 18,8%Ci arrangiamo senza framework

  • 25,0%Stiamo scrivendo il nostro

Hanno votato 16 utenti. Si sono astenuti 7 utenti.

Fonte: habr.com

Acquista hosting affidabile per siti web con protezione DDoS, VPS VDS server 🔥 Acquista hosting affidabile per siti web con protezione DDoS, VPS VDS server | ProHoster