GraphQL
GraphQLとは
GraphQLは、クライアントが欲しいデータの形をクエリで宣言して取得するAPI技術です。Metaが開発し、[[REST API]] の「取得しすぎ(over-fetching)/足りない(under-fetching)」問題への解決策として広まりました。
query {
user(id: 42) {
name
posts(limit: 3) {
title
}
}
}
このクエリは「ユーザー42の名前と、その投稿3件のタイトルだけ」を1回の通信で取得します。
初学者向けポイント
- エンドポイントは基本的に1つ(
/graphql)。RESTのようにURLを増やさない - スキーマが契約書。サーバーが提供できる型と操作をすべて定義し、そこから型([[TypeScript]] の型も)を自動生成できる
- 操作は3種類:
query(取得)/mutation(変更)/subscription(購読)
RESTとの使い分け
| 観点 | REST | GraphQL |
|---|---|---|
| 学習コスト | 低い | スキーマ設計の学習が必要 |
| 取得の柔軟性 | エンドポイント次第 | クライアントが自由に指定 |
| キャッシュ | HTTPキャッシュが効く | 工夫が必要 |
| 向いている場面 | シンプルなCRUD | 画面ごとに必要データが違う複雑なUI |
スキーマの読み方
サーバー側は「どんな型を、どんな操作で返せるか」をスキーマに書きます。
type User {
id: ID!
name: String!
email: String
posts(limit: Int = 10): [Post!]!
}
type Query { user(id: ID!): User }
! は「nullにならない」印です。name: String! は必ず値が入り、email: String はnullがあり得ます。この情報はそのまま [[TypeScript]] の型に変換できるので、「返ってこないかもしれない値」をコンパイル時に見つけられます。
ただし非nullのフィールドが実行時にnullになると、そのフィールド単体では済まず、nullを許す一番近い親までまとめてnullに巻き戻されます。「1件だけ著者情報が欠けていたので記事一覧が丸ごと消えた」という事故はここから起きます。言い切れないものに ! を付けない、が指針です。
200 OK なのに失敗している
GraphQLは通常、単一のエンドポイントへ POST でクエリを送ります。ここでRESTの感覚が2つ通じません。
1つはエラーの返し方です。存在しないユーザーを引いてもHTTPのステータスは 200 が返り、レスポンス本体の errors 配列にエラーが入ります。[[HTTPステータスコード]] だけで分岐するコードは成功と失敗を取り違えるので、data と errors は毎回両方を確認してください。
もう1つはキャッシュです。URLが常に /graphql で、しかもPOSTなので、[[HTTPキャッシュ]] がそのままでは効きません。比較表の「工夫が必要」はこの意味です。実務ではApollo Clientなどが、取得したオブジェクトをID単位で保持する正規化キャッシュで役割を肩代わりします。
サーバー側で起きるN+1
クライアントが自由にネストを掘れるぶん、サーバー側では「フィールドごとにデータを取りに行く関数」が数多く動きます。
query {
posts(limit: 100) {
author { name }
}
}
素直に実装すると、著者を引くクエリが100回発行されます。[[ORM]] で有名なN+1問題と同じ構造ですが、GraphQLではクエリを書くのがクライアントなので、サーバーのコードを眺めていても発生に気づけないのが厄介です。定番の対策は DataLoader で、同じ処理サイクル内の個別の取得要求を溜め、1回の問い合わせ([[テーブル結合(JOIN)]] や IN 句)にまとめます。
重いクエリから身を守る
「何でも書ける」ことは「重いクエリも書ける」ことでもあります。友達の友達の友達……と深く辿る1本でサーバーが止まりかねません。RESTなら「重いエンドポイントだけ制限する」で済む対策も、入り口が1つでは使えません。
- 深さ制限 — ネストの段数に上限を設ける
- 複雑度制限 — フィールドごとにコストを割り当て、合計が上限を超えたら拒否する
- 永続化クエリ — 事前に登録したクエリ以外は実行させない
[[レートリミット]] のように回数だけで守るのでは足りません。
バージョンを増やさずに進化させる
[[APIバージョニング]] では /v1 /v2 とURLを分けるのが定番ですが、GraphQLは別の道を選びます。フィールドの追加は要求しないクライアントに影響しないため、そのまま足せます。廃止したいフィールドには @deprecated(reason: "name を使ってください") を付け、スキーマ上で非推奨だと宣言します。
あとは「まだそのフィールドを要求しているクライアントがいるか」をログから数え、0になったら削除します。誰が何を使っているか計測できるのが強みです。
GraphQLから次に読む地図
広がる先は5方向です。近いところから進んでください。
土台をそろえる — 比較対象の [[REST API]]、通信そのものの [[HTTP]]、返る形式の [[JSON]]。GraphQLもJSONを返すので、ここが分かって初めて差分として学べます。
契約から型を作る — スキーマから [[TypeScript]] の型を生成すると、! の有無がそのまま型に効きます。先に契約を決める発想は [[gRPC]] も同じです。
性能を守る — 遅さの原因はたいていサーバー側です。N+1の本体は [[ORM]]、まとめ取りは [[テーブル結合(JOIN)]]、遅いクエリは [[実行計画とクエリチューニング]]。[[HTTPキャッシュ]] が効かない分は [[キャッシュ戦略]] で補います。
公開して運用する — 入り口が1つだからこそ守りを寄せられます。共通処理は [[APIゲートウェイ]]、流量は [[レートリミット]]、誰の要求かは [[認証と認可]]、壊さず育てる話は [[APIバージョニング]] へ。
作って呼ぶ — サーバーは [[Node.js]] が代表格。ブラウザ側は [[fetchと非同期通信]] でPOSTするのが素の形で、subscription なら裏側の [[WebSocket]] を先に押さえます。
関連技術とのつながり
- [[REST API]] — 比較対象となる設計スタイル。併用も多い
- [[JSON]] — レスポンスの形式
- [[Node.js]] — Apollo Server などの代表的な実装環境
Q: GraphQLでリクエストが失敗したときのHTTPステータスの扱いとして、本文の説明に合うのはどれ?
- [ ] 必ず 404 や 500 が返るのでステータスコードだけで判定できる
- [x] 通常は 200 が返り、レスポンス本体の errors 配列を確認する必要がある
- [ ] エラー時はレスポンス自体が返らず接続が切れる
解説: GraphQLは通常200を返し、errorsにエラーを入れます。dataとerrorsの両方を毎回確認する必要があります。
Q: スキーマの `name: String!` の `!` が意味するものはどれ?
- [x] その値がnullにならないこと
- [ ] その値が必ずキャッシュされること
- [ ] そのフィールドが非推奨であること
解説: `!` は非nullの印です。非nullのフィールドが実行時にnullになると、nullを許す一番近い親までまとめてnullに巻き戻されます。
Q: GraphQLのサーバー側で起きるN+1問題への定番の対策はどれ?
- [ ] クエリの深さを1段に固定する
- [ ] レスポンスをすべてバイナリ形式にする
- [x] DataLoaderで個別の取得要求をまとめ、1回の問い合わせに変換する
解説: DataLoaderは同じ処理サイクル内の個別要求を溜めて、JOINやIN句によるまとめ取りへ変換します。