🐾

GraphQLオブジェクト間で比較が必要なフィールドをDataloaderの仕組みで解決する

2023/03/10に公開

はじめに

こんにちは 🍡🌸🌿
なぜか今年は花粉症を克服した ymizunuma です。スペースマーケットでエンジニアしています。

今回は GraphQL を実装していて詰まったことがあったので、自分なりに解決した方法を紹介します。もし、同じようにお困りの方がいれば参考になれば幸いです。

今回の内容

ある GraphQL のオブジェクトに同一種類のオブジェクト同士で比較/判断する必要のあるフィールドがあるとした場合に、主に N+1 問題を解決するために利用されるDataloaderの仕組みを利用して解決したので、実現したいこと、課題となるポイント、解決方法に分けて紹介します。

実現したいこと

同一種類のオブジェクト同士で比較/判断する必要のあるフィールドというのがどういうことかそもそもわかりづらいと思うので、以下に具体例を示します。

下記のように「指定した ID の飼い主」と「飼育している犬の一覧」を取得するクエリがあったとします。Query.dogOwnerdogsフィールドを指定すると、Dogオブジェクトの配列が返却されるイメージです。

query dogOwner {
  dogOwner(id: 1) {
    dogs {
      id
      name
      birthDate # 生年月日
    }
  }
}

ここでそれぞれの犬が何番目に生まれたかを示すフィールド(ordinalNumberByBirth)が欲しくなったとします。この場合、それぞれの犬の生まれ順を判断するために、他の犬の生年月日との比較の中で、フィールドを解決しなければなりません。

この何番目に生まれたかを示すordinalNumberByBirthフィールドが、先ほどの同一種類のオブジェクト同士で比較/判断する必要のあるフィールドに該当し、本記事ではこういったフィールドの解決方法について書いていきます。

ちなみに最終的に取得したい結果としては以下の通りです。

{
  "data": {
    "dogOwner": {
      "dogs": [
        {
	  "id": 1,
          "name": "わんこ1",
          "birthDate": "2010-01-01",
	  "ordinalNumberByBirth": "1番目"
        },
        {
	  "id": 2,
          "name": "わんこ2",
          "birthDate": "2015-01-01",
	  "ordinalNumberByBirth": "2番目"
        },
        {
	  "id": 3,
          "name": "わんこ3",
          "birthDate": "2020-01-01",
	  "ordinalNumberByBirth": "3番目"
        }
      ]
    }
  }
}

課題となるポイント

さて、NestJS で上記のフィールドを取得したい場合、何が課題になっているのでしょうか?一旦、前提となるordinalNumberByBirthフィールド定義までの実装を載せておきます。

DogOwnerObjectType を作成

@ObjectType("DogOwner")
export class DogOwnerObjectType {
  @Field(() => ID, { nullable: false })
  id!: number;

  @Field(() => [DogObjectType], { nullable: false })
  dogs: Dog[];
}

DogObjectType を作成

@ObjectType("Dog")
export class DogObjectType {
  @Field((type) => ID, { nullable: false })
  id!: number;

  @Field((type) => String, { nullable: false })
  name!: string;

  @Field((type) => String, { nullable: false })
  birthDate!: string;
}

DogOwnerResolver を作成

@Resolver(() => DogOwnerObjectType)
export class DogOwnerResolver {
  constructor(
    private readonly dogOwnerRepository: DogOwnerRepository,
    private readonly dogRepository: DogRepository
  ) {}

  @Query(() => DogOwnerObjectType)
  async dogOwner(
    @Args("id", { type: () => Int }) id: number
  ): Promise<DogOwner> {
    return this.dogOwnerRepository.fetchOneById(id);
  }

  @ResolveField()
  async dogs(@Parent() dogOwner: DogOwnerObjectType): Promise<Dog[]> {
    const dogs = await this.dogRepository.fetchByDogOwnerId(dogOwner.id);
    return dogs;
  }
}

DogOwnerResolverQuery.dogOwnerを ID 指定で呼び出せるようにしており、指定したidDogOwnerを DB から引いてきます。DogOwnerObjectTypeに定義したdogsフィールドについてはDogOwnerResolver@ResolveFieldで解決します。ちなみに@Parentデコレータによって DB から引いてきたDogOwnerが渡ってくるのですが、この辺りの解説はNestJS ドキュメントにも記載あるので今回は割愛します。

この時点でのクエリ実行結果は以下のようになります。「指定した ID の飼い主」と「飼育している犬の一覧」が取得できています。

{
  "data": {
    "dogOwner": {
      "dogs": [
        {
	  "id": 1,
          "name": "わんこ1",
          "birthDate": "2010-01-01"
        },
        {
	  "id": 2,
          "name": "わんこ2",
          "birthDate": "2015-01-01"
        },
        {
	  "id": 3,
          "name": "わんこ3",
          "birthDate": "2020-01-01"
        }
      ]
    }
  }
}

このような実装がある前提で、先ほどのordinalNumberByBirthフィールドを実装しようとする場合はどのようにしたらよいでしょうか?引き続きいけるとこまで実装してみます。

DogObjectType を追加修正

@ObjectType('Dog')
export class DogObjectType {
  @Field((type) => ID, { nullable: false })
  id!: number;

  @Field((type) => String, { nullable: false })
  name!: string;

  @Field((type) => String, { nullable: false })
  birthDate!: string;

+  @Field((type) => String, { nullable: false })
+  ordinalNumberByBirth!: string;
}

DogResolver を作成

@Resolver(() => DogObjectType)
export class DogResolver {
  @ResolveField()
  ordinalNumberByBirth(@Parent() dog: DogObjectType): string {
    //
  }
}

ここまで実装して手が止まりました。
@Parentで渡ってくるdogは、解決しようとしている単一のDogObjectTypeのみで、出生順を決めるための他の犬の情報までは参照できません。

このように GraphQL では、1 オブジェクト単位でフィールド解決を行うため、同一種類の複数オブジェクトに跨って判断が必要なフィールドの場合は、他のオブジェクトの情報を参照ができず、解決が困難になるという課題に直面しました。

解決方法 - Dataloader の仕組みを利用する

上記の課題をクリアするために、Dataloader の仕組みを利用します。今回の記事の趣旨になります。

Dataloader については過去のスペースマーケット技術ブログ【Graphql 不思議機能 dataloader の大きな2つの流れ】でも紹介していますが、簡単に言うと渡ってきた Key を溜め込み、最終的に溜め込んだ Key 配列を元にして、SQL や API 問い合わせを一括実行し、最終的な実行結果の配列と溜め込んでいた Key 配列をマッピングしてフィールド解決することができるライブラリです。

この仕組みを利用して、@Parentで渡ってきた単一のDogObjectTypeを溜め込み、溜め込んだDogObjectType同士の生年月日を比較して目的となるフィールドの解決を目指します。実装は以下のようになります。

DogDataloader を作成

import * as DataLoader from "dataloader";

@Injectable()
export class DogDataloader {
  public readonly resolveOrdinalNumberByBirth = new DataLoader(
    async (dogs: DogObjectType[]) => {
      return dogs.map((resolvedDog: DogObjectType) => {
        const ordinalNumber = dogs.filter(
          (dog) => dog.birthDate <= resolvedDog.birthDate
        ).length;
        return `${ordinalNumber}番目`;
      });
    },
    { cache: false }
  );
}

DogResolver の追加修正

@Resolver(() => DogObjectType)
export class DogResolver {
+  constructor(private readonly dogDataloader: DogDataloader) {}

  @ResolveField()
  async ordinalNumberByBirth(@Parent() dog: DogObjectType): Promise<string> {
+    return this.dogDataloader.resolveOrdinalNumberByBirth.load(dog);
  }
}

DogDataloaderの処理内容が少々難解に見えますが、溜め込んだDogObjectTypeの配列から 1 件ずつ取り出して、配列内のDogObjectTypeの生年月日と比較し、自身の生年月日より古い生年月日を持つデータだけにフィルタリングした上で件数を取得しています。これで、何番目に生まれた犬かを判定することができます。

最終的にDogDataloader内では[ '1番目', '2番目', '3番目' ]が返り、各DogObjectType毎にマッピングされてフィールド解決ができるようになります。

実行結果的にも問題なさそうです。

{
  "data": {
    "dogOwner": {
      "dogs": [
        {
	  "id": 1,
          "name": "わんこ1",
          "birthDate": "2010-01-01",
	  "ordinalNumberByBirth": "1番目"
        },
        {
	  "id": 2,
          "name": "わんこ2",
          "birthDate": "2015-01-01",
	  "ordinalNumberByBirth": "2番目"
        },
        {
	  "id": 3,
          "name": "わんこ3",
          "birthDate": "2020-01-01",
	  "ordinalNumberByBirth": "3番目"
        }
      ]
    }
  }
}

本来とは異なった Dataloader の利用法になりましたが、同一種類のオブジェクト同士で比較/判断する必要のあるフィールドの解決が困難だったという課題についてはクリアすることができるようになりました 🎉

実装としては以上になります。

さいごに

いかがでしたでしょうか。自分としては今回の実装を通してわんちゃんがでてくるシステムって癒されそうでいいなと思いましたより一層 GraphQL で表現できることの幅が広がった気がします。まだまだ GraphQL についてはわからないことも多いので、もし間違っている点や、より良い実装方法があれば、コメント頂けると幸いです。

弊社では、GraphQL や NestJS を利用した技術刷新にも積極的に取り組んでおります。これらの技術を用いた開発に興味がある方は、ぜひ弊社の採用ページをご覧ください。

https://www.wantedly.com/projects/1113570
https://www.wantedly.com/projects/1113544
https://www.wantedly.com/projects/1061116
https://spacemarket.co.jp/recruit/engineer/

スペースマーケット Engineer Blog

Discussion