Help us understand the problem. What is going on with this article?

ElmとGraphQLの出会い - さようならJson Decoder !

More than 1 year has passed since last update.

はじめに

昨年にGitHubがGraphQLのAPIを公開して大きな話題になりました。GraphQLに関する詳細はこちらの記事をご覧になっていただければと思います。最近ではBaaS(Backend as a Service)として手軽に利用できるGraphQLサーバー(ScapholdHasuraAWS Appsync等)も増えてきています。

JavaScriptで使われているメジャーなクライアントライブラリはRelayApolloでしょうか、大きなプロジェクトでの採用実績も多くあるそうです。ElmからPorts経由でこれらのライブラリの利用することも可能とは思いますが、キャッシングやオフラインのサポート、サブスクリプションで同期だとか高度な事を望まなければ、所詮はJSONデータのやり取りなので、ElmからHttpで直接GraphQLサーバーを叩いて利用する事も可能です。

Elmのコードから、変数を文字列で書かれたGraphQLのクエリに埋め込み、さらにJSON形式にエンコードしてGraphQLサーバーに送ります。

{"operationName":null,"variables":{},"query":"{\n  country(code: \"JP\") {\n    code\n    name\n  }\n}\n"}

結果として返される以下の様なJSON形式の文字列をデコードしてElmの型に戻します。

{"data":{"country":{"code":"JP","name":"Japan"}}}

残念なことに、この方法ではElmとGraphQLがそれぞれ持つ型チェックの機能が活用されていません。また、規模が大きくなって複雑になってくると、この方法では破綻するのが容易に想像できます。

GraphQLサーバーとスキーマ

今回はオープンソースで公開されているシンプルなスキーマのGraphQL APIサーバー(Graphiqlコンソールリポジトリ)を例にします。そこで定義されているスキーマの内容は下記の通りです。

type Continent {
  code: String
  name: String
}

type Country {
  code: String
  name: String
  native: String
  phone: String
  continent: Continent
  currency: String
  languages: [Language]
  emoji: String
  emojiU: String
}

type Language {
  code: String
  name: String
  native: String
  rtl: Int
}

type Query {
  continents: [Continent]
  continent(code: String): Continent
  countries: [Country]
  country(code: String): Country
  languages: [Language]
  language(code: String): Language
}

typeで定義されている部分はGraphQLではオブジェクト型と呼ばれ、複数のフィールドを持つことができます。各フィールドには戻り値としての型が合わせて定義されています。詳しくはこちらを見て下さい。なんとなくElmの型に似ていますね。実際に全て以下の様なレコード型の別名で書くことができます。

type alias Continent =
    { code : Maybe String
    , name : Maybe String
    }


type alias Country =
    { code : Maybe String
    , name : Maybe String
    , native : Maybe String
    , phone : Maybe String
    , continent : Maybe Continent
    , currency : Maybe String
    , languages : Maybe (List (Maybe Language))
    , emoji : Maybe String
    , emojiU : Maybe String
    }


type alias Language =
    { code : Maybe String
    , name : Maybe String
    , native : Maybe String
    , rtl : Maybe Int
    }

{-- 実際にはこの部分はElmのレコード型としては使いません
type alias Query =
    { continents: Maybe (List (Maybe Continent))
    , continent: Maybe String -> Maybe Continent
    , countries: Maybe (List (Maybe Country))
    , country: Maybe String -> Maybe Country
    , languages: Maybe (List (Maybe Language))
    , language: String -> Maybe Language
    }
--}

うーんMaybeだらけ…これは先のGraphQLのスキーマで、全てのフィールドがnullを許すとして定義されているためです。本来はスキーマ側でもっと制約をかけるべきなのでしょうが、スキーマを変更できない場合には受け入れる他ありません。

この2つの世界の間を出来るだけ楽をしながらかつ型安全に橋渡しする方法が欲しいのです。graphqlをキーワードとしてElm 0.18のパッケージを検索すると11個、Elm 0.19のパッケージでは淘汰されたのか4個が結果として得られます。
Screen Shot 2018-12-03 at 0.08.53.png
現時点で、この4個の中で最も活発に使われていると思われるのが、一番上のdillonkearns/elm-graphqlになります。全てを試したわけではありませんが、ほとんどはJson.DecodeJson.EncodeをGraphQL用に合わせるアプローチで、フィールド名を文字列として書かなければならず、その点において型安全が失われてしまっています。

一方、@dillonkearnsのアプローチでは、外部ツールを導入することにより、GraphQLサーバーのイントロスペクション機能を利用してGraphQLのスキーマを解析し、Elmのコードを自動生成してくれます。生成されたコードは、主にGraphQLオブジェクトとそのフィールドをデコードするための関数で構成されます。これにより型安全なコードを書けることを保証してくれます。

