2021.10.08
Dataloaderを用いたGraphQLの性能改善
こんにちは。次世代システム研究室のT.M です。
はじめに
GraphQLは、従来のように一つの画面専用のAPIの作成や一つの画面で複数回APIを叩く必要なく、柔軟にデータオブジェクトを取得できます。GraphQLではスキーマを定義し、サーバでスキーマの全てのフィールドに対してリゾルバを用意します。しかし、単純なリゾルバの場合、クライアントからのリクエスト次第ではN+1問題が発生します。その解決策として、Dataloaderがあります。本稿では、Dataloaderの実装方法について解説します。
スキーマ
GraphQLでは、スキーマに従い柔軟なデータオブジェクトを取得することができます。例えば、以下のスキーマが定義されているとします。
type Post { id: ID! title: String! author: User! } type User { id: ID! name: String! } type Query { posts: [Post] }
このとき、クライアントは以下のクエリによりPostのtitle一覧を取得することができます。
query { posts { id title } }
また、Postのフィールドとしてauthorが定義されているので、titleだけではなく、authorのnameも合わせて取得することもできます。
query { posts { id title author { id name } } }
このようにGraphQLではスキーマを定義して柔軟なクエリを作成できるようになっています。
リゾルバ
GraphQLサーバでは、各フィールドにリゾルバを定義する必要があります。以下は、Postのデータがpostテーブルに保存されている場合のnest.jsコードです。
@Resolver(of => Post) export class PostsResolver { constructor( private readonly postsService: PostsService, ) {} @Query(returns => [Post]) posts() { return this.postsService.findAll(); } } @Entity() @ObjectType() export class Post { @PrimaryGeneratedColumn('increment') @Field((type) => ID) id: number; @Column() @Field() title: string; @Column() userId: number; }
Post.titleフィールドのようにDBから取得した値をそのままで良い場合、リゾルバはpostオブジェクトのtitleを取得する関数であれば良い。その場合、nest.jsでは@Fieldをつけるだけで良いです。
Post.authorはuserテーブルを取得する必要があるので、以下のようにリゾルバを定義する必要があります。
export class PostsResolver { ... @ResolveField(returns => User) author(@Parent post: Post) { return this.usersService.findById(post.userId); } }
このように@ResolveFieldを付けた関数を作成することで、リゾルバとなります。
N+1問題
フィールドをリゾルブするごとにリゾルバが実行されるので、postsのデータ数だけPost.authorのリゾルバを実行することになります。つまり、以下のようなSQLが実行されることになります。
SELECT * FROM post; SELECT * FROM user WHERE id = 1; SELECT * FROM user WHERE id = 2; SELECT * FROM user WHERE id = 4; SELECT * FROM user WHERE id = 5; SELECT * FROM user WHERE id = 9; SELECT * FROM user WHERE id = 10; ...
さらにpost.userIdが同じ値であっても二重でSQLが実行されます。
このように単純なリゾルバでは、N+1問題が発生して性能に影響を与えます。
Dataloader
N+1問題を解決する方法として、まとめて取得すれば良いです。つまり、以下のようなSQLが実行されるようになれば良いです。
SELECT * FROM post; SELECT * FROM user WHERE id in (1,2,4,5,9,10);
Dataloaderを使ったリゾルバにより実現することができます。
import DataLoader from 'dataloader'; export abstract class BaseDataloader<K, V> extends Object { protected dataloader: DataLoader<K, V> = new DataLoader<K, V>( this.batchLoad.bind(this), ); public load(key: K): Promise<V> { return this.dataloader.load(key); } protected abstract batchLoad(keys: K[]): Promise<(V | Error)[]>; } @Injectable({ scope: Scope.REQUEST }) export class UsersDataloader extends BaseDataloader<number, User> { constructor( private readonly usersService: UsersService, ) {} protected batchLoad(keys: number[]): Promise<(User | Error)[]> { return this.usersService.findByIds(keys); } } export class UsersService { constructor( @InjectRepository(User) userRepository: Repository, ) {} async findByIds(keys: number[]) { const users = await this.userRepository.findByIds(keys); return keys.map(k => users.find(u => u.id === k) || new Error('Not Found')); } } export class PostsResolver { constructor( ... private readonly usersDataloader: UsersDataloader, ) {} ... @ResolveField(returns => [User]) author(@Parent() post: Post) { return this.usersDataloader.load(post.userId); } }
Dataloaderはbatchloadを実装し、リゾルバでloadを呼び出します。batchloadの実装では、引数のIDの順番で結果を返さないといけません。そのため、findByIdsでデータを取得した後、mapを利用して引数の順番に並べ替えています。
また、リクエストごとにまとめて実行をしたいので、@Injectable({ scope: Scope.REQUEST })をつけて、リクエストごとにインスタンスを作成しています。
今回はhas one の関係にあるデータを取得していますが、DataloaderのloadManyなどを実装することでhas many の関係にあるデータを取得することができるようになります。
まとめ
Graphqlは柔軟にクエリを書けるため、従来のAPIで起こっていた、画面ごとにAPIを作成したり、一つの画面を描画するために何度もAPIを叩く、という問題を解決しました。また、サーバの実装に関しても、スキーマを定義して、各フィールドのリゾルバを実装すれば良いだけなので、とても開発を効率よく進めることができるようになりました。
GraphQLのサーバを実装しているときに最初に当たる性能の問題として、N+1問題があります。Dataloaderを利用することでこの問題を解決することができました。
次世代システム研究室では、グループ全体のインテグレーションを支援してくれるアーキテクトを募集しています。インフラ設計、構築経験者の方、次世代システム研究室にご興味を持って頂ける方がいらっしゃいましたら、ぜひ募集職種一覧からご応募をお願いします。
皆さんのご応募をお待ちしています。
参考
@nestjs/graphql でdataloaderを使う
グループ研究開発本部の最新情報をTwitterで配信中です。ぜひフォローください。
Follow @GMO_RD