JSON (JavaScript Object Notation) ist ein für Menschen lesbares Daten- und Dateiformat, das häufig zum Austausch von Daten verwendet wird, insbesondere für Webanwendungen. Daher überrascht es nicht, dass es in der Sprache Elm einen besonderen Platz einnimmt.
Elm stellt die Kernmodule Json.Decode und Json.Encode bereit, um JSON-Daten zu parsen und zu schreiben.
JSON-Decoder sind eine Möglichkeit, zu deklarieren, welcher Datentyp erwartet wird und auf welche Datenstruktur die Daten abgebildet werden sollen.
Importieren wir einige Aliase und Typen aus den Modulen:
import Json.Decode as Decode exposing (Decoder, Error)
import Json.Encode as Encode exposing (Value)
Wenn du Decode.decodeString : Decoder a -> String -> Result Error a mit einem Decoder und einem JSON-String verwendest, ist das Ergebnis entweder erfolgreich, wenn der String den Spezifikationen des Decoders entspricht, oder es schlägt andernfalls fehl.
Es gibt keine Möglichkeit, die rohen JSON-Daten zu untersuchen, wenn du den Decoder definierst, was sich ungewöhnlich anfühlen kann, wenn du aus imperativen Sprachen kommst.
JSON hat eine kleine Anzahl grundlegender Datentypen:
"hello world"
true oder false
34 oder 0.12
null
Jeder davon ist mit einer Decode-Funktion verknüpft:
Decode.decodeString Decode.string """\"hello\""""
--> Ok "hello"
Decode.decodeString Decode.string """null"""
--> Err ...
Decode.decodeString Decode.bool """true"""
--> Ok True
Decode.decodeString Decode.bool """0"""
--> Err ...
Decode.decodeString Decode.int """12"""
--> Ok 12
Decode.decodeString Decode.float """3.14"""
--> Ok 3.14
Decode.decodeString (Decode.null AnyValue) """null"""
--> Ok AnyValue
Decode.decodeString (Decode.null AnyValue) """true"""
--> Err ...
Beachte, dass du mit Decode.null entscheiden kannst, wie du ein null in deinem Programm modellierst, indem es einen beliebigen Wert als Argument verlangt (vielleicht (), Nothing oder irgendetwas, das zu deinem Programm passt).
JSON definiert außerdem zwei Datenstrukturen, um grundlegende Datentypen zusammenzufassen:
[0, null, "none", []]
{"id": 1, "is_admin": false, "more_info": {}}
Decode bietet auch Funktionen, um diese zu dekodieren:
Decode.decodeString (Decode.list Decode.int) """[1, 2, 3]"""
--> Ok [1, 2, 3]
Decode.decodeString (Decode.list Decode.int) """[1, 2, 3, "not an int"]"""
--> Err ...
Decode.decodeString (Decode.dict Decode.int) """{ "key1": 17, "key2": 71 }"""
--> Ok (Dict.fromList [("key1", 17), ("key2", 71)])
Decode.decodeString (Decode.dict Decode.int) """{ "key1": 17, "key2": "seventy-one" }"""
--> Err ...
Diese Funktionen Decode.list : Decoder a -> Decoder (List a) und Decode.dict : Decoder a -> Decoder (Dict String a) erwarten jeweils einen Decoder als Argument, der jedes einzelne Element der Datenstruktur dekodieren soll.
Wenn der Argument-Decoder ein Element eines Arrays oder Objekts nicht dekodieren kann, schlägt der gesamte Decoder fehl, was ein häufiges Muster ist.
Einfache Decoder zu komplexeren zu kombinieren, ist die Kernidee dieser Technik. Anhand eines Beispiels stellen wir mehrere weitere Funktionen vor, mit denen du beliebig komplexe Decoder für echte Daten bauen kannst.
Um das Kombinieren grundlegender Decoder zum Parsen komplexer JSON-Daten zu üben, bauen wir Decoder zum Parsen (einer Teilmenge) von GeoJSON-Daten. GeoJSON ist ein spezialisiertes JSON-basiertes Datenformat, mit dem geografische Objekte wie Orte, Pfade oder Regionen dargestellt werden, die durch 2D- oder 3D-Koordinaten zusammen mit weiteren Eigenschaften beschrieben werden.
Ein GeoJSON Geometry-Objekt ist ein Objekt, das ein Feld type mit einem String-Wert enthält (es gibt 7 mögliche Typwerte) und ein Feld coordinates mit geografischen Koordinaten (gesammelt in einer Struktur, die vom Typ abhängt).
Zum Beispiel
{
"type": "Point",
"coordinates": [127.831, 26.461]
}
oder
{
"type": "LineString",
"coordinates": [
[127.831, 26.461],
[127.829, 26.465],
[127.829, 26.469],
[127.83, 26.47]
]
}
Ein Geometry-Objekt könnte in Elm mit dem folgenden Typ dargestellt werden
type Geometry
= Point (List Float)
| LineString (List (List Float))
| ...
Um den Wert eines bestimmten Feldes zu prüfen, können wir Decode.field : String -> Decoder a -> Decoder a verwenden, was den Wert eines bestimmten Schlüssels eines Objekts dekodiert.
decodePointCoordinates : Decoder (List Float)
decodePointCoordinates =
Decode.field "coordinates" (Decode.list Decode.float)
decodeLineStringCoordinates : Decoder (List (List Float))
decodeLineStringCoordinates =
Decode.field "coordinates" (Decode.list (Decode.list Decode.float))
Um den von einem Decoder erfolgreich geparsten Wert umzuwandeln, können wir Decode.map : (a -> value) -> Decoder a -> Decoder value oder einen seiner Verwandten verwenden, von Decode.map2 (a -> b -> value) -> Decoder a -> Decoder b -> Decoder value bis Decode.map8.
In diesem Fall wollen wir abhängig vom Wert im Feld type entscheiden, welchen Koordinaten-Decoder wir verwenden. Das ist eine perfekte Aufgabe für Decode.andThen : (a -> Decoder b) -> Decoder a -> Decoder b und seine Freunde Decode.succeed : a -> Decoder a und Decode.fail : String -> Decoder a.
decodeGeometry : Decoder Geometry
decodeGeometry =
Decode.field "type" Decode.string
|> Decode.andThen
(\value ->
case value of
"Point" ->
decodePointCoordinates |> Decode.map Point
"LineString" ->
decodeLineStringCoordinates |> Decode.map LineString
_ ->
Decode.fail "Geometry not implemented yet, or invalid type value"
)
Beachte an dieser Stelle, dass wir Decode.andThen verwenden könnten, um die Koordinaten zu validieren (zu prüfen, dass die Koordinaten paarweise oder als Tripel auftreten und dass die Koordinatenwerte geografisch sinnvoll sind).
Decode.decodeString decodeGeometry """{"type": "Point", "coordinates": [127.831, 26.461]}"""
--> Ok (Point [127.831, 26.461])
Decode.decodeString decodeGeometry """{"type": "LineString", "coordinates": [127.831, 26.461]}"""
--> Err ...
Ein GeoJSON Feature-Objekt repräsentiert etwas räumlich Begrenztes: Es hat ein Feld type mit dem Wert "Feature", ein Feld geometry mit einem Geometry-Objekt oder null, ein Feld properties mit einem beliebigen JSON-Objekt oder null und ein optionales Feld id mit einem JSON-String- oder Zahlenwert.
{
"type": "Feature",
"geometry": null,
"properties": null
}
oder
{
"type": "Feature",
"id": "0157",
"geometry": {
"type": "Point",
"coordinates": [127.831, 26.461]
},
"properties": {
"country": "Japan"
}
}
Kümmern wir uns zuerst um das Feld id.
Wenn es existiert, muss es Strings oder Zahlen aufnehmen können. Wir könnten also entweder einen eigenen Typ für den Wert einführen oder ihn immer als String speichern.
Um zwischen zwei möglichen Decodern zu wählen, können wir Decode.oneOf : List (Decoder a) -> Decoder a verwenden.
decodeFeatureId : Decoder String
decodeFeatureId =
Decode.oneOf
[ Decode.string
, Decode.int |> Decode.map String.fromInt
, Decode.float |> Decode.map String.fromFloat
]
|> Decode.field "id"
Decode.decodeString decodeFeatureId """{"id": "seventeen"}"""
--> Ok "seventeen"
Decode.decodeString decodeFeatureId """{"id": 17}"""
--> Ok "17"
Decode.decodeString decodeFeatureId """{"type": "Point"}"""
--> Er ...
Um auszudrücken, dass das Feld optional ist, könnten wir dieselbe Technik verwenden
decodeMaybeFeatureId : Decoder (Maybe String)
decodeMaybeFeatureId =
Decode.oneOf
[ decodeFeatureId |> Decode.map Just
, Decode.succeed Nothing
]
Wir könnten aber stattdessen Decode.maybe : Decoder a -> Decoder (Maybe a) verwenden
decodeMaybeFeatureId : Decoder (Maybe String)
decodeMaybeFeatureId =
Decode.maybe decodeFeatureId
Decode.decodeString decodeMaybeFeatureId """{"id": 17}"""
--> Ok (Just "17")
Decode.decodeString decodeMaybeFeatureId """{"type": "Point"}"""
--> Ok Nothing
Beachte, dass decodeMaybeFeatureId (oder jeder mit Decode.maybe umhüllte Decoder) immer erfolgreich ist, da er immer auf Ok Nothing zurückfallen kann, obwohl ein Decoder, der ihn verwendet, trotzdem fehlschlagen kann
Decode.decodeString (Decode.list decodeMaybeFeatureId) """{"id": 17}"""
--> Err ...
Als Nächstes konzentrieren wir uns auf das Feld geometry, das entweder ein Geometry-Objekt oder null sein kann.
Wir könnten erneut Decode.oneOf verwenden, aber es gibt eine bessere Option: Decode.nullable : Decoder a -> Decoder (Maybe a).
decodeFeatureGeometry : Decoder (Maybe Geometry)
decodeFeatureGeometry =
Decode.nullable decodeGeometry
|> Decode.field "geometry"
Decode.decodeString decodeFeatureGeometry """{"geometry": {"type": "Point", "coordinates": [127.831, 26.461]}}"""
--> Ok (Just (Point [127.831, 26.461]))
Decode.decodeString decodeFeatureGeometry """{"geometry": null}"""
--> Ok Nothing
Decode.decodeString decodeFeatureGeometry """{"geometry": {}}"""
--> Err ...
Decode.maybe und Decode.nullable haben dieselbe Signatur, aber ihr Verhalten ist unterschiedlich, da das Ergebnis OK Nothing eines Decode.nullable nur aus dem speziellen JSON-Wert null entsteht.
Schließlich kann das Feld properties ein beliebiges JSON-Objekt oder null enthalten, was eine Aufgabe für Decode.value : Decoder Value ist.
decodeProperties : Decoder (Maybe (Dict String Value))
decodeProperties =
Decode.nullable (Decode.dict Decode.value)
|> Decode.field "properties"
Decode.decodeString decodeProperties """{"properties": null}"""
--> Ok Nothing
Decode.decodeString decodeProperties """{"properties": {"country": "Japan"}}"""
--> Ok (Just (Dict.fromList [("country", <internals>)]))
Decode.decodeString decodeProperties """{"properties": 17}"""
--> Err ...
Beachte, dass Value ein undurchsichtiger Typ ist, was bedeutet, dass du seinen Inhalt nicht ohne Weiteres prüfen kannst.
Um ihn zu verwenden, kannst du entweder einen Decoder dafür schreiben und ihn mit Decode.decodeValue : Decoder a -> Value -> Result Error a ausführen oder ihn direkt über einen Port an JavaScript senden.
Jetzt sind wir bereit, Feature-Objekte zu parsen:
type alias Feature =
{ id : Maybe String
, geometry : Maybe Geometry
, properties : Maybe (Dict String Value)
}
decodeFeature : Decoder Feature
decodeFeature =
Decode.field "type" Decode.string
|> Decode.andThen
(\value ->
if value /= "Feature" then
Decode.fail "not a Feature"
else
Decode.map3 Feature
decodeMaybeFeatureId
decodeFeatureGeometry
decodeProperties
)
Decode.decodeString decodeFeature """{"type": "Feature", "geometry": null, "properties": null}"""
--> Ok { geometry = Nothing, id = Nothing, properties = Nothing }
"""
{
"type": "Feature",
"id": "0157",
"geometry": {
"type": "Point",
"coordinates": [127.831, 26.461]
},
"properties": {
"country": "Japan"
}
}
"""
|> Decode.decodeString decodeFeature
--> Ok { geometry = Just (Point [127.831,26.461]), id = Just "0157", properties = Just (Dict.fromList [("country",<internals>)]) }
Mit Encodern kannst du gültiges JSON aus Elm-Werten schreiben, und zwar mit der Funktion Encode.encode : Int -> Value -> String.
Das erste Argument gibt an, wie stark das Endergebnis eingerückt wird, und das zweite Argument ist der zu schreibende JSON-Wert.
Ein Value kann entweder aus Decode.value gewonnen oder von einem der Encoder erzeugt werden:
Encode.encode 0 (Encode.string "hello")
--> "hello"
Encode.encode 0 (Encode.bool True)
--> "true"
Encode.encode 0 (Encode.int 12)
--> "12"
Encode.encode 0 (Encode.float 3.14)
--> "3.14"
Encode.encode 0 Encode.null
--> "null"
Encode.encode 0 (Encode.list Encode.int [1, 2, 3])
--> "[1,2,3]"
Encode.encode 4 (Encode.list Encode.int [1, 2, 3])
--> "[\n 1,\n 2,\n 3\n]"
Encode.encode 0 (Encode.dict String.toLower Encode.int (Dict.fromList [("KEY1", 17), ("KEY2", 71)]))
--> "{\"key1\":17,\"key2\":71}"
Encode.encode 4 (Encode.dict String.toLower Encode.int (Dict.fromList [("KEY1", 17), ("KEY2", 71)]))
--> "{\n \"key1\": 17,\n \"key2\": 71\n}"
sowie mit Encode.object : List ( String, Value ) -> Value
Encode.object
[ ( "key1", Encode.int 17 )
, ( "key2", Encode.int 71 )
]
|> Encode.encode 0
--> "{\"key1\":17,\"key2\":71}"
Definieren wir Encoder für die zuvor definierten GeoJSON-Decoder
encodeGeometry : Geometry -> Value
encodeGeometry geometry =
case geometry of
Point coord ->
Encode.object
[ ( "type", Encode.string "Point" )
, ( "coordinates", Encode.list Encode.float coord )
]
LineString coord ->
Encode.object
[ ( "type", Encode.string "LineString" )
, ( "coordinates", Encode.list (Encode.list Encode.float) coord )
]
encodeFeature : Feature -> Value
encodeFeature feature =
let
maybeId =
case feature.id of
Nothing ->
[]
Just id ->
[ ( "id", Encode.string id ) ]
in
Encode.object
(maybeId
++ [ ( "type", Encode.string "Feature" )
, ( "geometry"
, case feature.geometry of
Nothing ->
Encode.null
Just geometry ->
encodeGeometry geometry
)
, ( "properties"
, case feature.properties of
Nothing ->
Encode.null
Just dict ->
Encode.dict identity identity dict
)
]
)
Im Allgemeinen ist ein guter Test für deine Decoder-Encoder-Paare die Prüfung, dass elmValue |> encoder |> decoder == elmValue gilt.
Je mehr du über das Programmieren lernst, desto mehr denkst du, dass das mehr als nur eine Modeerscheinung sein könnte. Vielleicht steckt sogar ein Geschäft dahinter: Menschen schreiben Code, sie wollen ihn speichern, teilen, zusammenarbeiten, vielleicht sogar Feedback bekommen.
Also los, bauen wir einen Dienst, der Menschen zum Programmieren ermutigt: hup, hup, Leute, benutzt git! Nennen wir ihn... GitHup.
Du machst dich daran, eine REST-JSON-API zu bauen: Benutzer, Pull Requests, Kommentare, das Backend ist schnell fertig. Hier ist ein Beispiel für die Nutzlast, die ausgeliefert wird, wenn du den Server nach der Liste der Kommentare zu einem Pull Request fragst:
[
{
"id": 256,
"pull_request_review_id": 42,
"user": {
"id": 101,
"login": "octodog",
"avatar_url": "https://githup.com/images/error/octodog_happy.gif",
"site_admin": false
},
"body": "Great stuff!",
"side": "RIGHT",
"_links": {
"self": {
"href": "https://api.githup.com/repos/octodog/Hello-World/pulls/comments/1"
},
"html": {
"href": "https://githup.com/octodog/Hello-World/pull/1#discussion-diff-1"
},
"pull_request": {
"href": "https://api.githup.com/repos/octodog/Hello-World/pulls/1"
}
}
},
{
"id": 11,
"pull_request_review_id": null,
"user": {
"id": 2,
"login": "hexacat",
"name": "Alex Kate",
"avatar_url": "https://githup.com/images/error/hexacat_happy.gif",
"site_admin": true
},
"body": "Amazing stuff!",
"side": "LEFT",
"_links": {
"self": {
"href": "https://api.githup.com/repos/octodog/Hello-World/pulls/comments/1"
}
}
}
]
Natürlich wird das Frontend in Elm geschrieben, aber zuerst musst du herausfinden, was es mit diesen berüchtigten Decodern und Encodern auf sich hat.
Spaß beiseite, die Anforderungen dieser Übung stammen direkt aus GitHubs REST-API-Endpunkten für Pull-Request-Review-Kommentare. Einige Felder wurden der Einfachheit halber aus den JSON-Schemas entfernt, aber näher an echte Anwendungen kommt man kaum heran.
Deinem Adlerauge ist aufgefallen, dass sowohl Kommentare als auch Benutzer ein Feld id haben, ein perfekter Ausgangspunkt.
Definiere decodeId, sodass es ein JSON-Objekt mit einer ganzzahligen id dekodieren kann.
Decode.decodeString decodeId """{id: 10}"""
--> Ok 10
Du bemerkst im obigen Beispiel, dass nicht alle Benutzer einen Namen haben.
Definiere decodeName, sodass es einen String im Feld name dekodieren kann, falls einer vorhanden ist.
Kein Name? Kein Problem, gib einfach Nothing zurück.
Dieser Decoder darf niemals fehlschlagen.
Decode.decodeString decodeName """{"id": 10, "name": "Otto"}"""
--> Ok (Just Otto)
Decode.decodeString decodeName """null"""
--> Ok Nothing
Beim Dekodieren von Benutzern sind wir schon fast auf halbem Weg, bringen wir es zu Ende.
Definiere decodeUser, sodass es ein JSON-Benutzerobjekt dekodieren kann.
Achte darauf, decodeId und decodeName in decodeUser zu verwenden.
Decode.decodeString decodeUser
"""
{
"id": 101,
"login": "octodog",
"avatar_url": "https://githup.com/images/error/octodog_happy.gif",
"site_admin": false
}
"""
--> Ok
--> { id = 101
--> , name = Nothing
--> , login = "octodog"
--> , avatarUrl = "https://githup.com/images/error/octodog_happy.gif
--> , siteAdmin = False
--> }
Manche Kommentare in einer Pull-Request-Review sind eigenständig und haben daher keine pull_request_review_id.
Die Spezifikation erwähnt jedoch, dass es in einem solchen Fall trotzdem ein Feld pull_request_review_id geben sollte, aber der Wert sollte null sein, das Nothing des kleinen Mannes.
Definiere decodePullRequestReviewId, sodass es eine Ganzzahl im Feld pull_request_review_id dekodieren kann, falls eine vorhanden ist.
Wenn es keine Ganzzahl gibt, sollte dort null stehen, und wenn es kein null gibt, sollte der Decoder fehlschlagen.
Decode.decodeString decodePullRequestReviewId """{"pull_request_review_id": 3}"""
--> Ok (Just 3)
Decode.decodeString decodePullRequestReviewId """{"pull_request_review_id": null}"""
--> Ok Nothing
Decode.decodeString decodePullRequestReviewId """{"id": 3}"""
--> Err ...
Als Nächstes kommt dieses interessant aussehende Feld side.
In einer Pull-Request-Review kannst du Code kommentieren, der entfernt wurde (auf der Left-Seite des Bildschirms), oder Code, der hinzugefügt wurde (auf der Right-Seite des Bildschirms).
Definiere decodeSide, sodass es entweder "LEFT" oder "RIGHT" im Feld side dekodieren und der entsprechenden Typvariante zuordnen kann.
Decode.decodeString decodeSide """{"side": "LEFT"}"""
--> Ok Left
Decode.decodeString decodeSide """{"side": "middle?"}"""
--> Err ...
Jeder Kommentar hat eine Reihe von Links, die über die Kommentare hinweg nicht einheitlich zu sein scheint.
Definiere decodeLinks, sodass es eine Sammlung von URLs im Feld _links dekodieren kann.
Die Namen und die Anzahl der Links können variieren, stelle sicher, dass du das berücksichtigst.
Die Links selbst sind JSON-Objekte, die immer ein Feld href haben sollten.
Decode.decodeString decodeLinks """
{
"_links": {
"self": { "href": "https://api.githup.com/repos/octodog/Hello-World/pulls/comments/1" },
"html": { "href": "https://githup.com/octodog/Hello-World/pull/1#discussion-diff-1" },
"pull_request": { "href": "https://api.githup.com/repos/octodog/Hello-World/pulls/1" }
}
}
"""
--> Ok
--> (Dict.fromList
--> [ ( "self", "https://api.githup.com/repos/octodog/Hello-World/pulls/comments/1" )
--> , ( "html", "https://githup.com/octodog/Hello-World/pull/1#discussion-diff-1" )
--> , ( "pull_request", "https://api.githup.com/repos/octodog/Hello-World/pulls/1" )
--> ]
--> )
Fast geschafft, bringen wir es zu Ende.
Definiere decodeComment und decodeComments, sodass sie ein einzelnes JSON-Kommentarobjekt bzw. eine Liste von Kommentaren dekodieren können.
Achte darauf, alle bisher definierten Funktionen in decodeComment zu verwenden, mit Ausnahme von decodeName.
Natürlich sollte decodeComments ebenfalls decodeComment verwenden.
Eine Funktion fehlt noch in all dem: einen neuen Kommentar an den Server senden zu können.
Dafür brauchst du das Gegenteil eines Decoders, du musst in der Lage sein, ein Elm-Comment in einen JSON-Value umzuwandeln.
Definiere encodeComment, sodass es jeden gültigen Comment kodieren kann.
Die Reihenfolge der Felder spielt keine Rolle, solange der Encoder einen Wert erzeugen kann, den decodeComment dekodieren kann.
Melde dich bei Exercism an, um Elm mit 28 Konzepte110 Übungen und echtem menschlichen Mentoring zu lernen und zu meistern, alles kostenlos.