環境の準備

以前に書いたparcel-bundlerとElmを組み合わせる内容をボイラープレートにした物を用意したのでそちらを利用します。最初に、GraphQLサーバーからElmコードを生成してくれるツールをnpmでインストールし、必要なElmのパッケージもインストールしておきます。

git clone git@github.com:kyasu1/elm-parcel-boilerplate.git
cd elm-parcel-boilerplate
npm install --save-dev @dillonkearns/elm-graphql
elm install dillonkearns/elm-graphql
elm install elm/json

スキーマからElmのコードを生成

npx elm-graphql https://countries.trevorblades.com/ --base Country --output src/elm

とすると以下のようなファイルが自動生成されます。
Screen Shot 2018-12-03 at 0.37.42.png

今回はシンプルなスキーマなので使用するのは下記のファイルのみとなります:

  • Country/Query.elmはスキーマ中のtype Queryで定義されたフィールドをデコードする関数の集まり
  • Country/Object.elmはスキーマ中で定義されたオブジェクトをElm上での表現するためのカスタム型の集まり
  • Country/Object/Continent.elmはスキーマ中のtype Continentで定義されたフィールドをデコードする関数の集まり
  • Country/Object/Country.elmはスキーマ中のtype Coutnryで定義されたフィールドをデコードする関数の集まり
  • Country/Object/Language.elmはスキーマ中のtype Languageで定義されたフィールドをデコードする関数の集まり

Elmアプリを書いてみる

まずは必要なモジュールをインポートします。

src/elm/Main.elm
import Country.Object
import Country.Object.Continent as Continent
import Country.Object.Country as Country
import Country.Object.Language as Language
import Country.Query as Query
import Graphql.Http
import Graphql.Operation exposing (RootQuery)
import Graphql.OptionalArgument exposing (OptionalArgument(..))
import Graphql.SelectionSet exposing (SelectionSet, succeed, with)

Graphqlで始まるモジュールには実際にクエリを構築したり送信するためのヘルパー関数が含まれています。

シンプルなクエリ

先のスキーマを見るとcodeを引数にとり、返り値としてCountryを返すクエリcountryが定義されています。国コードを指定してその国の情報を取得することができます。

graphql
query {
    ....
  country(code: String): Country
  ....
}

結果として得られるCountryオブジェクトには多くのフィールドが定義されていますが、この中からcodenameのみを取得するシンプルなクエリ(SelectionSet)をElmで構築してみます。

GraphQLのクエリで書くと次のようになります。

graphql
query {
  country(code: "JP") {
    code
    name
  }
}

自分の理解ではSelectionSetはGraphQLクエリにおけるフィールドの集まりだと思っています。queryオブジェクトはcountryフィールド一つで構成されるSelectionSetを含みます。countryオブジェクトはcodenameの二つののフィールドで構成されるSelectionSetを含むといった感じです。この階層に沿って分解してSelectionSetを構成していきます。

まずは最終的に欲しいElmの型を定義します。先のスキーマではnull値を返す可能性があるため、全てのフィールドにMaybeを付ける必要があります。

src/elm/Main.elm
type alias Response =
    { countryRowMaybe : Maybe CountryRow
    }


type alias CountryRow =
    { code : Maybe String
    , name : Maybe String
    }


query : String -> SelectionSet Response RootQuery
query code =
    succeed Response
        |> with (Query.country (\arg -> { arg | code = Present code }) countrySelection)


countrySelection : SelectionSet CountryRow Country.Object.Country
countrySelection =
    succeed CountryRow
        |> with Country.code
        |> with Country.name

どっかで見たような。そうです、NoRedInk/elm-json-decode-pipelineの記法と同じです。1

RootQueryはGraphQLのスキーマ定義にあったtype query {}オブジェクトに相当し、with以下では必要なフィールドを取得してデコードし、最終的にResponse型にして返すよう指示します。同様に、Country.Object.Countrytype Country {}オブジェクトに相当し、with以下で必要なフィールドだけを取得・デコードし、最終的にCountryRow型にして返すようするよう指示しています。

Query.countryは国コードを指定するための引数を取りますが、GraphQLのスキーマではnullでも良いと定義されてしまっているため、(\arg -> { arg | code = Present code })という形式で引数を渡す必要があります。もしidentityを渡すと引数にnullを指定したことになります。2

実際にこのクエリをサーバーに投げる場合にはGraphQL.Httpを使用します。

src/elm/Main.elm
execQuery : Cmd Msg
execQuery =
    query "JP"
        |> Graphql.Http.queryRequest "https://countries.trevorblades.com/"
        |> Graphql.Http.send GotRespponse

 ちょっと複雑なクエリ

GraphQLでは複数のオブジェクトや関連するオブジェクトを一度に取得できます。

query {
  continents {
    code
    name
  }
  countries {
    country {
    code
    name
    native
    phone
    continent {
      code
      name
    }
    currency
    languages {
      code
      name
      native
      rtl
    }
    emoji
    emojiU
  }
}

このクエリでは大陸の一覧と国の一覧を同時に取得しています。このクエリを発行するSelectionSetを構築すると以下のようになります。

type alias Response =
    { continents : List Continent
    , countries : List Country
    }

query : SelectionSet Response RootQuery
query =
    succeed Response
        |> with
            (Query.continents continentSelection
                |> SelectionSet.nonNullOrFail
                |> SelectionSet.nonNullElementsOrFail
            )
        |> with
            (Query.countries countrySelection
                |> SelectionSet.nonNullOrFail
                |> SelectionSet.nonNullElementsOrFail
            )


continentSelection : SelectionSet Continent Country.Object.Continent
continentSelection =
    succeed Continent
        |> with (Continent.code |> SelectionSet.nonNullOrFail)
        |> with (Continent.name |> SelectionSet.nonNullOrFail)


countrySelection : SelectionSet Country Country.Object.Country
countrySelection =
    succeed Country
        |> with
            (Country.code
                |> SelectionSet.nonNullOrFail
            )
        |> with
            (Country.name
                |> SelectionSet.nonNullOrFail
            )
        |> with Country.native
        |> with Country.phone
        |> with
            (Country.continent continentSelection
                |> SelectionSet.nonNullOrFail
            )
        |> with Country.currency
        |> with
            (Country.languages languageSelection
                |> SelectionSet.nonNullOrFail
                |> SelectionSet.nonNullElementsOrFail
            )
        |> with Country.emoji
        |> with (Country.emoji |> SelectionSet.withDefault "")


languageSelection : SelectionSet Language Country.Object.Language
languageSelection =
    succeed Language
        |> with
            (Language.code
                |> SelectionSet.nonNullOrFail
            )
        |> with Language.name
        |> with Language.native
        |> with Language.rtl

ここで、SelectionSet.nonNullOrFailSelectionSet.nonNullElementsOrFailがパイプに追加されていますが、スキーマ上ではnullが許されているフィールドを、nullにならないはずと仮定して、Elmに渡す際に強制的にMaybeを外した値にしてくれます。万が一nullが返された場合にはデコードエラーとなります。

本来は、nullになることがないフィールドであれば、スキーマの設計の際にnon-nullなフィールドと定義するべきなのですが、パブリックなスキーマなどで、変更ができない場合にリスクを覚悟で使用します。また、nullになる可能性がありデフォルト値が存在する場合には、SelectionSet.withDefaultをパイプに追加して処理することも可能です。この場合にはエラーとなりません。

このスキーマ使った簡単な例を作りましたので参考にしてみて下さい。

動くデモ http://kyasu1.github.io/
リポジトリ https://github.com/kyasu1/elm-graphql-example

おわりに

  • JSONデコーダを書いていく作業とSelectionSetの構築は似ています(というか同じ記法になるように合わせている)。前者でなんとなくデコーダを書いてコンパイルが通っても、フィールド名にタイポがあったり、自分の想定外のJSONが返されると実行時にデコードに失敗ます。こういったエラーの原因の追求には結構時間が取られたりします。一方、後者ではElmの型チェックによりコンパイル時にエラーが発生します。逆にコンパイルが通ればGraphQLスキーマに対しては実行時エラーが発生しないことを保証してくれます。
  • 冒頭にも書いたように、当初はGraphQLクエリを文字列として書いてました。レコードに含まれるフィールドが微妙に異なれば、JSONデコーダも別々に用意する必要がありました。スキーマに変更があればその都度手書きで変更…とても苦痛でした。この辺の作業がほとんど自動化されてしまうのでとても楽ができて素晴らしいです。
  • 紹介したパッケージの使い方を理解するには、Json.Decode使い方が理解できているのが前提です。Json Decoderが不要になるわけではありません:laughing:
  • 偶然にもちょっとだけ貢献しました :blush:

  1. この記事を書いている間にアップデートされてこの記法に変わりました( https://github.com/dillonkearns/elm-graphql/blob/master/CHANGELOG-ELM-PACKAGE.md#200---2018-12-02 )。Json.Decode同じ様にmapNを用いた記法も用意されています。 

  2. もっとわかりやすい方法が検討されているようです( https://discourse.elm-lang.org/t/optional-key-records-syntax-proposal/2634 )。 

Why do not you register as a user and use Qiita more conveniently?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away