GraphQL PaginationのNestJSでの実装


はじめに

CADDiでバックエンドエンジニアをしている狭間と申します。
この記事は CADDi Advent Calendar 18日目の記事です。昨日は,@wolf_cppさんによるValgrindでコード解析してみるでした!

CADDiではBFFにGraphQL用いているシステムが幾つかあり、私が担当しているシステムでもGraphQLを利用しています。
しばらく前にPaginationを実装したのですが、そこを改善したいと思っており、今回はそれについて書きたいと思います。
私が担当しているシステムではNestJSを使用しており、NestJSを使った実装例を紹介できればと思います。

GraphQLでのPagination

GraphQLの公式ではベストプラクティスとして、こういった方法が上げらています。Relay-style cursor pagination とか Relay-style pagination と呼ばれているようです。
かなりリッチな機能なので用途によってはtoo muchな気がしますが、拡張性も考えてこれに従って実装することにしました。
Apollo Client 3.0に Relay-style cursor pagination 向けのキャッシュ機能があるそうです。
明日の記事で @gushernobindsme さんがこのあたりを解説してくれているので、そちらもぜひ読んでみてください。

NestJSでの実装

NestJSの公式にPaginationの例がいくつか上がっているのですが(Resolverの例とかModelの例)、部分的にしか記載がないので、そのあたりを解説できればと思います。
NestJSにちょうどよいサンプルがあったのでこれを拡張していきます。
NestJSではGraphQLのスキーマを事前に定義しておいて、そこからコードをジェネレートする方法とコードからスキーマを生成する方法があるのですが、今回は後者を使用します。

Paginationのモデル定義

Paginationの実装にあたって、まずはこれを参考にPaginationの定義を用意します。

page-info.model.ts
import { ObjectType } from "@nestjs/graphql";

@ObjectType("PageInfo")
export class PageInfo {
  startCursor: string;
  endCursor: string;
  hasNextPage: boolean;
  hasPreviousPage: boolean;
}
pagnated-connection.model.ts
import { Field, ObjectType, Int } from "@nestjs/graphql";
import { Type } from "@nestjs/common";
import { PageInfo } from "./page-info.model";

export function PaginatedConnection<T>(classRef: Type<T>): any {
  @ObjectType({ isAbstract: true })
  class AbstractConnectionType {
    @Field((type) => Int)
    totalCount: number;

    @Field((type) => [AbstractEdgeType], { nullable: true })
    edges: AbstractEdgeType[];

    @Field((type) => PageInfo)
    pageInfo: PageInfo;
  }

  @ObjectType(`${classRef.name}Edge`)
  abstract class AbstractEdgeType {
    @Field((type) => String)
    cursor: string;

    @Field((type) => classRef)
    node: T;
  }
  return AbstractConnectionType;
}

page-info.model.tsについては通常のclassなので説明不要かと思います。
問題はpagnated-connection.model.tsの方かなと思います。
PaginatedConnectionでclassを動的に生成するような処理になっています。(ここでanyを返してしまうのが微妙なのですが、解決できず...)

Paginationモデルの適用

実際のモデルに適用するには下記のようにします。

paginated-recipe.model.ts
import { Recipe } from "./recipe.model";

import { ObjectType } from "@nestjs/graphql";
import { PaginatedConnection } from "src/common/pagination/model/pagnated-connection.model";

@ObjectType()
export class PaginatedRecipe extends PaginatedConnection(Recipe) {}

先程紹介した PaginatedConnection により動的にclassが生成され、それを継承しています。
なので概念的には下記のような形になっているはずです。

class PaginatedRecipe {
  @Field((type) => Int)
  totalCount: number;

  @Field((type) => [AbstractEdgeType], { nullable: true })
  edges: AbstractEdgeType[];

  @Field((type) => PageInfo)
  pageInfo: PageInfo;

  @ObjectType("RecipeEdge")
  class AbstractEdgeType {
    @Field((type) => String)
    cursor: string;

    @Field((type) => Recipe)
    node: Recipe;
  }
}

あとはこれをresolverで返すようにしてあげれば完成です。

(抜粋)recipes.resolver.ts
@Resolver((of) => Recipe)
export class RecipesResolver {
  constructor(private readonly recipesService: RecipesService) {}

  // @Query(returns => [Recipe])
  // recipes(@Args() recipesArgs: RecipesArgs): Promise<Recipe[]> {
  //  return this.recipesService.findAll(recipesArgs);
  // }

 // 元のクエリをPaginationしたものに置き換え
  @Query((returns) => PaginatedRecipe)
  recipes(@Args() args: PaginationRecipesArgs): Promise<PaginatedRecipe> {
    return this.recipesService.findAll(args);
  }
}

パラメータは下記のようにしてあります。

pagination.args.ts
import { ArgsType, Field, Int } from "@nestjs/graphql";

@ArgsType()
export class PaginationArgs {
  @Field((type) => Int)
  first?: number;

  @Field((type) => Int)
  after?: number;

  @Field((type) => String)
  last?: string;

  @Field((type) => String)
  before?: string;
}
pagination.args.ts
import { ArgsType, Field } from '@nestjs/graphql';
import { PaginationArgs } from 'src/common/pagination/dto/pagination.args';

@ArgsType()
export class PaginationRecipesArgs extends PaginationArgs{
  @Field(type => String)
  title?: string;
}

起動した結果をPlaygroundで確認してみると、期待通りのスキーマ定義になっていることが確認できました。

まとめ

NestJSのサンプルを参考にPaginationの定義を共通化することができました。
class定義にanyが残ってしまったので、そこだけもう少しうまくできれば良かったなと思っています。
ただ影響は受けるのはresolverだけなので、service側とのインターフェイスを工夫してあまり影響がないようにできるような気はします。次回そのあたりが書けたらなと思います。

冒頭でも少し触れましたが、明日は @gushernobindsme さんの「Apollo Client 3.0 ではじめる快適キャッシュ生活」です。お楽しみに!