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 parte significativa è rappresentata da sistemi transazionali con un'enorme quantità di logica di business e gestione documentale simile a un sistema di gestione elettronica dei documenti. Le versioni moderne dei nostri prodotti si basano su tecnologie JavaEE, ma stiamo anche sperimentando attivamente con i microservizi. Uno dei punti più problematici di queste soluzioni è l'integrazione di vari sottosistemi appartenenti a domini correlati. I compiti di integrazione ci hanno sempre causato enormi grattacapi, indipendentemente dagli stili architetturali, stack tecnologici e framework utilizzati, tuttavia, di recente si sono registrati progressi nella risoluzione di tali compiti.

Nell'articolo che vi proponiamo, parlerò dell'esperienza e delle ricerche architettoniche dell'Università Nazionale ‘Krista’ in questo settore. Esamineremo anche un esempio di una semplice soluzione a un compito di integrazione dal punto di vista di un sviluppatore applicativo e scopriremo cosa si cela dietro questa semplicità.

Disclaimer

Le soluzioni architetturali e tecniche descritte nell'articolo sono proposte da me sulla base della mia esperienza personale, contestualizzata in compiti specifici. Queste soluzioni non hanno pretese di universalità e potrebbero non risultare ottimali in diverse condizioni d'uso.

Cosa c'entra BPM?

Per rispondere a questa domanda, è necessario approfondire un po' la specificità delle applicazioni delle nostre soluzioni. La maggior parte della logica di business nel nostro tipico sistema transazionale consiste nell'immissione di dati nel database tramite interfacce utente, la verifica manuale e automatizzata di tali dati, il loro passaggio attraverso un certo workflow, la pubblicazione in un altro sistema / database analitico / archivio e la generazione di report. Pertanto, la funzione chiave del sistema per i clienti è l'automazione dei loro processi aziendali interni.

Per comodità, utilizziamo nel nostro linguaggio il termine "documento" come una certa astrazione di un insieme di dati, uniti da una chiave comune, a cui è possibile "associare" un determinato workflow.
Ma come gestire la logica di integrazione? Infatti, il compito dell'integrazione è originato dall'architettura del sistema, che è 'scomposta' in parti NON per richiesta del cliente, ma sotto l'influenza di altri fattori:

  • sotto l'influenza della legge di Conway;
  • a causa del riutilizzo di sottosistemi precedentemente sviluppati per altri prodotti;
  • su decisione dell'architetto, in base ai requisiti non funzionali.

C'è una forte tentazione di separare la logica di integrazione dalla logica di business del flusso di lavoro principale, per non contaminare la logica di business con artefatti di integrazione e liberare lo sviluppatore applicativo dalla necessità di comprendere le specificità del panorama architettonico del sistema. Questo approccio ha diversi vantaggi, tuttavia la pratica dimostra la sua inefficacia:

  • la risoluzione delle problematiche di integrazione tende a ridursi alle opzioni più semplici, come le chiamate sincrone, a causa della limitatezza dei punti di estensione nell'implementazione del flusso di lavoro 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 interromperla modificando il workflow;
  • il sistema smette di essere un tutto unico dal punto di vista dell'utente, diventano evidenti le "giunture" tra i sottosistemi, si generano 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 qualifica per gli sviluppatori applicativi non esplodano, la creazione di nuove interazioni di integrazione deve avvenire in modo semplice e fluido, con le minime opportunità di scelta del metodo di soluzione. Questo è più difficile di quanto sembri: lo strumento deve essere abbastanza potente da fornire all'utente un'ampia gamma di opzioni, senza però permettergli di commettere errori dannosi. Ci sono molte domande a cui deve rispondere un ingegnere nel contesto delle attività di integrazione, domande di cui non dovrebbe preoccuparsi uno sviluppatore applicativo nella sua attività quotidiana: limiti delle transazioni, coerenza, atomicità, sicurezza, scalabilità, distribuzione del carico e delle risorse, routing, marshalling, diffusione e commutazione dei contesti, ecc. È necessario fornire agli sviluppatori applicativi modelli di soluzioni sufficientemente semplici, che contengano già le risposte a tutte queste questioni. Questi modelli devono essere abbastanza sicuri: la logica aziendale cambia molto frequentemente, il che aumenta il rischio di introdurre errori, mentre il costo di tali errori deve rimanere a un livello sufficientemente basso.

