🐙

GraphQLの概要とNestJSとの組み合わせ

2023/03/12に公開

軽く調べたのでまとめる

GraphQL基本的な用語

スキーマ

クライアントがクエリできるデータの型。
オブジェクトタイプで構成され、どの種類のオブジェクトをリクエストでき、どのフィールドを指定できるかを定義する。
クライアントからの呼び出しは、スキーマを通じて検証され、実行される。

type User {
  id: ID!
  firstName: String
  lastName: String
}

リゾルバー

スキーマに含まれる特定のフィールドのデータを返す関数。
実際のデータ操作を行う。
クライアントはgetUsersというクエリを実行できて、サーバーは0個以上の[User]の配列を返す

type Query {
  getUsers: [User]
}

クエリ

CRUDにおけるReadに相当する。

query getUsers {
  firstName
  lastName
}

ミューテーション

CRUD の create, update, delete

mutation {
  createUser (name : "John", username: "jo123"){
    name
    username
  }
}

Apollo Server

Apollo Serverは、JavaScriptで実装されたGraphQLサーバーで、クライアントからのGraphQL操作を処理します。バックエンド側のデータソースとのやりとりを行い、データの取得や変更を行うことができます。また、スキーマとリゾルバーもApollo Serverで定義することができます。

特徴・メリット

単一のAPIエンドポイントからのデータ取得

GraphQLの最大の利点は、単一のAPIエンドポイントを通じて、あらゆるデータにアクセスできることです。

アンダーフェッチ、オーバーフェッチなし

REST APIの場合、1つのAPIから取得できるリソースは1つだけです。1つのページを作り上げるために3つのリソースへのアクセスが必要な場合、3回のAPIコールが発生することになり、非効率です。このような問題をアンダーフェッチと呼びます。

同じリソースへのアクセスでも、全てのフィールドのデータが必要な場合と、2つのフィールドのデータだけが必要な場合があります。一般的なREST APIの場合、2つのフィールドだけ必要な場合でも、全てのフィールドのデータを取得してしまいがちです。この問題をオーバーフェッチと呼びます。GraphQLでは、クエリの中でリソースのどのフィールドを取得するかを指定する文法が規定されているため、クエリ内で宣言的に記述するだけで解決することができます。

強い型つけスキーマ

GraphQLでは、強い型付けが採用されています。クライアントによるサーバーデータへのアクセス方法を取り巻くパラメータの定義には、スキーマ定義言語(SDL)が使用されます。クライアントに公開されるすべてのAPIはSDLで記述し、RESTに見られるデータの不整合に関する問題に対処しています。

GraphQLとTypeScriptを組み合わせることで、GraphQLクエリの型安全性を向上させてエンドツーエンドの型付けを行うことができます。バックエンド・フロントエンドのどちらも型とコードを生成することができ、コンパイル時にチェックでき、自動補完、モックやAPIドキュメントの自動生成、directiveによるvalidation・permission仕様の明確化など、様々な恩恵を受けることができます。

注意点

学習の難しさ・スキーマの設計が難しい

スキーマ設計は一度サービスをリリースしてしまうと大きく変更を加えることは互換性の観点から困難になります。予めチームで方針をすり合わせてから開発するのがよい。

ファイルのアップロード

ネイティブのファイルアップロード機能がない。
Base64エンコーディングを使って対処することができるが、エンコードとデコードには時間と費用がかかることがある。
ライブラリとかで対応できる
graphql-upload

エラーハンドリング

エラーの場合も含めて、すべてのAPIリクエストに対して200Okのステータスを返す。

Request: query { books { error_field } }
Response:
Request Method:POST
Status Code: 200 OK
{“errors”:[{“message”:”Cannot query field \”error_field\” on type \”Book\”.”,”category”:”graphql”,”locations”:[{“line”:3,”column”:3}]}]}

パフォーマンス

  • 複雑なクエリ
    クライアントがあまりに多くのネスト状態のクエリを出すと、間違ったクエリが送信される可能性があり、サーバーにとって非常に時間のかかる処理になる

  • N + 1問題
    GraphQLでは木構造をたどりながらその都度リソースの読み込みを解決していくので、N+1問題が起こる
    DataLoader などを利用することでバックエンドのパフォーマンスを向上することが可能。
    PrismaもN + 1問題に対処してるので一緒に使用すれば対応できそう。

  • キャッシュ
    HTTPキャッシュ方式をサポートせず、ApolloまたはRelayクライアントのキャッシュ機構に依存している
    一方で、GraphQLのキャッシュはURLを生成せずにレスポンス内容も異なるので複雑

  • 認証
    GraphQL 自身はクエリ言語であり、認証自体の機構を持っていない。
    データストアとクライアントの間に存在するレイヤーであり、認証は完全に独立したレイヤー。
    REST が出来ることとは全く違うアプローチになる。

セキュリティ

クエリのコスト制限

クライアントでレスポンスの内容を決定できるので、簡単に負荷の高いクエリを投げることができる。
例えば次のように循環参照を利用すると無限の数のノードをリクエストすることができる。

query {
  shop(id: "xxx") {
    products(first: 100) {
      shop {
        products(first: 100) {
          shop {
            products(first: 100) {
              shop {
                …
              }
            }
          }
        }
      }
    }
  }
}

ネストに制限をかけたりリクエストに制限をかけたりなどの対処法はある

NestJS + GraphQL

NestJSは GraphQLModule を提供していて、これを使用してGraphQLサーバの実装を行える

GraphQLModuleは、 GraphQLサーバの実装である Apollo をラップしたもの。
GraphQLModule と GraphQLModule用のデコレータをコードに付与することで、GraphQLクエリのフィールドを解決する実装(Resolvers)を書きながら GraphQL のスキーマ定義を自動生成することができる。

NestJS は GraphQL の構築に2つの方法を提供しており,code firstとschema firstがある.

schema first

GraphQL SDL (Schema Definition Language)ファイルから TypeScript のクラスやインタフェースが自動生成されるというものであるとのこと.

code first

デコレータとTypeScriptクラスを使って対応するGraphQLスキーマを生成します。このアプローチはTypeScriptのみで作業を行い、言語構文間のコンテキスト切り替えを避けたい場合に有効

app.module.tsで読み込み・初期化
コードファーストのアプローチを使用するには、optionsオブジェクトにautoSchemaFileプロパティを追加する

// importは省略

@Module({
  imports: [
    GraphQLModule.forRoot({
      autoSchemaFile: path.join(process.cwd(), 'src/schema.gql'),
      sortSchema: true,
    }),
    PostsModule,
  ],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule {}

ObjectTypeを用意する
サービスから取得できるフィールドのコレクションで、各フィールドはタイプを宣言する。
定義された各オブジェクトタイプは、APIにおけるドメインオブジェクトを表す。
@Field((type) => String)の記述がGraphQL Schemaのためのデコレータ

import { Field, ID, Int, ObjectType } from '@nestjs/graphql';

@ObjectType()
export class UserModel {
  @Field((type) => ID)
  id: number;

  @Field((type) => String)
  name: string;

  @Field((type) => Int)
  age: number;

  @Field()
  createdAt: Date;
}

次にServiceとResolverを用意

@Injectable()
export class UsersService {
  create(newUser: NewUser) {
    return 'This action adds a new user';
  }

  findOne(): UserModel[] {
    return [
      {
        id: 1,
        name: 'Yuki',
        age: 10,
        createdAt: new Date(),
      },
      {
        id: 2,
        name: 'gaku',
        age: 90,
        createdAt: new Date(),
      },
    ];
  }

  update(id: number) {
    return `This action updates a #${id} user`;
  }

  remove(id: number) {
    return `This action removes a #${id} user`;
  }
}

@Resolver((of) => UserModel): UserModelに相当するスキーマを返すことを宣言。
このクラスでUserModelへ書いたすべてのフィールドのデータを取得できることを意味する。

@Query(() => [UserModel], { name: 'users', nullable: true }): usersというクエリが呼ばれたらこのメソッドを実行する動きになる

@Resolver((of) => UserModel)
export class UsersResolver {
  constructor(private usersService: UsersService) {}

  @Query((returns) => [UserModel], { name: 'users', nullable: true })
  getUser() {
    const user = this.usersService.findOne();
    if (!user) {
      throw new NotFoundException();
    }
    return user;
  }

  @Mutation((returns) => String)
  addUser(@Args('newUser') newUser: NewUser): string {
    return this.usersService.create(newUser);
  }

  @Mutation((returns) => Boolean)
  removeUser(@Args({ name: 'id', type: () => Int }) id: number) {
    return this.usersService.remove(id);
  }
}

下記コマンドでNestJS サーバを起動するとスキーマが自動生成される
各ファイルを修正すると自動でスキーマも修正される

npm run start:dev // "start:dev": "nest start --watch",
# ------------------------------------------------------
# THIS FILE WAS AUTOMATICALLY GENERATED (DO NOT MODIFY)
# ------------------------------------------------------

"""
A date-time string at UTC, such as 2019-12-03T09:54:33Z, compliant with the date-time format.
"""
scalar DateTime

type Mutation {
  addUser(newUser: NewUser!): String!
  removeUser(id: Int!): Boolean!
}

input NewUser {
  age: Int!
  name: String!
}

type Query {
  users: [UserModel!]
}

type UserModel {
  age: Int!
  createdAt: DateTime!
  id: ID!
  name: String!
}

Apollo Studioというものがあって、http://localhost:3000/graphql でApollo Studioを立ち上げて動作確認できる

Discussion