こんにちは、estieでSWEをしております。hishikiです。
estieでは複数のプロダクトを開発しており、いくつかのプロダクトは NestJS x GraphQLで作られています。
今回はNestJS x GraphQLの開発を進める上で特に理解しておきたい、Resolver
とResolveField
について執筆します。なお 我々は schema first、 ORMは Prismaでの実装をおこなっているためそちらに則ったコードを記載しております。
Field Resolverを @ResolveField で実装する
Graphql
以下のようなGraphQLのクエリを考えます。
GqlBuilding
というGraphQLの型があり、buildingというQueryが存在する単純な例です。
type GqlBuilding { id: ID! name: String! address: String! } type Query { building(id: ID!): GqlBuilding! } query($buildingId: ID!) { building(id: $buildingId) { id name address } }
Resolver
GqlBuilding
に対する Resolverです。
@Resolver()
には GraphQLのtypeに対応する GqlBuilding
を指定します。このように指定することで GqlBuilding
という型の値を返す際に、NestJSがBuildingsResolver
を探し出して処理を進めます。
@Resolver('GqlBuilding') export class BuildingsResolver { @Query('building') async findOne(@Args('id', ParseIntPipe) id: number): Promise<Building> { ... } }
ResolveFieldを使う
@ResolveField
用いてよりGraphQLらしいコードを実装しましょう。
ここでDBにあるBuilding Modelのidの型がint型だった場合には以下のようなResolverの実装になります。(custom scalarを用いる方法もあるがここでは ResolveFieldで対応する)
@Resolver('Building') export class BuildingsResolver { @Query('building') async findOne(@Args('id', ParseIntPipe) id: number): Promise<Building> { // DBにアクセスしてbuildingを取得 const building = findBuilding(id); // GraphQLのidの型はID!でありバックエンドからはstringで返す必要があるためtoString()を呼び出してbuildingを返す return { id: building.id.toString(), name: building.name, address: building.address }; } }
この実装のみでは問題なさそうですが、@Query('buildings')
を実装した場合でも同様の処理が必要になります。これでは GraphQLで開発を進める嬉しさが減ってしまいますね。
@Query('buildings') async findBuildings(@Args('params') params: Params): Promise<Array<Building>> { // DBにアクセスしてbuildingsを取得 const buildings = findBuildings(params); // idはstringで返す必要があるためtoString()を呼び出してbuildingを返す return buildings.map((building) => ({ id: building.id.toString(), name: building.name, address: building.address }); }
これらのような各フィールドの処理は @ResolveField を使ってうまく処理ができます。
@Resolver('Building') export class BuildingsResolver { @Query('building') async findOne(@Args('id', ParseIntPipe) id: number): Promise<DatabaseBuilding> { // DBにアクセスしてbuildingを取得 return findBuilding(id); } @ResolveField('id') id(@Parent() building: DatabaseBuilding): string { return this.building.id.toString() } }
処理の順序としては
Query名をもとに
findOne
が呼ばれる。Queryで必要とする各Fieldに対応する @ResolveField が存在すれば(今回であれば
id
) それらが呼ばれて、Fieldごとの値が解決される。各Fieldに対応する
@ResolveField
が存在しない場合(今回であれば、name
とaddress
)は値がそのままレスポンスとして返される。
ちなみに今回の例では @ResolveField
decoratorの引数とメソッド名が一致しているため引数は省略することができます。
DataLoader
GraphQLでの開発では頻出のDataloaderです。NestJS × GraphQLの開発でもほぼ必須かと思います。
詳細は割愛しますが、GraphQLで実直な実装を行った場合には N+1問題が発生します。これらを解決してくれるのがdataloaderです。
dataloaderの機能としては2つで、 Cache
と Batch
です。
先ほどの GqlBuildingにownerが紐づいた例を考えてみましょう。
type GqlBuilding { id: ID! name: String! address: String! owner: GqlOwner! } type GqlOwner { id: ID! name: String! } type Query { buildingsWithOwner(id: ID!): GqlBuilding! } query($buildingId: ID!) { buildingsWithOwner(id: $buildingId) { id name address owner { id name } } }
また DatabaseのBuildingではOwnerテーブルへの外部キーとして ownerIdを保持しているものとします。
N+1問題の例
複数の buildingを取得するfindBuildings Queryでは、それに関連する ownerを取得するタイミングで N+1問題が発生します。(これに関してはGraphQL一般で起こりうる問題です)
@Resolver('Building') export class BuildingsResolver { ... @Query('buildingsWithOwner') async findBuildings(@Args('params') params: Params): Promise<Building> { return findBuildings(params); } @ResolveField('owner') async owner(@Parent() building: Building):Owner { // DBにアクセスしてownerを取得するがここで N+1問題が発生する。 return findOwner(building.ownerId); } }
Dataloaderを利用する
Dataloaderを利用するために実装者が行うべきことはDataloaderのbatchLoad
functionを継承したクラスを作成し、単一のkeyのアクセスを行う場合にはload, 複数の場合にはloadManyを呼び出します。
batchLoadは溜め込んだkeyを用いてDBにアクセスして値を返却するという役割を担っています。
estieでは以下のようなBaseDataloader
という抽象クラスを定義して、これを継承したDataLoaderクラスを作成しそこからload, loadManyを呼び出しています。
抽象クラス 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)); protected abstract batchLoad(keys: readonly K[]): Promise<(V | Error)[]>; public async load(key: K): Promise<V> { return this.dataloader.load(key); } public async loadMany(keys: K[]): Promise<V[]> { if (keys.length <= 0) { return []; } const res = await this.dataloader.loadMany(keys); } }
抽象クラスを継承した具体クラス OwnerDataloader
(ownerIdを受け取ってownerを返却する)
batchLoadをoverrideし、データ返却時に mapを利用することで計算量を落としています。
後述しますが、keyに対して返却するvalueの長さと順序を揃える必要があります。
@Injectable({ scope: Scope.REQUEST }) export class OwnerDataloader extends BaseDataloader<bigint, DatabaseOwner> { ... protected async batchLoad(keys: bigint[]): Promise<(DatabaseOwner | Error)[]> { const owners = await this.prisma.owner.findMany({ where: { id: { in: keys, }, }, }); // O(n)に計算量を落とすため、Mapを事前に作成しておく const ownerHash = new Map<bigint, DatabaseOwner>(owners.map((owner) => [owner.id, owner])); return keys.map((key) => { const owner = ownerHash.get(key); if (!owner) { return new Error(`${this.constructor.name}: ${key} Not found`); } return owner; }); } } }
上で実装したownerDataloaderを用いてResolveFieldでは
‘‘‘ownerDataloader.load(key)‘‘‘, ‘‘‘ownerDataloader.loadMany(keys)‘‘‘のように呼び出すことで、N+1を回避しながらDBアクセスが行えます。
@Query('buildings') async findBuildings(@Args('params') params: Params): Promise<Building> { return findBuildings(params); } @ResolveField('owners') async owner(@Parent() building: Building):Owner { // dataloaderを用いて owner を取得 return ownerDataloader.load(building.ownerId); }
注意すること
keyに対してvalueの長さと順序を揃える。
- key: [1,2,3]であればvalueは [key1_value, key2_value, key3_value]となるようにする。
- batch処理ではkeyを溜め込んでまとめて値を返すため、keyとvalueの長さと順序を揃えることでkeyに対応するvalueを返却できるようにしています
dataloaderのスコープに気をつける。
- cacheは便利ですが、意図しない値を返してしまう恐れがあります。
- NestJSではInjection scopes | NestJS - A progressive Node.js frameworkでクラスの scopeを定めることができます。まずはリクエストごとのスコープとしておき、問題がなさそうであればスコープの幅を広げていくのが安全です。
- 今回の例であれば
@Injectable({ scope: Scope.REQUEST })
のような形でスコープを設定しています。
処理を追う
少し長くなってしまったため、ここまでの処理を追ってみましょう
- query名をもとに BuildingsResolverの
findBuildings
が呼ばれる
async findBuildings(@Args('params') params: Params): Promise<Array<Building>> { return findBuildings(params); }
findBuildingsメソッドは データベースにある buildingを取ってくる。
BuildingsResolver
でGqlBuilding
の各Field を解決するGqlBuilding
における owner FieldがbuildingのownerIdをkeyとしてdataloaderによってまとめてloadされる。OwnersResolver
でGqlOwner
の各Fieldを解決する…
以上のように GqlBuilding
→ GqlOwner
… と下の階層へ降りながら各フィールドを解決していることがわかります。いかにもGraphの親から子へと走査している感じがしますね。
GqlBuilding -- id |- name |- address - GqlOwner (dataloaderでload) - id - name ...
余談
これだけありがたいDataLoaderですが内部実装は500行ほどなのでぜひソースコードを読むと理解が深まると思います。
参考記事:
graphql/dataloader を読んだ話
最後に
NestJS x GraphQLで開発をしたい方も、そうではなくRustで開発をしたい方も、Next.jsで開発したい方もぜひカジュアルを面談しましょう!ご応募お待ちしております。