NestJS x GraphQLで開発を始めるときに知っておきたかったこと


こんにちは、estieでSWEをしております。hishikiです。

estieでは複数のプロダクトを開発しており、いくつかのプロダクトは NestJS x GraphQLで作られています。

今回はNestJS x GraphQLの開発を進める上で特に理解しておきたい、ResolverResolveFieldについて執筆します。なお 我々は 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 が存在しない場合(今回であれば、nameaddress)は値がそのままレスポンスとして返される。

ちなみに今回の例では @ResolveField decoratorの引数とメソッド名が一致しているため引数は省略することができます。

DataLoader

GraphQLでの開発では頻出のDataloaderです。NestJS × GraphQLの開発でもほぼ必須かと思います。

詳細は割愛しますが、GraphQLで実直な実装を行った場合には N+1問題が発生します。これらを解決してくれるのがdataloaderです。

dataloaderの機能としては2つで、 CacheBatchです。

先ほどの 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を取ってくる。

  • BuildingsResolverGqlBuildingの各Field を解決する

  • GqlBuildingにおける owner FieldがbuildingのownerIdをkeyとしてdataloaderによってまとめてloadされる。

  • OwnersResolverGqlOwnerの各Fieldを解決する…

以上のように GqlBuildingGqlOwner … と下の階層へ降りながら各フィールドを解決していることがわかります。いかにもGraphの親から子へと走査している感じがしますね。

GqlBuilding -- id
            |- name
            |- address
             - GqlOwner (dataloaderでload) - id
                                            - name
                                          ...


余談

これだけありがたいDataLoaderですが内部実装は500行ほどなのでぜひソースコードを読むと理解が深まると思います。

参考記事: graphql/dataloader を読んだ話

最後に

NestJS x GraphQLで開発をしたい方も、そうではなくRustで開発をしたい方も、Next.jsで開発したい方もぜひカジュアルを面談しましょう!ご応募お待ちしております。

hrmos.co

© 2019- estie, inc.