Zustands-Entwurfsmuster (State-Pattern) in PHP


In meiner OpenStreetMap Radkarte verwende ich ein Klasse, die GPX-Dateien analysiert. Bei einer Analyseart wird ein Zustandsautomat mit mehreren Case-Strukturen umgesetzt. Den Bereich werde ich hier mit dem Entwurfsmuster Zustand (State-Pattern) refaktorisieren.

<?php
 
class GPXFileFastAnalyze {
  public $distance;
  public $startdate;
   
  private $gpxFileName;
  private $shortInfoExit = false;
  private $shortInfoFound = false;
  private $shortInfoData = 0;
   
  function __construct($gpxFileName) {
    $this->gpxFileName = $gpxFileName;  
  }
   
  public function doAnalyze() {
    $parser = xml_parser_create();
     
    xml_set_object($parser, $this);
    xml_set_element_handler($parser, "startElementHandler", "endElementHandler");
    xml_set_character_data_handler($parser, "characterDataHandler");
     
    $gpxFile = fopen($this->gpxFileName, "r");
       
    while (! feof($gpxFile) AND $this->shortInfoExit == false) {
      $line = fgets($gpxFile , 4096);
      xml_parse($parser, $line);
    }
     
    xml_parser_free($parser);
     
    return $this->shortInfoFound;
  }
   
  protected function startElementHandler($parser, $name, $attrs) {
    $this->shortInfoData = 0;
     
    switch ($name) {
      case "DISTANCE":
        $this->shortInfoData = 1;
        break;
      case "STARTDATE":
        $this->shortInfoData = 2;
        break;  
      case "TRK":
      case "RTE":
        $this->shortInfoExit = true;
        break;  
    }
  }
   
  protected function endElementHandler($parser, $name) {
    // Do nothing special, when closing tag is detected
  }
   
  protected function characterDataHandler($parser, $tagData) {
    switch ($this->shortInfoData) {
      case 1:
        $this->distance = floatval($tagData);
        $this->shortInfoFound = true;
        break;
      case 2:
        $this->startdate = DateTime::createFromFormat("Y-m-d", $tagData);
        break;  
    }
     
    $this->shortInfoData = 0;
  }
}

In der Methode doAnalyze wird ein XML-Parser gestartet, dem Zeile für Zeile die Daten der XML-Datei übergeben werden. Trifft der Parser auf ein öffnendes XML-Tag, so wird die Methode startElementHandler aufgerufen. Die Daten, die durch das XML-Tag umschlossen werden, werden an die Methode characterDataHandler übergeben und beim schließenden XML-Tag wird endElementHandler aufgerufen.

Die Daten, die mich in diesem Fall interessieren, sind in den XML-Tags <distance> und <startdate> enthalten. Sobald die Track- oder Routendaten gefunden werden, kann die Analyse beendet werden.

Ich merke mir also in der Methode startElementHandler, welches Tag zuletzt gefunden wurde (in der Variablen shortInfoData) oder ob die Analyse beendet werden kann (shortInfoExit).

Wenn dann die Methode characterDataHandler aufgerufen wird, übernehme ich die Daten in die entsprechenden Variablen.

Die Variable shortInfoData dient also als Merker für den aktuellen Status (kein relevantes XML-Tag gefunden, <distance>-Tag gefunden, <startdate>-Tag gefunden). Zusätzlich verwendet ich den Merker shortInfoExit, um die Parser-Schleife zu verlassen, wenn keine interessanten Daten mehr folgen.

Daraus lässt sich das Status-Diagramm (ganz oben) entwickeln. Das Status-Entwurfsmuster besagt nun, dass für jeden möglich Status eine Klasse erstellt wird, die alle das gleiche Interface implementieren. Je nach Status wird dann eine Instanz der entsprechenden Statusklasse für die Datenverarbeitung verwendet.

interface GPX_FileFastAnalyzeState {
  function getStateByTagName($name);
  function analyzeData($data);
  function doExit();
}

Jede Status-Klasse implementiert das obige Interface.

Die Methode SetStateByTagName setzt eine neue Instanz der Statusklasse. Diese wird vom aktuellen Status anhand des übergebenen Tag-Namens ermittelt.

Die Methode DoExit liefert Wahr zurück, wenn in dem aktuellen Status die Parserschleife abgebrochen werden soll.

class GPX_FileFastAnalyze {
 
  public $distanceInKm;
  public $startDate;
  private $_gpxFileName;
  public $shortInfoFound = false;
  private $_currentState;
 
  function __construct($gpxFileName) {
    $this->_gpxFileName = $gpxFileName;
  }
 
  public function setState($newState) {
    $this->_currentState = $newState;
  }
 
  /*
   * Mehtod to do the analysis.
   * A xml parser will be created, the gpx file will be open and the file data
   * will be handed to the parser line by line. 
   */
  public function doAnalyze() {
    // Set the initial state to: idle
    $this->_currentState = new GPX_FileFastAnalyzeIdleState($this);
 
    // Create xml parser an connect the callback methods
    $parser = xml_parser_create();
    xml_set_object($parser, $this);
    xml_set_element_handler($parser, "startElementHandler", "endElementHandler");
    xml_set_character_data_handler($parser, "characterDataHandler");
 
    $gpxFile = fopen($this->_gpxFileName, "r");
 
    // Read the input file line by line and hand over the data to the parser.
    // Stop the loop until the file ends or the current state commands it.
    // The xml parser do its work and calls the callback methods according to
    // the xml data.
    while (!feof($gpxFile) AND $this->_currentState->doExit() == false) {
      $line = fgets($gpxFile, 4096);
      xml_parse($parser, $line);
    }
 
    xml_parser_free($parser);
 
    return $this->shortInfoFound;
  }
 
  /*
   * Callback for xml parser when starting xml is found.
   */
  protected function startElementHandler($parser, $name, $attrs) {
    $this->_currentState->setStateByTagName($name);
  }
 
  /*
   * Callback for xml parser when a closing xml tag is found.
   */
  protected function endElementHandler($parser, $name) {
    // Do nothing special, when closing tag is detected
  }
 
  /*
   * Callback for xml parser when character data is found.
   */
  protected function characterDataHandler($parser, $tagData) {
    $this->_currentState->analyzeData($tagData);
  }
}

Die Parserschleife wird jetzt so umgebaut, dass bei einem neuen Start-Tag (startElementHandler) die aktuelle Statusklasse nach dem nächsten Status gefragt wird. Dabei wird der neue Start-Tag mit übergeben. Die Interface-Methode getStateByTagName gibt eine Instanz auf den nächsten Status zurück und diese wird als aktueller Status gespeichert.

Die Implementierung der Status-Klassen sieht wie folgt aus:

/*
 * Abstract base state class which implements the standard behavior of the
 * GPX_FileFastAnalyzeState interface.
 */
abstract class GPX_FileFastAnalyzeBaseState implements GPX_FileFastAnalyzeState {
 
  protected $gpxAnalyzer;
 
  function __construct($gpxAnalyzer) {
    $this->gpxAnalyzer = $gpxAnalyzer;
  }
 
  public function doExit() {
    return FALSE;
  }
 
}
 
/*
 * Class to analyze the distance tag.
 */
class GPX_FileFastAnalyzeDistanceState extends GPX_FileFastAnalyzeBaseState {
 
  public function setStateByTagName($name) {
    // Statechange is performed after analyzing distance
  }
   
  public function analyzeData($data) {
    // Get the distance from data
    $this->gpxAnalyzer->distanceInKm = floatval($data);
    // Inform the analyzer on detected data
    $this->gpxAnalyzer->shortInfoFound = true;
    // Set new state to: idle
    $this->gpxAnalyzer->setState(new GPX_FileFastAnalyzeIdleState($this->gpxAnalyzer));
  }
}
 
/*
 * Class to analyze the start date tag.
 */
class GPX_FileFastAnalyzeStartDateState extends GPX_FileFastAnalyzeBaseState {
 
  public function setStateByTagName($name) {
    // Statechange ist performed after analyzing time
  }
   
  public function analyzeData($data) {
    // Extract the date from tag data
    $this->gpxAnalyzer->startDate = \DateTime::createFromFormat("Y-m-d", $data);
    // Inform the analyzer on detected data
    $this->gpxAnalyzer->shortInfoFound = true;
    // Set new state to: idle
    $this->gpxAnalyzer->setState(new GPX_FileFastAnalyzeIdleState($this->gpxAnalyzer));
  }
}
 
/*
 * Class to represent the stop state. If the stop state is set, the analyzer
 * stops the work and exits.
 */
class GPX_FileFastAnalyzeStopState extends GPX_FileFastAnalyzeBaseState {
 
  public function setStateByTagName($name) {
    // Never move to another state
  }
   
  public function analyzeData($data) {
    // Nothing to analyze, stop considered
  }
 
  public function doExit() {
    return TRUE;
  }
}
 
/*
 * Class to represent the idle state. In idle state the found xml tags are 
 * analyzed and the state will change accordingly
 */
class GPX_FileFastAnalyzeIdleState extends GPX_FileFastAnalyzeBaseState {
 
  public function setStateByTagName($name) {
    switch ($name) {
      case "DISTANCE" :
        $this->gpxAnalyzer->setState(new GPX_FileFastAnalyzeDistanceState($this->gpxAnalyzer));
        break;
 
      case "STARTDATE":
        $this->gpxAnalyzer->setState(new GPX_FileFastAnalyzeStartDateState($this->gpxAnalyzer));
        break;
 
      case "TRK":
      case "RTE":
        $this->gpxAnalyzer->setState(new GPX_FileFastAnalyzeStopState($this->gpxAnalyzer));
        break;
 
      default:
        // Leave current state at idle
        break;
    }
  }
 
  public function analyzeData($data) {
    // Nothing to analyze
  }
}

Es gibt vier Statusklassen, die unterschiedliche Aktionen durchführen, je nachdem welche (welcher Status) aktiv ist.

Auf den ersten Blick scheint es, als hätte die Refaktorierung keine Vorteile gebracht. Der Code ist komplexer (vier Klassen mehr) und auch nicht kleiner. Warum also das ganze?

Der Sinn erschließt sich, wenn neue Funktionalität hinzukommt. Es ist nur noch eine Case-Struktur vorhanden, die angepasst werden muss. Vorher waren es zwar nur zwei, aber es handelt sich auch um ein einfaches Beispiel. Die Abfrage, in welchem Status man sich befindet, hätte bei größeren Programmen wesentlich öfter durchgeführt werden müssen.

Die Übersicht nimmt zu, da die Folgestatus in jedem Status ersichtlich sind.