Máquina de estados con Apache SCXML

2
6522

Finite State Machine con Apache SCXML

0. Índice de
contenidos.

1. Entorno

Este tutorial está escrito usando el siguiente entorno:

  • Hardware: Portátil Mac Book Pro 17″ (2,93 Ghz Intel Core 2 Duo, 8 GB DDR3)
  • Sistema Operativo: Mac OS X Mavericks 10.9
  • Apache SCXML 0.9
  • Eclipse Kepler
  • scxmlgui

2. Introducción.

En este tutorial vamos a ver cómo crear una máquina de estados sencillita que pase entre tres estados. Para hacer el comportamiento básico vamos a ayudarnos de la herramienta scxmlgui.

Cuando tengamos el scxml con la funcionalidad básica, vamos a añadirle otras funciones en código java gracias a apache SCXML.

Bien, vamos a explicar cómo es el funcionamiento de una máquina de estados, para ello primero vamos a ver los elementos básicos que tienen.

  • Estado: Es la parte más importante, y como su propio nombre indica, es un estado concreto en el flujo de la máquina de estados. Cada estado tiene asociados transiciones y funciones OnEntry y OnExit que se ejecutarán al entrar y al salir del estado, respectivamente. Del estado se sale mediante transiciones que se activan mediante eventos y tienen el estado de destino como Target.
  • Transición: Tienen de propiedades una condición que tiene que cumplir para realizarse, un evento que será la que la dispare y un target que es el estado destino. También se pueden asociar acciones a realizar cuando ocurran.
  • OnEntry y OnExit: Contienen acciones a realizar cuando una transición llegue al estado o salga de él, respectivamente.
  • Accion: Son elementos de código que se incorporan a las transiciones o a OnEntry/OnExit para que sean ejecutadas.

Con esto ya tenemos la base para crear la máquina de estados, así que vamos con ello.

La máquina de estados que voy a realizar tendrá tres estados (A, B y C), con dos transiciones, una entre A y B (transicion_AB) y otra entre B y C (transicion_BC). El estado B tendrá una acción en OnEntry y dos acciones en OnExit. El estado C tendrá una acción en OnEntry. El evento que dispara la transición AB será llamado «Transicion_AB», y el evento de la transición BC «Transicion_BC». Y por último, la transición BC tendrá dos acciones.

En el esquema siguiente queda más claro la explicación

3. Creando el archivo base scxml.

Nos vamos a ayudar de la herramienta scxml, que la podéis encontrar aquí, y su funcionamiento es muy sencillo (ya que sólo vamos a hacer la base y el resto de funcionalidad lo agregaremos desde código java).

Cuando la tengamos abierta, creamos un nuevo archivo (File –> New SCXML). Ahora con el botón derecho sobre la plantilla agregamos un nodo (nodo es el equivalente a estado) con Add node, damos doble click para ponerle de Nombre «A», como aparece en la siguiente imagen.

Ahora, con el botón presionado sobre el nodo, arrastramos a otra zona y soltamos, que nos creará un nuevo nodo y una transición que los une, como hicimos antes, renombramos el nuevo nodo y le ponemos «B».

Ahora damos con el botón derecho sobre la transición y «edit transition». Ahora en la pestaña evento ponemos «transición_AB», y en condición ponemos símplemente true.

Ahora ya sólo nos queda hacer lo mismo para crear un nuevo nodo a partir de B, realizamos los pasos anteriores salvo que el nuevo nodo se llamará «C» y la transición que los une «Transición_BC». El resultado tiene que ser el siguiente:

Guardamos el archivo como «fsm.scxml», y su contenido tiene que ser como el que sigue (salvo los comentarios de dónde está colocado cada nodo):


<scxml version="0.9" xmlns="http://www.w3.org/2005/07/scxml"><!--   node-size-and-position x=0 y=0 w=664 h=596  -->
 	<state id="A"><!--   node-size-and-position x=166 y=112 w=75 h=75  -->
  		<transition cond="true" event="Transicion_AB" target="B"></transition>
 	</state>
 	<state id="B"><!--   node-size-and-position x=170 y=240 w=75 h=75  -->
  		<transition cond="true" event="Transicion_BC" target="C"></transition>
 	</state>
	<state id="C"><!--   node-size-and-position x=170 y=380 w=75 h=75  --></state>