Ma che c'entra il BPM? Esistono numerosi modi per implementare il workflow…
Infatti, nelle nostre soluzioni è molto popolare un'altra implementazione dei processi aziendali – attraverso la definizione dichiarativa del diagramma delle transizioni di stato e il collegamento di gestori con la logica aziendale sulle transizioni. In questo caso, lo stato che determina la posizione attuale del "documento" nel processo aziendale è un attributo dello stesso "documento".

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

La popolarità di questa implementazione è dovuta alla relativa semplicità e velocità di creazione di processi aziendali lineari. Tuttavia, man mano che i sistemi software diventano sempre più complessi, la parte automatizzata del processo aziendale si espande e si complica. Nasce la necessità di decomporre, riutilizzare parti dei processi e diramare i processi affinché ogni ramo venga eseguito in parallelo. In tali condizioni, lo strumento diventa scomodo e il diagramma delle transizioni di stato perde informatività (le interazioni di integrazione non sono riflettute sul diagramma).

Integrazione in stile BPM
Ecco come appare il processo dopo diverse iterazioni di chiarificazione dei requisiti

La soluzione a 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: è diventato possibile implementare processi aziendali complessi mantenendo un diagramma sufficientemente informativo e aggiornato in 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 laboriosità nella creazione di processi aziendali tramite strumenti visivi non ha permesso di raggiungere standard di produttività accettabili, e lo strumento stesso è diventato uno dei meno amati tra gli sviluppatori. Ci sono state anche lamentele riguardo alla struttura interna del motore, che hanno portato alla creazione di molte "patch" e "soluzioni improvvisate".

Un aspetto positivo fondamentale dell'uso di jBPM è stata la consapevolezza dei benefici e dei rischi associati alla presenza di uno stato persistente per un'istanza del processo aziendale. Abbiamo anche identificato la possibilità di utilizzare un approccio orientato ai processi per implementare complessi protocolli di integrazione tra diverse applicazioni, sfruttando interazioni asincrone tramite segnali e messaggi. La presenza di uno stato persistente gioca un ruolo cruciale in questo contesto.

Dall'affermazione precedente, si può concludere: l'approccio orientato ai processi in stile BPM ci consente di affrontare un ampio spettro di compiti per l'automazione di processi aziendali sempre più complessi, integrando armoniosamente le attività di integrazione e mantenendo la possibilità di visualizzare il processo implementato nella notazione più adatta.

Svantaggi delle chiamate sincrone come modello di integrazione

L'integrazione sincrona si riferisce a una semplice chiamata bloccante. Un sottosistema funge da lato server e espone un'API con il metodo richiesto. L'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 situati all'interno della stessa applicazione e processo, oppure in vari. Nel secondo caso è necessaria l'applicazione di una qualche implementazione RPC e l'assicurazione del marshalling dei parametri e del risultato della chiamata.

Integrazione in stile BPM

Questo modello di integrazione presenta un numero considerevole di svantaggi, ma è ampiamente utilizzato nella pratica a causa della sua semplicità. La velocità di implementazione è allettante e porta a riutilizzarlo ancora e ancora in situazioni di scadenze "scottanti", registrando la soluzione come debito tecnico. Tuttavia, può anche capitare che sviluppatori inesperti lo utilizzino inconsapevolmente, senza rendersi conto delle conseguenze negative.

