Benutzer-Werkzeuge

Webseiten-Werkzeuge


start:software:generischer_zustandsautomat_mit_javascript

Generischer Zustandsautomat mit Javascript

Etwas komplexere AJAX- oder XULrunner- Anwendungen und Browser-Extensions führen eine Menge asynchrone Kommunikation durch; sie durchlaufen beim Herunter- oder Hochladen und -Verarbeiten von Dateien oder in der Kommunikation mit dem Benutzer über eine GUI eine Vielzahl von internen Zuständen über einen unbestimmt langen Zeitraum.

Gerade bei komplexen asynchronen Abläufen kommt man um die Erstellung von Zustandsdiagrammen oder -Tabellen kaum herum und möchte diese dann auch im Code wiederfinden können. Bei Javascript ist dies durch die reichen Metaprogrammierungs- Möglichkeiten der Sprache selbst und die JSON- Syntax recht einfach. Definition eines Zustandsautomaten

In BNF- Notation läßt sich die Definition eines Zustandsautomaten wie folgt darstellen:

        states:= '{' {state} '},'
        state:= currentState : '{' {event} '},'
        event:= eventName ':' nextState | action
        currentState:= name of current state as string
        nextState:= name of following state as string
        eventName:= event name as string
        action:= anonymous function that handles event
        action can return false to stop automaton,
        a returned string will reference the next state

Den Zyklus der Anzeige-Zustände einer Verkehrsampel kann man in dieser Notation wie folgt schreiben:

        automaton.states= {
        start:
        {
           enter: "halt"
        },
 
        halt: {
           enter: function() {
              this.red= true;
              this.yellow= false;
              this.green= false;
              return "getReady";
           }
        },
 
        getReady: {
           enter: function() {
              this.red= true;
              this.yellow= true;
              this.green= false;
              return "go";
           }
        },
 
        go: {
           enter: function() {
              this.red= false;
              this.yellow= false;
              this.green= true;
              return "attention";
           }
        },
 
        attention: {
           enter: function() {
              this.red= false;
              this.yellow= true;
              this.green= false;
              return "halt";
           }
        }
    }

Die Zustände heißen hier start, halt, getReady, go und attention und werden bei regelmäßigem Feuern des in der Implementierung des Zustandsautomaten bereits vordefinierten Events enter zyklisch und synchron durchlaufen, weil alle Eventhandler- Funktionen den korrekten Folgezustand zurückliefern. Der Zustand start ist eigentlich überflüssig, aber konsumiert immerhin einen „Tick“, soll hier aber nur eine mögliche Syntax für Fälle zeigen, in denen kein Eventhandler benötigt wird.

In der Praxis sollte man allerdings keine solchen „Endlosschleifen“ synchron implementieren, weil sonst der Aufrufstapel irgendwann überläuft, denn jeder Zustandswechsel bedeutet mindestens zwei Funktionsaufrufe, deren Rückkehr erst bei einer Beendigung des Automaten erfolgen kann. Eine asynchrone Verarbeitung lässt sich aber immer leicht z.B. mit einem Timer realisieren und macht dies Ampel-Beispiel ohnehin erst brauchbar - zuerst werden aber die Methoden der Implementierung des Zustandsautomaten vorgestellt.

Funktionen des Zustandsautomaten

function handleEvent(eventName:string):object

Mit dieser Methode läßt sich ein in der Automatendefinition „eventName“ genanntes Event triggern. Die Events enter und exit sind bereits vordefiniert und werden vor und nach jeder Zustandsänderung aufgerufen (wenn implementiert). Sofern vorhanden, werden bei jeder Event-Verarbeitung auch noch die Callback-Funktionen beforeEvent und afterEvent aufgerufen.

Dem Eventnamen kann eine Funktion oder ein String assoziiert sein (siehe oben). Ein String wird als Name des Folgezustands interpretiert und löst einen direkten Zustandsübergang aus, eine Funktion wird ausgeführt. Wenn diese Funktion einen String zurückliefert, wird dieser wiederum als Folgezustand interpretiert bzw. führt zu einem Zustandswechsel.

Wenn der aktuelle Zustand keine Behandlung für das Event realisiert, liefert handleEvent den Wert false zurück, auch beforeEvent kann false zurückliefern um einen Zustandswechsel (temporär) noch zu verhindern. Wenn der Zustandswechsel erfolgreich ist, gibt handleEvent eine Referenz auf den Zustandsautomaten zurück.

function changeState(newState:string):object

Mit dieser Funktion kann direkt ein Zustandswechsel durchgeführt werden sowie ein Startzustand eingestellt werden. Auch hier gibt es optionale Callbacks beforeStateChange und afterStateChange, welche zum Monitoren des Zustandswechsels oder im Falle von beforeStateChange auch zur Verhinderung des Zustandswechsels verwendet werden können (wenn beforeStateChange den Wert false zurück liefert).

Ansonsten wird vor dem Zustandswechsel das vordefinierte Event „enter“ und danach das Event „exit“ getriggert aber kein Eventhandler ausgeführt, wenn alter Zustand und Folgezustand gleich sind. Beim Versuch, in einen undefinierten Zustand zu wechseln, gibt changeState den Wert false zurück, im Erfolgsfall eine Referenz auf den Zustandsautomaten.

function getTransitionHandler(stateMachine:StateMachine, newState:string):function()

Diese Methode liefert eine parameterlose Funktion zurück, welche intern die Funktion changeState() ausführt. Die parameterlose Funktion kann als Callback (Handler) für Timer, XMLHttpRequest-Objekte oder andere asynchronen Abläufe genutzt werden. Ein Aufruf erfolgt mit einer Referenz auf den Zustandsautomaten (normalerweise this) und dem Namen des Folgezustands. Dabei wird eine Closure erzeugt, welche diese Daten dem Callback zum Zeitpunkt von dessen Ausführung zur Verfügung stellt (Currying).

function getTriggerHandler(stateMachine:StateMachine, eventName:string):function()

Diese Methode liefert eine parameterlose Funktion zurück, welche intern die Funktion handleEvent() ausführt. Die parameterlose Funktion kann als Callback oder Handler für Timer, XMLHttpRequest-Objekte oder andere asynchronen Abläufe genutzt werden. Ein Aufruf erfolgt mit einer Referenz auf den Zustandsautomaten (normalerweise this) und dem Namen eines zu triggernden Events. Dabei wird eine Closure erzeugt, welche diese Parameter dem Callback zum Zeitpunkt von dessen Ausführung zur Verfügung stellt (Currying).

Implementierung des Zustandsautomaten

Die oben beschriebenen Funktionen sind als privilegierte Methoden direkt im Konstruktor der Klasse StateMachine implementiert:

        function StateMachine()
        {
           this.states={};
           this.state = null;
 
           this.handleEvent = function(eventName)
           {
              if((typeof(this.beforeEvent) != 'undefined')
              && (this.beforeEvent.call(this, eventName) == false)) return false;
 
              var event = this.states[this.state][eventName];
              if(typeof(event) === 'undefined') return false;
 
              if(typeof(event) === 'string')
              {
                 return this.changeState(event);
              }
 
              if(typeof(event) == 'function')
              {
                 var result = event.call(this, eventName);
                 if(typeof(result) === 'string') return this.changeState(result);
              }
 
              if(typeof(this.afterEvent) != 'undefined') this.afterEvent.call(this, eventName);
              return this;
           }
 
           this.changeState = function(newState)
           {
              var oldState = this.state;
 
              if((typeof(this.beforeStateChange) != 'undefined')
              && (this.beforeStateChange.call(this, newState, oldState) == false))
                 return false;
 
              if(oldState == newState) return this;
              if(!this.states[newState]) return false;
              if(oldState) this.handleEvent('exit');
 
              this.state = newState;
              this.handleEvent('enter');
 
              if(typeof(this.afterStateChange) != 'undefined')
              this.afterStateChange.call(this, newState, oldState);
              return this;
           }
 
           this.getTransitionHandler= function(stateMachine, newState)
           {
              return function()
              {
                 stateMachine.changeState(newState);
              }
           }
 
           this.getTriggerHandler= function(stateMachine, eventName)
           {
              return function()
              {
                 stateMachine.handleEvent(eventName);
              }
           }
        }

Anwendungsbeispiel: Ampelsteuerung

Das folgende Beispiel verdeutlicht in einer einfachen Anwendung, wie man den Zustandsautomaten einsetzen kann. Eine Website zeigt eine Verkehrsampel, welche durch einen Zustandsautomaten und Timer gesteuert den gewohnten „Ampelzyklus“ durchläuft. Wird die Ampel angeklickt, wechselt sie vom aktiven in einen passiven Modus, bei dem nur noch die gelbe Lampe blinkt. Erneutes Anklicken führt wieder zum normalen Ampelbetrieb.

Ampelsteuerung Seiten-Quellcode

Um den Code einfach zu halten, habe ich auf spezifische Browser-Anpassungen verzichtet- will heißen daß dies Codebeispiel den Firefox voraussetzt. Abhängigkeiten von Firefox ergeben sich aus den CSS-Regeln und dem Verwenden der Firebug-Console.

Jedem, der häufig mit Javascript bzw Ajax arbeiten muß, seien der Firefox und dessen Debugger- Addon Firebug dringend empfohlen.

        <?xml version="1.0" encoding="UTF-8"?>
        <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
    <meta http-equiv="Content-Type" content="text/html; charset=UTF-8"/>
    <title>State Engine Test</title>
 
    <style type="text/css" >
            /* design of the lamps */
        .light {
            position: relative;
            width: 50px; height: 50px; margin: 10px;
 
            border: solid black; border-width: 2px;
            -moz-border-radius: 50px;
            background-color: gray;
        }
 
            /* design of the housing */
        #traffic_light {
            position: fixed;
            left: 120px; top: 30px; width: 74px; height: 210px;
            border: solid black; border-width: 2px;
            -moz-border-radius: 5px;
            background-color: #003f00;
        }
    </style>
 
    <script type="text/javascript" src="stateEngine.js" />
    <script type="text/javascript">
        /* <![CDATA[ */
        // body onload handler
        function start()
        {
            // enable Firebug console, if present
            if(typeof console == 'undefined'){
                if(typeof loadFirebugConsole == 'function'){ loadFirebugConsole(); }
            }
 
            // create controller instance and set start state
            var tlc= new TrafficLightController();
            tlc.changeState("halt");
 
            // register click-Handler for whole trafficlight div-section
            var tl=document.getElementById("traffic_light");
            tl.addEventListener("click",
                    function() {
                        // must create closure to make tlc accessible
                        tlc.toggleActive();
                    }, true);
        }
 
        // this constructor inherits class StateMachine
        function TrafficLightController()
        {
            // create a StateMachine instance and aggregate it to variable "self"
            this.automaton= new StateMachine();
            var self= this.automaton;
 
            self.active= true; // init trafficlight state
 
            // click-handler toggles trafficlight state
            // by firing events
            self.toggleActive= function()
            {
                clearTimeout(self.timer);
                self.handleEvent(self.active ? "deactivate": "activate");
                self.active= !self.active;
            }
 
            // if Firebug present, log state transitions
            self.beforeStateChange= function(to, from)
            {
                if (typeof(console) == "object")
                    console.log("transition: %s -> %s", from, to);
            }
 
            // trafficlight view triggered after state change
            self.afterStateChange= function()
            {
                // get lamp DIV-sections
                var redLamp= document.getElementById("red_lamp");
                var yellowLamp= document.getElementById("yellow_lamp");
                var greenLamp= document.getElementById("green_lamp");
 
                // set expected DIV section background-color
                setLamp(redLamp, this.red, "red");
                setLamp(yellowLamp, this.yellow, "yellow");
                setLamp(greenLamp, this.green, "green");
            }
 
            // state engine definition
            self.states= {
                halt: {
                    enter: function() {
                        this.red= true;
                        this.yellow= false;
                        this.green= false;
                        self.timer= setTimeout(this.getTransitionHandler(this, "getReady"), 2000);
                    },
                    deactivate: "off"
                },
 
                getReady: {
                    enter: function() {
                        this.red= true;
                        this.yellow= true;
                        this.green= false;
                        self.timer= setTimeout(this.getTransitionHandler(this, "go"), 2000);
                    },
                    deactivate: "off"
                },
 
                go: {
                    enter: function() {
                        this.red= false;
                        this.yellow= false;
                        this.green= true;
                        self.timer= setTimeout(this.getTransitionHandler(this, "attention"), 2000);
                    },
                    deactivate: "off"
                },
 
                attention: {
                    enter: function() {
                        this.red= false;
                        this.yellow= true;
                        this.green= false;
                        self.timer= setTimeout(this.getTransitionHandler(this, "halt"), 2000);
                    },
                    deactivate: "off"
                },
 
                off: {
                    enter: function() {
                        this.red= false;
                        this.yellow= false;
                        this.green= false;
                        self.timer= setTimeout(this.getTransitionHandler(this, "blink"), 500);
                    },
                    activate: "halt"
                },
 
                blink: {
                    enter: function() {
                        this.red= false;
                        this.yellow= true;
                        this.green= false;
                        self.timer= setTimeout(this.getTransitionHandler(this, "off"), 500);
                    },
                    activate: "halt"
                }
            }
 
            // set the expected lamp background-color or gray for off state
            function setLamp(lamp, status, activeColor)
            {
                var color= status ? activeColor : "gray";
                lamp.setAttribute("style", "background-color: "+color);
            }
 
            //!! IMPORTANT parasitic inheritance implementation !!
            // this will make the stateengine accessible to instances
            return self;
        }
        /* ]]> */
    </script>
</head>
<body onload="start();">
<div id="traffic_light">
    <div id="red_lamp" class="light" />
    <div id="yellow_lamp" class="light" />
    <div id="green_lamp" class="light" />
</div>
</body>
</html>

Die gesamte Demo inkludiert nur die im Listing oben vorgestellte Zustandsautomaten-Klasse (Datei stateEngine.js) und enthält allen notwendigen Code in XHTML, CSS und Javascript.

Oberflächliches

Hinsichtlich XHTML existiert nur eine fix positionierte DIV-Sektion mit dem Ampelgehäuse und relativ dazu positioniert je eine weitere DIV-Sektion für jede der Lampen. Layout und Darstellung sind komplett mit CSS realisiert, Bitmaps benötigt man für so ein einfaches Beispiel nicht, Allerdings schafft der Internet Explorer es nicht, die Lampen richtig darzustellen, weil er CSS - wie alle Standards - fehlerhaft bis gar nicht unterstützt. Ein Grund mehr, ihn zumindest für die Entwicklung von solchen Beispielen gar nicht einzusetzen.

Parasitäre Vererbung

Interessant ist hier sowieso nur die in der Klasse TrafficLightController realisierte Implementierung eines spezifischen Zustandsautomaten, der von der Klasse StateMachine abgeleitet wird. Als prototypische und dynamisch typisierte Sprache ermöglicht Javascript gleich mehrere Möglichkeiten, Klassenvererbung zu realisieren, auch wenn die Sprache keine feste Syntax für Klassen hat.

Die hier verwendete Methode nennt sich „parasitäre Vererbung“ (parasitic inheritance). Dieses Verfahren passt gut zu dem von mir bevorzugten Programmierstil, der vor allem darin besteht, Klassen komplett im Konstruktor zu definieren (wie auch die StateEngine-Klasse im Beispiel). Die Verwendung von Konstruktoren zur Klassendefinition findet sich auch bei modernen Sprachen wie Scala, die Objektorientierung mit funktionaler und prototypischer Programmierung verbinden.

