DSL mit Xtext

DSL-Entwicklung mit Eclipse Xtext und Scala

Michael Adams
©iStockfoto.com/maxuser

Eclipse Xtext ermöglicht die einfache Entwicklung domänenspezifischer Sprachen (DSLs) und die Erzeugung von Sprachinstanzen in der Eclipse IDE. Die Sprache Scala bietet integrale Konzepte zur Erstellung von Domänensprachen und ermöglicht durch ihre moderne Architektur eine flexible und erweiterbare Abbildung auf beliebige Zielsprachen. Was spricht dagegen, diese Konzepte miteinander zu verbinden? Der folgende Beitrag beschreibt die Möglichkeiten der Kombination von Xtext und Scala anhand von realen Problemstellungen im Softwareentwicklungsprozess.

Domänenspezifische Sprachen sind in der Softwarewelt zurzeit ein viel diskutiertes Thema. Durch die zunehmende Ausrichtung moderner objektorientierter Sprachen auf syntaktische Einfachheit und natürlichsprachliche Formulierbarkeit sind die Möglichkeiten der Erstellung von DSLs vielfältig. Nichtsdestotrotz stellt die gute Umsetzbarkeit von DSLs eine große Herausforderung an das Sprachdesign moderner objektorientierter Sprachen dar. Die Sprache Scala hat diesen Spagat zwischen guter Lesbarkeit und praxisnaher Verwendung sehr gut gemeistert, denn die Sprache unterstützt zwei unterschiedliche Konzepte: einerseits die Formulierung von Sprachen in EBNF-Notationen für die Erstellung von externen DSLs durch den sprachintegrierten Parser-Generator; andererseits die Nutzung der spracheigenen Möglichkeiten für die Erstellung interner DSLs.

Artikelserie

Teil 1: Einführung in DSLs und Erstellungsmöglichkeiten in Scala und Xtext
Teil 2: Erzeugung einer intuitiv nutzbaren DSL sowie einer Eclipse-Sprachumgebung; Verwendung einer mit Xtext und Scala erzeugten DSL.

Scala und DSLs

Wer kennt nicht die Probleme, die in früheren Zeiten der Softwareentwicklung mit der Erzeugung kleiner domänenspezifischer Sprachen für den täglichen Gebrauch einhergingen – von der Wahl der geeigneten Werkzeuge für die lexikalische Analyse und der Codeerzeugung bis hin zur Einbettung dieser Werkzeuge in den Softwareentwicklungsprozess? Mit der Einführung von XML als Beschreibungsmittel für die Strukturierung beliebig komplexer Daten ist die Erstellung von Mini-Sprachen beinahe gänzlich in den Hintergrund gerückt. Für beinahe alle Zwecke der Darstellung von technischen, aber auch fachlichen Inhalten war XML das Mittel der Wahl – und ist es zum Teil auch heute noch. Die komplexer werdende Geschäftslogik der heutigen fachlichen Anforderungen erfordert eine einfache, möglichst in die Programmiersprache eingebettete Möglichkeit der Formulierung abstrakter, domänenspezifischer Inhalte. Das spiegelt sich in der wachsenden Popularität dynamischer Sprachen wie zum Beispiel Ruby oder Groovy wider.

Die objektfunktionale Sprache Scala stellt mit ihrem sprachintegralen Konzept des Parser-Kombinators ein elegantes Hilfsmittel für die Beschreibung solcher Domänensprachen zur Verfügung. Die Idee dabei ist die Formulierung von ausführbaren Grammatiken, die durch Komposition von einfachen zu komplexeren Parsern entstehen. Kompositionen werden dabei als funktionale Abbildungen beschrieben. Die Implementierung der Parser-Klasse basierend auf dem Konzept eines Monads (Kasten: „Monads“) garantiert dabei die kompositionelle Integrität der verwendeten Parser-Typen [1].

Typischerweise besteht ein Parser-Kombinator aus einer Menge von Produktionsregeln. Diese nutzen die Operatoren der Scala-Parser-Klasse zur Komposition von Parser-Resultaten und Codegenerierung nach dem in Tabelle 1 abgebildeten Schema.

Operator Bedeutung
„…“ Definition eines Literals
„…“.r Definition eines regulären Ausdrucks
P <~ Q Komposition von Parser P und Parser Q, wobei das Resultat der funktionalen Abbildung von P auf die Eingabe für die weitere Verarbeitung erhalten bleibt
P ~> Q Komposition von Parser P und Parser Q, wobei das Resultat der funktionalen Abbildung von Q für die weitere Verarbeitung erhalten bleibt
P ~ Q Komposition von Parser P und Parser Q, wobei das Resultat der funktionalen Abbildungen sowohl von P als auch von Q für die weitere Verarbeitung erhalten bleiben
P | Q Alternative Auswahl von Parser P oder Parser Q
P ^^ f Konvertierung des Resultats von Parser P auf Resultat f

Tabelle 1: Produktionsregeln des Parser-Kombinators

Parser-Kombinatoren haben folgende Vorteile…

  • Kompaktes, in die Sprache integriertes Scannen, Parsen und Codegenerieren
  • Vorgefertigte Parser für Java, Regex u. a.
  • Einfache Syntax mit wenigen Parser-Operatoren
  • Gute Erweiterbarkeit durch Vererbung

 …und Parser-Generatoren folgende Nachteile:

  • Der Standard-Parser arbeitet mit Backtracking, wiederholtes Parsen geht zu Lasten der Performanz.
  • Obwohl die Parser Syntax verhältnismäßig schlank ist, bleibt ein gewisser Einarbeitungsaufwand.

Die Herausforderung bei der Erstellung einer externen DSL besteht in den notwendigen Kenntnissen in zwei unterschiedlichen Bereichen: Zum einen erfordert die Konstruktion der  Grammatik eines Parsers Wissen über die interne Arbeitsweise des Scanners, die Erzeugung eines AST und die Codegenerierung. Falls die Performanz des Parsers signifikant sein sollte, sind zudem Kenntnisse hinsichtlich der unterschiedlichen Grammatiktypen wie LL(1) oder LALR wünschenswert, um die Arbeitsweise des Parsers zu optimieren. Zum anderen dürfen bei der Konstruktion einer geeigneten Grammatik das Design und die Lesbarkeit der entstehenden Domänensprache nicht vernachlässigt werden.

Monads
Monads stammen ursprünglich aus der Sprache Haskell und beschreiben dort Typen aus vielen Bereichen wie IO, Kollektionen, um nur einige zu nennen. Die wesentlichen Funktionen eines Monads lassen sich in Scala über funktionale Abbildungen definieren. Demnach definiert sich ein typischer Monad M parametrisiert über Typ A in der Form:

class M[A] {
def map[B](f: A => B): M[B]
def flatMap[B](f: A => M[B]): M[B]
def filter(f: A => Boolean): M[A]
def foreach(f: A => Unit): Unit
}

In Scala sind Monads weit verbreitet. Beispielsweise sind der Typ Option und die Kollektionen als Monads implementiert. Der Option-Typ kann zum Beispiel dazu verwendet werden, in Java Null-Pointer-Zugriffe zu verhindern. Dies demonstriert die Test-Spezifikation in Listing 1, basierend auf dem Scala-Specs-Framework.

object JPATestSpecs extends Specification {
 
  var emf : Option[EntityManagerFactory] = None
  var em : Option[EntityManager] = None
 
  def full[T](r: => T) : Option[T] = r match {
   case null => None
   case res => Some(res)
  }
 
  "initialization" should {
 
    doBefore({
        emf =
Full[EntityManagerFactory](
Persistence.createEntityManagerFactory(
"examplePersistenceUnit"))
        for (e <- emf) em =
Full[EntityManager](e.createEntityManager())
      }
    )
 
    doAfter(
    {for (e <- em) e.close()}
    )
  }
 
  "jpa setup" in {
      emf must_!= None
      em must_!= None
  }
 
  ...
}


Interne DSLs in Scala

Moderne objektorientierte und funktionale Sprachen bieten eine zweite Alternative der DSL-Entwicklung, die die Möglichkeiten der Programmiersprache selbst nutzt. Voraussetzung dafür ist, dass die Generator-Sprache die Fähigkeiten der Syntaxmodellierung auf einer abstrakten Ebene bietet. Diese Metaprogrammierung ist naturgemäß in dynamischen Sprachen wie Ruby oder Groovy implizit vorhanden, während etwas in die Jahre gekommene Sprachen wie Java in diesem Bereich Defizite aufweisen. Scala bietet durch seine extrem flexible Syntax bei gleichzeitiger Typsicherheit eine ideale Basis für die Sprachmodellierung. Funktionalitäten wie Implicits, Funktionen höherer Ordnung, optionale Verwendung von Semikolon, Klammerung und Punktnotation, Operatoren als Funktionen und Currying sind die wesentlichen Grundlagen der Erstellung von Domänensprachen in Scala.

Implicits

Implicits erlauben dem Scala-Compiler, nicht gefundene Referenzen mithilfe von als implicit markierten Codeteilen aufzulösen. Entgegen dem „offenen“ Konzept in Ruby, wo Erweiterungen an bestehenden Klassen für alle Namensräume gelten, sind implizite Änderungen in Scala nur in dem aktuell sichtbaren Scope definiert. Dies erlaubt eine bessere Kontrolle und größere Transparenz der durch das implicit-Konzept bereitgestellten Funktionalitäten. Durch die nahtlose Integration von Scala und Java können die APIs bestehender Java-Typen durch implizite Scala-Methoden erweitert werden. Ein Beispiel ist die Scala-Erweiterung der Java-String-Klasse. Diese Erweiterung ermöglicht die Beschreibung eines Testfalls als String:

             case class TestcaseDesc(val doc : String) {

        def withTeststeps(steps : List[Testcommands])
      : (String, List[Testcommands]) = (doc, steps)
      }

  implicit def docToTestcases(doc : String) = TestcaseDesc(doc)

Über die implizite Verbindung wird der Testfallbeschreibung ein Objekt vom Typ TestaseDesc zugeordnet. Dieses Objekt erhält durch den Aufruf der Methode withTeststeps die zugehörigen Testschritte.

Funktionen höherer Ordnung

Funktionen dieser Art basieren auf der Behandlung von Funktionen als First Class Values, was bedeutet, dass Funktionen wie Werte behandelt werden (Variablenzuordnung, Parameterübergabe etc.). Sie bilden in Verbindung mit optionalem Semikolon, Klammerung und Punktnotation die Basis für die natürlichsprachliche Formulierung von Anweisungen (so auch Operatoren als Funktionen). In Scala werden sie häufig in Verbindung mit funktionaler Programmierung auf Kollektionen verwendet.

 

Beispiel:

    def isEven(n: Int) = (n % 2) == 0
    List(1, 2, 3, 4) filter isEven foreach println  

statt:

    List(1,2,3,4)).filter(isEven(_)).foreach(println(_))

Eclipse Xtext

Offiziell von der Firma Itemis entwickelt, lehnt sich das als Sprachentwicklungsframework deklarierte Werkzeug Xtext durch seine Integration in die Eclipse-Umgebung stark an die dort verwendeten Modellterminologien des EMF und TMF an. Innerhalb dieser Umgebung stellt das Framework im Rahmen der Erstellung von Sprachen verschiedene Artefakte zur Verfügung. Die wichtigsten sind:

 

  • Ein Scanner und Parser für die Abbildung der EBNF-ähnlichen Grammatik auf eine Sprachinstanz.
  • Ein Ecore-Metamodell als Repräsentation des daraus resultierenden abstrakten Syntaxbaums. Es werden Java-Klassen dieses Modells zur weiteren Verarbeitung bereitgestellt.
  • Ein in Eclipse integrierter Editor mit Funktionalitäten wie Syntax-Highlighting, Codevervollständigung etc. für die Erfassung einer Sprachinstanz.
  • Ein Codegenenerator, der Funktionalitäten bereitstellt, um den AST auf beliebige Ausgaberepräsentationen abzubilden. Dieser Generator basiert in der Version Xtend 2 auf einer völlig neu konzipierten Sprachbeschreibung der Vorgängerversion Xtend, die die Sprache Java um nützliche Funktionalitäten wie Multi-Methoden, Typ-Inferenz, Aspekte der funktionalen Programmierung etc. erweitert. Der durch den Xtend2-Compiler erzeugte Code ist reiner Java-Code, wodurch die Kompatibilität zu bestehenden Umgebungen und die Wartbarkeit gewährleistet sind.

 

Da die Nutzung des Code-Generators optional ist, können mit Xtext erzeugte Sprachen durch beliebige Werkzeuge „weiterverarbeitet“ werden. Allgemein unterscheidet man folgende Anwendungsszenarien für den Einsatz von Xtext:

 

  • Beschreibung von Spracherweiterungen und Entwicklungsumgebungen auf Basis moderner IDEs für schon bestehende Sprachbeschreibungen
  • Entwicklung domänenspezifischer Sprachen (DSL)
  • Entwicklung allgemeiner Programmiersprachen

 

Da die Entwicklung allgemeiner Sprachen in der Praxis von geringerer Relevanz ist, geht der Artikel auf die Möglichkeit der Nutzung von Xtext für die ersten beiden Punkte ein.

Der nächste Abschnitt beschreibt den Einsatz von Xtext für Problemstellungen in Punkt eins anhand einer Sprache für das automatisierte Testen. Dazu ist zunächst ist eine Beschreibung der Anwendungsdomäne notwendig. Auf Basis der Anwendungsdomäne entsteht eine interne Scala DSL, die vom Anwendungsprogrammierer zur Erstellung von Testfällen verwendet werden kann.

Eine Testsprache für den automatisierten funktionalen Test

Testautomatisierung spielt in heutigen Geschäftsanwendungen eine wichtige Rolle. Während die Automatisierung auf Modul- und Klassenebene des Programmcodes zu den täglichen Aufgaben des Entwicklers zählt und durch den Quasi-Standard JUnit problemlos umgesetzt werden kann, wird Automatisierung in späteren Phasen des Softwareentwicklungsprozesses häufig vernachlässigt. Die Gründe hierfür sind vielfältig und sollen in diesem Kontext nicht weiter erläutert werden.

Ein wesentlicher Grund ist sicherlich das Fehlen eines möglichst einfachen Werkzeugs, welches das Testen auf funktionaler Ebene erleichtert. Ein möglicher Ansatz eines solchen Werkzeugs ist Thema von [2].

Die Bedienung erfolgt über eine schlüsselbasierte Sprache, die auf die Erfordernisse des Testautomatisierers zugeschnitten ist. Die Erfassung von Testfällen basiert auf dem HTML-Format und kann somit auch in Office-Produkten erfolgen. Die Nutzerfreundlichkeit dieser Art der Testfallerfassung liegt auf der Hand. Jedoch gibt es auch Nachteile:

 

  • Die ausschließliche Definition von funktionalen Testfällen in HTML verhindert eine mögliche Einbettung in den Testprogrammcode, zum Beispiel in Verbindung mit einem Testframework wie JUnit.
  • Trotz der Einfachheit der Testsprache bleibt ein Einarbeitungsaufwand für den Test-Automatisierer.

 

Eine Lösung dieser Problemstellungen kann eine Anwendung basierend auf der Definition einer internen Scala-DSL in Verbindung mit einer Sprachentwicklungsumgebung bieten.

Definition der internen DSL

Ausgangspunkt für das Sprachdesign sind Kriterien wie Einfachheit, Nutzerfreundlichkeit, gute Erweiterbarkeit und Lesbarkeit. Vor diesem Hintergrund wurde folgende formale Struktur für die Definition von Testfällen gewählt:

<Test-Kommando> <Test-Kontext-Typ> <Test-Kontext> ( 
<IndexTyp> <Index> ( the_value <value>)*)+

Test-Kontext: Ein Kommando wird in einem bestimmten Kontext ausgeführt. Abhängig von dem verwendeten Kontext wird das Kommando unterschiedlich interpretiert. Technisch verbirgt sich hinter einem Kontext eine Klasse, die das Kommando anhand einer eigenen Logik auswertet oder aber sich über definierte Schnittstellen Services anderer Werkzeuge bedient. Beispielsweise verwendet der Web-Kontext die Automatisierungsfunktionalitäten des Werkzeugs Selenium. In Tabelle 2 sind die aktuell implementierten Kontexte aufgelistet.

Kontext Bedeutung
WEB Definiert Funktionalitäten für die Automatisierung von Oberflächen
DB Definiert Funktionalitäten für die Verwaltung von Daten in Datenbanken

Tabelle 2: Applikationskontexte der internen DSL

Index

Über einen Index kann ein Element innerhalb eines Testobjekts referenziert werden, auf dem dann das aktuelle Kommando ausgeführt wird. Die in Tabelle 3 aufgelisteten Index-Methoden stehen zur Verfügung.

Index Bedeutung
on_index Sucht ein Element, dessen Beschreibung dem angegebenen Index entspricht
contains_index Sucht ein Element, dessen Beschreibung einen Teil des angegebenen Indexes enthält. Als Beschreibungen dienen unterschiedliche Informationen, bei Webelementen z.B Element-Name, Element-ID etc.

Tabelle 3: Element-Indizierung der internen DSL

Als Test-Kommandos dienen leicht verständliche Schlüsselwörter, die im täglichen funktionalen Testgebrauch immer wiederkehren, leicht einprägsam sind und in verschiedenen Kontexten wie der Oberflächensteuerung, dem Datenzugriff etc. eingesetzt werden können. Tabelle 4 zeigt mögliche Kommandos für die Testsprache.

Kommando Kontext Bedeutung
Start WEB oder DB Startet einen WEB-Kontext oder einen DB-Kontext
enter WEB oder DB Setzt den vorgegebenen Wert in ein Eingabefeld oder setzt bzw. aktualisiert einen vorgegebenen Wert in einer Datenbanktabelle
Select WEB oder DB Selektiert ein vorgegebenes Element in einer Liste oder einen Wert in einer Datenbank-Tabelle
Check WEB oder DB Prüft den Wert eines Elements auf der Oberfläche gegen einen vorgegebenen Wert oder einen Wert in einer Datenbanktabelle
Exists WEB oder DB Prüft die Existenz eines Elements auf der Oberfläche oder die Existenz eines Werts in einer Datenbanktabelle
Press WEB Betätigt ein Schaltelement auf der Oberfläche

Tabelle 4: Kommandos der internen DSL

Werte: Einige Kommandos verwenden die Angabe von Werten. Mögliche Ausprägungen sind Basisdatentypen wie ganze Zahlen, Fließkommazahlen, Zeichenketten und Datumsangaben.

Umsetzung der DSL in Scala

Tests sind in Klassen zusammengefasst, die wiederum in Dateien im Dateisystem verwaltet werden. Ein Test besteht aus einer Liste von Testfällen. Die Testfälle werden in Scala wie in Listing 2 formuliert.

(*)
class SomeTest {

      def withTestcases =
      (
       """
       Testcase description 1
       """ withTeststeps List[Testcommands](
          cmd => cmd.<cmd> <context_type> <context>
      (<IndexTyp> <Index> ( the_value <value>)*)+
                   ...))
       ::
       """
       Testcase description 2
       """ withTeststeps List[Testcommands](
          cmd => cmd.<cmd> <context_type> <context>
      (<IndexTyp> <Index> ( the_value <value>)*)+
                   ...))
       ....
       ) // end withTestcases
}

Die Hauptkomponente eines TestClients verwendet eine Test-Service-Instanz für die Testabarbeitung und erfordert die Injektion einer Testreport-Komponente, die die Ergebnisse der Testausführung in HTML bereitstellt (Listing 3).

trait TestClientComponent {
       self: TestReportComponent =>

    val testServiceComponent : TestServiceComponent

    class TestClient(val resultFile : File) {
    
      def showResult(result : String) = resultFile.write(result)

      def executeTests(testFiles : List[File]) : List[TestResult]     = {
        val testResults = testServiceComponent.testService.
    executeTestcasesInFiles(testFiles)
        showResult(testReport.reportHTMLTestResult(testResults))
        testResults
      }
    }

}

Die Steuerung sowohl der Testfallermittlung als auch der Testfallausführung wird von einer eigenen Service-Komponente vorgenommen. Diese konstruiert für alle gefundenen Testdateien Testinstanzen. Die Testobjekte liefern bei Aufruf eine Liste von Paaren aus jeweils einer Testfallbeschreibung und den einzelnen Testschritten (Listing 4).

  trait TestServiceComponent  {

    val testService: TestService

    trait TestService {

      def executeTestcasesInFile(file : File): Option[TestResult]
      def executeTestcasesInFiles(files : List[File]) :
       List[TestResult]

      def consTestInstance(file : File) = {
        ...    
      }
    }
 }

  trait TestServiceComponentImpl extends TestServiceComponent {

    val testService = new TestServiceImpl()

    class TestServiceImpl extends TestService {

       def executeTestcasesInFile(file : File) :
    Option[TestResult] = {

         withTestcaseResults(file) {
           testcases => Some(TestResult fromTestcases(testcases))
         }
      }

      def executeTestcasesInFiles(files: List[File]) =
        files.map(testFile => {
          testService.executeTestcasesInFile(testFile).get
        })

      private[this] def withTestcaseResults(file : File)(op :
    List[Testcase] => Option[TestResult]) =
        op(consTestInstance(file).withTestcases map (
    docTs => new Testcase(docTs._1, docTs._2).execute()))

    }

  }

Erreicht wird die Paarbildung über die implizite Abbildung eines String-Objekts auf ein Objekt vom Typ TestcaseDesc:

case class TestcaseDesc(val doc : String) {

      def withTeststeps(steps : List[Testcommands]) :
    (String, List[Testcommands]) = (doc, steps)        
}

implicit def docToTestcases(doc : String) = TestcaseDesc(doc)

Ein Testkommando definiert sich als Abbildung eines Kommandoerzeugers auf einen Kommando-Dispatcher (Listing 5).

type Testcommands = CommandBuilder => CommandDispatcherManager

trait CommandDispatcherManager {

...

      def on(el : String) = {  … }
      def on_index(el : String) = { …  }
      def contains_index(el : String) = { ... }
      def and_index(id : String) = { ... }
      def and_contains_index(id : String) = { ... }
      def on_app(appStr : String) = { ... }
      def the_value(value : String) = { ... }
      def lesser(value : String) = { ... }
      def equals(value : String) = { ... }
      def greater(value : String) = { ... }
...
}

Bei Ausführung eines Testfalls wird auf einen im aktuellen Kontext erstellten Kommandoerzeuger zurückgegriffen, der über die execute-Methode einen impliziten Kommando-Dispatcher liefert, welcher das Kommando ausführt (Listing 6).

case class Testcase() {

      var doc : Option[String] = None

      var teststeps =  List[CommandBuilder =>
      CommandDispatcherManager]()
      
      def this(doc : String,
    teststeps : List[CommandBuilder =>
      CommandDispatcherManager])(implicit cmd :
                CommandBuilder) = {
    ...
   }

      def execute()(implicit cmd : CommandBuilder) = {
    ...
   }
}

Als Kommando-Dispatcher werden je nach Testobjekt verschiedene Typen angeboten. Für eine Webanwendung steht beispielsweise der Selenium-Kommando-Dispatcher bereit. Der Aufruf der Testabarbeitung erfolgt durch die Komposition eines Testclients mit den einzelnen Bausteinen für die Testausführung (Listing 7).

new TestClientComponent with TestResultLocalCacheCompImpl with     TestReportComponentImpl {
        val testServiceComponent = TestServiceComponentImpl
        val testResultCache = new TestResultLocalCacheImpl
        val testReport = new TestReportImpl
        val testClient = new TestClient(new
      File("./reports/testReport.html")) 
    }.testClient.executeTests(FileRegexMatcher.findBy(
         new File("./testcases"), "class .*Test").toList)

Dieses Beispiel demonstriert, wie ein Anwendungsentwickler sowohl Testdateien in Scala beschreiben und ausführen, als auch deren Ergebnisse auswerten kann.

Ausblick

Man stelle sich nun vor, dass nicht nur der Anwendungsentwickler während seiner Entwicklung, sondern auch der Domänenexperte im Vorfeld der Entwicklung Testfälle erzeugen will, die nicht auf Applikationscode basieren. Der zweite Teil des Artikels beschäftigt sich mit der Frage, wie dies mit Xtext und Xtend in einfacher Form sichergestellt werden kann. In den meisten Fällen wird es nicht möglich sein, auf bestehende, interne Sprachbeschreibungen zurückgreifen zu können. Vielmehr wird die Notwendigkeit bestehen, von Grund auf eine neue Domänensprache zu entwickeln.

Geschrieben von
Michael Adams
Michael Adams
Michael Adams ist technischer Seniorsoftwareberater bei der NTT Data, Deutschland in Köln und beschäftigt sich mit Softwarearchitekturthemen in Java, modellgesteuerter Entwicklung und Domänensprachen.
Kommentare

Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht.