Elmで比べるRESTful APIとGraphQL
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/httpとelm/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.andThen
でgetPlanetName
にperson.homeworld
で取得したurlを渡します。最後にTask.map
でResponse
型になるようにマッピングをします。
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)
あとはupdate
でResponse
を受け取り、view
でResponse
を表示して終わりです。
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を触れることで、それらの解説もできるかもしれないので期待せずお待ち下さい。
Discussion