Skip to content

Files

Latest commit

 

History

History
454 lines (351 loc) · 18.7 KB

json.md

File metadata and controls

454 lines (351 loc) · 18.7 KB
layout title
post
JSON-Daten

Die Kommunikation mit einem Server läuft über ein Austauschformat, zumeist JSON oder XML. In diesem Kapitel beschäftigen wir uns damit, wie wir Daten im JSON-Format mit einem Server austauschen. Der Abschnitt Decoder beschreibt, wie wir Daten, die wir im JSON-Format erhalten, in Elm-Datentypen umwandeln können. Der Abschnitt Encoder beschreibt, wie wir Daten aus unserer Anwendung in das JSON-Format umwandeln.

Decoder

Wenn wir HTTP-Anfragen durchführen, erhalten wir vom Server häufig eine Antwort in Form vom JSON. Diese Antwort erhalten wir aber in Form eines Strings. Das heißt, um die Informationen, die wir benötigen zu extrahieren, müssen wir den String verarbeiten. Wir wollen in unserer Anwendung aber nicht mit den String hantieren, den wir vom Server erhalten haben, sondern mit einem strukturierten Elm-Datentyp arbeiten. Um diese Aufgabe umzusetzen, werden in Elm Decoder verwendet.

You shall not parse, syntax error on line 1{: width="400px" .centered}

Ein Decoder ist eine Elm-spezifische Variante des allgemeineren Konzeptes eines Parser-Kombinators. Parser-Kombinatoren sind eine leichtgewichtige Implementierung von Parsern. Ein Parser ist eine Anwendung, die einen String erhält und prüft, ob der String einem vorgegebenen Format folgt. Jeder Compiler nutzt zum Beispiel einen Parser, um Programmtext auf syntaktische Korrektheit zu prüfen. Neben der Überprüfung, ob der String einem Format entspricht, wird der String für gewöhnlich auch noch in ein strukturiertes Format umgewandelt. Im Fall von Elm, wird der String zum Beispiel in Werte von algebraischen Datentypen umgewandelt. In einer objektorientierten Programmiersprache würde ein Parser einen String erhalten und ein Objekt liefern, das eine strukturierte Beschreibung des Strings zur Verfügung stellt.

Um in einer Elm-Anwendung einen Decoder zu nutzen, müssen wir zuerst den folgenden Befehl ausführen.

elm install elm/json

Die Bibliothek elm/json ist eine eingebettete domänenspezifische Sprache und stellt eine deklarative Technik dar, um Decoder zur Verarbeitung von JSON zu beschreiben.

Der Typkonstruktor Decoder ist im Modul Json.Decode1 definiert. Das Modul stellt eine Funktion

decodeString : Decoder a -> String -> Result Error a

zur Verfügung. Mithilfe dieser Funktion kann ein Decoder auf eine Zeichenkette angewendet werden. Ein Decoder a liefert nach dem Parsen einen Wert vom Typ a als Ergebnis. Wenn wir einen Decoder a mit decodeString auf eine Zeichenkette anwenden, erhalten wir entweder einen Fehler und eine Fehlermeldung mit einer Fehlerbeschreibung oder wir erhalten einen Wert vom Typ a. Daher ist der Ergebnistyp der Funktion decodeString entsprechend Result Error a.

Das Modul Json.Decode stellt die folgenden primitiven Decoder zur Verfügung.

string : Decoder String
int : Decoder Int
float : Decoder Float
bool : Decoder Bool

Um zu illustrieren, wie diese Decoder funktionieren, schauen wir uns die folgenden Beispiele an. Der Aufruf

Decoder.decodeString Decoder.int "42"

liefert als Ergebnis Ok 42. Das heißt, der String "42" wird erfolgreich mit dem Decoder int verarbeitet und liefert als Ergebnis den Wert 42. Der Aufruf

Decoder.decodeString Decoder.int "a"

liefert dagegen als Ergebnis

Err (Failure ("This is not valid JSON! Unexpected token a in JSON at position 0"))

da der String "a" nicht der Spezifikation des Formates entspricht, da es sich nicht um einen Int handelt.

Die Idee der Funktion map, die wir für Listen kennengelernt haben, lässt sich auch auf andere Datenstrukturen anwenden. Genauer gesagt, kann man map für die meisten Typkonstruktoren definieren. Da Decoder ein Typkonstruktor ist, können wir map für Decoder definieren.

Elm stellt die folgende Funktion für Decoder zur Verfügung.

map : (a -> b) -> Decoder a -> Decoder b

Diese Funktion kann zum Beispiel genutzt werden, um das Ergebnis eines Decoder in einen Konstruktor einzupacken. Wir nehmen einmal an, dass wir den folgenden – zugegebenermaßen etwas artifiziellen – Datentyp in unserer Anwendung nutzen.

type alias User =
    { age : Int }

Wir können nun auf die folgende Weise einen Decoder definieren, der eine Zahl parst und als Ergebnis einen Wert vom Typ User zurückliefert.

userDecoder : Decoder User
userDecoder =
    Decoder.map User Decoder.int

Wir sind nun nur in der Lage einen JSON-Wert, der nur aus einer Zahl besteht, in einen Elm-Datentyp umzuwandeln. In den meisten Fällen wird das Alter des Nutzers auf JSON-Ebene nicht als einzelne Zahl dargestellt, sondern zum Beispiel durch folgendes JSON-Objekt.

{
  "age": 18
}

Auf diese Struktur können wir unseren Decoder nicht anwenden, da der Decoder int nur Zahlen parsen kann. Um dieses Problem zu lösen, nutzt man in Elm die folgende Funktion.

field : String -> Decoder a -> Decoder a

Mit dieser Funktion kann ein Decoder auf ein einzelnes Feld einer JSON-Struktur angewendet werden. Das heißt, der folgende Decoder ist in der Lage die oben gezeigte JSON-Struktur zu verarbeiten.

userDecoder : Decoder User
userDecoder =
    Decoder.map User (Decoder.field "age" Decoder.int)

Der Aufruf

Decoder.decodeString userDecoder "{ \"age\": 18 }"

liefert in diesem Fall als Ergebnis Ok { age = 18 }. Das heißt, dieser Aufruf ist in der Lage, den String, der ein JSON-Objekt darstellt, in einen Elm-Record zu überführen. Ein Parser verarbeitet einen String häufig so, dass Leerzeichen für das Ergebnis keine Rolle spielen. So liefert der Aufruf

Decoder.decodeString userDecoder "{\t   \"age\":\n     18}"

etwa das gleiche Ergebnis wie der Aufruf ohne die zusätzlichen Leerzeichen.

In den meisten Fällen hat die JSON-Struktur, die wir verarbeiten wollen, nicht nur ein Feld, sondern mehrere. Für diesen Zweck stellt Elm die Funktion

map2 : (a -> b -> c) -> Decoder a -> Decoder b -> Decoder c

zur Verfügung, mit der wir zwei Decoder zu einem kombinieren können.

Wir erweitern unseren Datentyp wie folgt.

type alias User =
    { name : String
    , age : Int
    }

Nun definieren wir einen Decoder mithilfe von map2 und kombinieren dabei einen Decoder für den Int mit einem Decoder für den String.

userDecoder : Decoder User
userDecoder =
    Decoder.map2
        User
        (Decoder.field "name" Decoder.string)
        (Decoder.field "age" Decoder.int)

Der Aufruf

decodeString userDecoder "{ \"name\": \"Max Mustermann\", \"age\": 18}"

liefert in diesem Fall das folgende Ergebnis.

Ok { age = 18, name = "Max Mustermann" }

Der Decoder userDecoder illustriert wieder gut die deklarative Vorgehensweise. Zur Implementierung des Decoders beschreiben wir, welche Felder wir aus den JSON-Daten verarbeiten wollen und wie wir aus den Ergebnissen der einzelnen Parser das Endergebnis konstruieren. Wir beschreiben aber nicht, wie genau das Verarbeiten durchgeführt wird.

Bisher können wir nur recht einfach gehaltene Decoder definieren. Im Folgenden wollen wir uns ein komplexeres Beispiel anschauen. Für unser Beispiel gehen wir davon aus, dass die JSON-Struktur, die wir von einem Server erhalten, ein Feld mit der Version der Schnittstelle hat. Abhängig von der Version wollen wir jetzt den einen oder anderen Decoder verwenden.

Zuerst betrachten wir zwei primitive Decoder, die für dich allein genommen, eher nutzlos erscheinen, um Zusammenspiel mit weiteren Funktionen aber sehr nützlich sind. Die Funktion succeed :: a -> Decoder a liefert einen Decoder, der immer erfolgreich ist. Das heißt, der folgende Aufruf

decodeString (Decoder.succeed 23) "{ \"name\": \"Max Mustermann\", \"age\": 18}"

liefert immer als Ergebnis 23. Die Funktion fail :: String -> Decoder a liefert dagegen einen Decoder, der immer fehlschlägt. Das heißt, der folgende Aufruf

decodeString (Decoder.fail "Error message") "{ \"name\": \"Max Mustermann\", \"age\": 18}"

liefert dagegen als Ergebnis

Err (Failure ("Error message"))

Diese beiden primitiven Decoder sind für sich allein genommen nicht sehr nützlich. Sie werden aber sehr mächtig, wenn man sie mit einer Form von Fallunterscheidung kombiniert. In diesem Fall können wir nämlich dafür sorgen, dass der Decoder entweder erfolgreich ist, indem wir succeed verwenden und das Ergebnis liefern oder mit einer Fehlermeldung fehlschlägt.

Wir definieren dazu erst einmal einen Decoder, der die Version liefert.

versionDecoder : Decoder Int
versionDecoder =
    Decoder.field "version" Decoder.int

Außerdem haben wir die folgenden beiden Decoder für die beiden Varianten der JSON-Struktur. Das heißt, in einer Version hieß das Feld bool und in einer anderen Version hieß es boolean.

boolDecoder : Decoder Bool
boolDecoder =
    Decoder.field "bool" Decoder.bool

booleanDecoder : Decoder Bool
booleanDecoder =
    Decoder.field "boolean" Decoder.bool

Wir möchten jetzt gern einen Decoder definieren, der abhängig von der Version entweder boolDecoder oder booleanDecoder verwendet. Diese Art von Decoder können wir mithilfe von map und map2 aber nicht definieren. Das Problem besteht darin, dass wir abhängig von einem Wert des Felder version den Decoder bestimmen möchten, mit dem wir die restlichen Felder verarbeiten. Die Funktionen map und map2 haben aber die Typen (a -> b) -> Decoder a -> Decoder b und (a -> b -> c) -> Decoder a -> Decoder b -> Decoder c. Das heißt, die Funktion, die wir an map und map2 übergeben, können sich als Ergebnis nicht für einen Decoder entscheiden.

Wir können die gewünschte Funktionalität aber mit der folgenden Funktion implementieren.

andThen : (a -> Decoder b) -> Decoder a -> Decoder b

Hier haben wir statt eines Arguments a -> b oder a -> b -> c jetzt ein Argument vom Typ a -> Decoder b. Das heißt, wir können abhängig vom konkreten Wert, der vom Typ a übergeben wird, den Decoder wählen, den wir anschließend verwenden. Wir können damit den folgenden Decoder definieren.

decoder : Decoder Bool
decoder =
    let
        chooseVersion version =
            case version of
                1 ->
                    boolDecoder

                2 ->
                    booleanDecoder

                _ ->
                    Decoder.fail
                        ("Version "
                            ++ String.fromInt version
                            ++ " not supported"
                        )
    in
    Decoder.andThen chooseVersion versionDecoder

Die Funktion Decoder.fail liefert einen Decoder, der immer fehlschlägt. Das heißt, wenn wir eine Version parsen und es sich weder um Version 1 noch um Version 2 handelt, liefert decoder einen Fehler. Dieses Beispiel illustriert, dass wir mithilfe von andThen abhängig von einem Wert, den wir zuvor geparset haben, verschiedene Decoder ausführen können.

Die Reihenfolge der Argumente im Aufruf Decoder.andThen chooseVersion versionDecoder ist unglücklich, da wir zuerst den Decoder versionDecoder durchführen und anschließend die Funktion chooseVersion. Es stellt sich also die Frage, warum die Funktion Decoder.andThen ihre Argumente in dieser Reihenfolge erhält. Der Grund besteht darin, dass man die Funktion Decoder.andThen zusammen mit dem Operator |> nutzt. Das heißt, man nutzt bei der Definition von Decodern häufig Piping.

Um Piping anzuwenden, benötigen wir eine einstellige Funktion. Die Funktion Decoder.andThen ist aber zweistellig. Daher wird die Funktion Decoder.andThen zuerst partiell auf das Argument chooseVersion appliziert. Wir erhalten somit, Decoder.andThen chooseVersion, also eine einstellige Version. Diese Funktion können wir mithilfe von |> auf ihr Argument anwenden. Wir erhalten damit die folgende Definition. Aus didaktischen Gründen haben wir zuvor die Konstante versionDecoder definiert. In einer "realen" Anwendung würde man auf die Definition dieser Konstante aber eher verzichten.

decoder : Decoder Bool
decoder =
    let
        chooseVersion version =
            case version of
                1 ->
                    boolDecoder

                2 ->
                    booleanDecoder

                _ ->
                    Decoder.fail
                        ("Version "
                            ++ String.fromInt version
                            ++ " not supported"
                        )
    in
    Decoder.field "version" Decoder.int
        |> Decoder.andThen chooseVersion

Um die Funktion Decoder.andThen noch etwas zu illustrieren, wollen wir den Decoder userDecoder, den wir zuvor bereits definiert haben, noch einmal mithilfe von Decoder.andThen definieren.

user : Decoder User
user =
    Decoder.field "id" Decoder.int
        |> Decoder.andThen
            (\id ->
                Decoder.field "name" Decoder.string
                    |> Decoder.andThen
                        (\name ->
                            Decoder.succeed
                                { id = id
                                , name = name
                                }
                        )
            )

Aufgrund der Schachtelung ist dieser Code vergleichsweise schwierig zu lesen. Daher sollte man für diesen Anwendungsfall die Funktion map2 verwenden. Dieses Beispiel illustriert aber, dass wir die Funktion vom Typ a -> Decoder b definieren können, indem wir wiederum andThen verwenden. Auf diese Weise, können wir die Entscheidung, die in der Funktion vom Typ a -> Decoder b getroffen wird, von mehreren Werten abhängig machen. Wir könnten in der Definition von user zum Beispiel abhängig von den Werten von id und name den Decoder erfolgreich ein Ergebnis liefern oder scheitern lassen.

{% include callout-info.html content=" Die Programmiersprache Haskell stellt die do-Notation zur Verfügung, um Funktionen übersichtlicher zu gestalten, wenn sie mehrere Aufrufe einer Funktion wie andThen enthalten. " %}

Als weiteres Beispiel für die Verwendung von andThen betrachten wir den Fall, dass wir auf dem Server einen Wert vom Typ String speichern, in der Anwendung aber einen Aufzählungstyp zur Darstellung verwenden. Wir betrachten an dieser Stellen den folgenden Datentyp, der verschiedene Benutzerrollen definiert.

type Role
    = Admin
    | User

Auf dem Server wird der Eintrag Role mithilfe eines String dargestellt. Daher benötigen wir einen Decoder, der das Feld in Form eines String parset und anschließend in unseren Datentyp Role umwandelt. Dabei kann es vorkommen, dass in der Datenbank ein Wert steht, den wir nicht erwarten. Daher müssen wir auch den Fall behandeln, dass der String in der Datenbank weder "Admin" noch "User" ist.

roleDecoder : Decoder Role
roleDecoder =
    let
        decodeRole string =
            case string of
                "Admin" ->
                    Decoder.succeed Admin

                "User" ->
                    Decoder.succeed User

                _ ->
                    Decoder.fail (string ++ " is not a valid value of type Role")
    in
    Decoder.field "role" Decoder.string
        |> Decoder.andThen decodeRole

Encode

Wenn wir mit einem Server kommunizieren, müssen wir nicht nur in der Lage sein, die JSON-Daten, die wir vom Server erhalten, in Elm-Datentypen umzuwandeln. Wir müssen auch in der Lage sein, JSON-Daten, zu erzeugen, die wir an den Server schicken können. Für diesen Zweck wird in Elm das Modul Json.Encode2 aus der Bibliothek elm/json verwendet.

Der Typ Value repräsentiert JSON-Daten. Dieser Datentyp stellt keine Konstruktoren zur Verfügung. Stattdessen stellt das Modul Json.Encode eine Reihe von Funktionen zur Verfügung, mit denen wir die verschiedenen Formen von JSON-Daten erzeugen können. Das Modul stellt außerdem eine Funktion encode : Int -> Value -> String zur Verfügung, mit der wir aus einem Value einen String erzeugen können. Der Int gibt dabei die Einrückungstiefe an, die bei der Formatierung des JSON-Strings verwendet wird.

Zuerst wollen wir einen Wert vom Typ User, den wir im Abschnitt Decoder definiert haben, in einen JSON-Wert umwandeln. Wir wollen einen User als JSON-Objekt mit zwei Feldern darstellen. Daher verwenden wir die Funktion object : List ( String, Value ) -> Value. Die erste Komponente der Paare in der Liste gibt dabei die Namen der Felder an. Die zweite Komponente der Paare ist ein JSON-Wert. Neben der Funktion object stellt das Modul Json.Encode zum Beispiel die Funktion string : String -> Value zur Verfügung, um aus einem String auf Elm-Ebene einen entsprechenden JSON-Wert zu machen.

Der folgende Aufruf

Encode.encode 4 (Encode.string "test")

liefert zum Beispiel "\"test\" als Ergebnis. Das heißt, wir erhalten einen String, der Anführungszeichen und den Text test enthält. Bei dem Ergebnis handelt es sich um einen validen JSON-Wert.

Der User enthält neben dem Namen vom Typ String noch ein Alter vom Typ Int. Um dieses zu encodieren, mutzen wir die Funktion Encode.int : Int -> Value. Der folgende Aufruf

Encode.encode 4 (Encode.int 23)

liefert zum Beispiel "23" als Ergebnis. Das heißt, wir erhalten einen String, der den Text 23 enthält. Dabei handelt es sich wieder um einen validen JSON-Wert, da eine einzelne Zahl ein valider JSON-Wert ist.

Um einen Wert vom Typ User in ein JSON-Objekt umzuwandeln, nutzen wir die folgende Definition. Wir gehen davon aus, dass die folgende Definition in einem Modul User definiert wird, daher können wir den Namen encode wählen.

encode : User -> Encode.Value
encode { name, age } =
    Encode.object
        [ ( "name", Encode.string name )
        , ( "age", Encode.int age )
        ]

Footnotes

  1. Dieses Modul wird hier mittels import Json.Decoder as Decode exposing (Decoder) importiert. Wir benennen das Modul in Decoder um, da der Name Decode unglücklich gewählt ist, da das Modul den Typ Decoder exportiert.

  2. Dieses Modul wird hier mittels import Json.Encode as Encode importiert. Im Gegensatz zum Modul Json.Decode stellt das Modul Json.Encode eben keinen Encoder zur Verfügung, sondern Funktionen. Diese Funktionen laufen unter dem Oberbegriff Encode.