GraphQL
GraphQL ・ ぐらふきゅーえる
クライアントが必要なデータの形を宣言して取得するAPIのクエリ言語。過不足取得を解消する。
✦ 30秒で試す — 航海欲しいのは名前1つなのに、プロフィールが丸ごと届く同時に送れる分はまとめて送る。応答を待つ段は何段?予想してみる概要
GraphQL は、Facebook(現 Meta)が開発した API のためのクエリ言語です。最大の特徴は、「どんなデータを、どんな形で欲しいか」をサーバではなくクライアントが宣言することにあります。クライアントは欲しいフィールドだけを列挙した問い合わせ(クエリ)を送り、サーバはそれと同じ形の JSON を返します。注文書と納品物の形が一致する、と言い換えられます。
登場する典型的な場面は、1つのバックエンドを多様な画面が使うサービスです。スマホアプリの一覧画面は名前とサムネイルだけ、Web の詳細画面は投稿履歴まで — 画面ごとに欲しいデータの形が違うとき、GraphQL ならサーバ側に画面専用の口を増やすことなく、各クライアントが自分に必要な形を自分で組み立てられます。その代わり、どの問い合わせが来てもさばけるように、データの取り方の工夫はサーバの内側に集まります。
なぜ生まれたか
REST API では、リソースごとに用意されたエンドポイントが返すデータの形をサーバが決めます。ここから2つの慢性的な不満が生まれました。ひとつはオーバーフェッチ(over-fetching)— 一覧画面に名前だけ欲しいのに、プロフィール全項目が返ってくる無駄。もうひとつはアンダーフェッチ(under-fetching)— 「ユーザー情報 → その投稿一覧 → 各投稿のコメント」のような入れ子のデータを揃えるために、HTTP リクエストを何往復も重ねる手間です。回線の遅いモバイル環境では、この往復と無駄なデータ量がそのまま体感速度の悪化になります。
Facebook はまさにこの問題 — 多様な画面を持つモバイルアプリと巨大なデータグラフ — に直面し、2012年に社内で GraphQL を開発、2015年に公開しました。「サーバがデータの形を決める」から「クライアントが形を宣言し、サーバは能力(スキーマ)を公開する」への転換が、この技術の本質です。
詳細
スキーマ・クエリ・リゾルバの三点セット
GraphQL のサーバは、まずスキーマで「どんな型があり、どこからどこを辿れるか」を型システムとして定義します。クライアントはスキーマの範囲内でクエリを書き、サーバは届いたクエリをまずスキーマと突き合わせて検証します(存在しないフィールドや型の合わない引数は、実行する前に弾かれます)。検証を通ると、各フィールドに対応するリゾルバ(そのフィールドの値を実際に取ってくる関数)がデータベースや他の API からデータを集めます。エンドポイントは通常1つだけで、REST のように URL を増やす代わりに、スキーマという型付きの地図を1枚公開する構図です。
1リクエストで入れ子のデータを取る
REST なら「ユーザー取得 → 投稿一覧取得 → 各投稿のコメント取得」と数往復かかる問い合わせが、GraphQL では1つのクエリにまとまります。
query {
user(id: "42") {
name
posts(limit: 3) {
title
comments { body }
}
}
}
応答はこのクエリと同じ入れ子構造の JSON になり、書いていないフィールドは一切含まれません。REST で同じものを揃えようとすると、投稿の id はユーザーの投稿一覧を受け取るまで分からず、コメントの URL は投稿の id が分かるまで作れません。つまり往復は並列にできず、「前の応答を待ってから次を送る」段が入れ子の深さぶん積み重なります。データを「表」ではなく「グラフ(つながりの網)」として捉え、必要な枝だけを辿る——名前の由来でもあるこの発想が、往復の削減と過不足取得の解消を同時に実現します。取得だけでなく、更新は Mutation、サーバからのプッシュ配信は Subscription という形で同じ型システムの上に載っています。
クエリの枝を付け外しして確かめてください。応答の JSON が同じ形で伸び縮みし、下では同じデータを REST で揃える場合の往復の段数と、使わないのに届いた余分なバイト数が積み上がります。
いまのクエリは user { name } だけです。左のクエリの posts や comments を押して枝を足してください。右の応答が同じ形で伸び、下では GraphQL と REST の往復が同時に走ります。
時間は単純化した値です(モバイル回線を想定し、片道 60 ms、サーバの処理 15 ms、約 2 Mbps)。バイト数は JSON.stringify した応答の本物の UTF-8 のバイト数で、HTTP ヘッダは含めていません。
N+1 問題と DataLoader
部品の GraphQL の段で、往復は1回のままなのに「中で DB へ」の回数が増えていったことに気づいたでしょうか。往復が消えたのではなく、サーバの内側へ移ったのです。リゾルバは便利な反面、素朴に実装すると深刻な性能問題を招きます。投稿100件それぞれの著者フィールドを解決するとき、リゾルバが素直に動くと「投稿一覧に1回 + 著者取得に100回」のデータベース照会が発生します。これが N+1問題です。ORM でも同種の問題は起きますが、GraphQL ではクエリの形がクライアント次第なので、あらゆる形を想定した JOIN をサーバ側に事前に用意しておくのは難しくなります(届いたクエリを先読み(lookahead)して、必要な関連だけを JOIN で取る実装もあります)。
定番の解決策が DataLoader です。1回のリクエスト処理中に発生した「著者 ID で取得」の呼び出しをいったん溜めてから、「ID 群をまとめて1回で取得」というバッチ照会に変換し、結果を各リゾルバに配り直します。あわせてリクエスト内での重複取得をキャッシュする役割も担います。GraphQL サーバを実装するなら、ほぼ必須の道具と考えてよいでしょう。
キャッシュ・悪意あるクエリと、REST との使い分け
GraphQL の弱点としてよく挙がるのがキャッシュです。REST では「GET /users/42 の応答を URL 単位でキャッシュする」という HTTP 標準の仕組みや CDN がそのまま効きますが、GraphQL は単一エンドポイントへの POST でクエリごとに応答が変わるため、そのままではこの経路が機能しません。回避策は2つあります。1つは、クライアントがあらかじめ登録したクエリをハッシュ値だけで呼ぶパーシステッドクエリを GET で送る方法で、URL が短く一意になるので CDN でもキャッシュできます(登録済みのクエリしか受け付けないようにすれば、任意のクエリを投げ込まれる心配も減ります)。もう1つは Apollo Client などのクライアントライブラリで、応答をオブジェクトの ID 単位に分解して手元に正規化キャッシュする方式です。こちらは主流ですが、その分クライアント側の複雑さは増します。
また、クライアントが形を決められるということは、悪意あるクライアントが深い入れ子(friends { friends { friends { … } } })でサーバを疲弊させられるということでもあります。GraphQL では要求の回数を数えるだけのレート制限では足りず、クエリの深さの上限や、フィールドごとに重みを付けて1クエリの「コスト」を見積もり、上限を超えたら実行前に拒否するクエリコスト制限が実務では必須です。エラーの扱いも独特で、一部のフィールドだけ失敗しても HTTP のステータスは 200 のまま、応答の errors に理由が入るのが普通なので、監視の仕方も REST とは変わります。
使い分けの目安はこうです。多様な画面・クライアントが1つのデータグラフを柔軟に読みたいとき、フロントエンドとバックエンドのチームが分かれていて「画面ごとの API 調整」がボトルネックになっているときは GraphQL が輝きます。一方、リソース構造が素直で HTTP キャッシュを活かしたい公開 API や、シンプルな CRUD には REST API の単純さが勝ります。銀の弾丸ではなく、「形の決定権をクライアントに移す」ことの利得が、サーバ側に移る複雑さ(N+1 への対策、コスト制限、キャッシュの工夫)を上回る場面で選ぶ技術です。
試問
左は GraphQL のクエリで、項目(name・posts・comments・author…)を押すと付け外しでき、右に同じ形の応答の JSON が出ます。下では、同じデータを REST で揃える場合の往復の段数と、使わないのに届いた余分なバイト数も比べられます。いまのクエリは user { name } です。
いまのクエリは user { name } だけです。左のクエリの posts や comments を押して枝を足してください。右の応答が同じ形で伸び、下では GraphQL と REST の往復が同時に走ります。
時間は単純化した値です(モバイル回線を想定し、片道 60 ms、サーバの処理 15 ms、約 2 Mbps)。バイト数は JSON.stringify した応答の本物の UTF-8 のバイト数で、HTTP ヘッダは含めていません。
