[GraphQLの良さ] RESTfulなアーキテクチャとの違い
出発地点としては、URLで表現されるリソースをHTTPメソッドで操作するステートレスなAPIの比較からGraphQLの良さを見ていきます。
たとえば、GET /articlesで記事一覧、 GET /articles/{id}で記事詳細、POST /articlesとすると記事作成というRESTfulなアーキテクチャです。
GET /articlesと叩くと、記事一覧画面の中でヘッダーにメディアの名、 そしてArticle1、Article2、Article3の記事といった例がよくありますが、 皆さんが作っているシステムはこんなにシンプルでしょうか? 本当は以下のように複雑だけどユーザーには使いやすい画面になっていると思います。
そうすると、これは本当にGET /articlesでいいのかと思うんですね。 この課題に対していろいろな対応の仕方があります。
複数リソースを取得する必要はあるけど、あくまで記事一覧だと見なす。 あるいは、複数のリソースによって構成される1つの(メタ)リソースを見出す。これはダッシュボードというリソースなのでGET /dashboard、 あるいはリソース指向ではなく画面指向やコンポーネント指向の命名をする。
あとは単一リソースを返すシンプルなAPIをクライアントが必要に応じて複数回、 /articlesと/authorsと/videosを1画面で何度も叩くみたいな設計手法があります。
その他にも、導線や利用端末によって表示項目が変わる仕様があるのに、同一のGET /articles/{id}で記事詳細を表示していると、不要なデータもまとめて取得するオーバーフェッチングが起こります。
あるいは、リソース指向よりもユースケース指向で表現したい更新系のAPIがあるときに、アカウントのアクティベーションを行いたいけれど、それをHTTPメソッドのPOST/PUTで操作するのはしっくりこないんですね。
そこで、GraphQLが1つの解決策になると思います。クライアントはGraphQLスキーマに従ってQueryを投げる、あるいはMutationを投げる。Queryは参照系の動作で、Mutationは更新系の動作です。これらを/api/graphqlみたいな単一のAPIエンドポイントで待ち受けるというのが 概観です。
[GraphQLの良さ] over- fetchingの心配や、リソース指向のAPIエンドポイント設計が不要に
この手法ではオーバーフェッチングの心配もなく、1画面の表示に大量のリクエストを発行する必要は無くなります。リソース指向のAPIエンドポイント設計が不要になるのがGraphQLの特徴ですね。
「何を返す必要があるのか」の知識をクライアントに寄せられるのですが、先ほど出した複雑なリソースを取得したい画面において、クライアントは何が欲しいかというクエリ言語を書くとAPIがうまく機能します。仮に画面で表示したい項目が変わってもすでにスキーマで定義されたものであれば、サーバサイドの開発が不要になります。
それから、GraphQLスキーマによるスキーマ駆動開発があるとフロー効率も向上します。バックエンドの実装が行われる前にクライアントは開発が着手可能になりますし、スキーマで定義されたAPIのデータを 返すresolverの実装をしていくことが可能です。
GraphQLの難しさ
その反面 難しさもあって、 単一のエンドポイントに様々なユースケースのリクエストを投げられるようになると、APIのエンドポイントが別々だから成り立っていた今までの慣習 をかなり見直さないといけません。 いわゆるObservability(観測性)ですね。全てのQueryやMutationを受け付ける/api/graphqlといった、単一のWebエンドポイントでは、そのSuccessRateを見ても特定ユースケースの情報が分からないという難しさがありますね。
あるいはAuthorization(認可)です。RailsのようなMVCフレームワークの場合、ルーティングに対応する個々のControllerで認可を行うことが多かったりして、今までの考え方 と変わるのは単一のエンドポイントでやりくりする難しさですね。
また、1つのリクエストで複数のエラーが起こるので、部分的に成功、または部分的に失敗の場合のリクエストに対してHTTPのステータスコードで400系のエラーを返します。あるいはレスポンスボディーの中のerrorsというフィールドにエラー情報を詰め込むか、エラーを示すエンティティをGraphQLのスキーマ上で定義する、 といった選択を迫られるのはよくある話です。
N+1はGraphQLで特に起こりやすいと言われます。Articleというエンティティがtitleというフィールドを持っていて取得するときに、DBからArticleの1レコードが持つタイトルからも返しますみたいな定義がされています。 ただ、こういう素朴な実装をしていると、Articleを一気に複数取得するQueryが投げられてtitleをresolveするときに、Articleの一件取得を複数回行うN+1が起こってしまいます。
なので、単一のフィールドをどのようにresolveするかという実装と、それが一気に複数回呼ばれることがあるユースケースの想定に思考のギャップが生まれて、N+1が起こりやすいと言われているのかなと思います。
あとは、任意のクエリをクライアントは投げられるので、ネストがあまりに深いクエリ とか、膨大なエンティティを取得するクエリが悪意を持つものから投げられる場合も難しさがあります。
GraphQLの良さ・難しさを踏まえたスタディサプリによるスキーマ駆動開発の実例
まずはスキーマ駆動開発の実例を紹介します。1番左に出てるのは実際に社内で使われてるfigmaで、こういった画面仕様が決定したタイミングでGraphQLのスキーマが決まり、クライアントはKotlin/Swift/Reactで実装します。サーバーサイドとして、Node.jsとRailsでResolverを実装することは割と日常的に行われていました。
N+1についてはセオリー通りData Loaderによる対応が多いですね。 Data LoaderはDBアクセスとかHTTPリクエストなどのN+1を起こされたくない処理をバッチ化する仕組みですが、GraphQLだと逆にN+1に気を使うので混入させないという声もありました。
一方で、Data Loaderを導入しない派とする派に実は分かれるという話を聞いて、 その辺りはチームで意識を揃える必要があると最近学びました。(僕は完全に導入する派です)
あとは、Observabilityですね。DatadogでQueryやMutation別にトラッキングできる仕組みを整えています。 これはApolloサーバーのミドルウェアとかを使っています。
あと結構社内で面白かったエピソードとしては、 エンドポイントすら /api/graphql/{query名}に投げてねというルールを作っているチームもいました。 こうするとQuery名に おけるSuccessが個別に取れるんですよね。 ただし内部の実装としては、このgraphql/ 以下のルーティングは無視されて、実際にリクエストのハンドリングを行うのが単一のGraphQLのControllerという実装にしているそうです。用途が限定される場合においてはこれで十分なケースもあると思います。
あとは、任意のクエリを投げられるから結構危ないという話は、Persisted Queryという仕組みを使って、あらかじめ登録されたクエリのみを許容するようにしています。
また、インターナルなシステム連携においてもGraphQL APIを利用していまして、 schema stitchingというものを使っています。これはマイクロサービスの提供するそれぞれのスキーマが提供するものを Type Mergingという技術を使ってAPI Gatewayで合成できます。 ただ結構難しくて、日々デバッグに頭を悩まされながら使っていました。
さらに、Go製のマイクロサービス群におけるschema stitchingやFederationの採用を検討したチームは、利用しているライブラリではうまく扱えなさそうだと分かって工夫が必要でした。RubyからRubyへのインターナルの通信においては、OpenAPIよりもGraphQLが好きで採用したチームもいました。
GraphQL活用まとめ
最後まとめになりますが、まずスキーマ駆動開発ができる組織や関係性を築くことが、GraphQLの便利さを引き出すためにすごく大事だと思っています。
実際はDB設計まで見越してGraphQLスキーマ設計をした方が良いシーンもあって、 やはり理想はクライアントサイドとサーバサイドが同一のチーム、あるいはクライアントサイドやサーバサイドみたいな区分なく両方できる人が多いチーム、ロールの違いを超えて議論がしやすい関係性のチームとかだと良いと思いました。
あとは、GraphQLの利用はエコシステムに依存することだと自覚する必要があると思います。 言語によってGraphQLエコシステムの発達度合いは異なるので、選択できるライブラリとやりたいことのバランスを確認する必要があります。
また、どうしてもビジネスロジックやそれが守るデータに比べるとGraphQLの方が廃れる可能性が高いので、ロジックとGraphQLのみの都合を避けた設計も個人的には意識したいと思いました。
パネリストによるGraphQLについてのディスカッション
――ここからは、運営の方で用意したテーマに沿って、先ほど発表していただいた3名のパネリストによるディスカッションを行います。まず一つ目のテーマは「GraphQLはどういった現場フェーズの会社だと向いていて、どういった活用が想定されるか」です。
内山さんいかがでしょうか?
内山:GraphQLはクライアントの画面の要請に設計の力学を映しやすい技術なので、それにバックエンドの人も応えやすいですね。 なので、クライアントサイドの人とバックエンドの人が膝を詰めて議論しやすかったり、そういったロールがきっちり分かれていなかったりするチームが合っているんじゃないかと思っています。
――続けて松本さん、いかがでしょうか。
松本: 自分も内山さんと概ね同じで、0→1の規模が小さい場合は 導入しやすいと思いますね。 先ほどの発表にもあったマルチデバイスとか、うちみたいにプロダクト間の連携があって、いろんなコンテキストでリソースを取得する場合にも非常に良いと思います。
――続いてqsonaさん、お願いします。
qsona: たとえば、今どの現場にReactが向いてるかと言われたら、ほぼ全ての現場ですという答えになるんですよ。 別にjQuery使わなくていいからReact使ってくれというのと同じ感覚で、 GraphQLを使ってくれという気持ちです。
――続いてのテーマは「立ち上げでは向いてたり、技術スタック含めて採用するケースがあるけれども、REST APIを採用してるサービスが途中からGraphQLに移行するメリットはあるのか」ですが、 今度はqsonaさんいかがでしょうか。
qsona:SHEのRailsアプリとかはREST APIがあります。ただ、GraphQLをやっていきたいのでちょっとずつGraphQLを増やしてます。 元々RESTだった画面を 機能改善のついでにGraphQLに置き換える感じならいけると思います。
―― 続いて松本さん、いかがでしょうか。
松本: GraphQLのメリットが享受できるなら一気に置き換える必要は無くて、徐々に入れていくのは全然ありだと思います。 GraphQLだから1画面1リクエストという制約は無いので、 リスクと生産性のバランスを考えながら適用していけば良いと思いますね。一般的なリファクタリングの方針の決め方とあまり変わらない気はします。
――続いて、内山さんにもこの辺りをご意見いただければと思います。
内山:既存のAPIを完全に置き換えるよりは、段階的な 移行がセオリーになると思います。 ただ、既存のREST APIに明確な問題や課題があったとして、それがGraphQLに移行した時にコスト的なメリットが本当に出るかは懐疑的ですね。少なくとも慎重に考えた方がいいですね。
――続いてのテーマは、「逆にGraphQLを使わない方が良い現場や状況があるか」ですが、 内山さんからお願いします。
内山: なんとなくGraphQLが良いらしいから入れてみました、というスタンスでいくと、予期しない場合の対応とか、悪意あるQueryが投げられたりする場合があるので 覚悟が無いと危ないですね。あとGraphQLエコシステムとの相性があまり良くない言語を使う怖さはありますね。
―― 松本さんはいかがでしょうか。
松本: 自分もほぼ同意見でして、スキーマ駆動開発をしなきゃいけないので、サーバとエンジニアとの距離が遠い状況は合う合わない以前の問題かなと思いますね。 距離が遠いと、スキーマの定義とかの認識をすり合わせるコミュニケーションに結構コストがかかりそうですね。
内山:開発は画面設計から始まることが多いのでフロントエンドによるスキーマ設計で完結しそうに思えますが、実際はDB設計しながらスキーマを考えることも必要だと思っていて、フロントエンドとバックエンドの距離が近い方が 良いスキーマが作れる感覚があります。
――qsonaさんもそのあたりはいかがでしょうか。
qsona:GraphQL APIはユースケース駆動のAPIを作るには向いてないんです。 なので、GraphQLを使えない状況は2個あって、1個はそもそもユースケースでしかAPIを作れないケース。 もう1個は画面設計を見る一方でDBを見ながらリソースを定義しに行く場合で、 使い捨て前提ならユースケースベースの画面にAPIから入った方が楽なケースがあるとは思います。
視聴者からの質問に答える質疑応答タイムへ
――ここからは、視聴者の方からの質問に回答していきます。まず1個目の質問ですね。 「GraphQLでN+1を解決する場合、Data Loaderを使わずにどのように解決するのでしょうか」とのことで、これは内山さんの資料にありましたね。
内山: 基本的にはN+1を解決する場合はData Loaderやその手のバッチ処理が必要になりますが、N+1を放置する方がいることを僕は最近知ったんです。 Data Loaderって実装次第ではオーバーヘッドがそれなりにあるんですね。その待ち時間がN+1にかかるパフォーマンスの悪さとどちらが悪くなるのかをちゃんと計測して、そこにData Loaderを入れるのか入れないかの判断が必要で、場合によってはN+1の方が良くて、Nが2とか3なら大したことないという話がありました。
―― 続いて「認可周りはどうやって実装していますか」ということで、 松本さんいかがでしょうか。
松本: 認可はディレクティブとかで結構やってますが、問題とかは全く無いですね。
―― 内山さん、コメントで「ディレクティブをやってもヒットしなかった」と書いてありますが、実際どうやっているかを最後に聞ければと思いますが、いかがでしょうか。
内山: Resolverの中に入ってからビジネスロジックとして認可を書いたりとか結構泥臭くやってます。認可と言ってもどのレイヤーで止めたいのかが微妙にユースケースによって変わるんです。 なので、場合によってはMutationとかQueryのAuth ErrorみたいなResultを返している部分もあるし、Resolverの中に入ってから 「このユーザーは権限が無い」と言って止めることもあります。認可は割とケースバイケースになりがちで、個別にやるしかないと個人的には思います。
―― 最後の質問です。 「テストはリクエストベースでやるのか、モデル含めてどうテストしてるのか」とのことで、 内山さんからお願いします。
内山:実際にGraphQL QueryやMutationを投げて意図した通りの結果が返ってくるかを、リクエストスペックを書きながら検証しています。 GraphQLのレイヤーとビジネスロジックのレイヤーを分けて、モデルはモデルとしていつも通りテストするように意識しています。
――続けて松本さんいかがでしょうか。
松本:うちも結構似てますが、テストに100%工数を割けてない実態もあるので、 ユースケースとかユースケースのサービスのレイヤーに絞って今テストをしていますね。 なので、GraphQLだからこうしてるという感じではないです。
――続いてqsonaさんいかがでしょうか。
qsona: 基本的にサーバーサイドのテストはリクエストにいかにもフロントエンドが投げそうなクエリを書いてチェックしているので、 結果をアサーションする必要はなくて、型が間違ってたり NOT NULLのはずがNULLになっていたりする場合はリクエストが成功しないんです。
ただ、お悩みポイントとしては投げる側です。 タイプごとのテストはちょっと書きにくいので、ペアレントオブジェクトを指定してタイプごとにテストを書いて、 1段階目までちゃんとresolveされれば良しとしています。