エクサウィザーズ Engineer Blog

株式会社エクサウィザーズのエンジニアチームブログ

PreloadResolverという仕組みを作ってGraphQLのN+1問題に対応した話

エクサウィザーズでハナスト開発チームのTLをしている原です。

ハナストは「音声入力で介護の記録をするアプリ」で、こちらのページでプロダクトの紹介をしています。

hanasuto.carewiz.ai

以前は、ハナストAPIのテストについてこちらの記事で書きました。

techblog.exawizards.com

今回の記事ではハナストの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技術を使った社会課題の解決などに少しでも興味がありましたら、ハナストチームおよびエクサウィザーズに是非ご応募ください。

hrmos.co