2022-09-20

新プロジェクトのバックエンド開発にGraphQLを採用してみた話

Gompei

はじめに

こんにちは。ソフトウェアエンジニアの@Gompeiです。
2022年5月に入社後、スマートマットクラウドのバックエンド領域を担当していました。

今回は新プロジェクトにおいてGraphQLを採用したので、現時点までのバックエンド開発を通しての感想・今後対応予定のパフォーマンス課題についてまとめていきます。
(ちなみに本プロジェクトではApolloを採用しています。)

目次

  • はじめに
  • GraphQL採用理由
    • Backend For Frontend(BFF)
    • GraphQL Federation
  • GraphQLを採用したバックエンド開発を通して
  • パフォーマンス課題
    • Model Resolverの作成
    • Dataloaderの導入
  • 終わりに

GraphQL採用理由

今回GraphQLを採用した理由は主に3点です。

  • GraphQL Federationを導入することで、BFFの実装を不要にしたい
  • データのオーバーフェッチを防ぐ
  • スキーマファーストによる開発で、バックエンド・フロントエンドエンジニア間のコミニケーションコストを減らしたい

本プロジェクトでは、1点目の「GraphQL Federationを導入することで、BFFの実装を不要にしたい」が一番の採用理由の為、こちらについて説明していきます。

Backend For Frontend(BFF)

まずBFFとは「フロントエンドとバックエンド双方の複雑な処理を吸収するアーキテクチャ」の1つです。 当社サービスのスマートマットクラウドではこのBFFアーキテクチャを採用しており、フロントエンドはBFFサーバーに対してリクエストすることで、各画面に必要なデータを取得できる環境でした。

BFF-Architecture

しかしBFFはAPI Gatewayと違い、各サービスのレスポンスを加工する責務を持つケースがあり、またそれらの処理を手動で実装・修正するのに工数コストが高くなりがちで、開発スピードを求める新プロジェクトにおいては、それらのコストが課題になっていました。

GraphQL Federation

GraphQL Federationは、複数のGraphQLサービスをGatewayで集約して、1つのGraphQLエンドポイントとして提供するアーキテクチャです。

Gatewayは、スキーマをAPI経由で取得できるIntrospectionというGraphQLの機能を使用して、各サブグラフのスキーマを取得・統合してくれます。 その為クライアント側は、1つのクエリで複数のサブグラフにまたがるデータを取得することができます。
(各サービスが提供するGraphQL APIをサブグラフと呼びます。)

GraphQL-Gateway-Architecture

またデータ取得時も、Gatewayが各サブグラフから取得したデータを自動的にマージしてくれるので、データを加工するロジックを手動で実装する必要がありません。 そして各サブグラフを登録する設定も非常に簡単なので、BFFの実装コスト削減も兼ねて、 Federation環境の構築を前提でGraphQLを採用しました。
(補足で、機能要件として複数のマイクロサービスにリクエストが必要でした。)

import { IntrospectAndCompose } from '@apollo/gateway';
import { ApolloGatewayDriver, ApolloGatewayDriverConfig } from '@nestjs/apollo';
import { Module } from '@nestjs/common';
import { GraphQLModule } from '@nestjs/graphql';

@Module({
  imports: [
    GraphQLModule.forRoot<ApolloGatewayDriverConfig>({
      driver: ApolloGatewayDriver,
      server: {
        // ... Apollo server options
        cors: true,
      },
      gateway: {
        supergraphSdl: new IntrospectAndCompose({
          // ここにサブグラフのGraphQLエンドポイントを追加していく
          subgraphs: [
            { name: 'users', url: 'http://user-service/graphql' },
            { name: 'posts', url: 'http://post-service/graphql' },
          ],
        }),
      },
    }),
  ],
})
export class AppModule {}

余談ですが、本プロジェクトではGatewayをNode・NestJS、ServiceはGolang・gqlgenで実装しています。

GraphQL-BE-Architecture

GraphQLを採用したバックエンド開発を通して

現時点までのGraphQLを使用したバックエンド開発を通して、スキーマファーストによる開発が個人的に好きになりました。 RESTでもOpenAPI Generatorなどを使用すれば、上記のような開発は可能だと思いますが、弊社では使用していないのと、GraphQL・gqlgenの組み合わせが便利だと思っています。

スキーマファーストによる開発で、バックエンド・フロントエンドエンジニア間のコミニケーションコストを減らしたい

GraphQLスキーマさえ定義できていれば、バックエンドの実装が遅れていてもフロントエンドの判断で作業を進められるようになり、コミニケーションコストを減らすことができたと思いました。

スキーマファースト以外のメリットとしては、RESTと違いAPIエンドポイントが1つなので、API設計で悩む手間が無くなりました。 (GraphQLスキーマの設計で悩むことはありますが...)

データのオーバーフェッチを防ぎたい為

上記の採用理由についてですが、こちらはGraphQL Server実装時にデータのオーバーフェッチを防ぐ仕組みを取り入れる必要があります。 具体的な例を本記事の「パフォーマンス課題」に記載しており、リリースまでの課題としています。 (現在対応中です。)

その他にも、「エラーハンドリング」周りや、「APQの導入」・「クエリコストの数値化」などがプロジェクトの課題として残っており、本プロジェクトリリース後に実装例や知見を記事化できればと思います。

パフォーマンス課題

新機能をリリースするまでに解決したいパフォーマンス課題が2点あるので、それらを記載していきます。

Model Resolverの作成

この課題については、本プロジェクトでGraphQLを採用した理由の1つ、「データのオーバーフェッチを防ぎたい」に関連しています。

まずGraphQLの特徴として、「クライアント側が欲しいデータのみを選択できる」というのが挙げられます。 ですが下のGraphQLスキーマをgqlgenでコード生成した場合、デフォルトだとpersonクエリ実行時に、子オブジェクトであるcarがクエリで指定されているか判別できない為、クエリ実行毎に無駄なデータをDatabaseから取得しなければなりません。
(これから説明するgqlgenのModel Resolver機能ではなく、Field Collection機能を使用してオーバーフェッチを防ぐ方法の1つです)

  • GraphQLスキーマ
type Person {
    id: ID!
    name: String!
    # サンプルなのでnullableにしていません
    cars: [Car!]!
}

type Car {
    id: ID!
    name: String!
    make: String!
    model: String!
    color: String!
    owner: Person
}

type Query {
    person(id: ID!): Person!
}
  • クエリ一例
{
  # carsが指定されているか判別できない為、
  # クエリ実行毎にcarデータも取得しないといけない
  # (queryResolver)
  person(id: 1) {
    id
    name
  }
}

解決策として、各Model用のリゾルバを作成することにより、各オブジェクトがリクエストされた際のみ処理を実行することができるようになります。 本記事はgqlgenの機能紹介が本題ではないので、実装方法についてはドキュメントを添付しておきます。

現段階では機能実装を優先して開発しているので、時間のあるタイミングでリゾルバを分離していきたいと考えています。

func (r *queryResolver) Person(ctx context.Context, id uint64) (*gqlModel.Person, error) {
    // ここにデータ取得処理を実装
    panic(fmt.Errorf("not implemented"))
}

// Carオブジェクトがリクエストされた際のみ実行される
func (r *personResolver) Cars(ctx context.Context, obj *gqlModel.Person) ([]*gqlModel.Car, error) {
    // ここにデータ取得処理を実装
    panic(fmt.Errorf("not implemented"))
}

type personResolver struct{ *Resolver }

DataLoaderの導入

先ほど「Model Resolverの作成」について記載しましたが、子オブジェクト取得時にDataLoaderを導入していないとN+1問題が発生してしまい、Databaseに都度アクセスしてしまうことになります。

DataLoaderを簡単に説明すると、「Loaderにデータを取得するためのidなどを保存しておき、一定時間後に保存された値を元にまとめてDBに問い合わせる」処理を可能にするライブラリになります。

本プロジェクトでも、パフォーマンス対策の1つとしてDataLoaderを導入予定です。

  • DataLoader導入前

GraphQL-DataLoader-Before

  • DataLoader導入後

GraphQL-DataLoader-After

有名なDataLoaderのライブラリとしては、GraphQLを開発しているMeta(facebook)が公開している「graphql/dataloader」などがあります。

今回バックエンドをGolangで実装しているので「graph-gophers/dataloader」の使用を検討しています。

終わりに

前の会社でHasuraを少し使用していたのですが、GraphQLについてはほぼわからないことだらけで、開発当初は苦戦しました。 ですが開発や個人学習が進むにつれ、GraphQLのメリットが大きいことに気づき、本プロジェクトで採用して良かったと思っています。 今後も開発などを通して、GraphQLについての知識や知見を増やしていこうと思います。

参考記事

最新の記事