メルカリ Shops での NestJS を使った GraphQL Server の実装

ソウゾウの Software Engineer をやっています、@mookjp です。

8/10 の記事「メルカリShopsの技術スタックと、その選定理由」では、メルカリ Shops のアーキテクチャについて、その全体像を紹介しました。 この記事では、そのうちの BFF(Backend for Frontend) レイヤとして用意した GraphQL サーバについて、NestJS を使った実装例を交えて紹介します。

GraphQL とは

GraphQL は、クライアントアプリケーションがより柔軟に必要なデータを取得することができるように設計されたクエリ言語です。SQL と、SQL を発行するクライアントアプリケーション、そして SQL を受け付けるデータベースサーバをイメージしてもらうとわかりやすいかもしれません。

GraphQL を使うことによって、既存のアプリケーションサーバの API 仕様を変更せずにクライアント側で取得したいデータのコントロールができたり、 GraphQL スキーマ定義を利用しリクエストの正しさを事前に検証することができるなど、様々なメリットを得られます。

GraphQL サーバ周辺の構成

連載企画の他の記事でもご紹介していますが、メルカリ Shops の GraphQL サーバ周辺の構成は以下のようになっています。

GraphQL サーバ周辺の構成

モバイルクライアントや Next.js を使ったフロントエンドサーバは、GraphQL サーバへリクエストを送信することによって必要なデータの取得や更新を行っています。 GraphQL サーバは Microservices の gRPC API を呼び出します。

NestJS とは

8/10 の記事でも紹介しましたが、メルカリ Shops では NestJS フレームワークを使った BFF を採用しています。 NestJS は Web サーバーとしてのロジックの実装をより素早く、テストしやすく作ることを目指したフレームワークです(HTTP Server としての実装は Express, fastify を選ぶことができます)。 また、すべての機能は TypeScript での記述をサポートしています。メルカリ Shops では、 TypeScript で NestJS サーバのコードを記述しています。

NestJS の特徴は、Modulesの仕組みを使って適切に分割された機能単位での開発が容易にできることです。 NestJS が提供するデコレータを使うことにより、依存の注入(Dependency Injection、以下 DI と表記します)は NestJS に任せることができます。

例えば、「Shop」「Asset」という複数の機能単位に分割して開発を進め、「ShopService」で「AssetDataLoader」を利用したい場合は以下のようにデコレータを付与することにより、DI を実現できます。

NestJS の Modules

// asset.module.ts

// …

@Module({
  providers: [AssetService, AssetDataLoader],
  exports: [AssetDataLoader],
})
export class AssetModule {}
// shop.module.ts

// …

@Module({
  imports: [AssetModule],
  providers: [ShopService, ShopDataLoader],
})
export class ShopModule {}
// shop.service.ts

// …

@Injectable()
export class ShopService {
  constructor(private assetDataLoader: AssetDataLoader) {} // AssetModule で公開された AssetDataLoader を利用
  // ...
}

NestJS の DI については、ドキュメントサンプルコードがありますのでそちらも読んでみるとよりイメージしやすいかと思います。

GraphQL Module

メルカリ Shops では、 NestJS が提供する GraphQLModule を使って GraphQL サーバの実装を行っています。

この GraphQLModule は、 GraphQL サーバの実装である Apollo をラップしたものです。 GraphQLModule と GraphQLModule 用のデコレータをコードに付与することで、GraphQL クエリのフィールドを解決する実装(以下、Resolvers と表記します)を書きながら GraphQL のスキーマ定義を自動生成することができます(Code First なスキーマ定義です)。 Apollo のドキュメントでは GraphQL スキーマを定義したのちに Resolvers を実装していく手法が説明されていますが、GraphQLModule を使うことによってこの作業を効率化することができます。

次のセクションでは、実際にどのようにコードを書いていくかを紹介します。

NestJS で Code First なスキーマ定義をする

それでは実際にどのようなコードを書いて GraphQL のスキーマ定義と Resolvers の実装をしていくかを紹介します。

今回の例では、以下のような GraphQL スキーマ定義を、Resolvers の実装をしながら行っていきます。

type Asset {
  // ...
}

type Shop {
  name: String!
  thumbnail: Asset
  // ...
}

input ShopsInput {
  // …
}

Input UpdateShopInput {
  // …
}

type Query {
  shops(shopsInput: ShopsInput!): [Shop!]!
}