Oltre al miglioramento più evidente della coesione delle sotto-componenti, ci sono anche problemi meno apparenti legati alla "frammentazione" e "prolungamento" delle transazioni. Infatti, se la logica aziendale apporta alcune modifiche, le transazioni diventano indispensabili; queste bloccheranno, a loro volta, risorse specifiche dell'applicazione che sono influenzate da tali modifiche. Ciò significa che una sotto-componente non potrà completare la transazione e rimuovere i blocchi fino a quando non riceverà una risposta dall'altra. Questo aumenta notevolmente il rischio di generare vari effetti:

  • perdita di reattività del sistema, gli utenti devono aspettare a lungo le risposte alle loro richieste;
  • il server smette di rispondere del tutto alle richieste degli utenti a causa del pool di thread sovraccarico: la maggior parte dei thread è "bloccata" in attesa di una risorsa occupata da una transazione;
  • iniziano a verificarsi deadlock: la probabilità della loro occorrenza dipende fortemente dalla durata delle transazioni, dalla quantità di logica aziendale coinvolta e dai blocchi;
  • si verificano errori di timeout delle transazioni;
  • Il server va in 'out of memory' se l'operazione richiede di elaborare e modificare grandi volumi di dati, e la presenza di integrazioni sincrone rende molto difficile suddividere l'elaborazione in transazioni più 'leggere'.

Dal punto di vista architettonico, l'uso di chiamate bloccanti durante l'integrazione porta a una perdita di controllo sulla qualità delle singole sottosistemi: non è possibile garantire gli obiettivi di qualità di un sottosistema separatamente da quelli dell'altro. Se i sottosistemi sono sviluppati da team diversi, questo diventa un grosso problema.

Le cose diventano ancora più interessanti se i sottosistemi da integrare si trovano in diverse applicazioni e devono essere apportate modifiche sincrone da entrambe le parti. Come garantire la transazionalità di queste modifiche?

Se le modifiche vengono apportate in transazioni separate, sarà necessario garantire una gestione affidabile delle eccezioni e delle compensazioni, il che annulla completamente il principale vantaggio delle integrazioni sincrone – la semplicità.

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

La «Saga» come soluzione ai problemi delle transazioni

Con la crescente popolarità dei microservizi, sta aumentando la richiesta di Schema Saga.

Questo schema affronta perfettamente i problemi sopra menzionati delle lunghe transazioni e amplia le opportunità di gestione dello stato del sistema da parte della logica di business: la compensazione dopo una transazione fallita può non riportare il sistema allo stato iniziale, ma fornire un percorso alternativo per l'elaborazione dei dati. Questo consente inoltre di non ripetere i passaggi di elaborazione dei dati già completati durante i tentativi ripetuti di portare il processo a una conclusione 'buona'.

È interessante notare che in sistemi monolitici questo schema è anche rilevante quando si tratta di integrazione di sottosistemi debolmente collegati e si osservano effetti negativi causati da transazioni prolungate e dalle relative blocchi delle risorse.

Applicando i nostri processi aziendali in stile BPM, implementare le 'Saga' risulta molto semplice: i singoli passaggi delle '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'. Ciò significa che non abbiamo bisogno di alcun meccanismo di coordinamento aggiuntivo. Sarà necessario solo un broker di messaggi che supporti garanzie 'almeno una volta' come trasporto.

Ma anche questa soluzione ha il suo 'prezzo':

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

Per i sistemi monolitici, la giustificazione per l'uso del modello «Saga» non è così evidente. Per i microservizi e altri SOA, dove probabilmente esiste già un broker e la piena coerenza è stata sacrificata fin dall'inizio del progetto, i vantaggi derivanti dall'uso di questo modello possono superare significativamente gli svantaggi, soprattutto in presenza di un'API conveniente a livello di logica di business.

Incapsulamento della logica di business nei microservizi

Quando abbiamo iniziato a sperimentare con i microservizi, è emerso un interrogativo legittimo: dove posizionare la logica di business di dominio rispetto al servizio che fornisce la persistenza dei dati di dominio?

