Primeiros passos com Spring State Machine

Este artigo tem como objetivo apresentar a Spring State Machine como uma opção de desenvolvimento de máquinas de estados de entidades

*Por Leon Watanabe, Software Developer na Netshoes

No desenvolvimento de sistemas muitas vezes nos deparamos com a necessidade de modelar objetos ou conceitos que possuem estados bem definidos e suas transições. Contudo essa definição de estado e suas transições acabam sendo desenvolvidas de forma descentralizada, ou seja, as mudanças de estados ocorrem de forma pulverizada no sistema, dificultando o entendimento do fluxo de mudança de estados do objeto e, até mesmo, de evoluções, seja com a inclusão ou remoção de um novo estado, seja com a alteração de um fluxo já existente.

Uma conhecida solução para este problema é o uso de máquinas de estados finita (FSM — Finite State Machine) que também é implementada pelo Spring Framework: a Spring State Machine.

Spring State Machine: Introdução

A seguir, serão apresentados alguns recursos básicos da state machine, desde sua configuração inicial até a execução da máquina de estados.

No exemplo a seguir, utilizaremos o gerenciador de dependências Maven. Para começar, será preciso criar um projeto java maven e incluir a dependência do Spring State Machine ao pom.xml do projeto.

<dependencies>
<dependency>
<groupId>org.springframework.statemachine</groupId>
<artifactId>spring-statemachine-core</artifactId>
<version>2.1.0.RELEASE</version>
</dependency>
</dependencies>

Neste exemplo, será desenvolvida uma máquina de estados simplificada para representar o fluxo de estados de um pedido de compras:

Conforme apresentado no diagrama anterior, os estados possíveis da máquina de estados são: CREATED, APPROVED, CANCELLED, INVOICED, SHIPPED e DELIVERED. Para isso, vamos criar um enum para definir esses valores.

public enum OrderStates {
CREATED, APPROVED, INVOICED, CANCELLED, SHIPPED, DELIVERED
}

Além dos estados, a máquina de estados possui os eventos responsáveis pelas transições de estados. Os eventos são: CONFIRMED_PAYMENT, INVOICE_ISSUED, CANCEL, SHIP, DELIVER. Novamente, vamos utilizar um enum para representar esses valores:

public enum OrderEvents {
CONFIRMED_PAYMENT, INVOICE_ISSUED, CANCEL, SHIP, DELIVER
}

Uma vez com os estados e eventos definidos, podemos configurar nossa máquina de estados. Para isso, vamos criar a seguinte classe de configuração:

@Configuration
@EnableStateMachine
public class OrderStateMachineTransitionByEventConfig extends EnumStateMachineConfigurerAdapter<OrderStates, OrderEvents> {

Esta classe configura a máquina de estados baseada nos estados definidos no enum de estados OrderStates e no enum de eventos OrderEvents. A classe EnumStateMachineConfigurerAdapter possui vários métodos de configuração. Para este exemplo, vamos utilizar apenas a configuração de inicialização, a de configuração dos estados e a de configuração de suas transições.

No método abaixo, vamos configurar o startup automático e incluir um listener para identificar as transições de estados.

@Override
public void configure(StateMachineConfigurationConfigurer<OrderStates, OrderEvents> config)
throws Exception {
config
.withConfiguration()
.autoStartup(true)
.listener(listener());
}

O método a seguir configura o estado inicial (CREATED), além dos demais estados, ou seja, todos os valores que compõe o enum OrderStates.

@Override
public void configure(StateMachineStateConfigurer<OrderStates, OrderEvents> states) throws Exception {
states
.withStates()
.initial(OrderStates.CREATED)
.states(EnumSet.allOf(OrderStates.class));
}

Para definir as transições, a configuração a seguir utiliza a seguinte sequência lógica: dado um estado de origem (source), deseja-se mudar para o estado de destino (target) quando ocorrer um determinado evento (event). Por exemplo, para ocorrer a transição de estados entre CREATED (source) para APPROVED (target), o evento CONFIRMED_PAYMENT(event) deve ocorrer.

@Override
public void configure(StateMachineTransitionConfigurer<OrderStates, OrderEvents> transitions) throws Exception {
transitions
.withExternal()
.source(OrderStates.CREATED).target(OrderStates.APPROVED)
.event(OrderEvents.CONFIRMED_PAYMENT)
.and().withExternal()
.source(OrderStates.APPROVED).target(OrderStates.INVOICED)
.event(OrderEvents.INVOICE_ISSUED)
.and().withExternal()
.source(OrderStates.APPROVED).target(OrderStates.CANCELLED)
.event(OrderEvents.CANCEL)
.and().withExternal()
.source(OrderStates.INVOICED).target(OrderStates.SHIPPED)
.event(OrderEvents.SHIP)
.and().withExternal()
.source(OrderStates.SHIPPED).target(OrderStates.DELIVERED)
.event(OrderEvents.DELIVER)
;
}

No seguinte trecho, vamos implementar o listener responsável por verificar a mudança de estados.

@Bean
public StateMachineListener<OrderStates, OrderEvents> listener() {
return new StateMachineListenerAdapter<OrderStates, OrderEvents>() {
@Override
public void stateChanged(State<OrderStates, OrderEvents> from, State<OrderStates, OrderEvents> to) {
System.out.println("OrderState change from " + from.getId() + " to " + to.getId());
}
};
}
}

Para testarmos nossa máquina de estados, vamos criar a seguinte classe:

@SpringBootApplication
public class Application implements CommandLineRunner {
@Autowired
private StateMachine<OrderStates, OrderEvents> stateMachine;
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
@Override
public void run(String... args) {
System.out.println("Iniciando máquina de estados...");
stateMachine.sendEvent(OrderEvents.CONFIRMED_PAYMENT);
stateMachine.sendEvent(OrderEvents.INVOICE_ISSUED);
stateMachine.sendEvent(OrderEvents.SHIP);
stateMachine.sendEvent(OrderEvents.DELIVER);
System.out.println("Máquina de estados finalizada");
}
}

Esta classe de teste basicamente recebe uma máquina de estados e testa suas transições de acordo com o evento recebido. Por exemplo, a máquina nasce no estado CREATED. Quando é informado o primeiro evento, OrderEvents.CONFIRMED_PAYMENT, a máquina de estados muda para o próximo estado: APPROVED.

Como resultado da execução deste trecho de código temos:

Iniciando máquina de estados…
Iniciando máquina de estados…
OrderState change from CREATED to APPROVED
OrderState change from APPROVED to INVOICED
OrderState change from INVOICED to SHIPPED
OrderState change from SHIPPED to DELIVERED
Máquina de estados finalizada

Incluindo uma ação

Podemos incrementar nossa máquina de estados associando ações de entrada e/ou saída do estado. Por exemplo, podemos incluir uma ação de envio de email quando o estado de SHIPPED for alcançado. Para isso, vamos modificar a definição dos estados e incluir a ação de entrada no estado SHIPPED. Note: a ação de entrada será uma action (sendEmail) e a ação de saída explicitamos como “null” (na prática, podemos omitir esse segundo parâmetro — fizemos isso apenas para esclarecer que é possível informar a ação de saída neste ponto).

@Override
public void configure(StateMachineStateConfigurer<OrderStates, OrderEvents> states) throws Exception {
states
.withStates()
.initial(OrderStates.CREATED)
.state(OrderStates.CREATED)
.state(OrderStates.APPROVED)
.state(OrderStates.CANCELLED)
.state(OrderStates.INVOICED)
.state(OrderStates.SHIPPED, sendEmail(), null)
.state(OrderStates.DELIVERED)
;
}
@Bean
public Action<OrderStates, OrderEvents> sendEmail() {
return context -> System.out.println("Email para informar envio do pedido");
}

Modificamos o método main para não efetuar o evento de DELIVER e testamos novamente:

@Override
public void run(String... args) {
System.out.println("Iniciando máquina de estados...");
stateMachine.sendEvent(OrderEvents.CONFIRMED_PAYMENT);
stateMachine.sendEvent(OrderEvents.INVOICE_ISSUED);
stateMachine.sendEvent(OrderEvents.SHIP);
System.out.println("Máquina de estados finalizada");
}

Como saída temos:

Iniciando máquina de estados…
OrderState change from CREATED to APPROVED
OrderState change from APPROVED to INVOICED
Email para informar envio do pedido
OrderState change from INVOICED to SHIPPED
Máquina de estados finalizada

Como podemos constatar, a ação é executada ao entrar no estado SHIPPED.

Condição de guarda

Podemos incluir também uma condição de guarda para a transição de estados. Ou seja, a transição de estado só ocorrerá se respeitar a condição de guarda. Como exemplo, podemos incluir a seguinte condição de guarda para a transição APPROVED → INVOICED: só será permitido emitir a fatura em dias úteis.

Primeiramente, vamos alterar a máquina de estados para incluir a condição de guarda através do método “guard”.

@Override
public void configure(StateMachineTransitionConfigurer<OrderStates, OrderEvents> transitions) throws Exception {
transitions
//(...)
.source(OrderStates.APPROVED).target(OrderStates.INVOICED)
.event(OrderEvents.INVOICE_ISSUED)
.guard(onlyWorkingDays())
.and().withExternal()
//(...)
;
}
@Bean
public Guard<OrderStates, OrderEvents> onlyWorkingDays() {
return context -> !EnumSet.of(DayOfWeek.SATURDAY, DayOfWeek.SUNDAY).contains(
((LocalDate) context.getMessage().getHeaders().get("day")).getDayOfWeek());
}

Para testarmos a condição de guarda, vamos alterar o método run novamente da classe de testes para passar a data na mensagem do contexto da máquina de estados. Como nosso evento é um enum e, consequentemente, nosso payload da mensagem é um enum, estamos passando a informação na header da mensagem. Mas, o evento poderia ser um objeto complexo contendo essa informação se fosse necessário.

@Override
public void run(String... args) {
System.out.println("Iniciando máquina de estados...");
stateMachine.sendEvent(OrderEvents.CONFIRMED_PAYMENT);
stateMachine.sendEvent(new Message<OrderEvents>() {
@Override
public OrderEvents getPayload() {
return OrderEvents.INVOICE_ISSUED;
}
@Override
public MessageHeaders getHeaders() {
final Map<String, Object> params = new HashMap<>();
final LocalDate saturday = LocalDate.of(2019, 2, 2);
params.put("day", saturday);
return new MessageHeaders(params);
}
});
System.out.println("Máquina de estados finalizada");
}

Neste último exemplo, passamos para o método sendEvent uma mensagem com a informação do dia para que a condição de guarda possa tomar uma decisão: de permitir ou não a mudança de estados. Neste caso, estamos passando um valor inválido (sábado). Como podemos conferir o resultado abaixo:

Iniciando máquina de estados…
OrderState change from CREATED to APPROVED
Máquina de estados finalizada

Devido à condição de guarda não ter sido atendida, a máquina de estados não mudou para INVOICED embora o evento INVOICE_ISSUED tenha sido informado.

Concluindo…

A ideia deste artigo não é a de apresentar todos os recursos da Spring State Machine, obviamente. Temos a documentação referência e vários exemplos disponíveis no repositório do Spring State Machine. Entre os diversos recursos que ela dispõe, podemos citar a persistência da máquina de estados, sub-estados, “fork” e “join” do fluxo de estados, etc. Como visto, a Spring State Machine se apresenta como uma opção interessante de solução para a implementação de máquina de estados.

Apêndice:

Alguns conceitos da máquina de estados são definidos a seguir:

  • Estado: é a principal entidade da máquina de estados onde suas transições são dirigidas por eventos. Representa um modelo em que a máquina de estados pode permanecer.
  • Transição: é a relação entre um estado origem e um estado alvo.
  • Evento: é uma entidade que é enviada para a máquina de estados que determina, a partir de um estado origem, a mudança de estado.
  • Condição de guarda: é uma expressão booleana avaliada dinamicamente que afeta o comportamento da máquina de estados, habilitando ações e transições apenas quando ela for avaliada como verdadeira.
  • Ação: é um comportamento executado durante o disparo de uma transição. Ela pode ser uma ação de entrada, isto é, a ação que é executada ao entrar no estado; ou de saída, isto é, a ação que é executada ao sair do estado.

Exemplos:
Exemplo utilizado no artigo

Exemplo sem utilizar máquina de estados

Máquina de estados persistida no MongoDB

Bibliografia:

PROJECTS — Spring Statemachine

Spring Statemachine — Reference Documentation

Github — StateMachine

Entre para nosso time