Der Vorteil dieses Ansatzes bei Javascript ist, dass alle Methoden und Attribute (auch die privaten) allen Methoden innerhalb der Klasse zur Verfügung stehen und die von außen zugänglichen Methoden „privileged“ sind, also auch auf private Elemente zugreifen können - die Zugriffsmöglichkeiten lassen sich von öffentlich bis privat also sehr präzise festlegen - wie man es sich bei objektorientierter Programmierung wünscht.

Die parasitäre Vererbung bewirkt, dass innerhalb der Klassendefinition im Konstruktor die Oberklasse aggregiert vorliegt und deshalb im Beispiel durch die Variable self angesprochen wird. self legt die Referenz auf den Zustandsautomaten fest und kann in verschachtelten Funktionen verwendet oder nach „außen“ weitergegeben werden, wenn man den Zustandsautomaten erreichen will. Die Variable this ist eine Objekt-Referenz auf das Konstruktor- also Funktionsobjekt, in welchem this verwendet wurde. Somit ist this nur im Konstruktor TrafficLightController an dieses Objekt gebunden- innerhalb von anderen Funktionen referenziert this also deren Umgebung. Variablen wie self können dazu dienen, einen Referenz auf den Kontext einer bestimmten Funktion festzulegen.

Nachdem der Konstruktor mit „return self“ verlassen wird, haben die so erzeugten Instanzen von TrafficLightController so direkten Zugriff auf alle Elemente von StateMachine, wie es bei einer klassischen Klassenvererbung („is“ Beziehung) der Fall ist. Aus einer „has“-Beziehung in der Klasse selbst wird in Instanzen also eine „is“-Beziehung.

Asynchrone Verarbeitung mit Closures

Gegenüber dem ersten Ampel-Zustandsautomaten oben, der synchron arbeitete, funktioniert dieser mittels Timern komplett asynchron. Dabei hilft die Funktion getTransitionHandler() welche die bei synchroner Verarbeitung genutzte changeState()-Funktion in eine Closure packt und dem Timer über eine parameterlose Callback-Funktion zugänglich macht. Diese Callback Funktion hat aber zur Laufzeit Zugriff auf alle Elemente des Zustandsautomaten. Ein ähnlicher „Trick“ mußte bei der Einrichtung des Klick-Eventhandlers toggleActive() angewendet werden: Die Umgebung eines Eventhandlers kennt nur das Fensterobjekt, nicht aber den Zustandsautomaten. Eine anonyme Funktion bzw. Closure erlaubt es hier erst, Zugriff auf Elemente wie self.active und self.handleEvent zu bekommen, die zum Zustandsautomaten gehören.

Statt mit handleEvent() hätte man auch per changeState() direkt einen Zustandsübergang herbeiführen können und sich die vielen Event-Definitionen mit „deactivate“ und „activate“ erspart. Aber hier sollte einfach mal ein Anwendungs-Beispiel gezeigt werden und dies Beispiel ist eigentlich zu einfach, weil Events hier nicht für unterschiedliche Zustände unterschiedlich behandelt werden. Model-View-Controller

Obwohl TrafficLightController sowohl Steuerung als auch Darstellung realisiert, wurden diese Bereiche sauber getrennt: Der Zustandsautomat verändert nur die „Modell“- Variablen red, yellow und green, aber kommuniziert nicht die View-Objekte bzw. DIV-Sections im DOM- Modell. Dieser „Nebeneffekt“ geschieht ausschließlich in der Methode afterStateChange() und könnte daher auch im Rahmen eines Refactorings leicht in eine andere (eigene) Klasse verlagert werden. Bei diesem einfachen Beispiel bringt das aber keine wesentlichen Vorteile, bei „echten“ Anwendungen sollte man Datenmodell, Ablaufsteuerung (wie den Zustandsautomaten) und Darstellung aber klar voneinander trennen, zumal eine dynamische Programmiersprache dies durch Metaprogrammierung (z.B. „scaffolding“) und losere Bindung einfacher als in statisch typisierten Sprachen wie Java oder C++ macht.

start/software/generischer_zustandsautomat_mit_javascript.txt · Zuletzt geändert: 2018/09/21 16:45 (Externe Bearbeitung)