Iniciar Spring StateMachine

Entrada


Nos projetos, encontrei três exemplos, de uma forma ou de outra relacionados à teoria dos autômatos finitos

  • Exemplo 1. Um código govnokod divertido . Demora muito tempo para entender o que está acontecendo. Uma característica da modalidade da teoria indicada no código é um despejo bastante feroz que às vezes se parece muito com um código processual. O fato de que esta versão do código é melhor para não tocar no projeto conhece todos os tecnólogos, metodólogos e especialistas em produtos. Eles entram nesse código para corrigir algo em caso de emergência (quando está completamente quebrado), não há como finalizar nenhum recurso. Por que é assustador quebrar. O segundo recurso marcante que isola esse tipo é a presença de switches tão poderosos, em tela cheia.
    Há até uma piada sobre esse ponto:
    Tamanho ideal
    Em um dos JPoint, um dos oradores, talvez Nikolai Alimenkov, falou sobre quantos casos no switch são normais, disse que a resposta principal é "até agora cabe na tela". Portanto, se você interferir e sua opção já não estiver normal, pegue e reduza o tamanho da fonte no IDE
  • Exemplo 2. Estado do Padrão . A idéia principal (para quem não gosta de seguir os links) é que dividimos uma determinada tarefa de negócios em um conjunto de estados finais e as descrevemos com código.
    A principal desvantagem do Estado Padrão é que os estados se conhecem, sabem que existem irmãos e se chamam. Esse código é bastante difícil de universalizar. Por exemplo, ao implementar um sistema de pagamento com vários tipos de pagamento, você corre o risco de cavar tanto no Generic-s que a declaração de seus métodos pode se tornar algo como isto:

    private < T extends BaseContextPayment, Q extends BaseDomainPaymentRequest, S, B extends AbstractPaymentDetailBuilder<T, Q, S, B>, F extends AbstractPaymentBuilder<T, Q, S, B> > PaymentContext<T, S> build(final Q request, final Class<F> factoryClass){ //""  } 

    Resumindo Estado: uma implementação pode resultar em código bastante complicado.
  • Exemplo 3 StateMachine A idéia principal do Pattern é que os estados não se conhecem, o controle de transição é realizado pelo contexto, é melhor, menos conectado, o código é mais simples.

Tendo experimentado todo o “poder” do primeiro tipo e a complexidade do segundo, decidimos usar o Pattern StateMachine para o novo caso de negócios.
Para não reinventar a roda, foi decidido tomar a Statemachine Spring como base (esta é a Spring).

Depois de ler as docas, fui ao YouTube e Habr (para entender como as pessoas trabalham com ele, como se sente no produto, que tipo de rake, etc.). Acontece que há pouca informação, no YouTube há alguns vídeos, todos são bastante superficiais. Sobre Habré, sobre esse assunto, encontrei apenas um artigo, bem como o vídeo, bastante superficial.
Em um artigo, é impossível descrever todas as sutilezas do trabalho da Spring statemachine, contornar o cais e descrever todos os casos, mas tentarei dizer o mais importante e exigido, e sobre o rake, especificamente para mim, quando me familiarizei com a estrutura, as informações abaixo eram seria muito útil.

Corpo principal


Criaremos um aplicativo Spring Boot, adicionaremos um iniciador da Web (o aplicativo Web será executado o mais rápido possível) .O aplicativo será uma abstração do processo de compra. O produto na compra passará pelas etapas de novo declínio reservado e reservado e a compra será concluída.
Um pouco de improvisação, haveria mais status em um projeto real, mas tudo bem, também temos um projeto muito real.
No pom.xml do aplicativo da Web recém-instalado, adicione a dependência da máquina e dos testes para ele (o Web Starter já deve estar, se coletado via start.spring.io ):
 <dependency> <groupId>org.springframework.statemachine</groupId> <artifactId>spring-statemachine-core</artifactId> <version>2.1.3.RELEASE</version> </dependency> <dependency> <groupId>org.springframework.statemachine</groupId> <artifactId>spring-statemachine-test</artifactId> <version>2.1.3.RELEASE</version> <scope>test</scope> </dependency> <cut /> 

Crie a estrutura:

Ainda não preciso entrar em detalhes dessa estrutura, explicarei tudo em sequência e haverá um link para a fonte no final do artigo.

Então vamos lá.
Temos um projeto limpo com as dependências necessárias; para começar, criaremos enum, com estados e eventos, uma abstração bastante simples, esses componentes em si não possuem lógica.
 public enum PurchaseEvent { RESERVE, BUY, RESERVE_DECLINE } 

 public enum PurchaseState { NEW, RESERVED, CANCEL_RESERVED, PURCHASE_COMPLETE } 

Embora formalmente, você pode adicionar campos a esses enum e codificar algo que é característico de, por exemplo, um estado específico, o que é bastante lógico (fizemos isso resolvendo nosso caso de maneira conveniente).
Vamos configurar a máquina através da configuração java, criar o arquivo de configuração e, para a classe estendida EnumStateMachineConfigurerAdapter <PurchaseState, PurchaseEvent>. Como nosso estado e evento são enum, a interface é apropriada, mas não é necessária, qualquer tipo de objeto pode ser usado como genérico (não consideraremos outros exemplos no artigo, pois EnumStateMachineConfigurerAdapter é mais que suficiente na minha opinião).

O próximo ponto importante é se uma máquina permanecerá no contexto do aplicativo: em uma única instância do @EnableStateMachine ou sempre que um novo @EnableStateMachineFactory será criado. Se este for um aplicativo da web para vários usuários com vários usuários, a primeira opção dificilmente será adequada para você, portanto, usaremos o segundo como o mais popular. O StateMachine também pode ser criado via construtor como um bean comum (consulte a documentação), o que é conveniente em alguns casos (por exemplo, você precisa que a máquina seja declarada explicitamente como um bean) e, se for um bean separado, podemos dizer qual é o seu escopo por exemplo, sessão ou solicitação. Em nosso projeto, o wrapper (recursos de nossa lógica de negócios) foi implementado no bean de máquina do estado, o wrapper era único e a própria máquina de protótipo
Ancinho
Como implementar protótipo em singlton?
Na verdade, tudo o que você precisa fazer é obter um novo bean do applicationContext toda vez que acessar o objeto. A injeção de um applicationContext na lógica de negócios é um pecado; portanto, uma máquina de estado de bean deve implementar uma interface com pelo menos um método ou um método abstrato (injeção de método). Ao criar uma configuração java, você precisará implementar o método abstrato indicado e, na implementação, obteremos applicationContext novo bean. É prática comum ter um link para o applicationContext na classe config e, através do método abstrato, chamaremos .getBean ();

A classe EnumStateMachineConfigurerAdapter possui vários métodos, substituindo os que configuramos na máquina.
Para começar, registre os status:
  @Override public void configure(final StateMachineStateConfigurer<PurchaseState, PurchaseEvent> states) throws Exception { states .withStates() .initial(NEW) .end(PURCHASE_COMPLETE) .states(EnumSet.allOf(PurchaseState.class)); } 

initial (NEW) é o status em que a máquina estará após a criação do bean, end (PURCHASE_COMPLETE) é o status em que a máquina executa statemachine.stop (), para uma máquina não determinística (a maioria) é irrelevante, mas algo precisa ser especificado . .states (EnumSet.allOf (PurchaseState.class) de todos os status, você pode empurrar em massa.

Definir configurações globais da máquina
  @Override public void configure(final StateMachineConfigurationConfigurer<PurchaseState, PurchaseEvent> config) throws Exception { config .withConfiguration() .autoStartup(true) .listener(new PurchaseStateMachineApplicationListener()); } 

Aqui, o autoStartup determina se a máquina será iniciada imediatamente após a criação por padrão, em outras palavras - se ela alternará automaticamente para o status NEW (falso por padrão). Imediatamente, registramos um ouvinte para o contexto da máquina (sobre isso um pouco mais tarde), na mesma configuração, você pode definir um TaskExecutor separado, o que é conveniente quando uma ação longa é executada em algumas de suas transições e o aplicativo deve ir além.
Bem, as próprias transições:
  @Override public void configure(final StateMachineTransitionConfigurer<PurchaseState, PurchaseEvent> transitions) throws Exception { transitions .withExternal() .source(NEW) .target(RESERVED) .event(RESERVE) .action(reservedAction(), errorAction()) .and() .withExternal() .source(RESERVED) .target(CANCEL_RESERVED) .event(RESERVE_DECLINE) .action(cancelAction(), errorAction()) .and() .withExternal() .source(RESERVED) .target(PURCHASE_COMPLETE) .event(BUY) .guard(hideGuard()) .action(buyAction(), errorAction()); } 

Toda lógica de transições ou transições é definida aqui, o Guard pode ser pendurado nas transições, um componente que sempre retorna booleano, o que exatamente você confere na transição de um status para outro a seu critério, qualquer lógica pode ser perfeita no Guard, este é um componente completamente comum mas ele deve retornar booleano. Na estrutura de nosso projeto, por exemplo, o HideGuard pode verificar uma determinada configuração que o usuário pode definir (não mostra este produto) e, de acordo com ele, não deixa a máquina entrar no estado protegido pelo Guard. Observo que o Guard, apenas um pode ser adicionado a uma transição na configuração, esse design não funcionará:
  .withExternal() .source(RESERVED) .target(PURCHASE_COMPLETE) .event(BUY) .guard(hideGuard()) .guard(veryHideGuard()) 

Mais precisamente, ele funcionará, mas apenas o primeiro guarda (hideGuard ())
Mas você pode adicionar várias ações (agora estamos falando sobre a ação, que prescrevemos na configuração de transições), eu pessoalmente tentei adicionar três ações a uma transição.
  .withExternal() .source(NEW) .target(RESERVED) .event(RESERVE) .action(reservedAction(), errorAction()) 

o segundo argumento é ErrorAction, o controle chegará a ele se o ReservedAction gerar uma exceção (throw ).
Ancinho
Lembre-se de que se em sua ação você ainda manipular o erro via tentativa / captura, não entrará no ErrorAction; se precisar processar e entrar no ErrorAction, deverá lançar RuntimeException () do catch, por exemplo (você mesmo disse que é muito necessário).

Além da ação "suspensa" nas transições, você também pode "suspendê-las" no método configure for state, aproximadamente da seguinte forma:
  @Override public void configure(final StateMachineStateConfigurer<PurchaseState, PurchaseEvent> states) throws Exception { states .withStates() .initial(NEW) .end(PURCHASE_COMPLETE) .stateEntry() .stateExit() .state() .states(EnumSet.allOf(PurchaseState.class)); } 

Tudo depende de como você deseja executar a ação.
Ancinho
Observe que, se você especificar uma ação ao configurar state (), assim
  states .withStates() .initial(NEW) .end(PURCHASE_COMPLETE) .state(randomAction()) 

ela será executada de forma assíncrona, pressupõe-se que, se você disser .stateEntry (), por exemplo, a Ação seja executada diretamente na entrada, mas se você disser .state (), a Ação deverá ser executada no estado de destino, mas não é tão importante quando.
Em nosso projeto, configuramos todas as ações na configuração de transição, pois você pode pendurá-las várias de cada vez.

A versão final da configuração ficará assim:
 @Configuration @EnableStateMachineFactory public class StateMachineConfig extends EnumStateMachineConfigurerAdapter<PurchaseState, PurchaseEvent> { @Override public void configure(final StateMachineConfigurationConfigurer<PurchaseState, PurchaseEvent> config) throws Exception { config .withConfiguration() .autoStartup(true) .listener(new PurchaseStateMachineApplicationListener()); } @Override public void configure(final StateMachineStateConfigurer<PurchaseState, PurchaseEvent> states) throws Exception { states .withStates() .initial(NEW) .end(PURCHASE_COMPLETE) .stateEntry() .stateExit() .state() .states(EnumSet.allOf(PurchaseState.class)); } @Override public void configure(final StateMachineTransitionConfigurer<PurchaseState, PurchaseEvent> transitions) throws Exception { transitions .withExternal() .source(NEW) .target(RESERVED) .event(RESERVE) .action(reservedAction(), errorAction()) .and() .withExternal() .source(RESERVED) .target(CANCEL_RESERVED) .event(RESERVE_DECLINE) .action(cancelAction(), errorAction()) .and() .withExternal() .source(RESERVED) .target(PURCHASE_COMPLETE) .event(BUY) .guard(hideGuard()) .action(buyAction(), errorAction()); } @Bean public Action<PurchaseState, PurchaseEvent> reservedAction() { return new ReservedAction(); } @Bean public Action<PurchaseState, PurchaseEvent> cancelAction() { return new CancelAction(); } @Bean public Action<PurchaseState, PurchaseEvent> buyAction() { return new BuyAction(); } @Bean public Action<PurchaseState, PurchaseEvent> errorAction() { return new ErrorAction(); } @Bean public Guard<PurchaseState, PurchaseEvent> hideGuard() { return new HideGuard(); } @Bean public StateMachinePersister<PurchaseState, PurchaseEvent, String> persister() { return new DefaultStateMachinePersister<>(new PurchaseStateMachinePersister()); } 

Preste atenção ao esquema da máquina, é muito claramente visível o que exatamente codificamos (quais transições em quais eventos são válidos, qual Guard protege o status e o que será executado quando o status for alternado, qual ação).

Vamos fazer o controlador:
 @RestController @SuppressWarnings("unused") public class PurchaseController { private final PurchaseService purchaseService; public PurchaseController(PurchaseService purchaseService) { this.purchaseService = purchaseService; } @RequestMapping(path = "/reserve") public boolean reserve(final String userId, final String productId) { return purchaseService.reserved(userId, productId); } @RequestMapping(path = "/cancel") public boolean cancelReserve(final String userId) { return purchaseService.cancelReserve(userId); } @RequestMapping(path = "/buy") public boolean buyReserve(final String userId) { return purchaseService.buy(userId); } } 


interface de serviço
 public interface PurchaseService { /** *    ,          * * @param userId id ,    ,      id  *    http- * @param productId id ,     * @return /  ,             *      . */ boolean reserved(String userId, String productId); /** *   /    * * @param userId id ,    ,      id  *    http- * @return /  ,             *      . */ boolean cancelReserve(String userId); /** *     * * @param userId id ,    ,      id  *    http- * @return /  ,             *      . */ boolean buy(String userId); } 

Ancinho
Você sabe por que é importante criar bean através da interface ao trabalhar com o Spring? Diante deste problema (bem, sim, sim, e Zhenya Borisov falou no estripador), quando uma vez no controlador, eles tentaram implementar uma interface improvisada e não vazia. O Spring cria um proxy para componentes e, se o componente não implementa nenhuma interface, ele o faz por meio do CGLIB, mas assim que você implementa alguma interface - o Spring tenta criar um proxy por meio de um proxy dinâmico, como resultado, você obtém um tipo de objeto incompreensível e NoSuchBeanDefinitionException .

O próximo ponto importante é como você restaurará o estado da sua máquina, pois a cada chamada será criado um novo bean que não sabe nada sobre seus status anteriores da máquina e seu contexto.
Para esses fins, a máquina de estado da mola possui um mecanismo Persistens:
 public class PurchaseStateMachinePersister implements StateMachinePersist<PurchaseState, PurchaseEvent, String> { private final HashMap<String, StateMachineContext<PurchaseState, PurchaseEvent>> contexts = new HashMap<>(); @Override public void write(final StateMachineContext<PurchaseState, PurchaseEvent> context, String contextObj) { contexts.put(contextObj, context); } @Override public StateMachineContext<PurchaseState, PurchaseEvent> read(final String contextObj) { return contexts.get(contextObj); } } 

Para nossa implementação ingênua, usamos o Mapa habitual como um armazenamento de estado; em uma implementação não ingênua, será algum tipo de banco de dados, preste atenção ao terceiro tipo genérico String, esta é a chave pela qual o estado da sua máquina será salvo, com todos os status, variáveis ​​no contexto, id e assim por diante. No meu exemplo, usei o ID do usuário para a chave de salvamento, que pode ser absolutamente qualquer chave (usuário session_id, login exclusivo etc.).
Ancinho
Em nosso projeto, o mecanismo para salvar e restaurar estados da caixa não nos convinha, pois armazenávamos os status da máquina no banco de dados e podiam ser alterados por um trabalho que não sabia nada sobre a máquina.
Eu tive que fixar o status recebido do banco de dados, executar alguma InitAction que, quando a máquina é iniciada, recebeu o status do banco de dados e defini-lo à força, e só então lançou o evento, um exemplo de código que faz o seguinte:
 stateMachine .getStateMachineAccessor() .doWithAllRegions(access -> { access.resetStateMachine(new DefaultStateMachineContext<>({ResetState}, null, null, null, null)); }); stateMachine.start(); stateMachine.sendEvent({NewEventFromResetState}); 


Consideraremos a implementação do serviço em cada método:
  @Override public boolean reserved(final String userId, final String productId) { final StateMachine<PurchaseState, PurchaseEvent> stateMachine = stateMachineFactory.getStateMachine(); stateMachine.getExtendedState().getVariables().put("PRODUCT_ID", productId); stateMachine.sendEvent(RESERVE); try { persister.persist(stateMachine, userId); } catch (final Exception e) { e.printStackTrace(); return false; } return true; } 

Pegamos o carro na fábrica, colocamos um parâmetro no contexto da máquina; no nosso caso, é um productId, o contexto é um tipo de caixa na qual você pode colocar tudo o que precisa, sempre que houver acesso ao bean de máquina de estado ou ao seu contexto, pois a máquina inicia automaticamente quando o contexto é iniciado , depois do início, nosso carro estará no status NOVO, lance o evento na reserva de mercadorias.

Os dois métodos restantes são semelhantes:
  @Override public boolean cancelReserve(final String userId) { final StateMachine<PurchaseState, PurchaseEvent> stateMachine = stateMachineFactory.getStateMachine(); try { persister.restore(stateMachine, userId); stateMachine.sendEvent(RESERVE_DECLINE); } catch (Exception e) { e.printStackTrace(); return false; } return true; } @Override public boolean buy(final String userId) { final StateMachine<PurchaseState, PurchaseEvent> stateMachine = stateMachineFactory.getStateMachine(); try { persister.restore(stateMachine, userId); stateMachine.sendEvent(BUY); } catch (Exception e) { e.printStackTrace(); return false; } return true; } 

Aqui, primeiro restauramos o estado da máquina para o userId de um usuário específico e, em seguida, lançamos um evento que corresponde ao método api.
Observe que productId não aparece mais no método, o adicionamos ao contexto da máquina e o obtemos após a restauração da máquina a partir do backup.
Na implementação Action, obteremos o ID do produto no contexto da máquina e exibiremos uma mensagem correspondente à transição no log; por exemplo, darei o código ReservedAction:
 public class ReservedAction implements Action<PurchaseState, PurchaseEvent> { @Override public void execute(StateContext<PurchaseState, PurchaseEvent> context) { final String productId = context.getExtendedState().get("PRODUCT_ID", String.class); System.out.println("   " + productId + " ."); } } 

Não podemos deixar de mencionar o ouvinte, que prontamente oferece alguns scripts nos quais você pode aguentar, veja por si mesmo:
 public class PurchaseStateMachineApplicationListener implements StateMachineListener<PurchaseState, PurchaseEvent> { @Override public void stateChanged(State<PurchaseState, PurchaseEvent> from, State<PurchaseState, PurchaseEvent> to) { if (from.getId() != null) { System.out.println("   " + from.getId() + "   " + to.getId()); } } @Override public void stateEntered(State<PurchaseState, PurchaseEvent> state) { } @Override public void stateExited(State<PurchaseState, PurchaseEvent> state) { } @Override public void eventNotAccepted(Message<PurchaseEvent> event) { System.out.println("   " + event); } @Override public void transition(Transition<PurchaseState, PurchaseEvent> transition) { } @Override public void transitionStarted(Transition<PurchaseState, PurchaseEvent> transition) { } @Override public void transitionEnded(Transition<PurchaseState, PurchaseEvent> transition) { } @Override public void stateMachineStarted(StateMachine<PurchaseState, PurchaseEvent> stateMachine) { System.out.println("Machine started"); } @Override public void stateMachineStopped(StateMachine<PurchaseState, PurchaseEvent> stateMachine) { } @Override public void stateMachineError(StateMachine<PurchaseState, PurchaseEvent> stateMachine, Exception exception) { } @Override public void extendedStateChanged(Object key, Object value) { } @Override public void stateContext(StateContext<PurchaseState, PurchaseEvent> stateContext) { } } 

O único problema é que essa é uma interface, o que significa que você precisa implementar todos esses métodos, mas como é improvável que precise de todos eles, alguns ficarão vazios, cuja cobertura dirá que os métodos não são cobertos por testes.
Aqui no lisener, podemos pendurar absolutamente quaisquer métricas em eventos completamente diferentes da máquina (por exemplo, os pagamentos não passam, a máquina geralmente entra em algum tipo de status PAYMENT_FAIL, ouvimos transições e, se a máquina entrar em um status incorreto - escrevemos, em um log estranho, ou base ou chame a polícia, o que for).
Ancinho
Há um evento stateMachineError no lisener-e, mas com uma nuance, quando você tem uma exceção e a manipula na captura, a máquina não considera que houve um erro, é necessário falar explicitamente na captura
stateMachine.setStateMachineError (exceção) e transmite um erro.

Como uma verificação do que fizemos, executaremos dois casos:
  • 1. Reserva e subsequente rejeição da compra. Enviaremos ao aplicativo uma solicitação para o URI "/ reserve", com os parâmetros userId = 007, productId = 10001, e depois a solicitação "/ cancel" com o parâmetro userId = 007, a saída do console será a seguinte:
    Machine started
    10001 .
    NEW RESERVED
    Machine started
    10001
    RESERVED CANCEL_RESERVED
  • 2. Reserva e compra bem-sucedida:
    Machine started
    10001 .
    NEW RESERVED
    Machine started
    10001
    RESERVED PURCHASE_COMPLETE

Conclusão


Concluindo, darei um exemplo de teste da estrutura, acho que tudo ficará claro a partir do código, você só precisa de uma dependência da máquina de teste e pode verificar a configuração declarativamente.
  @Test public void testWhenReservedCancel() throws Exception { StateMachine<PurchaseState, PurchaseEvent> machine = factory.getStateMachine(); StateMachineTestPlan<PurchaseState, PurchaseEvent> plan = StateMachineTestPlanBuilder.<PurchaseState, PurchaseEvent>builder() .defaultAwaitTime(2) .stateMachine(machine) .step() .expectStates(NEW) .expectStateChanged(0) .and() .step() .sendEvent(RESERVE) .expectState(RESERVED) .expectStateChanged(1) .and() .step() .sendEvent(RESERVE_DECLINE) .expectState(CANCEL_RESERVED) .expectStateChanged(1) .and() .build(); plan.test(); } @Test public void testWhenPurchaseComplete() throws Exception { StateMachine<PurchaseState, PurchaseEvent> machine = factory.getStateMachine(); StateMachineTestPlan<PurchaseState, PurchaseEvent> plan = StateMachineTestPlanBuilder.<PurchaseState, PurchaseEvent>builder() .defaultAwaitTime(2) .stateMachine(machine) .step() .expectStates(NEW) .expectStateChanged(0) .and() .step() .sendEvent(RESERVE) .expectState(RESERVED) .expectStateChanged(1) .and() .step() .sendEvent(BUY) .expectState(PURCHASE_COMPLETE) .expectStateChanged(1) .and() .build(); plan.test(); } 

Ancinho
Se de repente você quiser testar sua máquina sem elevar o contexto com os testes de unidade usuais, poderá criar uma máquina através do construtor (discutido parcialmente acima), criar uma instância da classe com uma configuração e obter ação e proteção a partir daí, ela funcionará sem contexto, você pode escrever um pequeno teste A estrutura é falsa; será uma vantagem verificar quais ações foram chamadas, quais não são, quantas vezes etc., em casos diferentes.

PS


Nossa máquina está trabalhando de forma produtiva; até agora não encontramos nenhum problema operacional, está chegando um recurso no qual podemos usar a grande maioria dos componentes da máquina atual ao implementar uma nova (a proteção e algumas ações são perfeitas)

Nota


Não considerei isso no artigo, mas quero mencionar oportunidades como escolha, esse é um tipo de gatilho que funciona com o princípio de troca, onde os guardas estão pendurados nos casos, e a máquina tenta alternadamente ir para esse estado, que é descrito na configuração de opções e para onde o guarda deixará ir, sem alguns eventos, é conveniente quando, ao inicializar a máquina, precisamos mudar automaticamente para algum tipo de pseudo-host.

Referências


Doca
Sources

Source: https://habr.com/ru/post/pt462371/


All Articles