Integração de estilo BPM

Integração de estilo BPM

Olá, Habr!

Nossa empresa é especializada no desenvolvimento de soluções de software de classe ERP, cuja maior parte é ocupada por sistemas transacionais com grande quantidade de lógica de negócios e fluxo de documentos à la EDMS. As versões atuais de nossos produtos são baseadas em tecnologias JavaEE, mas também estamos experimentando ativamente microsserviços. Uma das áreas mais problemáticas de tais soluções é a integração de vários subsistemas pertencentes a domínios adjacentes. Os problemas de integração sempre nos deram uma enorme dor de cabeça, independentemente dos estilos de arquitetura, pilhas de tecnologia e estruturas que utilizamos, mas recentemente houve progresso na resolução de tais problemas.

No artigo que trago à sua atenção falarei sobre a experiência e pesquisa arquitetônica que a NPO “Krista” possui na área designada. Também veremos um exemplo de solução simples para um problema de integração do ponto de vista de um desenvolvedor de aplicativos e descobriremos o que está oculto por trás dessa simplicidade.

Isenção de responsabilidade

As soluções arquitetônicas e técnicas descritas no artigo são propostas por mim com base na experiência pessoal no contexto de tarefas específicas. Estas soluções não pretendem ser universais e podem não ser ideais sob outras condições de utilização.

O que o BPM tem a ver com isso?

Para responder a esta pergunta, precisamos nos aprofundar um pouco mais nas especificidades dos problemas aplicados às nossas soluções. A parte principal da lógica de negócios em nosso sistema transacional típico é inserir dados no banco de dados por meio de interfaces de usuário, verificação manual e automatizada desses dados, realizá-los através de algum fluxo de trabalho, publicá-los em outro sistema/banco de dados analítico/arquivo, gerar relatórios . Assim, a principal função do sistema para os clientes é a automação de seus processos internos de negócios.

Por conveniência, usamos o termo “documento” em comunicação como uma abstração de um conjunto de dados unidos por uma chave comum à qual um determinado fluxo de trabalho pode ser “vinculado”.
Mas e a lógica de integração? Afinal, a tarefa de integração é gerada pela arquitetura do sistema, que é “cortada” em partes NÃO a pedido do cliente, mas sob a influência de fatores completamente diferentes:

  • sujeito à lei de Conway;
  • como resultado da reutilização de subsistemas previamente desenvolvidos para outros produtos;
  • a critério do arquiteto, com base em requisitos não funcionais.

Há uma grande tentação de separar a lógica de integração da lógica de negócios do fluxo de trabalho principal, para não poluir a lógica de negócios com artefatos de integração e poupar o desenvolvedor de aplicativos da necessidade de se aprofundar nos recursos do cenário arquitetônico do sistema. Esta abordagem tem uma série de vantagens, mas a prática mostra a sua ineficácia:

  • a resolução de problemas de integração geralmente recorre às opções mais simples na forma de chamadas síncronas devido aos pontos de extensão limitados na implementação do fluxo de trabalho principal (as desvantagens da integração síncrona são discutidas abaixo);
  • os artefatos de integração ainda penetram na lógica central do negócio quando é necessário feedback de outro subsistema;
  • o desenvolvedor do aplicativo ignora a integração e pode facilmente quebrá-la alterando o fluxo de trabalho;
  • o sistema deixa de ser um todo único do ponto de vista do usuário, “costuras” entre os subsistemas tornam-se perceptíveis e aparecem operações redundantes do usuário, iniciando a transferência de dados de um subsistema para outro.

Outra abordagem é considerar as interações de integração como parte integrante da lógica e do fluxo de trabalho principais do negócio. Para evitar que as qualificações dos desenvolvedores de aplicativos disparem, a criação de novas interações de integração deve ser fácil e sem esforço, com oportunidades mínimas de escolha de uma solução. Isso é mais difícil do que parece: a ferramenta deve ser poderosa o suficiente para fornecer ao usuário a variedade necessária de opções para seu uso, sem permitir que ele “dê um tiro no próprio pé”. Há muitas perguntas que um engenheiro deve responder no contexto de tarefas de integração, mas nas quais um desenvolvedor de aplicações não deve pensar em seu trabalho diário: limites de transação, consistência, atomicidade, segurança, escalonamento, distribuição de carga e recursos, roteamento, marshaling, contextos de distribuição e comutação, etc. É necessário oferecer aos desenvolvedores de aplicativos modelos de soluções bastante simples, nos quais as respostas para todas essas perguntas já estejam ocultas. Estes modelos devem ser bastante seguros: a lógica de negócio muda com muita frequência, o que aumenta o risco de introdução de erros, o custo dos erros deve permanecer num nível bastante baixo.

Mas o que o BPM tem a ver com isso? Existem muitas opções para implementar o fluxo de trabalho...
Na verdade, outra implementação de processos de negócios é muito popular em nossas soluções - por meio da definição declarativa de um diagrama de transição de estado e da conexão de manipuladores com a lógica de negócios para transições. Neste caso, o estado que determina a posição atual do “documento” no processo de negócio é um atributo do próprio “documento”.

Integração de estilo BPM
Esta é a aparência do processo no início de um projeto

A popularidade desta implementação se deve à relativa simplicidade e velocidade de criação de processos de negócios lineares. Entretanto, à medida que os sistemas de software se tornam cada vez mais complexos, a parte automatizada do processo de negócios cresce e se torna mais complexa. Há necessidade de decomposição, reaproveitamento de partes de processos, bem como ramificação de processos para que cada ramificação seja executada em paralelo. Sob tais condições, a ferramenta torna-se inconveniente e o diagrama de transição de estado perde seu conteúdo informativo (as interações de integração não são refletidas no diagrama).

Integração de estilo BPM
É assim que fica o processo após várias iterações de esclarecimento de requisitos.

A saída para esta situação foi a integração do motor jBPM em alguns produtos com os processos de negócios mais complexos. No curto prazo, esta solução teve algum sucesso: tornou-se possível implementar processos de negócio complexos mantendo um diagrama bastante informativo e relevante na notação BPMN2.

Integração de estilo BPM
Uma pequena parte de um processo de negócios complexo

No longo prazo, a solução não correspondeu às expectativas: a alta intensidade de trabalho na criação de processos de negócios por meio de ferramentas visuais não permitiu atingir indicadores de produtividade aceitáveis, e a própria ferramenta tornou-se uma das mais odiadas pelos desenvolvedores. Também houve reclamações sobre a estrutura interna do motor, o que levou ao aparecimento de muitos “remendos” e “muletas”.

O principal aspecto positivo do uso do jBPM foi a consciência dos benefícios e malefícios de ter o próprio estado persistente de uma instância de processo de negócios. Vimos também a possibilidade de utilizar uma abordagem de processo para implementar protocolos de integração complexos entre diferentes aplicações utilizando interações assíncronas através de sinais e mensagens. A presença de um estado persistente desempenha um papel crucial nisso.

Com base no exposto, podemos concluir: A abordagem de processos no estilo BPM nos permite resolver uma ampla gama de tarefas para automatizar processos de negócios cada vez mais complexos, encaixar harmoniosamente as atividades de integração nesses processos e manter a capacidade de exibir visualmente o processo implementado em uma notação adequada.

Desvantagens das chamadas síncronas como padrão de integração

A integração síncrona refere-se à chamada de bloqueio mais simples. Um subsistema atua como lado do servidor e expõe a API com o método necessário. Outro subsistema atua como lado do cliente e na hora certa faz uma ligação e aguarda o resultado. Dependendo da arquitetura do sistema, os lados cliente e servidor podem estar localizados no mesmo aplicativo e processo ou em aplicativos diferentes. No segundo caso, você precisa aplicar alguma implementação de RPC e fornecer empacotamento dos parâmetros e do resultado da chamada.

Integração de estilo BPM

Este padrão de integração tem um conjunto bastante grande de desvantagens, mas é amplamente utilizado na prática devido à sua simplicidade. A rapidez de implementação cativa e obriga a utilizá-la continuamente face a prazos prementes, registando a solução como dívida técnica. Mas também acontece que desenvolvedores inexperientes o utilizam inconscientemente, simplesmente sem perceber as consequências negativas.

Além do aumento mais óbvio na conectividade dos subsistemas, há também problemas menos óbvios com o “crescimento” e o “alongamento” das transações. Na verdade, se a lógica de negócios fizer algumas alterações, as transações não poderão ser evitadas e as transações, por sua vez, bloquearão determinados recursos do aplicativo afetados por essas alterações. Ou seja, até que um subsistema aguarde uma resposta do outro, ele não conseguirá concluir a transação e remover os bloqueios. Isto aumenta significativamente o risco de uma variedade de efeitos:

  • Perde-se a capacidade de resposta do sistema, os usuários aguardam muito tempo pelas respostas às solicitações;
  • o servidor geralmente para de responder às solicitações do usuário devido a um pool de threads superlotado: a maioria dos threads está bloqueada em um recurso ocupado por uma transação;
  • Os impasses começam a aparecer: a probabilidade de sua ocorrência depende fortemente da duração das transações, da quantidade de lógica de negócios e dos bloqueios envolvidos na transação;
  • aparecem erros de tempo limite da transação;
  • o servidor “falha” com OutOfMemory se a tarefa requer processamento e alteração de grandes quantidades de dados, e a presença de integrações síncronas torna muito difícil dividir o processamento em transações “mais leves”.

