Über unsMediaKontaktImpressum
Roland Mast 05. April 2016

Eine DSL in Scala zur Optimierung der letzten Meile

Spätestens wenn sich der erste Erfolg einer neuen Webanwendung einstellt, kommen Wünsche nach eigenen mobilen Apps auf. Trotz geeigneter responsiver HTML-Ausspielung [1] möchte man den Nutzern die User Experience einer nativen App nicht vorenthalten. Neben der größtmöglichen Nutzung der vorhandenen Funktionalitäten soll die App meist weitere Features anbieten, die im normalen Webangebot nicht möglich sind. Jetzt beginnt die Herausforderung, dass die Daten des gemeinsamen Backends für die unterschiedlichsten Clients individuell aufbereitet und passend ausgeliefert werden müssen. Dazu ist eine einfache und flexible Möglichkeit gefragt, diese Anpassungsmöglichkeiten bereitzustellen. Eine spezifische DSL (domain-specific language) erleichtert die Definition der notwendigen Transformationen. Im Folgenden wird eine mögliche Lösung in Scala beschrieben.

Problemstellung Letzte Meile

Stellen wir uns für die weitere Betrachtung ein System vor, das unterschiedliche Services nutzt. Dies können Inhouse-Services sein, wie ein eigener Content-Service, ein Produkt-Katalog, ein Empfehlungs-Service oder ähnliches. Zusätzlich sollen jedoch auch externe Services genutzt werden um Wetterdaten, YouTube, Twitter o. ä. anzubinden. Folglich hat man keinen Einfluss auf die Datenschnittstelle aller Services. Als Datenformat vieler Services hat sich inzwischen JSON etabliert. Deshalb werde ich mich im Folgenden darauf beschränken. Der beschriebene Ansatz kann jedoch durch geeignete Konvertierungen auch für andere Formate verwendet werden. Zusätzlich engen wir die Art der Services auf REST-Services ein, die per http angesprochen werden.

Zurück zu den Clients. Deren unterschiedliche Bedürfnisse erfordern die individuelle Belieferung mit geeigneten Daten. Das Ziel ist eine einfache und zuverlässige Nutzung der Daten innerhalb des Clients und eine Optimierung der Kommunikation auf der letzten Meile. Dieser Begriff aus der Telekommunikation wird für das letzte Verbindungsstück zum Teilnehmer verwendet, das meist die geringste Bandbreite aufweist. Beispiele hierfür sind der Mobilfunk oder (öffentliche) WLAN-Netze mit geringer Bandbreite.

Die Optimierung beruht auf einer effizienten und effektiven Anbindung der Clients, bei der folgende Punkte im Vordergrund stehen:

  • Es werden nur die Daten übertragen, die der Client auch wirklich benötigt. Unnötiger Ballast wird herausgefiltert.
  • Die Anzahl der Requests wird minimiert, indem fachlich zusammengehörende Service-Anfragen im Backend gebündelt erfolgen und das zusammengefasste Ergebnis ausgeliefert wird.
  • Das Format und die Struktur der Daten richtet sich an die individuellen Bedürfnisse der Clients.
  • Die unterschiedliche Änderungsgeschwindigkeit von Backend und Client-Apps wird durch eine geeignete Architektur unterstützt. Die Anpassungen an den Schnittstellen zum Client können unabhängig vom Backend-Releasezyklus durchgeführt werden.

Da jeder Client seine eigenen Anforderungen an die Schnittstelle zum Backend stellt, kann das Problem nicht mit einem generischen Ansatz angegangen werden. Die aufgeführten Punkte gelten für jeden Client individuell. Unterschiedliche mobile Apps, Smart-TVs, Spielekonsolen, öffentlich zugängliche APIs usw. werden entsprechend ihrer Anforderungen individuell beliefert. Folglich muss in der Architektur an der Schnittstelle zum Client die Möglichkeit geschaffen werden, auf die eingehenden Requests spezifisch antworten zu können. Die zentrale Aufgabe fällt dabei der Transformation der Daten zu, die mit möglichst einfachen Mitteln definiert werden soll. Dazu bietet sich der Einsatz einer DSL an.

Anforderungen an die DSL

Welche Anforderungen stellen sich nun an die DSL? Welche Aufgaben sollen mit ihr abgedeckt werden? Wir gehen zur Vereinfachung davon aus, dass das Routing der Requests durch ein geeignetes Web-Framework gelöst wird und diese Aufgabe nicht von der DSL abgedeckt werden muss. Jetzt gilt es, die Requests zu definieren, über die die verschiedenen Backend-Services abgefragt werden. Dabei sollen einfache und kaskadierende Aufrufe mit den zugehörigen Parametern unterstützt werden. Sobald die Antworten der unterschiedlichen Services eingetroffen sind, gilt es, diese zusammenzusetzen und in das gewünschte Format zu transformieren. Die hier vorgestellte DSL [2] unterstützt die Definition des Zugriffs auf die Backend-Services und die Datentransformation.

Aufruf eines Backend-Services

Bei einfachen Service-Aufrufen sorgt ein URLBuilder für die Generierung der URL mit den erforderlichen Parametern. Ebenso können Header-Parameter festgelegt werden, die z. B. für eine Authentifizierung erforderlich sind. Mit diesen Werten wird eine BackendServiceConfiguration erzeugt (s. Listing 1). Die Definition der zugehörigen Transformation wird später beschrieben.

Listing 1: Einfacher Service-Aufruf

  BackendServiceConfiguration(
    name = "openWeatherAPI",
    urlBuilder = URLBuilderKeyValueParameters(
      "https://community-open-weather-map.p.mashape.com/weather",
      Set("units"->"metric", "lang"->"de", "APPID"->"0000")),
    headers = List[HttpHeader](
      Authorization(BasicHttpCredentials("user", "pw"))),
    transformation = jsonTransformation
  )

Kaskadierender Aufruf

In manchen Szenarien kann ein einzelner Service nicht alle gewünschten Informationen liefern oder es werden im Sinne eines Mashups verschiedene Quellen zusammengefasst. Dazu ist es erforderlich, Services nacheinander aufzurufen und z. B. den Service-Aufruf B mit Ergebnissen aus der Antwort von Service A zu parametrisieren. Die DSL stellt dazu einen BackendServiceCombinator bereit (s. Listing 2). Damit erfolgt die Konfiguration der Verbindung zwischen den Services. Es kann angegeben werden, wie die Ergebnisse des ersten für den weiteren Aufruf genutzt werden. Im Beispiel wird über extractPathsKeyValue definiert, dass aus allen Dokumenten die Städte verwendet werden, um damit den Query-Parameter q für den kaskadierten Aufruf mit Daten zu versorgen. In diesem Fall wird für jede Stadt ein weiterer Request an den Wetter-Service ausgelöst. Die Ergebnisse werden an die über den Ausdruck appendPath festgelegte Stelle im JSON geschrieben, d. h. im Beispiel wird die Wetter-Info jedem Dokument hinzugefügt. Durch Überschreiben von config mit sophoraContent (weatherBackendService) werden die beiden Services miteinander verknüpft.

Listing 2: Kaskadierender Service-Aufruf

object SophoraContentWithWeatherCombinator extends ServiceConfiguration {

override def config = {
  sophoraContent(weatherBackendService)
}

  private def sophoraContent(backendServiceConfiguration: BackendServiceConfiguration) = {
    BackendServiceCombinator(
      name = name,
      urlBuilder = urlBuilder,
      headers = headers,
      extractPathsKeyValue = ExtractPathsKeyValue(
        "q" -> "$.documents[*].city"),
      backendServicesList = List(backendServiceConfiguration),
      appendPath = "$.documents[*]",
      transformation = jsonTransformation)
  }

  private def weatherBackendService = {
  // siehe voriges Beispiel
  }
}

Transformation der JSON-Daten

Die Transformationsregeln sind der Kern der DSL. Hiermit können die Antworten der Service-Aufrufe in die passende Form transformiert werden.

Die zu transformierenden Elemente werden über JsonPath-Ausdrücke [3] referenziert. Die an XPath angelehnte Syntax erlaubt es einzelne oder mehrere Elemente eines JSON-Dokuments zu identifizieren. In Tabelle 1 sind einige Beispiel-Ausdrücke mit Ergebnissen aufgeführt, die sich auf das JSON in Listing 4 beziehen. Diese und weitere Beispiele können mit dem Jayway JsonPath Evaluator [4] interaktiv ausprobiert werden.

Tabelle 1: JsonPath-Ausdrücke

JsonPath Selektion Ergebnis
$.timestamp Der Zeitstempel, als Element auf oberster Ebene "2016-03-11T13:30:43.000Z"
$.documents..city Die Stadt aller Dokumente als Array ["Berlin", "Paris"]
$.documents[0].title Der Titel des ersten Dokuments "Berlin: City of Design"
$.documents[1].taxo[3,4] Die Einträge in taxo mit dem Index von 3 bis 4 innerhalb des zweiten Dokuments ["en vogue", "La Fayette"]

Listing 3: JSON-Antwort des Content-Services

{
"totalEntriesCount": 1,
"pageCount": 1, "pageIndex": 0, "pageSize": 10,
"documents":
  [
    {
      "sophora-content:city":"Berlin",
      "sophora-content:taxo":["Berlin", "Germany", "capital", "city of design", "KaDeWe"],
      "sophora-content:title": "Berlin: City of Design",
      "sophora-content:topline":"Berlin",
      "sophora:createdBy":"admin"
    },
    {
      "sophora-content:city":"Paris",
      "sophora-content:taxo":["Paris", "France", "capital", "en vogue", "La Fayette"],
      "sophora-content:title":"Paris: En vogue"
    },
    {
      "sophora-content:city":"Milano",
      "sophora-content:taxo":["Milano", "Italy"],
      "sophora-content:title":"Milano: Bene, bene"
    }
  ]
}

Listing 4: Beispiel JSON für JsonPath-Ausdrücke und Transformationsregeln


  "documents":[ 
    { 
      "city":"Berlin",
      "taxo":["Berlin", "Germany", "capital", "city of design", "KaDeWe"],
      "title":"Berlin: City of Design"
    },
    { 
      "city":"Paris",
      "taxo":["Paris", "France", "capital", "en vogue", "La Fayette"],
      "title":"Paris: En vogue"
      "timestamp":"2016-03-11T13:30:43.000Z"
    }
  ],
  "timestamp":"2016-03-11T13:30:43.000Z"
  „keyName“:{}
}

