Closed68

Production Ready GraphQL 輪読会

ゲントクゲントク

An Introduction to GraphQL

エンドポイントベースのAPIアーキテクチャ

  • ほんの数年前までは、エンドポイントベースのAPIアーキテクチャがWeb APIの分野を席巻していた
    • エンドポイントベースとは、HTTPエンドポイントを中心に解決するAPIのこと
    • HTTP上のJSON API、RPCスタイルのエンドポイント、RESTなど
  • エンドポイントベースのAPIの利点
    • 実装が非常にかんたん
    • 慎重に設計すれば、特定のユースケースに最適化することができる
    • かんたんにキャッシュできて、discoverableで(?)、クライアントにとって利用しやすい
    • エンドポイントベースのAPIは、クライアントとサーバー間のやりとりを、1つの機能はまたユースケースに最適化することに関しては素晴らしい

Web APIを使用するクライアントの種類の増加

  • Web APIを使用するクライアントの種類は爆発的に増えている
    • かつてはWebブラウザが主なクライアントだったが、モバイルアプリや分散アーキテクチャの一部である他のサーバー、ゲーム機、IOT家電など
  • クライアントの種類が爆発的に増えていて、それらのシナリオに対応することが難しくなっている
  • クライアントによって送信すべきデータの種類や大きさがことなる
  • 結局、最適化されたエンドポイントではなく、万能(one-size-fits-all)なAPIを作ることになる
ゲントクゲントク

discoverable

エンドポイントの存在を知りやすい?
products/createがあったら、products/editもあるんじゃない?という推察がしやすい

ゲントクゲントク

One-Size-Fits-All

One-Size-Fits-All APIとはなにか

  • あまりにも多くのユースケースに答えようとするAPIのこと
  • 共通のユースケースを利用する多くの異なる方法に適応できず、汎用的にしすぎてしまったAPI
  • 多くのクライアントに最適化させようとすると、メンテナンスが面倒になってしまうため、汎用的にしすぎてしまう
  • エンドポイントベースのAPIではかなり一般的な問題となり、REST APIのせいにされることもあった
    • 実際は、RESTはこれを解決する方法を提供しているので、RESTに問題があるわけではない

様々なクライアントからのリクエストにどのように対応するか

エンドポイントを増やす

GET api/playstation/products

GET api/mobile/products
  • 最も単純なアプローチ
  • バリエーションの増加によってAPIが変更に対してもろくなってしまい、メンテや機能追加が苦痛になってしまう

クエリパラメータで対応

GET api/products?version=gaming

GET api/products?version=mobile

GET api/products?partial=full

GET api/products?partial=minimal
  • 仕様が複雑になりがち
  • 独自のクエリ言語のようになるものもある
GET api/products?include=another&fields[products]=name,price

GET api/products?fields=name,photos(title,metadata/height)

エンドポイントベースのAPIは、最適化とカスタマイズのトレードオフ

  • 最適化 … エンドポイントが単一のユースケースにたいしてどれだけ最適化されているか
  • カスタマイズ … エンドポイントが異なるユースケースやバリエーションにどれだけ対応できるか
ゲントクゲントク

Let's Go Back in Time

過去の事例

Netflix

  • Netflixは、800以上の異なるデバイスをサポートするAPIを開発していて、限界を感じていた
    • OSFAのアプローチは、API利用者ではなくAPI提供者の利便性に重点を置いている点が問題であると指摘した
  • その問題を解決するために、クライアントとサーバーのレイヤーの間に新しいコンセプトのレイヤーを用意するアプローチを見出した
  • 単に多くのカスタムエンドポイントを書くというわけではなく、サーバー上でより管理しやすくなっている
    • サーバーコードの役割
      • データの取得
      • 必要なサービスの呼び出し
    • アダプターの役割
      • クライアント固有の方法でデータをフォーマットする
  • APIチームは、サーバー上でクライアントのアダプターをビルドすることを、クライアント開発者に許可する
  • Netflixはこのアプローチを"Api platform that includes server-executed client-based code"というかなり一般的な名前で特許申請した

SoundCloud'

  • SoundCloudも似たような悩みを抱えていた
  • モノリシックなアーキテクチャからよりサービス思考のアーキテクチャに移行する中で、既存のAPIに悩まされた
    • たとえばモバイルアプリケーションでは、Webアプリケーションよりもペイロードのフットプリントを小さく、リクエスト頻度を減らす必要があった
    • 既存のAPIはそのことを考慮していないので、新しいエンドポイントが必要になるたび、フロントエンドチームとバックエンドチームは話し合いをしながら開発することになった
  • SoundCloudはこの問題を解決するために、メインAPIに高度なカスタマイズオプションを含めるのではなく、各ユースケースに独自のAPIサーバーを用意した
  • このアプローチは非常に理にかなっていて、これによって開発者は、他のユースケースを気にすることなくそれぞれのユースケースを効果的に最適化できるという、エンドポイントベースのAPIが得意とする開発ができるようになった
  • BFFと呼ばれるパターン
  • 1つのユースケースに対して管理可能なAPIを書くことができるので、One-Size-Fits-AllなAPIを書くという罠に陥るのを避けることができる
プラハの社長プラハの社長

Type System

紹介された要素:

  • type
  • field
  • query
  • schema root
  • argument
  • variable
  • alias
  • mutation
  • enum
  • abstract type
  • union type
  • fragment
  • inline fragment
  • directive
  • introspection

良いAPIとは何か?

  • 良いAPIは誤って使うのが難しく、正しく使うのが簡単
    • 工場とかの機械デザインと定義が似ている
  • graphQLは良いAPIをデザインするのを簡単にしてくれるわけではない。強力な型システムを持っているが、正しく使わないと他のAPI同様に様々な罠にかかる

過去に著者が直面した使いづらいAPIの一例:

  • ドキュメントが古い
  • 使われる用語に表記揺れが多い
  • 想定外の挙動

デザインファースト

  • いきなり実装するのではなく最初にデザインするのが大切
  • デザインなしに実装してしまうと内部的な実装とエンドユーザーに公開するインターフェイスが密結合してしまいやすい
  • GraphQLの実装を担当する人がドメインエキスパートである可能性は低いため、常にドメインエキスパートと共同で作業を行うのかベスト
  • 公開されたAPIは変えづらいため、自分が表現したい概念をしっかり理解しておくことが将来の破壊的変更を防いでくれる
ゲントクゲントク

Client First

  • GraphQLはクライアント中心型のAPI
  • なによりもまずクライアントのユースケースを念頭に置いてAPIを設計することが非常に重要
  • クライアントのニーズに応えるAPIを構築することで、使いやすく、誤用されにくい方法で設計できる
  • クライアントファーストを怠ると、クライアントは自分のやりたいことを実現するために推測したり、大量のドキュメントを読んだりすることが必要になる

できるだけ早くクライアントとAPIを共有する

  • プロセスのできるだけ早い段階で「最初のクライアント」と一緒に作業をすること
  • できるだけ早くクライアントと設計を共有し、できるだけ早くAPIと統合させる
  • 設計したAPIのモックサーバーを提供してもいい
  • 詳細は6章で紹介
  • (とくに公開APIを扱う場合、)クライアントファーストは必ずクライアントの望み通りにすること、という意味ではない
    • クライアントは問題にぶつかると、そのまま解決策を提示してくることがある
    • クライアントに提示された解決策を実行する前に、まず問題についての情報を集めて、よりエレガントな解決策がないか探してみる

YAGNI

  • YAGNIは、APIの設計に関しても有用
  • クライアントが望むもの以上のものを提供しようとして、ひどい設計になったり、パフォーマンスやセキュリティ上の問題を起こしたAPIをたくさん見た

実装の詳細とスキーマ設計を分離する

  • クライアントファーストな設計は、実装の詳細に影響されないスキーマ設計をしやすくする
  • クライアントは、APIのバックエンドでどのようなデータベースを使用しているか、バックエンドのアプリケーションがどのような設計になっているかなどは気にしない
  • 可用性やパフォーマンスなどの懸念が設計上の決定に影響することはあるが、あくまでクライアントファーストで、決定は慎重に下すべき
  • 実装の詳細に影響されない設計にすることで、内部の関心事に早いペースで対応できるようになる

GraphQLスキーマのジェネレータについて

  • データベースや他のデータソースからGraphQL APIを構築するツールが多くあるが、デザインファーストの観点から見ると、筋が悪い
    • スキーマを実装と連動させることになる
    • テーブルやエンティティがムダに汎用化される
    • クライアントのニーズを考慮しにくくなる
    • 必要以上のものを公開することになりがち
  • 既存のAPI定義やSwagger/OpenAPIからGraphQLスキーマ定義を生成するジェネレーターにも注意
    • RESTとGraphQLでは、設計上の懸念が異なる
    • RESTはリソースに焦点が当てられていて、postputなどのメソッドのセマンティクスに基づいてエンドポイントを設計する
  • ジェネレータはプロトタイプづくりにはいいかもしれないが、クライアント中心のAPIを求める者にとっては、求めているものは何も得られない
  • 自動生成されたスキーマが、人間が設計したGraphQLファーストのスキーマよりきれいに設計されることはほとんどない
  • ものによっては利便性と設計の美しさのトレードオフで許容できるものもあるかもしれない
ゲントクゲントク

Naming

  • 適切な命名というものは、APIが教えてくれる
  • Java APIの設計に関する引用だが、GraphQLのようなWeb APIにもよく当てはまる
  • 命名は難しいがとても重要
  • よい命名をすることで、ドキュメントを読んだり推測したりする時間を節約できる
  • 正しい命名は正しい設計へと導いてくれるが、間違った命名は正しくない設計へと導く

一貫性のある命名

  • 接頭辞や接尾辞を統一する
  • 同じ概念に対してBlogPostと命名したりPostと命名したりせず、どちらかに統一する
  • これらが守られていないと、APIの探索や利用が難しくなる

良くない例1

type Query {
  products(ids: [ID!]): [Product!]!
  findPosts(ids:[ID!]): [Post!]!
}

productsの取得はproductsだけなのに、postsの取得には接頭辞findがついている

良くない例2

type Mutation {
  addProduct(input: AddProductInput): AddProductPayload
  createPost(input: CreatePostInput): CreatePostPayload
}

接頭辞がaddだったりcreateだったりしている

APIの対称性

  • publishPostがあるならunpublishPostもないと不自然
  • 「驚き最小の原則」に則る
    • インターフェイスの2つの要素がお互いに矛盾あるいは不明瞭だったとき、その動作としては人間のユーザーやプログラマがもっとも自然に思える(もっとも驚きがすくない)ものを選択すべきだとする考え方

具体的な命名

  • 命名は具体的であること
  • "Event"や"User"のような一般的な名前が導入されると、多くの場合命名領域が専有されて困る

良くない例3

type Query {
  viewer: User!
}

type User {
  name: String!
  hasTwoFactorAuthentication: Boolean
  billing: Billing!
}

クエリルート上でviewerとして動作するUserは、現在ログインしているユーザーに関する情報を含んでいる

type Query {
  viewer: User!
  team(id: ID!): Team
}

type Team {
  members: [User!]!
}

type User {
  name: String!
  hasTwoFactorAuthentication: Boolean
  billing: Billing!
}

その後、チームのメンバーをリストアップする処理でUserが使われ始めたが、チームのメンバーとしてリストアップされるUserは個人情報を持つべきではない

type Query {
  user(id: ID!): User
  viewer: Viewer!
  team(id: ID!): Team
}

type Team {
  members: [User!]!
}
interface User {
  name: String!
}

type TeamMember implements User {
  name: String!
  isAdmin: Boolean!
}

type Viewer implements User {
  name: String!
  hasTwoFactorAuthentication: Boolean
  billing: Billing!
}

Userをinterfaceとし、TeamMemberViewerとに型を分けるべき

プラハの社長プラハの社長

use the schema luke

graphqlではdescription(詳細)を 追加できる。スキーマタイプが何を表していて、ミューテーションに何ができるのかを示すのは大切だが、スキーマを見るだけでAPIの使い方がわかるのが理想なので頼りすぎてはいけない

  • 例えばプリミティブなスカラーで表現するのではなくenumを使う
  • JSONとして表現するのではなくmetaAttributeとしてkey/valueを定義する
    • これは何が嬉しいんだろう
      • ネストしていないことがわかる
      • keyがあればvalueが存在することが保証されている
      • 名前がjsonだと汎用的すぎるので、より用途が具体化されている(誤って他のところで使われる不適切な抽象化を防げる)
  • カスタムスカラーを使うことでstringではなくmarkdown/EmailAddressであることを明示する

循環するようなデータ構造だとどうしても上記の手段が使えないこともあるが、可能な限り使うことを心がける

ゲントクゲントク

Expressive Schemas

  • 表現力豊かなAPIは、クライアント側の開発者が使い方を容易に理解できる
  • どのように使われることを意図しているかを表現する

nullabilityをうまく利用する

良くない例

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
}

idnameもオプショナルで、全く直感的じゃない

  • どちらの引数も渡さなかったらどうなるのか
  • どちらの引数も渡すとどうなるのか

次のように改善できる

type Query {
  productByID(id: ID!): Product
  productByName(name: String!): Product
}
  • idとnameそれぞれでフィールドを用意する
  • 複数の異なる取得方法を提供したとしても、既存のクライアントに対してオーバーヘッドを与えることはないので、フィールドを追加することを恐れる必要はない

型でうまく表現する

良くない例

type Payment {
  creditCardNumber: String
  creditCardExp: String
  giftCardCode: String
}

Stringだけでは表現力が不十分

  • カード番号の形式
  • カードの期限の形式

次のように改善できる

type Payment {
  creditCardNumber: CreditCardNumber
  creditCardExpiration: CreditCardExpiration
  giftCardCode: String
}

# Represents a 16 digits credit card number
scalar CreditCardNumber

type CreditCardExpiration {
  isExpired: Boolean!
  month: Int!
  year: Int!
}
  • カード番号はカスタムスカラを用意して、実装側にバリデーションを宣言できる
  • カードの期限は、すべて必須で3項目を用意した
    • 実装側のバリデーション実装をある程度省ける