Do ponto de vista arquitetônico, o uso de chamadas de bloqueio durante a integração leva a uma perda de controle sobre a qualidade dos subsistemas individuais: é impossível garantir os indicadores de qualidade alvo de um subsistema isoladamente dos indicadores de qualidade de outro subsistema. Se os subsistemas forem desenvolvidos por equipes diferentes, isso será um grande problema.

As coisas ficam ainda mais interessantes se os subsistemas que estão sendo integrados estiverem em aplicações diferentes e você precisar fazer alterações síncronas em ambos os lados. Como garantir a transacionalidade dessas mudanças?

Se as alterações forem feitas em transações separadas, você precisará fornecer tratamento e compensação de exceções confiáveis, e isso elimina completamente o principal benefício das integrações síncronas: a simplicidade.

As transações distribuídas também vêm à mente, mas não as utilizamos em nossas soluções: é difícil garantir confiabilidade.

"Saga" como solução para o problema da transação

Com a crescente popularidade dos microsserviços, a demanda por Padrão de Saga.

Esse padrão resolve perfeitamente os problemas de transações longas mencionados acima e também expande as capacidades de gerenciamento do estado do sistema do lado da lógica de negócios: a compensação após uma transação malsucedida pode não reverter o sistema ao seu estado original, mas fornecer uma rota alternativa de processamento de dados. Isso também permite evitar a repetição de etapas de processamento de dados concluídas com sucesso ao tentar levar o processo a um final “bom”.

Curiosamente, em sistemas monolíticos este padrão também é relevante quando se trata da integração de subsistemas fracamente acoplados e são observados efeitos negativos causados ​​por transações de longa duração e bloqueios de recursos correspondentes.

Em relação aos nossos processos de negócios no estilo BPM, é muito fácil implementar “Sagas”: etapas individuais da “Saga” podem ser especificadas como atividades dentro do processo de negócios, e o estado persistente do processo de negócios também pode ser especificado. determina o estado interno da “Saga”. Ou seja, não necessitamos de nenhum mecanismo de coordenação adicional. Tudo o que você precisa é de um corretor de mensagens que ofereça suporte a garantias de “pelo menos uma vez” como meio de transporte.

Mas esta solução também tem o seu “preço”:

  • a lógica empresarial torna-se mais complexa: a compensação precisa de ser resolvida;
  • será necessário abandonar a consistência total, que pode ser especialmente sensível para sistemas monolíticos;
  • A arquitetura se torna um pouco mais complicada e surge uma necessidade adicional de um intermediário de mensagens;
  • serão necessárias ferramentas adicionais de monitorização e administração (embora em geral isto seja bom: a qualidade do serviço do sistema aumentará).

Para sistemas monolíticos, a justificativa para o uso de "Sag" não é tão óbvia. Para microsserviços e outros SOA, onde provavelmente já existe um corretor e a consistência total é sacrificada no início do projeto, os benefícios de usar esse padrão podem superar significativamente as desvantagens, especialmente se houver uma API conveniente na lógica de negócios nível.

Encapsulando lógica de negócios em microsserviços

Quando começamos a experimentar microsserviços, surgiu uma questão razoável: onde colocar a lógica de negócio do domínio em relação ao serviço que garante a persistência dos dados do domínio?

Ao observar a arquitetura de vários BPMSs, pode parecer razoável separar a lógica de negócios da persistência: criar uma camada de microsserviços independentes de plataforma e domínio que formem um ambiente e um contêiner para executar a lógica de negócios do domínio e projetar a persistência dos dados do domínio como uma camada separada de microsserviços muito simples e leves. Os processos de negócios, neste caso, realizam a orquestração dos serviços da camada de persistência.

Integração de estilo BPM

Essa abordagem tem uma vantagem muito grande: você pode aumentar a funcionalidade da plataforma tanto quanto quiser, e apenas a camada correspondente de microsserviços da plataforma ficará “gorda” com isso. Os processos de negócios de qualquer domínio podem usar imediatamente as novas funcionalidades da plataforma assim que ela for atualizada.

