Open3

Production Ready GraphQL を読む

AltechAltech

前提

背景

Wantedly のサービスにおいて広い範囲で GraphQL の導入が進んでいる。

使い所としては、以下の図のようにバックエンドのシステムを使って Web アプリ、iOS アプリ、Android アプリを作るための API: Application Programming Interface として位置付けている。

これが業務的な背景で、自分個人の知識としては、初めてのGraphQL(O'Reilly Japan)を斜め読みして Web アプリの機能を大小ひとつずつ実装してみた程度。実は GraphQL のサーバーは書いたことがない。API の知識は gRPC など色々あるので全体的にそれでエスパーして補完している。

動機

  • GraphQL のスキーマ設計を業務で中心的にやっていくのでより適切なレビューできるようにしたい
  • 一般的なソフトウェア設計のお作法と GraphQL のスキーマ設計のプラクティスを対応づけたい

・・・ということで Production Readly GraphQL を一通り読もうと思う。

目次

  • An Introduction to GraphQL
  • GraphQL Schema Design
  • Implementing GraphQL Servers
  • Security
  • Performance & Monitoring
  • Tooling
  • Workflow
  • Public GraphQL APIs
  • GraphQL in Distributed Architecture
  • Versioning
  • Documenting GraphQL APIs
  • Migrating From Other API Styles
  • Closing Thoughts

※日本語の引用は DeepL による

AltechAltech

An Introduction to GraphQL

I call Endpoint-based any APIs based on an architecture that revolves around HTTP endpoints. These may be a JSON API over HTTP, RPC style endpoints, REST, etc.

エンドポイントベースの API と呼ぶの的確だなあ。GraphQL だとオブジェクトが中心になってエンドポイントが中心ではなくなると思っているので、良い表現だと思った。

In more recent years, the number of different types of consumers of web APIs has exploded.

異なるクライアントが増えている、ということが重要な社会的背景。大事。

異なるクライアントとは何かというと、この文脈では単にアプリケーション・プラットフォームが増えている(e.g. Playstation)ということではなく、API にカスタマイズを要求させるようなもの、としてみる方が良いだろう。

ちなみに、単一の固定的なエンドポイントでこういった要求に対処しようとすることを "One-Size-Fits-All" な API を作ることと言っていて、それは罠であるというのが本書のスタンス。複雑性の程度によるが、一定のラインを越えるとそれが罠になるのは確かだと思う。

Let’s Go Back in Time

Enter GraphQL

Netflix, Sound Cloud の事例。BFF パターンによる解決。

そして2012年ごろからの Facebook の取り組みと GraphQL の導入。Facebook プラットフォームのデータを取得する標準的な API である Graph API は自分も2014年に触っていたので、問題意識はそこにも透けて見えた。

ちなみにこの流れから暗に主張されていることは、GraphQL は BFF ではないということ。むしろ BFF で解決しようとした問題をより別の形で解決しようとしているのが GraphQL なので、GraphQL で BFF をやるというのは少し変な話かも、という疑いを持った方が良い可能性がある。

あと GraphQL という技術が色々誤解されがちなので「GraphQL とは何 でないか」という話が挿入されている。そして、GraphQL とはクエリ言語とそれを実行するサーバーエンジンのことだと言っている。

GraphQL is a specification for an API query language and a server engine capable of executing such queries.

試しに、 https://graphql.org/ を見に行ってみると、1行目にほぼ同じことが書かれていた。へー。

GraphQL is a query language for APIs and a runtime for fulfilling those queries with your existing data.

個人的に、GraphQL という技術をきちんと定義されたクエリ言語だと捉えていたので、その実行エンジンまで含むんだなあと少し認識を改めた。

Type System

Practically, the type system of a GraphQL engine is often referred to as the schema
A common way of representing a schema is through the GraphQL Schema Definition Language (SDL).

よく見ているやつは SDL という名前がついているらしい。これは型システムのことだと言っているが、 query なども含むのかな?それともそれはまた別なのかな?スキーマ定義言語、にどこまで含まれているかこの時点では不明。

Types & Fields

基本。type から始めてオブジェクト型を定義できること、その中にはスカラーフィールドだけではなく、別のオブジェクト型を返すフィールドを埋め込めること。それは query で取得できること。

Schema Roots

オブジェクトグラフに対してのエントリーポイントが必要。 Query という特別な型を定義することで、Query Root が定義できる。Query の他にも、スキーマの Root としては、Mutation と Subscription がある。

少し先取りになるが、エントリーポイントには API 呼び出し時には対応するキーワードが用意されていて、そのキーワードで対応する定義が使われる、という暗黙のルールがある(フィールドと同じように考えると query や mutation に含めるエントリーポイント自体複数並べられそうだが、これは実際どうなんだろう)。

(ところで、細かい話だが、type Query というのは Query という型を上書きしている、みたいなことになるんだろうか。あるいは、何回も type Query って書いたらどうなる?Query という型はあらかじめ空の定義があって、それを何らか再定義する、みたいな捉え方になりそう)

Arguments

Just like a function, a GraphQL field can define arguments that a GraphQL server can use to affect the runtime resolution of the field.

GraphQL のフィールドは引数を取れる。これが一般則。

クエリルートの定義にはこれが応用されているが、エントリーポイントでなければ引数が定義できない、というわけではない。

type Query {
  shop(id: ID!): Shop!
}

一般的なフィールドに引数がある例。

type Product {
  price(format: PriceFormat): Int!
}

ここで始めて、type 以外のキーワードから始める定義が出てくる。input type。これは GraphQL の世界観だと、第一級の型ではないっぽい雰囲気で書かれているので少し区別した方が良さそう。

input PriceFormat {
  displayCents: Boolean!
  currency: String!
}

Variables

変数。query の横にオペレーション名が書けて、そこに変数を入れられる。変数は $ プレフィックスを書くことで、引数(Arguments)と区別されている。

Aliases

エイリアス。フィールド名の前に、abcProduct: などとする。ユースケースに納得感があった。

This comes in useful when requesting the same field multiple times with different arguments.

Mutation

クエリルートとは別に、ミューテーションルートが定義できる。型は Mudation

基本はクエリと一緒だが、以下の点が異なる。

  1. Top-level fields under the mutation root are allowed to have side effects / make modifications.
  2. Top-level mutation fields must be executed serially by the server, while other fields could be executed in parallel.

1は自明だが、2が少し興味深かった。あと、これで上で書いた「複数のミューテーションを一回で投げられるのか」についての疑問も解消された(投げられる、ということだろう)。

Enum

列挙型。積極的に使っていこうね。

Abstract Types

抽象型として、インターフェイスとユニオンがある(この二つが併記して紹介されてるのちょっと面白い)。キーワードは素直に interfaceunion。構文は、interfacetype と同じで、union| 区切り。

どちらも複数の型をまとめることができ、個別の型のフィールド要求に対しては ...on SpecificProduct {} で指定可能。これはインラインフラグメント。

インターフェイスを使うと、共通のフィールドを取るクエリがより単純になるのが利点(メモ:実務的には TypeScript などクライアント言語の型システムにどうマッピングされるかは確認した方が良さそう)。全く違うものを合成するなら、明らかにユニオンなのでそのバランスだろう。

抽象型は便利だけど注意が必要だから後で紹介するね、みたいなことが書かれている。

Fragment

フラグメントは fragment キーワードで個別に定義可能。fragment HogeFragment on Product {} のように型に紐づける。...HogeFragment で埋め込める。

Directive

ディレクティブ。

Directives provide clients with a way to annotate fields in a way that can modify the execution behavior of a GraphQL server.

ディレクティブを付けられる対象は、フィールドだけではなく、型定義に対しても付けられたりする。

type SpecialType @featureFlagged(flag: "secret-flag") {
  secret: String!
}

この例だと、開発用のスキーマには SpecialType が存在するが、本番にはいない、とかそういう使い方が想定されていそう。

Introspection

GraphQL はイントロスペクション(リフレクションとも言う)の仕組みがある。これによって、クライアントは単に API を叩くことだけではなく、そもそも何ができるのかが探索できるようになる。

query {
  __schema {
    types {
      name
    }
  }
}

読んでいて思ったのだが、GraphQL の上手いところは、汎用的なクエリ言語を定義することで同一のツール・エコシステム内でイントロスペクションを兼ね備えることができているところ だと思った。

これによって、クライアントの学習コストは元より、イントロスペクションのために特別なツールチェインを用意する必要がなく、開発用のツールチェインが整うと自然にイントロスペクションの体験も改善されるようになっている。プログラミング言語ではこういう設計はあるけど、API でこれをやってるのすごいなー。

AltechAltech

GraphQL Schema Design

What Makes an API Great?

APIs should be easy to use and hard to misuse - Joshua Bloch

道具全般に言えることではあるが、なるほどこういう言葉があるのか。

GraphQLは本質的に、良いAPIを設計することを容易にはしてくれない。強力な型システムが付属しているが、それを正しく使わなければ、他のAPIスタイルと同じ罠に陥る可能性がある。

はい。

Design First

実装よりも先にインターフェイスについて考えましょうという話と、ドメインに詳しい人と一緒に設計しましょうということが書いてあった。

Client First

バックエンドのリソースやエンティティの観点からAPIを設計したくなりますが、何よりもまずクライアントのユースケースを念頭に置いてGraphQL APIを設計することが非常に重要です。
...
クライアントファーストは、特にパブリックAPIを扱う場合、必ずしもクライアントが望むことをそのまま行うことを意味しない。クライアントが問題にぶつかったとき、解決策をストレートに提示してくることはよくあることです。彼らが提案する解決策を実行する前に、まず問題についての情報を集め、一歩引いてみてください。問題の裏には、もっとエレガントな解決策がある可能性があります。

バックエンドの実装に引っ張られることを避けようということが書かれている(これは Shopify や GitHub など巨大なサービスで働いてきた著者らの経験が反映されている気がする)。

ポイントとしては、クライアントのユースケースは大きなヒントではあるのでしっかりそれを見ること、しかしそれをそのまま実現するのではなく一歩引いて考えること、の2点でバランスを取ろうと言う話。

これを正しく行うための素晴らしい方法は、プロセスのできるだけ早い段階で「最初のクライアント」と一緒に作業することだ。これは、あなたのデザインをクライアントと早期に共有し、できるだけ早くAPIと統合させ、途中であなたのAPIデザインのモックサーバを提供することさえ意味します。

個人的に当たり前にやっていることではあるけど、明示的に推奨したいね。

次、データベース密結合系のツールについても一言もの申しているのがいるのが興味深い。全否定ではなく、プロトタイプとしては役に立つこともあると言っている。

GraphQLの型付けされた性質は、データベースや他のデータソースからGraphQL APIを構築することを提案する多くのベンダーやツールも惹きつけているようだ。もしあなたが "クライアントファースト "の視点でAPIを構築しているなら、これはほとんど意味を なさない。これはこれまで述べてきたことに反しています。

Naming

名前重要、以上。

が、GraphQL で間違った名前づけをするとどう言うことが起きていることの例としては分かりやすい。

一点面白かったのが、いろいろなところで使いたくなる User みたいなやつはインターフェイスにすると言う言う解決をとっていること。一定数の共通項が出てきたとき、こう言う括り方が手段としてある。

Descriptions

コメント。だいたいのモノにはコメントが付けられる。コメントは大事だけど、コメントを読まないと使えないような API は理想ではないよね、と言う話。

過度にコメントに依存していて設計が不十分な場合の code smell の典型例が記載されている。

典型的な匂いは、エッジケースを説明する記述、条件文を含む記述、文脈的な振る舞いを説明する記述だ。

Use the Schema, Luke!

型にちゃんと情報載せようね!のこれも具体例付きバージョン。

カスタムスカラーも選択肢として出ている。シリアライザとか各言語で用意しなきゃいけないのかな。その辺だけちょっと懸念。

Expressive Schemas

続:型にちゃんと情報載せようね!

悪い例:

(ID か name でプロダクトを見つけられるとして)

type Query {
  # Find a query by id or by name. Passing none or both
  # will result in a NotFound error.
  findProduct(id: ID, name: String): Product
}

これはまあ論外だと思うんだけど、こうしようと書いてある。

type Query {
  productByID(id: ID!): Product
  productByName(name: String!): Product
}

id と name のユニオンにして productByKey だけにしても形式的な情報としては等価だと思うが、こっちの方が human readable で良いですね。できるだけ探索ケースをクエリルートに出すこと。

type Query{
  products(sort:SortOrder=DESC):[Product!]
}

デフォルト値の仕組みもあるので、活用することでデフォルトの振る舞いをスキーマに乗せられる。

Specific or Generic

こうするより、

type Query {
  posts(first: Int!, includeArchived: Boolean): [Post!]!
}

こうする方が良い可能性を検討した方が良い。

type Query {
  posts(first: Int!): [Post!]!
  archivedPosts(first: Int!): [Post!]!
}

絞り込み条件が複雑になった場合も、何も考えず汎用的にするのではなく、ユースケースの知識をクエリに反映させらないか、検討する。

あとは、ミューテーションの方でダメな例が出ている。

type Mutation {
  updateCheckout(
    input: UpdateCheckoutInput
  ): UpdateCheckoutPayload
}

input UpdateCheckoutInput {
  email: Email
  address: Address
  items: [ItemInput!]
  creditCard: CreditCard
  billingAddress: Address
}

こうすると良いかもね、と言う話。

type Mutation {
  addItemToCheckout(
    input: AddItemToCheckoutInput
  ): AddItemToCheckoutPayload
}

input AddItemToCheckoutInput {
  checkoutID: ID!
  item: ItemInput!
}

とにかく単純なデータベースの CRUD で本当に良いのか?ユースケースを絞る余地はないか?と言うことをを考えてみる、と言うことかな。実際の落とし所は、状況によるとは思うが、こう言った選択肢は頭に入れておきたい。

The Relay Specification

Relay について。Relay とは、Facebook が出している GraphQL のための JavaScript クライアント。

強力だが、次のことを GraphQL API について仮定する。

  • A method to re-fetch objects using a global identifier
  • A Connection concept that helps paginate through datasets.
  • A specific structure for mutations

List and Pagination

主な方法として、オフセットページネーションとカーソルベースがある。オフセットページネーションは、よく知られているようにオフセット件数が増えると遅くなるし、変更が入るとオフセットがずれる。

基本は、「途中のページに飛ぶ」と言う機能が絶対に必要というわけでなければ、カーソルページネーションにすること。

Relay Connections

こういうやつ。

query {
  products(first: 10, after: "abc123") {
    edges {
      cursor
      node {
        name
      }
    }
    pageInfo {
      endCursor
      hasNextPage
      hasPreviousPage
    }
  }
}

概念としては、Connection が全体で、これは Edge のリストを返す。Edge は繋がっている先のオブジェクトである Node を含む。また、Edge に対してカーソル情報も乗る。

Edge に対してはさらに情報が載せられて、GitHub API の例でオブジェクト間の関連についての domain-specific なデータを載せる例が挙げられている。

type TeamMemberEdge {
  cursor: String!
  node: User!
  role: TeamMemberRole!
}

他、以下のプラクティスが挙げられている。

  • Connection には totalCount のようなフィールドがよく追加されるが、これは必要でなければやらないこと
  • オフセットベースの方が実装が簡単だが、その場合でも将来に備えて Connection パターンを使っておくことを強く推奨する

Sharing Types

クライアントの構造を再利用したいなどの理由で、型を共通化したくなるが、それをやると後で困ることが多い、という事例。たまたま同じである型を同じにしないようにしよう。

「これは私が見てきたGraphQLスキーマの中で最も一般的な問題かもしれません。」と書かれていて、これって本当に難しい問題なんだな、と思った。

個別の話で言うと、バランスとして面白かったのは、同じ User を使うにしろ Connection の型を分けておくことで、リレーションに関する情報をそこに詰め込むケース。

Global Identification

オブジェクトにグローバルな識別子を振った上で、それに基づいてフェッチできるようにすること。Relay から来たアイデアだが、Relay を使っていなくても有用な場合がある。主な目的は、クライアントサイドのキャッシュをシステマティックにやること。

Nullability

GraphQL の挙動メモ。non-null なフィールドに null が入ってしまった場合、親まで遡って nullable なオブジェクトを探しにいく。

当然 non-null にすることのメリットはあるが、次が注意点。

特に分散環境では、何がNULLになるのか、ならないのかを予測するのは非常に困難です。アーキテクチャが進化し、タイムアウト、一時的なエラー、レート制限など、あらゆるものが特定のフィールドに対してNULLを返す可能性があります。
...
データベースの関連付けやネットワーク呼び出しなど、いつか失敗する可能性のあるものに支えられたオブジェクトタイプを返すフィールドは、ほとんど常にnullableであるべきです。

Abstract Types

ユニオンとインターフェイスを適切に使い分けましょうという話が、時間的な API の進化も視野に入れて書かれている。

使い分けの経験則として、インターフェイスは振る舞いを共有するものに対して付けるものであることが多い、と言っている。具体例は、GitHub のスターをつけることができると言うインターフェイス。ユニオンの代表例は、いろんな種類のオブジェクトを検索結果として返す API。

あと、ユニオンを使い際の下記のプラクティスは大事だなと思った。

GraphQLクライアントは新しいケースに対して防御的にコーディングすべきであり、GraphQLサーバーは重要なクライアントロジックに影響を与える可能性のある型の追加に注意すべきです。

Designing for Static Queries

GraphQL のクエリはクエリビルだなどを介さず、また動的に組み立てることなどせず、できるだけ静的な状態を保ちましょうと言う話。

これはプラクティスとしては定着しているが、きちんとその理由が書かれているのが良いですね。

Mutation

レスポンスは操作対象のオブジェクトそのものではなく、payload としてラップしたものを返す。こうすることで、いろいろな操作についての情報を含められる。

# An example payload type for a createCheckout mutation
type CreateProductPayload {
  product: Product
  successful: Boolean!
}

基本は、ミューテーションごとに 1 つの必須かつユニークなインプット型を使用する。

Fine-Grained or Coarse-Grained

どのくらいの粒度でミューテーションを定義するか?問題。

いろんなトレードオフがあるが、経験則としては以下が語られている。

一般に、粗い粒度の作成ミューテーションと、エンティティを更新するための細かい粒度のミューテーションを持つことは、経験則として良いことだと思います。

なお、細かければクライアントにとっては良いかというと、そうでもない:

本当に細かいフィールドやミューテーションは多くの利点がありますが、制御フローのビジネスロジックをより多くクライアントに押し付けることになります。

あとはトランザクションの話。GraphQL にトランザクションの仕組みはないが、トランザクションとしてまとめたい操作をミューテーションの粒度に合わせてやれば良い。

なお、パターンとして複数のオブジェクトを一気に更新したりするような設計も紹介されている。

Error

先に結論から。

GraphQLのエラーはもともと例外的なイベントやクライアント関連の問題を表すために設計されたものであり、必ずしもエンドユーザーに伝える必要のある製品やビジネスのエラーを想定しているわけではないことを理解すれば、このことはすべて納得がいくでしょう。
...
ビジネス/ドメインルールの一部であるユーザー向けのエラーについては、例外/クエリレベルのエラーとして扱うのではなく、スキーマの一部として設計することが現在のベストプラクティスです。

そして GraphQL のエラーの形は以下。

{
  "errors": [
    {
      "message": "Error when computing price.",
      "locations": [ { "line": 6, "column": 7 } ],
      "path": [ "shop", "products", 1, "price" ],
      "extensions": {
        "code": "SERVICE_CONNECT_ERROR"
      }
    }
  ],
  "data": {
    "shop": {
      "name": "Cool Shop",
      "products": [
        {
          "id": "1000",
          "price": 100
        }
      ]
    }
  }
}

Error as Data

レスポンスとなる payload のなかに、エラー情報を含めると言う素朴な方法。

クライアントは必要な情報を得られている。一方で、そのエラー情報を照会するかどうかはクライアントに任せられている(気づかないかもしれない)。

Union / Result 型

当然、ユニオンにすればパターンマッチで確認することを強制できる。

インターフェイスを使ってある程度基本的な形を定義することもできる。

interface UserError {
  message: String!
  code: ErrorCode!
  path: [String!]!
}

type DuplicateProductError implements UserError {
  message: String!
  code: ErrorCode!
  path: [String!]!
  duplicateProduct: Product!
}

Schema Organization

名前空間はないけど、プレフィックスをつけるなどもできるし分かりやすい名前にすれば自然と問題は解決する、と言うスタンス。

Asynchronous Behavior

非同期もモデリングできると言う話。Shopify では「ジョブ」と言う一般的な概念をスキーマに乗せている、と言う話が書かれていた。

Data-Driven Schema vs Use-Case-Driven Schema

あまり聞きなれない用語でちょっと不思議だったけど、Data-Driven というのは一般的な形で大量のデータを供給するような API のことを指している雰囲気を感じた。

原則としてはユースケースに向かう方が良いけど、アプリケーションによってデータ指向のものもあるよね、という話かなー。

Summary

まとめ。

本章通して全体的に思った所感として、ユースケースの情報はクエリやミューテーションのルートに入れ込む のが良さそう。

そもそも GraphQL 自体の設計として、オブジェクトはどこからどういう風にでも取れるということになっている。もちろん、情報設計をする上ではユースケースは参考にするが、ユースケースそれ自体ではないはず。

ということで、オブジェクトの定義がユースケースから独立する。

一方で、パフォーマンスその他の観点で、API からユースケースが見えるメリットが一定ある。だとするとそれはどこかに表現したい。

であればオブジェクトグラフは汎用的にしつつ、オブジェクトグラフのエントリーポイントを積極的にユースケースによって制約するという、この2点の抑えで設計のバランスを取るのが良いのかなと思った。