複数のフィールドに共通のprefixがついているとき、それはオブジェクトを抽出するリファクタリングが必要であるニオイの可能性がある(今回はcredit

ありえない状態を作れないようにする

よくない例

type Cart {
  paid: Boolean
  amountPaid: Money
  items: [CartItem!]!
}

amountPaid: nullかつpaid: trueはありえないのに、クエリできる
なので、以下のようなクエリを送るとおそらくエラーになる

{
  "data": {
    "cart": {
      "paid": true,
      "amountPaid": null,
      "item": [...]
    }
  }
}
{
  "data": {
    "cart": {
      "paid": false,
      "amountPaid": 10000,
      "item": [...]
    }
  }
}

次のように改善できる

type Cart {
  payment: Payment
  items: [CartItem!]!
}

type Payment {
  paid: Boolean!
  amountPaid: Money!
}
  • Paymentを定義
  • カートにpaymentプロパティがあれば、支払いが済んでいることを意味する
    • paid: false, amountPaid: 1000とかだったらどういう意味になるんだろう?
    • ↓のほうがよくないか?
type Cart {
  payment: Payment
  items: [CartItem!]!
}

union Payment = Paid | NotPaid

type Paid {
  paid: True!
  amountPaid: Money!
}

type NotPaid {
  paid: False!
}

オプショナルな引数にはデフォルト値を設定する

良くない例

type Query {
  products(sort: SortOrder): [Product!]!
}
  • productの引数sortがオプショナルになっている
  • 何も渡さなかった場合どうなるかわからない

デフォルト値が設定できる

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

まとめ

  • フィールドは必ず「1つのことをうまくやる」ようにする
  • スキーマで表現できる条件はスキーマで表現する(なるべく実装しない)
  • フィールドと引数の関係を表現するために、オブジェクトをうまく定義する
  • オプショナルの入力値にはデフォルト値を用意する
プラハの社長プラハの社長

表現豊かなスキーマ

  • 大事なのは実装で様々なパターンに対応するのではなくスキーマで対応が必要なケース自体を減らすこと
  • これより

  • これの方が幾つかの観点で優れている
    • もしクレジットカードを支払い手段として選択した場合は 詳細項目が必須化されているので、必要な項目が提供されていないパターンを自走で考慮しなくても済む(でも有効な値かどうかは確認しなければいけないからどのみち実装の手間はそこまで減らないのではないかと思った。nullかどうかの確認は不要だけど、 文字列なら空文字のチェックとかは必要だし
    • Make impossible states impossible
  • スメルの見つけ方
    • 同じようなprefixが フィールドに何カ所も登場する。こういう時はprefixを 新たな形として切り出す余地がある



  • このケースでもpaid: falseでamountPaid: 1000 みたいな状態は作れてしまうのではないか…?

テクニックのまとめ

  • すべてのフィールドは1つのことをうまく行うようにする。(汎用的なことをしようとしすぎない)
  • スキーマが強制できることを実行時に確認しなくても済むようにする
  • 不可能な状態を不可能にする
  • デフォルト値を使うことで引数を省略したときの挙動を明示する
    • posdにも通じる気がした
プラハの社長プラハの社長

Productについて

  • 使うエンドポイントを覚えるのも引数の使い方を覚えるのも、オーバーヘッドとしては等しいと仮定しているのだろうか。それならimpossible stateをimpossibleにできているメリットの方が大きいよね、という考え方?
プラハの社長プラハの社長

絶対に値がtrueになるべきfieldの定義をどうやるか

  • directive?
  • クライアント側でパターンマッチできるから、type Paidのpaid fieldにマッチすればpaidAmountはpaid: trueの時しか取得できなくなる?
プラハの社長プラハの社長

専門か汎用か

  • フラグに応じて取得するリソースが変化するクエリを1つ作るよりは、2つの別々のクエリを作成した方がキャッシュしやすい、可読性が高い、最適化しやすい
    • posts(includeArchived: boolean)よりはpostsとarchivedPostsみたいな
    • 特殊ケースを 極力排除する考え方に近いのかもしれない
  • 複雑で自由度の高い検索に対応するユースケースであれば複雑な引数を持つのも適している可能性がある。 これは実在するユースケースに対して最適化しているので問題ではない(特定のユースケースに対する解決策ではなく汎用的な解決策を作ろうとした時に問題が現れる
  • 専門と汎用のトレードオフはケースバイケースなので、SQLのようなフィルターをAPIでサポートしたい場合は汎用的エンドポイントに落ち着くこともあるかもしれない

汎用的なエンドポイントのデメリット

  • どのユースケースにも最適化されていないためクライアントから見つけづらい
    • これが本当なら
      • リゾルバの中に条件分岐が見つかったらすぐに新しいクエリとして切り出すべき。だが、そうするとたくさんのエンドポイントができあがるので逆に探すのが難しくなる
      • たくさんのエンドポイントから探すのも、たくさんある引数の使い方を覚えるのも手間は変わらないから、よりメリットが大きい分割を選ぶってことなのかな
    • 1つのリゾルバの中であらゆるエッジケースに対応してパフォーマンスを考慮しなければいけない

専門的なクエリのデメリット

  • 特定のクライアントとユースケースに依存しすぎてしまうと他のクライアントに向けてカスタマイズしづらくなってしまうこと

貧血GraphQL

  • ドメインモデル貧血症から拝借された名前

クエリの例

  • 例えば商品と価格と割引情報をクライアントに返す時、以下のスキーマだとクライアントで合計金額を計算するロジックを書かなければいけない:
type Discount {
  amount: Money!
}

type Product {
  price: Money!
  discounts: [Discount!]!
}


Excerpt From
Production Ready GraphQL
Marc-Andre Giroux
This material may be protected by copyright.
  • これだと 税抜き税込のロジックが変更された時、 クライアントサイドのコードに変更加える必要がある。それならスキーマに合計金額を記載しておいた方が良い:
type Product {
  price: Money!
  discounts: [Discount!]!
  taxes: Money!
}

Excerpt From
Production Ready GraphQL
Marc-Andre Giroux
This material may be protected by copyright.
  • スキーマは単純にデータをクライアントに返すのではなく、ドメインに沿ってデザインした方が良い

ミューテーションの例

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

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

Excerpt From
Production Ready GraphQL
Marc-Andre Giroux
This material may be protected by copyright.
  • 問題点
    • このクエリの使い方をクライアントが推察する必要がある(アイテムを配列に追加するだけで良いのか、他のattributeも追加する必要があるのか判別がつかない)
      • 下手すると実行事例外まで気づかないし、最悪の場合は不整合データがうまれる
    • カートに商品を追加したいときにどのフィールドを選択すれば良いのか、丁寧にスキーマを見ないとわからない。 そもそも「カードに追加する」と言うアクションが可能なのかどうかもフィールドを見て推察するしかない
      • 認知負荷が増える
    • すべてがnullable。汎用的なスキーマが持つデメリットを全て持っている
type Mutation {
  addItemToCheckout(
    input: AddItemToCheckoutInput
  ): AddItemToCheckoutPayload
}

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

Excerpt From
Production Ready GraphQL
Marc-Andre Giroux
This material may be protected by copyright.
  • 改善点
    • オプショナルなフィールドが存在しない
      • クライアントが使い方で迷わない(認知負荷が減る)
    • どのフィールドを更新するべきか推察する必要がない
      • 認知的負荷が減る
    • このミューテーションを実行した時に起き得るエラーの種類が限定されるので、リゾルバがより詳細なエラーを返せる
      • 汎用的な入力から専門的な出力を返すのは難しい。専門的な入力から専門的な出力を返すのは容易い、という解釈もできそう
    • このミューテーションを使うことでクライアントが異常な状態に陥る事は無い(矛盾するフィールドの更新を行えないためだと解釈した)
      • 誤って使う方が難しい状態になる
ゲントクゲントク

Lists & Pagination

素朴なリスト

type Product {
  variants: [ProductVariant!]!
}
  • リストを単純に公開するだけでは、データによってはバックエンド/クライアント双方のパフォーマンスの問題につながる
  • ページネーションは優れたAPIのために不可欠な要素
  • サーバー側では、ページネーションによって、データセットの特定の部分だけを読み込むことができる
  • クライアント側では、ページネーションによって、よりよいユーザー体験とパフォーマンスを実現することができる

Offset Pagination

REST

GET /products?limit=250&page=3

GraphQL

type Query {
  products(limit: Int!, page: Int!): [Product!]!
}
  • もっともバックエンドが実装しやすいパターン
  • クライアントにも柔軟性を与えられる

しかし、APIプロバイダーが成長するにつれて、問題が起きる
デカいデータセットから値を取得するSQL

SELECT * FROM products
WHERE user_id = %user_id
LIMIT 250 OFFSET 500;
  • OFFSETの値が大きくなると、データベースはそのオフセットに到達するまでデータを読み込まなければならない
  • リストに変更が入ると、一貫性のない結果を返してしまう可能性がある

Cursor Pagination

  • カーソルページネーションはオフセットページネーションとは別の視点からこの問題を解決する
  • 「カーソル」は、リスト上のアイテムを指す識別子

クライアントは、このカーソルを基準に、カーソルの前または後ろの数を指定してAPIにリクエストする

型定義

type Query {
  products(limit: Int!, after: String): [Product!]!
}

リクエストクエリ

query {
  products(first: 10, after: "def456") {
    next
    items {
      name
      price
    }
  }
}

サーバーは、次のカーソルが何であるかをクライアントに常に提供して、次のリクエストができるように実装する

{
  "data": {
    "products": {
      "next": "def456",
      "items": [{}, {}, {}]
    }
  }
}

メリット

オフセットページネーションで起きていたバックエンドのパフォーマンス問題は解決している

SELECT * FROM products
WHERE user_id = %user_id
AND id >= 15
ORDER BY id DESC
LIMIT 10

デメリット

  • カーソルページネーションにはページという概念が存在しない
  • 全部で何ページあるかはクライアントはわからない
  • ページをスキップすることはできない
ゲントクゲントク

例ではidをカーソルにしているが、実際は更新順とか、評価順とかかも?

プラハの社長プラハの社長

Relay Connection

  • リレーページネーショんはカーソル系のページネーショんを想定して作られている
query {
  products(first: 10, after: "abc123") {
    edges {
      cursor
      node {
        name
      }
    }
    pageInfo {
      endCursor
      hasNextPage
      hasPreviousPage
    }
  }
}”
“type ProductConnection {
  edges: [ProductEdge]
  pageInfo: PageInfo!
}

type ProductEdge {
  cursor: String!
  node: Product!
}

type PageInfo {
  endCursor: String
  hasNextPage: Boolean!
  hasPreviousPage: Boolean!
  startCursor: String
}

type Product {
  name: String!
}Excerpt From
Production Ready GraphQL
Marc-Andre Giroux
This material may be protected by copyright.
  • こんな見た目になる。コネクションはedgesとpageInfoを返す。データ自体はedgesのnodeに含まれている
  • コネクションは複雑な関係を表現するのに役立つ。GitHubAPIはアイテム自体ではなくアイテムの関連に関する情報はedgeに含めている。

connectionのフィールドのカスタマイズ

  • edge { node }と書くのが面倒な人のために直接nodeを取得できるクエリも用意することがある(edgeを介した方が便利なので両方用意するのが良いらしい)
  • よく追加されるのはtotalCount。ただし計算に時間がかかるのでデフォルトで含めるのは避けた方が良い
  • GitHubのconnectionにもtotalCountがよく含まれている。pageInfoはAPI全体で共通みたい。これはそのまま真似できそう*

ページネーショんまとめ

  • カーソル型は無限スクロールにしか対応できない(ページ数の表示が不可能に近い)
  • どうしてもオフセット型が良い場合はデメリットを意識すること
  • オフセット型を使うとしてもconnectionを介した表現方法を推奨する

でも基本はカーソル型がオススメだぜ

  • 正確性(ページネーション中に他の要素が追加/削除された時の挙動)とパフォーマンス(SQLのoffset句の問題)に優れている
  • relayクライアントと統合しやすい
  • 他のGraphQLAPIでも主流なので統一しておいた方が使いやすい
  • connection/edgeパターンを使った方が複雑なユースケースを表現しやすい(それはカーソル型をオススメする理由ではないのでは)
ゲントクゲントク

Sharing Types

  • スキーマが大きくなってくると、型を再利用したくなってくる
  • うまくいくこともあるが、あまりに多くの方を共有しようとすると、うまくいくことはほとんどない

型を共有して失敗する例1

userorganizationの例

type UserConnection {
  edges: [UserEdge!]!
  pageInfo: PageInfo!
}

type UserEdge {
  node: User
}

type User {
  login: String!
}

type Organization {
  users: UserConnection!
}

teamという概念を追加するときに、membersフィールドにUserConnectionを設定してしまうと、まずい

type UserConnection {
  edges: [UserEdge!]!
  pageInfo: PageInfo!
}

type UserEdge {
  node: User
}

type User {
  login: String!
}

type Organization {
  users: UserConnection!
  teams: [Team]
}

type Team {
  members: UserConnection!
}
  • teamorganizationとにそれぞれ異なる機能が追加されるようになると、困る
  • TeamMemberConnectionOrganizationUserConnectionとをそれぞれつくってあげれば、困らない

型を共有して失敗する例2

  • inputの型の共有
  • createとupdateは似たinputになることが多い
    • updateにはid等が必要になるので、id, inputTypeにしがち
input ProductInput {
  name: String
  price: MoneyINput
}

type Mutation {
  createProduct(input: ProductInput):
    CreateProductPayload
  
  updateProduct(id: ID!, input: ProductInput):
    UpdateProductPayload
}
  • これも先の例と同様に、それぞれ必要になる値が異なってきたときに困る
  • inputを共通化していると、「createのときは全部必須、updateのときは一部null許容」になりがち
    • null許容のinputがあると、実行時にnullかどうかの検証処理が必要になり、大変になる
プラハの社長プラハの社長

global identification

  • connectionと似て、relayの仕組みから拝借されているもののrelayの利用に限定されないプラクティスとしてグローバル識別子が挙げられる

それは何

  • node(id: xxx)の形でどんなタイプのノードでも取得できる、システム全体で一意な識別子

何が嬉しい

  • クライアントサイドのキャッシュを簡単に実装できるようになる
    • relayを使う場合は特に大事
    • apollo clientでは必要ないらしいけど、あって損はない

何が難しい

  • idの形式にクライアントが依存するのを防ぐ
    • これを防ぐにはopaque idが推奨される。構築されたidをbase64化したり、意味のないidにする事で防止できる
      • graphqlに限らず全般的に大事な話な気がした
    • 一方、自分が扱っているidの形式がわからないことで開発者体験が悪化することもある(?)。slackのトークンは最初の一文字がトークンのタイプに対するヒントになっていた。一部だけ開発者に役立つ情報を紛れ込ませておくのも有効なことがあるらしい
      • 確かに自分もslack bot作ってる時、間違ったトークンを指定していることにすぐ気づけた
  • 分散システムの場合、システム全体で一意性を担保するのが難しそう。しかもリソースの種類を問わないからアプリ側でULID作るだけだとTwitterぐらい大きなシステムだと意外と重複しそう?3億のMAUが平均100ツイートしたとして、それにretweetが30、かつlikeが100とかついて、全リソースが過去のものも含めてid振ったら意外と京が見えてくるのでは...?ないか
    • おそらくこれを防止するために shop_id:type_name:id みたいなproduct_idを構築する方法が紹介されていたのかな
    • でもそれだとshopに関連しないproductが生まれた時に大変じゃない?と思ったけど、graphqlのグローバル識別子とdb上の識別子が一対である必要はないので、グローバル識別子はgraphqlの都合でdbとは切り離して構築すれば良いのかな。キャッシュ程度にしか使われていないのであれば不一致してもさほど大きな問題ではない。でも実際に不一致した時に検知するの難しそうだな...この対応表はどこで管理するのが良いんだろう

nullability

graphqlのnon-nullなresponseフィールドがnullだった時の挙動

  • 最も近いnullableなparentまで遡ってnullを返す

nullの良いところ

  • より表現力が高く、予想しやすいスキーマにつながる
    • 認知負荷の軽減
  • クライアント側の条件分岐を減らせる
    • 特殊ケースの排除

nullの扱いが難しいところ

  • null -> non-nullは後方互換生のある変更だけど、non-null -> nullは破壊的な変更
    • 変更の増幅
  • 本当にnon-nullになるか分かりづらいことがある。ネットワークの遅延などにより本来nullになるはずのないところがnullになることがあり得る
    • 驚き最小の法則に違反する

nullの判断に関するチェックシート

  • requestの引数は基本non-null。表現力の向上と、驚き最小の効果が得られる
  • 潜在的に失敗し得るシステム(外部db、ネットワークコールなど)に依存している部分はnullable
    • 取得失敗したらサーバがエラーを返すからnon-nullableにしておいて良いんじゃないかな?と思ったけど、一部が失敗しても他の取得成功した部分だけでも返す方が一般的なのかな?だからnullableにしておくんだろか
    • この理屈に従うとdbから値を取得するレスポンスは全部nullableになりそうだけど、これは解釈違いだろか
  • 絶対に存在することに確証を持てるスカラー値はnone-nullにしてもよい
    • そんなレスポンスあるん?totalCountとかかな?
  • もしnullになった時、親レベルでnullになることが許容できるレスポンスであれば、フィールドをnon-nullにしても構わない。ただし稀らしい。

気になったこと

https://docs.github.com/en/graphql/reference/objects#issueconnection

  • githubのapiってnode+edgeを返してるけど、edgeの中にnodeが入っているのに、あえてnodeをedgeを同じ階層で提供し直す必要あるんだろうか?
    • 別の項でedge { node } って書くのがめんどい人のために両方提供してるって書いてあったから、それか。忘れてた
ゲントクゲントク

Abstract Types

抽象型は、インターフェイスと永続化層を切り離すのに役立つ

type SocialMediaFeedCard {
  id: ID!
  title: String!
  birthdayDate: DateTime
  eventDate: DateTime
  postContent: String
}

誕生日に関する投稿、イベントに関する投稿など、それぞれ型を分けるべき

interface SocialMediaCard {
  id: ID!
  title: String!
}

type BirthdayFeedCard implements SocialMediaFeedCard {
  id: ID!
  title: String!
  date: DateTime!
}

type EventFeedCard implements SocialMediaFeedCard {
  id: ID!
  title: String!
  date: DateTime!
}

type ContentFeedCard implements SocialMediaFeedCard {
  id: ID!
  title: String!
  content: String!
}

Union or Interface?

  • GraphQLにはユニオン型とインターフェイス型の2つの抽象型がある
  • インターフェイス型は、振る舞いを共有するものに対して共通の契約を提供するものであるべき
    • 例: GitHubのStarrableインターフェイス
  • ユニオン型は、あるフィールドが異なる型を返す可能性があるが、これらの型が必ずしも共通の振る舞いを持つとは限らない場合に使用されるべき
    • 例: 一般的に使用される、リスト型を返すsearchフィールド
  • なにか共通項があってまとめられるならinterface、そうじゃなければユニオンということ?

Don't Overuse interfaces

  • インターフェイス型は、スキーマの中でより強い契約を作ることができるが、単に共通のフィールドを共有するために使われる場合などの誤用もあり得る
    • 共通のフィールドがあっても、共通の振る舞いをしない場合はインターフェイス型による型の共有は避ける
  • よくないインターフェイスの使い方をしているかどうかを見分ける方法のひとつに、「命名」がある
    • ItemInterfaceのような意味のない命名は、適切な抽象化ができてないというスメル
  • もっともよく見かけるインターフェイス型の誤用は、実装コードの再利用のためにインターフェイス型を使っているというもの
  • フィールドの再利用をかんたんにしたい場合は、GraphQLスキーマではなく、実装側で工夫する

Abstract Types and API Evolution

  • 抽象型を利用することで、APIに機能を追加しやすくなるか?
    • インターフェイス型の場合は、あてはまる
      • 機能を追加するだけで、既存の機能には影響しないようにできる
    • ユニオン型の場合は、あてはまらない
      • まったく違う型を許容するので、既存の機能に対して破壊的な変更を及ぼす可能性がある
  • GraphQLクライアントは新しいケースに対して防御的にコーディングするべきであり、GraphQLサーバーへの機能追加はクライアントを考慮して慎重に行うべき
プラハの社長プラハの社長

Designing for static query

static query

何なのか

引数や実行時の環境に依存しないクエリ

何が嬉しいのか

  • どんなクエリが最終的に発行されるのかソースコードを見るだけで判別できる
  • クエリ名を与えられるのでログに残しやすい
  • graphQLをそのまま使っているので言語に依存しない

「ORMのメソッドに頼らず生SQLを書いていこう!」って言われると「うっ」って感じるけど、GraphQLならではの利点が結構大きいのかな → 違うか。生SQLを書くのはサーバ側の話で、今回はクライアント側の話か

dynamic query

プログラムの実行時に組み立てられるクエリ

const productFields = products.map((id, index) => {
  return `product${index}: product(id: "${id}") { name }`;
})

const query = `
  query {
    ${productFields}.join('\n')
  }
`// 最終的に仕上がるクエリ
“query {
  product0: product(id: "abc") { name }
  product1: product(id: "def") { name }
  product2: product(id: "ghi") { name }
  product3: product(id: "klm") { name }
}Excerpt From
Production Ready GraphQL
Marc-Andre Giroux
This material may be protected by copyright.
Excerpt From
Production Ready GraphQL
Marc-Andre Giroux
This material may be protected by copyright.

何が辛いのか

  • 実行してみるまでどんなクエリが発行されるのかわからない
  • productの数によってクエリが変わる。fieldを動的に増やすためにfield aliasを使っている
    • field alias自体がダメというより0とか3とか意味が伝わりづらいfield aliasだからダメなのかな
    • かつクライアント側にとって、field aliasが実行時まで確定しないの死ぬほど使いづらそう

改善案

query FetchProducts($ids: [ID!]!) {
  products(ids: $ids) {
    name
    price
  }
}Excerpt From
Production Ready GraphQL
Marc-Andre Giroux
This material may be protected by copyright.
  • 複数形を取得するためのfieldは常に用意しておくのが良いらしい
ゲントクゲントク

Mutations

  • ミューテーションは、おそらくGraphQLを学び使う人がもっとも苦労する部分
  • クエリと同じ単なるフィールドだが、エラーハンドリングだったり副作用の扱いだったりが大変

Input and Payload types

(もともとRelayにあった)一般的な習慣として、ミューテーションはインプット型を受け取りペイロード型(ミューテーションの結果として使用されることを目的とした型)を返すようにする

type Mutation {
  createProduct(input: CreateProductInput!) CreateProductPayload
}

input CreateProductInput {
  name: String!
  price: Money!
}

type CreateProductPayload {
  product: Product
}
  • ペイロード型
    • 作成されたProductを返している
    • 失敗した場合に備えてnullable
    • 単純にProductを返しているのではなくCreateProductPayloadを返している
      • 型を変更することなくミューテーションに変更を加えることができる
      • 将来的には、変化したものだけでなくそれ以上のものを返さなければいけなくなる場合もある
        • たとえばsuccessfulフィールド
  • インプット型
    • Relayの規約では、ミューテーションごとに1つの必須かつユニーク型を使用することになっている
    • 引数の増加はよくある変更なので、それを1つの変数で扱えるようにしておく
      • いわゆるROROパターンというやつ?
  • インプット型もペイロード型も、ほとんどの場合、型を共有すべきでない
  • しかし、必要になれば、いくつか(といっても最小限)の引数を持つことを恐れないこと(次セクションで解説)
プラハの社長プラハの社長

github見るとpayload型を作ってない。普通に型を返してるっぽい。fieldは自由に追加してる→あれ、これがpayload型?

https://docs.github.com/en/graphql/reference/mutations#addupvote
https://docs.github.com/en/graphql/reference/mutations#markfileasviewed

https://graphql-rules.com/rules/mutation-payload

この辺を読んだ感じ、複数バージョンが共存するAPIの時に役立ちそう?ver1とver2が同じpayload型を返していれば複数バージョンを同時にサポートしやすい的な?

https://www.apollographql.com/blog/graphql/basics/designing-graphql-mutations/#:~:text=Designing the mutation payload

apolloのブログでもpayloadが良いプラクティスとして紹介されてる

理由はネストしていた方が名前空間の衝突を気にすることなくフィールドの追加や変更ができるから。

著者のyoutubeも見た。今回のとはちょっと関係ないけど勉強になる

  • PRのisMergableのfieldに関する例(feに計算させるのではなくbe側で計算しておいてfieldに含めて返した方が変更箇所が減って良い的な。ロジックがbeに寄る点についてはgraphql問わずなプラクティス)
  • mutationをデータではなくユースケースに依存させた方が良いもう一つの例として、addAssigneeには成功したけどaddLabelには失敗した〜みたいな不整合状況が発生しづらくなることが挙げられていた。suspenseとかswrもそうだけど、部分的な成功をより扱いやすくする流れを感じる
プラハの社長プラハの社長

fine / coarse grained

細かいmutationは様々なメリットがあるが、デメリットもある。特にschemaはネットワーク上のやりとりに関わるため、より粗い方に向かいがち

なぜネットワークを介すると(メソッドシグネチャなどと比較して)粗い方に行きがちなのか

  • 部分的に失敗する可能性が高い→クライアントのハンドリングが複雑化しがち
  • クライアントがシンプルでいられるようにする方がメリット大きい

オススメ

  • create系のmutationは粗く、update系のmutationは細かく
    • ユースケース的にそうなることが多いからかな。ユースケース駆動でスキーマ作ってると自然とそうなる的な

トランザクションについて

mutation {
  product1: addProductToCheckout(...) { id }
  product2: addProductToCheckout(...) { id }
  product3: addProductToCheckout(...) { id }
}Excerpt From
Production Ready GraphQL
Marc-Andre Giroux
This material may be protected by copyright.
  • 仮に全てのaddProductが成功するか失敗してほしいとする

  • 問題1:static queryになっていない

  • 問題2:graphqlはmutationを直列で実行するので2つ目だけ失敗することがある

  • graphqlにもtransactionをサポートする要望がよく寄せられるが、多くの場合はaddProduct"s"ToCheckoutを用意することで事足りる。static queryにもなる

  • 「カートに商品を追加して」「請求先を変更して」「値引きを適用して」などの処理を一つのトランザクションでサポートする必要があれば、それは一つのユースケースとしてmutationを作るべき

batch

type Mutation {
  updateCartItems(
    input: UpdateCartItemsInput
  ): UpdateCartItemsPayload
}

input UpdateCartItemsInput {
  cartID: ID!
  operations: [UpdateCartItemOperationInput!]!
}

input UpdateCartItemOperationInput {
  operation: UpdateCardItemOperation!
  ids: [ID!]!
}

enum UpdateCartItemOperation {
  ADD
  REMOVE
}Excerpt From
Production Ready GraphQL
Marc-Andre Giroux
This material may be protected by copyright.
  • こんな感じで複数のoperationを受け取るクエリを設計する事も可能
  • 今回の例はたまたまadd/removeどちらのinputも同じfieldを持っていた。そうでない場合はinput unionを使いたいけど現状未実装なのでinputに全てのfieldを持たせておいてoptionalにしておくのが良い
  • あるいはdirectiveを使ってfieldのうち1つしか指定できないことを強制できる
input UpdateCartItemOperationInput @oneField {
  operation: UpdateCartItemOperation!
  addInput: CartItemOperationAddInput
  removeInput: CartItemOperationRemoveInput
  updateInput: CartItemOperationUpdateInput
}Excerpt From
Production Ready GraphQL
Marc-Andre Giroux
This material may be protected by copyright.
ゲントクゲントク

Errors

エラー処理には正解がなく、多くの人を悩ませる。エラー処理は、APIが使用される文脈に依存する。

queryの例

{
  "message": "Could not connect to product service.",
  "locations": [{ "line": 6, "column": 7}],
  "path": ["viewer", "products", 1, "name"],
  "extensions": {
    "code": "SERVICE_CONNECT_ERROR"
  }
}
  • message … エラーの概要
  • locations … クエリ内のエラーの箇所(行と列で指定)
  • path … クエリ内のエラーの箇所(ルートからのたどり方(例だとproductsフィールドのインデックス1))
  • extensions … 仕様の追加にともなう命名の衝突を避けるためのフィールド
    • code … アプリケーションが処理しやすいように識別子を設定

dataといっしょにerrorsをみてみると、こんなかんじ

{
  "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
        },
        {
          "id": "1001",
          "price": null
        },
        {
          "id": "1002",
          "price": 100
        }
      ]
    }
  }
}

