Exemple de mise en oeuvre de SCXML – partie 2

Introduction

La première partie de cet article présentait la machine à états d'une calculatrice quatre opérations.

Cet article va présenter la partie "Java" de l'application.

Diagramme de classe

Diagramme de classes de "scxml_calc"

  • La classe "App" constitue le point d'entrée de l'application
  • L'interface graphique tient dans la classe "Main" du package "gui"
  • Le package "common" contient des abstractions pour faciliter l'utilisation de machine à états SCXML
  • Le coeur du programme est situé dans le package "fsm"

Implémentation

La suite présente l'implémentation de quelques classes importantes. Ensuite, le paragraphe "Entrées / Sorties" reprend les classes décrites dans cette partie et explique les interactions entre le code java et le code SCMXL.

GUI

...
public class Main extends javax.swing.JFrame {
 
    private CalcFSM fsm;
 
    public Main() throws Exception {
        initComponents();
        fsm = new CalcFSM();
        fsm.addListener(new DebugToConsoleListener());
        fsm.addListener(new DisplayListener(fsm, tfDisplay));
        fsm.addListener(new ComputeListener(fsm));
    }
 
    ...                 
 
    private void digitActionPerformed(java.awt.event.ActionEvent evt) {
        JButton b = (JButton) evt.getSource();
        fsm.doOnDigit(b.getText());
    }                                     
 
    private void operationActionPerformed(java.awt.event.ActionEvent evt) {
        JButton b = (JButton) evt.getSource();
        fsm.doOnOperation(b.getText());
    }                                         
 
    private void btnEqualsActionPerformed(java.awt.event.ActionEvent evt) {
        fsm.doOnEquals();
    }                                         
 
    private void btnCActionPerformed(java.awt.event.ActionEvent evt) {
        fsm.doOnClear();
    }
 
    private javax.swing.JButton btn0;
    private javax.swing.JButton btn1;
    private javax.swing.JButton btn2;
    private javax.swing.JButton btn3;
    private javax.swing.JButton btn4;
    private javax.swing.JButton btn5;
    private javax.swing.JButton btn6;
    private javax.swing.JButton btn7;
    private javax.swing.JButton btn8;
    private javax.swing.JButton btn9;
    private javax.swing.JButton btnAddition;
    private javax.swing.JButton btnC;
    private javax.swing.JButton btnDivision;
    private javax.swing.JButton btnEquals;
    private javax.swing.JButton btnMultiplication;
    private javax.swing.JButton btnSubstraction;
    private javax.swing.JPanel jPanel2;
    private javax.swing.JLabel tfDisplay;
}

La classe graphique contient un lien fort avec le moteur de machine à états (CalcFSM). Ensuite, selon les contrôles graphiques activés, les transitions de la machine à états sont déclenchées.

CalcFSM

...
public class CalcFSM extends FiniteStateMachine {
 
    public CalcFSM() throws Exception {
        startSCXML("file:///////path/to/calc.scxml");
    }
 
    public void doOnDigit(String digit) {
        doTriggerEvent(new TriggerEvent("onDigit", TriggerEvent.SIGNAL_EVENT, digit));
    }
 
    public void doOnClear() {
        doTriggerEvent(new TriggerEvent("onClear", TriggerEvent.SIGNAL_EVENT));
    }
 
    public void doOnOperation(String operator) {
        doTriggerEvent(new TriggerEvent("onOperation", TriggerEvent.SIGNAL_EVENT, operator));
    }
 
    public void doOnEquals() {
        doTriggerEvent(new TriggerEvent("onEqual", TriggerEvent.SIGNAL_EVENT));
    }
}

Cette classe est la fille de la classe suivante.

Dans son constructeur, cette classe ouvre et lance la machine à états. De plus, elle publie les méthodes qui permettent de déclencher les transitions.

FiniteStateMachine

...
public abstract class FiniteStateMachine {
 
