Mit den Fehlern leben – Fehlerbehandlung in Scala-Flow-Design

If debugging is the process of removing software bugs, then programming must be the process of putting them in.– Edsger Dijkstra

Klar, Fehlerbehandlung muss sein, auch in Flow-Design. Dabei sind im Prinzip zwei Fehlerarten zu unterscheiden.

  1. Erwartete oder vorhersehbare Fehler – Dies sind Fehlersituation, die Bestandteil des Modells sind, welches mit Flow-Design entworfen und realisiert wird. Sie müssen über das Flow-Design-Modell darstellbar sein, am besten deklarativ. Der Benutzer einer Funktionseinheit wird damit gezwungen, sich mit dem Fehlerfall im Modell auseinanderzusetzen.
  2. Unerwartete Fehler – Mit diesen Fehlern muss vor allem die Infrastruktur, die Programmablaufumgebung umgehen können, denn es sind am Ende Fehler, die der Entwickler verursacht oder Umstände, die er nicht bedacht hat. Sie sind nicht Bestandteil des Flow-Design-Modells aber wohl Bestandteil des Flow-Design-Metamodells und damit Bestandteil der Flow-Design-Runtime oder in unserem Falle der Flow-Design-Bibliothek. Sie erfordern die Behandlung durch den Entwickler in Form einer Fehlersuche im Modell oder im Code.

Erwartete Fehler

Im Sinne von Flow-Design sind erwartete Fehler nichts weiter als Daten, die die Funktionseinheit auf die gleiche Art wie normale Daten verlassen – über Ports. Es sind zwei verschiedene Wege denkbar, wie das geschehen kann. Am Ende ist es eine Design-Entscheidung und hängt damit vom Design-Kontext ab. Der erste Weg ist die Anreicherung des ausfließenden Datentyps um einen Fehlerzustand, um einen Fehler über die „normalen“ Daten signalisieren zu können. Für diesen Weg wäre keine Änderung an der bisherigen Bibliotheksimplementierung notwendig. Datentypen unterliegen eh dem konkreten Design-Prozess des Benutzers der hier vorgestellten Flow-Design-Bibliothek.

Der zweite Weg ist die Spezifikation eines ausgezeichneten Ports, über den im Fehlerfall eine Fehlernachricht ausfließt. Dazu muss die bis jetzt vorliegende Implementierung im Prinzip ebenfalls nicht geändert werden. Jeder deklarierte Port OutputPortX kann als Fehler-Port gekennzeichnet werden.

Es ist sozusagen meine Design-Entscheidung im Rahmen der Implementierung einer Flow-Design-Bibliothek in Scala ein besonders benannten Port vorzusehen, dem ich die Semantik der expliziten Fehlerbehandlung zuordnen kann. Wem diese Design-Entscheidung nicht gefällt, kann auf den Einsatz dieses speziellen Ports verzichten. Wer sich darauf einlässt, der kann von Vereinfachungen bei der Programmierung der Fehlerbehandlung profitieren. Wer mehr als einen Fehlerport modellieren will, muss ab dem zweiten auch auf „normale“ Ausgabe-Ports ausweichen.

Da der ausgezeichnete Fehler-Port semantisch ein Ausgabe-Port ist, folgt er in der Implementierung den anderen Ausgabe-Ports.

trait ErrorPort[T] { port =>

  private[this] var errorOperations: List[T => Unit] = List()

  private def errorIsProcessedBy(operation: T => Unit) {
    errorOperations = operation :: errorOperations
  }

  val error = new Object {
    def -> (operation: T => Unit) =
        port.errorIsProcessedBy(operation)
    def isProcessedBy(operation: T => Unit) =
        port.errorIsProcessedBy(operation)
  }  

  protected def forwardError(exception: T) {
    if (!errorOperations.isEmpty) {
      errorOperations.foreach(forward => forward(exception))
    }
    else {
      println("no binding defined for error port of " + this)
    }
  }
}


Spezifikation eines Fehler-Ports

Wie ich einem vorhergehenden Artikel erwähnt hatte, ist die Funktionseinheit Collector nur für eine einmalige Verwendung gedacht. Es gibt keine Möglichkeit, sie zurückzusetzen. Jede dritte oder weitere Nachricht würde an die Liste bereits vorhandener Nachrichten angehängt. Mit der Spezifikation eines expliziten Fehler-Ports für die Funktionseinheit Collector zwinge ich nun den Benutzer der Funktionseinheit sich mit diesem vorhersehbaren Fehler auseinanderzusetzen und ihm in seinem Modell Beachtung zu schenken.

 
final class Collector(val separator: String) 
	with InputPort1[String] 
	with InputPort2[String] 
	with OutputPort[String]
	with ErrorPort[String]
{

  protected def processInput1(msg: String) {
    if (accumulation.length >= 2) 
      forwardError(this + 
          " got more than two input messages; not allowed")
    accumulateInput(msg)
  }

  protected def processInput2(msg: String) {
    if (accumulation.length >= 2)
      forwardError(this + 
          " got more than two input messages; not allowed")
    accumulateInput(msg)
  }
  …
}


