NestJSでdataloaderを使いたいマン
ドーモ。ミナ=サン。
コンニチハ、dino a.k.a. shioデス👋
前回シリーズものの記事を書いたのですが、その続きを書く前にこっちを書いておきたいなと思ったので書きます。
導入
NestJS
でのdataloader
の使い方を知るには、まずはNestJS
にdataloader
を導入する必要があります。
今回導入完了までに必要なステップは2つです。
STEP0. 前提
とその前に、ちょっとだけ前提をば。
今回はUser
とPost
という二つのObjectがone-to-many
の関係でリレーションしている場合を想定します。
また、コード内に登場するRepository的なやつはPrisma的な雰囲気で書いています。
TypeORMくんごめんやで...。
dataloader
を依存パッケージに追加する
STEP1. さて、今度こそ導入開始です。
まずは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-many
やmany-to-many
の関係のmany
の立場にあるObjectがキャッシングできるのは、そのObjectのレコードが必ず全て取得できキャッシングできると分かっているときのみです。
ほう、kwsk。
以下の図のように、それぞれvalue
が3
、5
、0
のPost
が、単一のUser
とリレーションしている場合を考えます。
このとき、以下のようなGraphQL Queryを投げたとしましょう。
query {
findPosts(where: { value: { gt: 0 } }) {
value
user {
posts {
value
}
}
}
}
このGraphQL Queryでは、value
が0
より大きなPost
を全て取得し、取得したPost
のvalue
とそれに関連するUser
に関連するPost
のvalue
をレスポンスするようにリクエストしています(語彙
ここで、この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
}
]
}
}
]
}
}
おわかりいただけただろうか。
下のレスポンスオブジェクトの異常な点、それは value
が0
のPost
がUser
とリレーションしていないことになっている という点です。
なぜこんなことが起きてしまったのか、その理由はfindPosts
内ではvalue
が3
のPost
とvalue
が5
のPost
しかキャッシングしていなかったからに他なりません。
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
の扱い方を紹介しました。
自分が実装しようとしたときはかなり詰まって、今回のUser
とPost
のようなDataloader
を実装するのに丸一日費やしました...。
この記事がどこかの誰かに需要があれば幸いです。
さて、次回こそはシリーズものの続きを書かなきゃなぁ。
あ、Twitterのフォローオネガイシマス...。
またね。
Discussion