LoginSignup
5
4

GraphQLにおけるN+1問題の解決策

Last updated at Posted at 2024-06-05

バックエンドにあるREST APIからデータを取得して、GraphQLのスキーマに載せて返すようなBFF(Backend for Frontend)を設計しています。
その過程で、GraphQLでN+1問題を回避するにはどうしたらいいか?という課題に直面したので、今回はその解決策をまとめてみます。

N+1問題とは何か

まず、N+1問題とは何かという話から始めます。
例えば、データベース上にAuthor(著者)テーブルとBook(本)テーブルが存在し、関連は次のようであるとします。

image.png

今回は「著者の一覧を、各著者に紐づく本の情報とともに取得できるようにしてほしい」というクライアントからの要件を受けて、次のようなGraphQLのスキーマを定義しました。

schema.gql
type Query {
  """著者一覧取得"""
  authors: [Author!]!
}

type Author {
  books: [Book!]!

  """著者ID"""
  id: Int!

  """著者名"""
  name: String!
}

type Book {
  """著者ID"""
  authorId: Int!

  """ISBN"""
  id: String!

  """タイトル"""
  title: String!
}

図で描くとこんな感じです。

image.png

そして、Queryノード内で"authors"ノードが要求されたときに動くリゾルバと、Authorノード内で"books"ノードが要求されたときに動くリゾルバを、かくかくしかじかと定義しました。(具体的な実装はこちら

さて、一通りのプログラムが完成したので、いよいよ動作確認です。GraphQLのエンドポイント(/graphql)に向けて、次のようなQueryを投げてみます。

query.gql
  query {
           authors {
            id
            name
            books {
              id
              title
            }
          }
        }

すると、次のようにばっちりデータを取得することができました。めでたしめでたし。

authors.json
{
  "data": {
    "authors": [
      {
        "id": 1,
        "name": "Samuel Beckett",
        "books": [
          {
            "id": "978-0802144423",
            "title": "Waiting for Godot"
          },
          {
            "id": "978-0571244577",
            "title": "Happy Days"
          }
        ]
      },
      {
        "id": 2,
        "name": "Anton Pavlovich Chekhov",
        "books": [
          {
            "id": "978-1559365512",
            "title": "The Cherry Orchard"
          }
        ]
      },
      {
        "id": 3,
        "name": "William Shakespeare",
        "books": []
      }
    ]
  }
}

と言いたいところなのですが、よくよくログを確認してみると、

GET http://localhost:8080/authors (12ms)
GET http://localhost:8080/authors/2/books (6ms)
GET http://localhost:8080/authors/3/books (8ms)
GET http://localhost:8080/authors/1/books (9ms)

このように、処理の過程で4回のデータ取得(/authorsに1回、/authors/{authorId}/booksに3回)が発生していることが分かりました。
今回は/authorsの返却値が3件だったので、たかだか3+1=4回のHTTPリクエストで済みました。しかし、/authorsの返却値が1,000件だったら?10,000件だったら?

このように、親ノードを取得するために1回のデータ取得が行われた後、紐づく子ノードを取得するためにN回のデータ取得を行ってしまうことをN+1問題といいます。

この記事では、パフォーマンスに大きな影響が出てしまうN+1問題を、GraphQLで回避するための解決策をご紹介します。

解決策1: JOIN FETCHする

REST APIの設計に慣れている人にとって、最初に思いつく解決策はSQLを発行する時点でJOIN FETCHすることだと思います。

今回はバックエンドにあるREST APIにHTTPリクエストを送信する構成になっていますが、SQLで考えると、

SELECT * FROM Author;
SELECT * FROM Book WHERE author_id = 2;
SELECT * FROM Book WHERE author_id = 3;
SELECT * FROM Book WHERE author_id = 1;

のようなクエリが発行されている状態です。

これを回避するために、

SELECT * FROM Author a LEFT JOIN Book b ON a.id = b.author_id;

というSQLを発行するようなREST APIのエンドポイント(/authorsWithBooks)を作成し、BFF(GraphQL)からはこのエンドポイントを呼び出すようにします。

具体的な実装は次のようになります。

まず、REST APIを呼び出す処理をまとめたクラスに、/authorsWithBooksを呼び出すための関数を追加します。

author.service.ts
  public async findAllWithBooks(): Promise<Array<Author>> {
    const data = await this.get(`http://localhost:8080/authorsWithBooks`);
    console.log('Response from Backend API: ' + JSON.stringify(data));
    return data;
  }

次に、"authors"というQueryが要求されたときに動くリゾルバで、この関数が呼ばれるように修正します。

authors.resolver.ts
  @Query(() => [Author], { name: 'authors', description: '著者一覧取得' })
  public async getAuthors(): Promise<Author[]> {
    console.log('-------------getAuthors START-------------');

    // const authors = await this.authorService.findAll();
    const authors = await this.authorService.findAllWithBooks();

    console.log('-------------getAuthors END-------------');
    return authors.map((author) => new Author(author));
  }

最後に、"books"ノードが要求されたときに、/authors/{authorId}/booksに対してリクエストをしないように、Authorノード内のリゾルバを削除します。

author.resolver.ts
@Resolver(() => Author)
export class AuthorResolver {
  // constructor(private bookService: BookService) {}
  // @ResolveField('books', () => [Book])
  // public async getBooks(@Parent() author: Author): Promise<Book[]> {
  //   console.log('-------------getBooks START-------------');
  //   const books = await this.bookService.findByAuthorId(author.id);
  //   console.log('-------------getBooks END-------------');
  //   return books.map((book) => new Book(book));
  // }
}

これでもう一度同じQueryを実行してみます。すると、今度も同じ結果を得ることができるはずです。

Query
query.gql
  query {
           authors {
            id
            name
            books {
              id
              title
            }
          }
        }
レスポンス結果
authors.json
{
  "data": {
    "authors": [
      {
        "id": 1,
        "name": "Samuel Beckett",
        "books": [
          {
            "id": "978-0802144423",
            "title": "Waiting for Godot"
          },
          {
            "id": "978-0571244577",
            "title": "Happy Days"
          }
        ]
      },
      {
        "id": 2,
        "name": "Anton Pavlovich Chekhov",
        "books": [
          {
            "id": "978-1559365512",
            "title": "The Cherry Orchard"
          }
        ]
      },
      {
        "id": 3,
        "name": "William Shakespeare",
        "books": []
      }
    ]
  }
}

では、肝心のログを見てみましょう。

GET http://localhost:8080/authorsWithBooks (7ms)

はい、今度は1回のHTTPリクエストで処理が完結しています。

info.fieldNodesの利用

しかし、このコーディングには1つの問題点があります。

例えば、Queryの内容が次のように、Authorに直接紐づくノードのみを要求し、booksノードを要求しなかった場合、処理はどのようになるでしょうか。

query.gql
  query {
           authors {
            id
            name
          }
        }

答えはもちろん、"authors"に紐づくリゾルバが動いた結果、authorService.findAllWithBooksが呼ばれるため、

GET http://localhost:8080/authorsWithBooks (6ms)

このエンドポイントにHTTPリクエストを送信することになります。

しかしこれでは、バックエンドのREST API側で本来やる必要のないJOINが発生していることになり、パフォーマンスを低下させてしまいます。

この問題を解消するためには、リクエストされたQuery内で具体的にどのノードが要求されたかを見て、どのバックエンドAPIを呼び出すべきかを判断する必要があります。

各リゾルバ内で、リクエストされたQueryのツリー構造を知るためには、GraphQLのinfoオブジェクト 1を利用します。

authors.resolver.ts
import { Info, Query, Resolver } from '@nestjs/graphql';
import { Author } from './author.model';
import { AuthorService } from './author.service';

@Resolver(() => [Author])
export class AuthorsResolver {
  constructor(private authorService: AuthorService) {}

  @Query(() => [Author], { name: 'authors', description: '著者一覧取得' })
  public async getAuthors(@Info() info: any): Promise<Author[]> {
    console.log('-------------getAuthors START-------------');

    if (this.doesPathExist(info, 'books')) {
      const authors = await this.authorService.findAllWithBooks();

      console.log('-------------getAuthors END-------------');
      return authors.map((author) => new Author(author));
    } else {
      const authors = await this.authorService.findAll();

      console.log('-------------getAuthors END-------------');
      return authors.map((author) => new Author(author));
    }
  }

  /**
   *
   * @param info GraphQLのinfoオブジェクト
   * @param path 判定したいノード
   * @returns Queryにpathが含まれる場合はtrue, それ以外はfalse
   */
  private doesPathExist(info: any, path: string): boolean {
    for (const selection of info.fieldNodes[0].selectionSet.selections) {
      if (selection.name.value == path) {
        return true;
      }
    }
    return false;
  }
}

こうすることで、Query内に"books"が要求された場合は

GET http://localhost:8080/authorsWithBooks (44ms)

のエンドポイントが呼ばれ、要求されなかった場合は

GET http://localhost:8080/authors (7ms)

のエンドポイントが呼ばれることになるため、無駄なJOINが発生する問題は解消することができました。

解決策2: 幅優先(breadth-first)でフェッチする

上記の解決策も悪くはないものの、「1つのノードに1つの対応するリゾルバを割り当てる」というGraphQLの基本的な考え方からは外れてしまっています。つまり、「複数のノードに関するロジックを1つのリゾルバ内で実行している」点が、あまりGraphQLらしくないと言えます。2

では、他にどのような解決のアプローチがあるのでしょうか。
もう一度、N+1問題が発生する原因をグラフを用いて考えます。

image.png

上図のように、"authors"に紐づくN件の"Author"を取得し、さらに"Author"の子ノードである"books"を取得し......という具合に、深さ優先(depth-first)の考え方で処理をしていくと、どうしても親ノードの数だけクエリが発生してしまいます。

しかし、とある賢人は考えました。
幅優先(breadth-first)でレイヤーごとにデータ取得をするようにすれば、「クエリの数=ネストの数」に抑えることができるのではないか?(つまり、N+1問題を1+1+......にできる!)

image.png

図にするとこんな感じです。ポイントは、今まで

SELECT * FROM Book WHERE author_id = :authorId;

だったクエリが、

SELECT * FROM Book WHERE author_id IN (:authorIds);

とWHERE IN句を用いたSQLに変わっているところです。

つまり、全てのAuthorのauthorId(=BookのPK)が出揃ったタイミングで、バッチ的にbooksを一括取得するという考え方です。

そして、これを可能にするのが、Facebookのエンジニアが開発したDataloaderというライブラリです。
https://github.com/graphql/dataloader

Dataloaderを用いた実装

まず、バックエンドのREST APIに

SELECT * FROM Book WHERE author_id IN (:authorIds);

に対応するエンドポイント(/books/findByAuthorIds)を作成します。

そして、REST APIを呼び出す処理をまとめたクラスに、/books/findByAuthorIdsを呼び出すための関数を追加します。

book.service.ts
  public async findByAuthorIds(authorIds: string[]): Promise<Array<Book>> {
    const idsString = authorIds.join(',');
    const data = await this.get(`http://localhost:8080/books/findByAuthorIds`, {
      params: {
        authorIds: idsString,
      },
    });
    console.log('Response from Backend API: ' + JSON.stringify(data));
    return data;
  }

ここで、AuthorResolverから直接上記のfindByAuthorIds()を呼び出すのではなく、間にDataloaderを噛ませます。

base-dataloader.ts
base-dataloader.ts
import * as 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 clear(key: K): DataLoader<K, V> {
    return this.dataloader.clear(key);
  }

  public clearAll(): DataLoader<K, V> {
    return this.dataloader.clearAll();
  }

  public async load(key: K): Promise<V> {
    return this.dataloader.load(key);
  }

  public async loadMany(keys: K[]): Promise<(V | Error)[]> {
    return this.dataloader.loadMany(keys);
  }

  public prime(key: K, value: V): DataLoader<K, V> {
    return this.dataloader.prime(key, value);
  }

  protected abstract batchLoad(keys: K[]): Promise<(V | Error)[]>;
}

book.dataloader.ts
import { BaseDataloader } from '@/graphql/base-dataloader';
import { Injectable, Scope } from '@nestjs/common';
import { Book } from './book.backend.model';
import { BookService } from './book.service';

@Injectable({ scope: Scope.REQUEST })
export class BookDataloader extends BaseDataloader<string, Book[]> {
  constructor(private readonly bookService: BookService) {
    super();
  }

  protected async batchLoad(keys: string[]): Promise<Array<Book[]>> {
    console.log('-------------BookDataloader batchLoad-------------');

    const books = await this.bookService.findByAuthorIds(keys);

    // authorId単位でbooksを仕分ける
    const groupedByAuthorId = this.groupByAuthorId(keys, books);

    return keys.map((key) => groupedByAuthorId[key]);
  }

  /**
   *
   * @param keys 親ノードAuthorのauthorIdの配列
   * @param books バックエンドAPIから取得したBookの配列
   * @returns key: authorId, value: Book[]のMap
   */
  private groupByAuthorId(
    keys: string[],
    books: Book[],
  ): Record<string, Book[]> {
    return Object.fromEntries(
      keys.map((key) => [
        key,
        books.filter((book) => key === book.authorId.toString()),
      ]),
    );
  }
}

AuthorResolverからは、Dataloaderのloadという関数を呼び出します。

author.resolver.ts
import { Parent, ResolveField, Resolver } from '@nestjs/graphql';
import { Author } from './author.model';
import { Book } from '../book/book.model';
import { BookDataloader } from '../book/book.dataloader';

@Resolver(() => Author)
export class AuthorResolver {
  constructor(private bookDataloader: BookDataloader) {}

  @ResolveField('books', () => [Book])
  public async getBooks(@Parent() author: Author): Promise<Book[]> {
    console.log('-------------getBooks START-------------');
    console.log('------> load');
    const books = await this.bookDataloader.load(author.id.toString());
    console.log('-------------getBooks END-------------');
    return books.map((book) => new Book(book));
  }
}

Queryが実行されたときの流れを簡単に記述すると、

Query
query.gql
  query {
           authors {
            id
            name
            books {
              id
              title
            }
          }
        }

①Queryノードに"authors"ノードが含まれるので、対応するAuthorsResolverのgetAuthors関数が実行される(ココまで同じ)
②さらにAuthorノード内に"books"ノードが含まれるので、対応するgetBooks関数が実行され、dataloaderにauthor.idがloadされる
③親ノードであるAuthorのauthor.idが全てloadされると、BookDataloaderのbatchLoad関数が実行され、ここで初めて /books/findByAuthorIdsをコールする

という流れになります。

ちなみに、このときの実行時ログは次のようになります。

-------------getAuthors START-------------
GET http://localhost:8080/authors (16ms)
Response from Backend API: [{"id":1,"name":"Samuel Beckett","books":null},{"id":2,"name":"Anton Pavlovich Chekhov","books":null},{"id":3,"name":"William Shakespeare","books":null}]
-------------getAuthors END-------------
-------------getBooks START-------------
------> load
-------------getBooks START-------------
------> load
-------------getBooks START-------------
------> load
-------------BookDataloader batchLoad-------------
GET http://localhost:8080/books/findByAuthorIds?authorIds=2%2C3%2C1 (7ms)
Response from Backend API: [{"id":"978-0802144423","title":"Waiting for Godot","authorId":1},{"id":"978-0571244577","title":"Happy Days","authorId":1},{"id":"978-1559365512","title":"The Cherry Orchard","authorId":2}]
-------------getBooks END-------------
-------------getBooks END-------------
-------------getBooks END-------------

想定通り、HTTPリクエストが2回だけ実行されています。

また、Queryに"books"ノードが含まれない場合は、①の処理のみで終了するため、

-------------getAuthors START-------------
GET http://localhost:8080/authors (11ms)
Response from Backend API: [{"id":1,"name":"Samuel Beckett","books":null},{"id":2,"name":"Anton Pavlovich Chekhov","books":null},{"id":3,"name":"William Shakespeare","books":null}]
-------------getAuthors END-------------

このように/authorsのみが呼ばれます。

補足)親ノード:子ノードが1:多の場合

DataloaderのbatchLoad関数では、以下の制約を守る必要があります。3

  • 値の配列はキーの配列と同じ長さでなければならない
  • 値の配列の各インデックスは、キーの配列の同じインデックスに対応していなければならない

今回のケースで言うと、

image.png

authorId=1には2冊の本が紐づき、authorId=2には1冊の本、authorId=3には0冊の本が紐づいているわけですが、このマッピングをDataloaderに教えてあげる必要があります。

この処理が、上述のgroupByAuthorId関数にあたります。

book.dataloader.ts
  /**
   *
   * @param keys 親ノードAuthorのauthorIdの配列
   * @param books バックエンドAPIから取得したBookの配列
   * @returns key: authorId, value: Book[]のMap
   */
  private groupByAuthorId(
    keys: string[],
    books: Book[],
  ): Record<string, Book[]> {
    return Object.fromEntries(
      keys.map((key) => [
        key,
        books.filter((book) => key === book.authorId.toString()),
      ]),
    );
  }
}

結論

今回はGraphQLにおけるN+1問題の解決策として、

  • 解決策1: JOIN FETCHする
  • 解決策2: 幅優先(breadth-first)でフェッチする

という2つの方法を紹介しました。
特に後者のDataloaderを用いる方法は、「1つのノードに1つの対応するリゾルバを割り当てる」という設計指針にうまく落とし込めるものであり、より使いやすいように感じます。

ただし、あくまでパフォーマンスを追求する意味では、前者を採用する方がよりよいケースも存在します。具体的には、「業務上ほとんどの場合において、ネストされたオブジェクトも一緒に取得する」といったケースです。この場合は、1+1+......というクエリが発生するDataloaderを使うよりも、JOIN FETCHするようなクエリを1回だけ発行する方がより合理的である可能性もあります。

以上、「GraphQLにおけるN+1問題の解決策」でした。

具体的な実装例を下記にまとめたので、実際にコードを書いて確かめたい方はご参照ください。

実装例①:N+1問題が発生するプログラム

author.module.ts
author.module.ts
import { Module } from '@nestjs/common';
import { AuthorsResolver } from './authors.resolver';
import { AuthorResolver } from './author.resolver';
import { AuthorService } from './author.service';
import { BookModule } from '../book/book.module';
import { BookService } from '../book/book.service';

@Module({
  imports: [BookModule],
  providers: [AuthorsResolver, AuthorResolver, AuthorService, BookService],
})
export class AuthorModule {}
authors.resolver.ts
authors.resolver.ts
import { Query, Resolver } from '@nestjs/graphql';
import { Author } from './author.model';
import { AuthorService } from './author.service';

@Resolver(() => [Author])
export class AuthorsResolver {
  constructor(private authorService: AuthorService) {}

  @Query(() => [Author], { name: 'authors', description: '著者一覧取得' })
  public async getAuthors(): Promise<Author[]> {
    console.log('-------------getAuthors START-------------');

    const authors = await this.authorService.findAll();

    console.log('-------------getAuthors END-------------');
    return authors.map((author) => new Author(author));
  }
}
author.service.ts
author.service.ts
import { Injectable } from '@nestjs/common';
import { RESTDataSource } from '@apollo/datasource-rest';
import { Author } from './author.backend.model';

@Injectable()
export class AuthorService extends RESTDataSource {
  constructor() {
    super();
  }

  public async findAll(): Promise<Array<Author>> {
    const data = await this.get(`http://localhost:8080/authors`);
    console.log('Response from Backend API: ' + JSON.stringify(data));
    return data;
  }
}
author.resolver.ts
author.resolver.ts
import { Parent, ResolveField, Resolver } from '@nestjs/graphql';
import { Author } from './author.model';
import { Book } from '../book/book.model';
import { BookService } from '../book/book.service';

@Resolver(() => Author)
export class AuthorResolver {
  constructor(private bookService: BookService) {}

  @ResolveField('books', () => [Book])
  public async getBooks(@Parent() author: Author): Promise<Book[]> {
    console.log('-------------getBooks START-------------');

    const books = await this.bookService.findByAuthorId(author.id);

    console.log('-------------getBooks END-------------');
    return books.map((book) => new Book(book));
  }
}
book.service.ts
book.service.ts
import { Injectable } from '@nestjs/common';
import { RESTDataSource } from '@apollo/datasource-rest';
import { Book } from './book.backend.model';

@Injectable()
export class BookService extends RESTDataSource {
  constructor() {
    super();
  }

  public async findByAuthorId(authorId: number): Promise<Array<Book>> {
    const data = await this.get(
      `http://localhost:8080/authors/${authorId}/books`,
    );
    console.log('Response from Backend API: ' + JSON.stringify(data));
    return data;
  }
}
author.model.ts
author.model.ts
import { Field, Int, ObjectType } from '@nestjs/graphql';
import { Author as AuthorFromBackend } from './author.backend.model';

@ObjectType()
export class Author {
  @Field(() => Int, { description: '著者ID' })
  id!: number;

  @Field(() => String, { description: '著者名' })
  name!: string;

  constructor(author: AuthorFromBackend) {
    this.id = author.id;
    this.name = author.name;
  }
}
author.backend.model.ts
author.backend.model.ts
export class Author {
  id!: number;
  name!: string;
}
book.model.ts
book.model.ts
import { Field, Int, ObjectType } from '@nestjs/graphql';
import { Book as BookFromBackend } from './book.backend.model';

@ObjectType()
export class Book {
  @Field(() => String, { description: 'ISBN' })
  id!: string;

  @Field(() => String, { description: 'タイトル' })
  title!: string;

  @Field(() => Int, { description: '著者ID' })
  authorId!: number;

  constructor(book: BookFromBackend) {
    this.id = book.id;
    this.title = book.title;
    this.authorId = book.authorId;
  }
}
book.backend.model.ts
book.backend.model.ts
export class Book {
  id!: string;
  title!: string;
  authorId!: number;
}

実装例②:JOIN FETCHするプログラム

authors.resolver.ts
authors.resolver.ts
import { Info, Query, Resolver } from '@nestjs/graphql';
import { Author } from './author.model';
import { AuthorService } from './author.service';

@Resolver(() => [Author])
export class AuthorsResolver {
  constructor(private authorService: AuthorService) {}

  @Query(() => [Author], { name: 'authors', description: '著者一覧取得' })
  public async getAuthors(@Info() info: any): Promise<Author[]> {
    console.log('-------------getAuthors START-------------');

    if (this.doesPathExist(info, 'books')) {
      const authors = await this.authorService.findAllWithBooks();

      console.log('-------------getAuthors END-------------');
      return authors.map((author) => new Author(author));
    } else {
      const authors = await this.authorService.findAll();

      console.log('-------------getAuthors END-------------');
      return authors.map((author) => new Author(author));
    }
  }

  /**
   *
   * @param info GraphQLのinfoオブジェクト
   * @param path 判定したいノード
   * @returns Queryにpathが含まれる場合はtrue, それ以外はfalse
   */
  private doesPathExist(info: any, path: string): boolean {
    for (const selection of info.fieldNodes[0].selectionSet.selections) {
      if (selection.name.value == path) {
        return true;
      }
    }
    return false;
  }
}

author.service.ts
author.service.ts
import { Injectable } from '@nestjs/common';
import { RESTDataSource } from '@apollo/datasource-rest';
import { Author } from './author.backend.model';

@Injectable()
export class AuthorService extends RESTDataSource {
  constructor() {
    super();
  }

  public async findAll(): Promise<Array<Author>> {
    const data = await this.get(`http://localhost:8080/authors`);
    console.log('Response from Backend API: ' + JSON.stringify(data));
    return data;
  }

  public async findAllWithBooks(): Promise<Array<Author>> {
    const data = await this.get(`http://localhost:8080/authorsWithBooks`);
    console.log('Response from Backend API: ' + JSON.stringify(data));
    return data;
  }
}

実装例③:Dataloaderを利用したプログラム

author.module.ts
author.module.ts
import { Module } from '@nestjs/common';
import { AuthorsResolver } from './authors.resolver';
import { AuthorResolver } from './author.resolver';
import { AuthorService } from './author.service';
import { BookModule } from '../book/book.module';
import { BookService } from '../book/book.service';
import { BookDataloader } from '../book/book.dataloader';

@Module({
  imports: [BookModule],
  providers: [
    AuthorsResolver,
    AuthorResolver,
    AuthorService,
    BookService,
    BookDataloader,
  ],
})
export class AuthorModule {}

authors.resolver.ts
authors.resolver.ts
import { Query, Resolver } from '@nestjs/graphql';
import { Author } from './author.model';
import { AuthorService } from './author.service';

@Resolver(() => [Author])
export class AuthorsResolver {
  constructor(private authorService: AuthorService) {}

  @Query(() => [Author], { name: 'authors', description: '著者一覧取得' })
  public async getAuthors(): Promise<Author[]> {
    console.log('-------------getAuthors START-------------');

    const authors = await this.authorService.findAll();

    console.log('-------------getAuthors END-------------');
    return authors.map((author) => new Author(author));
  }
}
book.dataloader.ts
book.dataloader.ts
import { BaseDataloader } from '@/graphql/base-dataloader';
import { Injectable, Scope } from '@nestjs/common';
import { Book } from './book.backend.model';
import { BookService } from './book.service';

@Injectable({ scope: Scope.REQUEST })
export class BookDataloader extends BaseDataloader<string, Book[]> {
  constructor(private readonly bookService: BookService) {
    super();
  }

  protected async batchLoad(keys: string[]): Promise<Array<Book[]>> {
    console.log('-------------BookDataloader batchLoad-------------');

    const books = await this.bookService.findByAuthorIds(keys);

    // authorId単位でbooksを仕分ける
    const groupedByAuthorId = this.groupByAuthorId(keys, books);

    return keys.map((key) => groupedByAuthorId[key]);
  }

  /**
   *
   * @param keys 親ノードAuthorのauthorIdの配列
   * @param books バックエンドAPIから取得したBookの配列
   * @returns key: authorId, value: Book[]のMap
   */
  private groupByAuthorId(
    keys: string[],
    books: Book[],
  ): Record<string, Book[]> {
    return Object.fromEntries(
      keys.map((key) => [
        key,
        books.filter((book) => key === book.authorId.toString()),
      ]),
    );
  }
}
base-dataloader.ts
base-dataloader.ts
import * as 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 clear(key: K): DataLoader<K, V> {
    return this.dataloader.clear(key);
  }

  public clearAll(): DataLoader<K, V> {
    return this.dataloader.clearAll();
  }

  public async load(key: K): Promise<V> {
    return this.dataloader.load(key);
  }

  public async loadMany(keys: K[]): Promise<(V | Error)[]> {
    return this.dataloader.loadMany(keys);
  }

  public prime(key: K, value: V): DataLoader<K, V> {
    return this.dataloader.prime(key, value);
  }

  protected abstract batchLoad(keys: K[]): Promise<(V | Error)[]>;
}
book.service.ts
book.service.ts
import { Injectable } from '@nestjs/common';
import { RESTDataSource } from '@apollo/datasource-rest';
import { Book } from './book.backend.model';

@Injectable()
export class BookService extends RESTDataSource {
  constructor() {
    super();
  }

  public async findByAuthorId(authorId: number): Promise<Array<Book>> {
    const data = await this.get(
      `http://localhost:8080/authors/${authorId}/books`,
    );
    console.log('Response from Backend API: ' + JSON.stringify(data));
    return data;
  }

  public async findByAuthorIds(authorIds: string[]): Promise<Array<Book>> {
    const idsString = authorIds.join(',');
    const data = await this.get(`http://localhost:8080/books/findByAuthorIds`, {
      params: {
        authorIds: idsString,
      },
    });
    console.log('Response from Backend API: ' + JSON.stringify(data));
    return data;
  }
}
  1. infoオブジェクトの詳細な型定義はこちら

  2. GraphQLの思想を十分に調べたわけではないので、あくまで個人の見解です。

  3. https://github.com/graphql/dataloader#batch-function

5
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
4