Entrada
En los proyectos me encontré con tres ejemplos, uno u otro relacionado con la
teoría de autómatas finitos.- Ejemplo 1. Un código de
govnokod entretenido . Lleva mucho tiempo entender lo que está sucediendo. Un rasgo característico de la realización de la teoría indicada en el código es un volcado bastante feroz que a veces se parece mucho a un código de procedimiento. El hecho de que esta versión del código sea mejor para no tocar el proyecto lo sabe todo tecnólogo, metodólogo y especialista en productos. Entran en este código para arreglar algo en caso de emergencia (cuando está completamente roto), no se trata de finalizar ninguna función. Porque da miedo romper. La segunda característica sorprendente que aísla este tipo es la presencia de interruptores tan potentes, pantalla completa.
Incluso hay una broma sobre este puntaje:
Tamaño óptimoEn uno de los JPoint, uno de los oradores, quizás Nikolai Alimenkov, habló sobre cuántos casos en el cambio son normales, dijo que la respuesta principal es "hasta ahora encaja en la pantalla". En consecuencia, si interfiere y su cambio ya no es normal, tome y reduzca el tamaño de fuente en el IDE
- Ejemplo 2. Estado del patrón . La idea principal (para aquellos a quienes no les gusta seguir enlaces) es dividir una determinada tarea comercial en un conjunto de estados finales y describirlos con código.
El principal inconveniente de Pattern State es que los estados se conocen entre sí, saben que hay hermanos y se llaman entre sí. Tal código es bastante difícil de hacer universal. Por ejemplo, cuando implementa un sistema de pago con varios tipos de pagos, corre el riesgo de excavar tanto en Generic-s que la declaración de sus métodos puede convertirse en algo como esto:
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){
Resumen del estado: una implementación puede resultar en un código bastante complicado. - Ejemplo 3 StateMachine La idea principal de Pattern es que los estados no se conocen entre sí, el control de transición se lleva a cabo por el contexto, es mejor, menos conectividad: el código es más simple.
Habiendo experimentado todo el "poder" del primer tipo y la complejidad del segundo, decidimos usar Pattern StateMachine para el nuevo caso de negocios.
Para no reinventar la rueda, se decidió tomar Statemachine Spring como base (esto es Spring).
Después de leer los muelles, fui a YouTube y Habr (para entender cómo la gente trabaja con él, cómo se siente en el producto, qué tipo de rastrillo, etc.) Resultó que hay poca información, en YouTube hay un par de videos, todos son bastante superficiales. En Habré sobre este tema encontré solo un artículo, así como el video, bastante superficial.
En un artículo, es imposible describir todas las sutilezas del trabajo de Spring statemachine, dar la vuelta al muelle y describir todos los casos, pero trataré de decir lo más importante y lo que se demanda, y sobre el rastrillo, específicamente para mí, cuando conocí el marco, la información a continuación fue Sería muy útil.
Cuerpo principal
Crearemos una aplicación Spring Boot, agregaremos un iniciador web (conseguiremos que la aplicación web se ejecute lo más rápido posible). La aplicación será una abstracción del proceso de compra. El producto en el momento de la compra pasará por las etapas de rechazo nuevo, reservado, reservado y compra completa.
Un poco de improvisación, habría más estados en un proyecto real, pero bueno, también tenemos un proyecto muy real.
En el pom.xml de la aplicación web recién horneada, agregue la dependencia en la máquina y en las pruebas para ello (Web Starter ya debería ser, si se recopila a través de
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 />
Crea la estructura:

Todavía no tengo que entrar en detalles de esta estructura, explicaré todo en secuencia y habrá un enlace a la fuente al final del artículo.
Entonces vamos.
Tenemos un proyecto limpio con las dependencias necesarias, primero creamos enumeración, con estados y eventos, una abstracción bastante simple, estos componentes en sí mismos no tienen ninguna lógica.
public enum PurchaseEvent { RESERVE, BUY, RESERVE_DECLINE }
public enum PurchaseState { NEW, RESERVED, CANCEL_RESERVED, PURCHASE_COMPLETE }
Aunque formalmente, puede agregar campos a estas enumeraciones y codificar algo en ellos que sea característico de, por ejemplo, un estado particular, que es bastante lógico (lo hicimos resolviendo nuestro caso, de manera bastante conveniente).
Configuraremos la máquina a través de la configuración de Java, crearemos el archivo de configuración y, para la clase extendida EnumStateMachineConfigurerAdapter <PurchaseState, PurchaseEvent>. Dado que nuestro estado y evento es enum, la interfaz es apropiada, pero no es necesaria, cualquier tipo de objeto puede usarse como genérico (no consideraremos otros ejemplos en el artículo, ya que EnumStateMachineConfigurerAdapter es más que suficiente en mi opinión).
El siguiente punto importante es si una máquina vivirá en el contexto de la aplicación: en una sola instancia de @EnableStateMachine, o cada vez que se cree una nueva @EnableStateMachineFactory. Si se trata de una aplicación web multiusuario con un grupo de usuarios, entonces la primera opción no es adecuada para usted, por lo que utilizaremos la segunda como la más popular. StateMachine también se puede crear a través del generador como un bean normal (consulte la documentación), lo cual es conveniente en algunos casos (por ejemplo, necesita que la máquina se declare explícitamente como un bean), y si es un bean separado, podemos decirle nuestro alcance Por ejemplo, sesión o solicitud. En nuestro proyecto, se implementó el contenedor (características de nuestra lógica de negocios) sobre el bean statemachine, el contenedor era singleton y la máquina prototipo en sí
Rastrillo¿Cómo implementar un prototipo en singlton?
De hecho, todo lo que necesita hacer es obtener un nuevo bean del applicationContext cada vez que acceda al objeto. Inyectar un contexto de aplicación en la lógica de negocios es un pecado, por lo tanto, una máquina de estado de bean debe implementar una interfaz con al menos un método o un método abstracto (inyección de método), al crear una configuración de Java, deberá implementar el método abstracto indicado, y en la implementación sacaremos de applicationContext nuevo bean. Es una práctica normal tener un enlace al applicationContext en la clase config, y a través del método abstracto llamaremos a .getBean ();
La clase EnumStateMachineConfigurerAdapter tiene varios métodos, anulando los que configuramos la máquina.
Para comenzar, registre los estados:
@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) es el estado en el que estará la máquina después de que se haya creado el bean, end (PURCHASE_COMPLETE) es el estado en el que la máquina ejecuta statemachine.stop (), para una máquina no determinista (la mayoría de las cuales) es irrelevante, pero algo debe especificarse . .states (EnumSet.allOf (PurchaseState.class) lista de todos los estados, puede empujar en masa.
Configure las configuraciones globales de la máquina
@Override public void configure(final StateMachineConfigurationConfigurer<PurchaseState, PurchaseEvent> config) throws Exception { config .withConfiguration() .autoStartup(true) .listener(new PurchaseStateMachineApplicationListener()); }
Aquí, el inicio automático determina si la máquina se iniciará inmediatamente después de la creación de forma predeterminada, en otras palabras, si cambiará automáticamente al estado NUEVO (falso de forma predeterminada). Inmediatamente, registramos un oyente para el contexto de la máquina (al respecto un poco más tarde), en la misma configuración puede establecer un TaskExecutor separado, lo cual es conveniente cuando se realiza una Acción larga en algunas de sus transiciones, y la aplicación debería ir más allá.
Bueno, las transiciones en sí mismas:
@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()); }
Aquí se configura toda la lógica de las transiciones o transiciones, Guard se puede colgar en las transiciones, un componente que siempre devuelve boolean, qué es exactamente lo que verificará en la transición de un estado a otro a su discreción, cualquier lógica puede ser perfecta en Guard, este es un componente completamente ordinario pero debe volver booleano. Dentro del marco de nuestro proyecto, por ejemplo, HideGuard puede verificar una determinada configuración que el usuario podría establecer (no mostrar este producto) y, de acuerdo con él, no dejar que la máquina entre al estado protegido por Guard. Observo que Guard, solo se puede agregar uno a una transición en la configuración, tal diseño no funcionará:
.withExternal() .source(RESERVED) .target(PURCHASE_COMPLETE) .event(BUY) .guard(hideGuard()) .guard(veryHideGuard())
Más precisamente funcionará, pero solo el primer guardia (hideGuard ())
Pero puede agregar varias Acciones (ahora estamos hablando de Acción, que prescribimos en la configuración de las transiciones), personalmente intenté agregar tres Acciones a una transición.
.withExternal() .source(NEW) .target(RESERVED) .event(RESERVE) .action(reservedAction(), errorAction())
el segundo argumento es ErrorAction, el control lo obtendrá si ReservedAction produce una excepción (throw ).
RastrilloTenga en cuenta que si en su Acción aún maneja el error a través de try / catch, entonces no entrará en ErrorAction, si necesita procesar y entrar en ErrorAction, entonces debería lanzar RuntimeException () desde catch, por ejemplo (usted mismo dijo que es muy necesario).
Además de "colgar" la acción en las transiciones, también puede "colgarlas" en el método de configuración de estado, aproximadamente en la siguiente 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)); }
Todo depende de cómo quieras ejecutar la acción.
RastrilloTenga en cuenta que si especifica una acción al configurar el estado (), así
states .withStates() .initial(NEW) .end(PURCHASE_COMPLETE) .state(randomAction())
se ejecutará de forma asincrónica, se supone que si dice .stateEntry (), por ejemplo, la Acción debe ejecutarse directamente en la entrada, pero si dice .state (), la Acción debe ejecutarse en el estado de destino, pero no es tan importante cuando.
En nuestro proyecto, configuramos todas las acciones en la configuración de transición, ya que puede colgarlas varias a la vez.
La versión final de la configuración se verá así:
@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 atención al esquema de la máquina, es muy claramente visible en lo que codificamos exactamente (qué transiciones en qué eventos son válidos, qué Guard protege el estado y qué se hará cuando se cambie el estado, qué Acción).