Um estudo mais detalhado revelou desvantagens significativas desta abordagem:

  • um serviço de plataforma que executa a lógica de negócios de muitos domínios ao mesmo tempo acarreta grandes riscos como um ponto único de falha. Mudanças frequentes na lógica de negócios aumentam o risco de erros que levam a falhas em todo o sistema;
  • problemas de desempenho: a lógica de negócios trabalha com seus dados por meio de uma interface estreita e lenta:
    • os dados serão novamente organizados e bombeados pela pilha da rede;
    • um serviço de domínio geralmente fornece mais dados do que o necessário para o processamento da lógica de negócios devido a recursos insuficientes para parametrizar solicitações no nível da API externa do serviço;
    • várias partes independentes da lógica de negócios podem solicitar repetidamente os mesmos dados para processamento (esse problema pode ser mitigado adicionando componentes de sessão que armazenam dados em cache, mas isso complica ainda mais a arquitetura e cria problemas de relevância de dados e invalidação de cache);
  • problemas de transação:
    • processos de negócios com estado persistente, armazenados por um serviço de plataforma, são inconsistentes com os dados do domínio e não há maneiras fáceis de resolver esse problema;
    • colocar o bloqueio de dados do domínio fora da transação: se a lógica de negócios do domínio precisar fazer alterações após primeiro verificar a exatidão dos dados atuais, é necessário excluir a possibilidade de uma alteração competitiva nos dados processados. O bloqueio externo de dados pode ajudar a resolver o problema, mas tal solução acarreta riscos adicionais e reduz a confiabilidade geral do sistema;
  • dificuldades adicionais na atualização: em alguns casos, o serviço de persistência e a lógica de negócios precisam ser atualizados de forma síncrona ou em sequência estrita.

No final das contas, tivemos que voltar ao básico: encapsular os dados do domínio e a lógica de negócios do domínio em um microsserviço. Esta abordagem simplifica a percepção de um microsserviço como um componente integrante do sistema e não dá origem aos problemas acima. Isso também não é dado de graça:

  • A padronização de API é necessária para interação com a lógica de negócios (em particular, para fornecer atividades de usuários como parte de processos de negócios) e serviços de plataforma de API; requer atenção mais cuidadosa às alterações da API e compatibilidade com versões anteriores e futuras;
  • é necessário adicionar bibliotecas de tempo de execução adicionais para garantir o funcionamento da lógica de negócios como parte de cada microsserviço, e isso dá origem a novos requisitos para tais bibliotecas: leveza e um mínimo de dependências transitivas;
  • os desenvolvedores de lógica de negócios precisam monitorar as versões das bibliotecas: se um microsserviço não for finalizado há muito tempo, provavelmente conterá uma versão desatualizada das bibliotecas. Isto pode ser um obstáculo inesperado para adicionar um novo recurso e pode exigir a migração da antiga lógica de negócios de tal serviço para novas versões de bibliotecas se houver alterações incompatíveis entre as versões.

Integração de estilo BPM

Uma camada de serviços de plataforma também está presente em tal arquitetura, mas esta camada não forma mais um contêiner para executar a lógica de negócios do domínio, mas apenas seu ambiente, fornecendo funções auxiliares de “plataforma”. Essa camada é necessária não apenas para manter a natureza leve dos microsserviços de domínio, mas também para centralizar o gerenciamento.

Por exemplo, as atividades dos usuários nos processos de negócios geram tarefas. No entanto, ao trabalhar com tarefas, o usuário deve ver as tarefas de todos os domínios na lista geral, o que significa que deve haver um serviço de registro de tarefas de plataforma correspondente, limpo da lógica de negócios do domínio. Manter o encapsulamento da lógica de negócios nesse contexto é bastante problemático e este é outro compromisso desta arquitetura.

Integração de processos de negócios através dos olhos de um desenvolvedor de aplicativos

Conforme mencionado acima, um desenvolvedor de aplicações deve se abstrair das características técnicas e de engenharia de implementação da interação de diversas aplicações para que possa contar com uma boa produtividade de desenvolvimento.

Vamos tentar resolver um problema de integração bastante difícil, inventado especialmente para o artigo. Esta será uma tarefa de “jogo” envolvendo três aplicações, onde cada uma delas define um determinado nome de domínio: “app1”, “app2”, “app3”.

Dentro de cada aplicação são lançados processos de negócios que começam a “jogar bola” através do barramento de integração. Mensagens com o nome “Bola” funcionarão como uma bola.

Regras do jogo:

  • o primeiro jogador é o iniciador. Ele convida outros jogadores para o jogo, inicia o jogo e pode encerrá-lo a qualquer momento;
  • outros jogadores declaram sua participação no jogo, “conhecendo-se” entre si e com o primeiro jogador;
  • após receber a bola, o jogador seleciona outro jogador participante e passa a bola para ele. O número total de transmissões é contado;
  • Cada jogador tem “energia” que diminui a cada passe da bola daquele jogador. Quando a energia acaba, o jogador sai do jogo, anunciando sua demissão;
  • se o jogador ficar sozinho, ele anuncia imediatamente sua saída;
  • Quando todos os jogadores forem eliminados, o primeiro jogador declara o fim do jogo. Se ele sair do jogo mais cedo, resta seguir o jogo para completá-lo.

Para resolver esse problema, usarei nossa DSL para processos de negócios, que nos permite descrever a lógica em Kotlin de forma compacta, com um mínimo de clichê.

