Carpe Diem

備忘録

GraphQLのメリット

背景

GraphQLでよく挙がるメリットとして以下があります。

  • RESTful APIと違って都度UIに依存したAPI設計をする必要がない
    • マルチデバイス対応サービスにおいて大きなメリット
  • オーバーフェッチを避けることができる
    • Switchなどデバイス制約が多いクライアントにとっても適したAPIとなる
  • アンダーフェッチによるAPIコールの増大を防げる

一方であまり触れられない大きなメリットとして

  • ビジネスロジックを集約できる
  • クライアントからのクエリによってバックエンド処理を減らせる

という点があります。今回はこれについて説明します。

環境

  • TypeScript 5.2.2
  • Node.js 20.8.10
  • GraphQL 16.8.1
  • Apollo-Server 4.9.5
  • Prisma 5.5.2

グラフ構造で考えた場合のメリット

スキーマ定義をグラフのモデルと考えると、そこにおけるオブジェクト(データ)の探索・処理がグラフ理論に基づいていて効率的になります。 RESTful APIの場合、そのデータの探索・処理が実装する人に依存したコードとなるため、探索効率が適切かどうかはレビューするまで分かりません
一方でGraphQLではGraphQLのやり方で書けばデータ探索・処理が自動的に適切なものとなります。
(自動的であるがゆえN+1問題が起きてしまうようなデメリットももちろん有ります)

ではどうすればGraphQLらしい書き方になるか、というと

  • ゾルバをQueryノードだけでなく、データモデルのノードにも割り当てること

になります。

具体的な実装

それでは具体的な実装について例を挙げてみます。

ビジネスロジックの集約

オブジェクト指向の考えに近く、ロジックをノードに実装しようという話です。

例えば次のようなスキーマがあり、

  type User {
    id: Int
    name: String
    email: String
    age: Int
  }
  type Query {
    users: [User]
    userById(id: Int): User
    userByEmail(email: String): User
  }

実装は次のようになっています。

const resolvers = {
  Query: {
    users: async () => {
      return await prisma.user.findMany();
    },
    userById: async (_: any, args: any) => {
      return await prisma.user.findUnique({
        where: { id: args.id },
      });
    },
    userByEmail: async (_: any, args: any) => {
      return await prisma.user.findUnique({
        where: { email: args.email },
      });
    },
  },
};

その後仕様変更が入り、UserisAdultみたいなフラグを追加するとします。

  type User {
    id: Int
    name: String
    email: String
    age: Int
    isAdult: Boolean
  }
  type Query {
    users: [User]
    userById(id: Int): User
    userByEmail(email: String): User
  }

GraphQLライクではないコードの場合

GraphQLのメリットを活かさないコードの場合は次のようになります。

const isAdult = (age: number) => {
  return age >= 18
};

const resolvers = {
  Query: {
    users: async () => {
      const user = await prisma.user.findMany();
      return user.map((u) => {
        return {
          ...u,
          isAdult: isAdult(u.age),
        };
      });
    },
    userById: async (_: any, args: any) => {
      const user = await prisma.user.findUnique({
        where: { id: args.id },
      });
      if (!user) {
        throw new Error("User not found");
      }
      return {
        ...user,
        isAdult: isAdult(user.age),
      };
    },
    userByEmail: async (_: any, args: any) => {
      const user = await prisma.user.findUnique({
        where: { email: args.email },
      });
      if (!user) {
        throw new Error("User not found");
      }
      return {
        ...user,
        isAdult: isAdult(user.age),
      };
    },
  },
};

このように全ての影響する関数を洗い出し、修正が必要となります。
またisAdultフィールドが不要な場合であっても処理が行われるため、コンピューティングリソースが消費されます。

GraphQLの場合

GraphQLのメリットを活かしたコードの場合は次のようになります。
ポイントはresolversUser.isAdultノードの処理を追加したことです。

const resolvers = {
  User: {
    isAdult: (parent: any) => {
      return parent.age >= 18;
    },
  },
  Query: {
    users: async () => {
      return await prisma.user.findMany();
    },
    userById: async (_: any, args: any) => {
      return await prisma.user.findUnique({
        where: { id: args.id },
      });
    },
    userByEmail: async (_: any, args: any) => {
      return await prisma.user.findUnique({
        where: { email: args.email },
      });
    },
  },
};

既存のコードはいじらず、かつisAdultフィールドが不要なときは実行されないのでコンピューティングリソース消費しません。

DBアクセスの削減

DBアクセスにも同じことが言えます。

Schema

  type Post {
    title: String
    content: String
  }
  type User {
    id: Int
    name: String
    email: String
    posts: [Post]
  }
  type Query {
    users: [User]
  }

このようなスキーマ

query ExampleQuery {
  users {
    id
    name
  }
}

のようにPostsを含まずUserだけ取得するケースの場合に、ちゃんと書かないとPostsまでクエリが流れてしまいます。

GraphQLライクではないコードの場合

GraphQLのメリットを活かさないコードの場合は次のようになります。

