🕌

Elmで比べるRESTful APIとGraphQL

9 min read

Elmに関してGraphQLを利用する記事はいくつか存在しますが、クライアント視点でREST APIとGraphQLどのような違いがあるかを比べてみた記事が無かったので執筆しました。筆者はREST APIを10年以上使っていてGraphQLは仕事や趣味等で一切使ったことがなかったため、どのような点が嬉しく感じたか?も記述していこうかと思います。

今回利用させていただくAPIは、SWAPI(The Star Wars API)になります。GraphQL版のSWAPIは、たまたまdillonkearns/elm-graphqlパッケージを作ってくださった方が用意してくださっていたので感謝しつつ利用させていただきました。クライアントとインターフェースの差異を見たいので、出力するviewは以下のようにシンプルにキャラクター名と出身惑星の名前だけを出力するようにしました。

<main>
    <p>Darth Vader</p>
    <p>Tatooine</p>
</main>

ElmのModelは以下のようになっています。

type alias Response =
    { vader : Maybe HumanData
    }


type alias HumanData =
    { name : String
    , homePlanet : Maybe String
    }

以下が比較するコードです。

ElmでRESTful API

ElmでRESTful APIを使う場合には、elm/httpelm/json2つのパッケージを利用します。elm/httpはHTTPプロトコルを利用するために使います。また、今回はJSONをレスポンスとして利用するためにelm/jsonを利用します。

SWAPIの人物を取得するRESTful APIは、https://swapi.dev/api/people/{peopleId}/ ですが、このAPI一つ叩くだけでは惑星の名前までは取得することができません。"homeworld"の値を見ると別の、http://swapi.dev/api/planets/{planetId}/ APIが叩かれているのがわかります。

{
	"name": "Darth Vader",
	"height": "202",
	"mass": "136",
	"hair_color": "none",
	"skin_color": "white",
	"eye_color": "yellow",
	"birth_year": "41.9BBY",
	"gender": "male",
	"homeworld": "http://swapi.dev/api/planets/1/",
	"films": [
		"http://swapi.dev/api/films/1/",
		"http://swapi.dev/api/films/2/",
		"http://swapi.dev/api/films/3/",
		"http://swapi.dev/api/films/6/"
	],
	"species": [],
	"vehicles": [],
	"starships": [
		"http://swapi.dev/api/starships/13/"
	],
	"created": "2014-12-10T15:18:20.704000Z",
	"edited": "2014-12-20T21:17:50.313000Z",
	"url": "http://swapi.dev/api/people/4/"
}

惑星のAPIの"name"を取得することで2つの情報が手に入れられます。

{
    "name": "Tatooine", 
    "rotation_period": "23", 
    "orbital_period": "304", 
    "diameter": "10465", 
    "climate": "arid", 
    "gravity": "1 standard", 
    "terrain": "desert", 
    "surface_water": "1", 
    "population": "200000", 
    "residents": [
        "http://swapi.dev/api/people/1/", 
        "http://swapi.dev/api/people/2/", 
        "http://swapi.dev/api/people/4/", 
        "http://swapi.dev/api/people/6/", 
        "http://swapi.dev/api/people/7/", 
        "http://swapi.dev/api/people/8/", 
        "http://swapi.dev/api/people/9/", 
        "http://swapi.dev/api/people/11/", 
        "http://swapi.dev/api/people/43/", 
        "http://swapi.dev/api/people/62/"
    ], 
    "films": [
        "http://swapi.dev/api/films/1/", 
        "http://swapi.dev/api/films/3/", 
        "http://swapi.dev/api/films/4/", 
        "http://swapi.dev/api/films/5/", 
        "http://swapi.dev/api/films/6/"
    ], 
    "created": "2014-12-09T13:50:49.641000Z", 
    "edited": "2014-12-20T20:58:18.411000Z", 
    "url": "http://swapi.dev/api/planets/1/"
}

JSONデコーダ

APIのレスポンスからJsonのレスポンスを取得するには、Json Decoderを記述する必要があります。field名を指定してElmの何の型に変換させるかデコーダ(Json.Decode.string等)を指定します。構造にする場合には、Json.Decode.map2等を利用してレコード(Person)にマッピングします。

import Json.Decode as JD


type alias Person =
    { name : String
    , homeworld : String
    }


personDecoder : JD.Decoder Person
personDecoder =
    JD.map2 Person
        nameDecoder
        (JD.field "homeworld" JD.string)


nameDecoder : JD.Decoder String
nameDecoder =
    JD.field "name" JD.string

HTTP

RESTful APIを叩くための準備をしますが、単にHttp.get関数を利用すると2つのAPIの結果を一度に取得することができず状態を2回に分ける必要があります。今回は一度に取得するように見せかけるためにTaskと言う非同期に計算を合成することができる型で結果を受け取ります。Http.taskはそのままではJsonの結果を受け取れないためjsonResolverを別途定義してあげる必要があります。

getPerson : Int -> Task Http.Error Person
getPerson id =
    Http.task
        { method = "GET"
        , headers = []
        , url = "https://swapi.dev/api/people/" ++ String.fromInt id ++ "/"
        , body = Http.emptyBody
        , resolver = jsonResolver personDecoder
        , timeout = Nothing
        }


getPlanetName : String -> Task Http.Error String
getPlanetName url =
    Http.task
        { method = "GET"
        , headers = []
        , url = url
        , body = Http.emptyBody
        , resolver = jsonResolver nameDecoder
        , timeout = Nothing
        }


