背景
GraphQLでよく挙がるメリットとして以下があります。
- RESTful APIと違って都度UIに依存したAPI設計をする必要がない
- マルチデバイス対応サービスにおいて大きなメリット
- オーバーフェッチを避けることができる
- アンダーフェッチによるAPIコールの増大を防げる
一方であまり触れられない大きなメリットとして
- ビジネスロジックを集約できる
- クライアントからのクエリによってバックエンド処理を減らせる
という点があります。今回はこれについて説明します。
環境
グラフ構造で考えた場合のメリット
スキーマ定義をグラフのモデルと考えると、そこにおけるオブジェクト(データ)の探索・処理がグラフ理論に基づいていて効率的になります。
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 }, }); }, }, };
その後仕様変更が入り、User
にisAdult
みたいなフラグを追加するとします。
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のメリットを活かしたコードの場合は次のようになります。
ポイントはresolvers
にUser.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; }, }, };
- Userデータの取得
- UserIdの抽出
- UserIdからPostデータの取得
- データの結合
というロジックです。
よくありそうな実装ですが、次のような課題があります。
- クエリでPostデータが不要でもDBアクセスしてしまう
- ロジックを自分で管理しなくてはいけない
- 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も入れているので若干読みにくいですが、本質はresolvers
にUser.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
その他
サンプルコード
今回のサンプルコードはこちら
クエリログを出すには
こちらで解説しています。
まとめ
オブジェクト指向言語であったとしても書き方によってはオブジェクト指向でないことがあるように、GraphQLを使っていたとしてもそのメリットを享受できる書き方になっているかをちゃんと理解しておく必要がある、という話でした。