Osservando l'architettura dei vari BPMS, potrebbe sembrare sensato separare la logica di business dalla persistenza: creare uno strato di microservizi indipendenti dalla piattaforma e dal dominio, che formano 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 si occupano dell'orchestrazione dei servizi dello strato di persistenza.

Integrazione in stile BPM

Questo approccio ha un grande vantaggio: è possibile incrementare la funzionalità della piattaforma senza limiti, e solo il corrispondente strato dei microservizi della piattaforma diventerà più complesso. I processi aziendali di qualsiasi dominio possono subito utilizzare la nuova funzionalità della piattaforma non appena viene aggiornata.

Un'analisi più dettagliata ha evidenziato significative carenze in questo approccio:

  • Il servizio della piattaforma, che esegue la logica aziendale di molti domini, porta con sé grandi rischi come unico punto di fallimento. Le modifiche frequenti alla logica aziendale aumentano il rischio di errori, portando a guasti che si propagano a tutto il sistema;
  • Problemi di prestazioni: la logica aziendale lavora con i propri dati attraverso un'interfaccia ristretta e lenta:
    • I dati devono essere nuovamente marshalizzati e inviati attraverso lo stack di rete;
    • Il servizio di dominio spesso restituirà più dati di quanti ne necessiti la logica aziendale per l'elaborazione, a causa di capacità insufficienti di parametrizzazione delle richieste a livello dell'API esterna del servizio;
    • diverse parti indipendenti della logica aziendale possono ripetere la richiesta degli stessi dati per l'elaborazione (è possibile attenuare questo problema aggiungendo componenti di sessione che memorizzano i dati in cache, ma ciò complica ulteriormente l'architettura e crea problemi di aggiornamento dei dati e invalidazione della cache);
  • problemi di transazionalità:
    • i processi aziendali con stato persistente, il cui mantenimento è responsabilità del servizio di piattaforma, si disallineano dai dati di dominio, e non si prevedono soluzioni semplici a questo problema;
    • spostamento del bloccaggio dei dati di dominio al di fuori della transazione: se la logica aziendale di dominio richiede di apportare modifiche, è necessario escludere la possibilità di modifiche concorrenti ai dati elaborati. Il bloccaggio esterno dei dati può aiutare a risolvere il problema, ma tale soluzione comporta ulteriori rischi e riduce l'affidabilità complessiva del sistema;
  • complessità aggiuntive durante l'aggiornamento: in alcuni casi è necessario aggiornare il servizio di persistenza e la logica aziendale in modo sincrono o in un rigoroso ordine sequenziale.

Alla fine, è stato necessario tornare alle origini: incapsulare i dati del dominio e la logica di business del dominio in un unico microservizio. Questo approccio semplifica la percezione del microservizio come un componente coeso all'interno del sistema e non genera i problemi sopra menzionati. Tuttavia, ciò ha un costo:

  • è necessaria la standardizzazione dell'API per interagire con la logica di business (in particolare, per garantire attività degli utenti all'interno dei processi aziendali) e i servizi della piattaforma API; è necessario prestare maggiore attenzione alle modifiche all'API, alla compatibilità diretta e retroattiva;
  • è necessario l'aggiunta di librerie runtime aggiuntive per garantire il funzionamento della logica di business all'interno di ciascun microservizio, e questo comporta nuove esigenze per tali librerie: leggerezza e minimo numero di dipendenze transitive;
  • Gli sviluppatori della logica aziendale devono tenere traccia delle versioni delle librerie: se un microservizio non è stato aggiornato da tempo, è probabile che contenga versioni obsolete delle librerie. Questo può diventare un ostacolo inaspettato per l'aggiunta di nuove funzionalità e potrebbe richiedere la migrazione della vecchia logica aziendale del servizio a nuove versioni delle librerie, se ci sono state modifiche incompatibili tra le versioni.

