PRODUCTION READY GRAPHQLを読む: 2章. GraphQLスキーマ設計 ①

PRODUCTION READY GRAPHQLを読む: 1章. GraphQL入門の続き。

今回読んだ章

この本の章立て

2章のタイトルは GraphQL Schema Design です。

この章の冒頭に、Googleの開発者Joshua blochの言葉が引用されています。

APIs should be easy to use and hard to misuse

優れたAPIは、簡単に使えて、誤った使い方をしにくいものでなければならない。 クライアントが使い方や振る舞いを容易に理解できる設計とするための考え方をこの章で学んでいきます。 なお、2章は長いので、3回程度に分けていく予定です。

設計ファースト

  • 実装を始める前に、スキーマの設計を行う。
    • そうでないと、システム内部の実装と結びついた設計となってしまう。
  • ユースケースに精通している人々と協力して設計する。
  • GraphQLには設計の懸念事項があるため、GraphQLのエキスパートとドメインのエキスパートが一緒に設計を行う。
  • APIは一度公開すると変更のコストがかかる。はじめに設計を行うことで、後々の変更を行うリスクを下げる。

クライアントファースト

  • GraphQLはクライアント中心のAPI
    • バックエンドのリソースやエンティティベースでなく、ユースケースベースで設計
    • これを怠るとAPIが汎用的になり、クライアントは自分のやりたいことを理解しにくくなったり、理解のために大量のドキュメントを読むことになったり
  • できるだけ早い段階で設計をクライアントと共有する
    • 6章で語られるらしい
  • YAGNI
    • クライアントの必要なものだけを提供する
      • 無駄なAPIは後々パフォーマンスやセキュリティ観点で廃止になることが多々ある
  • 実装の詳細に影響されないスキーマにする
    • 使っているDB、サーバーサイドの言語、ライブラリに依存しない
  • 多くのライブラリはDBをベースにスキーマを生成するが、これはクライアントファーストではない
    • スキーマが実装と結合することになる
    • テーブル、エンティティが汎用的になる
    • 顧客のニーズを意識していない
    • 必要以上のデータを提供することもある(YAGNIに反する)
  • RESTの定義からスキーマファイルを生成するツールもある
    • RESTとGraphQLでは設計上の注意点が異なるため、1からスキーマを作ったほうがいい
    • 開発スピードとのトレードオフ

以降、具体的なGood Practiceを見ていきます。

命名

よい命名は、ドキュメントを読んだり推測したりせずとも、APIが何をするものなのかという情報を与えてくれる。よい命名をするだけで正しい設計に導いてくれることが多い。

一貫性

# bad
type Query {
  products(ids: [ID!]): [Product!]!
  findPosts(ids: [ID!]): [Post!]!
}
type Mutation {
  addProduct(input: AddProductInput): AddProductPayload
  createPost(input: CreatePostInput): CreatePostPayload
}

対称性

具体性

一般的な名前を使ってしまうと、のちにより一般的な名前をつけたい時に変更が必要になる

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

後に、ユーザーのリストが取りたくなって、こう実装するとする。

type Query {
  viewer: User!
  team(id: ID!): Team
}
type Team {
  members: [User!]!
}
type User {
  name: String!
  hasTwoFactorAuthentication: Boolean
  billing: Billing!
}

これでは、各ユーザーの個人設定まで取れてしまう。 そこで、ユーザーをリストで取得する際に返すオブジェクトと、ユーザーが自分の設定を見る際に返すオブジェクトにTypeを分ける

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!
}

ユーザ情報を扱うinterfaceをUserとしたため、もともとUserとしていた個人設定を取るオブジェクトは、具体的なViewerに名前を変更することになった。 こういった変更が発生しないよう、初めからユースケースを検討して具体的な命名をするべき。

Descriptions

  • Descriptionsを書くことで、外部ドキュメントに頼らずスキーマを理解できる
  • 型が何を表すのか、Mutationで何が起こるのか明確に記述する
  • スキーマを見るだけで理解できることが理想
    • Descriptionエッジケースの記述、条件や応じて変わる挙動に関する記述をする
  • Descriptionは大事だが、ユースケースを理解させるためにDescriptionに依存しない

スキーマの機能を活用する

  • Enumを使う
  • Json文字列ではなく構造化する
  • カスタムscalarで形式を伝える
type Product {
# Instead of a string description, we use a
# custom scalar to indicate to clients
# that they can treat the result of this field # as valid markdown.
description: Markdown
}
scalar Markdown
  • 不可能な状態にすることを不可能にする (Make Impossible States Impossible)
  • 以下のスキーマでは支払い情報としてギフトコードorカード情報のどちらかを受け取る。
  • しかし、カード番号、カード有効期限に何を渡していいかわからない。また、ロジック側で形式チェックが必要。
type Payment { 
  creditCardNumber:String 
  creditCardExp: String 
  giftCardCode: 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!
}

以下に改善することで、カード情報が入力される場合に番号と期限を強制できる

type Payment {
  creditCard: CreditCard
  giftCardCode: String
}
type CreditCard {
  number: CreditCardNumber!
  expiration: CreditCardExpiration!
}
# Represents a 16 digits credit card number
scalar CreditCardNumber
type CreditCardExpiration {
  isExpired: Boolean!
  month: Int!
  year: Int!
}
  • デフォルト動作を示す

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

汎用と特化

1つのフィールドに複数の責務を持たせている場合、別のフィールドに分割することでより良い設計になることがある

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

キャッシュが働きやすく、クライアントにとってわかりやすいスキーマとなる。

以下の例では、SQLに近い汎用的な操作を提供しているが、クライアントにとっても使用方法の理解が難しい。

query {
  posts(where: [
    { attribute: DATE, gt: "2019-08-30" },
    { attribute: TITLE, includes: "GraphQL" }
  ]) {
  id
  title
  date
  } 
}

汎用的な操作が求められるならこのような実装も考えられる。そうでないなら、以下の例のように必要なユースケースに絞った実装にすることを検討するべき

type Query {
  filterPostsByTitle(
    includingTitle: String!,
    afterDate: DateTime
  ): [Post!]!
}

Anemic GraphQL

Anemic GraphQLという章タイトルは、Anemic Domain Model(貧弱なドメインモデル)という用語をもじっている。

The anemic domain model is described as a programming anti-pattern where the domain objects contain little or no business logic like validations, calculations, rules, and so forth.

Anemic GraphQLとは、スキーマを単なるデータの入れ物と見ており、アクション、ユースケース、機能を考慮せずに設計されているスキーマのこと。

Queryの例

ユースケースを考えず、単にデータを返している例。

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

これは単に定価と割引額を返しているとする。このとき、顧客側で以下のような計算をする必要がある。

const discountAmount = accounts.reduce((amount, discount) => {
  amount + discount.amount
}, 0);
const totalPrice = product.price - discountAmount

ところが、数ヶ月後に商品に税金が課されることになったとする。クライアント側に税金の額を渡すよう変更する必要がある

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

これに伴い、クライアント側のロジックを修正する必要がある。これを防ぐには、最初からクライアントが必要としているもの、つまり合計金額を渡せば良い

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

こうすることで、今後もtotalPriceの計算方法が変わった場合も、クライアント側は正確な値を取得できる。単にデータを公開するのではなく、クライアントが必要としている形に加工して提供する。

Mutationの例

以下は、ECサイトで使用する、購入に関する情報を変更するミューテーションの例。

type Mutation {
  updateCheckout(
    input: UpdateCheckoutInput
  ): UpdateCheckoutPayload
}
input UpdateCheckoutInput {
  email: Email
  address: Address
  items: [ItemInput!]
  creditCard: CreditCard
  billingAddress: Address
}

あらゆる属性を変更でき、「商品をカートに追加する」「配送先を変更」「支払い方法を変更」など様々な場面で使える便利なミューテーションに見えるが、以下の問題がある。

  • 単にデータの更新に焦点を当てており、ユースケースを意識していない。
    • 一部の値だけ更新したい場合に、更新しないパラメータを渡す必要があるかどうかがわからない。
    • 最悪の場合、クライアントの意図しない形でデータが上書きされる可能性がある。
  • 特定のアクション、例えば「カートに追加」したいだけの時でも、その他の各フィールドの値を渡す必要がある
  • カートに商品を追加したい場合、既に追加済みの商品リストを取得して、新たに追加する商品を追加したリストを生成して渡す必要がある
  • 単にフィールドを見て何ができるかをクライアントが推測する必要がある。
  • 各フィールドがnull許容になっており、スキーマの表現力が低い
type Mutation {
  addItemToCheckout(
    input: AddItemToCheckoutInput
  ): AddItemToCheckoutPayload
}
input AddItemToCheckoutInput {
  checkoutID: ID!
  item: ItemInput!
}
  • 各フィールドが必須になっており、クライアントが何を渡したらいいか迷わない。
  • 追加したい項目を渡すだけでいい
  • このミューテーションで発生するエラーのパターンが減り、より細かいエラーを返せる
  • クライアントが意図しない更新をすることがない

副次的な効果として、入力とペイロードが予測可能になり、読むのも使うのも簡単になる。

まとめ

  • スキーマユースケースベース、クライアントのニーズベースで設計する
    • ドメインのエキスパートとGraphQLのエキスパートが協力するのがベスト
  • 命名では、ユーザーが迷わないように一貫性・対称性・具体性を意識する
  • スキーマの機能を活用して、ユーザーができることを明確にし、誤った使い方ができないようにする
    • スキーマ側の遊びを減らすことで、ロジック側の負担も減る
  • バックエンドのモデルをそのまま返すのではなく、ユースケースに応じた値を返す

感想

  • ユースケースベースで設計すべき、という話については、「理想はそうだけど、実際はモデルベースでスキーマを生成しちゃう方がラクなのでは」という気がしてしまう。
    • 単一のバックエンドの場合はそれでいいけど、複数のバックエンドを持つBFFみたいな構成の場合は、ユースケースベース・スキーマファーストで実装する方がスムーズなのかな、と思った
  • 命名スキーマ設計の話は、GraphQLに限らず一般的なプログラミングの考え方としても勉強になった
    • 「インターフェース側で制約をかけられる部分はビジネスロジックに任せずインターフェース側で担う。そうすることでクライアントにとってもわかりやすいし実装側の負担も減る。」という話は確かにそう。今まで意識できてなかったかもしれない。

今回はここまで。次回はRelay仕様に沿ったスキーマ設計などに触れるようです。