State Pattern-CLI: Realisierung von Transitionen und Menüstruktur

https://bildung.social/@oerinformatik/110504024656041988

https://oer-informatik.de/state-pattern-cli3

tl/dr; (ca. 20 min Lesezeit): Am Beispiel eines Command-Line-Interfaces wird das State-Pattern beispielhaft in Java implementiert - nachdem die Grundstruktur und die Zustands-ENUMS in den vorigen Teilen erstellt wurden, geht es jetzt um die objektorientierte Realisierung der Transitionen und Menüstruktur. (Zuletzt geändert am 11.06.2023)

  1. Aufbau des State-Patterns allgemein und grundlegende Struktur
  2. Realisierung der Zustände als Singletons (per Java-ENUMS)
  3. Realisierung der Transitionen und deren (dieser Artikel)
  4. Realisierung von Effekten und Guards von Transitionen

Das bisherige Grundgerüst

Es soll eine Kommandozeilen-Benutzeroberfläche für ein Programm erstellt werden, das mit folgendem Zustandsdiagramm modelliert wurde:

Zustandsdiagramm der Kommendozeilen-Benutzerführung
Zustandsdiagramm der Kommendozeilen-Benutzerführung

In der bisherigen Implementierung wurde bereits das Grundgerüst des State-Diagramms implementiert und die Zustandsobjekte in einem ENUM gekapselt:

Die vorgeschaltete abstrakte Klasse AbstractUIState
Die vorgeschaltete abstrakte Klasse AbstractUIState

Zur Orientierung nochmal im Schnelldurchlauf die Aufgaben der bisherigen Klassen/Interfaces:

  • eine Kontext-Klasse UIContext, die das Objekt repräsentiert, dessen Zustand sich ändert,

  • einige Zustandsklassen (beispielhaft AnmeldenUI und ListUI), die das Verhalten in bestimmten Zuständen implementieren,

  • die ENUM-Klasse (UIStateEnum), die die einzelnen Zustandsobjekte als Singletons zentral verwaltet,

  • ein gemeinsames Interface UIStateInterface der Zustandsklassen, dass die Austauschbarkeit der Zustände ermöglicht und

  • eine abstrakte Klasse AbstractUIState, die gemeinsame Implementierungen aller Zustandsklassen umsetzt.

Transitionen erweitern

Bislang erfolgt der Zustandsübergang nur fest implementiert in der Kontext-Klasse - das entspricht natürlich nicht dem Wesen einer Benutzeroberfläche. Daher soll das Projekt jetzt auf objektorientierte Art mit den Zustandsübergängen (Transitionen) erweitert werden. Hierzu sind einige Anpassungen fällig:

  • Wir brauchen eine Klasse Transition, die einen einzelnen Zustandsübergang und dessen Eigenschaften erfasst (Transitionsziel, auslösende Trigger, auszuführende Events, Eintrag im Menü).

  • Eine weitere Klasse (Transitions) soll die unterschiedlichen Transitionen eines Zustands aggregieren und mögliche Transitionen des aktiven Zustands als Menü ausgeben.

  • Jede Zustandsklasse soll mit Transisions über so ein Aggregat der möglichen Zustandsübergänge verfügen. Es wird also ein Attribut vom Typ Transisions in AbstractUIState eingefügt.

OOP-Entwurf zur Umsetzung von Transitionen (UML-Klassendiagramm von Transition, Transitions, AbstractUIState)
OOP-Entwurf zur Umsetzung von Transitionen (UML-Klassendiagramm von Transition, Transitions, AbstractUIState)

Im Einzelnen:

Die Klasse Transition, die die Eigenschaften eines Zustandsübergangs modelliert

Im UML-Zustandsdiagramm bestehen Transitionen aus drei Komponenten:

  • ein Event (Trigger), der den Zustandsübergang auslöst,

  • ein Guard (Bedingung), der festlegt, unter welchen Bedingungen ein Zustandsübergang stattfinden darf,

  • ein Effekt, also eine Aktivität, die beim Zustandsübergang ausgeführt wird.

  • Natürlich ist eine Transition zudem charakterisiert durch den Ausgangs- und Zielzustand.

Wir vereinfachen zunächst etwas und erstellen unsere Klasse mit drei Attributen (sowie zugehörigem Getter und Konstruktor):

Die Klasse Transition als UML-Klassendiagramm
Die Klasse Transition als UML-Klassendiagramm

Es sind drei Attribute je Transition vorgesehen:

  • Der Zielzustand target (in Form des Enum-Objekts),

  • ein Trigger in Form des Zeichens (Taste), deren Eingabe den Zustandsübergang einleitet,

  • ein beschreibender Text für das Menü oder die Hilfe, der den Zustandsübergang benennt/beschreibt.

Um später ein Menü mit allen möglichen Zustandsübergängen generieren zu können, lassen wir uns alle Informationen in einer Zeile ausgeben und implementieren daher eine toString()-Methode.

Der Quelltext dieser Klasse sieht folgendermaßen aus:

Zusammenfassung aller Transitionen eines Zustands in der Klasse Transitions

Damit sich die Zustandsklassen nicht selbst um alle zugehörigen Transitionen kümmern müssen, wird diese Funktionalität ausgelagert in die Klasse Transitions. Diese Klasse

  • speichert, für welchen Ausgangszustand source sie zuständig ist,

  • sammelt alle Transition-Objekte dieser Zustandsklasse in einer ArrayList (transitionList),

  • sucht auf Basis eines Triggers (hier: eine Zeichenkette), ob dieser eine Transisiton auslöst (findTransition(...)),

  • führt gefundene Transitionen durch und gibt den neuen Zustand zurück (transist(...)).

Die genannten Funktionen lassen sich wie folgt modellieren:

Die Implementierung könne etwa so aussehen:

Die beiden weiteren Methoden dieser Klasse verdienen etwas mehr Aufmerksamkeit:

Gibt es für den vorliegenden Trigger (die eingegebene Zeichenkette) eine zuständige Transition? Das prüft findTransition(...):

transist(...) führt die Transition durch:

  • Ist überhaupt eine Änderung des Zustands erfolgt, oder bleibt alles beim Alten?

  • Starte die Aktivität beim Verlassen des alten Zustands (exit()).

  • Starte die Aktivität beim Betreten des neuen Zustands (entry()).

  • Übergebe den aktuellen Zustand an das aufrufende Modul.

Implementiert ist das folgendermaßen:

Anpassungen der abstrakten Klasse AbstractUIState

In den Zustandsklassen sollen die Transitionen über Transitions aggregiert werden. Dazu ist ein Attribut transitions nötig, eine Methode, die ein neues Transitionsobjekt anlegt (initializeTransitions()) sowie eine Methode, die neue Transitionen für das jeweilige Zustandsobjekt zu transitions hinzufügt (addTransition()):

Jedes Zustandsobjekt soll zukünftig selbst prüfen, ob ein Trigger (hier vereinfacht: ein Tastendruck bzw. eine übergebene Zeichenkette) einen Zustandswechsel hervorruft. Dazu benötigen wir eine weitere Methode, die die Transitionen sucht und ausführt: handleTriggerEvent():

Ergänzungen des Interface

Die Methoden handleTriggerEvent() und initializeTransitions() müssen für alle Zustandsobjekte über das Interface aufrufbar sein. In Java bestimmt der Instanztyp, welches Verhalten genutzt wird, es können aber jeweils nur Methoden aufgerufen werden, deren Typ der Referenz diese Methoden deklarieren. Da der Aufruf über eine Interface-Referenz von UIStateInterface erfolgt, müssen wir die neuen Methoden dort aufnehmen:

Die Änderungen an Interface und abstrakter Klasse verändern auch das UML-Klassendiagramm:

Das Interface UIStateInterface und die Klasse AbstractUIState mit den ergänzent Methoden initializeTransitions() und handleTriggerEvent(...)
Das Interface UIStateInterface und die Klasse AbstractUIState mit den ergänzent Methoden initializeTransitions() und handleTriggerEvent(...)

Transitionen den Zuständen zuordnen

Jetzt folgt etwas Handarbeit: Wir müssen jeden einzelnen Zustandsübergang (Transition) bei allen Zustandsklassen einfügen (per addTransition()). Hierzu wird initializeTransitions() spezialisiert:

  • Zunächst wird das Transitionsobjekt erstellt, in dem die Methode der Superklasse (AbstractUIState) aufgerufen wird.

  • Danach wird für jede gewünschte Transition einmal per addTransition() das Transitionsziel (per ENUM), die Trigger-Zeichenkette und ein Titel übergeben.

Im Fall der Zustandsklasse AnmeldenUI wäre dies:

Wenn alle im Zustandsdiagramm modellierten Zustandsklassen erstellt wurden, wird der Umfang bei ListUI deutlich größer - und die Vorteile einer einfachen Navigation per State-Pattern ersichtlich. Ich habe die betreffenden Transitions hier zunächst auskommentiert, da im Tutorial ja bislang nur ANMELDEN_UI und LIST_UI umgesetzt wurden - die anderen Verhaltensklassen müssen natürlich analog implementiert werden.

Damit das Gesamtsystem wieder in einem definierten Zustand ist, muss jetzt nur noch die Kontext-Klasse angepasst werden:

Anpassungen im Kontext

Gravierende Anpassungen ergeben sich auch am Kontext:

  • Zu Beginn muss jedes Zustandsobjekt einmal konfiguriert werden: die Methode initializeTransitions(); wird daher in der main()-Methode für jedes Zustandsobjekt einmal aufgerufen.

  • Danach wird das Kontext-Objekt erstellt,

  • der Ausgangszustand per setUiStateEnum() gesetzt und

  • eine Methode loop() als Dauerschleife gestartet, die ab jetzt alle weitere Logik übernimmt.

In dieser loop() wird

  • zunächst auf eine Eingabe auf der Konsole gewartet (readln() wird unten implementiert),

  • geprüft, ob diese Zeichenkette einen Zustandswechsel hervorruft (die Methode handleTriggerEvent() delegiert dies an das aktive Zustandsobjekt) und

  • der Zustandswechsel durchgeführt.

Die Methode handleTriggerEvent() delegiert die Ausführung der Triggerauswertung - ganz wie im State-Pattern vorgesehen - an den aktiven Zustand:

Die Methode readln() schließlich gibt einfach die Eingabe in der Konsole zurück. Diese muss leider immer mit return (Zeilenumbruch) abgeschlossen werden.

Ab diesem Punkt ist das System wieder benutzbar, aber noch nicht allzu komfortabel. Man muss auswendig wissen, mit welchem Trigger man welchen Zustand erreichen kann. Mit drei kleinen Änderungen können wir unsere Konsolen-App noch mit einem Menü versorgen - dann ist das schon fast eine runde Sache.

Beim Eintritt in einen Zustand ein Menü mit allen möglichen Transitionen ausgeben

Wir haben schon fast alle Informationen zusammen, die wir benötigen, um uns ein Menü auszugeben:

Die Klasse Transitions kennt alle zugelassenen Transitionen des aktiven Zustands - wir müssen sie nur noch mit einer neuen Methode aufbereiten:

Wenn uns Transitions diese Einträge liefert, können wir sie in AbstractUIState in einer neuen Methode aufbereiten und ausgeben:

Wir müssen showMenue() noch dem UIStateInterface hinzufügen, damit wir das Menü aus dem UIContext heraus erreichen können:

Die Methode showMenue() können wir schließlich in entry() des UIContext aufrufen, damit wir bei jedem Eintritt in einen neuen Zustand direkt ein Menü erhalten:

Damit wir direkt nach Programmstart ein Menü erhalten (denn bis dahin hat ja noch ein Zustandsübergang stattgefunden) müssen wir einmal das Menü händisch aufrufen. Ich habe dazu in der main()-Methode nach Setzen des Start-Zustands die Zeile myUI.uiStateEnum.getUIState().showMenue(); angefügt:

Das Programm testen:

Wenn wir das Programm starten, meldet sich die Konsolenapp erwartungsgemäß mit einer Liste der möglichen Menüpunkte (Transitionen):

############################################################
######################## AnmeldenUI ########################
############################################################
'l': Anmelden (LIST_UI)

UI-Menu (Eingabe mit [ENTER] beenden)>

Eine Eingabe von l löst wunschgemäß die Transition aus (wir haben noch relativ viele println im Code, die das dokumentieren). Es wir das neue Menü ausgegeben.

Prüfe, ob der Trigger <l> eine Transition auslöst:
 - Prüfe Transition LIST_UI mit Trigger <l>
 ==> Transition zu LIST_UI gefunden! (Weitere Suche wird gestoppt)
>>> Leaving state AnmeldenUI
<<< Entering state ListUI

############################################################
########################## ListUI ##########################
############################################################
'a': Abmelden (ANMELDEN_UI)

UI-Menu (Eingabe mit [ENTER] beenden)>

Dargestellt habe ich hier das Menü mit den beiden Zuständen AnmeldenUI und ListUI. Spätestens jetzt ist aber ein guter Zeitpunkt die übrigen Zustandsklassen und deren Transitionen anhand des oben abgebildeten UML-Zustandsdiagramms zu implementieren!

Das “Big Picture”

Am Ende der dritten Iteration ist schon relativ viel umgesetzt und wir haben eine funktionierende Navigation zwischen den unterschiedlichen Benutzerschnittstellen-Zuständen.

Das UML-Klassendiagramm des Gesamtsystems sieht mittlerweile schon recht umfangreich aus:

Klassendiagramm am Ende der 3. Iteration
Klassendiagramm am Ende der 3. Iteration

Was bleibt noch zu tun?

Um das komplette Zustandsdiagramm abzubilden und einen Zustandsautomaten zu implementieren fehlen aber noch einige Komponenten:

  • Transitionen bestehen aus Triggern, Guards und Effekten - bislang sind nur erstere implementiert

  • Trigger können bislang nur für Zustandswechsel vergeben werden: innere Aktivitäten können darüber noch nicht ausgelöst werden.

  • Innere Zustände, parallele Zustände… es gibt viel zu tun in der…

… vierten Iteration!


Hinweis zur Nachnutzung als Open Educational Resource (OER)

Dieser Artikel und seine Texte, Bilder, Grafiken, Code und sonstiger Inhalt sind - sofern nicht anders angegeben - lizenziert unter CC BY 4.0. Nennung gemäß TULLU-Regel bitte wie folgt: State Pattern-CLI: Realisierung von Transitionen und Menüstruktur” von oer-informatik.de (H. Stein), Lizenz: CC BY 4.0. Der Artikel wurde unter https://oer-informatik.de/state-pattern-cli3 veröffentlicht, die Quelltexte sind in weiterverarbeitbarer Form verfügbar im Repository unter https://gitlab.com/oer-informatik/design-pattern/state-pattern. Stand: 11.06.2023.

[Kommentare zum Artikel lesen, schreiben] / [Artikel teilen] / [gitlab-Issue zum Artikel schreiben]

Kommentare gerne per Mastodon, Verbesserungsvorschläge per gitlab issue (siehe oben). Beitrag teilen: