GraphQLは何に向いているか

今年GitHubGraphQL APIを正式公開したあたりから、GraphQLが去年とかに比べちょっと流行り始めたように感じる。idobataがGraphQL APIを公開したり、Kibelaも公開APIをGraphQLで作ることを宣言している。

利用者側からすると使えるインターフェースの中から必要なものを調べて使うだけなのであまり考えることはないのだが、自分がAPIを提供する立場になると話は変わってくる。REST APIとGraphQL APIはどちらかがもう一方のスーパーセットという風にはなっておらず、どちらかを選択すると何かを捨てることになるので、要件に応じてどちらを選ぶのが総合的に幸せなのか考える必要がある。

以前趣味でGitHub連携のあるサービスを作っており、それを最近GraphQL API v4を使うように移行し、そこでついでにそのサービスのGraphQL APIを書いてみたりした結果GraphQLができること・できないことが少し見えてきたので、僕の現在の「GraphQLはREST APIに比べどういう用途に向いているか」についての考えをまとめておく。

REST APIと比較したGraphQL

この比較では、REST APIJSON Schemaと同時に使われうることも想定して書く。理由は、GraphQLが解決している問題の一部をREST APIで実現するためにJSON Schemaが使われることがしばしばあり、逆にGraphQLでは元々それに近い機能がありまず使わないと思うので、現実世界の問題を解く上ではそれらをセットにして比較した方がいいと考えたため。

なお、対比する上で本質的な部分となる「仕様上の問題」と、時間が解決しうる(が現実世界では当然考慮が必要な)「現在のエコシステムの問題」は分けて記述する。エコシステムに関しては筆者の都合でサーバー側はgraphql-rubyを念頭に置いている。

GraphQLにしても解決できない問題

仕様上の問題

  • GraphQLはありとあらゆるリソースをリクエスト一発で取得できる夢の技術ではない
    • 正直触る前は大体そういうイメージだった
    • 例えばページネーションなしに1種類のリソースを6000個取得しようとするとレスポンスに1分かかりリバースプロキシ(かunicorn, rack-timeout等)でタイムアウトになるREST APIが世の中には実在したのだけど、それをそのままGraphQLのクエリで再現したとして確実に同じ時間がかかる。つまりページネーションは確実に必要で、クライアント側でそのリソースに関してループを回す必要があり、そのリソースに関して何度かリクエストが必要になる。
    • 単に1つのリソースをページネーションしないといけない場合だけでなく、N個あるリソースにそれぞれM個リソースがネストしていてぞれそれにorderが必要な場合、これは確実にN+1回クエリが必要なわけだけど、そうやって裏側で非効率なクエリが走るようなクエリを投げてしまうと一回のリクエストに時間がかかりすぎてタイムアウトするリスクがあるので、ある程度は分けないといけない。実際僕もGitHubのGrpahQL APIを叩いているとリトライをしても結構タイムアウトを見た。
    • 後述するようにリクエスト数は減ることは多いが、必ず1回にできる銀の弾丸ではないという主張

現在のエコシステムの問題

  • 現時点ではAPIクライアントを自動生成できるライブラリは限られており、アプリ側にいちいち長いクエリを書く必要がある

GraphQLにすると困る問題

仕様上の問題

  • クエリをパースしないとキャッシュの可否を判定できないため、HTTPキャッシュが難しい
    • REST APIであれば、同じとみなせるGETリクエストをVarnish等でキャッシュすることが容易かつ効率的にできるが、GraphQLだとリクエストボディのJSONをパースし、その中に入っているqueryをパースし、そこにmutationがあるかどうかをチェックする必要がある。
    • ワークアラウンドとしてquery fieldが生えてるエンドポイントとmutation fieldが生えてるものを分ける等が考えられるが、エンドポイントを分ける(REST APIに近づける)ほど当然キャッシュのコントロールがしやすくなるわけで、HTTPキャッシュがないと困る用途には向かないと思う。
    • 追記: id:yamitzky さんのコメントで知りましたが、GET /graphql?query=...といった形でのリクエストも仕様上想定しているため、これは誤解のようです。graphql-rubyのgeneratorだと生えないので勘違いしていました。
  • HTTPのメソッドやステータスコードによる挙動の予測ができなくなる
    • queryとmutationしかないということは、HTTPメソッドのGETかPOSTしかない状態に等しく、mutationの中でそれがリソースの追加・更新・削除のうちどれなのかを表現する方法は別に仕様レベルでは標準化されておらず、実装した本人以外から見たら挙動が予測しにくくなる。
    • クエリの結果がエラーになっても大体200 OKが返ってくる。(OKとは)
      • 他にもGitHubの場合502 Gad Gateway: This may be the result of a timeout, or it could be a GitHub bugといったエラーが結構頻繁に返ってくるんだけど、timeoutの場合はリトライしたいしGitHub bugならリトライすべきではないのでこれはHTTPステータスコードで区別して表現してほしい。本当はどちらも(バグを含む何かが原因の)タイムアウトなのかなあ(そのうちサポートに確認する)。
      • errorオブジェクトのルールをちゃんと決めて実装すれば解決できるけど、このあたりに標準的な仕様やガイドラインが存在しない結果そういうレスポンスを生んでしまうのは問題だと思う。
  • 必要なfieldを必ず明示しなければいけないので、自動生成しない限りはAPIクライアントを書くのに必要なコードの文字数・行数は増えそう
    • 後述するようにIDEのGraphiQLのアシストがあるのでそこまで大変ではない

