エクサウィザーズでハナスト開発チームのTLをしている原です。
ハナストは「音声入力で介護の記録をするアプリ」で、こちらのページでプロダクトの紹介をしています。
以前は、ハナストAPIのテストについてこちらの記事で書きました。
今回の記事ではハナストのAPIで実践している、PrelaodResolverというGraphQLのN+1問題対応の仕組みを紹介します。
GraphQLのN+1問題
まず、GraphQLのN+1問題がどのように発生するかを簡単に説明します。
例えば
type Query { books: [Book!]! } type Book { id: ID! author: Author! } type Author { id: ID! name: String! }
というスキーマがあったとします。
ここで、BookResolverが
class BookResolver { async author(book: Book): Promise<Author> { return findAuthorById(book.authorId) } ... }
のような実装になっていたとすると
books { author { name { name } } }
というクエリでデータを取得した時に、Bookの数だけfindAuthorById
が実行されてしまいます。
できればAuthorは
findAllAuthorsByIds(books.map((_) => _.authorId))
のような処理でまとめて取得するべきなのですが、そうではなく取得したBookの数だけfind処理が実行されてしまうのがN+1問題です。
DataLoaderを使う場合
GraphQL のN+1問題の対処にはDataLoaderを使うのが一般的です。
DataLoaderを使うと上記の例だと
const authorLoader = async (books: Book[]): Promise<Author[]> => { const authorIds = books.map(books.map((_) => _.authorId)) const authors = await findAllAuthorsByIds(books) const authorMap = arrayToMap(authors) return books.map((book) => authorMap[book.authorId]) }
というDataLoaderを用意して、こちらをBookResolverに関連付けて、Book#author
が必要な場合はこのDataLoaderを呼んで、その結果からAuthorを取得するようになります。
実際の組み込み方はDataLoaderのライブラリによりますが、大まかな処理はこのようになります。
ハナストGraphQL APIでのN+1問題対策
DataLoaderでも一通りのN+1問題対策はできるのですが、ハナストではPreload Resolverという仕組みを作って、こちらでN+1問題の対策をしています。
PreloadResolverでのN+1問題対策
PreloadResolverを使った仮想コードはこのようになります。
class BookPreloadResolver { async preload( books: Book[], path: string[], info: GraphQLResolveInfo ): { authors: Author[] } { let authors: Author[] = [] // GraphQLスキーマを分析して、Book#authorの取得が必要か判定する if(findFieldInSchema(path, 'author', info)) { authors = await this.preloadAuthor(books) } return { authors } } private async preloadAuthor(books: Book[]): Promise<Author[]> { const authorIds = books.map(books.map((_) => _.authorId)) authors = await findAllAuthorsByIds(authorIds) const authorMap = arrayToMap(authors) books.forEach((book) => { const author = authorMap[book.authorId] book.preloadAuthor(author) }) } ... } class Book { private preloadedAuthor: Author | undefined = undefined preloadAuthor(author: Author): void { this.preloadedAuthor = author } loadAuthor(): Author { if(this.preloadedAuthor) { return this.preloadedAuthor } else { // preloadされていない場合は例外を投げる throw new Error('author is not preloaded.') } } } class BookResolver { author(book: Book): Author { // preload済みのAuthorオブジェクトを取得する return book.loadAuthor() } } class QueryResolver { constructor( private readonly bookPreloadResolver: BookPreloadResolver, ) { } async books(info: GraphQLResolveInfo): Promise<Book[]> { const books = await findAllBooks() await bookPreloadResolver.preload(books, ['books'], info) return books } }
PreloadResolverではDataLoaderとは異なり、BookオブジェクトにAuthorをpreloadしています。
参照側はpreload済みのAuthorオブジェクトを返して、preloadされていない場合は例外を投げるようにしています。
このような仕組みにすることで
- ネストしたN+1問題への対処
- GraphQL field毎のpreload可否の設定
がやりやすくなります。
以下で、それぞれについて詳しく説明します。
PreloadResolverによるネストしたN+1問題への対処
例えば
type Author { location: Location! } type Location { address: String! }
のようにBook#authors
からさらにネストしてAuthor#location
を取得する必要がある場合は、以下のようにPreloadResolverを呼び出します。
class QueryResolver { constructor( private readonly bookPreloadResolver: BookPreloadResolver, private readonly authorPreloadResolver: AuthorPreloadResolver, ) { } async books(info: GraphQLResolveInfo): Promise<Book[]> { const books = await findAllBooks() const { authors } = await bookPreloadResolver.preload(books, ['books'], info) await authorPreloadResolver.preload(authors, ['books', 'author'], info) return books } } class AuthorPreloadResolver { // locationのpreload処理を実装 } class AuthorResolver { // preload済みのlocation取得処理を実装 }
こうすることでBook#authorだけでなく、Author#Locationもpreloadされます。
GraphQL field毎のpreload可否の設定
例えば
type Query { books: [Book!]! authors: [Author!]! } type Book { author: Author! category: Category! } type Author { books: [Book!] } type Category { id: ID! name: String! }
のようなGraphQLスキーマとなっていて、
books { author { name } }
authors { books { category { name } } }
はOKだけど、
authors { books { author { name } } }
のような循環参照のpreloadは禁止したいというケースがあります。
このような場合、まずPreloadResolverをこのように実装します。
type BookPreloadFields = { author?: AuthorPreloadFields category?: CategoryPreloadFields } type AuthorPreloadFields = { books?: BookPreloadFields } type CategoryPreloadFields = {} class BookPreloadResolver { async books( path: string[], info: GraphQLResolveInfo, options: { fields: BookPreloadFields } ): Promise<{ authors: Author[], categories: Category[] }> { let authors: Author[] = [] let categories: Category[] = [] // GraphQLスキーマを分析して、authorの取得が必要か判定する if(findFieldInSchema(path, 'author', info)) { // authorの取得が禁止されていたら例外を投げる if(options.fields.author == undefined) { throw new Error(`Book#author preload is forbidden at ${path.join('.')}`) } // 許可されていたらauthorをpreloadする authors = await this.preloadAuthor(books) } // GraphQLスキーマを分析してcategoryの取得が必要か判定する if(findFieldInSchema(path, 'category', info)) { // categoryの取得が禁止されていたら例外を投げる if(options.fields.category == undefined) { throw new Error(`Book#category preload is forbidden at ${path.join('.')}`) } // 許可されていたらcategoryをpreloadする categories = await this.preloadCategory(books) } return { authors, categories } } }
このQueryResolver側では以下のようにPreloadResolverを使います。
class QueryResolver { ... async books(info: GraphQLResolveInfo): Promise<Book[]> { const books = await findAllBooks() await this.bookPreloadResolver.preload( books, ['books'], info, // books { author { * } category { * } } を許可する { fields: { author: {}, category: {} } } ) return books } async authors(info: GraphQLResolveInfo): Promise<Author[]> { const authors = await findAllAuthors() const { books } = await this.authorPreloadResolver.preload(authors, ['authors'], info) await this.bookPreloadResolver.preload( books, ['authors', 'books'], info, { fields: { // authors { books { author { * } } } は禁止する author: undefined, // authors { books { category { * } } } を許可する category: {} } } ) return authors } }
PreloadResolverを使った再帰的なpreload
PreloadResolverを使って再帰的なpreload処理を行うことも可能です。
例えばBookとAuthorを再帰的にpreloadする処理はこのようになります。
class GraphQLPreloadResolver { constructor( private readonly bookPreloadResolver: BookPreloadResolver, private readonly authorPreloadResolver: AuthorPreloadResolver, } { } async preloadBook( books: Book[], path: string[], info: GraphQLResolveInfo, options: { fields: BookPreloadFields } ): Promise<void> { // booksが空の場合は再帰処理を終了 if(books.length == 0) { return } const { authors } = await this.bookPreloadResolver.preload(books, path, info, options) // 再帰的にauthorsのpreload処理を行う await this.preloadAuthor( authors, path.concat(['author']), info, { fields: options.fields.author ?? {} } ) } async preloadAuthor( authors: Author[], path: string[], info: GraphQLResolveInfo, options: { fields: BookPreloadFields } ): Promise<void> { // authorsが空の場合は再帰処理を終了 if(authors.length == 0) { return } const { books } = await this.authorPreloadResolver.preload(authors, path, info, options) // 再帰的にbooksのpreload処理を行う await this.preloadBook( books, path.concat(['author']), info, { fields: options.fields.books ?? {} } ) } }
このGraphQLPreloadResolverを使って
book(id: 1) { author { books { name } } }
books { author { name } }
authors { books { name } }
というpreloadをしようとする場合は、QueryResolverをこのように書きます。
class QueryResolver { constructor( private readonly graphQLPreloadResolver: GraphQLPreloadResolver ) {} async book(args: { id: string }, info: GraphQLResolveInfo): Promise<Book | null> { const book = await findBookById(args.id) if(!book) { return null } await this.graphQLPreloadResolver.preloadBook( [book], ['book'], info, // books { author { books { * } } }を許可する { fields: { author: { books: {} } } } ) return book } async books(_args: {}, info: GraphQLResolveInfo): Promise<Book[]> { const books = await findAllBooks() await this.graphQLPreloadResolver.preloadBook( books, ['books'], info, // books { author { * } }を許可する { fields: { author: {} } } ) return books } async authors(_args: {}, info: GraphQLResolveInfo): Promise<Author[]> { const authors = await findAllAuthors() await this.graphQLPreloadResolver.preloadAuthor( authors, ['authors'], info, // authors { books { * } }を許可する { fields: { books: {} } } ) return authors } }
再帰処理で実装することで、冗長な処理が少なく記述できているかと思います。
PreloadResolverによるN+1問題対応のメリット&デメリット
個人的にはPreloadResolverには以下のようなメリットがあると思っています。
- 処理の流れがわかりやすい
- ネストしたN+1問題に再帰処理で対応できる
- field毎のpreloadの許可・禁止を設定しやすい
一方で、DataLoaderで困らない用途であればDataLoaderを使った方が記述量は少なく済むかと思うので、その辺りは用途に応じて使い分けるのが良いかと思います。
まとめ
今回はハナストのGraphQL APIでのN+1問題への対応としてPreloadResolverというアプローチをご紹介しました。
ハナストチームではGraphQL技術を活用して介護領域での音声AIサービスの開発を行なっており、一緒に働いていただける方を積極的に募集しています。
GraphQL技術を使った社会課題の解決などに少しでも興味がありましたら、ハナストチームおよびエクサウィザーズに是非ご応募ください。