O processo de negócios do primeiro jogador (também conhecido como iniciador do jogo) funcionará no aplicativo app1:

classe InicialPlayer

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

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

class PlayersList : ArrayList<PlayerInfo>()

// Это класс экземпляра процесса: инкапсулирует его внутреннее состояние
class InitialPlayer : ProcessImpl<InitialPlayer>(initialPlayerModel) {
    var playerName: String by persistent("Player1")
    var energy: Int by persistent(30)
    var players: PlayersList by persistent(PlayersList())
    var shotCounter: Int = 0
}

// Это декларация модели процесса: создается один раз, используется всеми
// экземплярами процесса соответствующего класса
val initialPlayerModel = processModel<InitialPlayer>(name = "InitialPlayer",
                                                     version = 1) {

    // По правилам, первый игрок является инициатором игры и должен быть единственным
    uniqueConstraint = UniqueConstraints.singleton

    // Объявляем активности, из которых состоит бизнес-процесс
    val sendNewGameSignal = signal<String>("NewGame")
    val sendStopGameSignal = signal<String>("StopGame")
    val startTask = humanTask("Start") {
        taskOperation {
            processCondition { players.size > 0 }
            confirmation { "Подключилось ${players.size} игроков. Начинаем?" }
        }
    }
    val stopTask = humanTask("Stop") {
        taskOperation {}
    }
    val waitPlayerJoin = signalWait<String>("PlayerJoin") { signal ->
        players.add(PlayerInfo(
                signal.data!!,
                signal.sender.domain,
                signal.sender.processInstanceId))
        println("... join player ${signal.data} ...")
    }
    val waitPlayerOut = signalWait<String>("PlayerOut") { signal ->
        players.remove(PlayerInfo(
                signal.data!!,
                signal.sender.domain,
                signal.sender.processInstanceId))
        println("... player ${signal.data} is out ...")
    }
    val sendPlayerOut = signal<String>("PlayerOut") {
        signalData = { playerName }
    }
    val sendHandshake = messageSend<String>("Handshake") {
        messageData = { playerName }
        activation = {
            receiverDomain = process.players.last().domain
            receiverProcessInstanceId = process.players.last().id
        }
    }
    val throwStartBall = messageSend<Int>("Ball") {
        messageData = { 1 }
        activation = { selectNextPlayer() }
    }
    val throwBall = messageSend<Int>("Ball") {
        messageData = { shotCounter + 1 }
        activation = { selectNextPlayer() }
        onEntry { energy -= 1 }
    }
    val waitBall = messageWaitData<Int>("Ball") {
        shotCounter = it
    }

    // Теперь конструируем граф процесса из объявленных активностей
    startFrom(sendNewGameSignal)
            .fork("mainFork") {
                next(startTask)
                next(waitPlayerJoin).next(sendHandshake).next(waitPlayerJoin)
                next(waitPlayerOut)
                        .branch("checkPlayers") {
                            ifTrue { players.isEmpty() }
                                    .next(sendStopGameSignal)
                                    .terminate()
                            ifElse().next(waitPlayerOut)
                        }
            }
    startTask.fork("afterStart") {
        next(throwStartBall)
                .branch("mainLoop") {
                    ifTrue { energy < 5 }.next(sendPlayerOut).next(waitBall)
                    ifElse().next(waitBall).next(throwBall).loop()
                }
        next(stopTask).next(sendStopGameSignal)
    }

    // Навешаем на активности дополнительные обработчики для логирования
    sendNewGameSignal.onExit { println("Let's play!") }
    sendStopGameSignal.onExit { println("Stop!") }
    sendPlayerOut.onExit { println("$playerName: I'm out!") }
}

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

Além de executar a lógica de negócios, o código acima pode produzir um modelo de objeto de um processo de negócios, que pode ser visualizado na forma de um diagrama. Ainda não implementamos o visualizador, então tivemos que gastar um pouco de tempo desenhando (aqui simplifiquei um pouco a notação BPMN referente ao uso de portas para melhorar a consistência do diagrama com o código abaixo):

Integração de estilo BPM

app2 incluirá o processo de negócios do outro jogador:

classe RandomPlayer

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

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

class PlayersList: ArrayList<PlayerInfo>()

class RandomPlayer : ProcessImpl<RandomPlayer>(randomPlayerModel) {

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

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

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

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

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

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

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

Diagrama:

Integração de estilo BPM

Na aplicação app3 faremos um jogador com um comportamento um pouco diferente: em vez de selecionar aleatoriamente o próximo jogador, ele agirá de acordo com o algoritmo round-robin:

classe RoundRobinPlayer

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

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

class PlayersList: ArrayList<PlayerInfo>()

class RoundRobinPlayer : ProcessImpl<RoundRobinPlayer>(roundRobinPlayerModel) {

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

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

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

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

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

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

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

Caso contrário, o comportamento do jogador não difere do anterior, portanto o diagrama não muda.

Agora precisamos de um teste para executar tudo isso. Darei apenas o código do teste em si, para não sobrecarregar o artigo com clichês (na verdade, usei o ambiente de teste criado anteriormente para testar a integração de outros processos de negócios):

testeJogo()

@Test
public void testGame() throws InterruptedException {
    String pl2 = startProcess(app2, "RandomPlayer", playerParams("Player2", 20));
    String pl3 = startProcess(app2, "RandomPlayer", playerParams("Player3", 40));
    String pl4 = startProcess(app3, "RoundRobinPlayer", playerParams("Player4", 25));
    String pl5 = startProcess(app3, "RoundRobinPlayer", playerParams("Player5", 35));
    String pl1 = startProcess(app1, "InitialPlayer");
    // Теперь нужно немного подождать, пока игроки "познакомятся" друг с другом.
    // Ждать через sleep - плохое решение, зато самое простое. 
    // Не делайте так в серьезных тестах!
    Thread.sleep(1000);
    // Запускаем игру, закрывая пользовательскую активность
    assertTrue(closeTask(app1, pl1, "Start"));
    app1.getWaiting().waitProcessFinished(pl1);
    app2.getWaiting().waitProcessFinished(pl2);
    app2.getWaiting().waitProcessFinished(pl3);
    app3.getWaiting().waitProcessFinished(pl4);
    app3.getWaiting().waitProcessFinished(pl5);
}

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

Vamos fazer o teste e ver o log:

saída do console

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

De tudo isto podemos tirar várias conclusões importantes:

  • com as ferramentas necessárias, os desenvolvedores de aplicações podem criar interações de integração entre aplicações sem interromper a lógica de negócios;
  • a complexidade de uma tarefa de integração que requer competências de engenharia pode ser ocultada dentro da estrutura se isso for inicialmente incluído na arquitetura da estrutura. A dificuldade de um problema não pode ser ocultada, portanto a solução para um problema difícil no código terá essa aparência;
  • Ao desenvolver a lógica de integração, é imperativo levar em conta a eventual consistência e a falta de linearização das mudanças no estado de todos os participantes da integração. Isto obriga-nos a complicar a lógica para torná-la insensível à ordem em que ocorrem os acontecimentos externos. No nosso exemplo, o jogador é forçado a participar do jogo após declarar sua saída do jogo: os outros jogadores continuarão a passar a bola para ele até que a informação sobre sua saída chegue e seja processada por todos os participantes. Esta lógica não decorre das regras do jogo e é uma solução de compromisso no quadro da arquitectura escolhida.

A seguir, falaremos sobre os vários meandros da nossa solução, compromissos e outros pontos.

Todas as mensagens estão em uma fila

Todas as aplicações integradas funcionam com um barramento de integração, que é apresentado na forma de um broker externo, um BPMQueue para mensagens e um tópico BPMTopic para sinais (eventos). Colocar todas as mensagens em uma fila é em si um compromisso. No nível da lógica de negócios, agora você pode introduzir quantos novos tipos de mensagens desejar, sem fazer alterações na estrutura do sistema. Esta é uma simplificação significativa, mas acarreta certos riscos, que no contexto das nossas tarefas típicas não nos pareciam tão significativos.

Integração de estilo BPM

Porém, há uma sutileza aqui: cada aplicação filtra “suas” mensagens da fila de entrada, pelo nome de seu domínio. O domínio também pode ser especificado em sinais se for necessário limitar o “escopo de visibilidade” do sinal a uma única aplicação. Isso deve aumentar o rendimento do barramento, mas a lógica de negócios agora deve operar com nomes de domínio: para endereçamento de mensagens - obrigatório, para sinais - desejável.

Garantindo a confiabilidade do barramento de integração

A confiabilidade consiste em vários pontos:

  • O agente de mensagens selecionado é um componente crítico da arquitetura e um ponto único de falha: deve ser suficientemente tolerante a falhas. Você deve usar apenas implementações testadas pelo tempo, com bom suporte e uma grande comunidade;
  • é necessário garantir a alta disponibilidade do agente de mensagens, para o qual ele deve estar fisicamente separado das aplicações integradas (a alta disponibilidade de aplicações com lógica de negócio aplicada é muito mais difícil e cara de garantir);
  • o corretor é obrigado a fornecer garantias de entrega “pelo menos uma vez”. Este é um requisito obrigatório para a operação confiável do barramento de integração. Não há necessidade de garantias de nível “exatamente uma vez”: os processos de negócios, via de regra, não são sensíveis à chegada repetida de mensagens ou eventos e, em tarefas especiais onde isso é importante, é mais fácil adicionar verificações adicionais ao negócio. lógica do que usar constantemente garantias bastante “caras”;
  • o envio de mensagens e sinais deve estar envolvido em uma transação geral com mudanças no estado dos processos de negócios e dados de domínio. A opção preferida seria usar um padrão Caixa de saída transacional, mas exigirá uma tabela adicional no banco de dados e um repetidor. Em aplicações JEE, isso pode ser simplificado usando um gerenciador JTA local, mas a conexão com o broker selecionado deve ser capaz de funcionar em XA;
  • os manipuladores de mensagens e eventos recebidos também devem trabalhar com uma transação que altera o estado de um processo de negócios: se tal transação for revertida, o recebimento da mensagem deverá ser cancelado;
  • mensagens que não puderam ser entregues devido a erros devem ser armazenadas em um armazenamento separado D.L.Q. (Fila de cartas mortas). Para tanto, criamos um microsserviço de plataforma separado que armazena essas mensagens em seu armazenamento, indexa-as por atributos (para agrupamento e pesquisa rápida) e expõe uma API para visualização, reenvio para o endereço de destino e exclusão de mensagens. Os administradores de sistema podem trabalhar com este serviço através de sua interface web;
  • nas configurações do corretor, você precisa ajustar o número de novas tentativas de entrega e atrasos entre as entregas para reduzir a probabilidade de mensagens entrarem em DLQ (é quase impossível calcular os parâmetros ideais, mas você pode agir empiricamente e ajustá-los durante a operação );
  • O armazenamento DLQ deve ser monitorado continuamente e o sistema de monitoramento deve alertar os administradores do sistema para que, quando ocorrerem mensagens não entregues, eles possam responder o mais rápido possível. Isto reduzirá a “área afetada” de uma falha ou erro de lógica de negócios;
  • o barramento de integração deve ser insensível à ausência temporária de aplicativos: as assinaturas de um tópico devem ser duráveis ​​e o nome de domínio do aplicativo deve ser exclusivo para que, enquanto o aplicativo estiver ausente, outra pessoa não tente processar suas mensagens do fila.

Garantindo a segurança do thread da lógica de negócios

A mesma instância de um processo de negócios pode receber várias mensagens e eventos ao mesmo tempo, cujo processamento será iniciado em paralelo. Ao mesmo tempo, para um desenvolvedor de aplicativos, tudo deve ser simples e seguro para threads.

A lógica de negócios de um processo processa cada evento externo que afeta esse processo de negócios individualmente. Tais eventos poderiam ser:

  • lançar uma instância de processo de negócios;
  • ação do usuário relacionada à atividade dentro de um processo de negócios;
  • recebimento de uma mensagem ou sinal ao qual uma instância de processo de negócios está inscrita;
  • acionamento de um timer definido por uma instância de processo de negócios;
  • ação de controle via API (por exemplo, interrupção de processo).

Cada um desses eventos pode alterar o estado de uma instância de processo de negócios: algumas atividades podem terminar e outras podem começar, e os valores das propriedades persistentes podem mudar. O encerramento de qualquer atividade pode resultar na ativação de uma ou mais das seguintes atividades. Estes, por sua vez, podem deixar de esperar por outros eventos ou, caso não necessitem de nenhum dado adicional, podem concluir na mesma transação. Antes de fechar a transação, o novo estado do processo de negócio é salvo no banco de dados, onde aguardará a ocorrência do próximo evento externo.

Dados persistentes de processos de negócios armazenados em um banco de dados relacional são um ponto muito conveniente para sincronizar o processamento se você usar SELECT FOR UPDATE. Se uma transação conseguiu obter o estado de um processo de negócio a partir da base para alterá-lo, então nenhuma outra transação em paralelo será capaz de obter o mesmo estado para outra alteração, e após a conclusão da primeira transação, a segunda é garantido para receber o estado já alterado.

Utilizando bloqueios pessimistas no lado do SGBD, cumprimos todos os requisitos necessários ACIDe também manter a capacidade de dimensionar o aplicativo com lógica de negócios, aumentando o número de instâncias em execução.

No entanto, bloqueios pessimistas nos ameaçam com conflitos, o que significa que SELECT FOR UPDATE ainda deve ser limitado a um tempo limite razoável, caso ocorram conflitos em alguns casos flagrantes na lógica de negócios.

Outro problema é a sincronização do início de um processo de negócio. Embora não haja instância de processo de negócios, não há estado no banco de dados, portanto o método descrito não funcionará. Se você precisar garantir a exclusividade de uma instância de processo de negócios em um escopo específico, precisará de algum tipo de objeto de sincronização associado à classe de processo e ao escopo correspondente. Para resolver este problema, usamos um mecanismo de bloqueio diferente que nos permite bloquear um recurso arbitrário especificado por uma chave no formato URI através de um serviço externo.

Em nossos exemplos, o processo de negócios InitialPlayer contém uma declaração

uniqueConstraint = UniqueConstraints.singleton

Portanto, o log contém mensagens sobre a retirada e liberação do bloqueio da chave correspondente. Não existem tais mensagens para outros processos de negócios: uniqueConstraint não está configurado.

Problemas de processos de negócios com estado persistente

Às vezes, ter um estado persistente não só ajuda, mas também atrapalha muito o desenvolvimento.
Os problemas começam quando mudanças precisam ser feitas na lógica de negócios e/ou no modelo de processos de negócios. Nem todas essas mudanças são compatíveis com o antigo estado dos processos de negócios. Se houver muitas instâncias ativas no banco de dados, fazer alterações incompatíveis poderá causar muitos problemas, que frequentemente encontramos ao usar o jBPM.

Dependendo da profundidade das mudanças, você pode agir de duas maneiras:

  1. crie um novo tipo de processo de negócios para não fazer alterações incompatíveis com o antigo e use-o em vez do antigo ao iniciar novas instâncias. As cópias antigas continuarão funcionando “como antes”;
  2. migre o estado persistente dos processos de negócios ao atualizar a lógica de negócios.

A primeira forma é mais simples, mas tem suas limitações e desvantagens, por exemplo:

  • duplicação da lógica de negócios em muitos modelos de processos de negócios, aumentando o volume da lógica de negócios;
  • Muitas vezes é necessária uma transição imediata para uma nova lógica de negócios (em termos de tarefas de integração - quase sempre);
  • o desenvolvedor não sabe em que ponto os modelos desatualizados podem ser excluídos.

Na prática usamos ambas as abordagens, mas tomamos uma série de decisões para facilitar a nossa vida:

  • No banco de dados, o estado persistente de um processo de negócios é armazenado em um formato facilmente legível e processado: em uma string no formato JSON. Isso permite que as migrações sejam realizadas tanto dentro do aplicativo quanto externamente. Como último recurso, você pode corrigi-lo manualmente (especialmente útil no desenvolvimento durante a depuração);
  • a lógica de negócio de integração não utiliza nomes de processos de negócio, de forma que a qualquer momento é possível substituir a implementação de um dos processos participantes por um novo com novo nome (por exemplo, “InitialPlayerV2”). A ligação ocorre através de nomes de mensagens e sinais;
  • o modelo de processo possui um número de versão, que incrementamos se fizermos alterações incompatíveis neste modelo, e esse número é salvo junto com o estado da instância do processo;
  • o estado persistente do processo é lido primeiro do banco de dados em um modelo de objeto conveniente, com o qual o procedimento de migração pode trabalhar se o número da versão do modelo for alterado;
  • o procedimento de migração é colocado próximo à lógica de negócio e é denominado “preguiçoso” para cada instância do processo de negócio no momento de sua restauração do banco de dados;
  • se você precisar migrar o estado de todas as instâncias de processo de forma rápida e síncrona, soluções mais clássicas de migração de banco de dados serão usadas, mas você terá que trabalhar com JSON.

Você precisa de outra estrutura para processos de negócios?

As soluções descritas no artigo nos permitiram simplificar significativamente nossa vida, ampliar o leque de problemas resolvidos no nível de desenvolvimento de aplicações e tornar mais atraente a ideia de separar a lógica de negócios em microsserviços. Para o conseguir, muito trabalho foi feito, foi criada uma estrutura muito “leve” para processos de negócio, bem como componentes de serviço para resolver os problemas identificados no contexto de uma vasta gama de problemas de aplicação. Desejamos compartilhar esses resultados e tornar o desenvolvimento de componentes comuns de acesso aberto sob licença gratuita. Isso exigirá algum esforço e tempo. Compreender a procura por tais soluções poderia ser um incentivo adicional para nós. No artigo proposto, muito pouca atenção é dada às capacidades do próprio framework, mas algumas delas são visíveis nos exemplos apresentados. Se publicarmos nossa estrutura, um artigo separado será dedicado a ela. Enquanto isso, ficaríamos gratos se você deixasse um pequeno feedback respondendo à pergunta:

Apenas usuários registrados podem participar da pesquisa. Entrarpor favor

Você precisa de outra estrutura para processos de negócios?

  • 18,8%Sim, estou procurando algo assim há muito tempo

  • 12,5%Estou interessado em saber mais sobre sua implementação, pode ser útil2

  • 6,2%Usamos uma das estruturas existentes, mas estamos pensando em substituí-la1

  • 18,8%Usamos um dos frameworks existentes, está tudo bem3

  • 18,8%gerenciamos sem uma estrutura3

  • 25,0%escreva o seu4

16 usuários votaram. 7 usuários se abstiveram.

Fonte: habr.com

Adicionar um comentário