Intégration au style BPM

Intégration au style BPM

Bonjour, Habr!

Notre entreprise se spécialise dans le développement de solutions logicielles de type ERP, dont une grande partie est constituée de systèmes transactionnels avec une quantité énorme de logique métier et de circulation documentaire à la manière de SED. Les versions modernes de nos produits sont basées sur des technologies JavaEE, mais nous expérimentons également activement avec les microservices. L'un des problèmes majeurs de ces solutions est l'intégration des différents sous-systèmes appartenant à des domaines connexes. Les tâches d'intégration ont toujours été une véritable douleur pour nous, quelle que soit l'architecture, les technologies ou les frameworks que nous utilisons. Toutefois, ces dernières années, nous avons constaté des progrès dans la résolution de ces problèmes.

Dans cet article que je vous propose, je vais parler de l'expérience et des recherches architecturales de l'entreprise «Krista» dans ce domaine. Nous examinerons également un exemple simple de solution à un problème d'intégration du point de vue d'un développeur d'application et découvrirons ce qui se cache derrière cette simplicité.

Avertissement

Les solutions architecturales et techniques décrites dans l'article sont proposées sur la base de mon expérience personnelle dans le contexte de tâches spécifiques. Ces solutions ne prétendent pas à l'universalité et pourraient ne pas être optimales dans d'autres conditions d'utilisation.

Quel rapport avec le BPM ?

Pour répondre à cette question, il faut approfondir un peu la spécificité des tâches d'application de nos solutions. La majeure partie de la logique métier dans notre système transactionnel typique concerne la saisie de données dans la base de données via des interfaces utilisateur, la vérification manuelle et automatisée de ces données, leur passage par un certain workflow, leur publication dans un autre système / base analytique / archive, et la génération de rapports. Ainsi, la fonction clé du système pour les clients est l'automatisation de leurs processus métier internes.

Pour faciliter la communication, nous utilisons le terme «document» comme une abstraction d'un ensemble de données regroupées par une clé commune, à laquelle un certain workflow peut être «attaché».
Mais que faire de la logique d'intégration ? En effet, la tâche d'intégration est engendrée par l'architecture du système, qui est «découpée» en parties NON pas à la demande du client, mais sous l'influence de facteurs complètement différents :

  • sous l'effet de la loi de Conway ;
  • en raison de la réutilisation de sous-systèmes précédemment développés pour d'autres produits ;
  • à la décision de l'architecte, en fonction des exigences non fonctionnelles.

Il existe une grande tentation de séparer la logique d'intégration de la logique métier du workflow principal, afin de ne pas contaminer la logique métier avec des artefacts d'intégration et de libérer le développeur d'applications de la nécessité de comprendre les spécificités du paysage architectural du système. Bien que cette approche présente certains avantages, la pratique montre son inefficacité :

  • la solution des problèmes d'intégration se réduit généralement aux options les plus simples de synchronisations en raison de la limitation des points d'extension dans la mise en œuvre du workflow principal (sur les inconvénients de l'intégration synchrone – ci-dessous) ;
  • les artefacts d'intégration pénètrent tout de même dans la logique métier principale lorsqu'un retour d'information est nécessaire d'un autre sous-système ;
  • le développeur d'applications ignore l'intégration et peut facilement la casser en modifiant le workflow ;
  • le système cesse d'être une entité cohérente du point de vue de l'utilisateur, des 'coutures' entre les sous-systèmes deviennent visibles, et des opérations utilisateur redondantes apparaissent, initiant le transfert de données d'un sous-système à un autre.

Une autre approche consiste à considérer les interactions d'intégration comme une partie intégrante de la logique commerciale principale et du workflow. Pour éviter que les exigences de qualification des développeurs d'applications n'atteignent des sommets, la création de nouvelles interactions d'intégration doit se faire facilement et sans contraintes, avec le minimum de choix possible en matière de solutions. Cela s'avère plus complexe qu'il n'y paraît : l'outil doit être suffisamment puissant pour fournir à l'utilisateur la multitude d'options nécessaires tout en évitant de se tirer dans le pied. De nombreuses questions se posent auxquelles un ingénieur doit répondre dans le contexte des tâches d'intégration, mais qui ne devraient pas occuper l'esprit d'un développeur d'applications dans son travail quotidien : les limites des transactions, la cohérence, l'atomicité, la sécurité, l'évolutivité, la répartition des charges et des ressources, le routage, le marshalling, la diffusion et le changement de contextes, etc. Il est essentiel de proposer aux développeurs d'applications des modèles de solutions suffisamment simples, déjà intégrés avec des réponses à toutes ces questions. Ces modèles doivent être suffisamment sûrs : la logique commerciale change très souvent, augmentant les risques d'erreurs, et le coût des erreurs doit rester à un niveau raisonnable.

Mais quel est le rapport avec le BPM ? Il existe de nombreuses façons de mettre en œuvre un workflow...
Effectivement, dans nos solutions, une autre réalisation des processus d'affaires est très populaire – via la définition déclarative d'un diagramme de transitions d'état et la connexion de gestionnaires avec la logique commerciale lors des transitions. Dans ce cas, l'état définissant la position actuelle du "document" dans le processus d'affaires est un attribut du "document" lui-même.

Intégration au style BPM
Voici à quoi ressemble le processus au démarrage du projet.

La popularité de cette mise en œuvre est due à la relative simplicité et à la rapidité de création des processus métier linéaires. Cependant, à mesure que les systèmes logiciels deviennent de plus en plus complexes, la partie automatisée du processus métier s'étend et se complique. Il devient nécessaire de décomposer, de réutiliser des parties des processus, ainsi que de ramifier les processus pour que chaque branche soit exécutée en parallèle. Dans ces conditions, l'outil devient peu pratique, et le diagramme des états perd de son informativité (les interactions d'intégration ne sont pas du tout reflétées dans le diagramme).

Intégration au style BPM
Voici à quoi ressemble le processus après plusieurs itérations de clarification des exigences.

La solution à cette situation a été l'intégration du moteur jBPM dans certains produits avec les processus métier les plus complexes. À court terme, cette solution a eu un certain succès : il est devenu possible de réaliser des processus métier complexes tout en maintenant un diagramme suffisamment informatif et à jour dans la notation BPMN2.

Intégration au style BPM
Une petite partie d'un processus métier complexe.

À long terme, la solution n'a pas répondu aux attentes : la forte charge de travail nécessaire à la création de processus métier via des outils visuels n'a pas permis d'atteindre des niveaux de productivité acceptables, et l'outil est devenu l'un des moins aimés des développeurs. Des critiques ont également été faites à propos de la structure interne du moteur, conduisant à l'apparition de nombreux « patchs » et « béquilles ».

Le principal aspect positif de l'utilisation de jBPM a été la prise de conscience des avantages et des inconvénients d'un état persistant pour une instance de processus métier. Nous avons également vu la possibilité d'appliquer une approche processuelle pour réaliser des protocoles d'intégration complexes entre différentes applications en utilisant des interactions asynchrones via des signaux et des messages. La présence d'un état persistant joue un rôle crucial à cet égard.

Sur la base de ce qui a été dit, on peut conclure que : l'approche processuelle dans le style BPM nous permet de résoudre un large éventail de tâches d'automatisation de processus métier de plus en plus complexes, d'intégrer harmonieusement des activités d'intégration dans ces processus et de conserver la possibilité d'une représentation visuelle du processus réalisé dans une notation adéquate.

Inconvénients des appels synchrones en tant que schéma d'intégration

L'intégration synchrone est comprise comme un appel bloquant simple. Un sous-système joue le rôle de serveur et expose une API avec la méthode requise. L'autre sous-système sert de client et effectue l'appel au bon moment en attendant le résultat. Selon l'architecture du système, les côtés client et serveur peuvent se trouver soit dans la même application et le même processus, soit dans des applications différentes. Dans ce dernier cas, il est nécessaire d'appliquer une certaine implémentation de RPC et d'assurer le marshalling des paramètres et du résultat de l'appel.

Intégration au style BPM

Ce schéma d'intégration présente un ensemble assez important d'inconvénients, mais il est très largement utilisé en pratique en raison de sa simplicité. La rapidité de mise en œuvre séduit et pousse à l'utiliser encore et encore dans des conditions d'échéances serrées, en inscrivant la solution dans la dette technique. Il arrive aussi que des développeurs inexpérimentés l'appliquent sans en avoir conscience, simplement sans se douter des conséquences négatives.

Au-delà de l'augmentation de la cohésion des sous-systèmes, il existe également des problèmes moins évidents liés à la 'répartition' et à l' 'étirement' des transactions. En effet, si la logique métier apporte des modifications, il est alors impossible d'éviter les transactions, et les transactions, à leur tour, bloquent certaines ressources de l'application affectées par ces changements. Autrement dit, tant qu'un sous-système n'a pas reçu de réponse de l'autre, il ne pourra pas achever la transaction et lever les blocages. Cela augmente considérablement le risque d'apparition de divers effets :

  • perte de réactivité du système, les utilisateurs attendent longtemps des réponses à leurs demandes ;
  • le serveur cesse complètement de répondre aux requêtes des utilisateurs à cause d'un pool de threads saturé : la majorité des threads sont 'bloqués' par la ressource occupée par la transaction ;
  • des deadlocks commencent à apparaître : la probabilité de leur apparition dépend fortement de la durée des transactions, du nombre de logiques métiers impliquées dans la transaction et des blocages ;
  • des erreurs d'expiration du délai de la transaction apparaissent ;
  • le serveur 'plante' par OutOfMemory si la tâche nécessite le traitement et la modification de grands volumes de données, et la présence d'intégrations synchrones rend très difficile la fragmentation du traitement en transactions 'plus légères'.

D'un point de vue architectural, l'utilisation d'appels bloquants lors de l'intégration entraîne une perte de contrôle sur la qualité des sous-systèmes individuels : il est impossible de garantir les indicateurs de qualité d'un sous-système sans tenir compte des indicateurs de qualité des autres sous-systèmes. Si les sous-systèmes sont développés par différentes équipes, cela devient un problème majeur.

Les choses deviennent encore plus intéressantes lorsque les sous-systèmes intégrés se trouvent dans différentes applications et qu'il est nécessaire d'apporter des modifications synchrones des deux côtés. Comment garantir la transactionnalité de ces changements ?

Si les modifications sont effectuées par des transactions distinctes, il faudra garantir un traitement fiable des exceptions et des compensations, ce qui annule complètement l'avantage principal des intégrations synchrones : la simplicité.

On pense également aux transactions distribuées, mais nous ne les utilisons pas dans nos solutions : il est difficile d'assurer la fiabilité.

Le 'Saga' comme solution au problème des transactions

Avec la croissance de la popularité des microservices, la demande pour le Modèle de Saga.

Ce modèle résout parfaitement les problèmes de transactions longues mentionnés ci-dessus, et élargit également les possibilités de gestion de l'état du système du côté de la logique métier : la compensation après une transaction échouée peut ne pas ramener le système à son état initial, mais fournir une alternative pour le traitement des données. Cela permet également de ne pas répéter les étapes de traitement de données déjà terminées lors des nouvelles tentatives d'amener le processus à une 'bonne' conclusion.

Fait intéressant, dans les systèmes monolithiques, ce modèle est également pertinent lorsque l'on parle d'intégration de sous-systèmes peu liés et que l'on observe des effets négatifs causés par des transactions longues et les verrouillages de ressources qui en découlent.

En ce qui concerne nos processus métier de type BPM, implanter les 'Sagas' s'avère très facile : les étapes individuelles de la 'Saga' peuvent être définies sous forme d'activités au sein du processus métier, et l'état persistant du processus métier définit également l'état interne de la 'Saga'. Cela signifie qu'aucun mécanisme de coordination supplémentaire n'est nécessaire. Seul un courtier de messages avec prise en charge des garanties 'au moins une fois' est requis comme moyen de transport.

Mais cette solution a son propre 'prix' :

  • la logique métier devient plus complexe : il faut gérer les compensations ;
  • il faudra renoncer à la pleine cohérence, ce qui peut être particulièrement sensible pour les systèmes monolithiques ;
  • l'architecture se complique un peu, une nécessité supplémentaire apparaît pour un broker de messages ;
  • des moyens supplémentaires de surveillance et d'administration seront nécessaires (bien que dans l'ensemble cela soit même bénéfique : la qualité du service système sera améliorée).

Pour les systèmes monolithiques, la justification de l'utilisation des « Sagas » n'est pas si évidente. Pour les microservices et d'autres SOA, où il y a probablement déjà un broker, et où la pleine cohérence a été sacrifiée dès le début du projet, les avantages de l'utilisation de ce modèle peuvent largement l'emporter sur ses inconvénients, surtout en présence d'une API conviviale au niveau de la logique métier.

Encapsulation de la logique métier dans les microservices

Lorsque nous avons commencé à expérimenter avec les microservices, une question légitime s'est posée : où placer la logique métier de domaine par rapport au service assurant la persistance des données de domaine ?

En regardant l'architecture des différents BPMS, il peut sembler raisonnable de séparer la logique métier de la persistance : créer une couche de microservices indépendants de la plateforme et du domaine, qui forment un environnement et un conteneur pour l'exécution de la logique métier de domaine, tandis que la persistance des données de domaine est traitée par une couche distincte de microservices très simples et légers. Les processus métier, dans ce cas, orchestrent les services de la couche de persistance.

Intégration au style BPM

Cette approche a un très grand avantage : on peut augmenter indéfiniment la fonctionnalité de la plateforme, et seule la couche correspondante de microservices de plateforme sera affectée. Les processus métier de n'importe quel domaine peuvent immédiatement utiliser la nouvelle fonctionnalité de la plateforme dès qu'elle est mise à jour.

Une étude plus approfondie a révélé des défauts significatifs de cette approche :

  • Le service de plateforme exécutant la logique métier de plusieurs domaines présente de grands risques en tant que point unique de défaillance. Des modifications fréquentes de la logique métier augmentent le risque d'erreurs, entraînant des pannes qui se propagent à l'ensemble du système ;
  • problèmes de performance : la logique métier fonctionne avec ses données via une interface étroite et lente :
    • Les données seront à nouveau transférées et traitées à travers la pile réseau ;
    • Le service de domaine renverra souvent plus de données que ce que la logique métier nécessite pour le traitement, en raison des capacités insuffisantes de paramétrage des requêtes au niveau de l'API externe du service ;
    • Plusieurs parties indépendantes de la logique métier peuvent redemander les mêmes données pour traitement (ce problème peut être atténué en ajoutant des composants de session qui mettent en cache les données, mais cela complique davantage l'architecture et crée des problèmes de pertinence des données et d'invalidation du cache) ;
  • Problèmes de transactionnalité :
    • Les processus métiers avec un état persistant, dont le stockage est pris en charge par le service de plate-forme, se désynchroniseront avec les données de domaine, et il n'y a pas de solutions simples à ce problème ;
    • Déplacement du verrouillage des données de domaine en dehors de la transaction : si la logique métier de domaine nécessite d'apporter des modifications après avoir vérifié la validité des données actuelles, il est nécessaire d'exclure la possibilité de modifications concurrentes des données traitées. Le verrouillage externe des données peut aider à résoudre ce problème, mais cette solution comporte des risques supplémentaires et réduit la fiabilité globale du système ;
  • Complexités supplémentaires lors des mises à jour : dans certains cas, il est nécessaire de mettre à jour le service de persistance et la logique métier de manière synchronisée ou dans un ordre strict.

En fin de compte, il a fallu revenir à la source : encapsuler les données de domaine et la logique métier de domaine dans un seul microservice. Cette approche simplifie la perception du microservice en tant que composant cohérent du système et ne génère pas les problèmes énumérés ci-dessus. Cela a également un coût :

  • Une standardisation de l'API est nécessaire pour interagir avec la logique métier (en particulier pour assurer l'activité des utilisateurs dans les processus métiers) et les services API de plate-forme ; une attention plus particulière est requise lors des modifications de l'API, de la compatibilité ascendante et descendante ;
  • L'ajout de bibliothèques runtime supplémentaires est nécessaire pour assurer le bon fonctionnement de la logique métier dans chacun de ces microservices, ce qui impose de nouvelles exigences à ces bibliothèques : légèreté et un minimum de dépendances transitives ;
  • Les développeurs de la logique métier doivent surveiller les versions des bibliothèques : si un microservice n'a pas été mis à jour depuis longtemps, il est probable qu'il contienne une version obsolète des bibliothèques. Cela peut devenir un obstacle inattendu lors de l'ajout d'une nouvelle fonctionnalité et nécessiter la migration de la logique métier ancienne de ce service vers de nouvelles versions de bibliothèques, si des changements incompatibles ont eu lieu entre les versions.