Im der main-Methode von RunFlow wird nun der Fehler behandelt, indem er auf der Konsole ausgegeben wird. Er könnte aber auch an eine weitere Funktionseinheit weitergeleitet werden, die ihn auf andere Art verarbeitet, da Fehler-Ports ja auch Ausgabeports sind.

 
object RunFlow {
  def main(args: Array[String]) {
    …
    collector.error isProcessedBy(errMsg => {
      println("error received from " + collector + ": " + errMsg)
    })
    … 
  }
}

Flow-Design-Idiom in Scala realisieren

In unserem Beispiel gibt es nur eine Funktionseinheit mit einem Fehler-Port. Denkt man sich jedoch viele solcher Funktionseinheiten mit je einem Fehler-Port gleichen Nachrichtentyps, dann wäre für jede dieser Funktionseinheiten ähnliche drei Zeilen zu implementieren. Wenn die Fehlerausgabe immer dieselbe wäre, dann würde sich in den drei Zeilen immer nur der Name der Funktionseinheit ändern. Das widerspricht ganz dem DRY-Prinzip, welches natürlich auch für Bibliotheken gilt und dem Bibliotheksbenutzer Werkzeuge an die Hand geben sollte, um das Prinzip auch einhalten zu können. Am effektivsten ließe sich das DRY-Prinzip umsetzten, wenn es ein Möglichkeit geben würde, bei Bedarf für alle Funktionseinheiten mit einem Fehler-Port des gleichen Nachrichtentyps, die Fehlerverarbeitung mit einem Mal zu setzen. In Java oder C# würde man Ähnliches typischerweise mit einer statischen Funktion realisieren. In Scala gibt es dafür das Konzept der Companion Objects. Diese sind Singleton Objects und in der gleichen Datei wie gleichnamige Klassen definiert und erweitern diese quasi um statische Methoden.

 
abstract class FunctionUnit {
  … 
}

final object FunctionUnit {

  def onErrorAt[ErrorType] 
    (functionUnitWithErrorPortList: ErrorPort[ErrorType]*) 
    (errorOperation: ErrorType => Unit) 
  {
    functionUnitWithErrorPortList.foreach( 
      functionUnit => 
         functionUnit.error isProcessedBy(errorOperation) )
  }
  … 
}


Hier wird gleich von drei Scala-spezifischen Sprachkonzepten Gebrauch gemacht. Einmal wird eine variable Parameterliste definiert, wobei in Scala zur Kennzeichnung einer solchen Parameterliste in Anlehnung an den Kleene-Operator das Zeichen „*“ nach dem Parametertyp wie in regulären Ausdrücken benutzt wird.

ErrorPort[ErrorType]*

Weiterhin ist ErrorType hier ein Methodentypparameter und onErrorAt damit eine generische Methode.

Das dritte hier verwendete Sprachkonzept ist die Aufteilung der Parameterliste in zwei getrennt geklammerte Listen, auch Currying genannt.

 
def onErrorAt[ErrorType] 
    	(functionUnitWithErrorPortList: ErrorPort[ErrorType]*) 
    	(errorOperation: ErrorType => Unit)

Ist der letzte Parameter ein Methodenobjekttyp wie in unserem Fall, so lässt sich in Scala eine dynamisch spezifizierten Methode anstelle von runden Klammern auch in geschweifte Klammern einbetten. Dies erlaubt die Fehlerbehandlung auf eine Art zu formulieren, die einer nativen Sprach-Kontrollstruktur ähnlich kommt:

 
object RunFlow {
  def main(args: Array[String]) {
    …
    onErrorAt(collector)  {
      errMsg => println("error received: " + errMsg)
    }
    … 
  }
}


Durch die Definition der Methode onErrorAt mit variabler Parameterliste als erstem Parameter ließe sich – sehr kompakt und intuitiv leicht verständlich – für mehrere Funktionseinheiten mit gleich typisiertem Fehler-Port eine gemeinsame Fehlerbehandlung realisieren:

 
onErrorAt(collector, reverse, toUpper, toLower) { … }

In unserem Beispiel ist jedoch nur Collector mit einem Fehler-Port ausgestattet.

Unerwartete Fehler