エラーになっているフィールドは、dataではnullになっている

mutationの例

mutation {
  createProduct(name: "Computer", price: 2000) {
    product { 
      name
      price
    }
  }
}

重複した製品が追加された場合のエラー

{
  "errors": [
    {
      "message": "Name for product already exists",
      "locations": [ { "line": 2, "column": 2 } ],
      "path": [ "createProduct" ],
      "extensions": {
        "code": "PRODUCT_NAME_TAKEN"
      }
    }
  ],
  "data": {
    "createProduct": null
  }
}
  • dataがnullだけだと、mutationsに関するメタデータが足りない
  • 情報を追加するために、extensionsにキーを追加する必要がある
  • エラーのペイロードはGraphQLスキーマの外側にあり、クライアントはGraphQLの型システムの恩恵を受けられない
  • ほとんどのフィールドが非nullの場合、エラーはクエリに致命的な影響を与えることになるので、nullabilityのセクションで見た null の伝播は常に念頭に置いておく必要がある

GraphQLエラーはもともと例外的なイベントやクライアント関連の問題を表すために設計されたものであり、必ずしもエンドユーザーに伝える必要のある想定内の製品やビジネスのエラーではない

エラーを2つの大きなカテゴリーに分類する

  • 開発者、クライアントのためのエラー
  • ユーザーのためのエラー

先ほど取り上げたGraphQLの「errors」キーは、開発者/クライアントエラーを捕捉するのに最適な場所です。これは開発者が読み、GraphQLクライアントが処理することを想定している。ビジネス/ドメインルールの一部であるユーザー向けのエラーについては、例外/クエリレベルのエラーとして扱うのではなく、スキーマの一部として設計することが現在のベストプラクティスとされている。

ゲントクゲントク

errorsのなかにエラーコードをもたせるだけでは、クライアント側でハンドリングするには情報が不十分
だから、なるべくdataを使いたい

プラハの社長プラハの社長
  • restでも想定されうるエラーが起きたときは200返しつつbodyに「error」フィールドを設けておくパターンもある。それに似てそう
  • 403とかはクライアントの処理分岐が大きく異なる+大体同じ扱い(ログイン画面に飛ばす)だから、これは想定内でも200とは扱いを分けても良さそう
プラハの社長プラハの社長

Errorsの後半全部

errors as data

こんな感じ

type SignUpPayload {
  emailWasTaken: Boolean!
  # nil if the Account could not be created
  account: Account
}Excerpt From
Production Ready GraphQL
Marc-Andre Giroux
This material may be protected by copyright.

ただし書き方を他のmutationで統一しづらくなるので、共通のtypeを作成する方が便利なことが多い

type SignUpPayload {
  userErrors: [UserError!]!
  account: Account
}

type UserError {
  # The error message
  message: String!

  # Indicates which field cause the error, if any
  #
  # Field is an array that acts as a path to the error
  #
  # Example:
  #
  # ["accounts", "1", "email"]
  #
  field: [String!]

  # An optional error code for clients to match on.
  code: UserErrorCode
}Excerpt From
Production Ready GraphQL
Marc-Andre Giroux
This material may be protected by copyright.

問題点

  • クライアントが失敗理由に気づかないことがある。errorsフィールドをクライアントがqueryするとは限らないので、失敗理由がわからないまま欲しいデータがnullとして返ってくることがある
  • クライアントがハンドリングを忘れる可能性もある。

これとデフォルトのgraphqlエラーハンドリングは何が違うのか

  • スキーマで表現できるかどうか

union/result type

type Mutation {
  signUp(email: string!, password: String!): SignUpPayload
}

union SignUpPayload =
  SignUpSuccess |
  UserNameTaken |
  PasswordTooWeak
mutation {
  signUp(
    email: "marc@productionreadygraphql.com",
    password: "P@ssword"
  ) {
    ... on SignUpSuccess {
      account {
        id
      }
    }

    ... on UserNameTaken {
      message
      suggestedUsername
    }

    ... on PasswordTooWeak {
      message
      passwordRules
    }
  }
}Excerpt From
Production Ready GraphQL
Marc-Andre Giroux
This material may be protected by copyright.

最も型が厳格でクライアントとしては使いやすいのではないか。最近人気を獲得しつつある

どっち使えばいいの?

スキーマにエラーが表現されている限りそこまでの意見はないものの

union/result typeのデメリット

  • 新しいtypeを追加した時にクライアントが壊れる可能性がある。クライアント側でtypeの増加に対して防御的にコーディングされている必要がある。スキーマから自動生成するようなクライアントなら良いがWEB APIに対してはそこまで出来ないこともある
    • 対して1つ目の手法ならuserErrorsの中に新しいエラーを追加した際、特にクライアント側での対応は必要ない

interfaceを使う

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

type DuplicateProductError implements UserError {
  message: String!
  code: ErrorCode!
  path: [String!]!
  duplicateProduct: Product!
}Excerpt From
Production Ready GraphQL
Marc-Andre Giroux
This material may be protected by copyright.

こんなふうにinterface使えば共通部分が存在することが常に保証される。かつ特定のエラーに関するフィールドも取得できる

  • userErrorsの中に全部エラーを突っ込んでいくアプローチの方がバリデーションを自動生成しやすいこともある

一つ目のアプローチのデメリット

  • 排他的なエラーを表現できない(unionなら必ず1つしか実装できない制限がかかる)

どちらにも存在するデメリット

  • クライアントがエラーをqueryすることを強制できない。graphqlのデフォルトのエラーはそれが保証されているのが大きなメリット
ゲントクゲントク

Schema Organization

クライアントにとって使いやすい(ユースケースを探しやすい)スキーマにするための、フィールドなどをうまく整理する方法について

Namespaces

  • GraphQLには名前空間の仕組みが求められていて、プロポーザルもあるが、どの仕様もあまり進んでいない
  • 命名を十分具体的にすれば、名前空間が必要になることはほとんどない

どうしても名前空間が必要な場合は、プレフィックスのような命名戦略を使用することをオススメする

type Instagram_User {
  # ...
}

type Facebook_User {
  # ...
}
  • 名前空間に関する要求のほとんどは、「スキーマステッチ(8章で紹介)」のような戦略を使っている開発者からくる
    • スキーマステッチ … 異なるスキーマをマージすることができる
    • 命名の競合の扉を開くことになる
  • スキーマの設計と、そのスキーマをサーバーサイドで構築する方法は、まったく別の問題
    • GraphQLに名前空間の仕組みを用意せずとも、サーバーサイドで名前空間、モジュール、再利用可能な関数を使用して、コードの整理を支援することは可能

Mutations

  • ミューテーションの名前として、crateProductproductCreateどちらがよいか
  • 著者がShopifyにいたころは、productCreateだった
    • SDLやintrospectionやGraphQLスキーマにおいて、ミューテーションのグループをわかりやすくするため
  • 著者の個人的な感想としては、整理のために読みにくい命名をすることが悲しかった
  • でも、addProductToCartのような具体的で読みやすい名前を使用しても大丈夫
  • tagsディレクティブを使用することでグループ化することもできる(?)
    • One idea I've been playing with is to use a tags directive

    • tagsのようなdirectiveを自分らで実装してみな?ってこと?
type Mutation {
  createProduct(...): CreateProductPayload @tags(names: ["product"])

  createShop: CreateShopPayload @tags(names: ["shop"])

  addImageToProduct(...): AddImageToProductPayload @tags(names: ["product"])
}

フィールドをネストさせて名前空間のようにして使うやりかたもある

mutation {
  products {
    deleteProduct(id: "abc") {
      product
    }
  }
}
  • こういうやりかたはGraphQLの仕様として定義されていないため、正しく動作しない可能性がある
  • deleteProductはふつうに考えれば読み取り専用のフィールドとみなされるはず
プラハの社長プラハの社長
プラハの社長プラハの社長

非同期処理

  • HTTPステータスなら202で終わるがGraphQLの場合は「一部は完了しているから返せるけど、一部は返せない」みたいな状態を表現できる
type Query {
  payment(id: ID!): Payment
}

union Payment = PendingPayment | CompletedPaymentExcerpt From
Production Ready GraphQL
Marc-Andre Giroux
This material may be protected by copyright.

unionを使わない手もある

type Operation {
  status: OperationStatus
  result: OperationResult
}

enum OperationStatus {
  PENDING
  CANCELED
  FAILED
  COMPLETED
}Excerpt From
Production Ready GraphQL
Marc-Andre Giroux
This material may be protected by copyright.

データ駆動かユースケース駆動か

  • 基本はユースケース駆動で良いけど、githubのapiなどは少し異なる使い方も可能。例えばgithubに集まっている大量のコメントを解析するサービスを作るのであれば必要なのはコメントだけで、ユースケースとは異なる開け口が必要。というのも...
    • ページネーショん:大半のapiはデータの大量消費に適しておらずページネーショんを挟むので困ることがある
    • タイムアウト:大体のapiプロバイダはタイムアウトを使って長時間の接続を避ける。しかしデータ駆動なクライアントだと困ることがある
  • とはいえこれはgraphqlに限った話ではなく、世の中にある大体のAPIはユースケース駆動に設計されている(しそうすべき)なのでデータ駆動なクライアントの要望には対応できない

ジョブを使う

そんな時はジョブを使うと良い。こんな感じで

“POST /async_graphql
{
  allTheThings {
    andEvenMore {
      things
    }
  }
}

202 ACCEPTED
Location: /async_graphql/HS3HlKN76EI5es7qSTHNmA
And then indicating to clients they need to poll for the result somewhere else:
GET /async_graphql/HS3HlKN76EI5es7qSTHNmA

202 ACCEPTED
Location: /async_graphql/HS3HlKN76EI5es7qSTHNmA
GET /async_graphql/HS3HlKN76EI5es7qSTHNmA

200 OK
{ "data": { ... } }”

Excerpt From
Production Ready GraphQL
Marc-Andre Giroux
This material may be protected by copyright.

まとめ

  • まずデザインファーストなアプローチでスキーマを設計する。ドメイン知識を持ったチームと相談すべし
  • ユースケース駆動でスキーマを設計する。データとかタイプで考えない
  • スキーマの表現力を高める。クライアントが正しく使う方が簡単な作りにする。ドキュメントはケーキの上のトッピングにすぎない
  • 汎用的/クレバーなスキーマを作らない。特定のユースケースに明確に答えられるタイプやフィールドを用意する
プラハの社長プラハの社長

コードファーストかスキーマファーストか

スキーマファースト

var { graphql, buildSchema } = require('graphql');

var schema = buildSchema(`
  type Query {
    hello: String
  }
`);Excerpt From
Production Ready GraphQL
Marc-Andre Giroux
This material may be protected by copyright.
  • SDLを自ら宣言するタイプ
  • ここまで触れてきたデザイン・ファーストとは別の概念なので注意

メリット

  • 言語に依存しないSDLでスキーマを定義するので、実装者の言語経験によらず、最終的なスキーマの状態を想像しやすい。またツールがサポートしていることが多い(マイナー言語で開発しているときは、言語自体がサポートされていなくてもSDLを作っておけばツールを使えることがある)
  • 実装について考える前にスキーマを考えることを強制できる(リゾルバはSDLには定義できないので)

デメリット

  • スキーマと実装が分離することで両者の整合性を保つのが難しくなる。スキーマの更新をリゾルバに反映し忘れるなど。
  • スキーマが肥大化すると共通クラスや関数を作りたくなるがSDLだと、それが実現しづらい
    • こういうツールを使うのがコードファーストの対案かな

コードファースト

  • SDLを直接自分達が書くのではなくrubyなりtsなりプログラミング言語のクラス等として表現する

メリット

  • スキーマ、リゾルバ、ロジック、インターフェースなどが同じ箇所に集まる。見つけやすく、変更が必要な箇所を特定しやすく、保守しやすい
    • githubではconnection型を共通化していたりする

デメリット

  • SDL依存のツールが使えない
  • 抽象化されすぎて最終的に出力されるSDLがイメージしづらい

ただ、このあたりのデメリットはこの後紹介する方法で克服できる

SDLデザイン、コード実装、SDL成果物

  • コードファーストとスキーマファーストを組み合わせるのが著者の好み。SDLを使って議論を行う。SDLの方が早く書けるし
  • スキーマを決めたらコードファーストで実装し始める。コード定義からSDL成果物を出力するのが最適。バージョン管理してコードに対してテストしておくのが良い
    • SDLとコードの不整合が起きないことを保証するテストってどんなのだろう
  • 双方の良いとこどりができる

アノテーションベースの実装

  • facebookで採用されている方法。コードファーストとスキーマファーストの中間
  • ドメインエンティティなどにアノテーションをつけてスキーマを自動生成する。

メリット

  • インターフェースが小さい。graphqlのスキーマを表現するためだけのオブジェクトを何百と用意する必要がなく、すでにあるエンティティを使い回せる

デメリット

  • apiの質がエンティティの質に引っ張られる。もしエンティティがdbテーブルに密結合したものだったら、apiは使いづらいものになりやすい(前の章で説明された通り、ユースケースではなくデータ駆動になってしまうから

dbと疎結合で使い回しやすいエンティティが設計できている限りにおいて有効な手段

サンプル

public class User {

    private String name;
    private Date registrationDate;

    @GraphQLQuery(
      name = "name",
      description = "A person's name"
    )
    public String getName() {
        return name;
    }

    @GraphQLQuery(
      name = "registrationDate",
      description = "Date of registration"
    )
    public Date getRegistrationDate() {
        return registrationDate;
    }
}Excerpt From
Production Ready GraphQL
Marc-Andre Giroux
This material may be protected by copyright.
  • 個人的にはドメインエンティティにこんな情報入れたく無いなぁと思った。インフラの知識漏れすぎでは?

SDL成果物の生成

  • jsならこういうの使えば簡単にスキーマが出力できる
  • introspection queryを使うことも選択肢の一つだけど、ソレは実行時に行われるだけだからスキーマ全体像を掴むことはできない
    • 定義されたqueryとかmutationに対してgraphiqlでintrospectionを実行して、返ってきた結果をドキュメント化する、ということかな?
  • partner onlyとかfeature flaggedフィールドも含めてスキーマ全体を表示するのがオススメ。非表示になることがわかるようmetadataを付与しておけば、SDLを見た人が「このフィールドは存在するけど一部の人にしか見れないんだな」と理解できる
    • 難点はプリンターのカスタマイズが必要なこと。
    • unless your metadata solution also adds directive definitions under the hood(?)と書いてあるが、どういうことだろう。メタデータ→SDLを生成するライブラリが自動的にdirective definitionを定義してくれるものなら良いけど、そうじゃなければ自前で作る必要があるよ〜ってことかな
  • sdl成果物はlint、破壊的な変更の検知、ドキュメントの自動生成など様々なプラクティスの基礎となる。バージョン管理しておくべし

まとめ

  • コードファーストを推奨する
  • jsならgraphql nexusとかgraphql-jsの使用を推奨する
  • sqprとかtype-graphqlみたいなアノテーションベースのアプローチも良いけど、graphqlスキーマと実装詳細を密結合させないように注意が必要
プラハの社長プラハの社長

DBがデータ駆動か情報駆動になっているか。graphqlのスキーマがユースケースではなくデータ駆動に設計されて、かつドメインエンティティとテーブルが一対一になっている場合、スキーマの設計がdbの設計にも影響していく(データ駆動ではなく情報駆動に近づいていく)可能性がある

ゲントクゲントク

GraphQL Server Basics

GraphQLサーバーの構築は、理論的にはかんたんだが実際にはかなり厄介

GraphQLサーバーの構成

GraphQLサーバーの実装に必要なものは以下の3つ

  • 型システムの定義(2章で設計したもの)
  • 型システムにしたがって、要求されたクエリを実行するランタイム実行エンジン(この章で紹介する)
  • クエリ文字列と変数を受け入れる準備ができているHTTPサーバー

ほぼすべての言語がGraphQLサーバーの実装を持っており、すべての言語が異なる方法でランタイムとHTTPサーバーを実現させている
具体的な構成としては、ユーザーがAPIの型システムと実行時の振る舞いを定義し、ライブラリは実行時アルゴリズムを含むGraphQL仕様の実装を担当するのが一般的

resolver

特定のフィールドのデータを満たすために使用される概念をresolverと呼ぶ
クエリを実現するためには、型システムだけでなく、ふるまいや型システムの背後にあるデータも必要
resolverの中核は、実際には単純な関数

function resolveName(parent, arguments, context) {
  return parent.name;
}
  • resolverは1つのフィールドのデータを解決する役割を担っている
  • GraphQLクエリの実行は、ツリー状のデータ構造の単純な深さ優先探索に似ている
  • GraphQLクエリのノード各行において、GraphQLサーバーは通常、そのフィールドに関連するresolver関数を実行する
  • 上の例の通り、resolver関数は3〜4個の引数をとる
  • parent
    • 親resolver関数から返されたオブジェクト
    • userresolverが返したオブジェクトusernameresolverが受け取る、ということ
  • arguments
    • フィールドの引数
  • context
    • 特定のクエリに関するグローバルなデータやコンテキストデータを含むオブジェクトであることが多い

ちょっとしたクエリを例に、サーバーの動きを想像してみる

query {
  user(id: "abc") {
    name
  }
}
  • まず、ルートオブジェクト(実装によって異なる)が、userresolverを呼び出す
    • parentはルートオブジェクト(?)、argumentsはid、contextはグローバルコンテキストオブジェクト
  • userresolverの結果を用いて、nameresolverを呼び出す
    • parentはuser、argumentsは無し、contextはグローバルコンテキストオブジェクト

まだGraphQLサーバー実装したことないのであれば、ここで1回実装してみてから戻ってくるとよい

ゲントクゲントク

Resolver Design

  • どのような方法でスキーマを構築したとしても、リゾルバーは必ず書かなければいけない
  • GraphQL実行エンジンのリゾルバーパターンは素晴らしい
    • リゾルバーを独立させ、ユースケースを追加しやすい
    • あくまでGraphQLはAPIインターフェイスであるので、ドメインやビジネスロジックと混同してはならないことに注意
  • 優れたリゾルバーは、ほとんどコードを含んでいないことが多い
    • ユーザーの入力を処理し、ドメインレイヤーを呼び出し、その結果をAPIの結果に変換する、だけ
    • 多くのバリデーションを施したくなるかもしれないが、リゾルバーはできるだけ"バカ"にする
    • ドメインレイヤーにアクセスするのはGraphQLのAPIだけではないかもしれないので、あまりGraphQLリゾルバーにロジックを集めすぎない

Beware of the Context Object

contextは「リクエスト」レベルの情報を保存するのに便利

  • なにかのヘッダーの値
  • リクエストIP(?)
  • 認証/認可のデータ

実行時の振る舞いを変更するためにcontextを使うのは、非常に良くない。コンテキストに関連する条件を追加するたびに、APIは予測しにくくなり、テストしにくくなり、キャッシュしにくくなる。

def user(object, arguments, context)
  if BLACKLIST.includes?(context[:ip])
    nil
  else
    getUser(arguments[:id])
  end
end

この例だけで間違いとは言えないが、こういった条件分が増えすぎると、すべてのコールで同じコンテキストが提供されることに依存してしまう。たとえば、内部通話やサービス間通話で ip が提供されない場合、リゾルバロジックが破綻することになる。

クエリ実行中にcontextの値を変更することも、避けたいパターン

def user(object, arguments, context)
  user = getUser(arguments[:id])
  context[:user] = user
  user
end
  • リゾルバーの順序依存性が高まる
  • スキーマが進化したときとかユーザーが思いも寄らないクエリパターンを実行した時にバグりやすくなる
  • contextを使わないというのは難しいが、contextを使って保持するデータは厳選する
  • クエリを実行する前にcontextの特定の部分を必須とするのもいいアイデアかもしれない
    • どういう意味?

Lookaheads and Order dependent fields

関数が解決しようとしているフィールドの下に、現在どのようなフィールドが参照されているかという情報を取得できるライブラリがあるが、この情報の扱いには十分注意する。たとえば、ある注文の商品データを先取りしてロードしたくなるとする。クエリシェイプ(?)や子のフィールドに依存するリゾルバロジックを書くと、問題を起こす場合がある。

  • クエリルートのフィールドは並列に実行することができるが、並列化できないリゾルバロジックを持つことで、永遠に自分をロックしたくないと思うかもしれない(?)

  • スキーマが進化すると、予期しない新しいクエリが出現するかもしれない。つまり、サブクエリを処理するために書いたロジックは、古いのではなくほとんどの場合間違っている(?)

  • リゾルバーパターンの良さは、それぞれのリゾルバーが1つのことをうまくやることができること

  • 一般的に、ある型やフィールドがクエリの中で使われる可能性があることを想定してはいけない

  • リゾルバーが頼るべきものは、他のフィールドではなく、リゾルバーが受け取るオブジェクト

  • リゾルバーは純粋関数である時にもっともうまく動作する(mutationフィールドを除く)

  • クエリルートにたいして問い合わせを行うときは、できるだけ一貫した振る舞いをし、副作用を持たないようにする

  • つまり、contextを変更しない、実行順序にまったく依存しないことを目指す

Summary

優れたリゾルバーを書くのは難しいが、正しい方向に向かうようにいくつかの基本原則に集中することができる

  • GraphQLレイヤーの中にビジネスロジックをできるだけ入れない
  • contextはできるだけイミュータブルにする
  • フィールドの順序依存性をなくす(contextの特定の値が他のフィールドでfillされている、などと仮定しない)
プラハの社長プラハの社長

lookaheadについて

  • これのことかな

  • 順番依存はcontextに関する話かな。できるだけ純粋関数にしておいたほうがよい

  • リゾルバはできるだけ馬鹿にしておこう

  • 本全体を通してリゾルバに関する話はここしか出てこない。リゾルバを馬鹿にしておくことを考えると自然な分量なのかな

プラハの社長プラハの社長

スキーマmetadata

スキーマが複雑になってくるとメタデータを駆使して表現力を高めたいことがある。例えばGitHubでは以下のようなメタデータが存在する

  • typeのバージョン(internal/public)
  • typeが開発中か否か
  • typeがフィーチャーフラグの裏に隠れているか
  • アクセスするためにどの認可スコープが必要か

コードファーストの場合

rubyだとこんな感じ

class PostType < MyBetterObjectType
    name "Post"
    description "A blog post"

    under_development since: "2012-07-12"

    schema :internal

    scopes :read_posts

    # Field definitions
  end”

Excerpt From
Production Ready GraphQL
Marc-Andre Giroux
This material may be protected by copyright.

graphql nexusだとこんな感じ(complexityをidフィールドに設定している)

export const User = objectType({
  name: "User",
  definition(t) {
    t.id("id", {
      complexity: 2,
    });
  },
});Excerpt From
Production Ready GraphQL
Marc-Andre Giroux
This material may be protected by copyright.

コードファーストとメタデータは相性が良い

スキーマファーストの場合

独自に定義したschema directiveを使うことが多い。

type Post
  @underDevelopment(since: "2012-07-12")
  @schema(schema: "internal")
  @oauth(scopes: ["read_posts"]) {
  title: String!
  comments: [Comment]
}Excerpt From
Production Ready GraphQL
Marc-Andre Giroux
This material may be protected by copyright.

メタデータをどうやってクライアントに共有するか

メタデータの共有方法については仕様がまとまっていない。現段階の仕様ではintrospection requestにはメタデータが表示されないため。

  • 例示されていた用途として「フィールドの計算コストをメタデータとして表現する」「認証情報」「キャッシュの性質」などが挙げられていた。コストを表現するのは考えたことなかったな。

著者のおすすめはメタデータをエンコードしてsdlに紛れ込ませること。少なくともツールがメタデータを入手できるようにする

  • どういうイメージだろう
    • metadataみたいな名前のfieldを定義して、そこにエンコードした値を詰め込む感じ?connectionに定義しておくイメージだろうか

sdlダンプとintrospection payloadの違いが若干鬱陶しいらしい(?)

  • どういうことだろう
    • sdlで表現されている内容(directiveとかを使って)とintrospection queryで返ってくる情報がミスマッチしていることが鬱陶しいってことなのかな。

スキーマメタデータは再利用可能な良いパターンを開発者に提供して開発を誘導する良い手段。例えばoauth_scopeメタデータを使えば該当するリゾルバでアクセス権限のチェックを自動化できる。複数名で開発されるgraphqlプラットフォームを開発している人にとってメタデータは必須の機能になるだろう

ゲントクゲントク

Multiple Schemas

  • 同時にメンテしなければいけない複数のスキーマ
    • Shopifyのstorefrontとadmin
    • GitHubのinternalとpublic
  • これらのスキーマは完全に分離して管理するべきなのか、共通の関心事を共有すべきなのか
  • ビルド時に異なるスキーマを構築するか、実行時に異なるスキーマを構築するかの二択

ビルド時にスキーマを構築するアプローチ

+-- admin
|   +-- order.rb
|   +-- schema.rb
+-- storefront
|   +-- collection.rb
|   +-- product.rb
|   +-- schema.rb
+-- common
|   +-- product_image.rb
  • どのようなスキーマが構築されているのか非常にわかりやすい
  • スキーマの変更もやりやすい
  • 型定義は再利用しないように注意する
    • 型定義がこれからも確実に変化しないことがわかっているのであれば再利用してもOK

実行時にスキーマを構築するアプローチ

const User = objectType({
  name: "User",
  // Annotate this type as INTERNAL only.
  schema: INTERNAL,
  definition(t) {
    t.int("id", { description: "Id of the user" });
  },
});
  • スキーマの大部分は同じだが、一部が少しずつ異なるような場合は有効
  • 特定のリクエストに基づいてフィールドを隠したり表示したりする機能として、schema visibility filterと呼ばれる手法が便利

Schema Visibility

RESTエンドポイントの話

  • 特定のクライアントにのみリソースを公開する場合
  • そのリソースは、特定のクライアント以外のクライアントには404を返す
  • 403を返すと、そのリソースが存在するという情報が漏洩してしまうことになる

GraphQLでは、リソースの存在を隠す代わりに、フィールド・型・または任意のスキーマメンバーのようなスキーマのサブセットを隠すための、スキーマのマスキングと呼ばれる手法がある
フィーチャーフラグで新しい機能を隠す/公開したり、特定のパートナーにのみ公開したり、マスクを利用してスキーマの複数のバージョンを維持するのに役立つ

通常のGraphQLの認可のしくみだと、認可されないフィールドへのアクセスはnullやエラーになる
スキーマのマスキングでは、特別なプレビューヘッダーがクエリとともに渡されたときのみスキーマの新機能にアクセスでき、そうでない場合は"Field not exist error"を受け取る

スキーマのマスキングは、ライブラリによってあったりなかったりする
(執筆時点で)GraphQLRubyやGraphQL Javaにはあるが、GraphQL-JSにはない

GitHubの実装では、型にフィーチャーフラグをつけることができる

class SecretFeature < ProductionReadyGraphQL::BaseType
  name "Secret"
  description "Do not leak this."

  feature_flagged :secret_flag

  # Field definitions
end

実行時にスキーマのマスキングが適用され、フィーチャーフラグをオンにしているユーザーだけに、この型の存在がわかるようになる

class FeatureFlagMask
  # If this returns true, the schema member will be allowed
  def call(schema_member, ctx)
    current_user = ctx[:current_uer]

    if schema_member.feature_flagged
      FeatureFlags
        .get(schema_member.feature_flagged)
        .enabled?(current_user)
    else
      true
    end
  end
end

MySchema.execute(query_string, only: FeatureFlagMask)

フィーチャーフラグのような素晴らしいユースケースはあるが、やりすぎは禁物
デバッグやテスト、ユーザーが最終的にどのスキーマのどのバージョンを見ることになるのかを追跡するのが難しくなる
たとえば、ある型を隠すとそれに応じて他の方のフィールドも隠されるような実装がしたくなるかもしれないが、そういうものを完璧に実装するのは難しい
実行時マスキングは、場合によっては、もっともシンプルなアプローチとは言えないかもしれない

プラハの社長プラハの社長

モジュール化

スキーマをモジュール化するためのツールは多数存在する

しかしコードファーストならあえてツールを導入しなくてもプログラミング言語自体が提供しているモジュール化で充足する。クライアントに提供されるスキーマと、その組み立て方は異なるもの。

言語でモジュール化するとしたら

こんなふうにドメインで分けるのがオススメ

“+-- graphql
|   +-- orders
|   |   +-- order_type.rb
|   |   +-- invoice_type.rb
|   |   +-- ...
|   +-- products
|   |   +-- product_type.rb
|   |   +-- variant_type.rb
|   +-- schema.rb”

Excerpt From
Production Ready GraphQL
Marc-Andre Giroux
This material may be protected by copyright.

GraphQLの構成要素の種別で分けるのは推奨しない。種別による分類が役立つことは稀なため

  • screaming architecture的な感じかな。フロントエンドのコードの分類方法としても定着してるし、バックエンドも協会づけられたコンテキストで同じことやってるし、これが鉄板感がある
“+-- graphql
|   +-- object_types
|   |   +-- order_type.rb
|   |   +-- product_type.rb
|   |   +-- ...
|   +-- interfaces
|   |   +-- ...
|   +-- input_types
|   |   +-- ...
|   +-- mutations
|   |   +-- add_product.rb
|   |   +-- delete_order.rb
|   +-- schema.rb”

Excerpt From
Production Ready GraphQL
Marc-Andre Giroux
This material may be protected by copyright.
ゲントクゲントク

Testing

  • GraphQLのテストは難しい、GraphQLを始めたばかりのチームにはとくに
  • GraphQLのダイナミックでカスタマイズ可能な性質が、テストを難しくしている
  • リゾルバーを、リゾルバーの章で説明したアドバイスにしたがって設計すると、テストはずっと楽になる
  • インターフェイスそのものをテストしたいところだが、その方法はあまりない

Integration Testing

  • ビジネスロジックが十分に分離されテストされているい場合でも、GraphQLレイヤーのテストは重要
  • 結局GraphQLエンジンはアルシュのブラックボックスであり、validationやcoercion(?)によって結果が変化しがち
  • 統合テストが、おそらくもっともかんたんで安全なテスト

オブジェクトタイプごとのテストがうまくいくことが多い

  • あるオブジェクトを返すフィールドに応じて動作するテスト
    • nodeフィールドを通してオブジェクトAを取得する、findProductのようなファインダーフィールドを通してオブジェクトAを取得する、など
  • オブジェクトのすべてのフィールドに対してクエリを実行し、何も見逃していないことを確認するためのテスト
  • 認証テスト
    • とくにフィールドごとに認証が異なる場合

Unit Testing Resolvers

  • 個別のフィールドに対してテストを行うことは、とくに複雑なパラメーターを持つフィールドに対しては価値がある
  • リゾルバーは、フィールドの結果がレスポンスのペイロードに追加される前、追加の変換を行いがち
  • 型のcoercionは、ほとんどすべてのGraphQLフレームワークが行ってくれるが、結果を変えてしまう可能性がある
  • contextオブジェクトがあることを考えると、グローバルクエリ(?)を考慮せずに単一のリゾルバーにcontextオブジェクトを提供するのは非常に困難
  • リゾルバーが第一引数で取得するparentオブジェクトも、モックするなどしなければいけない

Summary

GraphQLサーバーの実装は、アーキテクチャや言語、フレームワークによって大きく異なるが、役に立つ基本原則はいくつか存在する

  • 拡張性が高い、コードファーストのフレームワークを選ぶ
  • GraphQLのレイヤーはできるだけ薄くして、ロジックは独自のドメインレイヤーにいれる
  • モジュール化は、フレームワークの魔法を使うよりもプログラミング言語の機能を使ったほうがよい
  • ドメインロジックはドメインレイヤーでテストして、GraphQLのテストは統合テストで行うのが、コスパのよいアプローチ
  • 実行時の条件に基づく小さなスキーマのバリエーションを扱う際にはvisibilityフィルターを使用するが、大きく異るスキーマを扱う場合はそれぞれ分けてビルドすることをためらわない
ゲントクゲントク

Security

  • GraphQLの世界ではセキュリティがホットな話題
  • 「クライアントが必要なものを正確にクエリできる」という話をはじめて聞いた人は、クライアントがアクセスすべきでないデータにアクセスできてしまうのではないかということに不安を覚えがち
  • クライアントがサーバーをダウンさせたり、アクセスすべきでないものにアクセスされないための設定について説明する
  • 特にGraphQL特有のものについて取り上げる
ゲントクゲントク

Rate Limiting

エンドポイントベースのAPIでは、一定期間内にどれだけリクエストがきたか、などのレート制限を設けることでサーバーの負荷が高まりすぎないようにするが、GraphQLの場合それではうまくいかない

query A {
  me {
    name
  }
}
query B {
  me {
    posts(first: 100) {
      author {
        followers(first: 100) {
          name
        }
      }
    }
  }
}
  • クエリAとクエリBでは、リクエストあたりのコストがまったく違う(Bのほうが高価)
  • クエリAにとって妥当なレート制限を設けたとしても、クエリBにとってその制限は厳しすぎる

以上の理由から、GraphQLでは従来のやりかたと違う方法でレート制限を考える必要がある

プラハの社長プラハの社長

complexity-based / time-based approach

complexity-based

  • クエリの複雑度を実行前に検証するパターン。実行前に複雑度が分かっていてクエリを止められるのが好ましい
  • リクエストされているオブジェクトのコストを1として、その個数を掛け合わせる。
query {
  # Loading the viewer costs 1
  viewer {
    # For 1 user, fetch 100 posts, costs 1
    posts(first: 100)  {
      edge {
        node {
          # For each post, load one author: 1x100, costs 1
          author {
            name
          }
        }
      }
    }
  }
}Excerpt From
Production Ready GraphQL
Marc-Andre Giroux
This material may be protected by copyright.
  • こういう計算をgraphql rubyならできるし、graphql-query-complexityみたいなツールがjs界隈だと活用できる
  • フィールドのコストはスカラー値の場合考慮しない
    • 集計値はn+1に比べたら影響が小さいと割り切ってるのかな

問題点

  • リストが存在する場合は事前に個数がわからないこともある。ページネーションに対応していれば問題ないが、もしページネーションに対応していないリストの場合は
    • まずページネーションできないか考える
    • できない/不要なリストの場合は個数が固定だったり少ないこともあるので、事前にコストを計算できるかもしれない

time-based approach

  • 実際に実行にかかった時間を測定してコストに反映する方法。
    • 実行してみるまで結果が分からないのが難点
    • 実行時間を正確に測定できない場合も機能しないのが難点
  • いまいちなコスト計算方法だけどリクエストの個数を数えるよりはマシという意見らしい

rate-limitの共有方法

  • ヘッダーに含めて返す。githubのrestはこんなヘッダーを返している
“Status: 200 OK
X-RateLimit-Limit: 5000
X-RateLimit-Remaining: 4999
X-RateLimit-Reset: 1372700873”

Excerpt From
Production Ready GraphQL
Marc-Andre Giroux
This material may be protected by copyright.
  • graphqlならこんなふうにmeta情報として入れておける
query {
  rateLimit {
    cost
    limit
    remaining
    resetAt
  }
  user(id: "123) {
    login
  }
}Excerpt From
Production Ready GraphQL
Marc-Andre Giroux
This material may be protected by copyright.
  • githubのapiの場合は引数に(dryRun: true)を加えるとクエリを実行せずにコストだけ計算してくれるので非常に便利。

  • shopifyは"extensions"にメタ情報を含めている

{
  "data": {
    "shop": {
      "name": "ProductionReadyGraphQL"
    }
  },
  "extensions": {
    "cost": {
      "requestedQueryCost": 1,
      "actualQueryCost": 1,
      "throttleStatus": {
        "maximumAvailable": 1000,
        "currentlyAvailable": 999,
        "restoreRate": 50
      }
    }
  }
}Excerpt From
Production Ready GraphQL
Marc-Andre Giroux
This material may be protected by copyright.
  • あるいはhttpステータス429(too many requests)を返しておいて、ヘッダーにRetry-Afterを明示してあげるのも一つの方法。クライアントに計算させるより、ダメだった事実だけをサーバーが伝える方が親切かもしれない
ゲントクゲントク

Blocking Abusibe Queries

無限の深さのクエリーを許容してはいけない

query {
  product {
    variants {
      product {
        variants {
          product {
            # We can do that for a while
            variants {}
          }
        }
      }
    }
  }
}

無限の深さのクエリーは、カスタムバリデーターで実装することができる

  • GraphQL-JSでは深さ方向の検証を行うためのパッケージがある
  • GraphQL Rubyには最初から入っている

クエリの深さだけでなく、幅も悪用される可能性がある

query {
  product1: product(id: "1") { ... }
  product2: product(id: "1") { ... }
  product3: product(id: "1") { ... }
  product4: product(id: "1") { ... }
  product5: product(id: "1") { ... }
  # ...
}
  • Rate Limitingで紹介した複雑さを測るアプローチでかなりカバーできる
  • 最大深度や最大幅の代わりに最大複雑度を設定することは、たとえ低速であっても巨大なクエリーを許さない、ということになるため注意する

ノードの数による制限もある

  • ノードの数とは、あるクエリで要求されるオブジェクト型のインスタンスの数のこと
  • GitHubでは、クエリごとのノード数制限と複雑度制限の両方がある

クエリにどんな制限を設けても、それを回避する方法は存在する(膨大な引数のリストで過負荷を掛けたりとか)
クエリと変数の合計バイト数に制限を設けて、存在すると思ってもみなかった過剰なクエリをブロックすることを強くオススメする

Timeouts

  • 不正なクエリを実行する前にブロックしたり、クライアントのレートを制限したりしても、単純に複雑すぎるクエリを実行する可能性は残る
  • クエリを長時間実行させないために、タイムアウトをリクエスト時に設定する必要がある
  • GraphQLにおけるタイムアウトは、一般的なWeb APIと比較して、よく起こるものとされる
  • タイムアウトがあることで、最終防衛ライン的な安心感が得られる
  • しかし、可能な限り、タイムアウトの前にクエリをブロックできるような最大複雑度やノードの制限の数を見つけることを怠ってはいけない
プラハの社長プラハの社長

timeoutはクエリの複雑さに応じて動的に計算するのかな?それとも全クエリに対して一定の大きめなtimeoutを設定しておく?本で言及されているのは後者かな

プラハの社長プラハの社長

認証

  • 認証をgraphqlサーバーでやるかどうかが問題
  • つまりlogin/logout的なmutationを作るべきか
  • 著者のおすすめは外部で認証を済ませておくこと。currentUserやセッションで認証済みの情報をgraphqlサーバに伝達する
  • 理由はスキーマの変更なく異なる認証システムを使えること、graphql側のスキーマがステートレスになること。graphql側で認証するとフィールドごとに認証する必要が生じかねない。

認可

  • graphqlはドメインロジックへのアクセス方法の一つにすぎないため、認可は基本的にアプリケーションがわでハンドリングして、認可ロジックが重複するokとを避けた方が良い
  • 認可について考えるとき、異なるコンセプトを混ぜてしまうことが多い
    • apiスコープ:ユーザに代わってアクセス可能なfieldやtypeの定義(oauthなど)
      • こっちはgraphql側で実装しても構わない
    • ドメイン:管理者でなければissueをクローズできないとか
      • これはドメインロジックなのでgraphqlには実装しない方が良い
  • どうしてもgraphql側でドメインロジックを実装しなければいけない時に意識すべきこと
    • fieldではなくobjectを認可する
      • オブジェクトはapiスコープと対応関係にしやすい
      • オブジェクトのfieldは大体オブジェクトと同じ認可権限を求めることが多い
      • オブジェクトの取得方法を全て網羅するのは非常に大変。fieldレベルで制限しようとすると、意図しないアクセスパターンが生まれてしまう。
type Query {
  adminThings: AdminOnlyType!
    @authorization(scopes: ["read:admin_only_types"])
}Excerpt From
Production Ready GraphQL
Marc-Andre Giroux
This material may be protected by copyright.

これぐらいシンプルならまだ良い。adminThingsしか到達方法がないため、権限管理しやすい

type Query {
  adminThings: AdminOnlyType!
    @authorization(scopes: ["read:admin_only_types"])
  product: Product!
    @authorization(scopes: ["read:products"])
}

type Product {
  name: String
  settings: AdminOnlyType!
}Excerpt From
Production Ready GraphQL
Marc-Andre Giroux
This material may be protected by copyright.
  • でもこうなると、productを通してadminOnlyTypeが取得できてしまう
  • 極端な例だとグローバルidを使って直接nodeを参照できれば、fieldレベルの認可は素通りされてしまう
  • graphql-rubyはデフォルトでオブジェクト単位の認可を設定できる
  • jsだとgraphql-shieldが有効。

存在漏洩(leaking existence)

  • よくあるapi認可のミスが「リソースは存在するがアクセスできない」と「リソースが存在しない(と言っているが実際は教えない)」の違い
    • 前者の方が適しているケースは滅多にない。リソースが存在するかどうかを攻撃者に伝えたくないのでnullを返す方が良い。この理由からもfieldをnon-nullableにするかどうかは慎重に考えた方が良い
ゲントクゲントク

Blocking Introspection

セキュリティのためにGraphQLサーバーのintrospection機能を削除することについて

  • 著者からすれば、introspectionはGraphQLを素晴らしいものにしている要素の1つなので、それを取り除くのは奇妙なことのように思える
  • しかし、なぜGraphQLを使うか、という文脈にもよるので、ケースバイケース
    • 隠したい内部APIは、ホワイトリストによってクエリを管理するのが便利
    • introspectionはエンドユーザーではなくエンジニアや開発者のためのツールなので、開発時には有効にしておいたほうがいいが、内部APIのために運用時も有効にしておく必要はない
    • パブリックなGraphQL APIにおいては、スキーマは公開したいものであるので、introspectionを公開することは本質的には危険なことではない
  • Security by obscurity
  • 隠したい方やフィールドは、スキーマの可視性(2章で紹介済み)を利用して隠すのがオススメ
  • ホワイトリストの管理もスキーマの可視性も扱えないような場合は、introspectionを制限することでセキュリティ対策とすることができる
  • しかし、introspectionを制限するということは、クライアントやツールにとって非常に重要な機能を制限してしまうことである

Persisted Queries

永続化クエリは、GraphQLの強みを活かしつつ、その悩みのタネを最小限に抑えた非常に強力なコンセプト

典型的なGraphQLクエリのフロー

  • クライアントがサーバーにクエリを送信して、サーバーはクエリをレキシング→バース→検証→実行→結果をクライアントに返す
  • 実運用において、クライアントが毎回まったく同じクエリ文字列を送信していたとすると、毎回サーバーにクエリ文字列を処理させるのはムダなこと

永続化クエリは、この問題を解決する

クライアントが特定のクエリの識別子を取得すると、クエリを実行するために必要な変数とともにその識別子を送信することができる
たとえば、あるクエリの登録後にサーバーがURLを返した場合、クライアントはクエリを毎回送信する代わりに、このURLを使用することができる

  • クライアントが完全なクエリを送信することがなくなり、帯域幅を大幅に節約することができる
  • サーバーがクエリを事前に解析→検証→分析することによって、クエリを最適化することができる
  • 大規模なクエリほど、永続化クエリによるパフォーマンス改善やコスト削減は効いてくる
  • 永続化クエリのしくみで、事前に登録してあるクエリのみを実行できるようにすることは、クエリをホワイトリストで管理することになるので、セキュリティ的にもよい

永続化クエリは、当初我々が脱出したかった「エンドポイントベース」「固定クエリ」に回帰しているように見える
しかし、静的なクエリやリソースを扱っていても、これらのリソースはサーバーではなくクライアントによって生成される
ここで言われてるresourcesって何のことかよくわからなかった

多くのサーバーサードライブラリには、クエリをキャッシュしたり永続化する機能がある

永続化クエリは、すべての内部APIに必要なものであり、いずれは公開APIにも有用になるのではないかと考えている

Summary

  • GraphQLのレート制限は、一般的なエンドポイントベースのAPIよりも多くの考慮が必要
  • 複雑度や時間ベースでレート制限をするアプローチは、クライアントからのリクエストを制限するための最良の選択
  • タイムアウトは、クエリがサーバーの時間を長時間浪費するのを避けるために必要
  • クエリの深さはそれほど重要でなく、複雑度とノード数で十分な場合が多い
  • オブジェクト単位の認証は、フィールドの認証よりも単純で、エラーが発生しにくい(ことが多い)
  • introspectionを無効にすることは、プライベートなAPIでは良いアイデアだが、パブリックなAPIでは避けるべき
  • 永続化クエリは、(内部APIではとくに)非常に強力な概念である
プラハの社長プラハの社長

概要

graphqlのパフォーマンス管理は少々トリッキー:

  • 多種多様なリクエストが飛んでくるため特定のクエリを最適化しづらい(したとしても他のクエリに悪影響を及ぼすことがある、ということかな?)
  • リゾルバパターンに起因するフィールドのキャッシュ・事前計算のしづらさ
  • graphqlエクステンションがブラックボックス化することがある

モニタリング

  • 普通のapiならエンドポイントごとにレスポンスタイムをモニタリングするかもしれないが、graphqlはエンドポイントが一つなのでモニタリングしてもさほど意味がない
  • 大切なのはこれまで問題なかったクエリのパフォーマンスが劣化した時に検知できること
  • だからクエリごとにモニタリングする
    • これは少数のクライアント・少数のクエリだけを相手にするプライベートAPIなら簡単

モニタリングの大事なポイント

  • 識別子
    • 「誰がリクエストを投げているのか」判断できる情報をクライアントに投げてもらう
      • パブリックapiなら認証トークンがあるから考えることは少ない
      • プライベートapiの場合、識別方法を持たないまま運用していることが多い。識別子を投げた方が良い(どうしてだろう
      • graphql-client-nameとgraphql-client-versionなどをヘッダにつけて送ってもらう
    • 永続化クエリの場合は、そのクエリの識別子をつけて送ってもらう
      • そうすれば永続化クエリごとのパフォーマンス劣化を検知できる

フィールド単位のモニタリングとトレース

モニタリングは処理のフェーズ毎に行う

  • graphqlクエリは以下の3フェーズに分かれる
    • パース・字句解析(なんで順番逆に書いてるんだろ)
    • バリデーション・静的解析
    • 実行
  • これら3つのフェーズごとにモニタリングするのを推奨する。パースやバリデーションがスロークエリの原因になっているケースを見かけたことが多々ある

フィールド単位でモニタリングする

  • クエリ全体に加えてフィールド毎のパフォーマンスも測定する。より細かく原因を特定できる
  • ミドルウェアやリゾルバの拡張機能を使うことで測定できる。graphql-rubyならprometheusとか簡単に連携できる。apollo server+apollo engineなら簡単にログできる
  • graphqlはトレース対象としても最適。クエリの実行を「fine-grained trace」としてみれば、クエリ全体のレスポンスタイムに関する多くの情報が得られる。opentracingなどのトレース機能に対するフックを多くのgraphql実装が備えている
ゲントクゲントク

GraphQL Response Extensions

クエリのレスポンスにパフォーマンスの情報などをくっつけると、スロークエリのデバッグなどに役立つ

  • GraphQLの仕様では、extensionsキーに、追加情報を含めることができる
  • 追加情報は、トレース情報のようなメタデータにすると非常に便利
  • Apollo Tracingは、トレース情報のフォーマットを定義している
  • 各リゾルバーを呼び出すタイミングだけでなく、各リゾルバーが行う外部呼び出しのタイミングをエンコード(?)することが重要
  • パフォーマンスの問題の大部分は、キャッシュ、データベース、外部サービスなどを呼び出すリゾルバーに起因している事が多い

すべてのクライアントがこれらの追加情報を利用するということを想定していない限り、すべてのレスポンスに対してこのトレースを返したいとは思わないはず

  • 少なくとも追加情報は圧縮されていることを確認する
  • もし公開APIであるのなら、セキュリティのために、あまり詳細なトレースは公開しないほうがよい

Slow Query Log

  • クエリが遅すぎると判断する閾値を設定し、その閾値を超えるクエリをログに記録する
  • 既知のクエリのセットを扱うとき、または、クライアントとクエリの公開されたセットまたは十分な大きさのセットを扱う時にもっとも有用
    • どういう意味?
    • スロークエリログが役立つとき
      • 送られてくるクエリがわかっているとき
      • 公開APIなどで、さまざまなクエリが送られてくる場合でも、それらを統計的に扱うことができる場合
  • 遅いクエリについて、そのクエリに問題があるのか、単に大きいクエリだから問題ないのか、判断するのが難しい
  • 問題の早期発見のためのツールとして使うとよい

Tracking Queries over Time

  • パフォーマンスのリグレッションを確認する
  • 過去1時間/1日/1週間の間に遅くなったクエリがないかをトラックする
  • 余裕があるのであれば、時系列データベースやデータウェアハウスを使って、すべてのクエリを追跡するとよい
  • クエリ文字列は、空白や引数、フィールドの順番によって大きく変化するので、クエリのハッシュや署名を計算したほうがいいこともある
  • 引数で「250個取得する」ことを指定しているクエリと「1個取得する」ことを指定しているクエリとでは、パフォーマンスがぜんぜん違うので、分けて管理すると吉
プラハの社長プラハの社長
  • GraphQLはリゾルバパターンを使うことが多い、って書いてあるけど、むしろそれ以外のパターンが気になる

データローディング

  • リゾルバパターンがよく直面する問題はデータローディング
  • 問題の根本はリソルバが自分の小さな世界で生きていること。他のリゾルバと並列に実行されることがあること。つまり特定のデータを必要とするリゾルバはそのデータが既にロードされているかどうか、後からロードされるのかどうか、知る術がない

  • 例えばユーザの情報と、ユーザの友達3人に関する情報と、友達の1番の友達を3人ずつ取得するクエリを書いたとする。
  • エンドポイントベースのAPIならまず今のuserをロードして、friend3名をjoinテーブルからロードして、3名のfriendのidをINでロードして、best_friend_idを取得したらもう一度INでロードする。合計4つのクエリで事足りる
  • これはリゾルバベースでは難しい。friends(first: 3)のリゾルバは、best friendを事前にロードしておかなければいけないことを知らない。ナイーブに実行してしまうと最後のbest friendを取得するクエリが3つになってしまい、合計6つのクエリが必要になる。best friendの数が50に増えたら53のクエリが必要になる

この問題にどう対処するか

  • 事前にデータを読み込んで置けないか?
    • friendsリゾルバが先を見通してbestFriendをプリロードしておけば、bestFriendリゾルバはプリロードされたデータを参照するだけで事足りる
    • ただし、これはあまり評判の良い解決策ではない。GraphQLクライアントは任意のデータを要求できるので、プリロードしようとすると全パターンに対応できるリゾルバを作らなければいけない
  • データローダーを使う
    • DataLoaderと呼ばれるJS製のライブラリが作られたことが発端で、こう呼ばれるようになった

Lazy Loading(DataLoaderに関する詳細説明)

  • 先ほどとは逆のアプローチを取る。データをプリロードするのではなく、データロード自体をlazyに行うのがコンセプト

大事なコンセプト

  • 1.値を返すのではなくpromise的なものを返す
    • 全てのリゾルバは非同期に実行されるようになる
    • 普通のクエリは深さ優先探索で実施されるが、今回は違う
    • リゾルバがデータを必要とするときはすぐに取得するのではなく、実行エンジンにいつかは必要になることを伝えて、同レベルの他リゾルバに先にクエリの実行をしてもらう
  • 2.ローダー
    • 概念はシンプルだが実装は難しい
    • リゾルバがデータを必要としたら、まずloaderを経由する。すぐにデータストアに行くのではなく。
    • loaderの 役割は個別のリゾルバが必要とするデータをロードして集めておくこと
    • 典型的な実装は2つのメソッドを持つ
      • load
        • loading keyを引数に取る。promiseを返す。いずれは呼び手が必要としていたデータがここに含まれる
      • perform
        • 蓄積されたloading keyを使って最も効率的な方法でデータを全てロードする。このメソッドは実装者が自前で実装する必要があることが多い。実際の使用例を下に示す
    • 最初に理解しづらいのはperformが実行されるタイミング。実装によってpromiseがresolveするタイミングは異なる
      • 例えばnode.jsは非同期プリミティブを持つためdataLoaderはprocess.nextTickを使ってkeyをバッチロードする。node.jsのキューシステムを使って全てのpromiseがキューされるのを待ってからバッチ関数(perform)を実行している。気になる場合はこの動画が参考になる
      • rubyは違う仕組みを使っている。lazy executorというものがある。まず全てのfield resolverを実行して、promiseをresolveしないと先にいけないところまで進む。そのタイミングでバッチ関数(perform)を実行する。自前でdataloaderを実装するのであればこの辺を見ておくと役立つ
// Create a loader that can fetch multiple users
// in a single batch
const userLoader = new DataLoader(ids => getUsers(ids));

const UserType = new GraphQLObjectType({
 name: 'User',
 fields: () => ({
  name: { type: GraphQLString },
  bestFriend: {
   type: UserType,
   // The bestFriend resolver now returns a Promise
   // instead of loading the user right away.
   resolve: user => userLoader.load(user.bestFriendID)
  },
 })Excerpt From
Production Ready GraphQL
Marc-Andre Giroux
This material may be protected by copyright.
ゲントクゲントク

Lazy Loading Drawbacks

レイジーローディングはGraphQLサーバーのパフォーマンスに関して非常に重要な要素だが、いくつか注意すべき点がある

  • データロードのタイミングが複雑になるため
    • 監視が少し難しくなる
    • パフォーマンスの問題がデバッグしにくくなる
  • 個々のフィールドの性能が嘘になる可能性がある
    • 例として、何千人者ユーザーを単純にロードするためにエンキューするフィールド
    • このフィールドのパフォーマンスを監視してみると、非常に高速に解決しているように見える
    • 実際は、このフィールドはすべての作業をローダーに任せている
    • ローダーを別途監視する必要がある
  • すべてが非同期になる
    • 非同期処理のサポートが不十分な言語で実装するのは、かなりつらい
    • JavaScriptを使っていたり、実装者が非同期での作業に慣れているのであれば問題ない

Caching

GraphQLの良し悪しを議論する文脈で「GraphQLはキャッシュを壊す」とか「GraphQLはキャッシュできない」という意見がよく交わされる

より具体的な議論を交わすために、キャッシングとGraphQLについて解像度を高め、キャッシュに関するGraphQLの限界をよく理解する

GraphQL breaks server-side caching?

まず、「キャッシング」とは何を指すか?クライアントサイドか、サーバーサイドか、HTTPキャッシングか、アプリケーションサイドキャッシングか

  • GraphQLは既存のサーバーの上に薄いレイヤーを作っているだけで、サーバーサイドでのキャッシュを妨げるわけではない
  • ほとんどのGraphQLクライアントやフレームワークは非正規化キャッシュを備えている
    • クライアントサイドアプリケーションがすでに所有しているデータの再取得を回避している

サーバーサイドとクライアントサイドの両方のレイヤーでキャッシュができるのであれば、なぜGraphQLが「キャッシュを壊す」とか「キャッシュが難しい」とか言われるのだろうか?

HTTP Caching

たとえばRESTはHTTPセマンティクスを大いに利用する設計になっているが、GraphQLは(すくなくともデフォルトでは)そうなっていない

GraphQLはHTTPを、その可能性を最大限に利用するのではなく「dumb pipe」として利用するため、それが問題の原因になることがある

  • ブラウザのキャッシュのようなクライアントサイドのキャッシュは、まだ新しいデータの再取得を避けるためにHTTPキャッシングを利用する
  • ゲートウェイキャッシュは通常、サーバーと一緒に配備され、キャッシュレベルで情報がまだ最新であれば常にサーバーサイドにヒットするリクエストを避けるために使用される
    • CDNとかのことかな?

HTTPキャッシュに関してとくに重要なのは、鮮度と検証の2つのコンセプト

  • 鮮度
    • Cache-ControlとExpiresヘッダーを通して、サーバーがリソースの鮮度を判断する時間を伝達できるようにする
    • サーバーがCache-Control: max-age=3600ヘッダーを返すことで、少なくとも1時間経過するまではリソースを再度取得しないようにクライアントに指示できる
  • 検証
    • データがまだ新しいかどうかわからないときに、クライアントがデータを再取得することを避けるための方法
    • サーバー上のHTTPキャッシュにLast-Modifiedの値がある場合、クライアントはIf-Modified-Sinceを送信して、前回取得したときからデータが変化していなければ、データの取得を回避する
    • ETagを利用することで、クライアントは自分が持っているデータの「バージョン」を追跡することができ、不要な再取得を回避できる

ここまでみると、GraphQLでHTTPキャッシュの力を利用しない手はない、ように見える

プラハの社長プラハの社長

graphqlとhttpキャッシング

  • httpキャッシュ側に起因する問題も多いので、必ずしもgraphqlだから問題が起きるわけではない

httpキャッシングの問題

  • graphqlではpostしか送れないと思われがち。httpキャッシュはpostをキャッシュしないので、キャッシュ負荷だと思われがち。でもgraphqlはgetも受け取れるので、getを使えば解決する
    • ただしgetはクエリサイズに制限があるので注意が必要。
    • その場合はpersisted queryを使うと良い。persisted queryは典型的なapiエンドポイントとほぼ同じなのでキャッシュできる。
      • persisted queryの注意点1:fieldレベルのfreshnessは定義できない。クエリ単位なら定義できるけど。
      • persisted queryの注意点2:validationも同様にfieldレベルは無理。
  • graphqlクエリは複数のエンティティをまたぐことが多いのでfreshnessは短くなりがち
  • ただ、これはcustomizationとoptimizationのトレードオフ。全ての可変的なapiが直面する問題と同種
GET /user/1?partial=complete
GET /user/1?partial=compact
GET /user/1?fields=name,friends

Excerpt From
Production Ready GraphQL
Marc-Andre Giroux
This material may be protected by copyright.
Excerpt From
Production Ready GraphQL
Marc-Andre Giroux
This material may be protected by copyright.
  • こういうエンドポイントはキャッシュしづらい。クエリを少しでも変えるとキャッシュが無効化されてしまう(例えばfields=name,friendsの次にfields=nameを要求してもキャッシュが使われない)。カスタマイズ性を重視したエンドポイントが陥る問題とgraphqlが陥る問題は同じ。graphqlを採用するとカスタマイズ性が非常に高いエンドポイントに自動的になる(つまりoptimizationはある程度捨てる)ことに注意する必要がある

実際httpキャッシュがどれぐらい大事なのか?

  • 認証が必要なエンドポイントにhttpキャッシュがどれぐらい役立つのか、というテーマは永遠の議論が交わされている

public/private

  • パブリック(共有)キャッシュはauthorizationヘッダがついたリクエストを対象にするべきではない
  • プライベートキャッシュ(ブラウザキャッシュとかクライアントサイドキャッシュとか)は役立つケースが多いが、raphqlの文脈ではあまり役立たない(クエリがキャッシュを無効化する頻度と、キャッシュヒット率が低すぎるため)

どれだけ長くキャッシュが使えるのか

  • 大体のサービスではstaleデータを提供できる時間は非常に短い(変更されたらすぐ反映しなければいけない)ので、freshnessヘッダーが役立つケースは非常に少ない
  • etagみたいなvalidatorはサーバ側で必要データをかき集めて計算する必要がある。得られる利点はserializationの省略と帯域の節約。もし帯域とかserializationが問題ならetagを計算しても良い。ただしgraphql自体が認証されたエンドポイント・頻繁に更新されるデータに適したトレードオフを選択していることは注意が必要
  • 更新頻度が低いパブリックなAPIを設計するのであればgraphqlは最適な選択肢では無いかもしれない

他のキャッシュ方法を視野に入れる

  • http上のgraphqlに関する仕様が定まっていないことがキャッシュの扱いづらさを産んでいる。mutationであってもgetで実行できることとか。
  • ただgraphqlをキャッシュする方法は他にもたくさんあるので、それらについて今後の章では触れていく
プラハの社長プラハの社長

data layer caching

  • n+1問題に触れた時に登場したバッチローダーを覚えているか?(dataloaderじゃなくて?
  • dataLoaderはパフォーマンス向上のみならずキャッシュ機構も備えている
  • 他のレイヤーでキャッシュを試みるより先にデータキャッシュを使う方が良い。より簡単で、より狙ったデータソースのキャッシュを行える

resolver caching

  • リゾルバの結果はcontextにより変わり得るため注意が必要
  • やるならリゾルバ内のロジックをキャッシュする方法を考えることを推奨する(サーバからredis呼び出して〜みたいなイメージかな

http caching

キャッシュのまとめ

  • graphqlは普通のapiエンドポイントよりはキャッシュしづらい(クライアント側の拡張性が高いため)。ただしpersisted queryを使うことで一部効率化は図れる

compiled queries

  • とてもエキサイティン!!な新概念
  • persisted queryは他のクエリと同じように最終的にクエリを処理するため(validationとanalysisを省略するぐらい)graphqlの実行エンジンにかかるオーバーヘッドを全て省略できているわけではない
  • 最適化を事前に終わらせておけば良いのではないか、というのがcompiled queries(SSGみたいなイメージかな

まとめ

  • エンドポイント型のapiよりgraphqlの方が最適化しづらい。キャッシュはできるけど、さほど効率的ではない
  • モニタリングも個別フィールドやクエリ(persisted queryとか)を見ないと意味がない
  • n+1問題はdataLoaderを使ったlazyLoadで解決できる
  • compiled queriesは効率化をさらに一歩進められる可能性がある
ゲントクゲントク

Caching in Practice

特定性の高い、パブリックなエンドポイントベースのAPIに比べると効果は低いかもしれないが、それでもGraphQLでのキャッシュには多くの価値があり、さまざまなレベルで実現されている

Full Query Caching

GraphQLのキャッシュのほとんどはアプリケーションレベルで効果的に行われる
もっとも一般的な問題は、GraphQLのクエリは一度に複数のエンティティにまたがることができるため、クエリが、キャッシュされるべきスキーマの部分とキャッシュされるべきでない部分を同時に使用することがある、ということ

Shopifyではこの問題を解決するために、キャッシュとGraphQLをグローバルレベルで解決しようとするのではなく、キャッシュできるクエリーのみキャッシュするというアプローチを取った
キャッシュ可能なtypeを定義するときに、アノテーションを付ける

type Product @cacheable {
  name: String
}

クエリが実行されると、サーバーはすべてのフィールドを調べ、すべてのtypeがキャッシュ可能であることを確認する
すべてのtypeがキャッシュ可能であると確認できたら、クエリとユーザーのコンテキストに基づいて生成されたキーと紐付けてキャッシュする

Cache Keys

GraphQLのクエリは動的な性質を持っているため、クエリーの中に空白があるだけでも生成されるキーに影響を与え、そもそも同じクエリーであるにもかかわらずキャッシュミスする可能性がある

よいキャッシュキーは、一般的に少なくとも以下のものを含んでいる必要がある

  • ユーザー情報(認証済みAPIの場合)
  • クエリハッシュ
    • できるだけ正規化されている必要がある
      • 空白とかコメントとかばらつきを排除するという意味
      • フィールドの順番は正規化しないほうがいい(仕様が順序について述べている)
  • 変数のハッシュ
    • 異なる変数を持つクエリが同じものとしてキャッシュされることは避けたい
  • 操作名
  • キャッシュを破壊する要素
ゲントクゲントク

Tooling

著者は、ここ数年のGraphQLの成功の多くはツールのおかげだと考えている

著者のお気に入りのツールを紹介する

Linting

ESLintは、GraphQLの型システムとintrospectionを分析して、GraphQLの開発体験をよくする

スキーマや開発者がスケールしてくると、コードレビューだけでは間に合わなくなってくる
リンターを導入して開発者のローカルや自動テスト、CIパイプライン上で自動実行させることができる

かつて著者がGitHubで構築したリンターツールGraphQL Doctorは、GitHubでプルリクエストボットとして使える
graphql-schema-linterもgood

Change Management

GraphQL Doctorは、GraphQLスキーマの変更が破壊的変更かどうかを検知することができる

スキーマ間の差分リストを取得するのは、以下のツールが便利

スキーマの変更を、破壊的変更と非破壊的変更とに分類するのは非常に重要だが、手作業で監視することはかなり難しい

プラハの社長プラハの社長

analytics

  • graphqlにはselect *的なクエリがない(全てのfieldを取得する)
  • 不便に思われるかもしれないが、これによりアナリティクス上のメリットが得られる
  • 従来型のapiの場合、ユーザーが特定のリソースを使用したいことはわかるが、そのリソースのどのフィールドが使われているのかは分からなかった。仮にリソースのaddressプロパティを削除したら、その影響がどこまで波及するか分からないので、userエンドポイントを使っている全てのクライアントは影響を受けるものとみなす必要がある 。graphqlならそうならない。クライアントが多いpublic apiの場合は特に助かる

クエリアナライザの使い方

  • クエリの分析は負荷がかかるのでgraphql api本体ではなく別のアナライザに任せる方が良い
  • その際は役立つ情報は全部突っ込んでおいた方が良い
    • クエリを実行したアクターに関する情報。アクセストークンとか、アプリケーション識別子とか
    • 発生したエラー。存在しないフィールドへのアクセスとか、パースエラーとか
    • リゾルバの実行時間、クエリ全体の実行時間など、これまでパフォーマンスの章で会話してきた全ての情報
    • クエリを実行したスキーマのバージョン。SDL全体を送るには大きすぎるのでスキーマのハッシュを計算して送るのが有効かもしれない
  • まずは最短でクエリを実行して結果を返却した後にアナライザに送るのが大事そう

クエリアナライザの動き

  • アナライザがクエリを受け取ると、スキーマをfetchする必要がある。

スキーマの共有方法

  • gitでsdlを管理することが多い。ついでにgit shaも加えておく(変更検知のためかな
  • スキーマと照らし合わせてクエリを解析する(deprecateなfieldにアクセスしていないか、feature flagの対象になっているfieldにアクセスしていないか)
    • 古いスキーマverとかfeature flagをそろそろ消して完全に移行しても良いか〜みたいな判断に役立ちそう
  • 解析したクエリは後で検索可能な状態にしておく

クエリの検索可能な状態とは

  • クエリを非正規化する
    • フィールドのリスト(親タイプを含む)
    • 引数のリスト(親fieldとタイプを含む)
    • 使用されたfragment spread(ユーザーがinterfaceやunionに対して実際どんな型を使ったのか知るのに役立つ)
    • 使用したenum
  • 上記とは別にクエリを実行したユーザーに関する情報も保存しておくと役立つ
    • user.nameフィールドを最も使っているユーザー上位10
    • よく一緒にクエリされるfield
    • 特定のクライアントにとって最も遅いfieldの特定
    • optional削除が安全かどうかの判断(これユーザー情報を持っている事と関係なくね?

センシティブな情報は隠しておこう

  • httpリクエストにはパラメータのブラックリストが豊富に用意されているが、graphqlの場合は自分でクエリの中を見なければいけない
mutation {
  createUser(
    name: "REDACTED",
    age: null,
    profession: "REDACTED"
  ) {
    name
    age
  }
}Excerpt From
Production Ready GraphQL
Marc-Andre Giroux
This material may be protected by copyright.
  • こんな感じでマスキングする。マスキングならスキーマに対して有効なクエリのまま残るので

analyticsまとめ

まとめ

  • graphqlのスキーマによりさまざまなツールを使える。lintでベスプラや一貫性を担保しよう。breaking change detectorでapiを安定稼働させよう。集められるだけ多くの情報を集めておこう。クエリを理解できることは本当に役立つ
プラハの社長プラハの社長

publish

世に出す前に実施しておくと安全な3つのテクニック

mock server

  • graphqlのインターフェースは合っているが中身は異なる状態で出してみる。graphql-toolsとかオススメ。graphql-fakerもいいよ。スキーマからテストデータを生成するサーバを簡単に立ち上げられる
  • スキーマを成果物としてどこかに管理しておけば、CIで取得して、モックサーバを作るところを組み込むのも良い

feature flag

  • 一部のクライアントにのみ公開する

api preview

  • 特殊なヘッダーを使って、まだ全体リリースされていない機能を部分的に使ってもらう
  • feature flagとはちがって、過剰に使われてしまうことに注意が必要。previewなのに容易に変更できなくなることもある
  • 内部的なapiならfeature flagの方が扱いやすい(直接クライアントと対話できる条件ならff、話せなければapi preview)

analyze

  • リリースしたら、ちゃんと使われていることを確認しような
  • 利用者がクエリをこねくり回しているところを検知したら新しいユースケースを考えるチャンスかもしれない
    • graphqlのクエリをみることで新しいユースケースを発見できる、というのは今までにない観点だったな

ship

  • リリース!(励ましてる感じだったので、特に新しい情報はなかった
ゲントクゲントク

Workflow

チームが成長しても、APIの品質を保つのは難しい
ワークフローを考えないと失敗する

Design

あまりにも先行した設計は理想的でないこともあるが、著者の経験上、ほとんどのチームはAPI設計が甘い

  • GitHubのissueなどのコラボレーションドキュメントは、初期設計を投稿して議論するための素晴らしい方法
    • SDLを用いて議論する
  • プロジェクトマネージャー、デザイナー、ドキュメンテーションの専門家をプロセスのできるだけ早い段階で参加させる

Review

レビュアーは、自動化されたツールではうまくできない、核となる設計について考えるべき

だれがレビューをするべきか?
「GraphQLのエキスパート」だけではしだいに数が足りなくなる
「レビューチーム」を用意したところで、最初はうまくいくがスケールしない
レビューにとられる時間が多くなりすぎてどうにもならなくなってきたら、それは、レビュー時間を削減するためのスキーマ/API品質ツールに投資する時期が来たというサイン
なるべくツールに頼ろうという話?"Our team"とか"they"とかが誰のことなのかわからない

Development

開発フェーズは、設計に関する合意が取れた時点で開始するのが理想的
可能であれば、開発フェーズに入るまでは実装のことは考えないようにし、実装の詳細が設計に影響を与えることを避ける

この本で紹介してきたアドバイスに沿って実装すれば、実装フェーズでは何も問題は起きないはず

プラハの社長プラハの社長

schema stitching

  • サービスごとに保持しているスキーマをgatewayで合体させることを「schema stitching」と呼ぶ
  • 例えばUserサービスとProductサービスがそれぞれUserとProductと呼ばれるtypeを保持していたとする
  • schema stitchingはこんな具合に両者を合体させる
type Query { // <- 元々はこのQuery2つ存在した(各サービスに1つずつ)が、mergeして一つのqueryfieldになっている
  user(id: ID!): User
  product(id: ID!): Product
}

type User {
  name: String!
  age: Int!
}

type Product {
  name: String!
  price: Int!
}Excerpt From
Production Ready GraphQL
Marc-Andre Giroux
This material may be protected by copyright.

ただしこの方法はすぐに破綻する

type Query {
  user(id: ID!): User
}

type User {
  name: String!
  age: Int!
  productsForSale: [Product]!
}Excerpt From
Production Ready GraphQL
Marc-Andre Giroux
This material may be protected by copyright.
  • サービスを横断したクエリが存在する場合に問題が生じる。user serviceのschemaにはproduct typeが必要だが、product typeはproduct serviceにしか存在しない。両者のスキーマをくっつける必要がある
const linkTypeDefs = `
  extend type User {
    productsForSale: [Product]!
  }

  extend type Product {
    owner: User!
  }
`;

mergeSchemas({
  schemas: [
    productSchema,
    userSchema,
    linkTypeDefs,
  ],
});”

Excerpt From
Production Ready GraphQL
Marc-Andre Giroux
This material may be protected by copyright.
  • さらにこれらのクエリがどうresolveされるか自前で定義する必要がある。こんな長い定義が必要になる
const mergedSchema = mergeSchemas({
  schemas: [
    userSchema,
    productSchema,
    linkTypeDefs,
  ],
  resolvers: {
    User: {
      productsForSale: {
        fragment: `... on User { id }`,
        resolve(user, args, context, info) {
          return info.mergeInfo.delegateToSchema({
            schema: productSchema,
            operation: 'query',
            fieldName: 'productsByUserID',
            args: {
              ownerId: user.id,
            },
            context,
            info,
          });
        },
      },
    },
    Product: {
      owner: {
        fragment: `... on Product { ownerId }`,
        resolve(product, args, context, info) {
          return info.mergeInfo.delegateToSchema({
            schema: authorSchema,
            operation: 'query',
            fieldName: 'user',
            args: {
              id: product.ownerId,
            },
            context,
            info,
          });
        },
      },
    },
  },
});
”

Excerpt From
Production Ready GraphQL
Marc-Andre Giroux
This material may be protected by copyright.

この方法にはいくつかの問題が生じるため、apolloではschema stitchingをdeprecatedとして扱っている。apollo federationの使用が推奨されている

  • サービスを横断するリクエストは頻繁に生じるが、その度にサービス同士の関係性、resolveする詳細についてgatewayに記述しなければいけないため、ロジックがgatewayに集中しやすくなってしまう
  • gatewayが非常に脆くなる(命名の衝突、サービスのスキーマ変更など)
  • こんなシンプルなユースケースですらなかなかにクエリが凶悪な形になっていたから悪手だろうと思っていたら案の定deprecatedされていた

参考になりそうな外部資料

  • schema stitchingが導入された頃の記事があった
  • *schema stitchingからapollo federationに移行したexpediaの記事
    • expediaが直面していた課題
      • 複数のサービスにアクセスするためにgatewayのコードが大量に必要だった。gatewayが「どのサービスにどのtypeが存在するか」を常に知っておく必要がある
      • スキーマを変更したらgatewayの変更とデプロイがついてくる
      • gatewayへの変更のコンフリクト
      • 「真のスキーマ」がgatewayを実行しないとわからない状態に(各サービスのスキーマを見ただけでは完成形(どことどこがlinkされているか)が判断できないからかな?
    • hasuraもstitchingに関する記事を出していた
ゲントクゲントク

GraphQL in a Distributed Architecture

GraphQLにまつわるさまざまな人気のある分散アーキテクチャと戦略について
GraphQLはモノリシックなAPIレイヤーを起源としながらも、サービス指向アーキテクチャのような多くの異なるコンテキストで使用されてきた

GraphQL API Gateway

クライアントが興味を持ち消費するユースケースに対してプロバイダー(それらを実現するために必要な基礎的なサービス)(複数あるかもしれない)があるとする
APIゲートウェイは、プロバイダーの実装の詳細をクライアントから見えないようにすることができる

APIゲートウェイがあることによりできるようになること

  • APIゲートウェイは、プロバイダーの安定したファサードを維持しつつ、時間の経過とともにこれらの詳細を変更することを可能にする
  • レート制限や認証のようなロジックをAPIゲートウェイに集約することができる
    • 複数のAPIサーバーに同様の処理を実装することを避けることができる
  • より複雑なAPIゲートウェイでは、個々のサービスに対する複数の呼び出しを1つのリクエストに集約することもできる

APIゲートウェイを採用する際に気をつけること

APIゲートウェイは単一障害点になりやすい

ある処理が、ゲートウェイレベルでやるべきことなのか、個々のサービスでやるべきことなのか、よく考える

典型的なHTTPリソースを扱うときほど単純ではない

エンドポイントベースのAPIを扱うAPIゲートウェイは、リクエストを基盤サービスにマッピングする単純なプロキシのように動作する

上の例では、APIゲートウェイは適切なサービスを呼び出すことで/usersエンドポイントを解決し、/productsエンドポイントは別のサービスを呼び出すことで解決している

概念的にはAPIゲートウェイは単なるプロキシであってはならない(それはゲートウェイが基礎になる実装と強く結合してしまうことを意味するため)
APIゲートウェイは、「dumb proxy」ではなく「door」を目指すべきである
わからない

GraphQLでは、クエリが大きく複雑になると、複数のサービスにまたがってクエリを発行することになりがち
これは、GraphQLゲートウェイでは複雑なプランをオーケストレーションしなければならない可能性が高いことを示している

この問題は、GraphQL特有の問題ではない(GraphQLでよりはっきりわかるというだけ)

エンドポイントベースのAPIでも、ゲートウェイが異なるリクエストを一緒に合成したり、表現の変換を行ったりすることがあり、それはよく問題を起こす

次の数セクションでは、これらの課題にどのように対処すればよいのか、GraphQLゲートウェイを構築する際にもっとも一般的な解決策を探る

The "SImple" Way

シンプルな「プロキシ」による解決は、そのシンプルさゆえに魅力的

GraphQLのクエリは、複数のサービスにまたがる傾向があるが、それは必ずしも難しいルールではなく、スキーマの設計方法によってクエリの実行をシンプルにすることが可能

Airbnbの事例

Airbnbはユースケースに対して既存のThriftスキーマを持っており、それは異なるプレゼンテーションサービスにまたがっていた

AirbnbはThriftインターフェイスをGraphQLスキーマに変更し、ゲートウェイとして実行できるようにした

query LuxuryHomeQuery {
  luxuryHome {
    listings: luxuryListingsById(listingId: 123) {
      id bathrooms bedrooms
    }
    reviews: reviewsByListingId(listingId: 123) { // ...  }
    quote: luxuryListingQuote(listingId: 123) { // ...  }
  }
}

このクエリでは、1つのlistingIdに関連することなる3つの概念、listings, reviews, quoteを取得しているが、listeningId.123を提供しなければならない(123を毎回指定する必要がある)

一般的なGraphQLサーバーがこのユースケースをどのように設計するか、比較してみる

query LuxuryHomeQuery {
  luxuryListings(id: 123) {
    id
    bathrooms
    bedrooms
    reviews { // ... }
    quote { // ...  }
  }
}

GraphQLは、特定のノードからリレーションシップをフェッチすることを可能にする点で優れているが、なぜAirbnbは典型的なRPCに似た方法でスキーマを設計することを選択したのか

最初のケースでは、ゲートウェイの実行計画は2番めのケースよりもずっとシンプルであることがわかる
2番めのアプローチでは、クエリの一部を基礎となるサービス勘でかんたんに分離することができ、実行は単純なプロキシのアプローチと非常に似ていると想像できる

もしスキーマをより「純粋なGraphQL」的に設計した場合、クエリに対してより複雑な実行計画を処理する必要がある

listingを取得するサービスは、reviewとquoteを取得するためにクエリの残りの部分を理解する必要がある
もしくは、リスティングのフィールドを解決してゲートウェイに返し、ゲートウェイは不足している情報をクエリする必要がある

どちらの場合も、処理することが多くなる

このアプローチの欠点は、宣言的なGraphQLクエリ言語の美しさが失われ、より「バッチリクエスト」のように見えるものが戻ってきてしまうこと

このアプローチの利点は、APIゲートウェイがシンプルでありながら、下流のサービスと連携しすぎることがないこと
ゲートウェイはスキーマの異なる部分を単純に「配線」するだけ

クエリを分けることで実行計画が単純になったっていう理解でOK?

ゲントクゲントク

Apollo's Schema Federation

Schema Federationは、Apolloがリリースした、スキーマステッチングを置き換えることを目的とした製品

個々のサービスに自身のスキーマとほかを拡張する方法を定義させることで、スキーマの完全な分散化を目指している
これにより、ゲートウェイにリンクやビジネスロジックを書かなくてもよくなる

これを実現するために、フェデレーションを行うゲートウェイが"query plan"を構築してサービス間のクエリを解決できるように、型に注釈をつけられる仕様になっている

よくわからない、要調査

Schema Federationを使う上で注意すること

  • query planは制御やチューニングが難しいことで悪名高い
    • 監視やデバッグが難しくなる
    • 実装やツールを工夫してなんとかするしかない
  • フェデレーションを機能させるためには、すべてのツールがフェデレーション仕様を実装する必要がある
  • 基盤となるサービスが、フェデレートされたグラフに適した形の分割になっていない可能性がある
    • ユースケースは複数のサービスにまたがることがあるので、サービスの分割方法ではなく、ユースケースとサブドメインでグラフを分割することを目指すべき
      • 100のサービスがあったとしても、必ずしも100のスキーマが必要というわけではない
  • スキーマ間のリンクはできるだけシンプルに、パフォーマンスが低下しないようにする
  • Schema Federationのもっとも成熟した(そして独創的な)実装はJavaScriptである
    • jsのサービスを作る気がある、あるいはすでにある場合にもっとも有効
    • 他の言語においても追加される予定

著者のSchema Federationに対する印象

フェデレーションは、チームのニーズに合っていてスキーマステッチよりもスケーラブルであれば、美しいソリューションになりえる

ApolloチームはGraphQLに関する膨大な知識と経験を持っているので、クエリプランニングは改善され続けるだろうし、ブラックボックス化しないような設計が保たれるだろう

Single Schema Gateway

ステッチングとフェデレーションに関するほとんどの問題は、GraphQLが本質的にAPIへの集中型アプローチであるという事実に起因している

分散化を試みることもできるが、非常に複雑なゲートウェイや単一のGraphQLサーバーを構築することよって、結局集中化に行き着いてしまう

著者は、(マイクロサービスの文脈において)単一のスキーマと薄いAPIサーバーとして、GraphQLが居場所を持つことができると信じている

GraphQL APIを「単なる1つのサービス」と考えて、そもそもゲートウェイに求めるものとは何なのかを改めて考えてみる

物事を退屈でシンプルにしたいのであれば、多くの基礎となるサービスによって解決されるGraphQL APIサーバーを構築し、かつグラフを1箇所に集めることができる

フェデレーションやステッチングのようなものを選ぶと、組織の複雑さと運用の複雑さを交換することになる

Facebook、GitHub、Shopifyはすべてモノリシックなスキーマ運用をしている
グラフを異なるリポジトリに分離するのではなく、優れた開発者ツールを構築することで組織の問題に対処している

著者としては、単一のサービスとして動作する薄いGraphQLファサードが、GraphQLゲートウェイを構築するためのもっとも好ましいアプローチであると考えている

GraphQL APIは、ステッチングやフェデレーションのような複雑なアプローチを使用しなくてもゲートウェイとして機能させることができる
スキーマとインターフェイスは一元化し、実行部分(リゾルバー)だけフェデレーションさせる

このアプローチは既存のアーキテクチャを持つチームにとって、段階的な導入がしやすい
ステッチングやフェデレーションを使うのであれば、すべてのサービスにGraphQL APIを実装する必要があるが、この方法であれば既存のRESTやgRPCなどの機能を再利用することができる

そうなの?
→そう。スティッチングやフェデレーションはすべてのサービスにGraphQLを用意しないといけないが、そうでなければ単に数珠つなぎにサービスをコールすれば良いだけ

しかし、ステッチングやフェデレーションとは異なり、ここで説明するような成熟した GraphQLゲートウェイの実装は実際には存在していない

この本でこれまで取り上げてきたことを踏襲することで、必要なもののほとんどをすでに持っている

GraphQLでは、物事を退屈でシンプルに保つことは、実はとてもよいことである

「サービス指向アーキテクチャ」と「スキーマの連携」の背後にある考え方の1つは、ソフトウェアを開発する際にチームを切り離すこと
スキーマのフェデレーションは、異なるチームがそれぞれの側でスキーマを進化させることを意味するが、将来的にはゲートウェイがこれらのすべてを単一のAPIに統一させることができる

コードベースが小さくなることでスキーマの変更をより早く行えるようになるかもしれないが、最終的には周王中堅的なグラフとやりとりしていることを忘れてはいけない

これらの問題は、チームが適切な命名をし汎用的な型の再利用を避けることで、根本的な解決を目指さなければいけない
命名の衝突はスキーマをマージしようとした時によく起こる問題で、最初の命名が適切でないために起こる問題である

関心の分離と名前空間は、スキーマを単一のコードベースで構築する場合でも複数のコードベースで統合する場合でも、ベストプラクティスとツールで対処することができる

著者が推奨するのは、チームが概念に適切な名前をつけるためにリンターを使ったりベストプラクティスを適用したりすること

プラハの社長プラハの社長

graphql as bff

  • なんかタイポしてる?のか、意図がわからなかった。in?from?ってなんぞ?

そもそも何のためのBFFか

  • bffではクライアント毎の違いを認識して、クライアント(あるいは体験(experience))毎にapiサーバを用意する。クライアント毎に進化させてもサーバサイドのリソース共有が行われていないため、変更しやすい
  • bffは技術のみならず組織的な決断。チームは特定の体験(モバイルチームなど)に特化する。osfa型のapi開発で頻繁に生じるコミュニケーション問題が起きづらい

graphqlはBFFに適しているとは言い切れない

  • bffのメリットはgraphqlのメリットと一致する部分が多い。graphqlではfieldを追加してもクライアントに影響はない
  • ただ「クライアントが自分のニーズを満たすために宣言的なリクエストを組み立てる」という特徴以外に、graphqlがbffと似ている/bffを代替するソリューションであると言い切れる理由はない
  • graphqlサーバの作り方によってはosfa型の解決策になってしまうこともある
  • bffはクライアント側のユースケースに最適化するだけではない。bff毎にペイロードのシリアライズ方法を使い分けたり、違う方法でキャッシュしたり、違う方法で認証したり、などなど
  • あるべきbffパターンを追求するなら複数のgraphqlサーバが必要になるだろう

graphqlサーバをbffとして使用する際の注意点

  • 近い結果を得るために複数のアプローチを採用することを恐れてはいけない。client-specific fieldを使ったり、時には全く違うtypeを使ったり。既存クライアントが影響を受けないのがgraphqlの美点。欠点はドキュメントの肥大化や適切な手法の発見が難しくなること
  • osfa graphql apiを作ってしまうことに注意して回避すること
  • 通常のbffパターンほど体験毎にサービスが分解されていないため、ツールや文化を通して体験毎の独立性を担保しなければいけない
  • bffパターンによく起きる問題はgraphqlを採用しても起きること

結論

graphqlを使って単体のbffサーバを立てて、複数クライアントに対応することは可能。ただしbffのそもそもの目的は体験毎に独立性を持たせること。そもそも分割されているため自然と独立性が担保されやすい複数bffサーバ構成より、単一サーバの方が活発にメンテナンスしないとosfaになりやすい

サービス通信

  • graphqlは"north-south"トラフィックに適していることはわかった。"east-west"はどうだろうか
    • 多分ユーザーに近いところがNorthで、DBとかがSouth、みたいなイメージだと思われる

良いサービス間通信に必要な要件とは

  • パフォーマンス。SOAを選択すると、メソッドコールからネットワークコールに移行するため、速度が著しく低下する。だからパフォーマンスを考慮する必要がある
  • レジリエンス( 障害復旧の容易さ )。あまりにネットワークコールが隠蔽されすぎてしまうと、気づかず非効率な処理を実装してしまいがち
  • apiの進化( 変更容易性と言っても良いかな )。fieldを追加するたびにシステム全体のクライアントに変更が伝播するのは避けたい。expand-onlyは使いやすい。lock-stepデプロイを回避できるため。graphqlはこの特性を満たしている
  • プロトコル。大抵の人はgraphqlを使う際にhttp1.1を使うことで自分の首を絞めている( どういうことだろう )。graphqlはプロトコル不問なのでgraphql over H2とかover UDPとか色々対応できる。ただ我々が使い慣れているセマンティクス(http200で成功とか、ヘッダーとか)を使えた方が良いだろう。なのでgraphqlを採用する場合プロトコルに関しては特に気にする必要はない

じゃあgraphqlが何に秀でているのか考えてみよう

  • graphqlはflexibility(customization)のためにoptimizationを捨てている
  • 以下のメリットがある
    • 帯域の節約
    • さまざまなクライアントユースケースに対応できる
    • 低コストでリソース追加を行える
  • 以下のデメリットがある
    • バックエンドの複雑化とパフォーマンス維持の難易度向上
    • 特定のユースケースに特化できない
  • これを考慮するとgraphqlがサービス間通信に適しているようには感じない。gRPCの方が適しているように感じる

サマリ

  • graphqlを分散システムで使いたくなる気持ちはわかる。ただしgraphqlは簡単ではないし、分散システムではさらに難易度が上がる。SOAでgraphql apiを公開したい場合、筆者の推薦は
  • graphql apiを単一スキーマで公開して、配下のサービスに解決を委ねる。graphqlサーバ自体は可能な限り薄く保つ。ツールやベスプラに則って最高の開発者体験を目指す
  • apolloがstitchingをdeprecateさせているとはいえ、query rootでのみfieldをマージするのであれば許容できる。ただスキーマ間のnested linkを維持するのは本当にしんどい
  • query root以外でschemaをマージするのであればapollo federationが最適
  • サービス間通信ではgRPCの方がgraphqlより適していると思う
このスクラップは2022/12/27にクローズされました