Es stehen die im Folgenden aufgeführten Transformationsregeln zur Verfügung. Die beschriebenen Regel-Beispiele wurden alle auf das JSON in Listing 3 angewendet. Das Ausgangs-JSON stammt von der Demo-Site eines Content-Management-Systems [5]. Das Ergebnis befindet sich in Listing 4.

  • add – Fügt ein neues Element ein. Dabei kann es sich um ein Property mit einem primitiven Wert (z. B. integer, boolean, string), um ein Objekt oder um ein Array handeln. Das umgebende Element muss bereits existieren oder mit insert angelegt worden sein.
    Beispiel: Füge ein neues Element "timestamp" mit dem Wert der Variablen date auf oberster Ebene im JSON ein.
    add(date into "$.timestamp")
  • copy – Kopiert ein bestehendes Element an eine andere Stelle.
    Beispiel: Kopiere das gerade erzeugte timestamp-Element in das zweite Dokument und nenne das Element "date". (Ist im Beispiel-JSON nicht mehr sichtbar, da es nachträglich noch umbenannt wird, siehe move)
    copy("$.timestamp" into "$.documents[1].date")
  • delete – Entfernt ein Element.
    Beispiel: Lösche das dritte Dokument.
    delete("$.documents[2]")
  • insert – Erzeugt ein neues Array oder Objekt, dem mit insert oder add weitere Elemente hinzugefügt werden können.
    Beispiel: Erzeuge eine neues Objekt mit dem Namen "keyName" auf der obersten Ebene im JSON.
    insert(Map() at "keyName" into "$")
  • keep – Filtert die Elemente, die erhalten bleiben sollen. Alle anderen werden entfernt. Diese Transformation erfolgt mit Hilfe des mächtigen Pattern Matching [6] von Scala.
    Beispiel: Übernimmt nur die Elemente city, taxo und title der Dokumente und benennt diese um.
    keep("$.documents" using {
        case ("sophora-content:city", y: Any) => ("city", y)
        case ("sophora-content:taxo", y: Any) => ("taxo", y)
        case ("sophora-content:title", y: Any) => ("title", y)
    })
  • move – Verschiebt ein bestehendes Element. Kann auch zum Umbenennen benutzt werden.
    Beispiel: Benennt alle Elemente "date" in Dokumenten um in "timestamp".
    move("$.documents..date" into "$.documents..timestamp")
  • set – Überschreibt ein bestehendes Element.
    Beispiel: Überschreibt das zweite Dokument mit dem JSON aus parisDoc.
    set(parisDoc into "$.documents.[1]")

Das Beispiel dient vor allem der Demonstration möglichst vieler Transformationsregel. Ein komplettes realistisches Beispiel befindet sich in Listing 5.

Listing 5: Beispiel-Transformation für Content-Service

private def sophoraContent(backendServiceConfiguration: BackendServiceConfiguration) = {

  val jsonTransformation = Try {
    JsonTransformation() addRules(
      keep("$" using {
        case x@("documents", y) => x
      }),
      keep("$.documents" using {
        case ("sophora-content:city", y: Any) => ("city", y)
        case ("sophora-content:title", y: Any) => ("title", y)
        case ("sophora-content:taxo", y: Any) => ("taxo", y)
      }),
      add("jtt" into "$.transformationType")
      )
  }.toOption

  BackendServiceCombinator(
    // siehe Beispiel für BackendServiceCombinator
  )
}

Fazit

Sobald mehrere unterschiedliche Clients mit Daten versorgt werden müssen, lohnt es sich, darüber nachzudenken, wie dies am geeignetsten erfolgen kann. Im Sinne der Optimierung der letzten Meile sorgen individuelle Zugriffe für eine performante Anbindung. Auf die dafür erforderlichen Anpassungen muss das Backend flexibel reagieren können. Diese Aufgabe packt man am besten in eine eigene Architekturschicht. Die Definition der Requests an die Backend-Services und die erforderlichen Transformationen sollten leicht und verständlich erfolgen können. Die vorgestellte DSL erfüllt all diese Anforderungen. Damit ist die Applikation auch für zukünftige Anforderungen gewappnet und kann sich diesen stellen.

Quellen
  1. W. Merkel: Geräteübergreifend eine gute Figur machen - Warum Webseiten-Optimierung im Zeitalter der digitalen Transformation für Unternehmen so wichtig ist, 2016
  2. A. Kostka: Entwicklung einer internen DSL in Scala für die Transformation von JSON zu JSON, Masterarbeit an der HTWG Konstanz, 2016
  3. JsonPath
  4. Jayway: JsonPath-Evaluator
  5. Sophora Demo Site: Content-API
  6. Pattern Matching in Scala

Autor

Roland Mast

Roland Mast ist Software-Architekt, Senior-Entwickler und SCRUM-Master bei der Sybit GmbH. Er beschäftigt sich mit großen Content-Management-Systemen, Web-Applikationen im Medienumfeld und Software-Entwicklung in Java. Wichtig...
>> Weiterlesen
botMessage_toctoc_comments_9210