Intégration au style BPM

Une couche de services de plateforme est également présente dans une telle architecture, mais cette couche ne constitue pas un conteneur pour l'exécution de la logique métier de domaine, mais plutôt son environnement, fournissant des fonctions « de plateforme » auxiliaires. Cette couche est nécessaire non seulement pour maintenir la légèreté des microservices de domaine, mais aussi pour centraliser la gestion.

Par exemple, les activités des utilisateurs dans les processus métiers génèrent des tâches. Cependant, en travaillant avec des tâches, l'utilisateur doit voir les tâches de tous les domaines dans une liste générale, ce qui signifie qu'il doit y avoir un service de plateforme correspondant à l'enregistrement des tâches, exempt de la logique métier de domaine. Préserver l'encapsulation de la logique métier dans ce contexte est assez problématique, et c'est un autre compromis de cette architecture.

Intégration des processus métiers aux yeux d'un développeur d'application

Comme mentionné précédemment, le développeur d'application doit être abstrait des spécificités techniques et d'ingénierie de la mise en œuvre de l'interaction de plusieurs applications, afin qu'il puisse compter sur une bonne productivité de développement.

Essayons de résoudre un problème d'intégration assez complexe, spécialement conçu pour cet article. Ce sera un problème « ludique » impliquant trois applications, chacune d'elles définissant un certain nom de domaine : « app1 », « app2 », « app3 ».

À l'intérieur de chaque application, des processus métiers sont lancés, qui commencent à « jouer au ballon » via un bus d'intégration. Les messages portant le nom « Ball » serviront de ballon.

Règles du jeu :

  • le premier joueur – l'initiateur. Il invite d'autres joueurs à participer au jeu, commence le jeu et peut l'arrêter à tout moment ;
  • les autres joueurs déclarent leur participation au jeu, se « rencontrent » les uns les autres et le premier joueur ;
  • en prenant le ballon, le joueur choisit un autre joueur participant et lui passe le ballon. Le total des passes est comptabilisé.
  • Chaque joueur dispose d'une « énergie » qui diminue à chaque passe faite par ce joueur. Lorsqu'il n'a plus d'énergie, le joueur se retire du jeu en annonçant son départ.
  • Si le joueur est le dernier, il annonce immédiatement son départ.
  • Lorsque tous les joueurs sont éliminés, le premier joueur annonce la fin du jeu. S'il a été éliminé plus tôt, il reste à surveiller le jeu pour le terminer.

Pour résoudre ce problème, je vais utiliser notre DSL pour les processus métier, qui permet de décrire la logique en Kotlin de manière compacte, avec un minimum de code répétitif.

Dans l'application app1, le processus métier du premier joueur (qui est aussi l'initiateur du jeu) fonctionnera :

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

// Cette classe représente l'instance du processus : elle encapsule son état interne
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
}

// C'est la déclaration du modèle de processus : créée une fois, utilisée par tous
// les instances du processus de la classe correspondante
val initialPlayerModel = processModel(name = "InitialPlayer",
                                                     version = 1) {

    // Selon les règles, le premier joueur est l'initiateur du jeu et doit être le seul
    uniqueConstraint = UniqueConstraints.singleton

    // Déclaration des activités qui composent le processus métier
    val sendNewGameSignal = signal("NewGame")
    val sendStopGameSignal = signal("StopGame")
    val startTask = humanTask("Start") {
        taskOperation {
            processCondition { players.size > 0 }
            confirmation { "${players.size} joueurs se sont connectés. Commencer?" }
        }
    }
    val stopTask = humanTask("Stop") {
        taskOperation {}
    }
    val waitPlayerJoin = signalWait("PlayerJoin") { signal ->
        players.add(PlayerInfo(
                signal.data!!,
                signal.sender.domain,
                signal.sender.processInstanceId))
        println("... joueur ${signal.data} a rejoint ...")
    }
    val waitPlayerOut = signalWait("PlayerOut") { signal ->
        players.remove(PlayerInfo(
                signal.data!!,
                signal.sender.domain,
                signal.sender.processInstanceId))
        println("... joueur ${signal.data} est sorti ...")
    }
    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
    }

    // Maintenant, construisons le graphe de processus à partir des activités déclarées
    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)
    }

    // Ajoutons des gestionnaires supplémentaires aux activités pour les journaux
    sendNewGameSignal.onExit { println("Prêts à jouer!") }
    sendStopGameSignal.onExit { println("Arrêter!") }
    sendPlayerOut.onExit { println("$playerName : Je suis sorti !") }
}

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

En plus de l'exécution de la logique métier, le code proposé est capable de fournir un modèle objet du processus métier, qui peut être visualisé sous forme de diagramme. Nous n'avons pas encore réalisé le visualiseur, donc nous avons dû consacrer un peu de temps à dessiner (ici, j'ai légèrement simplifié la notation BPMN en ce qui concerne l'utilisation des gateways, afin d'améliorer la cohérence du diagramme avec le code fourni) :

Intégration au style BPM

L'application app2 inclura le processus métier d'un autre joueur :

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}")
}

Diagramme :

Intégration au style BPM

Dans l'application app3, nous allons rendre le joueur un peu différent : au lieu de choisir le prochain joueur au hasard, il agira selon l'algorithme 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: Je suis ici !") }
    sendPlayerOut.onExit { println("$playerName: Je suis sorti !") }
}

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("Étape ${process.shotCounter + 1}: " +
            "${process.playerName} >>> ${player.name}")
}

