BFF はわかる。GraphQL がわからない
REST で BFF を運用していると、必ず来る局面があります。
モバイルのホーム画面用に GET /api/mobile/home を生やす。
BFF の中ではユーザーサービスと注文サービスを合わせて 5 本ほど内部 API を呼び、画面が欲しい形に整形して返す。
集約と整形——BFF の正しい仕事です。
問題はその先です。
- Web のダッシュボードができれば
GET /api/web/dashboardが生まれる - 設定画面が増えれば、またひとつ生まれる
- 半年後には、誰も全容を把握していないエンドポイント群と、微妙に違う整形ロジックのコピーが残る
画面がひとつ増えるたびに、BFF のエンドポイントもひとつ増える。
このあたりで誰かが言います。
「それ、GraphQL にすればよくない?」
——で、答えに詰まる。
GraphQL の名前は知っています。
単一エンドポイントで、クライアントがクエリを書くらしい、くらいまでは知っている。
でも「なぜそれがエンドポイント増殖の解決になるのか」が繋がらない。
そこで今回は、GraphQL をゼロから説明し、そのうえで BFF になぜ向くのか?をみていきます。
GraphQL とは何か
GraphQL は Facebook(現 Meta)が開発した、API のための クエリ言語とそのランタイム です。
クライアントは「欲しいデータの形」をクエリとして書き、サーバーはその形どおりの JSON を返します。
query {
user(id: "1") {
name
avatarUrl
orders(first: 3) {
total
items {
title
}
}
}
}
レスポンスはこうなります。
{
"data": {
"user": {
"name": "miso",
"avatarUrl": "https://...",
"orders": [
{ "total": 3200, "items": [{ "title": "..." }] }
]
}
}
}
クエリとレスポンスが同じ形をしていますね。
つまり、データの形を決めているのはクライアント側です。
そして、見覚えがあるはずです。
これは冒頭の /api/mobile/home が中で手書きしていた集約そのものです。
5 本の内部呼び出しと整形コードが、クエリ 1 枚の宣言になっている。
クライアントが形を決めるので、REST の文脈で言う
- アンダーフェッチ: 1 回で足りず何度も取りに行く
- オーバーフェッチ: 要らないものまで受け取る
この 2 つが、同じひとつの仕組みで消えています。
エンドポイントは /graphql の 1 つだけです。
REST のようにリソースごとの URL を設計する代わりに、スキーマ を設計します。
type User {
id: ID!
name: String!
avatarUrl: String
orders(first: Int): [Order!]!
}
type Order {
total: Int!
items: [Item!]!
}
このスキーマが仕様書兼バリデーションとして機能します(! は non-nullable)。
graphql-codegen を通せば、ここから TypeScript の型が自動生成されます。
スキーマに書いた契約がそのままクライアントの型になる。
REST + OpenAPI でやろうとしていたことが、言語仕様のレベルで組み込まれているわけです。
操作は 3 種類だけ覚えれば足ります。
- Query: 読み取り
- Mutation: 書き込み
- Subscription: リアルタイム購読(WebSocket 経由が多い)
そして、サーバー側で「そのフィールドの値をどこからどう取るか」を書くのが resolver です。
const resolvers = {
Query: {
user: (_, { id }, { dataSources }) =>
dataSources.userAPI.getUser(id),
},
User: {
orders: (user, { first }, { dataSources }) =>
dataSources.orderAPI.getOrders(user.id, first),
},
};
ここまでが GraphQL の基本です。
つまり「クライアントがデータの形を宣言し、サーバーが resolver でそれを解決する」。
この一文に集約されます。
GraphQL は REST の上位互換ではない
ここまでの説明だけ読むと「REST より全部よさそう」に見えます。
実際、GraphQL の入門記事の多くはここで終わります。
でも弱点を知らずに導入すると痛い目を見るので、先に並べます。
HTTP キャッシュが効かない
REST なら GET /users/1 に CDN キャッシュを効かせられますが、GraphQL は単一エンドポイントへの POST です。
URL ベースのキャッシュ戦略が丸ごと使えません。
クエリの自由度が、そのまま攻撃面になる
クライアントが任意の深さのクエリを書けるということは、friends { friends { friends { ... } } } のような爆発するクエリも書けるということです。
depth limit や cost analysis を実装しないと DoS の入口になります。
N+1 が構造上必ず出る
さっきの resolver、orders の各要素に対して items の resolver が個別に走ります。
注文 100 件なら商品取得が 100 回。
DataLoader によるバッチングが事実上必須で、「素朴に書いたら動くが遅い」がデフォルトの挙動です。
固定費が高い
スキーマ設計、resolver 実装、codegen のパイプライン、DataLoader。
単純な CRUD アプリでこの初期コストを回収できるケースは多くありません。
つまり GraphQL は「柔軟性を得る代わりに、キャッシュと安全性の制御をアプリケーション層で自前実装する」技術です。
REST が HTTP のセマンティクスに乗って無料で得ていたものを、手放している。
ここで終わると「じゃあ使いどころがないのでは」となります。
いいえ、そんなことはないです。
この弱点リスト、GraphQL をどこに置くかで大きく変わります。
その置き場所の代表が、BFF です。
BFF とは何か
BFF(Backend for Frontend)は、フロントエンドとバックエンド群の間に挟む 集約層のアーキテクチャパターン です。
Best Friend Forever では、ないです。
Sam Newman が SoundCloud での事例をもとにパターン化した言葉で、Netflix がクライアント種別ごとに API アダプタ層を分けた構成も同系です。
動機はこうです。
マイクロサービスが増え、クライアントも Mobile / Web / TV と増えると、「全クライアント共通の汎用 API」は誰の要求にも最適化できなくなります。
- モバイルは軽いペイロードが欲しい
- Web はリッチなデータが欲しい
- TV は別の認証フローが要る
この要求差分を汎用 API に押し込むと、API は肥大化し、どのクライアントにとっても帯に短し襷に長しになる。
そこで、クライアント(の種類)ごとに専用のバックエンドを置く のが BFF です。
BFF の仕事は「複数のバックエンドを呼んで、担当フロントが欲しい形に組み替えて返す」こと。
集約(aggregation)と整形(transformation)です。
所有権は、担当するフロントエンドのチームが持つのが本来の形です。
画面要件の変更を、バックエンドチームへの依頼なしに完結させるためです。
ここで、冒頭の増殖問題の正体をはっきりさせておきます。
あれは BFF というパターンの弱点ではありません。
「データの形を決める権利がサーバー側にしかない」という REST の性質 が、画面差分の吸収という BFF の仕事と衝突して起きる弱点です。
パターンの弱点ではなく、実装手段から来る弱点です。
では、その実装手段を GraphQL に替えるとどうなるか。
GraphQLの弱点は「性質」ではなく「配置の帰結」だった
BFF の仕事は集約と整形でした。
これがスキーマ + resolver の構造とほぼ一対一で対応します。
resolver の呼び先を DB ではなく内部サービスの API にすれば、そのまま BFF になる。
さらに、REST-BFF が抱え込んでいた「画面ごとにエンドポイントが増殖する」問題を思い出してください。
GraphQL ならスキーマは 1 つで、画面の差分はクエリ側が吸収します。
クライアント種別ごとに BFF を分ける必要すら薄れて、1 つの GraphQL BFF に統合できるケースも多い。
REST 実装から来ていた BFF の弱点を、GraphQL が打ち消しているわけです。
そして肝心なのはここです。
逆方向——さっきの GraphQL の弱点リストを、BFF という配置で再評価してみます。
HTTP キャッシュが効かない
→ BFF が返すのはユーザー固有の集約データです。
そもそも CDN キャッシュの対象になりにくいレスポンスなので、失うものが最初からほぼありません。
クエリ DoS
→ BFF に来るクライアントは自社のフロントだけです。
ビルド時にクエリを抽出して ID 化し、実行時は ID だけを受け付ける persisted queries が使えます。
任意クエリを受け付けなくなるので、「クエリの自由度」というリスクを丸ごと封じられる。
不特定多数に公開する API では原理的に取れない選択肢です。
N+1
→ resolver が叩くのは DB ではなく内部 API です。
DataLoader でのバッチングに加えて、内部側に一括取得エンドポイントを生やすという逃げ道もあります。
DB 直撃の N+1 より対処の自由度が高い。
固定費
→ 集約層はどのみち書きます。
比較対象は「何もしない」ではなく「REST でエンドポイントを増殖させ続けるコスト」になるので、天秤の傾きが変わります。
つまり、GraphQL の弱点の大半は GraphQL 固有の性質ではなく、「不特定多数に公開する」という配置から来ていたわけです。
信頼できるクライアントしか来ない BFF に置けば、支払わずに済むコストだった。
おわりに
この記事で見てきたことを一言でまとめると、こうです。
GraphQL は REST の上位互換ではなく、キャッシュと安全性の制御を自前実装に引き受ける、別のトレードオフを持つ技術。
そしてその弱点の大半は GraphQL 固有の性質ではなく、「不特定多数に公開する」という配置から来ていた。
だから、信頼できるクライアントしか来ない BFF に置くと、弱点が消えて集約層としての強みだけが残る。
置かれた場所に咲きなさい、という言葉もありますが、適材適所も大事ですね。