Integrazione in stile BPM

Nella struttura architetturale è presente anche uno strato di servizi di piattaforma, ma questo strato non forma un contenitore per l'esecuzione della logica aziendale di dominio, bensì solo il suo ambiente, fornendo funzioni 'di piattaforma' ausiliarie. Questo strato non è necessario solo per mantenere leggero il microservizio di dominio, ma anche per centralizzare la gestione.

Ad esempio, le attività degli utenti nei processi aziendali generano compiti. Tuttavia, lavorando sui compiti, l'utente deve vedere i compiti di tutti i domini in un elenco comune, quindi deve esserci un servizio di piattaforma di registrazione dei compiti, ripulito dalla logica di business del dominio. Mantenere l'incapsulamento della logica di business in questo contesto è abbastanza problematica e questo rappresenta un ulteriore compromesso di questa architettura.

Integrazione dei processi aziendali attraverso gli occhi di uno sviluppatore applicativo

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

Cerchiamo di risolvere un compito di integrazione piuttosto complesso, appositamente ideato per questo articolo. Questa sarà una "task" coinvolgente tre applicazioni, in cui ognuna di esse definisce un certo nome di dominio: "app1", "app2", "app3".

All'interno di ogni applicazione si avviano processi aziendali che iniziano a "giocare a palla" attraverso il bus di integrazione. Il ruolo della palla sarà rivestito dai messaggi chiamati "Ball".

Regole del gioco:

  • il primo giocatore è l'iniziatore. Egli invita gli altri giocatori a unirsi al gioco, lo avvia e può terminarlo in qualsiasi momento;
  • gli altri giocatori dichiarano la loro partecipazione al gioco, "si presentano" l'uno all'altro e al 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 con ogni passaggio della palla fatto da quel giocatore. Al termine dell'energia, il giocatore esce dal gioco dichiarando la sua uscita;
  • se un giocatore rimane da solo, dichiara subito la sua uscita;
  • quando tutti i giocatori sono fuori, il primo giocatore dichiara la fine del gioco. Se è uscito prima, rimane a seguire il gioco per concluderlo.

Per affrontare questo compito, utilizzerò il nostro DSL per 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):

classe 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: creata una volta, utilizzata da tutte
// 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("... giocatore ${signal.data} si unisce ...")
    }
    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 gestori aggiuntivi per il log sulle attività
    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("Passo ${process.shotCounter + 1}: " +
            "${process.playerName} >>> ${player.name}")
}

Oltre all'esecuzione della logica aziendale, il codice fornito può generare un modello oggettuale del processo aziendale, che può essere visualizzato sotto forma di diagramma. Non abbiamo ancora realizzato il visualizzatore, quindi ho dovuto dedicare un po' di tempo al disegno (qui ho semplificato leggermente la notazione BPMN per quanto riguarda l'uso dei gateway, al fine di 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: I'm here!") }
    sendPlayerOut.onExit { println("$playerName: I'm out!") }
}

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

Diagramma:

Integrazione in stile BPM

Nell'app app3 faremo in modo che il giocatore abbia un comportamento 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 tutti gli altri aspetti, il comportamento del giocatore rimane invariato, quindi il diagramma non cambia.

Ora è necessario un test per avviare tutto questo. Riporterò solo il codice del test stesso per non appesantire l'articolo con boilerplate (in realtà ho utilizzato un ambiente di test creato in precedenza per il collaudo dell'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 bisogna aspettare un po' mentre i giocatori "fanno conoscenza" tra di loro.
    // Aspettare con sleep è una cattiva soluzione, 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;
}

Avviando il test, guardando il log:

output della console

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

Da tutto ciò si possono trarre alcune conclusioni importanti:

  • con gli strumenti necessari, gli sviluppatori applicativi possono creare interazioni integrate tra le applicazioni senza distaccarsi dalla logica aziendale;
  • la complessità (complexity) del compito di integrazione, che richiede competenze ingegneristiche, può essere nascosta all'interno del framework se è stata predisposta fin dall'inizio nell'architettura del framework. Tuttavia, la difficoltà (difficulty) del compito non può essere nascosta, pertanto la soluzione di un compito difficile nel codice apparirà di conseguenza;
  • Quando si sviluppa la logica di integrazione, è fondamentale considerare la coerenza eventuale e l'assenza di linearizzabilità dei cambiamenti di stato di tutti i partecipanti all'integrazione. Ciò costringe a complicare la logica per renderla insensibile all'ordine degli eventi esterni. Nel nostro esempio, il giocatore deve partecipare al gioco solo dopo aver annunciato la sua uscita: gli altri giocatori continueranno a passargli la palla finché l'informazione sul suo abbandono non arriverà e non verrà elaborata da tutti i partecipanti. Questa logica non deriva dalle regole del gioco ed è una soluzione compromissoria nell'ambito dell'architettura scelta.

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

Tutti i messaggi sono in un'unica coda

Tutte le applicazioni integrate funzionano con un'unica interfaccia di integrazione, rappresentata come un broker esterno, una coda BPMQueue per i messaggi e un topic BPMTopic per i segnali (eventi). Far transitare tutti i messaggi attraverso un'unica coda è, di per sé, un compromesso. A livello di logica aziendale, ora è possibile introdurre quanti più nuovi tipi di messaggi si desidera, senza apportare modifiche alla struttura del sistema. Questo semplifica notevolmente le operazioni, ma comporta certi rischi, che nel contesto delle nostre attività tipiche ci sono sembrati trascurabili.

Integrazione in stile BPM

Tuttavia, c'è una sottile distinzione: ogni applicazione filtra i propri messaggi dalla coda già all'ingresso, in base al nome del proprio dominio. Inoltre, il dominio può essere specificato anche nei segnali, se è necessario limitare il campo d'azione del segnale a un'unica applicazione. Questo dovrebbe aumentare la capacità della bus, ma ora la logica aziendale deve operare con i nomi di dominio: per indirizzare i messaggi è obbligatorio, per i segnali è preferibile.

Garantire l'affidabilità dell'interfaccia di integrazione

L'affidabilità è costituita da diversi fattori:

  • Il broker di messaggi selezionato è un componente critico dell'architettura e un punto unico di fallimento: deve essere sufficientemente resistente ai guasti. Dovrebbero essere utilizzate solo implementazioni collaudate, con un buon supporto e una vasta comunità.
  • È fondamentale garantire un'alta disponibilità del broker di messaggi, per cui deve essere fisicamente separato dalle applicazioni integrate (garantire l'alta disponibilità delle applicazioni con logica di business applicativa è significativamente più complesso e costoso).
  • Il broker deve garantire consegne "almeno una volta". Questo è un requisito imprescindibile per il funzionamento affidabile del bus di integrazione. Non è necessaria garanzia di "esattamente una volta": i processi aziendali, in genere, non sono sensibili alla ricezione ripetuta di messaggi o eventi, e in situazioni particolari dove è importante, è più semplice aggiungere un controllo aggiuntivo nella logica di business piuttosto che utilizzare continuamente garanzie piuttosto "onerose".
  • L'invio di messaggi e segnali deve essere coinvolto in una transazione generale con la modifica dello stato dei processi aziendali e dei dati di dominio. L'opzione preferita sarà l'uso del pattern Cassetta di invio transazionale, ma richiederà una tabella aggiuntiva nel database e un retransmitter. Nelle applicazioni JEE, questo aspetto può essere semplificato utilizzando un gestore JTA locale, ma la connessione al broker scelto deve essere in grado di operare in modalità XA;
  • i gestori dei messaggi in entrata e degli eventi 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 cancellata;
  • i messaggi che non possono essere consegnati a causa di errori devono essere archiviati in un deposito separato DLQ (Dead Letter Queue). Abbiamo creato un microservizio di piattaforma separato che salva tali messaggi nel proprio deposito, li indicizza per attributi (per una rapida raggruppamento e ricerca) e fornisce un'API per visualizzarli, rispedirli all'indirizzo di destinazione, eliminare messaggi. Gli amministratori del sistema possono interagire con questo servizio tramite la propria interfaccia web;
  • Nelle impostazioni del broker è necessario configurare il numero di tentativi di consegna e il ritardo tra le consegne per ridurre la probabilità che i messaggi finiscano nella DLQ (calcolare i parametri ottimali è praticamente impossibile, ma si può agire empiricamente e adattarli durante l'uso);
  • Il deposito della DLQ deve essere monitorato continuamente, e il sistema di monitoraggio deve avvisare gli amministratori di sistema affinché possano intervenire il prima possibile nel caso di messaggi non consegnati. Ciò contribuirà a ridurre l'area di impatto di un guasto o di un errore nella logica di business;
  • La bus di integrazione deve essere insensibile all'assenza temporanea delle applicazioni: le iscrizioni ai topic devono essere durature e il nome a dominio dell'applicazione deve essere unico affinché, durante l'assenza dell'applicazione, i suoi messaggi nella coda non vengano elaborati da qualcun altro.

Garantire la thread safety della logica di business

Un singolo esemplare di un processo aziendale può ricevere più messaggi ed eventi contemporaneamente, la cui elaborazione avrà luogo in parallelo. Allo stesso tempo, per il programmatore applicativo, tutto deve essere semplice e thread-safe.

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

  • l'avvio di un esemplare del processo aziendale;
  • un'azione dell'utente relativa a un'attività all'interno del processo aziendale;
  • la ricezione di un messaggio o di un segnale a cui è sottoscritto l'esemplare del processo aziendale;
  • l'attivazione di un timer impostato dall'esemplare del processo aziendale;
  • un'interazione di controllo tramite API (ad esempio, un'interruzione di emergenza del processo).

Ogni evento del genere può cambiare lo stato di un'istanza del processo aziendale: alcune attività possono finire e altre iniziare, e i valori delle proprietà persistenti possono cambiare. La chiusura di qualsiasi attività può attivare una o più attività successive. Queste, a loro volta, possono rimanere in attesa di altri eventi oppure, se non necessitano di ulteriori dati, possono completarsi nella stessa transazione. Prima della chiusura della 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, rappresentano un ottimo punto di sincronizzazione per il trattamento, se si utilizza il SELECT FOR UPDATE. Se una transazione riesce a ottenere lo stato del processo aziendale dal database per modificarlo, nessun'altra transazione potrà nel frattempo ottenere lo stesso stato per un'altra modifica, e dopo il completamento della prima transazione, la seconda riceverà garantito lo stato già modificato.

Utilizzando i lock pessimisti a livello di DBMS, soddisfiamo tutti i requisiti necessari ACID, mantenendo anche la possibilità di scalare l'applicazione con logica aziendale aumentando il numero di istanze attive.

Tuttavia, i lock pessimisti possono portarci a deadlock, quindi è comunque consigliabile limitare il SELECT FOR UPDATE a un timeout ragionevole nel caso si verifichino deadlock in situazioni estreme di logica aziendale.

Un altro problema è la sincronizzazione dell'avvio del processo aziendale. Finché non esiste un'istanza del processo aziendale, non esiste nemmeno 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 allo scope corrispondente. Per risolvere questo problema, utilizziamo un altro meccanismo di locking che consente di acquisire un lock su una risorsa arbitraria, specificata da una chiave nel formato URI, tramite un servizio esterno.

Nei nostri esempi, il processo aziendale InitialPlayer contiene la dichiarazione

uniqueConstraint = UniqueConstraints.singleton