</scxml>

4. Creando proyecto java.

Bien, ahora ya tenemos el archivo base, vamos a crear un proyecto en eclipse con maven, para ello vamos a File –> new –> Maven Project. (si no ve Maven Project, pulse en others y lo encontrará si tiene instalado el plugin en eclipse, sino tendrá que instalárselo).

Las siguientes pantallas se describen solas, podéis poner los mismos datos de ejemplo que he puesto yo.

Bien, ahora vamos a configurar el archivo pom.xml con las dependencias que vamos a necesitar, tiene que quedar así:


	<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
	xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
	<modelVersion>4.0.0</modelVersion>
	<groupId>com.autentia.tutorials</groupId>
	<artifactId>fsm-apache</artifactId>
	<version>1.0.0-SNAPSHOT</version>
	<description>Pruebas de concepto de máquinas de estado finitas con apache SCXML</description>

	<dependencies>
		<dependency>
			<groupId>commons-scxml</groupId>
			<artifactId>commons-scxml</artifactId>
			<version>0.9</version>
		</dependency>
		<dependency>
			<groupId>commons-beanutils</groupId>
			<artifactId>commons-beanutils</artifactId>
			<version>1.8.2</version>
		</dependency>
		<dependency>
			<groupId>commons-digester</groupId>
			<artifactId>commons-digester</artifactId>
			<version>1.8.1</version>
		</dependency>
		<dependency>
			<groupId>commons-el</groupId>
			<artifactId>commons-el</artifactId>
			<version>1.0</version>
		</dependency>
		<dependency>
			<groupId>commons-jexl</groupId>
			<artifactId>commons-jexl</artifactId>
			<version>1.1</version>
		</dependency>
		<dependency>
			<groupId>commons-logging</groupId>
			<artifactId>commons-logging</artifactId>
			<version>1.1.1</version>
		</dependency>
		<dependency>
			<groupId>xalan</groupId>
			<artifactId>xalan</artifactId>
			<version>2.6.0</version>
		</dependency>
		<dependency>
			<groupId>junit</groupId>
			<artifactId>junit</artifactId>
			<version>4.11</version>
		</dependency>
		<dependency>
			<groupId>org.mockito</groupId>
			<artifactId>mockito-all</artifactId>
			<version>1.9.5</version>
		</dependency>
	</dependencies>
</project>

Resumido, hemos incorporado las dependencias para apache SCXML, en la misma versión que viene en la página oficial ( commons-scxml 0.9, commons-beanutils 1.8.2, commons-digester 1.8.1, commons-el 1.0, commons-jexl 1.1, commons-logging 1.1.1 y xalan 2.6.0)

Además he incluido las dependencias para los test (JUnit 4.11 y Mockito 1.9.5).

Una vez hecho esto, vamos a crearnos las clases que vamos a usar. Primero implementaremos nuestra máquina de estados personalizada con los métodos que nos van a hacer falta, heredando de AbstractStateMachine, incluida en el paquete org.apache.commons.scxml.env. Yo he llamado a la clase FSM.java, y la he dejado en la ruta com/autentia/tutorials/fsm, que será donde estén las clases que vamos a implementar.

Dejo el código comentado a continuación:


import java.net.URL;
import java.util.List;

import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.apache.commons.scxml.env.AbstractStateMachine;
import org.apache.commons.scxml.model.Action;
import org.apache.commons.scxml.model.OnEntry;
import org.apache.commons.scxml.model.OnExit;
import org.apache.commons.scxml.model.State;
import org.apache.commons.scxml.model.Transition;


public class FSM extends AbstractStateMachine{
	
	private static final Log LOGGER = LogFactory.getLog(FSM.class);
	 
	 // Constructor
	 public FSM(URL xmlPath) {
		   super(xmlPath);
	 }
	 