    private SCXMLExecutor exec = null;
    private SCXML scxml = null;
 
    public FiniteStateMachine() throws Exception {
        // Il faut réimplementer ce constructeur avec un appel à startSCXML
    }
 
    protected void startSCXML(String filename) throws Exception {
        ErrorHandler errHandler = null;
 
        System.out.println("SCXML file : " + filename);
 
        scxml = SCXMLParser.parse(new URL(filename), errHandler);
 
        JexlEvaluator expEvaluator = new JexlEvaluator();
        SimpleDispatcher evtDisp = new org.apache.commons.scxml.env.SimpleDispatcher();
        SimpleErrorReporter errRep = new org.apache.commons.scxml.env.SimpleErrorReporter();
 
        exec = new SCXMLExecutor(expEvaluator, evtDisp, errRep);
        exec.setStateMachine(scxml);
 
        exec.go();
    }
 
    public void addListener(SCXMLListener listener) throws ModelException {
        exec.addListener(scxml, listener);
        exec.reset();
    }
 
    protected void doTriggerEvent(TriggerEvent evt) {
        try {
            exec.triggerEvent(evt);
        } catch (ModelException ex) {
            Logger.getLogger(FiniteStateMachine.class.getName()).log(Level.SEVERE, null, ex);
        }
    }
 
    public void reset() throws ModelException {
        exec.reset();
    }
 
    public Object evaluate(String expression) throws SCXMLExpressionException {
        return exec.getEvaluator().eval(exec.getRootContext(), expression);
    }
 
    public void set(String variable, Object value) throws SCXMLExpressionException {
        exec.getRootContext().set(variable, value);
    }
}

Cette classe fait le lien entre les classes SCXML d'Apache et la classe spécifique pour la machine à états de cette calculatrice.

Elle contient la méthode startSCXML qui permet de charger et de démarrer la machine à états. Cette méthode est largement inspirée de la documentation officielle.

Les méthodes "addListener" et "doTriggerEvent" sont des petites aides à l'implémentation.

"evaluate" et "set" sont deux méthodes qui permettent de lire et de modifier une variable déclarée dans le datamodel de SCXML.

Remarque : Cette classe mérite d'être améliorée pour s'abstraire beaucoup mieux de la technologie de la machine à états elle même, mais ce n'est pas le sujet de ce tutoriel.

Entrées / Sorties

Les classes présentées jusqu'à maintenant permettent d'ouvrir et de lancer la machine à états présentée dans le premier article sur ce sujet.

Elles permettent également d'interagir avec la machine en lui envoyant des ordres de transition.

Par exemple, le bouton "1" sur l'interface graphique contient le gestionnaire d'événements suivant :

    private void digitActionPerformed(java.awt.event.ActionEvent evt) {
        JButton b = (JButton) evt.getSource();
        fsm.doOnDigit(b.getText());
    }

En cas de clique, l'événement "doOnDigit" de la machine à états est appelé avec le texte "1" (le texte du bouton).

Etat : waiting_op1

Donc, si la machine est dans l'état "waiting_op1", la transition "onDigit" va avoir lieu et, du coup le mécanisme expliqué dans le premier article va faire que la variable "op1" dans le SCXML va se remplir avec cette valeur.

Ainsi, à mesure que l'utilisateur clique sur les différents boutons de l'interface graphique, la machine passe d'un état à l'autre.

Listeners

Comme le dit wikipedia : "Le listener, en français écouteur, est un terme anglais utilisé de façon générale en informatique pour qualifier un élément logiciel qui est à l'écoute d'évènements afin d'effectuer des traitements."

DebugToConsoleListener

public class DebugToConsoleListener implements SCXMLListener, Serializable {
 
    @Override
    public void onEntry(TransitionTarget tt) {
        System.out.println(tt.getId() + ".onEntry");
    }
 
    @Override
    public void onExit(TransitionTarget tt) {
        System.out.println(tt.getId() + ".onExit");
    }
 
    @Override
    public void onTransition(TransitionTarget tt, TransitionTarget tt1, Transition trnstn) {
        System.out.println(tt.getId() + ".onTransition => " + tt1.getId());
    }
}

Comme son nom l'indique, ce listener est surtout utile pour le développeur. Il affiche une trace sur la console dès qu'une transition à lieu.

Voici, ce qu'il se passe avec la séquence : "1 2 + 1 2 3 =" (avec un petit commentaire)
Trace Commentaire
start.onEntry initialisation
start.onExit  
start.onTransition => clear  
clear.onEntry  
clear.onExit  
clear.onTransition => reset  
reset.onEntry  
reset.onExit  
reset.onTransition => operand1  
operand1.onEntry operand1
waiting_op1.onEntry  
start.onEntry  
start.onExit  
start.onTransition => clear  
clear.onEntry  
clear.onExit  
clear.onTransition => reset  
reset.onEntry  
reset.onExit  
reset.onTransition => operand1  
operand1.onEntry  
waiting_op1.onEntry  
waiting_op1.onExit  
waiting_op1.onTransition => display_op1 OnDigit(1)
display_op1.onEntry  
display_op1.onExit  
display_op1.onTransition => waiting_op1  
waiting_op1.onEntry  
waiting_op1.onExit  
waiting_op1.onTransition => display_op1 OnDigit(2)
display_op1.onEntry  
display_op1.onExit  
display_op1.onTransition => waiting_op1  
waiting_op1.onEntry  
waiting_op1.onExit  
operand1.onExit  
operand1.onTransition => operand2 onOperation(+)
operand2.onEntry operand2
waiting_op2.onEntry  
waiting_op2.onExit  
waiting_op2.onTransition => display_op2 OnDigit(1)
display_op2.onEntry  
display_op2.onExit  
display_op2.onTransition => waiting_op2  
waiting_op2.onEntry  
waiting_op2.onExit  
waiting_op2.onTransition => display_op2 OnDigit(2)
display_op2.onEntry  
display_op2.onExit  
display_op2.onTransition => waiting_op2  
waiting_op2.onEntry  
waiting_op2.onExit  
waiting_op2.onTransition => display_op2 OnDigit(3)
display_op2.onEntry  
display_op2.onExit  
display_op2.onTransition => waiting_op2  
waiting_op2.onEntry  
waiting_op2.onExit  
operand2.onExit  
operand2.onTransition => compute onEqual
compute.onEntry calcul
compute.onExit  
compute.onTransition => display_result  
display_result.onEntry  
display_result.onExit  
display_result.onTransition => reset  
reset.onEntry retour à operand1
reset.onExit  
reset.onTransition => operand1  
operand1.onEntry  
waiting_op1.onEntry  

DisplayListener

public class DisplayListener implements SCXMLListener, Serializable {
 
    private FiniteStateMachine fsm;
    private JLabel display;
 
    public DisplayListener(FiniteStateMachine fsm, JLabel display) {
        this.fsm = fsm;
        this.display = display;
    }
 
    public void onEntry(TransitionTarget tt) {
        if (tt.getId().equalsIgnoreCase("clear")) {
            display.setText("0");
        }
 
        try {
            if (tt.getId().equalsIgnoreCase("display_op1")) {
                display.setText(fsm.evaluate("op1").toString());
            }
            if (tt.getId().equalsIgnoreCase("display_op2")) {
                display.setText(fsm.evaluate("op2").toString());
            }
            if (tt.getId().equalsIgnoreCase("display_result")) {
                display.setText(fsm.evaluate("result").toString());
            }
 
        } catch (SCXMLExpressionException ex) {
            Logger.getLogger(DisplayListener.class.getName()).log(Level.SEVERE, null, ex);
        }
    }
 
    public void onExit(TransitionTarget tt) {
        //
    }
 
    public void onTransition(TransitionTarget tt, TransitionTarget tt1, Transition trnstn) {
        //
    }

Cette classe reçoit au constructeur le label qui servira à l'affichage. Le coeur de cette classe est assez trivial. Selon l'état de la machine, le contenu d'une des variables (op1, op2 ou result) est affiché sur le label.

ComputeListener

public class ComputeListener implements SCXMLListener, Serializable {
 
    private FiniteStateMachine fsm;
    private String result;
 
    public ComputeListener(FiniteStateMachine fsm) {
        this.fsm = fsm;
    }
 
    public void onEntry(TransitionTarget tt) {
       try {
            if (tt.getId().equalsIgnoreCase("compute")) {
                String op1 = fsm.evaluate("op1").toString();
                String oper = fsm.evaluate("oper").toString();
                String op2 = fsm.evaluate("op2").toString();
                result = fsm.evaluate(op1 + oper + op2).toString();
            }
        } catch (SCXMLExpressionException ex) {
            Logger.getLogger(ComputeListener.class.getName()).log(Level.SEVERE, null, ex);
        }
    }
 
    public void onExit(TransitionTarget tt) {
        try {
            if (tt.getId().equalsIgnoreCase("compute")) {
                fsm.set("result",result);
            }
        } catch (SCXMLExpressionException ex) {
            Logger.getLogger(ComputeListener.class.getName()).log(Level.SEVERE, null, ex);
        }
    }
 
    public void onTransition(TransitionTarget tt, TransitionTarget tt1, Transition trnstn) {
        //
    }
 
}

Cette classe effectue le calcul lui-même. Quand la machine atteint l'état "compute", les valeurs de "op1", "op2" et "oper" sont lues grâce à la méthode "evaluate" de "FiniteStateMachine".

Ensuite, le calcul est réalisé en appelant à nouveau "evaluate" mais cette fois avec l'expression "op1 + oper + op2". L'expression est évaluée puis le résultat est renvoyé dans la variable "result".

Remarque : Cette manière d'évaluer cette expression n'est pas très orthodoxe. Cette application étant écrite à des fins pédagogiques, c'est un raccourci que je m'autorise...

Conclusion

Ce n'est pas un cas très courant, mais il m'est, de temps en temps, arrivé d'intervenir sur des applications qui contenaient un grand nombre de commandes. La plupart n'avaient pas été codées en utilisant une méthodologie de développement appropriée. Les commandes et les états n'étaient pas toujours gérés absolument systématiquement. Du coup, les premiers 80% de fonctionnement étaient faciles à atteindre, mais pour les 20% derniers, c'était vraiment la  beaucoup cycles de tests et de modifications jusqu'à avoir identifié et gérer tous les cas particuliers.

Comme je l'ai expliqué au début du premier article, un de mes clients a passé commande pour une application répondant à ces critères. De plus, je voyais bien que mon interlocuteur n'était pas certain à 100% de toutes les fonctionnalités.

La solution de la machine à états me plaisait, car je la connaissais déjà et que je pensais qu'elle me permettrait de résoudre ce problème. Ce qui me dérangeait dans cette solution était le fait de devoir résonner sur le diagramme (p.ex le diagramme d'états-transitions UML), puis de coder le tout.

Je me suis donc mis à la recherche d'une solution plus souple et je l'ai trouvée. Les outils que j'ai présentés tout au long de cet article permettent de réaliser le diagramme et de l'utiliser en temps que "produit fini" dans l'application. La séparation des couches est très bien faite et, du coup, les modifications de la machine à états seront faciles à réaliser.

Laisser un commentaire

Votre adresse de messagerie ne sera pas publiée. Les champs obligatoires sont indiqués avec *

*

Vous pouvez utiliser ces balises et attributs HTML : <a href="" title=""> <abbr title=""> <acronym title=""> <b> <blockquote cite=""> <cite> <code> <del datetime=""> <em> <i> <q cite=""> <strike> <strong>