jsonResolver : JD.Decoder a -> Http.Resolver Http.Error a
jsonResolver decoder =
    Http.stringResolver <|
        \response ->
            case response of
                Http.BadUrl_ url ->
                    Err (Http.BadUrl url)

                Http.Timeout_ ->
                    Err Http.Timeout

                Http.NetworkError_ ->
                    Err Http.NetworkError

                Http.BadStatus_ metadata body ->
                    Err (Http.BadStatus metadata.statusCode)

                Http.GoodStatus_ metadata body ->
                    case JD.decodeString decoder body of
                        Ok value ->
                            Ok value

                        Err err ->
                            Err (Http.BadBody (JD.errorToString err))

Task型にした後に実際APIを2回叩き計算を合成したコードが以下になります。まずはじめに、getPersonを叩き、Task.andThengetPlanetNameperson.homeworldで取得したurlを渡します。最後にTask.mapResponse型になるようにマッピングをします。

init : () -> ( Model, Cmd Msg )
init _ =
    ( { vader = Nothing }
    , Task.attempt GotReponse
        (getPerson 4
            |> Task.andThen
                (\person ->
                    Task.map
                        (\planetName ->
                            { vader = Just <| { name = person.name, homePlanet = Just planetName } }
                        )
                        (getPlanetName person.homeworld)
                )
        )
    )


type Msg
    = GotReponse (Result Http.Error Response)

あとはupdateResponseを受け取り、viewResponseを表示して終わりです。

ElmでGraphQL

ElmでGraphQLを利用するために、dillonkearns/elm-graphqlを利用します。こちらのパッケージが優れているところはGraphQLを型安全に使えるようにコードジェネレート機能も付いています(npm install --save-dev @dillonkearns/elm-graphql )。

GraphQLの場合は以下のクエリで人物名と惑星の名前を取得することができます。

クエリとマッピング

GraphQLのクエリとマッピングは同時に行います。

import Graphql.SelectionSet as SelectionSet exposing (SelectionSet)
-- 以下は自動生成されたモジュール
import StarWars.Query as Query
import StarWars.Object exposing (Human)
import StarWars.Object.Human as Human


type alias Response =
    { vader : Maybe HumanData
    }


type alias HumanData =
    { name : String
    , homePlanet : Maybe String
    }
    
    
query : SelectionSet Response RootQuery
query =
    SelectionSet.map Response
        (Query.human { id = StarWars.Scalar.Id "1001" } humanSelection)


humanSelection : SelectionSet HumanData StarWars.Object.Human
humanSelection =
    SelectionSet.map2 HumanData
        Human.name
        Human.homePlanet

クエリに関しては、Query.human { id = StarWars.Scalar.Id "1001" } humanSelection関数の中身を見ていただければ一目瞭然でしょう。

マッピングに関しては、SelectionSetモジュールを利用します。SelectionSet.map, SelectionSet.map2はJsonのデコーダと同じような使い勝手でわかりやすかったです。

HTTP

GraphQLは単なるインターフェースのためプロトコルの指定はありません。しかし、一般的にはHTTPプロトコルを利用するようです。先程のquery関数をGraphql.Http.queryRequestに渡し対象のAPIのURIも指定します。最後に受け取りたいMsgと共にGraphql.Http.sendに渡します。

type Msg
    = GotReponse (Result (Graphql.Http.Error Response) Response)


init : () -> ( Model, Cmd Msg )
init _ =
    ( { vader = Nothing }
    , query
        |> Graphql.Http.queryRequest "https://elm-graphql.herokuapp.com/api"
        |> Graphql.Http.send GotReponse
    )

RESTful APIとGraphQLを比較して

GraphQLは後発のWEB用インターフェースでRESTful APIの課題を解決しています。それらがElmをクライアントとして利用した場合に、本当にそれらの課題が解決されているかに着目していました。

過剰な取得

RESTful APIはリソースを指定して取得するインターフェースです。リソースのすべてを使わなくても、すべての情報を取得してしまいます。ElmではJSON Decoderを利用して一部の情報だけ取得するようにしてあげるのでノイズになることはありませんがOpen APIなどを利用されていなければ、Elmのコードが自動生成されることはありません。GraphQLの場合は確実にコードが自動生成されるため、欲しい情報通りにクエリを構築することは簡単に感じました。

過小な取得

RESTful APIはリソースを取得するためのAPIなので欲しいリソースが含まれていなければ多段にAPIを叩く必要があります。Elmでは、Task型の計算の合成を利用する必要がありました。GraphQLでは1度のクエリですべての欲しい情報が取得できるため、完全にコード量の差が生じました。

RESTのエンドポイント管理

こちらはバックエンド側に関わる問題ですが、上記の過剰な取得過小な取得を解決するためにはエンドポイントを新しくする必要があります。/api/people-with-planetのようなエンドポイントの作成です。これはクライアントの使い方だけ種類を増やす必要があり、かつ、クライアント側もエンドポイントの種類とリソースの形を意識し続けるため明らかに労力です。プロダクト開発でも同様の苦労を感じています。

まとめ

ElmでRESTful APIとGraphQLのコードを見比べてみることで、GraphQLの有利さが垣間見えたので試してみた価値があったように感じました。しかし、まだGraphQLの機能は豊富でそれらを利用した場合にどういった使い勝手になるかは不明なのと、この記事で触れていないGraphQLの触れていないデメリットも存在します。今後もっとGraphQLを触れることで、それらの解説もできるかもしれないので期待せずお待ち下さい。