JSON mit Akka HTTP

Keine Kommentare

In diesem Artikel wird die Nutzung von JSON mit Akka HTTP thematisiert.

Dabei verfolgen wir zunächst eine Umsetzung, die sich an der aktuellen Dokumentation von Akka orientiert und nutzen spray-json. Im Anschluss zeigen wir einen alternativen Ansatz mit akka-http-json und circe.

Die Domäne

Die Domäne wird hier bewusst einfach gehalten, da der Fokus auf Parsing und Mapping liegt:

case class Customer(
  id: UUID,
  name: String,
  registrationDate: LocalDate,
  gender: Gender,
  customerType: CustomerType,
  addresses: Option[Set[Address]]
)
case class Address(
  street: String,
  city: String,
  zip: String,
  active: Boolean = false
)

Neben diesen Case-Classes existieren noch die Aufzählungstypen Gender und CustomerType. Obwohl es nachteilig ist, Aufzählungen als Erweiterung von Enumeration zu implementieren (ein Artikel dazu hier), wird CustomerType hier so umgesetzt, um alle Eventualitäten abzudecken. Ein Grund dafür kann bspw. der Umgang mit Legacy-Code sein.

// Main.scala
object CustomerType extends Enumeration {
  type CustomerType = Value
  val Regular, Vip = Value
}

Gender wird als sealed Trait umgesetzt.

sealed trait Gender
case object Female extends Gender
case object Male extends Gender

Server

Für das Beispiel existiert ein einfacher Server, dessen Routing durch paths definiert wird. POST-Requests werden unter http://localhost:8080/customer entgegengenommen. Das übertragene Objekt wird zunächst in der Konsole ausgegeben, anschließend customers hinzugefügt und schließlich wird die aktualisierte Liste hinzugefügt. GET liefert für eine bestimmte UUID das entsprechende Element der Map zurück.

object Main extends App {
  import domain._
 
  implicit private val system = ActorSystem()
  implicit private val mat    = ActorMaterializer()
 
  // Some dummy data
  private val uuid1 = UUID.fromString("5919d228-9abf-11e6-9f33-a24fc0d9649c")
  private val uuid2 = UUID.fromString("660f7186-9abf-11e6-9f33-a24fc0d9649c")
  private val uuid3 = UUID.fromString("70d0d722-9abf-11e6-9f33-a24fc0d9649c")
 
  private val address1 = Address("Musterstrasse 2", "Musterstadt", "12345")
  private val address2 =
    Address("Testplatz 80 5", "Musterhausen", "45789", active = true)
  private val address3 =
    Address("Akka-Allee 1887", "Akkaburg", "61860", active = true)
 
  private var customers =
    Map(
      uuid1 -> Customer(uuid1,
                        "test1",
                        LocalDate.of(2010, 1, 11),
                        Female,
                        CustomerType.VIP,
                        None),
      uuid2 -> Customer(uuid2,
                        "test2",
                        LocalDate.of(2014, 6, 5),
                        Male,
                        CustomerType.VIP,
                        Some(Set(address1, address2))),
      uuid3 -> Customer(uuid3,
                        "test3",
                        LocalDate.of(2012, 2, 25),
                        Female,
                        CustomerType.REGULAR,
                        Some(Set(address3)))
    )
 
  private def route = {
    import Directives._
    pathPrefix("customer") {
      post {
        entity(as[Customer]) { customer =>
          println(customer)
          customers += customer.id -> customer
          complete(customers)
        }
      } ~
      path(JavaUUID) { id =>
        get {
          complete(customers(id))
        }
      }
    }
  }
 