Sinon, le comportement du joueur reste le même que précédemment, donc le diagramme ne change pas.

Nous avons maintenant besoin d'un test pour tout faire fonctionner. Je fournirai uniquement le code du test lui-même afin de ne pas surcharger l'article avec du boilerplate (en réalité, j'ai utilisé l'environnement de test créé précédemment pour tester l'intégration d'autres processus métier) :

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");
    // Maintenant, il faut attendre un peu que les joueurs se "rencontrent".
    // Attendre avec sleep est une mauvaise solution, mais c'est la plus simple.
    // Ne faites pas ça dans des tests sérieux !
    Thread.sleep(1000);
    // Démarrons le jeu en fermant l'activité utilisateur
    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;
}

Nous lançons le test et regardons le journal :

console output

Clé verrouillée prise lock://app1/process/InitialPlayer
Jouons !
Clé verrouillée retirée lock://app1/process/InitialPlayer
Joueur2 : Je suis là !
Joueur3 : Je suis là !
Joueur4 : Je suis là !
Joueur5 : Je suis là !
... rejoindre le joueur Joueur2 ...
... rejoindre le joueur Joueur4 ...
... rejoindre le joueur Joueur3 ...
... rejoindre le joueur Joueur5 ...
Étape 1 : Joueur1 >>> Joueur3
Étape 2 : Joueur3 >>> Joueur5
Étape 3 : Joueur5 >>> Joueur3
Étape 4 : Joueur3 >>> Joueur4
Étape 5 : Joueur4 >>> Joueur3
Étape 6 : Joueur3 >>> Joueur4
Étape 7 : Joueur4 >>> Joueur5
Étape 8 : Joueur5 >>> Joueur2
Étape 9 : Joueur2 >>> Joueur5
Étape 10 : Joueur5 >>> Joueur4
Étape 11 : Joueur4 >>> Joueur2
Étape 12 : Joueur2 >>> Joueur4
Étape 13 : Joueur4 >>> Joueur1
Étape 14 : Joueur1 >>> Joueur4
Étape 15 : Joueur4 >>> Joueur3
Étape 16 : Joueur3 >>> Joueur1
Étape 17 : Joueur1 >>> Joueur2
Étape 18 : Joueur2 >>> Joueur3
Étape 19 : Joueur3 >>> Joueur1
Étape 20 : Joueur1 >>> Joueur5
Étape 21 : Joueur5 >>> Joueur1
Étape 22 : Joueur1 >>> Joueur2
Étape 23 : Joueur2 >>> Joueur4
Étape 24 : Joueur4 >>> Joueur5
Étape 25 : Joueur5 >>> Joueur3
Étape 26 : Joueur3 >>> Joueur4
Étape 27 : Joueur4 >>> Joueur2
Étape 28 : Joueur2 >>> Joueur5
Étape 29 : Joueur5 >>> Joueur2
Étape 30 : Joueur2 >>> Joueur1
Étape 31 : Joueur1 >>> Joueur3
Étape 32 : Joueur3 >>> Joueur4
Étape 33 : Joueur4 >>> Joueur1
Étape 34 : Joueur1 >>> Joueur3
Étape 35 : Joueur3 >>> Joueur4
Étape 36 : Joueur4 >>> Joueur3
Étape 37 : Joueur3 >>> Joueur2
Étape 38 : Joueur2 >>> Joueur5
Étape 39 : Joueur5 >>> Joueur4
Étape 40 : Joueur4 >>> Joueur5
Étape 41 : Joueur5 >>> Joueur1
Étape 42 : Joueur1 >>> Joueur5
Étape 43 : Joueur5 >>> Joueur3
Étape 44 : Joueur3 >>> Joueur5
Étape 45 : Joueur5 >>> Joueur2
Étape 46 : Joueur2 >>> Joueur3
Étape 47 : Joueur3 >>> Joueur2
Étape 48 : Joueur2 >>> Joueur5
Étape 49 : Joueur5 >>> Joueur4
Étape 50 : Joueur4 >>> Joueur2
Étape 51 : Joueur2 >>> Joueur5
Étape 52 : Joueur5 >>> Joueur1
Étape 53 : Joueur1 >>> Joueur5
Étape 54 : Joueur5 >>> Joueur3
Étape 55 : Joueur3 >>> Joueur5
Étape 56 : Joueur5 >>> Joueur2
Étape 57 : Joueur2 >>> Joueur1
Étape 58 : Joueur1 >>> Joueur4
Étape 59 : Joueur4 >>> Joueur1
Étape 60 : Joueur1 >>> Joueur4
Étape 61 : Joueur4 >>> Joueur3
Étape 62 : Joueur3 >>> Joueur2
Étape 63 : Joueur2 >>> Joueur5
Étape 64 : Joueur5 >>> Joueur4
Étape 65 : Joueur4 >>> Joueur5
Étape 66 : Joueur5 >>> Joueur1
Étape 67 : Joueur1 >>> Joueur5
Étape 68 : Joueur5 >>> Joueur3
Étape 69 : Joueur3 >>> Joueur4
Étape 70 : Joueur4 >>> Joueur2
Étape 71 : Joueur2 >>> Joueur5
Étape 72 : Joueur5 >>> Joueur2
Étape 73 : Joueur2 >>> Joueur1
Étape 74 : Joueur1 >>> Joueur4
Étape 75 : Joueur4 >>> Joueur1
Étape 76 : Joueur1 >>> Joueur2
Étape 77 : Joueur2 >>> Joueur5
Étape 78 : Joueur5 >>> Joueur4
Étape 79 : Joueur4 >>> Joueur3
Étape 80 : Joueur3 >>> Joueur1
Étape 81 : Joueur1 >>> Joueur5
Étape 82 : Joueur5 >>> Joueur1
Étape 83 : Joueur1 >>> Joueur4
Étape 84 : Joueur4 >>> Joueur5
Étape 85 : Joueur5 >>> Joueur3
Étape 86 : Joueur3 >>> Joueur5
Étape 87 : Joueur5 >>> Joueur2
Étape 88 : Joueur2 >>> Joueur3
Joueur2 : Je suis sorti !
Étape 89 : Joueur3 >>> Joueur4
... le joueur Joueur2 est sorti ...
Étape 90 : Joueur4 >>> Joueur1
Étape 91 : Joueur1 >>> Joueur3
Étape 92 : Joueur3 >>> Joueur1
Étape 93 : Joueur1 >>> Joueur4
Étape 94 : Joueur4 >>> Joueur3
Étape 95 : Joueur3 >>> Joueur5
Étape 96 : Joueur5 >>> Joueur1
Étape 97 : Joueur1 >>> Joueur5
Étape 98 : Joueur5 >>> Joueur3
Étape 99 : Joueur3 >>> Joueur5
Étape 100 : Joueur5 >>> Joueur4
Étape 101 : Joueur4 >>> Joueur5
Joueur4 : Je suis sorti !
... le joueur Joueur4 est sorti ...
Étape 102 : Joueur5 >>> Joueur1
Étape 103 : Joueur1 >>> Joueur3
Étape 104 : Joueur3 >>> Joueur1
Étape 105 : Joueur1 >>> Joueur3
Étape 106 : Joueur3 >>> Joueur5
Étape 107 : Joueur5 >>> Joueur3
Étape 108 : Joueur3 >>> Joueur1
Étape 109 : Joueur1 >>> Joueur3
Étape 110 : Joueur3 >>> Joueur5
Étape 111 : Joueur5 >>> Joueur1
Étape 112 : Joueur1 >>> Joueur3
Étape 113 : Joueur3 >>> Joueur5
Étape 114 : Joueur5 >>> Joueur3
Étape 115 : Joueur3 >>> Joueur1
Étape 116 : Joueur1 >>> Joueur3
Étape 117 : Joueur3 >>> Joueur5
Étape 118 : Joueur5 >>> Joueur1
Étape 119 : Joueur1 >>> Joueur3
Étape 120 : Joueur3 >>> Joueur5
Étape 121 : Joueur5 >>> Joueur3
Joueur5 : Je suis sorti !
... le joueur Joueur5 est sorti ...
Étape 122 : Joueur3 >>> Joueur5
Étape 123 : Joueur5 >>> Joueur1
Joueur5 : Je suis sorti !
Étape 124 : Joueur1 >>> Joueur3
... le joueur Joueur5 est sorti ...
Étape 125 : Joueur3 >>> Joueur1
Étape 126 : Joueur1 >>> Joueur3
Joueur1 : Je suis sorti !
... le joueur Joueur1 est sorti ...
Étape 127 : Joueur3 >>> Joueur3
Joueur3 : Je suis sorti !
Étape 128 : Joueur3 >>> Joueur3
... le joueur Joueur3 est sorti ...
Joueur3 : Je suis sorti !
Stop !
Étape 129 : Joueur3 >>> Joueur3
Joueur3 : Je suis sorti !

De tout cela, on peut tirer plusieurs conclusions importantes :

  • avec les outils nécessaires, les développeurs d'applications peuvent créer des interactions intégrées entre les applications sans se distancier de la logique métier ;
  • la complexité (complexity) de la tâche d'intégration, nécessitant des compétences en ingénierie, peut être dissimulée au sein du framework, si cela est initialement intégré dans l'architecture du framework. En revanche, la difficulté de la tâche (difficulty) ne peut pas être cachée, donc la solution d'une tâche difficile dans le code apparaîtra en conséquence ;
  • lors de l'élaboration de la logique d'intégration, il est essentiel de prendre en compte la consistance éventuelle et l'absence de linéarisation des changements d'état de tous les participants à l'intégration. Cela oblige à complexifier la logique afin de la rendre insensible à l'ordre de survenance des événements externes. Dans notre exemple, un joueur doit participer au jeu seulement après avoir annoncé sa sortie : les autres joueurs continueront à lui passer le ballon tant que l'information au sujet de sa sortie n'aura pas été transmise et traitée par tous les participants. Cette logique ne découle pas des règles du jeu et constitue une solution de compromis dans le cadre de l'architecture choisie.

Nous allons maintenant aborder les différentes subtilités de notre solution, les compromis et d'autres aspects.

Tous les messages dans une seule file d'attente

Toutes les applications intégrées fonctionnent avec un seul bus d'intégration, qui est présenté sous la forme d'un courtier externe, d'une seule file d'attente BPMQueue – pour les messages et d'un seul topic BPMTopic – pour les signaux (événements). Faire passer tous les messages par une seule file d'attente est en soi un compromis. Au niveau de la logique métier, il est désormais possible d'introduire autant de nouveaux types de messages que souhaité, sans modifier la structure du système. C'est un simplification considérable, mais elle comporte certains risques, qui, dans le contexte de nos tâches types, nous ont semblé peu significatifs.

Intégration au style BPM

Cependant, il y a une nuance ici : chaque application filtre ses propres messages dans la file d'attente dès leur arrivée, en fonction du nom de son domaine. De plus, le domaine peut également être spécifié dans les signaux, si l'on souhaite limiter le "champ de visibilité" du signal à une seule application. Cela devrait augmenter la capacité de la bus, mais la logique métier doit maintenant traiter les noms de domaine : pour l'adressage des messages – c'est impératif, pour les signaux – c'est souhaitable.

Assurer la fiabilité de la bus d'intégration

La fiabilité se compose de plusieurs éléments :

  • le courtier de messages sélectionné est un composant critique de l'architecture et un point de défaillance unique : il doit être suffisamment résistant aux pannes. Il convient d'utiliser uniquement des implémentations éprouvées, dotées d'un bon support et d'une large communauté ;
  • il est nécessaire de garantir une haute disponibilité du courtier de messages, pour cela il doit être physiquement séparé des applications intégrées (il est beaucoup plus difficile et coûteux d'assurer une haute disponibilité des applications avec une logique métier appliquée) ;
  • le courtier doit garantir des livraisons "au moins une fois". C'est une exigence indispensable pour le bon fonctionnement de la bus d'intégration. Il n'est pas nécessaire d'avoir des garanties de niveau "exactement une fois" : les processus métier ne sont généralement pas sensibles à la réception répétée de messages ou d'événements, et dans des tâches particulières où cela est important, il est plus simple d'ajouter une vérification supplémentaire dans la logique métier que d'utiliser en permanence des garanties suffisamment "chères" ;
  • l'envoi de messages et de signaux doit être impliqué dans une transaction globale avec la modification de l'état des processus métier et des données de domaine. L'option préférée serait d'utiliser le modèle Transactional Outbox, mais cela nécessitera une table supplémentaire dans la base et un retransmetteur. Dans les applications JEE, ce point peut être simplifié par l'utilisation d'un gestionnaire JTA local, mais la connexion au courtier sélectionné doit savoir fonctionner en mode XA;
  • les gestionnaires de messages et d'événements entrants doivent également fonctionner avec la transaction de modification de l'état du processus métier : si une telle transaction est annulée, la réception du message doit également être annulée ;
  • les messages qui n'ont pas pu être livrés en raison d'erreurs doivent être stockés dans un dépôt séparé. DLQ (Dead Letter Queue). Nous avons créé un microservice plateforme dédié qui conserve ces messages dans son stockage, les indexe par attributs (pour un regroupement et une recherche rapides), et expose une API pour consulter, renvoyer à destination, et supprimer les messages. Les administrateurs système peuvent interagir avec ce service via leur interface web;
  • dans les paramètres du courtier, il est nécessaire d'ajuster le nombre de tentatives de livraison et les délais entre les livraisons pour réduire la probabilité que des messages se retrouvent dans la DLQ (calculer les paramètres optimaux est pratiquement impossible, mais il est possible d'agir empiriquement et de les ajuster en cours d'exploitation);
  • le stockage de la DLQ doit être surveillé en continu, et le système de surveillance doit alerter les administrateurs système afin qu'ils puissent réagir le plus rapidement possible en cas de messages non livrés. Cela permettra de réduire la « zone d'impact » d'une défaillance ou d'une erreur de logique métier;
  • le bus d'intégration doit être indifférent à l'absence temporaire d'applications : les abonnements au topic doivent être durables, et le nom de domaine de l'application doit être unique, de sorte qu'aucune autre personne ne tente de traiter ses messages dans la file d'attente pendant l'absence de l'application.

Assurer la sécurité des flux de la logique métier

Un même exemplaire de processus métier peut recevoir plusieurs messages et événements simultanément, dont le traitement s'exécutera en parallèle. En même temps, tout doit rester simple et thread-safe pour le développeur applicatif.

La logique métier du processus traite chaque événement externe affectant ce processus métier individuellement. Ces événements peuvent être :

  • le lancement d'un exemplaire de processus métier;
  • l'action d'un utilisateur liée à une activité au sein du processus métier;
  • la réception d'un message ou d'un signal auquel l'exemplaire de processus métier est abonné;
  • le déclenchement d'un minuteur, installé par l'exemplaire de processus métier;
  • l'intervention de contrôle via l'API (par exemple, l'interruption d'urgence du processus).

Chaque événement de ce type peut modifier l'état d'une instance de processus métier : certaines activités peuvent se terminer et d'autres commencer, et les valeurs des propriétés persistantes peuvent changer. La clôture de toute activité peut entraîner l'activation d'une ou plusieurs activités suivantes. Celles-ci peuvent, à leur tour, s'arrêter en attendant d'autres événements ou, si elles n'ont besoin d'aucune donnée supplémentaire, se terminer dans la même transaction. Avant la clôture de la transaction, le nouvel état du processus métier est enregistré dans la base de données, où il attendra la survenance du prochain événement externe.

Les données persistantes du processus métier, enregistrées dans une base de données relationnelle, constituent un point de synchronisation très pratique pour le traitement, si l'on utilise SELECT FOR UPDATE. Si une transaction a réussi à obtenir l'état du processus métier de la base pour le modifier, aucune autre transaction ne pourra simultanément obtenir cet même état pour un autre changement, et après l'achèvement de la première transaction, la deuxième recevra nécessairement l'état déjà modifié.

En utilisant des verrous pessimistes du côté du SGBD, nous satisfaisons toutes les exigences nécessaires ACID, tout en maintenant la possibilité de faire évoluer l'application avec la logique métier en augmentant le nombre d'instances en cours d'exécution.

Cependant, les verrous pessimistes nous exposent à des deadlocks, c'est pourquoi il est tout de même conseillé de limiter SELECT FOR UPDATE à un certain délai raisonnable en cas de problèmes de deadlock dans des cas flagrants de logique métier.

Un autre problème est la synchronisation du démarrage du processus métier. Tant qu'il n'y a pas d'instance de processus métier, il n'y a pas d'état associé dans la base, donc la méthode décrite ne conviendra pas. Si l'on doit garantir l'unicité de l'instance de processus métier dans un certain périmètre, un objet de synchronisation sera alors nécessaire, associé à la classe du processus et au périmètre correspondant. Pour résoudre ce problème, nous utilisons un autre mécanisme de verrouillage, permettant de verrouiller une ressource arbitraire, spécifiée par une clé au format URI, via un service externe.

Dans nos exemples, le processus métier InitialPlayer contient une déclaration

uniqueConstraint = UniqueConstraints.singleton

C'est pourquoi le journal contient des messages concernant la prise et la libération du verrou correspondant à la clé. Pour d'autres processus métier, il n'y a pas de tels messages : uniqueConstraint n'est pas défini.

Problèmes des processus métiers avec un état persistant

Parfois, la présence d'un état persistant n'aide pas seulement, mais complique également le développement.
Les problèmes commencent lorsque des modifications doivent être apportées à la logique métier et/ou au modèle de processus métier. Toute modification de ce type ne s'avère pas toujours compatible avec l'ancien état des processus métier. S'il y a de nombreux exemplaires « vivants » dans la base de données, alors apporter des modifications incompatibles peut causer de nombreux désagréments que nous avons souvent rencontrés lors de l'utilisation de jBPM.

En fonction de la profondeur des changements, deux approches sont possibles :

  1. créer un nouveau type de processus métier pour éviter d'apporter des modifications incompatibles à l'ancien et l'utiliser à la place de l'ancien lors du lancement de nouveaux exemplaires. Les anciens exemplaires continueront à fonctionner « comme avant » ;
  2. migrer l'état persistant des processus métiers lors de la mise à jour de la logique métier.

La première approche est plus simple, mais présente ses propres limitations et inconvénients, par exemple :

  • duplication de la logique métier dans de nombreux modèles de processus métiers, augmentation du volume de logique métier ;
  • un passage instantané à la nouvelle logique métier est souvent nécessaire (pour les tâches d'intégration – presque toujours) ;
  • le développeur ne sait pas à quel moment il peut supprimer les modèles obsolètes.

Dans la pratique, nous utilisons les deux approches, mais nous avons pris un certain nombre de décisions pour nous simplifier la vie :

  • dans la base de données, l'état persistant du processus métier est enregistré sous une forme facilement lisible et facilement traitable : sous forme de chaîne au format JSON. Cela permet d'effectuer des migrations tant à l'intérieur de l'application qu'à l'extérieur. En dernier recours, on peut aussi corriger manuellement (particulièrement utile lors du développement en phase de débogage) ;
  • la logique métier d'intégration n'utilise pas les noms des processus métiers, afin de pouvoir remplacer à tout moment l'implémentation d'un des processus participants par une nouvelle, avec un nouveau nom (par exemple, « InitialPlayerV2 »). La liaison se fait par les noms des messages et des signaux ;
  • Le modèle de processus possède un numéro de version que nous augmentons lorsque nous apportons des modifications incompatibles à ce modèle, et ce numéro est conservé avec l'état de l'instance de processus.
  • L'état persistant du processus est d'abord lu de la base de données dans un modèle objet pratique, avec lequel la procédure de migration peut travailler si le numéro de version du modèle a changé.
  • La procédure de migration est placée à côté de la logique métier et est appelée « paresseusement » pour chaque instance de processus métier au moment de sa restauration depuis la base de données.
  • Si l'état de toutes les instances de processus doit être migré rapidement et de manière synchrone, des solutions de migration de base de données plus classiques sont appliquées, mais ici il faut travailler avec JSON.

Avez-vous besoin d'un autre framework pour les processus métier ?

Les solutions décrites dans cet article nous ont permis de simplifier considérablement notre travail, d'élargir le éventail des questions traitées au niveau du développement appliqué et de rendre les idées de séparation de la logique métier en microservices plus attrayantes. Pour cela, beaucoup de travail a été accompli, un framework très « léger » pour les processus métier a été créé, ainsi que des composants utilitaires pour résoudre les problèmes mentionnés dans le contexte d'un large éventail de tâches appliquées. Nous avons le désir de partager ces résultats, de publier le développement de composants communs en accès libre sous licence gratuite. Cela exigera des efforts et du temps. Comprendre la demande pour de telles solutions pourrait être un stimulant supplémentaire pour nous. L'article proposé accorde très peu d'attention aux possibilités du framework lui-même, mais certaines d'entre elles sont évidentes à partir des exemples présentés. Si nous publions notre framework, un article séparé lui sera dédié. En attendant, nous vous serions reconnaissants de laisser un petit retour en répondant à la question :

Seuls les utilisateurs enregistrés peuvent participer au sondage. Connectez-vous, s'il vous plaît.

Avez-vous besoin d'un autre framework pour les processus métier ?

  • 18,8%Oui, nous cherchons quelque chose de similaire depuis longtemps.

  • 12,5%Je serais intéressé d'en savoir plus sur votre réalisation, ça pourrait être utile.

  • 6,2%Nous utilisons l'un des frameworks existants, mais envisageons un remplacement.

  • 18,8%Nous utilisons l'un des frameworks existants, tout nous convient.

  • 18,8%Nous nous débrouillons sans framework.

  • 25,0%Nous développons le nôtre.

16 utilisateurs ont voté. 7 utilisateurs se sont abstenus.

Source : habr.com

Acheter un hébergement fiable pour les sites avec protection DDoS, serveurs VPS VDS 🔥 Acheter un hébergement fiable pour les sites avec protection DDoS, serveurs VPS VDS | ProHoster