Hagamos el 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); } }
interfaz de servicio
public interface PurchaseService { boolean reserved(String userId, String productId); boolean cancelReserve(String userId); boolean buy(String userId); }
Rastrillo¿Sabes por qué es importante crear bean a través de la interfaz cuando trabajas con Spring? Enfrenté este problema (bueno, sí, sí, y Zhenya Borisov lo dijo en el destripador), cuando una vez en el controlador intentaron implementar una interfaz no vacía improvisada. Spring crea un proxy para los componentes, y si el componente no implementa ninguna interfaz, lo hará a través de CGLIB, pero tan pronto como implemente alguna interfaz, Spring intentará crear un proxy a través de un proxy dinámico, como resultado obtendrá un tipo de objeto incomprensible y NoSuchBeanDefinitionException .
El siguiente punto importante es cómo va a restaurar el estado de su máquina, porque para cada llamada se creará un nuevo bean que no sabe nada sobre sus estados anteriores de la máquina y su contexto.
Para estos propósitos, spring statemachine tiene un 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 nuestra implementación ingenua, usamos el Mapa habitual como un almacén de estado, en una implementación no ingenua será una especie de base de datos, preste atención al tercer tipo genérico de cadena, esta es la clave por la cual se guardará el estado de su máquina, con todos los estados, variables en el contexto, identificación Y así sucesivamente. En mi ejemplo, utilicé la identificación de usuario para la clave de guardar, que puede ser absolutamente cualquier clave (usuario session_id, inicio de sesión único, etc.).
RastrilloEn nuestro proyecto, el mecanismo para guardar y restaurar estados desde la caja no nos convenía, ya que almacenamos los estados de la máquina en la base de datos y podría ser cambiado por un trabajo que no sabía nada sobre la máquina.
Tuve que fijarme en el estado recibido de la base de datos, hacer algo de InitAction que, cuando se inicia la máquina, recibió el estado de la base de datos y configurarlo por la fuerza, y solo entonces arrojó un evento, un ejemplo de código que hace lo anterior:
stateMachine .getStateMachineAccessor() .doWithAllRegions(access -> { access.resetStateMachine(new DefaultStateMachineContext<>({ResetState}, null, null, null, null)); }); stateMachine.start(); stateMachine.sendEvent({NewEventFromResetState});
Consideraremos la implementación del servicio en 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; }
Obtenemos el automóvil de fábrica, ponemos un parámetro en el contexto de la máquina, en nuestro caso es un ID de producto, el contexto es una especie de caja en la que puede poner todo lo que necesita, donde haya acceso al bean de máquina de estado o su contexto, ya que la máquina se inicia automáticamente cuando se inicia el contexto , luego, después del inicio, nuestro automóvil estará en el estado NUEVO, realice el evento para reservar la mercancía.
Los dos métodos restantes son similares:
@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; }
Aquí primero restauramos el estado de la máquina para el ID de usuario de un usuario en particular, y luego lanzamos un evento que corresponde al método api.
Tenga en cuenta que productId ya no aparece en el método, lo agregamos al contexto de la máquina y lo obtendremos después de restaurar la máquina desde su copia de seguridad.
En la implementación de Action, obtendremos la identificación del producto del contexto de la máquina y mostraremos un mensaje correspondiente a la transición en el registro, por ejemplo, le daré el 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 + " ."); } }
No podemos dejar de mencionar al oyente, que de forma inmediata ofrece bastantes scripts que puedes esperar, compruébalo por ti mismo:
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) { } }
El único problema es que esta es una interfaz, lo que significa que debe implementar todos estos métodos, pero como es poco probable que los necesite a todos, algunos quedarán vacíos, lo que indicará que los métodos no están cubiertos por las pruebas.
Aquí en lisener podemos colgar absolutamente cualquier métrica en eventos completamente diferentes de la máquina (por ejemplo, los pagos no pasan, la máquina a menudo entra en algún tipo de estado PAYMENT_FAIL, escuchamos las transiciones y, si la máquina entró en un estado erróneo, escribimos, en un registro extraño, o base o llamar a la policía, lo que sea).
RastrilloHay un evento stateMachineError en lisener-e, pero con un matiz, cuando tiene una excepción y lo maneja en catch, la máquina no considera que haya un error, debe hablar explícitamente en catch
stateMachine.setStateMachineError (excepción) y pasa un error.
Como verificación de lo que hemos hecho, ejecutaremos dos casos:
- 1. Reserva y posterior rechazo de la compra. Enviaremos a la aplicación una solicitud para el URI "/ reserve", con los parámetros userId = 007, productId = 10001, y luego la solicitud "/ cancel" con el parámetro userId = 007 la salida de la consola será la siguiente:
Machine started
10001 .
NEW RESERVED
Machine started
10001
RESERVED CANCEL_RESERVED
- 2. Reserva y compra exitosa:
Machine started
10001 .
NEW RESERVED
Machine started
10001
RESERVED PURCHASE_COMPLETE
Conclusión
En conclusión, daré un ejemplo de prueba del marco, creo que todo se aclarará del código, solo necesita una dependencia en la máquina de prueba y puede verificar la configuración de forma declarativa.
@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(); }
RastrilloSi de repente desea probar su máquina sin elevar el contexto con las pruebas unitarias habituales, puede crear una máquina a través del generador (discutido parcialmente anteriormente), crear una instancia de la clase con una configuración y obtener acción y protección desde allí, funcionará sin contexto, puede escribir una pequeña prueba El marco es falso, es una ventaja, puede verificar qué acciones se llamaron, cuáles no, cuántas veces, etc., en diferentes casos.
PS
Nuestra máquina está trabajando de manera productiva, hasta ahora no hemos encontrado ningún problema operativo, viene una característica en la que podemos usar la gran mayoría de los componentes de la máquina actual al implementar una nueva (Guard y algunas acciones son perfectas)Nota
No lo consideré en el artículo, pero quiero mencionar oportunidades como la elección, este es un tipo de disparador que funciona según el principio del interruptor, donde los Guardias están colgados en los casos, y la máquina intenta alternativamente ir a ese estado, que se describe en la configuración de elección y donde Guard lo dejará ir, sin algunos eventos, es conveniente cuando, al inicializar la máquina, necesitamos cambiar automáticamente a algún tipo de pseudo host.Referencias
Fuentes Doca