Bis jetzt erzeugte eine Nachricht, die an einen Port ausgeleitet werden soll, der keine Verbindung zu irgendeiner Funktionseinheit besitzt, eine Fehlerausgabe auf der Konsole. Eigentlich stellt diese Fehlersituation einen Integrationsfehler dar. Der Entwickler des Modells hat vergessen, diesen Ausgangsport mit dem Eingangsport einer Funktionseinheit zu verbinden oder die ausfließenden Nachrichten anderweitig zu verarbeiten. Dies ist im Rahmen des durch den Entwickler erstellten Modells ein unerwarteter Fehler; mehr noch, es ist ein Fehler im Modell.
Warum nicht auch diesen Fehler nach den Prinzipien des Flow-Design verarbeiten?
Ich habe dazu die Implementierung der Klasse FunctionUnit um einen speziellen Integrationsfehler-Port erweitert:

 
abstract class FunctionUnit(val name: String) {
  … 
  private[this] var integrationErrorOperations:
      List[String => Unit] = List()

  private def onIntegrationError(
      integrationErrorOperation: String => Unit) 
  {
    integrationErrorOperations = 
      integrationErrorOperation :: integrationErrorOperations
  }

  protected def forwardIntegrationError(errorMsg: String) {
    if (!integrationErrorOperations.isEmpty) {
      integrationErrorOperations.foreach(
        forward => forward(errorMsg))
    }
    else {
      // at default print to stderr if no continuation is registered
      System.err.println(this + 
        " has an integration error: " + errorMsg)
    }
  }
}


Ähnlich der Methode onErrorAt gibt es auch für Integrationsfehler eine Methode, die es ermöglicht für mehrere Funktionseinheiten auf ein Mal eine Fehlerbehandlung festzulegen.

 
abstract class FunctionUnit {
  … 
}

final object FunctionUnit {

  def onIntegrationErrorAt 
       (functionUnitList: FunctionUnit*)
       (integrationErrorOperation: String => Unit) 
  {
    functionUnitList.foreach(
      functionUnit => 
        functionUnit.onIntegrationError(
          integrationErrorOperation) )
  }
  … 
}


Die Anwendung dieser Funktion gestaltet sich in unserem Beispiel folgendermaßen:

 
object RunFlow {
  def main(args: Array[String]) {
    …
    onIntegrationErrorAt(
      reverse, normalizer, notConnectedFunctionUnit) 
    {
      errMsg => System.err.println(
          "integration error happened: " + errMsg)
    }
    … 
  }
}


Eine sinnvoller Anwendung wäre jedoch die Ausgabe in ein Logging-System, da per Default, wenn keine Fehlerbehandlung registriert ist, ein Integrationsfehler sowieso auf die Fehlerkonsole ausgegeben wird.

Im Sinne dieser Änderungen wurden Integrationsfehler in allen Ausgabe-Port-Implementierung, wie am Beispiel des ErrorPorts im folgenden gezeigt, über forwardIntegrationError ausgeleitet. Dazu muss jeder Augabe-Port-Trait lediglich von FunctionUnit ableiten damit forwardIntegrationError bekannt ist.

 
trait ErrorPort[T] extends FunctionUnit { port =>
  …
  protected def forwardError(exception: T) {
    if (!errorOperations.isEmpty) {
      errorOperations.foreach(forward => forward(exception))
    }
    else {
      forwardIntegrationError(
        "no binding defined for error port of " + this)
    }
  }
}


Die Grenzen einer internen DSL

Hier zeigen sich auch die Grenzen interner DSLs im Allgemeinen – eine statische Prüfung von Kontextbedingungen ist nicht möglich. Ist eine solche verletzt, wie z.B. bei einem wie oben gesehen über den IntegrationError-Port ausgeleiteten Integrationsfehler, dann ist dies erst zur Laufzeit feststellbar. Das kann in bestimmten Situationen zu spät sein, insbesondere dann, wenn die Flow-Design-Systeme größer werden und die Testabdeckung der integrierenden Funktionseinheiten im Projektstress nicht immer bei annähernd 100% gehalten werden kann.

Auch andere produktivitätssteigernde Eigenschaften moderner IDEs wie Code-Completion, Code-Refactoring, Code-Template-Programming und Quick-Fix-Hilfen sind nur mit einer externen DSL realisierbar (projizierende Editoren, wie MPS von Jetbrains mal ausgenommen).
In Eclipse lassen sich externe DSLs mit allen diesen Eigenschaften recht komfortabel mit Xtext umsetzen. Dahin soll demnächst die Reise gehen…

Auch die zyklische Verbindung von Funktionseinheiten lässt sich statisch nur über eine externe DSL prüfen. Warum diese problematisch sind, will ich im nächsten Artikel diskutieren.

Randnotiz

Der hier in Auszügen dargestellte Code kann auf GitHub nachgelesen werden. Die Flow-Design-Bibliothek zu diesem Artikel ist im Paket de.grammarcraft.scala.flow nachzulesen, während die Beispielapplikation in de.grammarcraft.scala.flowtraits2 zu finden ist.

One Reply to “Mit den Fehlern leben – Fehlerbehandlung in Scala-Flow-Design”

  1. Pingback: Extend Your Flow Horizon – Flow-Design mit Xtend | Beyond Coding