現在のエコシステムの問題

  • GraphQL Proを使わないとモニタリングが難しい
    • そこまで高くはない($900/year)ので仕事でやってるなら普通に金払えばいいんだけど、NewRelicとかで詳しくモニタリングしたかったらGraphQL::Proを使う必要がある
    • graphql-rubyとかにはinstrumentationの仕組みがあるので、まあ困ったら自分で実装することは可能
  • Railsで使ったらMVCのレールのうちVCから割と外れる
    • まずcontrollerとviewに入っていたはずのロジックが全てapp/graphql以下にくることになる。VとCが一緒になるだけだったら最悪いい(?)気もするけどqueryやmutationといったrootのフィールドにはいろんなリソースのロジックが一箇所に集まってくる上にroutes.rb相当の情報も来てしまう。
    • ネストしたリソースが(graphql-rubyが生成する)typesディレクトリにちゃんと分割されていればまあそこまで問題になることはないかもしれないが、resolveのブロック内のコードには割とプロジェクトによって自由度が発生して初見だと読みづらくなるような気がしている
  • N+1クエリの解決方法がいつもと違う感じになる
    • 普通はActiveRecordSQLのAST(Arel)を組み立て、そこにこのリソースをeager loadingするよという情報を埋め込むことでActiveRecord::Associations::PreloaderActiveRecord::Associations::JoinDependencyになんとかさせるんだけど、これは使わなくなる
    • かわりに、こういう感じでクエリのトラバース中に必要なIDを集めておいて、最後にgraphql-batchのミドルウェアを通してまとめてクエリすることになる。ネストしたリソースをクエリしてくるのに必要な情報を、親のリソースの一覧を参照することで取ってこれる限りはどうにかできるので、REST APIで問題にならないようなリクエスト(しかできないようにスキーマを制限した場合)ならGraphQLでも問題にならないような気がしている。しかし、例えばコントローラーから使うことを想定してN+1を解決するpreloaderを独自に書いていた場合は使えなくなる可能性があると思う。
    • graphql-batch相当のライブラリがない言語でやる場合、特にそれが静的型付け言語とかだとちょっと面倒かもしれない
  • graphql.jsを使う場合はFacebookBSD+Patentsライセンスに同意する必要がある

どちらでも大差のない好みの問題

仕様上の問題

  • REST APIのバージョン管理」 vs 「GraphQLの@deprecated
    • GraphQLのサイトにEvolve your API without versionsと書かれているが、個人的にはあまりこれが優れている点だと感じない。/v1/v2みたいなバージョンを更新していくかわりにフィールドに@deprecatedをつけていくと更新の粒度は細かくできるが、例えばStringだったfieldを同じ名前でObjectにしようとすると、REST APIなら新しいバージョンを1つ生やせば済むが、GraphQLの場合は同じ名前空間でやらないといけないので1度別のfieldを用意してそちらに移行し元のfieldを直すという2ステップ必要になる。どっちが楽かはどう変更していきたいかによる。
    • そもそも普通にアプリを書いているとRundeckみたいにAPIのバージョンをバシバシ上げる必要ってそんなに感じなくて、新たにfieldやエンドポイントを足すような後方互換性のある変更が多く、バージョンを上げるとしたらそれこそREST API→GraphQLくらい全体的に大きな変更がないとやらない気がしていて、もともとこれがそんなに問題ではない

現在のエコシステムの問題

  • JSON Schema」 vs 「GraphQLの型」
    • GraphQLは型があって便利! みたいな主張を見るが、別にその目的がAPIのparameterのvalidationやクライアント側がpropertyの型を知ることなら、別にREST APIでもJSON Schemaを書いてそれをJSONで返すエンドポイントでも生やしておけばいい。
      • APIを生やしたら必ずJSON Schemaもセットで書かないと(かつそれが実装にも使われていないと)気が済まない人にはGraphQLはマッチしそう
    • デフォルトで型があることによりツールチェインのアシストは最初から厚いと思うけど、それは完全にエコシステムの問題で、JSON Schemaに対して何かやったらいい
  • ドキュメントの自動生成のしやすさ
    • JSON Schema」 vs 「GraphQLの型」に付随する話。エンドポイントの名前やfieldの型以上の情報は人間が書く必要があり、これはどちらも変わらない。やりやすさはツールの実装依存
    • 「GraphQLだと型を書くことが強制される」こと自体は強みかもしれないが、型だけではドキュメントにならないし、REST APIでも各エンドポイントに対して必ずJSON Schemaを書かないといけなくなるようなテストでも用意すれば大体同じ状況になる。ドキュメント生成自体に関しては完全に利用する側の問題だと思っている。
  • ユーザーのパラメータ・レスポンスのプロパティにおける型安全性
    • GitHub APIのGraphQL化のモチベーションにWe wanted assurances of type-safety for user-supplied parametersがあげられているが、これも別にJSON Schema書いてそれをユーザー入力のバリデーションに使えばいい話で、別に必ずしもGraphQLの仕様が解決する話ではなく、単にRESTの時にサボっていただけと見ることができる

GraphQLの方がより良く解決している問題

仕様上の問題

  • いわゆるfields paramよりもインターフェースがより柔軟で記述力も高い
    • fields paramというのは?fields=id,name,..みたいに返すfieldを指定するパラメータのことを言っている。cookpad/garageにこういうのがあるのだが、miyagawaさん曰くこれはgraphQL になる前の Facebook graph API のやつをまねたらしいので、ちゃんとGraphQLになったものがより洗練されてるのは頷ける話
    • GraphQLのクエリは普通改行するが、スペース区切りでも書けるし、あまりネストしていないようなケースでそうした場合はリクエストする側のコードの見た目はどちらもあまり変わらない(見やすい)感じになる。
  • APIのリクエスト数やround trip timeを減らしやすくなる
    • 「ありとあらゆるリソースをリクエスト一発で取得できる夢の技術ではない」と書いたが、あるリソースに対して1対1の関係でネストしたリソースも同時に取ってくるのにはGraphQLはとても向いている。もちろんREST APIでもそういうエンドポイントは生やせるが、GraphQLの方が自然かつ重複なく実装できると思う。
  • 余計なfieldのリクエストが減りやすい
  • 各fieldをクライアントが使っていないことを明示できるので、fieldの利用状況を調べやすい
    • REST APIでも?fields=id,name,..みたいなパラメータを用意し、それを使うのを強制すれば解決できないことはない。が、実際毎回書くのは面倒なのでgarageだとfieldsを指定しなかったりするとデフォルトの奴を返せたりするし、人間は楽な方に流れがち。仕様レベルで強制されるGraphQLの方がこの点は良いはず。フレームワーク次第ではREST APIでもこれは解決できる。

現在のエコシステムの問題

  • クライアントキャッシュを実装するためのGlobally Unique IDsなどガイドラインが示されており、実際にRelayのようなそれを念頭に置き活用するフレームワークが存在する
    • この辺にRelayの仕組みが書いてあるが、RelayからReactを使うと、ユニークなIDをキーにnodeをクライアントサイドにキャッシュし、クエリをトラバースして本当に必要な場所だけクエリされるようにすることが可能らしい。REST APIでもクライアントキャッシュは実装しようがあるが、こちらの方がより細かい粒度で柔軟にキャッシュができると思う。
  • クエリのIDE的な機能を持つGraphiQLに型がちゃんと活用されている
    • そのため、毎回必要なfieldを指定しないといけない割には、クエリを書くのはそこまで苦痛ではない
    • 一方でGraphiQLはいろいろキーバインドが潰されてるので普通のテキストのエディットはとてもやりにくい

GraphQLではないと解決できない問題

  • 思いつかなかった

まとめ

GraphQLを使う場合の前提条件として、HTTPキャッシュを使わないケース*1である必要があり、また現時点だとGraphQL Proに$900/yearを払うかAPIの詳細なモニタリングを諦める必要がある。

その上で、サーバー側に型の記述を強制しクライアント側にfieldの記述を強制することにより、以下の例のように双方が幸せになると判断した場合は好みに応じて使えばいいと思う。REST APIかGraphQLのどちらかを使わないとすごく困るという状況は上記の前提以外はあまりなさそう。

GraphQLが向いてそうなケース

  • サーバーの実装者とクライアント実装者の距離が遠く、サーバー実装者の想定と異なるAPIの使い方が想定できる時に、余計なリソースを返したり不要にリクエスト数が増えるのを防止したり、消したいfieldが使われ続けるのを@deprecatedにより抑止したり、その利用現状を調査したりしたい場合
  • Reactでクライアントを構築しており、Relay等のフレームワークとGraphQL*2を用いてリソース単位のクライアントサイドキャッシュを実装することで、サーバーやデータベースへの負荷が最小限に抑えられることが期待できる場合

具体的にはGitHubが1つ目に該当してFacebookが2つ目に該当したんじゃないかなあと思っている。

*1:取得されるリソースがリクエストごとに分散しており、あまりHTTPキャッシュが役に立たない場合。あるいは(Relayなどでクライアント側にキャッシュすることも考慮して)そもそもそんなに多くのリクエストが来ないので不要な場合。

*2:全部同じ会社から出てるので相性はよくて当然