LoginSignup
3
0

More than 1 year has passed since last update.

GraphQLの概念の簡単な説明

Last updated at Posted at 2022-11-15

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.usersUserResolver.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)[]という関数を渡す。ここでKVはキーと値の型である。この関数は、見ての通り、与えられたキーに対応する値(もしくはエラーオブジェクト)たちを返さなければならない。

これだけを提供しておけば、DataLoaderload: (key: K) => V|Errorという1つのkeyから1つの値を返すメソッドを提供してくれる。このDataLoaer.loadは、

  1. 呼び出しのたびにデータ問い合わせを行うのではなく、ある範囲の期間に発生した問い合わせ全てのキーをまとめてbatchLoadFnに渡す
  2. 返って来た値をキャッシュしておく

という気の利いた動作をする。これによって、DBへの問い合わせが最小化されるという仕組みになっている。

DataLoaderはKey-Valueストアへの問い合わせをキャッシュする、と便宜上説明したが、本質はキーのリストからオブジェクトのリストを得ることができるデータソースであれば何でも良いので、当然RDBも対象になる(典型的にはselect ... from ... where id in (...)というクエリを発行することになるだろう)。

3
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
0