GraphQLオブジェクト間で比較が必要なフィールドをDataloaderの仕組みで解決する
はじめに
こんにちは 🍡🌸🌿
なぜか今年は花粉症を克服した ymizunuma です。スペースマーケットでエンジニアしています。
今回は GraphQL を実装していて詰まったことがあったので、自分なりに解決した方法を紹介します。もし、同じようにお困りの方がいれば参考になれば幸いです。
今回の内容
ある GraphQL のオブジェクトに同一種類のオブジェクト同士で比較/判断する必要のあるフィールドがあるとした場合に、主に N+1 問題を解決するために利用されるDataloaderの仕組みを利用して解決したので、実現したいこと、課題となるポイント、解決方法に分けて紹介します。
実現したいこと
同一種類のオブジェクト同士で比較/判断する必要のあるフィールドというのがどういうことかそもそもわかりづらいと思うので、以下に具体例を示します。
下記のように「指定した ID の飼い主」と「飼育している犬の一覧」を取得するクエリがあったとします。Query.dogOwner
でdogs
フィールドを指定すると、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;
}
}
DogOwnerResolver
でQuery.dogOwner
を ID 指定で呼び出せるようにしており、指定したid
のDogOwner
を 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 を利用した技術刷新にも積極的に取り組んでおります。これらの技術を用いた開発に興味がある方は、ぜひ弊社の採用ページをご覧ください。
スペースを簡単に貸し借りできるサービス「スペースマーケット」のエンジニアによる公式ブログです。 弊社採用技術スタックはこちら -> whatweuse.dev/company/spacemarket
Discussion