GraphQLを勉強したので概要を書いておく。How To的な内容は別に譲り、自分なりに理解した基本的な考え方をメモ。
問題例
たとえば、何かのチームとメンバーの管理をするようなAPIを考えてみる。ごくシンプルに
- チームが複数ある
- チームにはユーザーが複数所属する
- 各ユーザーは、ちょうど1つのチームに所属する
くらいの典型的な要件だけ考えておく。
RESTでの例
とりあえず下のようなER図で表現されるテーブルたちが必要になる
REST APIであれば、とりあえずGET /user/:id
とかGET /team/:id
とかがまず用意されるだろう。その上で、「ユーザーが所属するチームを知りたい」とか「チームに所属するユーザーを知りたい」というアプリ側の要件に合わせてサーバー側のエンドポイントを設計していくことになる。
ここにRESTの典型的なペインがあって、基本的には、アプリ側の要件に合わせてサーバー側のエンドポイントを増やす必要が出てきてしまう(シンプルなAPIだけ用意しておいてアプリ側で必要なだけ問い合わせを組み合わせることもできるが、知りたい情報の種類に合わせてロジックの実装が増加していくという点で本質的には同じことである)。
GraphQLでは
GraphQLでは、以下のようなスキーマを登録しておくだけで、アプリ側で欲しい情報を組み合わせたクエリを自由に構築し問い合わせることが可能になる。
type User {
id: Int
name: String
team: Team
}
type Team {
id: Int
name: String
users: [User]
}
type Query {
getUser(id: Int!): User
getTeam(id: Int!): Team
}
クエリの例
id 1のユーザーの名前が知りたい
query {
getUser(id: 1) {
name
}
}
id 1のユーザーの名前と所属するチーム名が知りたい
query {
getUser(id: 1) {
name
team {
name
}
}
}
id 1のユーザーの名前と所属するチーム名と、そのチームに所属するユーザーの名前が知りたい
query {
getUser(id: 1) {
name
team {
name
users {
name
}
}
}
}
上のように、必要な情報粒度に合わせて、アプリ側で任意にオブジェクトの階層をたどることができる。その際に、API側で新たなロジックを実装する必要はない。リクエストに応じて必要な情報をたどる部分は、QraphQLがよしなにやってくれるからだ。便利
仕組み
GraphQLのクエリは、任意の深さにネストさせることができる(原理的には。当然パフォーマンスや資源の問題はある)。このように、潜在的に無限に続く可能性がある(つまり、事前にどこまで展開すれば良いんかを決めておくことができない)データ構造を扱うには遅延評価をすれば良い。そして、遅延評価はクロージャがあれば簡単に実装できる (参考)。GraphQLでは、この遅延評価のためのクロージャのことをResolverと呼んでいる。
上の例を引き続き使うと、以下のような雰囲気のオブジェクトとエントリポイントに対応した関数を実装する(これは、コード例としては不完全で雰囲気を再現するだけのものなので注意。正確なところはHow To的な記事を探して読む必要あり)。
type TeamResolver = {
id: number;
name: string;
users: () => Promise<UserResolver[]>;
};
type UserResolver = {
id: number;
name: string;
team: () => Promise<TeamResolver>;
};
export async function getUser(args: { id: number }): Promise<UserResolver> {
const user = await userLoader.load(args.id);
return {
id: args.id,
name: user.name,
team: async () => getTeam({ id: user.teamId }),
};
}
export async function getTeam(args: { id: number }): Promise<TeamResolver> {
const team = await teamLoader.load(args.id);
return {
id: args.id,
name: team.name,
users: async () => Promise.all(team.userIds.map((id) => getUser({ id }))),
};
}
ここで、TeamResolver.users
やUserResolver.team
の型が、値ではなく関数になっているのがポイントである。
GraphQLインタプリタは、クエリから要求されているオブジェクトを作成するが、その際に要求されているプロパティのリゾルバだけを実行する。
これによって、任意の深さにネストしたクエリへの対応を実現することができる。
N+1問題
上のコード例では、getTeamが返すTeamResolver.users
の実装が、以下のようになっている。
async () => Promise.all(team.userIds.map((id) => getUser({ id }))),
つまり、1つのチームのusers
を解決しようとしたら、そのチームの人数分だけgetUser
が呼ばれる。
これは、典型的なN+1問題である。これを解決するために、getUser: id => User
だけでなく、getUsers: [id] => [User]
みたいな関数を新しく用意すれば・・などと考えてしまうと、GraphQLが提供しているクエリの柔軟性/サーバー実装の容易さといったprosを大きく損ねることになる。
GraphQLでは、DataLoader
と呼ばれる仕組みを使って、これに対応するのが標準的である。DataLoader
は、GraphQLとは独立した仕組みだが、GraphQLを作った同じ人たちが提供している。
DataLoader
を一言でいうと、Key-Valueストアへの問い合わせを一時的にキャッシュしてくれる汎用ツール、という感じだろうか。
DataLoader
のユーザー(つまり我々アプリの実装者)は、コンストラクタにbatchLoadFn: (keys: K[]) => (V | Error)[]
という関数を渡す。ここでK
とV
はキーと値の型である。この関数は、見ての通り、与えられたキーに対応する値(もしくはエラーオブジェクト)たちを返さなければならない。
これだけを提供しておけば、DataLoader
はload: (key: K) => V|Error
という1つのkeyから1つの値を返すメソッドを提供してくれる。このDataLoaer.load
は、
- 呼び出しのたびにデータ問い合わせを行うのではなく、ある範囲の期間に発生した問い合わせ全てのキーをまとめて
batchLoadFn
に渡す - 返って来た値をキャッシュしておく
という気の利いた動作をする。これによって、DBへの問い合わせが最小化されるという仕組みになっている。
DataLoader
はKey-Valueストアへの問い合わせをキャッシュする、と便宜上説明したが、本質はキーのリストからオブジェクトのリストを得ることができるデータソースであれば何でも良いので、当然RDBも対象になる(典型的にはselect ... from ... where id in (...)
というクエリを発行することになるだろう)。