Pertanto, nel log sono presenti messaggi riguardanti l'acquisizione e il rilascio del blocco della chiave corrispondente. Non ci sono messaggi simili per altri processi aziendali: il vincolo unico non è stato definito.

Problemi dei processi aziendali con stato persistente

A volte, avere uno stato persistente non solo aiuta, ma può anche ostacolare notevolmente lo sviluppo.
I problemi iniziano quando è necessario apportare modifiche alla logica aziendale e/o al modello di processo aziendale. Non tutte queste modifiche sono compatibili con lo stato precedente dei processi aziendali. Se nel database ci sono molti esemplari "attivi", apportare modifiche incompatibili può causare molti problemi, come abbiamo spesso riscontrato utilizzando jBPM.

A seconda della profondità delle modifiche, si possono seguire due percorsi:

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

Il primo approccio è più semplice, ma presenta delle limitazioni e svantaggi, ad esempio:

  • duplicazione della logica di business in molti modelli di processi aziendali, aumento del volume della logica di business;
  • spesso è necessario un passaggio immediato alla nuova logica di business (per le questioni di integrazione – quasi sempre);
  • lo sviluppatore non sa quando è possibile eliminare i modelli obsoleti.

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

  • nella base di dati, lo stato persistente del processo aziendale viene memorizzato in un formato facilmente leggibile e facilmente elaborabile: in una riga di formato JSON. Questo consente di eseguire migrazioni sia all'interno dell'applicazione che all'esterno. Nel caso peggiore, è possibile modificarlo manualmente (particolarmente utile durante lo sviluppo e il debug);
  • la logica di integrazione aziendale non utilizza i nomi dei processi aziendali, in modo che sia possibile sostituire in qualsiasi momento l'implementazione di uno dei processi coinvolti con una nuova, con un nuovo nome (ad esempio, «InitialPlayerV2»). Il collegamento avviene tramite i nomi dei messaggi e dei 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 inizialmente in un modello oggetti conveniente, con cui può lavorare la procedura di migrazione, se il numero di versione del modello è cambiato;
  • la procedura di migrazione è posizionata accanto alla logica di business ed è chiamata 'pigramente' per ogni istanza del processo di business nel momento in cui viene ripristinata dal database;
  • se è necessario migrare rapidamente e sincronicamente lo stato di tutte le istanze del processo, si applicano soluzioni di migrazione più classiche del database, ma in questo caso si deve lavorare con JSON.

Serve un altro framework per i processi aziendali?

Le soluzioni descritte nell'articolo ci hanno permesso di semplificare notevolmente la nostra vita, ampliare le questioni affrontate a livello di sviluppo applicativo e rendere più allettanti le idee di estrazione della logica aziendale nei microservizi. A tal fine, è stato svolto un lavoro notevole, creando un framework molto "leggero" per i processi aziendali, oltre a componenti di servizio per affrontare i problemi identificati in un ampio contesto di compiti applicativi. Abbiamo il desiderio di condividere questi risultati, rendendo la sviluppo di componenti comuni accessibile al pubblico sotto licenza libera. Ciò richiederà sforzi e tempo. Comprendere la domanda per tali soluzioni potrebbe costituire un ulteriore stimolo per noi. Nell'articolo proposto, si presta poca attenzione alle funzionalità del framework stesso, ma alcune di esse sono evidenti dagli esempi presentati. Se decidiamo di pubblicare il nostro framework, verrà dedicato un articolo separato. Nel frattempo, saremmo grati se poteste lasciarci un breve 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 tornare utile

  • 6,2%utilizziamo uno dei framework esistenti, ma stiamo pensando di cambiare

  • 18,8%utilizziamo uno dei framework esistenti, siamo soddisfatti

  • 18,8%ce la caviamo senza framework

  • 25,0%stiamo scrivendo il nostro

Hanno votato 16 utenti. 7 utenti si sono astenuti.

Fonte: habr.com

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