😈

NestJSでdataloaderを使いたいマン

2022/10/28に公開約9,100字

ドーモ。ミナ=サン。

コンニチハ、dino a.k.a. shioデス👋

前回シリーズものの記事を書いたのですが、その続きを書く前にこっちを書いておきたいなと思ったので書きます。

導入

NestJSでのdataloaderの使い方を知るには、まずはNestJSdataloaderを導入する必要があります。
今回導入完了までに必要なステップは2つです。

STEP0. 前提

とその前に、ちょっとだけ前提をば。
今回はUserPostという二つのObjectがone-to-manyの関係でリレーションしている場合を想定します。
また、コード内に登場するRepository的なやつはPrisma的な雰囲気で書いています。
TypeORMくんごめんやで...。

STEP1. dataloaderを依存パッケージに追加する

さて、今度こそ導入開始です。
まずはdataloaderを依存関係に追加しましょう。

yarn add dataloader

STEP2. Dataloaderの親クラスを作成する

次に各DataloaderのもととなるBaseDataLoaderを作成していきます。
...これ命名的にBaseDataLoaderじゃなくてBaseDataloaderかも...(?

import DataLoader from 'dataloader';

export abstract class BaseDataLoader<K, V> {
  protected readonly 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 keys.length > 0 ? 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)[]>;
}

以上でdataloaderの導入は完了です。

扱い方

ここからはNestJSにおけるdataloaderの基本的な扱い方を紹介していきます。
dataloaderの機能は主に以下の3つです。

  • バッチ処理
  • キャッシング
  • ロード

バッチ処理

まずはバッチ処理についてです。
dataloaderは一つのリクエスト内で呼ばれた複数の.load()をバッチ処理することで、無駄なDBクエリの発生を抑制できます。
以下は、そのバッチ処理の定義のサンプルです。

User

import { Inject, Injectable, Scope } from '@nestjs/common';
import { BaseDataLoader } from '@/common/base/dataloader/base.dataloader';
import { InjectionToken } from '@/common/constant/injection-token.constant';
import { User } from '~/user/domain/model/user.model';
import { UserRepositoryInterface } from '~/user/domain/service/repository/user.repository';

@Injectable({ scope: Scope.REQUEST })
export class UserDataLoader extends BaseDataLoader<string, User> {
  constructor(
    @Inject(InjectionToken.USER_REPOSITORY)
    private readonly userRepository: UserRepositoryInterface,
  ) {
    super();
  }

  protected async batchLoad(userIds: string[]): Promise<(User | Error)[]> {
    const users = await this.userRepository.findMany({
      where: {
        id: { in: userIds },
      },
    });

    const mappedUsers = userIds.map((userId) => {
      const user = users.find((u) => u.id === userId);

      return user || new Error(`User with id ${userId} not found`);
    });

    return mappedUsers;
  }
}

Post

import { Inject, Injectable, Scope } from '@nestjs/common';
import { BaseDataLoader } from '@/common/base/dataloader/base.dataloader';
import { InjectionToken } from '@/common/constant/injection-token.constant';
import { Post } from '~/post/domain/model/post.model';
import { PostRepositoryInterface } from '~/post/domain/service/repository/post.repository';

@Injectable({ scope: Scope.REQUEST })
export class UserPostsDataLoader extends BaseDataLoader<string, Post[]> {
  constructor(
    @Inject(InjectionToken.POST_REPOSITORY)
    private readonly postRepository: PostRepositoryInterface,
  ) {
    super();
  }

  protected async batchLoad(userIds: string[]): Promise<(Post[] | Error)[]> {
    const posts = await this.postRepository.findMany({
      where: {
        userId: { in: userIds },
      },
    });

    const mappedPostsList = userIds.map((userId) => {
      const mappedPosts = posts.filter((post) => post.userId === userId);
      if (mappedPosts.length === 0) {
        return new Error(`Post with userId ${userId} not found`);
      }

      return mappedPosts;
    });

    return mappedPostsList;
  }
}

キャッシング

次にキャッシングについてです。
dataloaderでは適切なタイミングでキャッシングを行うことで、先程の.batchLoad()を走らせずに済ませることができます。

ただここで、ひとつ注意するべきことがあります。
それは、どのResolverでもキャッシングができるとは限らないということです。
特に、one-to-manymany-to-manyの関係のmanyの立場にあるObjectがキャッシングできるのは、そのObjectのレコードが必ず全て取得できキャッシングできると分かっているときのみです。

ほう、kwsk。

以下の図のように、それぞれvalue350Postが、単一のUserとリレーションしている場合を考えます。

このとき、以下のようなGraphQL Queryを投げたとしましょう。

query {
  findPosts(where: { value: { gt: 0 } }) {
    value
    user {
      posts {
        value
      }
    }
  }
}

このGraphQL Queryでは、value0より大きなPostを全て取得し、取得したPostvalueとそれに関連するUserに関連するPostvalueをレスポンスするようにリクエストしています(語彙
ここで、このfindPosts内では、取得したPostのみをdataloaderによってキャッシングしているということに留意してください。

このとき期待するレスポンスオブジェクトは、大体こんな感じのものなはずです多分。

{
  "data": {
    "findPosts": [
      {
        "value": 3,
        "user": {
	  "posts": [
	    {
	      "value": 3
	    },
	    {
	      "value": 5
	    },
	    {
	      "value": 0
	    }
	  ]
	}
      },
      {
        "value": 5,
        "user": {
	  "posts": [
	    {
	      "value": 3
	    },
	    {
	      "value": 5
	    },
	    {
	      "value": 0
	    }
	  ]
	}
      }
    ]
  }
}

しかし実際に返されるレスポンスオブジェクトは、多分こうです。

{
  "data": {
    "findPosts": [
      {
        "value": 3,
        "user": {
	  "posts": [
	    {
	      "value": 3
	    },
	    {
	      "value": 5
	    }
	  ]
	}
      },
      {
        "value": 5,
        "user": {
	  "posts": [
	    {
	      "value": 3
	    },
	    {
	      "value": 5
	    }
	  ]
	}
      }
    ]
  }
}

おわかりいただけただろうか。
下のレスポンスオブジェクトの異常な点、それは value0PostUserとリレーションしていないことになっている という点です。
なぜこんなことが起きてしまったのか、その理由はfindPosts内ではvalue3Postvalue5Postしかキャッシングしていなかったからに他なりません。

dataloaderの性質として、keyに対するvalueがある場合、batchLoadを行わないという特徴があります。
...いや実に当たり前なことを言っている自覚はありますが...。
そのため、valueのリストが不完全な場合、その不完全なvalueのリストが.load()されてしまいます。
逆に、valueがリストの形式でない場合(今回の例ではUser)には、多分どんなResolverであってもResolverの最後のタイミングでキャッシュをすることができます。

これが、manyの立場にあるObjectを安易にキャッシングしてはいけない理由です。

User

import { Inject, Logger } from '@nestjs/common';
import { Args, Int, Query, Resolver } from '@nestjs/graphql';
import { UserDataLoader } from '../dataloader/user.dataloader';
import { User as UserModel } from '../domain/model/user.model';
import { UserReaderUseCaseInterface } from '../domain/service/use-case/user-reader.use-case';
import { FindUserArgs } from './dto/args/find-user.args';
import { User } from './dto/object/user.object';
import { InjectionToken } from '@/common/constant/injection-token.constant';

@Resolver()
export class UserQuery {
  private readonly logger = new Logger(UserQuery.name);

  constructor(
    @Inject(InjectionToken.USER_READER_USE_CASE)
    private readonly userReaderUseCase: UserReaderUseCaseInterface,
    private readonly userDataLoader: UserDataLoader,
    // ...
  ) {}

  @Query(() => User, { nullable: true })
  async findUser(@Args() args: FindUserArgs): Promise<UserModel | null> {
    this.logger.log('findUser called');
    this.logger.log(args);

    const foundUser = await this.userReaderUseCase.findUser(args.where.authId);

    if (foundUser) {
      this.userDataLoader.prime(foundUser.id, foundUser);
    }

    return foundUser;
  }

// ...
}

ロード

dataloaderはロードをしてはじめて憎きN+1問題を解決できます。
ざまあみろと思いながらこれ見よがしに.load()しましょう。

User

import { Parent, ResolveField, Resolver } from '@nestjs/graphql';
import { UserPostsDataLoader } from '../dataloader/user-posts.dataloader';
import { User as UserModel } from '../domain/model/user.model';
import { User } from './dto/object/user.object';
import { Post } from '~/post/controller/dto/object/post.object';
import { Post as PostModel } from '~/post/domain/model/post.model';

@Resolver(() => User)
export class UserResolver {
  constructor(
    private readonly userPostsDataLoader: UserPostsDataLoader,
  ) {}

  @ResolveField(() => [Post])
  async posts(@Parent() user: UserModel): Promise<PostModel[]> {
    const posts = await this.userPostsDataLoader.load(user.id);

    return posts;
  }
}

Post

import { Parent, ResolveField, Resolver } from '@nestjs/graphql';
import { Post as PostModel } from '../domain/model/post.model';
import { Post } from './dto/object/post.object';
import { User } from '~/user/controller/dto/object/user.object';
import { UserDataLoader } from '~/user/dataloader/user.dataloader';
import { User as UserModel } from '~/user/domain/model/user.model';

@Resolver(() => Post)
export class PostResolver {
  constructor(private readonly userDataLoader: UserDataLoader) {}

  @ResolveField(() => User)
  async user(@Parent() post: PostModel): Promise<UserModel> {
    const user = await this.userDataLoader.load(post.userId);

    return user;
  }
}

おわりに

今回は日本語の記事があんまりなかったNestJSにおけるdataloaderの扱い方を紹介しました。
自分が実装しようとしたときはかなり詰まって、今回のUserPostのようなDataloaderを実装するのに丸一日費やしました...。
この記事がどこかの誰かに需要があれば幸いです。

さて、次回こそはシリーズものの続きを書かなきゃなぁ。

あ、Twitterのフォローオネガイシマス...。

https://twitter.com/shio3616

またね。

Discussion

ログインするとコメントできます