type Mutation {
  updateShop(updateShopInput: UpdateShopInput!): Boolean!
}

Object types の定義

GraphQL の Object types は以下のようなクラスで定義することができます。

// shop.entity.ts
// …

import { Asset } from '../../asset/entities/asset';

// ...

@ObjectType()
export class Shop {
  @Field(() => String)
  name!: string;

  @Field(() => Asset, { nullable: true })
  thumbnail?: Asset; // import して 別途定義された Object types を利用

  // …

  thumbnailId: string; // 後述する Resolvers 定義で説明します

  // ...
}

@ObjectType() デコレータを付与したクラスがひとつの Object types として定義されます。また、Object types の fields は @Field() デコレータを付与することによって定義できます。

Query と Mutation の定義

Resolvers の実装をすると同時に、GraphQL スキーマの Query と Muration を定義していくことができます。 Resolvers の実装は、@Resolver() デコレータを付与したクラスを作成し、そのメソッドに @Query(), @Mutation() デコレータを付与することで行います。 また、特定のフィールドについての Resolvers は @ResolveField() デコレータを付与することによって実装できます。

以下のコードでは、Shop type のデータの取得を ShopService を介して行いつつ、thumbnail フィールドの内容の取得を AssetDataLoader を介して行います。 ShopService では protobuf から生成した gRPC client を使って Microservices の API を呼び出します(コード例は省略します)。 AssetDataLoader は ShopService と同じく Microservices の API を呼び出してサムネイル情報を取得しますが、バッチリクエストを実現する実装となっています(この後の「DataLoader を使って Batch Request に対応する」セクションで説明します)。「Object types の定義」で定義した Shop クラスの thumbnailId フィールドは、 GraphQL スキーマの thumbnail フィールドを解決するために利用されます。

// shop.resolver.ts
// …
@Resolver(() => Shop)
export class ShopResolver {
  constructor(
      private readonly shopService: ShopService,
      private readonly assetDataLoader: AssetDataLoader,
      // ...
  ) {}

  @Query(() => [Shop])
  shops(
    @Args('shopsInput', { type: () => ShopsInput }) shopsInput: ShopsInput
  ) {
    return this.shopService.findShops(ctx, shopsInput);
  }

  @Mutation(() => Boolean)
  updateShop(
    @Args('updateShopInput') updateShopInput: UpdateShopInput
  ) {
    return this.shopService.updateShop(ctx, updateShopInput);
  }

  @ResolveField(() => Asset, { nullable: true })
  async thumbnail(@Parent() { thumbnailId }: Shop) {
    if (!thumbnailId) {
      return null;
    }
    try {
      return this.assetDataLoader.load(thumbnailId);
    } catch {
      return null;
    }
  }

  // …
}

GraphQL スキーマの生成

Code First で定義した GraphQL スキーマのファイル生成は、

のいずれかによって行うことができます。 NestJS サーバを起動して生成する場合は GqlModuleOptions の autoSchemaFile を設定します

メルカリ Shops では、 GraphQL スキーマの生成用のスクリプトファイルを nodejs_binary を使って Bazel の target として定義し、実行しています。

スキーマの Breaking Change (破壊的変更)を防ぐ

クライアントとサーバのインターフェイスとなる BFF として API の後方互換性を保つことは重要です。

8/10 の記事「メルカリShopsの技術スタックと、その選定理由」では、 バックエンドの Microservices 群に gRPC サーバを採用していると紹介していますが、 これらの gRPC サーバは手元の開発環境に加えて CI でも Protobuf ファイルの変更を検証することによって Breaking Change を防いでいます(buf breaking を使っています)。

これと同じく BFF は GraphQL スキーマの Breaking Change を GraphQL Inspector を使って検知しています。 GraphQL Inspector は CLI に加えて GitHub Action も提供しています。開発中、手元では CLI による検証を行い、 CI では GitHub Action による検証を行います。GitHub Action では Breaking Change が発生している箇所にアノテーションをつけてくれます。

GraphQL Inspector GitHub Actions

DataLoader を使って Batch Request に対応する

NestJS を使った GraphQL の Resolvers の定義方法については前述しました。非常にシンプルなコードで表現することができますが、以下のような GraphQL クエリを書いた場合、Resolvers の実装方法によってはとても非効率になってしまいます。