  import system.dispatcher
  Http().bindAndHandle(route, "localhost", 8080).onComplete {
    case Failure(cause) =>
      println(s"Can't bind to localhost:8000: $cause")
      system.terminate()
    case _ =>
      println(s"Server online at http://localhost:8080")
  }
  Await.ready(system.whenTerminated, Duration.Inf)

JSON-Support mit spray-json

Dieser Abschnitt orientiert sich an der Dokumentation zur Nutzung von JSON im Kontext von Akka HTTP 2.4.11. Um spray-json im Beispielprojekt einzubinden, wird folgende Dependency verwendet:

libraryDependencies += "com.typesafe.akka" %% "akka-http-spray-json-experimental" % "2.4.11"

Um den Server JSON-fähig zu gestalten, bedarf es noch der Einbindungen der notwendigen Kapazitäten für spray-json sowie der Definition eines JSON-Protokolls. Dazu werden im Trait JsonSupport das JSON-Format für Address und Customer definiert. Ersteres wird mit dem Aufruf jsonFormat4(Address) erreicht – die vier steht für die Anzahl der Parameter im Konstruktor. Das Format für Customer wird äquivalent behandelt.

trait JsonSupport extends SprayJsonSupport with DefaultJsonProtocol {
  implicit val addressFormat = jsonFormat4(Address)
  implicit val customerFormat = jsonFormat6(Customer)
}

Der Trait JsonSupport wird in Main.scala hinein gemixt:

object Main extends App with JsonSupport { //...

Custom-Types

Grundsätzlich würden diese Implementierungsschritte ausreichen, jedoch deckt DefaultJsonProtocol nicht alle Typen ab, die hier verwendet werden. Im vorliegenden Fall benötigen wir Formate für:

  • java.util.UUID
  • java.time.LocalDate
  • oben beschriebene Aufzählungen

Dazu wird das im Trait CustomerJsonProtocol für jedes benötige Format ein impliziter Wert des Types JsonFormat angelegt und dessen Methoden read und write implementiert.

trait CustomerJsonProtocol extends DefaultJsonProtocol {
 
  implicit val uuidJsonFormat: JsonFormat[UUID] = new JsonFormat[UUID] {
    override def write(x: UUID): JsValue = JsString(x.toString)
 
    override def read(value: JsValue): UUID = value match {
      case JsString(x) => UUID.fromString(x)
      case x           => deserializationError("Expected UUID as JsString, but got " + x)
    }
  }
 
  implicit val localDateJsonFormat: JsonFormat[LocalDate] =
    new JsonFormat[LocalDate] {
      private val formatter                     = DateTimeFormatter.ISO_DATE
      override def write(x: LocalDate): JsValue = JsString(x.format(formatter))
 
      override def read(value: JsValue): LocalDate = value match {
        case JsString(x) => LocalDate.parse(x)
        case x           => deserializationError("Wrong time format of " + x)
      }
    }
 
  implicit val customerTypeFormat: JsonFormat[CustomerType] =
    new JsonFormat[CustomerType] {
      override def write(x: CustomerType): JsValue = JsString(x.toString)
 
      override def read(value: JsValue): CustomerType = value match {
        case JsString("REGULAR") => CustomerType.REGULAR
        case JsString("VIP")     => CustomerType.VIP
        case x                   => deserializationError("No CustomerType with name " + x)
      }
    }
 