	 // Añade una accion a una transicion de un estado (stateName) y nombre del evento que la lanza (event)
	 public void addActionToTransitionByStateAndEvent(String stateName, String event, Action action){
		 State state = getState(stateName);
		 for(Transition t : (List<Transition>) state.getTransitionsList()){
			 if (t.getEvent().equals(event)){
				 t.addAction(action);
			 }
		 }
	 }

	// Añade una condicion a una transicion de un estado (stateName) y nombre del evento que la lanza (event)
	 public void addConditionToTransitionByStateAndEvent(String stateName, String event, String condition){
		 State state = getState(stateName);
		 for(Transition t : (List<Transition>) state.getTransitionsList()){
			 if (t.getEvent().equals(event)){
				 t.setCond(condition);
			 }
		 }
	 }

	 // devuelve State a partir de nombre en String
	 public State getState(String stateName){
		 return (State) getEngine().getStateMachine().getTargets().get(stateName);
	 }

	 // Introduce acciones a OnEntry de un estado pasado por su nombre en String
	 public void setActionInOnEntryByState(String stateName, Action [] actionsToAdd){
		 State state = getState(stateName);
		 OnEntry oe = state.getOnEntry();
		 for(Action action : actionsToAdd){
		 	oe.addAction(action);
		 }
		 state.setOnEntry(oe);
	 }

	// Introduce acciones a OnExit de un estado pasado por su nombre en String
	 public void setActionInOnExitByState(String stateName, Action [] actionsToAdd){
		 State state = getState(stateName);
		 OnExit oex = state.getOnExit();
		 for(Action action : actionsToAdd){
			 	oex.addAction(action);
		}
		 state.setOnExit(oex);
	 }

	 // Introduce una nueva transición a un estado por su nombre en String
	 public void setTransitionInState(String stateName, Transition transitionToAdd){
		 State state = getState(stateName);
		 state.addTransition(transitionToAdd);
	 }

	 // Crea una transición con los datos que se le pasan y la introduce en el estado stateName
	 // event --> nombre del evento que hará saltar la transición (si la condición es true)
	 // cond --> Condición para que la transición tenga lugar
	 // targetState --> estado destino de la transición
	 // actionsToAdd --> Acciones que ejecuta la transición 
	 public void addCustomTransition(String stateName ,String event, String cond, String targetState, Action [] actionsToAdd){
		 Transition t = new Transition();
		 t.setEvent(event);
		 t.setCond(cond);
		 t.setTarget(getState(targetState));
		 
		 for (Action a : actionsToAdd){
			 t.addAction(a);
	 	 }
		 setTransitionInState(stateName, t);
	 }

	 // Devuelve la lista de transiciones de un estado stateName que tienen como evento eventName
	 public List<Transition> getTransitionsByStateAndEvent(String stateName, String eventName){
		 State s = getState(stateName);
		 return  s.getTransitionsList(eventName);
		 
	 }

	 // Callback del estado A, es llamado cuando entra en este estado
	 public void A() {
		 LOGGER.info("STATE: A");
	  }

	// Callback del estado B
	 public void B() {
		 LOGGER.info("STATE: B");
	 }

	// Callback del estado C
	 public void C() {
		 LOGGER.info("STATE: C");
	 }
	  
}

Bien, ya tenemos la clase principal, ahora vamos a crear una clase que herede de Action, ya que es una clase abstracta, para poder crear nuestras acciones personalizadas, yo lo único que he implementado es un método que imprima en el log un string que se le pasa en el constructor, en este caso será un string informativo del método al cuál se le va a asociar la acción.

La clase CustomAction.java ubicada en la misma ruta que FSM.java es la siguiente:


import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.apache.commons.scxml.ErrorReporter;
import org.apache.commons.scxml.EventDispatcher;
import org.apache.commons.scxml.SCInstance;
import org.apache.commons.scxml.SCXMLExpressionException;
import org.apache.commons.scxml.model.Action;
import org.apache.commons.scxml.model.ModelException;

public class CustomAction extends Action{
	
	private static final Log LOGGER = LogFactory.getLog(CustomAction.class);

	private static final long serialVersionUID = 1L;
	String action;
	
	public CustomAction(String action){
		super();
		this.action = action;
	}

	@Override
	public void execute(EventDispatcher evtDispatcher, ErrorReporter errRep,
			SCInstance scInstance, Log appLog, Collection derivedEvents)
			throws ModelException, SCXMLExpressionException {
		LOGGER.info(action);
		
	}
	
	public void setAction(String action){
		this.action = action;
	}

}

Creo que hace falta explicar poco de la clase, es muy simple.

Ahora vamos a copiar el archivo fsm.scxml que creamos con el editor gráfico, en la carpeta resource, tanto dentro de main como de test, ya que ahora crearemos los test. En TDD lo primero que hay que crear son los test, pero para que quedaran más claros los he dejado para el final de la explicación.

En la misma ruta que teníamos las anteriores clases, pero partiendo ahora de test, vamos a crear la clase FSMTest.java, con el siguiente contenido:


import java.net.URL;
import java.util.Collection;

import org.apache.commons.scxml.ErrorReporter;
import org.apache.commons.scxml.EventDispatcher;
import org.apache.commons.scxml.SCInstance;
import org.apache.commons.scxml.TriggerEvent;
import org.apache.commons.scxml.model.Action;
import org.apache.commons.logging.Log;
import org.junit.BeforeClass;
import org.junit.Test;
import org.mockito.InOrder;
import org.mockito.Matchers;
import org.mockito.Mockito;
import static org.mockito.Mockito.spy;

import com.autentia.tutorials.fsm.CustomAction;
import com.autentia.tutorials.fsm.FSM;

public class FSMTest {
	
	public static final String STATE_A = "A";
	public static final String STATE_B = "B";
	public static final String STATE_C = "C";
	
	public static final String TRANSITION_AB = "Transicion_AB";
	public static final String TRANSITION_BC = "Transicion_BC";
	
	public static final URL urlXML = FSM.class.getClassLoader().getResource("fsm.scxml");
	
	public static TriggerEvent transicionAB = null;
	public static TriggerEvent transicionBC = null;
	
	public static boolean eventdata;
	
	private static Action actionTransitionCB1;
	private static Action actionTransitionCB2;
	
	private static CustomAction actionEntryB;
	private static CustomAction actionExitB1;
	private static CustomAction actionExitB2;
	private static CustomAction actionEntryC;
	
	public static FSM fsm = null;
	
	
		//Inicializamos las variables que nos van a hacer falta
		@BeforeClass
		public static void init(){
			fsm = spy(new FSM(urlXML));
			
			actionEntryB = spy(new CustomAction("Action onEntry B"));
			actionExitB1 = spy(new CustomAction("Action onExit B1"));
			actionExitB2 = spy(new CustomAction("Action onExit B2"));
			actionEntryC = spy(new CustomAction("Action onEntry C"));
			
			Action [] actionOnEntryB = {actionEntryB};
			Action [] actionOnExitB = {actionExitB1, actionExitB2};
			Action [] actionOnEntryC = {actionEntryC};
			
			actionTransitionCB1 = spy(new CustomAction("Action Transition CB1"));
			actionTransitionCB2 = spy(new CustomAction("Action Transition CB2"));
			
			fsm.setActionInOnEntryByState(STATE_B, actionOnEntryB);
			fsm.setActionInOnExitByState(STATE_B, actionOnExitB);
			
			fsm.setActionInOnEntryByState(STATE_C, actionOnEntryC);
			
			fsm.addActionToTransitionByStateAndEvent(STATE_B, TRANSITION_BC , actionTransitionCB1);
			fsm.addActionToTransitionByStateAndEvent(STATE_B, TRANSITION_BC , actionTransitionCB2);
			
			eventdata = true;
			
			fsm.addConditionToTransitionByStateAndEvent(STATE_B, TRANSITION_BC, "_eventdata");
			
			transicionAB = new TriggerEvent(TRANSITION_AB,TriggerEvent.SIGNAL_EVENT, eventdata);
			transicionBC = new TriggerEvent(TRANSITION_BC,TriggerEvent.SIGNAL_EVENT, eventdata);
			
		}

		// Comprobamos que las acciones introducidas en OnEntry y OnExit de los estados se ejecutan correctamente y en orden
		@Test
		public void shouldCallOnEntryAndOnExitInOrder(){
			
			try {
				
				InOrder inOrder = Mockito.inOrder(actionEntryB,actionExitB1,actionExitB2,actionEntryC);

				fsm.getEngine().triggerEvent(transicionAB);
				fsm.getEngine().triggerEvent(transicionBC);

				inOrder.verify(actionEntryB, Mockito.atLeastOnce()).execute(Matchers.any(EventDispatcher.class), Matchers.any(ErrorReporter.class), Matchers.any(SCInstance.class), Matchers.any(Log.class), Matchers.any(Collection.class));
				inOrder.verify(actionExitB1, Mockito.atLeastOnce()).execute(Matchers.any(EventDispatcher.class), Matchers.any(ErrorReporter.class), Matchers.any(SCInstance.class), Matchers.any(Log.class), Matchers.any(Collection.class));
				inOrder.verify(actionExitB2, Mockito.atLeastOnce()).execute(Matchers.any(EventDispatcher.class), Matchers.any(ErrorReporter.class), Matchers.any(SCInstance.class), Matchers.any(Log.class), Matchers.any(Collection.class));
				inOrder.verify(actionEntryC, Mockito.atLeastOnce()).execute(Matchers.any(EventDispatcher.class), Matchers.any(ErrorReporter.class), Matchers.any(SCInstance.class), Matchers.any(Log.class), Matchers.any(Collection.class));
				
			} catch (Exception e) {
				System.err.println("Error");
				e.printStackTrace();
			}
		}
		
		// Comprobamos que devuelve correctamente las transiciones de los estados A y B
		@Test
		public void shoulGetTransitions(){
			assert(fsm.getTransitionsByStateAndEvent("A", TRANSITION_AB) == transicionAB);
			assert(fsm.getTransitionsByStateAndEvent("B", TRANSITION_BC) == transicionBC);
		}
		
		//Comprobamos que llama en orden las acciones de las transicion entre B y C
		@Test
		public void shouldCallActionsInOrderInTransitionsCB(){
	
			InOrder inOrder = Mockito.inOrder(actionTransitionCB1, actionTransitionCB2);
			
			try {
				
				fsm.getEngine().triggerEvent(transicionAB);
				
				fsm.getEngine().triggerEvent(transicionBC);
				
				inOrder.verify(actionTransitionCB1, Mockito.atLeastOnce()).execute(Matchers.any(EventDispatcher.class), Matchers.any(ErrorReporter.class), Matchers.any(SCInstance.class), Matchers.any(Log.class), Matchers.any(Collection.class));
				inOrder.verify(actionTransitionCB2, Mockito.atLeastOnce()).execute(Matchers.any(EventDispatcher.class), Matchers.any(ErrorReporter.class), Matchers.any(SCInstance.class), Matchers.any(Log.class), Matchers.any(Collection.class));
				
			} catch (Exception e) {
				System.err.println("Error");
				e.printStackTrace();
			}
		}
		
		//Comprobamos que pasa correctamente de un estado a otro al llegar el evento correspondiente
		@Test
		public void runInAllStates(){
			
			fsm.addConditionToTransitionByStateAndEvent(STATE_B, TRANSITION_BC, "_eventdata");
			
			try {
				
				assert(fsm.getEngine().getCurrentStatus().equals("A"));
				fsm.getEngine().triggerEvent(transicionAB);
			
				assert(fsm.getEngine().getCurrentStatus().equals("B"));
	
				fsm.getEngine().triggerEvent(transicionBC);
				assert(fsm.getEngine().getCurrentStatus().equals("C"));
				
			} catch (Exception e) {
				e.printStackTrace();
			}
		}
}

Al inicio de los métodos hay un pequeño resumen de lo que comprueba cada test. Creo que no tiene mucha complejidad y es bastante legible el código por sí sólo, pero voy a explicar un par de cosas por si no quedan claras.

El método spy, incluido con mockito nos va a permitir saber el orden en el que se llaman a los métodos de esos objetos, y los crea llamando al constructor, cosa que con mock no se hace y no podemos seguir el orden de algunos objetos.

Otra cosa a tener en cuenta es la variable booleana eventdata, la cual se le pasa al lanzar el evento, y si os fijáis, llamamos al método de fsm addConditionToTransitionByStateAndEvent, el cual pasamos el estado b, la transicion que queremos configurar y el String que queremos que tenga la condición de esa transición (STATE_B, TRANSITION_BC, «_eventdata»). _eventdata es el nombre de la variable que da Apache SCXML a las variables que vienen desde la transición.

Como vemos líneas más abajo, introducimos la condición eventdata a la transición TransicionCB (transicionBC = new TriggerEvent(TRANSITION_BC,TriggerEvent.SIGNAL_EVENT, eventdata); y con lo que conseguimos que _eventdata que recoge apache SCXML tenga el valor de la variable booleana eventdata(true) con lo que conseguimos que la transición que pasamos sea lanzada dependiendo de la variable eventdata.

Realmente no necesitamos esto, sólo lo he incluido como información para hacer nuestro código más flexible y configurable.

Ahora ejecutamos la clase como JUnit test:

Vemos que pasan los test:

Y la salida tendría que ser algo parecido a esto:


Feb 4, 2014 12:04:04 PM com.autentia.tutorials.fsm.FSM A
INFO: STATE: A
Feb 4, 2014 12:04:04 PM com.autentia.tutorials.fsm.CustomAction execute
INFO: Action onEntry B
Feb 4, 2014 12:04:04 PM com.autentia.tutorials.fsm.FSM B
INFO: STATE: B
Feb 4, 2014 12:04:04 PM com.autentia.tutorials.fsm.CustomAction execute
INFO: Action onExit B1
Feb 4, 2014 12:04:04 PM com.autentia.tutorials.fsm.CustomAction execute
INFO: Action onExit B2
Feb 4, 2014 12:04:04 PM com.autentia.tutorials.fsm.CustomAction execute
INFO: Action Transition CB1
Feb 4, 2014 12:04:04 PM com.autentia.tutorials.fsm.CustomAction execute
INFO: Action Transition CB2
Feb 4, 2014 12:04:04 PM com.autentia.tutorials.fsm.CustomAction execute
INFO: Action onEntry C
Feb 4, 2014 12:04:04 PM com.autentia.tutorials.fsm.FSM C
INFO: STATE: C

5. Conclusiones.

Hemos visto cómo crear una máquina de estados sencilla que nos ayudará a que nuestras tareas tengan un flujo definido claro y configurable.

El código en github del proyecto.

Para cualquier duda o aclaración, en los comentarios.

Un saludo.

2 COMENTARIOS

  1. Hola Daniel, excelente articulo realmente, no hay muchos ejemplos sobre Apache Commons SCXML 0.9 (ni siquiera en la pagina oficial). Quisiera hacerte una consulta, sabes como se registran los custom actions a traves de la definicion en XML? Segun lo que habia visto en la pagina de Apache deberia ser algo asi:

    …..

    Incluyendo por supuesto en la definicion inicial el namespace xmlns:my=»http://my.custom-actions.domain/CUSTOM» (que en mi caso no apunta a nada), pero la realidad es que si no agrego en codigo Java los custom action asociados a onEntry y onExit no los ejecuta, es posible hacer esto a traves del xml o es una caracteristica que no existe en esta version?
    Saludos

    • Parece que el codigo que envie no salio, lo copio nuevamente con caracteres escapados:

      <state id=»miEstado»>
      <onexit>
      <my:customAction />
      </onexit>

      </state>

      Saludos

DEJA UNA RESPUESTA

Por favor ingrese su comentario!

He leído y acepto la política de privacidad

Por favor ingrese su nombre aquí

Información básica acerca de la protección de datos

  • Responsable:
  • Finalidad:
  • Legitimación:
  • Destinatarios:
  • Derechos:
  • Más información: Puedes ampliar información acerca de la protección de datos en el siguiente enlace:política de privacidad