query {
  shops(first: 100) { // Shop が 100 件取得される
    edges {
      node {
        name,
        thumbnail { // 取得した Shop ごとに thumbnails フィールドの内容を取得する
          imageUrl
        }
      }
    }
  }
}

フィールドの Resolvers では、GraphQL クエリ全体のコンテキストはわからないため、親の情報のみをもとにしてデータを返却する必要があります。 例えば以下のような Resolvers の実装にしてしまうと、Shop を 100 件取得した場合は Asset API を 100 回呼び出すことになります。

// shop.resolver.ts
// …
@Resolver(() => Shop)
export class ShopResolver {
  constructor(
      private readonly assetService: assetService,
      // ...
  ) {}

  // …

  @ResolveField(() => Asset, { nullable: true })
  async thumbnail(@Parent() { thumbnailId }: Shop) {
      return this.asserService.find(thumbnailId); // 1 件取得する Asset API 呼び出し
  }
  // …
}

DataLoader を利用する前の概念図

GraphQL ドキュメンテーションの「Server-side Batching & Caching」でも紹介されていますが、 Batch Request (以下バッチリクエストと表記します、複数のリソースを一度に取得するためのリクエストのことを意味します)と DataLoader を利用することによりこの問題を回避することができます。 DataLoader は、イベントループの単一フレーム内で実行されたすべての DataLoader.load(key)key を、ひとつのバッチリクエストのパラメータとしてまとめて送信します。

DataLoader を利用する場合の概念図

以下に gRPC サーバでの BatchGet API の定義例と DataLoader の実装例を挙げます。

BatchGet API の定義

以下は gRPC サーバの Protobuf 記述例です。

この例では、取得したい Asset を指定する id のリスト(以下の例の場合は ids が該当します)を受け取って、Asset のリストを返す rpc を定義します。

// Protobuf による gRPC の API 定義

// BatchGetAssets returns a list of requested assets.
rpc BatchGetAssets(BatchGetAssetsRequest) returns (BatchGetAssetsResponse) {}

message BatchGetAssetsRequest {
  repeated string ids = 1;
}

message Asset {
  // ...
}

message BatchGetAssetsResponse {
  repeated Asset assets = 1;
}

// ... 

NestJS で DataLoader を利用する

用意した バッチリクエスト API を 呼び出す DataLoader を実装します。 DataLoader はコンストラクタに BatchLoadFn 関数を受け取ります。key のリストを受け取って、key に対応する結果のリストを返すという関数です。 この BatchLoadFn 関数内で バッチリクエスト API を呼び出します。

type BatchLoadFn<K, V> =
    (keys: ReadonlyArray<K>) => PromiseLike<ArrayLike<V | Error>>;

Asset のための DataLoader 実装は以下のようになります。

// asset.dataloader.ts
// ...
@Injectable({ scope: Scope.REQUEST })
export class AssetDataLoader extends DataLoader<string, Asset> {
  constructor(private readonly assetService: AssetService) {
    super((keys: string[]) => {
      return this.assetService.findAll(keys); // BatchGetAssets の呼び出し
    });
  }
}

thumbnail フィールドの解決には、この AssetDataLoader の load() を使います。load() は DataLoader から継承したメソッドです。

// shop.resolver.ts
// …
@Resolver(() => Shop)
export class ShopResolver {
  constructor(
      private readonly assetDataLoader: AssetDataLoader,
      // ...
  ) {}

 // …

  @ResolveField(() => Asset, { nullable: true })
  async thumbnail(@Parent() { thumbnailId }: Shop) {
    if (!thumbnailId) {
      return null;
    }
    try {
      return this.assetDataLoader.load(thumbnailId);
    } catch {
      return null;
    }
  }

  // …
}

DataLoader.load(key) は内部で BatchLoadFn を使って取得した結果を key ごとの Promise にして返します。

DataLoader はデフォルト設定だと BatchLoadFn に渡す key の数を制限しません。バックエンド API の仕様として key の最大数を制限していると DataLoader.load(key) の呼ばれる回数によってクエリの実行が失敗してしまう場合があります。 例えば、上記の gRPC API の定義の BatchGetAssets が、BatchGetAssetsRequest.ids が 1000 件以上だとエラーになってしまうような仕様だと、DataLoader.load(key) が 1001 回呼ばれると DataLoader は BatchGetItemsRequest.ids に 1001 件の key を指定してリクエストを送信してしまいます。