const resolvers = {
  Query: {
    users: async () => {
      const users = await prisma.user.findMany();
      const userIds = users.map((user) => user.id);
      const posts = await prisma.post.findMany({
        where: { authorId: { in: userIds } },
      });

      const usersData = users.map((user) => {
        const userPosts = posts.filter((post) => post.authorId == user.id);

        return {
          id: user.id,
          name: user.name,
          email: user.email,
          posts: userPosts.map((post) => {
            return { title: post.title, content: post.content };
          }),
        };
      });

      return usersData;
    },
  },
};
  1. Userデータの取得
  2. UserIdの抽出
  3. UserIdからPostデータの取得
  4. データの結合

というロジックです。
よくありそうな実装ですが、次のような課題があります。

  1. クエリでPostデータが不要でもDBアクセスしてしまう
  2. ロジックを自分で管理しなくてはいけない
    1. Postフィールドが増えたときに追加修正が必要

クエリ結果

Prepare   SELECT `mydb`.`User`.`id`, `mydb`.`User`.`email`, `mydb`.`User`.`name` FROM `mydb`.`User` WHERE 1=1
Execute   SELECT `mydb`.`User`.`id`, `mydb`.`User`.`email`, `mydb`.`User`.`name` FROM `mydb`.`User` WHERE 1=1
Prepare   SELECT `mydb`.`Post`.`id`, `mydb`.`Post`.`title`, `mydb`.`Post`.`content`, `mydb`.`Post`.`published`, `mydb`.`Post`.`authorId` FROM `mydb`.`Post` WHERE `mydb`.`Post`.`authorId` IN (?,?,?)
Execute   SELECT `mydb`.`Post`.`id`, `mydb`.`Post`.`title`, `mydb`.`Post`.`content`, `mydb`.`Post`.`published`, `mydb`.`Post`.`authorId` FROM `mydb`.`Post` WHERE `mydb`.`Post`.`authorId` IN (1,2,3)

Prismaのincludeを使う場合

Prismaだと次のように書くことでシンプルにできますが、この書き方でもクエリの発行は防げていません。

const resolvers = {
  Query: {
    users: async () => {
      return await prisma.user.findMany({
        include: {
          posts: true,
        },
      });
    },
  },
};

クエリ結果

Prepare   SELECT `mydb`.`User`.`id`, `mydb`.`User`.`email`, `mydb`.`User`.`name` FROM `mydb`.`User` WHERE 1=1
Execute   SELECT `mydb`.`User`.`id`, `mydb`.`User`.`email`, `mydb`.`User`.`name` FROM `mydb`.`User` WHERE 1=1
Prepare   SELECT `mydb`.`Post`.`id`, `mydb`.`Post`.`title`, `mydb`.`Post`.`content`, `mydb`.`Post`.`published`, `mydb`.`Post`.`authorId` FROM `mydb`.`Post` WHERE `mydb`.`Post`.`authorId` IN (?,?,?)
Execute   SELECT `mydb`.`Post`.`id`, `mydb`.`Post`.`title`, `mydb`.`Post`.`content`, `mydb`.`Post`.`published`, `mydb`.`Post`.`authorId` FROM `mydb`.`Post` WHERE `mydb`.`Post`.`authorId` IN (1,2,3)

GraphQLの場合

GraphQLのメリットを活かしたコードの場合は次のようになります。

type BatchPost = (userIds: readonly number[]) => Promise<Post[][]>;

// DataLoader function to batch and cache post requests
const batchPosts: BatchPost = async (userIds) => {
  const posts = await prisma.post.findMany({
    where: {
      authorId: { in: [...userIds] },
    },
  });
  const postsByUserId = userIds.map((userId: number) =>
    posts.filter((post) => post.authorId === userId)
  );
  return postsByUserId;
};

// Create a DataLoader instance for posts
const postsLoader = new DataLoader<number, Post[]>(batchPosts);

const resolvers = {
  User: {
    posts: async (parent: any) => {
      return await postsLoader.load(parent.id);
    },
  },
  Query: {
    users: async () => {
      return await prisma.user.findMany({});
    },
  },
};

Dataloaderも入れているので若干読みにくいですが、本質はresolversUser.postsノードを入れているところです。
これによってGraphQLクエリが含まれない場合に、実装は意識せずともGraphQLが自動的にresolverの実行を制御しDBクエリを流さなくなります。

クエリ結果

Prepare   SELECT `mydb`.`User`.`id`, `mydb`.`User`.`email`, `mydb`.`User`.`name` FROM `mydb`.`User` WHERE 1=1
Execute   SELECT `mydb`.`User`.`id`, `mydb`.`User`.`email`, `mydb`.`User`.`name` FROM `mydb`.`User` WHERE 1=1

その他

サンプルコード

今回のサンプルコードはこちら

github.com

クエリログを出すには

こちらで解説しています。

christina04.hatenablog.com

まとめ

オブジェクト指向言語であったとしても書き方によってはオブジェクト指向でないことがあるように、GraphQLを使っていたとしてもそのメリットを享受できる書き方になっているかをちゃんと理解しておく必要がある、という話でした。

参考