JSON(JavaScript Object Notation)は、人間が読めるデータ形式でありファイル形式でもあり、データのやり取り、とくにWebアプリケーションでのデータ交換によく使われます。それだけに、Elmという言語の中でJSONが特別な位置を占めているのも不思議ではありません。
Elmには、JSONデータを解析したり書き出したりするためのコアモジュールとしてJson.DecodeとJson.Encodeが用意されています。JSONデコーダーは、どんな型のデータを期待するのか、そのデータをどのデータ構造に対応させるのかを_宣言_する方法です。
これらのモジュールから、いくつかのエイリアスと型をインポートしてみましょう。
import Json.Decode as Decode exposing (Decoder, Error)
import Json.Encode as Encode exposing (Value)
Decode.decodeString : Decoder a -> String -> Result Error aをデコーダーとJSON文字列と一緒に使うと、その文字列がデコーダーの仕様に合っていれば成功し、そうでなければ失敗します。デコーダーを定義するときに生のJSONデータを調べる方法はありません。命令型言語に慣れていると、これは少し不思議に感じられるかもしれません。
JSONの基本データ型はごくわずかです。
"hello world"
trueかfalseのどちらかです34や0.12のような値ですnullですこれらのそれぞれに、対応するDecode関数があります。
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 ...
Decode.nullを使うと、nullをプログラムの中でどう扱うかを自分で決められます。引数に任意の値を渡す仕組みになっているので、()やNothingなど、プログラムに合ったものを選べます。
JSONにはまた、基本データ型をまとめるためのデータ構造が2つあります。
[0, null, "none", []]
{"id": 1, "is_admin": false, "more_info": {}}
Decodeにも、これらをデコードする関数が用意されています。
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 ...
これらの関数、Decode.list : Decoder a -> Decoder (List a)とDecode.dict : Decoder a -> Decoder (Dict String a)は、どちらも引数にデコーダーを取ります。そのデコーダーは、データ構造の要素をひとつずつデコードする役割を担います。配列やオブジェクトの中に、引数のデコーダーでデコードできない要素が1つでもあると、デコーダー全体が失敗します。これはよく使われるパターンです。
単純なデコーダーを組み合わせて、より複雑なデコーダーを作るのが、この技法の中心となる考え方です。ここからは例を交えながら、現実世界の複雑なデータを自在にデコードできるデコーダーを組み立てるための関数を、いくつか紹介していきます。
基本的なデコーダーを組み合わせて複雑なJSONデータを解析する練習として、GeoJSONのデータを(一部だけ)解析するデコーダーを作ってみましょう。GeoJSONは、地理的な特徴を表すために使われる、JSONをベースにした専用のデータ形式です。地点や経路、領域といったものを、2次元または3次元の地理座標とその他のプロパティで表現します。
GeoJSONのジオメトリオブジェクトは、文字列の値を持つtypeフィールド(typeの値は7種類あります)と、地理座標を持つcoordinatesフィールド(座標のまとめ方はtypeによって変わります)を含むオブジェクトです。たとえば、次のようになります。
{
"type": "Point",
"coordinates": [127.831, 26.461]
}
あるいは
{
"type": "LineString",
"coordinates": [
[127.831, 26.461],
[127.829, 26.465],
[127.829, 26.469],
[127.83, 26.47]
]
}
ジオメトリオブジェクトは、Elmでは次のような型で表せます。
type Geometry
= Point (List Float)
| LineString (List (List Float))
| ...
あるフィールドの値を取り出すには、Decode.field : String -> Decoder a -> Decoder aが使えます。これは、オブジェクトの特定のキーの値をデコードするものです。
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))
デコーダーが無事に解析した値を変換するには、Decode.map : (a -> value) -> Decoder a -> Decoder valueや、その仲間たち、Decode.map2 (a -> b -> value) -> Decoder a -> Decoder b -> Decoder valueからDecode.map8までを使えます。
ここでは、typeフィールドの値に応じて、どの座標デコーダーを使うかを決めたいところです。これはDecode.andThen : (a -> Decoder b) -> Decoder a -> Decoder bと、その仲間であるDecode.succeed : a -> Decoder a、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"
)
ここで、Decode.andThenを使えば座標を検証できることにも触れておきます。座標が2つ組や3つ組になっているか、座標の値が地理的に妥当かを確かめられるのです。
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 ...
GeoJSONのフィーチャーオブジェクトは、空間的に区切られた何かを表します。"Feature"という値を持つtypeフィールド、ジオメトリオブジェクトまたはnullを持つgeometryフィールド、任意のJSONオブジェクトまたはnullを持つpropertiesフィールド、そして省略可能なidフィールド(JSONの文字列または数値)を持ちます。
{
"type": "Feature",
"geometry": null,
"properties": null
}
あるいは
{
"type": "Feature",
"id": "0157",
"geometry": {
"type": "Point",
"coordinates": [127.831, 26.461]
},
"properties": {
"country": "Japan"
}
}
まずidフィールドから取り組みましょう。このフィールドがある場合、文字列か数値のどちらかを受け入れる必要があります。そこで、値用に独自の型を用意するか、いつも文字列として保存するかのどちらかが考えられます。
2つの候補のデコーダーから1つを選ぶには、Decode.oneOf : List (Decoder a) -> Decoder aが使えます。
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 ...
このフィールドが省略可能であることを表すには、同じ技法が使えます。
decodeMaybeFeatureId : Decoder (Maybe String)
decodeMaybeFeatureId =
Decode.oneOf
[ decodeFeatureId |> Decode.map Just
, Decode.succeed Nothing
]
ただし、代わりにDecode.maybe : Decoder a -> Decoder (Maybe a)を使うこともできます。
decodeMaybeFeatureId : Decoder (Maybe String)
decodeMaybeFeatureId =
Decode.maybe decodeFeatureId
Decode.decodeString decodeMaybeFeatureId """{"id": 17}"""
--> Ok (Just "17")
Decode.decodeString decodeMaybeFeatureId """{"type": "Point"}"""
--> Ok Nothing
decodeMaybeFeatureId(あるいはDecode.maybeで包んだ任意のデコーダー)は、いつでもOk Nothingにフォールバックできるため、必ず成功します。ただし、これを使う側のデコーダーは失敗する可能性があります。
Decode.decodeString (Decode.list decodeMaybeFeatureId) """{"id": 17}"""
--> Err ...
次にgeometryフィールドに注目しましょう。このフィールドは、ジオメトリオブジェクトかnullのどちらかです。ここでもDecode.oneOfが使えますが、もっと良い選択肢があります。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とDecode.nullableは同じシグネチャですが、動作は異なります。Decode.nullableのOK Nothingという結果は、JSONの値がnullのときだけ返されるからです。
最後に、propertiesフィールドは任意のJSONオブジェクトかnullを含められます。これにはDecode.value : Decoder Valueが向いています。
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 ...
Valueは不透明な型なので、その中身を簡単に調べることはできません。使うには、そのためのデコーダーを書いてDecode.decodeValue : Decoder a -> Value -> Result Error aで実行するか、ポートを通して直接JavaScriptに送るかのどちらかです。
これでフィーチャーオブジェクトを解析する準備ができました。
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>)]) }
エンコーダーを使うと、Elmの値からEncode.encode : Int -> Value -> String関数で正しいJSONを書き出せます。最初の引数は出力のインデント量を指定し、2番目の引数は書き出すJSONの値です。
Valueは、Decode.valueから得るか、いずれかのエンコーダーで作るかのどちらかです。
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}"
Encode.object : List ( String, Value ) -> Valueを使っても同様です。
Encode.object
[ ( "key1", Encode.int 17 )
, ( "key2", Encode.int 71 )
]
|> Encode.encode 0
--> "{\"key1\":17,\"key2\":71}"
先ほど定義したGeoJSONのデコーダーに対応するエンコーダーを定義してみましょう。
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
)
]
)
一般に、デコーダーとエンコーダーの組み合わせが正しいかどうかは、elmValue |> encoder |> decoder == elmValueが成り立つかどうかで確かめられます。
プログラミングについて学べば学ぶほど、これはただの一時的な流行ではないかもしれないと思うようになります。ビジネスのチャンスだってあるかもしれません。人がコードを書き、それを保存し、共有し、共同作業をし、さらにはフィードバックまで欲しがるのですから。
よし、やりましょう。人がコードを書くのを後押しするサービスを作りましょう。さあ、みなさん、gitを使い始めましょう! 名前は……GitHupにしましょう。
まずはREST JSON APIの構築に取りかかります。ユーザー、プルリクエスト、コメント、バックエンドはあっという間に完成します。サーバーにプルリクエストのコメント一覧を要求したときに返されるペイロードの例を次に示します。
[
{
"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"
}
}
}
]
もちろん、フロントエンドはElmで作りますが、その前に、あの厄介なデコーダー/エンコーダーとは一体何なのかを理解する必要があります。
ジョーク交じりの話はさておき、この演習の要件はGitHubのREST APIにおけるプルリクエストレビューコメントのエンドポイントからそのまま取られています。 簡単にするために一部のフィールドはJSONスキーマから削除されていますが、これ以上ないほど実際のアプリケーションに近い内容です。
鋭い目で見れば、コメントにもユーザーにもidフィールドがあることに気づくはずです。始めるのにうってつけですね。
decodeIdを定義して、整数のidを持つJSONオブジェクトをデコードできるようにしましょう。
Decode.decodeString decodeId """{id: 10}"""
--> Ok 10
上の例を見ると、すべてのユーザーが名前を持っているわけではないことに気づきます。
decodeNameを定義して、nameフィールドに文字列があればそれをデコードできるようにしましょう。
名前がない? 問題ありません。単にNothingを返すだけです。
このデコーダーは決して失敗しないはずです。
Decode.decodeString decodeName """{"id": 10, "name": "Otto"}"""
--> Ok (Just Otto)
Decode.decodeString decodeName """null"""
--> Ok Nothing
ユーザーのデコードはほぼ半分まで来ました。仕上げてしまいましょう。
decodeUserを定義して、JSONのユーザーオブジェクトをデコードできるようにしましょう。
decodeUserの中でdecodeIdとdecodeNameを使うようにしましょう。
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
--> }
プルリクエストのレビューでは、単独で存在するコメントもあり、そのようなコメントにはpull_request_review_idがありません。
しかし、仕様によれば、そのような場合でもpull_request_review_idフィールドは存在し、値はnull(貧乏人のNothing)であるべきです。
decodePullRequestReviewIdを定義して、pull_request_review_idフィールドに整数があればそれをデコードできるようにしましょう。
整数がなければnullがあるはずで、nullがなければデコーダーは失敗するべきです。
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 ...
次は、いかにも面白そうなsideフィールドです。
プルリクエストのレビューでは、削除されたコード(画面のLeft側)にも、追加されたコード(画面のRight側)にもコメントできます。
decodeSideを定義して、sideフィールドの"LEFT"または"RIGHT"をデコードし、対応する型のバリアントにマッチさせましょう。
Decode.decodeString decodeSide """{"side": "LEFT"}"""
--> Ok Left
Decode.decodeString decodeSide """{"side": "middle?"}"""
--> Err ...
各コメントにはリンクの集合がありますが、コメントによって一貫していないようです。
decodeLinksを定義して、_linksフィールドにあるURLの集合をデコードできるようにしましょう。
リンクの名前も数も変わりうるので、それに対応できるようにしましょう。
リンク自体はJSONオブジェクトで、常にhrefフィールドを持つはずです。
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" )
--> ]
--> )
もう少しです。仕上げましょう。
decodeCommentとdecodeCommentsを定義して、それぞれ単一のJSONコメントオブジェクトとコメントのリストをデコードできるようにしましょう。
decodeCommentでは、decodeNameを除いて、これまでに定義したすべての関数を使うようにしましょう。
もちろん、decodeCommentsでもdecodeCommentを使います。
これまでのところで、まだ1つ足りない機能があります。サーバーに新しいコメントを送信できるようにすることです。
そのためには、デコーダーの逆を実現する必要があります。ElmのCommentをJSONのValueに変換できるようにするのです。
encodeCommentを定義して、任意の有効なCommentをエンコードできるようにしましょう。
フィールドの順序は問いません。decodeCommentがデコードできる値をエンコーダーが生成できればよいのです。