このようなことを防ぐため、 BatchLoadFn で呼び出すバックエンドの API の仕様に合わせて、 maxBatchSize オプション を指定しておきます。DataLoader は maxBatchSize に達した時点でバッチリクエストを分けてくれます。

また、メルカリ Shops では、Microservices にリクエストを送信する DataLoader と、Redis をキャッシュとして利用する DataLoader とを取得対象のリソースにより使い分けています。更新頻度が高くないデータについては後者を使うことによって高速化を図っています。

認証情報の受け渡しとアクセス制限

フロントエンドとバックエンドの Microservices の間に存在する BFF レイヤでは、認証情報の受け渡しと、その認証情報に応じたアクセス制限が必要です。

メルカリ Shops では GraphQL のクエリ内容に応じて、リクエストに必要な認証情報が含まれているか、また必要な権限があるかを検証しています。 具体的には、NestJS の @Resolver() コンポーネントで、アクセス制限のために実装したデコレータを使うことによってこれを実現しています。

// shop.resolver.ts
// …
@Mutation(() => Boolean)
@RequireAuth(‘role’) // @RequireAuth で認証情報の検証
 updateShop(
    @AuthContext() ctx: AuthContext, // @AuthContext で ctx に認証情報を含める
    @Args('updateShopInput') updateShopInput: UpdateShopInput
) {
  return this.shopService.updateShop(ctx, updateShopInput); // gRPC API 呼び出し時に認証情報を渡す
}

カスタムデコレータの @RequireAuth()@AuthContext() は以下のような実装になります。

import {
  applyDecorators,
  CanActivate,
  ExecutionContext,
  Inject,
  Injectable,
  SetMetadata,
  UseGuards,
} from '@nestjs/common';

export function RequireAuth(...roles: Role[]) {
  return applyDecorators(
    SetMetadata('roles', roles),
    UseGuards(AuthGuard)
  );
}

@Injectable()
export class AuthGuard implements CanActivate {
  constructor(
    private reflector: Reflector,
    @Inject(AuthService) private authService: AuthService
  ) {}

  async canActivate(context: ExecutionContext): Promise<boolean> {
     const ctx = GqlExecutionContext.create(context);
     const req: Request = ctx.getContext().req;

     // req から認証情報を取得する
     // 認証情報の検証、 role を取得する

     // reflector を使って制限する role を取得する
     const requiredRoles = this.reflector.get<Role[]>('roles', ctx.getHandler())

     // roles の検証
     // …
  }
}
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
import { GqlExecutionContext } from '@nestjs/graphql';
import { Request } from 'express'
import { Metadata } from 'grpc';

// ...

export type AuthContext = {
  metadata?: Metadata; // gRPC API 呼び出し時に認証情報を渡すため Metadata をセットしておく
};

export const AuthContext = createParamDecorator(
  async (data: unknown, ctx: ExecutionContext): Promise<AuthContext> => {
    const gqlctx = GqlExecutionContext.create(ctx);
    const req: Request = gqlctx.getContext().req;
    const metadata = new Metadata();

    // req の内容をもとに Metadata.set() で認証情報をセットする

    return {
      metadata: metadata,
    };
  }
);

おわりに

この記事では、メルカリ Shops での NestJS を使った GraphQL Server の実装についてご紹介しました。 BFF レイヤの実装においては、どのような手法を使うのか、GraphQL サーバを採用するのか、また GraphQL サーバを採用するならどの実装を使うか、など様々な選択肢があるかと思いますが、この記事が参考になりましたら幸いです。

また、8月18日(水) から「ソウゾウ TECH TALK」というイベントが開催されていますが、9月8日(水)の #04:Backends For Frontends の回では今回の話題も扱いますのでご興味があればご参加いただけるとうれしいです。 ソウゾウ TECH TALK では Backends For Frontends 以外にも様々なテーマを取り扱っていますので、他にも気になる回がありましたらぜひご参加ください。

メルカリ Shops を開発・運用している株式会社ソウゾウでは絶賛採用中です。 ソフトウェアエンジニアだけではなく、様々な職種で募集しています。ポジションはこちらを参照してください。興味があって話を聞いてみたい、という場合はカジュアル1on1を設定しておりますのでこちらもご検討ください。

明日 8月20日は Software Engineer @wakanapo さんの「メルカリShopsのML立ち上げ奮闘記」が公開予定です。お楽しみに!