  implicit val genderFormat: JsonFormat[Gender] = new JsonFormat[Gender] {
    override def write(x: Gender): JsValue = JsString(x.toString.toUpperCase)
 
    override def read(value: JsValue): Gender = value match {
      case JsString("MALE")   => Male
      case JsString("FEMALE") => Female
      case x                  => deserializationError("Not a Gender " + x)
    }
  }
}

Im Trait JsonSupport wird dieser Trait anstelle von DefaultJsonProtocol verwendet:

trait JsonSupport extends SprayJsonSupport with CustomerJsonProtocol {//...

JSON mit akka-http-json und circe

Alternativ zu spray-json existieren weitere Bibliotheken, um JSON im Kontext von Akka HTTP zu nutzen. Eine davon ist akka-http-json, die hier mit circe verwendet wird. Dazu wird folgende Dependency (anstelle der Dependency für spray-json) eingebunden:

libraryDependencies ++= List(
  "de.heikoseeberger" %% "akka-http-circe" % "1.10.1",
  "io.circe"          %% "circe-generic"   % "0.5.2",
  "io.circe"          %% "circe-java8"     % "0.5.2"</ul>
)

Die erste Dependency bindet die Integration von circe in Akka HTTP, die maßgeblich mittels CirceSupport zur Verfügung gestellt wird, ein. circe-generic ermöglicht die automatische Erzeugung von Codecs (analog Formaten bei spary-json) zur Compile-Time. circe-java8 beinhalten Codecs für spezielle Typen von Java 8, insbesondere LocalDate.

Um also circe zu nutzen, wird Main.scala folgendermaßen angepasst:

// more imports
import de.heikoseeberger.akkahttpcirce.CirceSupport
 
object Main extends App with CirceSupport {
  import io.circe.generic.auto._
  import io.circe.java8.time._
  //...

Im Companion Object für CustomerType werden Decoder und Encoder als implizite Werte angelegt:

object CustomerType extends Enumeration {
  type CustomerType = Value
  val Regular, Vip = Value
 
  implicit val customerTypeDecoder: Decoder[CustomerType] =
    new Decoder[CustomerType] {
      override def apply(c: HCursor) =
        c.as[String].flatMap{ x =>
          Xor
            .catchOnly[NoSuchElementException](CustomerType.withName(x))
            .leftMap(e => DecodingFailure(s"Error while decoding: ${e.getMessage}", List()))
      }
    }
 
  implicit val customerTypeEncoder: Encoder[CustomerType] =
    new Encoder[CustomerType] {
      override def apply(`type`: CustomerType) =
        Json.fromString(`type`.toString)
    }
}

Für Gender wird analog vorgegangen. In der vorliegende Version (0.5.2) von circe, die hier verwendet wird, sollten ADTs automatisch, jedoch gab es hierbei Probleme, die nicht gelöst werden konnten. Außerdem weicht der JSON-Output ab, so dass ein eigener Codec benötigt wird. Mit kann circe 0.6 kann dieser aber konfiguriert werden.

sealed trait Gender
case object Female extends Gender
case object Male   extends Gender
 
object Gender {
 
  implicit val genderDecoder: Decoder[Gender] =
    new Decoder[Gender] {
      override def apply(c: HCursor) = c.as[String].flatMap {
        case "FEMALE" => Xor.right(Female)
        case "MALE"   => Xor.right(Male)
        case x        => Xor.left(DecodingFailure(s"Not a gender $x", List()))
      }
    }
 
  implicit val genderEncoder: Encoder[Gender] =
    new Encoder[Gender] {
      override def apply(a: Gender) = Json.fromString(a.toString.toUpperCase)
    }
}

Für java.util.UUID sind dank circe-java8 bereits Mechanismen vorhanden und müssen nicht wie bei spray-json implementiert werden.

Fazit

JSON kann mit wenig Aufwand in einem auf Akka HTTP basierenden Server eingebunden werden. Ob nun spray-json, circe oder eine andere Bibliothek verwendet wird, hängt nicht nur vom Gusto des Entwicklers ab, sondern selbstverständlich von der Qualität, dem Funktionsumfang sowie wie die Pflege und Weiterentwicklung der jeweiligen Bibliothek. Persönlich erscheint circe umfangreicher als spray-json zu sein.

Das Repository mit den Projekten für circe und spray-json befindet sich hier.

Tags

Christian Hof

Christian Hof beschäftigt sich mit Big Data und das am liebsten innerhalb des Hadoop-Ökosystems. Dabei spielen agile Methoden und Testautomatisierung eine wichtige Rolle.

Share on FacebookGoogle+Share on LinkedInTweet about this on TwitterShare on RedditDigg thisShare on StumbleUpon

Kommentieren

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert.