JSON
前節ではHTTPリクエストを使ってある本の内容を取得する例を見てきました。これはこれで素晴らしいのですが、非常に多くのサーバーはJavaScript Object Notation(略してJSON)と呼ばれる特別な形式でデータを返してきます。
そこで、次の例では JSON データを取得する方法を紹介します。これを利用して「なんかどっかの本からテキトーに引用文を表示するボタン」を作ることができます。青い "Edit" ボタンをクリックしてこのプログラムに目を通してみてください。もしかしたらいくつか見たことある本があるかもしれません。 今すぐ青いボタンをクリック!
import Browser
import Html exposing (..)
import Html.Attributes exposing (style)
import Html.Events exposing (..)
import Http
import Json.Decode exposing (Decoder, map4, field, int, string)
-- MAIN
main =
Browser.element
{ init = init
, update = update
, subscriptions = subscriptions
, view = view
}
-- MODEL
type Model
= Failure
| Loading
| Success Quote
type alias Quote =
{ quote : String
, source : String
, author : String
, year : Int
}
init : () -> (Model, Cmd Msg)
init _ =
(Loading, getRandomQuote)
-- UPDATE
type Msg
= MorePlease
| GotQuote (Result Http.Error Quote)
update : Msg -> Model -> (Model, Cmd Msg)
update msg model =
case msg of
MorePlease ->
(Loading, getRandomQuote)
GotQuote result ->
case result of
Ok quote ->
(Success quote, Cmd.none)
Err _ ->
(Failure, Cmd.none)
-- SUBSCRIPTIONS
subscriptions : Model -> Sub Msg
subscriptions model =
Sub.none
-- VIEW
view : Model -> Html Msg
view model =
div []
[ h2 [] [ text "Random Quotes" ]
, viewQuote model
]
viewQuote : Model -> Html Msg
viewQuote model =
case model of
Failure ->
div []
[ text "I could not load a random quote for some reason. "
, button [ onClick MorePlease ] [ text "Try Again!" ]
]
Loading ->
text "Loading..."
Success quote ->
div []
[ button [ onClick MorePlease, style "display" "block" ] [ text "More Please!" ]
, blockquote [] [ text quote.quote ]
, p [ style "text-align" "right" ]
[ text "— "
, cite [] [ text quote.source ]
, text (" by " ++ quote.author ++ " (" ++ String.fromInt quote.year ++ ")")
]
]
-- HTTP
getRandomQuote : Cmd Msg
getRandomQuote =
Http.get
{ url = "https://elm-lang.org/api/random-quotes"
, expect = Http.expectJson GotQuote quoteDecoder
}
quoteDecoder : Decoder Quote
quoteDecoder =
map4 Quote
(field "quote" string)
(field "source" string)
(field "author" string)
(field "year" int)
この例は前節のものと非常によく似ていますね:
init
関数はLoading
の状態とランダムな本の引用文を取得するコマンドの組から始まります。update
関数では、新しい引用文が得られるときに発行されるGotQuote
メッセージを処理します。成功か失敗かにかかわらず、続くコマンドがないことを示すCmd.noneを返しています。また、誰かがボタンが押した際に発生するMorePlease
メッセージも処理し、ランダムな本の引用文を更に取得するためのコマンドを発行しています。view
関数では取得された引用文を表示します!
前節との主な違いはgetRandomCatGif
関数の定義にあります。Http.expectString
関数を使用する代わりに、Http.expectJson
に切り替えました。これはどういうことでしょうか?
JSON
サイト/api/random-quotes
に対してランダムな本の引用文を要求すると、要求を受け取ったサーバーは次のようなJSON形式の文字列を生成します:
{
"quote": "December used to be a month but it is now a year",
"source": "Letters from a Stoic",
"author": "Seneca",
"year": 54
}
ここに含まれている情報について、我々はなんの裏付けも持っていません。サーバーはフィールド名を変更することもできますし、状況によって各フィールドは異なる型を持つ可能性もあります。荒んだ世界なのです!
JavaScriptの世界で使われる手法は、JSONを単にJavaScriptオブジェクトに変換し、何も間違いが起こらないよう祈るだけです。しかし、もし何らかのタイプミスや想定外のデータに遭遇すると、あなたのコードのどこかで実行時例外が発生してしまいます。コードの間違いなのか?それともデータの間違いなのか?その原因を探る作業の始まりです!
Elmの世界では、我々のプログラムにデータを取り込むより前にJSONを検証します。もしデータに想定外の形式が含まれていれば、すぐにその点について気づくはずです。おかしなデータが忍びこんで、忍び込んだところからちょっと離れた別の場所で実行時例外を発するような余地はありません。これはJSONデコーダーによって達成されるのです。
JSONデコーダー
次のようなJSONがあるとします:
{
"name": "Tom",
"age": 42
}
特定の情報にアクセスするためには、このJSONをDecoder
に通す必要があります。もし"age"
の値が欲しいとしたら、どのようにこの情報にアクセスするかを定めたデコーダーDecoder Int
に通します:
全てが上手く行けば、出力側でInt
型の値を得ます。次にもし"name"
の値が欲しいとしたら、どのようにこの情報にアクセスするかを厳密に定めたデコーダーDecoder String
に通します:
全てがうまく行けば、出力側でString
型の値を得ます!
ではどうやってこの様なデコーダーを作ったらよいのでしょうか?
構成要素
パッケージelm/json
にJson.Decode
モジュールが含まれています。このモジュールには複数の最小単位のデコーダーが含まれていて、それらを組み合わせることができます。
例えば、{ "name": "Tom", "age": 42 }
から"age"
を取得するためには、次のようなデコーダーを用意します:
import Json.Decode exposing (Decoder, field, int)
ageDecoder : Decoder Int
ageDecoder =
field "age" int
-- int : Decoder Int
-- field : String -> Decoder a -> Decoder a
ここでfield
関数は2つの引数をとります:
String
— フィールド名。つまりこのデコーダーは"age"
フィールドを含むオブジェクトを要求しています。Decoder a
— 次に試すべきデコーダー。もし"age"
フィールドが存在すれば、その値のデコード処理をこのデコーダーで試みます。
これらをまとめると、field "age" int
では、"age"
フィールドがあるかを尋ね、もし存在する場合には、Decoder Int
でデコード処理を実行し整数の値の抽出を試みます。
"name"
フィールドについても全く同様に記述することができます:
import Json.Decode exposing (Decoder, field, string)
nameDecoder : Decoder String
nameDecoder =
field "name" string
-- string : Decoder String
この場合"name"
フィールドを含むオブジェクトを要求し、もし存在する場合にはその値がString
であることを要求します。
デコーダーを組み合わせる
さて、フィールドが1つだったら上記の方法でも問題ありません。でも 2つ のフィールドを持つJSONはどうやってデコードしたらいいんでしょうか? そこで使えるのが map2
です。この関数を使って2つのデコーダーをカチッと噛み合わせることができます。型を見てみましょう。
map2 : (a -> b -> value) -> Decoder a -> Decoder b -> Decoder value
ご覧の通り、この関数は2つのデコーダーを引数にとります。map2
はこの2つのデコーダーをそれぞれ評価し、結果を1つに合成します。では実際に2つの異なるデコーダーを渡して1つにしてみましょう。
import Json.Decode exposing (Decoder, map2, field, string, int)
type alias Person =
{ name : String
, age : Int
}
personDecoder : Decoder Person
personDecoder =
map2 Person
(field "name" string)
(field "age" int)
これで、例えばpersonDecoder
を{ "name": "Tom", "age": 42 }
に適用するとPerson "Tom" 42
というElmであつかえる値に変換できるようになりました。
さて、先ほどの定義をもっとデコーダーの流儀を反映した書き方に変更してみましょう。personDecoder
をmap2 Person nameDecoder ageDecoder
と定義するのです。このように、いつだって小さな部品を組み合わせることで所望のデコーダーを構築できるのです。
デコーダーをネストする
JSONデータというのは、ふつうそんなにフラットな構造をしていません。例えば/api/random-quotes
は次のバージョン/api/random-quotes/v2
で本の著者についての情報を以下のようにもっと増やしてくるかもしれません。
{
"quote": "December used to be a month but it is now a year",
"source": "Letters from a Stoic",
"author":
{
"name": "Seneca",
"age": 68,
"origin": "Cordoba"
},
"year": 54
}
もしこんなことになっても、いい感じの小さなデコーダーをネストさせることで対応できます。
import Json.Decode exposing (Decoder, map2, map4, field, int, string)
type alias Quote =
{ quote : String
, source : String
, author : Person
, year : Int
}
quoteDecoder : Decoder Quote
quoteDecoder =
map4 Quote
(field "quote" string)
(field "source" string)
(field "author" personDecoder)
(field "year" int)
type alias Person =
{ name : String
, age : Int
}
personDecoder : Decoder Person
personDecoder =
map2 Person
(field "name" string)
(field "age" int)
さて、先ほどのJSONデータには本の著者の出身地に関する"origin"
フィールドがありました。でも上記の例ではこのフィールドをデコードしていません。このように、デコーダーはJSONデータに含まれるフィールドを無視してもいいのです。このおかげで、めちゃくちゃ大きなJSON値からも、実際に必要なほんのちょっとの情報だけを取り出すことが可能になります。
次のステップ
ここでは紹介しきれませんでしたが、Json.Decode
には沢山の重要な関数があります:
bool
:Decoder Bool
list
:Decoder a -> Decoder (List a)
dict
:Decoder a -> Decoder (Dict String a)
oneOf
:List (Decoder a) -> Decoder a
つまり、あらゆる種類のデータ構造を抽出するための方法が存在するのです。とくにoneOf関数は一貫性のないJSONをデコードする際にとても役に立ちます(例えば、数字をInt型として受け取る場合や文字列で表現したString型として受け取る場合があったりと、困りますよね!)。
今回はmap2
やmap4
を使って、たくさんのフィールドを含むオブジェクトを取りあつかいました。しかし、取りあつかうJSONオブジェクトが大きくなるにつれて、NoRedInk/elm-json-decode-pipeline
の使用を検討したほうがよくなります。そのライブラリーで使われている型にはややわかりづらい部分がありますが、「こっちの方がずっと読みやすい」と言って採用している人たちも結構います。
面白い事実: JSからElmに切り替えたら、サーバー 側のコードのバグを見つけ出すことができたというような話題をいくつも聞いたことがあります。人々が書くデコーダーが検証として働く結果となりJSONの不自然な部分を捉えてくれます。NoRedInkがReactからElmに切り替えた際には彼らのRubyコードに幾つかのバグがあることが判明しました!