💎

GraphQL APIを開発するときに気をつけるべき点をまとめたドキュメントを公開します

2024/07/02に公開

弊社(トラストハブ)では、すべてのプロダクトのAPIにGraphQLを採用しています。
しかし、私が入社した時点では、GraphQLの設計に課題がありました。Query, Mutationの設計に統一性がなく、REST的な設計の名残も見られました。そのため、GraphQLの利点を十分に活かせていない箇所が多く存在していました。
自分は以前から業務でGraphQLを使ったAPIを作るために、海外でのGraphQLに関するドキュメントや、Shopify GraphQL APIドキュメントを読んで実践してきたので、この部分で貢献できると思いました。

そこで入社後にまずGraphQLのAPIをこういうルールで作ってみませんか?と提案するドキュメントを書いて、新規で作られるAPIについてはそのルールを使うようメンバーに布教する活動を行っていました。今では、ほとんどのコードではAPIの設計に統一性を持たせることができ、GraphQLの良さを最大限に活かせるようになっています。

この記事は、そのときに社内に展開したドキュメントを公開用に調整したものです。GraphQL API設計で気をつける点について、基礎的なトピックをカバーすることを意識して書いておりますので、ぜひ読んでいただけると幸いです。

GraphQLの出発点: REST的発想を捨てよう

なぜRESTではなくGraphQLを使うべきなのでしょうか?その理由は、長年REST APIがつくられてきた中で、幾つもの問題が発見されており、それを解決する手段が求められたからです。

REST APIの問題意識の一部として、クライアントが欲しいデータ構造に最適化できないという課題がありました。

  • アンダーフェッチ問題:リソースごとのAPIしか作っていない場合、一つの画面を作るために複数のAPIを叩く必要があり、リクエストの頻度が増えてしまうという課題
  • オーバーフェッチ問題:一つのリソースに関連する他のリソースを、単一のAPIでまとめて提供することにより、画面によっては不要なtable joinをしてしまったり、responseのデータ量が増大してしまうという課題
  • 上記の2つの問題を避けるために、画面ごと・ユースケースごとにカスタムされたAPIをどんどん作っていくことで、コードの分量が多くなってしまい、開発のアジリティが下がっていくという課題

GraphQLでは、部分フェッチグラフ構造という特性を活かして上記の問題を解決しています。この強みを活かしたAPI設計を心がけるべきでしょう。

  • 部分フェッチとは、queryに書いた必要なリソースだけを動的にデータベースから取得することで、不必要なデータを取得しない仕組みのことを指します。
  • 汎用的なqueryを用意するだけで、パフォーマンスを気にせずにほとんどのユースケースに対応ができます。そのため、画面ごとに新しいqueryを作る必要がなく、開発効率が上がります。
  • リソース間のリレーションをグラフとして表現することで、必要なデータをどんどん掘り下げることができます。これにより、一回の描画に複数のリクエストを投げることなく、一回のリクエストで完結させられます。

なお、部分フェッチを実現するために具体的にどのようなコードを書くべきかは、今後別の記事で公開しようと思います。

フロントエンドの複雑な表示分岐ロジックはバックエンドに寄せてしまおう

例えばフロントエンド開発において、リソースにおける複数のGraphQLのfieldの組み合わせ状態ごとに、画面の表示を分岐する要件があったとします。このようなコードは、得てして複雑になりやすく、書いた本人にも数ヶ月後にはすぐに解読できないコードになりやすいものです。

  • フロントエンドにとって理想的なAPIとは、単一のステータスfieldを参照するだけで、表示すべき画面の分岐判定が完結することです。このような状況ができることで、JSONに色付けをするだけの簡潔なコードを書くことができるでしょう。
  • まだまだフロントエンドはユニットテストを書かれることが少ないので、フロントエンドになるべく複雑なロジックを持たせない方が良いでしょう。

部分フェッチによって、ただDBのカラムを露出させるだけでなく、例えば画面の状態を表現するステータスカラムのようなカスタムfieldを作成することが現実的になります。

また、APIにロジックを寄せることで、アプリ開発など、クライアントが増えたときに楽できるというメリットもあります。

GraphQLに相応しい命名規則

GraphQLの基本的な考え方を理解したところで、次はGraphQLに相応しい命名規則について見ていきましょう。適切な命名は、APIの一貫性とわかりやすさ、何よりコードの書きやすさを大きく向上させます。

命名規則に関してはチームごとにルールを決めるものではありますが、中でも自分が合理的だと思った命名規則を紹介します。

自分が一番参考とするのはShopifyです。ShopifyのGraphQL APIはこまめにAPIがアップデートされており、Production向けのAPIとして最も完成度が高いAPIの一つです。

https://shopify.dev/docs/api/admin-graphql

Queryの粒度と命名規則

ShopifyのAPIドキュメントで商品の注文情報を表す Order Typeを直接取得するQueryを見てみると、以下の2つしかないことに気付くでしょう。

order(id: ID!): Order
orders(...arguments): OrderConnection!

ここから読み取れるQueryの設計思想と命名規則は以下です

  • 各画面の用途向けに細かいQueryを作るのではなく、単体データ取得と複数データ取得の2種類という、汎用的なQueryだけを用意することで多くのユースケースをカバーするという設計思想が見て取れます。
  • order, ordersという名前には get のようなアクション動詞や、 ~ById のようなどのfieldを取得キーとするかの名前が付いていません。この理由としては以下のものが考えられます。
    • Queryはそもそもデータを取得するものという前提があり、わざわざ動詞をつけるのは冗長という考え方があるため
    • argsには id のような引数が書かれており、わざわざどのキーを使っているかをQuery名にする必要がないため
    • 何より、GraphQLではQureyは取得できるリソースをシンプルに表す、というルールが一般的になっているため

このような取得系Queryの命名規則の問題は、GraphQL用のコードジェネレータを使うことでルールを統一できて、開発コストがかなり落ちるので積極的に採用を検討すべきでしょう。

Queryの単体レコード取得について

以下のように、単体レコードを取得するQueryは引数がidだけになっています。

また、return typeはnullableとすることで、フロントエンドから扱いやすい状態にするのが良いでしょう。

order(id: ID!): Order

Queryの複数レコード取得について

Shopifyでは、複数レコードを取得するQueryのreturn typeとしてConnection型が指定されています。具体的には以下のような定義になっています。

orders(
    first: Int
    after: String
    last: Int
    before: String
    reverse: Boolean = false
    sortKey: OrderSortKeys = ID
    query: String
  ): OrderConnection!

type OrderConnection {
  edges: [OrderEdge!]!
  pageInfo: PageInfo!
}

type OrderEdge {
  cursor: String!
  node: Order!
}

type PageInfo {
  hasNextPage: Boolean!
  hasPreviousPage: Boolean!
}

## orders queryのレスポンス例
{
  "orders": {
    "edges": [
      {
        "node": {
          "id": "gid://shopify/Order/126216516"
        }
      },
      {
        "node": {
          "id": "gid://shopify/Order/148977776"
        }
      },
      {
        "node": {
          "id": "gid://shopify/Order/235240302"
        }
      }
    ]
  }
}

これはRelay Specificationという標準仕様に基づいたものです。Relay Specificationでは、複数のレコードを返すQueryにおいて、Connection, Edgeというtypeを使うことが定められています。

  • Connectionは、複数レコードを分割して表示するページネーション表示のために、pageInfoという次のページがあるのかなどのデータを取得できるfieldが含まれます
  • Edgeは、ここのノード(レコード)と、cursorと呼ばれるページネーションのための追加情報を取得できるfieldが含まれます。

これに準拠することで、Apollo ClientやRelayなどのGraphQL Clientでページネーションを取り扱いやすくするというメリットを受けられます。

https://relay.dev/docs/guides/graphql-server-specification/

これに関しても、より詳細な説明は今後別記事で解説をしたいと思います。

Mutationの命名規則

Orders に関連するMutationの一覧を見てみると、命名規則が見えてきます。

orderCapture($input: OrderCaptureInput!): OrderCapturePayload!
orderEditBegin($id: ID!): OrderEditBeginPayload!
orderEditAddCustomItem(...arguments): OrderEditAddCustomItemPayload!
orderOpen($input: OrderOpenInput!): OrderOpenPayload!

order + Action名 という順番でMutationが定義されています。この理由として、

  • GraphQL Schemaファイルを見た時に、リソース順にMutation一覧がソートされる
  • Queryを書くときのエディタの支援もリソース名を先に入力してから候補一覧を絞り込むことができる

など、実用的なメリットが多いためだと思われます。

動詞の選び方

アクションを表す動詞についてどのような単語を採用すべきかについて、Shopifyの社内向け命名規則では以下のような説明がされています。

手始めに、CRUD動詞(create, read, update, delete)の名前をデフォルトとするのをやめてください。 データベースのステートメントは、CRUD動詞を使ってよく記述されていますが、それらはAPI利用者から隠されるべき実装の詳細です。

もし、より適切な意味のある動詞があれば、それを使いましょう。 たとえば、主な結果がコレクションの非公開であれば、collectionDeleteは使わずに、collectionUnpublishとしましょう。

おまけ:

field名にIDやURLなどといった全て大文字からなる単語が含まれていても、Shopifyではすべてキャメルケースに統一しています。例えば、 onlineStorePreviewUrl のように命名されています。

単語ごとに大文字にする、というルールは、新しい単語が生まれるたびに「これは大文字にすべきか、キャメルケースにすべきか」という思考が生まれてしまいます。そういった雑念をなくすために、分岐が無いルールに統一しているのだと思われます。

Queryの引数は、その数によってargsを使うかinputを使うかを変えても良い

Shopify APIを見ると、Mutationごとにargsとinputが使い分けられていることがわかります。

fulfillmentCancel($id: ID!): FulfillmentCancelPayload!
fulfillmentCreateV2($fulfillment: FulfillmentV2Input!): FulfillmentCreateV2Payload!

## fulfillmentCreateV2で定義されているinput
input FulfillmentV2Input {
  trackingInfo: FulfillmentTrackingInput
  notifyCustomer: Boolean = false
  lineItemsByFulfillmentOrder: [FulfillmentOrderLineItemsInput!]!
}

いろんなMutationを見てみると、引数の数が少ないときはargsを使い、引数が多いときはinputを使っているという法則が見えてきます。

  • この理由は、引数として id しか渡さない場合、わざわざinputを別ファイルに作るのはコードの見通しが悪くなるためだと思われます

では、具体的に何個のパラメータを取り扱うときに、argsとinputを分けるべきなのでしょうか?ここはShopify APIの中でも決まったルールは無いため、個人的な解釈なのですが、境界線は “argsの数が2つ” または “argsの中にネストが深いオブジェクトが存在するか” だと考えています。 argsが2つ程度なら、エディタで見たときにresolverの定義に書かれていても見やすいなと感じます。そして、3つ以上になる場合、またはネストが深いオブジェクトを入力したい場合はinputを使って分割すべきでしょう。

Mutationのreturn typeには、更新したリソースを含む専用のPayload型を使う

Mutation用のPayload型を作るのには、2つの理由があります:

理由1: mutationごとに追加で返したい情報がある時に、フィールドの追加が容易になる

mutationの返り値として返したい情報は、mutationで操作する対象のDBに保存するレコード情報だけとは限りません。

事例として、Stripe Checkoutの決済ページを作成するmutationを考えてみましょう。まずは、直接操作するレコードをreturn typeにする例を挙げてみます:

type Mutation {
  stripeCheckoutSessionCreate(input: CreateStripeCheckoutSessionInput!): StripeCheckout!
}

type StripeCheckout {
  id: ID!
  checkoutSessionId: String
  user: User!
}

stripeCheckoutSessionCreateは、ユーザーに決済ページにリダイレクトさせるためのURLを生成して返すというAPIです。なので、ユーザーにURLを渡してあげる必要がありますが、このURLは一時的なもので呼び出されるたびに新しいURLが生成されますし、そのようなものをStripeCheckout Typeで取り扱うべきものにも見えないという課題があります。

もしMutationにreturn専用のTypeを定義していると、フィールドの追加が簡単になります。例え初期の開発時には、returnに新しく追加したいフィールドがなかったとしても、後からフィールドを追加したくなるかもしれません。拡張性・統一性のためにも、全てのMutationの返り値Typeは専用のTypeにすべきでしょう。

type Mutation {
  stripeCheckoutSessionCreate(input: CreateStripeCheckoutSessionInput!): StripeCheckoutSessionCreatePayload!
}

type StripeCheckoutSessionCreatePayload {
  stripeCheckout: StripeCheckout!
  checkoutUrl: String!
}

おまけ:mutationの返り値のType名は、末尾にPayloadという名前をつけるというルールにすると、毎回名前に悩まずに済むためおすすめです。Shopifyでもそのようにルール化されています。

理由2: Clientのキャッシュの最適化のため

例えばApollo Clientでは、各typeごとにKeyとなるfieldで正規化されたレコードのキャッシュをローカルに保存するのがデフォルト設定になっています。

また、urqlではqueryとvariableをkeyとしたドキュメント型のキャッシュがデフォルトになっています。

このように、各クライアントライブラリごとに様々なキャッシュ戦略がありますが、多くのクライアントにおいて「mutation発火後に、そのmutationのresponseに含まれるrecordのTypeをキャッシュ更新・キャッシュクリアする」という共通の方針が採用されています。

そのため、更新したリソースのTypeは変にいじらずに、それを内包するPayload型を作って入れてあげることで、キャッシュ更新に対応しやすいでしょう。

よっぽどな理由がない限り、Json型を使わない

GraphQLのSchemaで型が厳密に定義できるという特徴を活かすために、Jsonのようななんでも使えてしまう型は、本当にJSONを扱いたいとき以外は禁止にした方が良いと思われます。Json型を気軽に使うのは、Typescriptでanyを使ってしまうようなものでしょう。

例えば、Shopifyでは画像を返すための専用のTypeである Image を用意しています。このタイプには、画像のサイズ情報やaltなど、フロントエンドで画像を表示するために必要な情報がTypeに含まれています。

複数の画像を返したいときは、 [Image] のようにTypeを配列にすることで対応が可能です。

https://shopify.dev/docs/api/admin-graphql/2023-04/objects/Image

過負荷対策

サーバーに意図的に過負荷をかけようとする攻撃者がとる攻撃方法として、代表的なものにはDoS攻撃と複雑なクエリによる過負荷が挙げられます。このセクションでは、これらの攻撃に対する GraphQL ならではの対応策について紹介します。

DoS攻撃

DoS攻撃とは、API endpointに対し、大量のリクエストをすることで攻撃する手法のことです。GraphQLに限らず、すべてのAPIでリスクがあるものでしょう。

DoS攻撃は古典的な手法であり、対応も複数考えられます。

  1. フレームワークレイヤーで、レートリミットを設定する。例えばNestJSでは @nestjs/throttler ライブラリを利用できる。 https://docs.nestjs.com/security/rate-limiting
  2. Load Balancerなどのレイヤーで、同一IPからの過剰なリクエストを制限する

複雑なクエリによる過負荷

複雑なクエリをリクエストするのは、GraphQLならではの攻撃手法です。GraphQLではクライアントでレスポンスの内容を決定できるので、再起的にデータを取得させることで簡単に負荷の高いクエリを投げることができてしまいます。以下はその例です:

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

これを見た時に、「無限にQueryをネストできないように、個別のresolverを作成することで制約を入れよう」という発想になってしまうかもしれませんが、そのようなことをしだすと 1 query = 1 resolver となってしまい、汎用的なAPIを提供するというGraphQLのメリットを消してしまうことになるため推奨できません。

この対応策として、GraphQLでは「Queryのコスト」という考え方を導入するのが一般的です。Queryにおけるネストの深さに制限をつけたり、ComplexityというQueryの複雑さを表す数値で制限をかけることで、GraphQLのメリットを残しつつ対策ができるというものです。

ShopifyやGitHubのようなGraphQL APIを提供している企業では、一定時間あたりのコストに制限をかけるなど、要素の組み合わせで提供をしています。

NestJS公式ドキュメントでは、 graphql-query-complexity ライブラリを利用する方法が紹介されています。最初から複雑なものを導入する必要はなく、リクエストごとに最大コストを設定しておくのが始めやすい方法でしょう。

https://docs.nestjs.com/graphql/complexity

また、Persisted Queryと呼ばれる手法で想定していないQueryを叩けないように制限する手法もありますが、これは上記で説明した方法を導入してから追加で導入を検討すべき項目だと思います。

N+1の回避方法

複数のレコードを取得するQueryにおいて、N+1が発生することに注意が必要ですが、GraphQLではBatch Loaderという仕組みで解決することができます。これについてはコード例をしながらの説明がわかりやすいと思うので、今後別の記事を書きたいと思います。

まとめ

本記事では、GraphQL APIを設計するときに自分が考えているポイントをいくつか紹介しました。GraphQLの導入を考えている方は、ぜひ参考にしていただけますと幸いです。また、この記事では紹介できなかったトピックがいくつかありますので、別の記事を書きたいと思います。

付録

Shopifyの社内向けGraphQL API設計ドキュメントが日本語化されています。EコマースのためのAPIを作るチュートリアルとなっており、とても参考になりますので、興味がある方はこちらもぜひ見てみてください。

TrustHub テックブログ

Discussion