
Olá pessoal!
Decidimos apoiar o tema da migração do projeto usando a Windows Workflow Foundation para .Net Core , iniciada por colegas da DIRECTUM, porque enfrentamos um problema semelhante há alguns anos e seguimos o nosso caminho.
Vamos começar com a história
Nosso principal produto, o Avanpost IDM, é um sistema de gerenciamento de ciclo de vida da conta e acesso aos funcionários. Ele sabe como gerenciar o acesso automaticamente, com base no modelo e de acordo com as solicitações. No início da formação do produto, tínhamos um sistema de autoatendimento bastante simples, com um fluxo de trabalho passo a passo simples, para o qual o mecanismo não era necessário em princípio.

No entanto, quando confrontados com grandes clientes, percebemos que era necessária uma ferramenta muito mais flexível, pois seus requisitos para os processos de coordenação de direitos de acesso buscavam as regras de um bom e pesado fluxo de trabalho. Após analisar os requisitos, decidimos desenvolver nosso próprio editor de processos no formato BPMN, adequado para nossas necessidades. Falaremos sobre o desenvolvimento do editor usando React.js + SVG um pouco mais tarde e hoje discutiremos o tópico de back-end - o mecanismo de fluxo de trabalho ou mecanismo de processo de negócios.
Exigências
No início do desenvolvimento do sistema, tínhamos os seguintes requisitos para o mecanismo:
- Suporte para diagramas de processo, um formato compreensível, a capacidade de transmitir do nosso formato para o formato do mecanismo
- Armazenamento do estado do processo
- Suporte à versão de processo
- Suporte para execução paralela (ramificações) do processo
- Uma licença adequada para usar a solução em um produto comercial replicado
- Suporte para dimensionamento horizontal
Após analisar o mercado (para 2014), decidimos por uma solução praticamente não alternativa para .Net: Windows Workflow Foundation.
Windows Workflow Foundation (WWF)
WWF é a tecnologia da Microsoft para definir, executar e gerenciar fluxos de trabalho.
A base de sua lógica é um conjunto de contêineres para ações (atividades) e a capacidade de criar processos sequenciais a partir desses contêineres. O contêiner pode ser comum - uma certa etapa do processo em que a atividade é realizada. Pode ser um gerente - contendo a lógica da ramificação.
Você pode desenhar um processo diretamente no Visual Studio. O diagrama de processo de negócios compilado é armazenado em Haml, o que é muito conveniente - o formato é descrito, é possível criar um designer de processo auto-escrito. Isso está por um lado. Por outro lado, o Xaml não é o formato mais conveniente para armazenar uma descrição - o esquema compilado para um processo mais ou menos real acaba sendo enorme, principalmente por causa da redundância. É muito difícil de entender, mas você precisa entender.
Mas se, mais cedo ou mais tarde, é possível compreender o zen com os esquemas e aprender a lê-los, a falta de transparência na operação do mecanismo em si aumenta o aborrecimento já durante a operação do sistema pelos usuários. Quando o erro provém das entranhas de Wf, nem sempre é possível descobrir 100% qual foi exatamente o motivo da falha. A fonte fechada e a relativa monstruosidade não ajudam o caso. Muitas vezes, as correções de erros eram devidas a sintomas.
Para ser justo, vale a pena esclarecer aqui que os problemas descritos acima, na maior parte, nos atormentaram devido à forte personalização sobre o Wf. Um dos leitores dirá com certeza que nós mesmos criamos um monte de problemas e os resolvemos heroicamente. Era necessário fabricar um motor fabricado desde o início. Em geral, eles estarão certos.
No final das contas, a solução funcionou de maneira estável o suficiente e entrou em produção com sucesso. Mas a transição de nossos produtos para o .Net Core nos forçou a abandonar o WWF e procurar outro mecanismo de processo de negócios, porque Desde maio de 2019, o Windows Workflow Foundation não foi migrado para o .Net Core. Como estávamos procurando por um novo mecanismo - o tópico de um artigo separado, mas no final decidimos pelo Workflow Core.
Núcleo do fluxo de trabalho
O Workflow Core é um mecanismo de processo de negócios gratuito. É desenvolvido sob a licença MIT, ou seja, pode ser usado com segurança no desenvolvimento comercial.
É feito ativamente por uma pessoa; várias outras fazem periodicamente uma solicitação de recebimento. Existem portas para outras linguagens (Java, Python e várias outras).
O motor está posicionado como leve. De fato, este é apenas um host para execução sequencial de ações agrupadas por qualquer regra de negócios.
O projeto possui documentação wiki . Infelizmente, ele não descreve todos os recursos do mecanismo. No entanto, será imprudente exigir documentação completa - o projeto de código-fonte aberto é suportado por um entusiasta. Portanto, o Wiki será suficiente para começar.
Pronto para uso, há suporte para armazenar o status do processo no armazenamento externo (armazenamento de persistência). Os fornecedores são padrão para:
- Mongodb
- SQL Server
- PostgreSQL
- Sqlite
- Amazon DynamoDB
Escreva o seu provedor não é um problema. Tomamos as fontes de qualquer padrão e fazemos como um exemplo.
O dimensionamento horizontal é suportado, ou seja, você pode executar o mecanismo em vários nós de uma só vez, enquanto possui um ponto de armazenamento de estados do processo (um armazenamento de persistência). Ao mesmo tempo, a fila de tarefas interna do mecanismo deve estar no armazenamento geral (rabbitMQ, como opção). Para excluir a execução de uma tarefa por vários nós, um gerenciador de bloqueios é fornecido ao mesmo tempo. Por analogia com provedores de armazenamento externo, há implementações padrão:
- Concessões de armazenamento do Azure
- Redis
- AWS DynamoDB
- SQLServer (na fonte existe, mas nada é dito na documentação)
Conhecer algo novo é mais fácil começar com um exemplo. Então vamos lá. Descreverei a construção de um processo simples desde o início, juntamente com uma explicação. Um exemplo pode parecer impossivelmente simples. Eu concordo - é simples. O máximo para começar.
Vamos lá
Etapa
Uma etapa é uma etapa do processo em que qualquer ação é executada. Todo o processo é construído a partir de uma sequência de etapas. Uma etapa pode executar muitas ações, pode ser repetida, por exemplo, para algum evento externo. Há um conjunto de etapas que são dotadas da lógica "pronta para uso":
- Waitfor
- Se
- Enquanto
- Foreach
- Atraso
- Paralela
- Horário
- Recorrente
Obviamente, em algumas primitivas incorporadas você não suporta o processo. Precisamos de etapas que concluam tarefas de negócios. Portanto, por enquanto, coloque-os de lado e tome medidas com nossa própria lógica. Para fazer isso, você precisa herdar da abstração StepBody .
public abstract class StepBody : IStepBody { public abstract ExecutionResult Run(IStepExecutionContext context); }
O método Run é executado quando o processo entra em uma etapa. É necessário colocar a lógica necessária nele.
public abstract class StepBody : IStepBody { public abstract ExecutionResult Run(IStepExecutionContext context); }
As etapas suportam injeção de dependência. Para fazer isso, basta registrá-los no mesmo contêiner que as dependências necessárias.
Obviamente, o processo precisa de seu próprio contexto - um local onde resultados intermediários de execução podem ser adicionados. O núcleo Wf possui seu próprio contexto para a execução de um processo que armazena informações sobre seu estado atual. Você pode acessá-lo usando a variável de contexto do método Run (). Além do embutido, podemos usar nosso contexto.
Analisaremos as maneiras de descrever e registrar o processo com mais detalhes abaixo, por enquanto, simplesmente definimos uma determinada classe - o contexto.
public class ProcessContext { public int Number1 {get;set;} public int Number2 {get;set;} public string StepResult {get;set;} public ProcessContext() { Number1 = 1; Number2 = 2; } }
Nas variáveis Number , escrevemos números; na variável StepResult - o resultado da etapa.
Decidimos sobre o contexto. Você pode escrever sua própria etapa:
public class CustomStep : StepBody { private readonly Ilogger _log; public int Input1 { get; set; } public int Input2 { get; set; } public string Action { get; set; } public string Result { get; set; } public CustomStep(Ilogger log) { _log = log; } public override ExecutionResult Run(IStepExecutionContext context) { Result = ”none”; if (Action ==”sum”) { Result = Number1 + Number2; } if (Action ==”dif”){ Result = Number1 - Number2; } return ExecutionResult.Next(); } }
A lógica é extremamente simples: dois números e o nome da operação chegam à entrada. O resultado da operação é gravado na variável de saída Result . Se a operação não estiver definida, o resultado será nenhum .
Decidimos sobre o contexto, há um passo com a lógica que precisamos também. Agora precisamos registrar nosso processo no mecanismo.
Descrição do processo. Registro no mecanismo.
Existem duas maneiras de descrever um processo. A primeira é a descrição no código - o código rígido.
O processo é descrito através da interface fluente . É necessário herdar da interface IWorkflow <T> generalizada, em que T é a classe de contexto do modelo. No nosso caso, este é um ProcessContext .
É assim:
public class SimpleWorkflow : IWorkflow<ProcessContext> { public void Build(IWorkflowBuilder<ProcessContext> builder) {
A descrição em si estará dentro do método Build . Os campos Id e Version também são obrigatórios. O núcleo Wf suporta controle de versão do processo - você pode registrar n versões de processo com o mesmo identificador. Isso é conveniente quando você precisa atualizar um processo existente e, ao mesmo tempo, dar vida às tarefas existentes.
Nós descrevemos um processo simples:
public class SimpleWorkflow : IWorkflow<ProcessContext> { public void Build(IWorkflowBuilder<ProcessContext> builder) { builder.StartWith<CustomStep>() .Input(step => step.Input1, data => data.Number1) .Input(step => step.Input2, data => data.Number2) .Input(step => step.Action, data => “sum”) .Output(data => data.StepResult, step => step.Result) .EndWorkflow(); } public string Id => "SomeWorkflow"; public int Version => 1; }
Se traduzido para o idioma "humano", será algo assim: o processo começa com a etapa CustomStep . O valor do campo da etapa Input1 é obtido do campo de contexto Number1 , o valor do campo da etapa Input2 é obtido do campo de contexto Number2 , o campo de ação é codificado permanentemente para o valor "sum" . A saída do campo Resultado é gravada no campo de contexto StepResult . Complete o processo.
Concordo, o código acabou sendo muito legível, é bem possível descobrir isso, mesmo sem conhecimento especial em C #.
Adicione mais uma etapa ao nosso processo, que produzirá o resultado da etapa anterior no log:
public class CustomStep : StepBody { private readonly Ilogger _log; public string TextToOutput { get; set; } public CustomStep(Ilogger log) {
E atualize o processo:
public class SimpleWorkflow : IWorkflow<ProcessContext> { public void Build(IWorkflowBuilder<ProcessContext> builder) { builder.StartWith<CustomStep>() .Input(step => step.Input1, data => data.Number1) .Input(step => step.Input2, data => data.Number2) .Input(step => step.Action, data => “sum”) .Output(data => data.StepResult, step => step.Result) .Then<OutputStep>.Input(step => step.TextToOutput, data => data.StepResult) .EndWorkflow(); } public string Id => "SomeWorkflow"; public int Version => 2; }
Agora, após a etapa com a operação de adição, segue a etapa de saída do resultado no log. Para a entrada, passamos a variável Result e context na qual o resultado da execução foi gravado na última etapa. Terei a liberdade de afirmar que essa descrição por meio de um código (hardcode) em sistemas reais seria de pouca utilidade. A menos que para alguns processos do escritório. É muito mais interessante poder armazenar o circuito separadamente. No mínimo, não precisamos remontar o projeto toda vez que precisamos alterar algo no processo ou adicionar um novo. O núcleo Wf fornece esse recurso armazenando o esquema json. Continuamos a expandir nosso exemplo.
Descrição do processo Json
Além disso, não fornecerei uma descrição através do código. Isso não é particularmente interessante e apenas inflará o artigo.
O núcleo Wf suporta a descrição do esquema em json. Na minha opinião, json é mais visual que o xaml (um bom tópico para o holivar nos comentários :)). A estrutura do arquivo é bastante simples:
{ "Id": "SomeWorkflow", "Version": 1, "DataType": "App.ProcessContext, App", "Steps": [ { /*step1*/ }, { /*step2*/ } ] }
O campo DataType indica o nome completo da classe de contexto e o nome do assembly no qual está descrito. Steps armazena uma coleção de todas as etapas do processo. Preencha o elemento Etapas :
{ "Id": "SomeWorkflow", "Version": 1, "DataType": "App.ProcessContext, App", "Steps": [ { "Id": "Eval", "StepType": "App.CustomStep, App", "NextStepId": "Output", "Inputs": { "Input1": "data.Number1", "Input2": "data.Number2" }, "Outputs": { "StepResult": "step.Result" } }, { "Id": "Output", "StepType": "App.OutputStep, App", "Inputs": { "TextToOutput": "data.StepResult" } } ] }
Vamos dar uma olhada na estrutura da descrição da etapa via json.
Os campos Id e NextStepId armazenam o identificador desta etapa e um indicador de qual etapa pode ser a próxima. Além disso, a ordem dos elementos da coleção não é importante.
StepType é semelhante ao campo DataType , contém o nome completo da classe da etapa (um tipo que herda de StepBody e implementa a lógica da etapa) e o nome do assembly. Mais interessantes são os objetos Entradas e Saídas . Eles são definidos na forma de mapeamento.
No caso de Entradas, o nome do elemento json é o nome do campo de classe da nossa etapa; o valor do elemento é o nome do campo na classe, o contexto do processo.
Para saídas, pelo contrário, o nome do elemento json é o nome do campo na classe, o contexto do processo; valor do elemento é o nome do campo de classe da nossa etapa.
Por que os campos de contexto são especificados por meio de dados. {Field_name} e, no caso de Output , etapa. {Field_name} ? Como o wf core, o valor do elemento é executado como uma expressão C # (a biblioteca Dynamic Expressions é usada). Isso é bastante útil, com sua ajuda você pode colocar alguma lógica de negócios diretamente dentro do esquema, se, é claro, o arquiteto aprovar essa desgraça :).
Diversificamos o esquema com primitivas padrão. Adicione uma etapa If condicional e processando um evento externo.
Se
Primitivo Se . Aqui começam as dificuldades. Se você está acostumado a bpmn e desenha processos nesta notação, encontrará uma configuração fácil. De acordo com a documentação, a etapa é descrita da seguinte maneira:
{ "Id": "IfStep", "StepType": "WorkflowCore.Primitives.If, WorkflowCore", "NextStepId": "nextStep", "Inputs": { "Condition": "<<expression to evaluate>>" }, "Do": [ [ { /*do1*/ }, { /*do2*/ } ] ] }
Não há sensação de que algo está errado aqui? Eu tenho um A entrada da etapa é definida como Condição - expressão. Em seguida, definimos a lista de etapas dentro da matriz Do (ações). Então, onde está o ramo falso ? Por que não existe uma matriz Do para False? Na verdade existe. Entende-se que o ramo False é simplesmente um passo adiante no processo, ou seja, seguindo o ponteiro em NextStepId . No começo, eu estava constantemente confuso por causa disso. Ok, resolvi isso. Embora não. Se as ações do processo, no caso de True, precisarem ser colocadas dentro do Do , é isso que será o "belo" json. E se houver estes Se fechado com uma dúzia? Tudo vai para o lado. Eles também dizem que o esquema no xaml é difícil de ler. Há um pequeno hack. Apenas amplie o monitor. Foi mencionado um pouco acima que a ordem das etapas na coleção não importa, a transição segue os sinais. Pode ser usado. Adicione mais uma etapa:
{ "Id": "Jump", "StepType": "App.JumpStep, App", "NextStepId": "" }
Adivinha o que estou levando? É verdade que apresentamos uma etapa de serviço, que em trânsito leva o processo a uma etapa no NextStepId .
Atualize nosso esquema:
{ "Id": "SomeWorkflow", "Version": 1, "DataType": "App.ProcessContext, App", "Steps": [ { "Id": "Eval", "StepType": "App.CustomStep, App", "NextStepId": "MyIfStep", "Inputs": { "Input1": "data.Number1", "Input2": "data.Number2" }, "Outputs": { "StepResult": "step.Result" } }, { "Id": "MyIfStep", "StepType": "WorkflowCore.Primitives.If, WorkflowCore", "NextStepId": "OutputEmptyResult", "Inputs": { "Condition": "!String.IsNullOrEmpty(data.StepResult)" }, "Do": [ [ { "Id": "Jump", "StepType": "App.JumpStep, App", "NextStepId": "Output" } ] ] }, { "Id": "Output", "StepType": "App.OutputStep, App", "Inputs": { "TextToOutput": "data.StepResult" } }, { "Id": "OutputEmptyResult", "StepType": "App.OutputStep, App", "Inputs": { "TextToOutput": "\"Empty result\"" } } ] }
A etapa If verifica se o resultado da etapa Eval está vazio. Se não estiver vazio, exibimos o resultado; se estiver vazio, a mensagem " Resultado vazio ". A etapa Jump leva o processo para a etapa Output , que está fora da coleção Do. Assim, mantivemos a “verticalidade” do esquema. Além disso, dessa maneira, é possível pular n etapas para trás, ou seja, para organizar um ciclo. Existem primitivas internas para loops no núcleo wf, mas elas nem sempre são convenientes. No bpmn, por exemplo, os loops são organizados por meio de If .
Use essa abordagem ou o padrão, depende de você. Para nós, essa organização era uma medida mais conveniente.
Waitfor
A primitiva WaitFor permite que o mundo externo influencie o processo quando ele já estiver em execução. Por exemplo, se na fase do processo for necessária a aprovação do curso adicional por qualquer usuário. O processo permanecerá na etapa WaitFor até receber um evento no qual está inscrito.
Estrutura primitiva:
{ "Id": "Wait", "StepType": "WorkflowCore.Primitives.WaitFor, WorkflowCore", "NextStepId": "NextStep", "CancelCondition": "If(cancel==true)", "Inputs": { "EventName": "\"UserAction\"", "EventKey": "\"DoSum\"", "EffectiveDate": "DateTime.Now" } }
Vou explicar um pouco os parâmetros.
CancelCondition - uma condição para interromper uma espera. Fornece a capacidade de interromper a espera por um evento e seguir em frente no processo. Por exemplo, se um processo estiver aguardando n eventos diferentes ao mesmo tempo (o núcleo do wf suporta a execução paralela de etapas), não é necessário aguardar a chegada de todos; nesse caso, o CancelCondition nos ajudará. Adicionamos um sinalizador lógico às variáveis de contexto e, ao receber o evento, configuramos o sinalizador como true - todas as etapas de WaitFor foram concluídas.
EventName e EventKey - nome e chave do evento. Os campos são necessários para distinguir eventos, isto é, em um sistema real com um grande número de processos de trabalho simultâneos. para que o mecanismo entenda qual evento é destinado a qual processo e qual etapa.
EffectiveDate - um campo opcional que adiciona um registro de data e hora do evento. Pode ser útil se você precisar publicar um evento "no futuro". Para que seja publicado imediatamente, você pode deixar o parâmetro em branco ou definir a hora atual.
Em todos os casos, nem sempre é conveniente dar um passo separado para processar reações externas; antes, mesmo que geralmente seja redundante. Uma etapa extra pode ser evitada adicionando a expectativa de um evento externo e a lógica de seu processamento à etapa usual. Complementamos a etapa CustomStep assinando um evento externo:
public class CustomStep : StepBody { private readonly Ilogger _log; public string TextToOutput { get; set; } public CustomStep(Ilogger log) { _log = log; } public override ExecutionResult Run(IStepExecutionContext context) {
Usamos o método de extensão padrão WaitForEvent () . Ele aceita os parâmetros mencionados anteriormente EventName , EventKey e EffectiveDate . Depois de concluir a lógica dessa etapa, o processo aguardará o evento descrito e novamente chamará o método Run () no momento em que o evento for publicado no barramento do mecanismo. No entanto, na forma atual, não podemos distinguir entre os momentos da entrada inicial na etapa e a entrada após o evento. Mas, de alguma forma, gostaria de separar a lógica antes e depois no nível da etapa. E a bandeira EventPublished nos ajudará com isso. Ele está localizado dentro do contexto geral do processo, você pode obtê-lo assim:
var ifEvent=context.ExecutionPointer.EventPublished;
Com base nesse sinalizador, você pode dividir com segurança a lógica em antes e depois de um evento externo.
Um esclarecimento importante - de acordo com a ideia do criador do mecanismo, uma etapa pode ser assinada apenas em um evento e reagir a ele uma vez. Para algumas tarefas, essa é uma limitação muito desagradável. Nós até tivemos que “terminar” o motor para fugir dessa nuance. Ignoraremos sua descrição neste artigo, caso contrário, o artigo nunca terminará :). Práticas de uso mais complexas e exemplos de melhorias serão abordados nos artigos subseqüentes.
Processo de registro no mecanismo. Publicação do evento no ônibus.
Assim, com a implementação da lógica das etapas e descrições do processo, foi descoberto. O que resta é a coisa mais importante, sem a qual o processo não funcionará - a descrição precisa ser registrada.
Usaremos o método de extensão padrão AddWorkflow () , que colocará suas dependências em nosso contêiner de IoC.
É assim:
public static IServiceCollection AddWorkflow(this IServiceCollection services, Action<WorkflowOptions> setupAction = null)
IServiceCollection - interface - um contrato para uma coleção de descrições de serviço. Ele mora dentro do DI da Microsoft (mais sobre isso pode ser lido aqui )
WorkflowOptions - configurações básicas do mecanismo. Não é necessário defini-los, os valores padrão são bastante aceitáveis para o primeiro conhecido. Nós estamos indo além.
Se o processo foi descrito no código, o registro ocorre assim:
var host = _serviceProvider.GetService<IWorkflowHost>(); host.RegisterWorkflow<SomeWorkflow, ProcessContext>();
Se o processo for descrito via json, ele deverá ser registrado da seguinte forma (é claro, a descrição do json deverá ser pré-carregada a partir do local de armazenamento):
var host = _serviceProvider.GetService<IWorkflowHost>(); var definitionLoader = _serviceProvider.GetService<IDefinitionLoader>(); var definition = loader.LoadDefinition({*json *});
Além disso, para as duas opções, o código será o mesmo:
host.Start();
O parâmetro definitionId é o identificador do processo. O que está escrito no campo Id do processo. Nesse caso, id = SomeWorkflow .
O parâmetro version especifica qual versão do processo executar. O mecanismo fornece a capacidade de registrar imediatamente n versões de processo com um identificador. Isso é conveniente quando você precisa fazer alterações na descrição do processo sem interromper as tarefas já em execução - novas serão criadas de acordo com a nova versão, as antigas viverão silenciosamente na antiga.
O parâmetro context é uma instância do contexto do processo.
Os métodos host.Start () e host.Stop () iniciam e param o processo de hospedagem. Se no aplicativo o lançamento de processos for uma tarefa aplicada e for realizada periodicamente, a hospedagem deverá ser interrompida. Se o aplicativo tiver o foco principal na implementação de vários processos, a hospedagem não poderá ser interrompida.
Existe um método para enviar mensagens do mundo externo para o barramento do mecanismo, que as distribuirá entre os assinantes:
Task PublishEvent(string eventName, string eventKey, object eventData, DateTime effectiveDate = null);
A descrição de seus parâmetros foi mais alta no artigo ( consulte a parte primitiva WaitFor ).
Conclusão
Definitivamente assumimos riscos quando decidimos a favor do projeto de código-fonte do Workflow Core, que é desenvolvido ativamente por uma pessoa e mesmo com documentação muito ruim. E você provavelmente não encontrará práticas reais de uso do núcleo wf em sistemas de produção (exceto os nossos). É claro que, após selecionar uma camada separada de abstrações, nos seguramos contra um caso de falha e a necessidade de retornar rapidamente ao WWF, por exemplo, ou uma solução auto-escrita, mas tudo correu muito bem e a falha não ocorreu.
A mudança para o mecanismo de código-fonte do Workflow Core de código aberto resolveu um certo número de problemas que nos impediam de viver pacificamente no WWF. O mais importante deles é, obviamente, o suporte ao .Net Core e à falta dele, mesmo em planos, o WWF.
A seguir está o código aberto. Trabalhando com o WWF e obtendo vários erros de suas entranhas, a capacidade de pelo menos ler a fonte seria muito útil. Sem mencionar a mudança de algo neles. Aqui, com a liberdade completa do Workflow Core (incluindo licenciamento - MIT). Se um erro aparecer repentinamente nas entranhas do mecanismo, basta baixar as fontes do github e procurar calmamente a causa de sua ocorrência. Sim, apenas a capacidade de iniciar o mecanismo no modo de depuração com pontos de interrupção já facilita muito o processo.
Obviamente, resolvendo alguns problemas, o Workflow Core trouxe seus próprios novos. Tivemos que fazer uma quantidade significativa de alterações no núcleo do motor. Mas Trabalhar no "acabamento" por si só custa menos tempo do que desenvolver seu próprio mecanismo a partir do zero. A solução final foi bastante aceitável em termos de velocidade e estabilidade, permitindo esquecer os problemas com o motor e focar no desenvolvimento do valor comercial do produto.
PS Se o tópico for interessante, haverá mais artigos sobre o wf core, com uma análise mais profunda do mecanismo e soluções para problemas de negócios complexos.