Production Ready GraphQLという洋書の4章の「GraphQL Schema Design」を自分なりにまとめたもの。
Design First
- お気に入りのライブラリでいきなり実装を始めるのではなく、設計から始める
- 実装から始めると、実装の詳細と密結合した設計になりがち
- 担当ドメインの専門家の話を聞きながら、APIを設計できるのがベスト
- 設計から始めることで破壊的な変更の頻度を減らせる
Client First
- 設計をする際は、Clientのユースケースを念頭に置く
- Clientのニーズの要求を満たし、簡単にAPIを使えるようにし、間違った使い方はなるべくできないようにする
- 開発のなるべく早い段階からClientと結合して、フィードバックをもらうこと
- バックエンドの実装の詳細などのClientが知る必要のない情報は公開しない
- データベースからGraphQL APIを生成するツールはたくさんあるが、Client Firstの観点からはあまり意味はない
- 生成されたスキームは、バックエンドの実装と密に結合している
- 一般的すぎるAPIが生成されがち
- Clientの要望を全く意識していない
- 不要な情報を公開しすぎる
- Open APIをGraphQLスキームに変換するツールも良くはない
Naming
- 良い命名をしていると、ドキュメントを見なくても動作が分かる
- 良い命名は正しい設計につながる
- 一貫性が一番大切
- 悪い例:Queryに動詞が付いていたりいなかったりする
- products
- findProducts
- 悪い例:表記ゆれ
- BlogPost
- Post
- 悪い例:Mutationの動詞の揺れ1
- addProduct
- createPost
- 悪い例:Queryに動詞が付いていたりいなかったりする
- APIに対称性をもたせる
- publishPostときたら、反対の操作はunpublishPostとする
- 命名は具体的にする
- 具体的な命名は、大規模なAPI廃止を避けられるし、使い方も理解しやすい
- 一般的すぎる命名の例:"Event"、"User"
Descriptions
- APIの使い手にとって、Descriptionは外部のドキュメントより見つけやすくて良い
- ほとんどのエンティティにDescriptionを書くと良い
- 良いDescriptionは、typeが何を表現するか、mutationは何をするかを明瞭にする
- とはいえ、Descriptionをあまり読まなくても、APIを理解して使えるようにすべき
- Descriptionに、エッジケースや独特な振る舞いをする条件が記述されていると良くない兆候2
Use the Schema, Luke!
- enumを使う
- stringやcustom encoding scalarにJSON文字列のような半構造化データを持たせるのではなく、typeを定義する
- custom scalarは役に立つかも (DDDの値型のような立ち位置)
Expressive Schema
- フィールドは一つの仕事をこなすようにし、一般的だったり利口すぎるフィールドは避ける3
- なるべくスキーマで正しい使い方を強制する
- 関連したフィールドや引数の集まりを表現するために、型を定義する
- optional inputsや引数を使うときは、デフォルト値を定義する
以下、具体例。
- 例1
- いまいち
findProduct(id: ID, name: String): Product
- IDとnameを両方とも指定していないときの動作が読み取れない
- 良い
productByID(id: ID!): Product
productByName(name: String!): Product
- いまいち
- 例2
- いまいち
products(sort: SortOrder): [Product!]!
- SortOrderを指定しないときの並びが読み取れない
- 良い
products(sort: SortOrder = DESC): [Product!]!
- いまいち
Specific or Generic
- おおむね特化したAPIの方が良い
- いまいち
posts(first: Int!, includeArchived: Boolean): [Post!]!
- 良い
posts(first: Int!): [Post!]!
archivedPosts(first: Int!): [Post!]!
- いまいち
- booleanの引数は、汎化されすぎている兆候かもしれない
- 汎化したAPIは便利なこともあるが、使い方が分かりづらい、性能に難がある、といった問題もある4
Anemic GraphQL
- 他のフィールドから計算される値はフィールドとして定義する
- Clientに計算させるのではなくServerで計算させるということ
- ルールが変わるとClientを修正しないといけないから
- Mutationで万能Update API + XXXInputを定義するのではなく、ユースケース別にAPIやInputを定義する
- optionalなフィールドがなくなる
- APIの動作が分かりやすくなる
- Inputに不正な値の組み合わせを設定できなくなる
List&Pagination
Pagination
Offset Paginationをサポートする必要がないなら、Cursor Paginationが良い。
- Offset Pagination
- Good: 実装が楽、ユーザがページの飛ばし読みができる
- Bad: 走査するデータ数が増えるので性能に難あり、リストの閲覧中に項目数が変わると整合性が取れなくなる
- Cursor Pagination
- Offset Paginationと特徴の裏返し
- ほとんどのGraphQL APIはこちらの方式
Relay Connection
以下のようなスキーマ設計のこと。
type Query {
products(limit: Int!, after: String): ProductConnection!
}
type ProductConnection {
edges: [ProductEdge]
pageInfo: PageInfo!
"""
これ以外のフィールドを持たせるのもあり。
たとえば、node一覧へのショートカット。
nodes: [Product!]!
"""
}
type ProductEdge {
cursor: String!
node: Product!
}
type PageInfo {
endCursor: String
}
"""リスト対象"""
type Product {
name: String!
}
EdgeやConnectionに情報を足せるので、色々なユースケースにも対応しやすい。
Sharing Types
- 型を使い回すことに自信がないなら、使い回さない方が良い
- 型の使い回しは便利なときもあるが、後々、問題を起こすこともある
Global Identification
- 慣例
- Node interfaceを作り、それを実装する型にはグローバルに一意なIDを設定する
-
node(id: ID!) Node
やnodes(ids: [ID!]!) [Node!]!
で取得できるようにする - IDには、型を判別するための情報も含める
- API ClientとしてRelayをサポートしないなら慣例に従うのは必須ではないが、Relayを使わなくても良い設計パターンになりえる
- IDはOpaque にした方が良い5
- API ResponseのIDをBase64でエンコードにしておくと、使い手にOpaqueな値ということを思い出させるのに良い
Nullability
- non-nullのメリット
- Schemaから読み取れる情報が多くなる
- API Client側の過度な防御コードを減らせる
- 注意
- non-nullからnullableの変更は破壊的変更
- どのフィールドがnullになるのか否かの予測は難しい
- タイムアウト、API Rate Limit起因でフィールドがnullになることも
- nullableな値をnullで返したときの挙動が独特
- ルールに違反したフィールドの先祖をさかのぼって、最初に見つけたnullableなフィールドの値がnullになる
- ガイドライン
- 引数
- non-nullにすると良いことが多い
- 既存のQuery/Mutationに引数を追加するときは互換性を保つためにnullableにするのが良い
- Objectフィールド
- Database、networks callのように失敗する可能性があるものから取得しているなら、nullableにした方が良い
- 単純なスカラ値のフィールド
- 大体、non-nullで大丈夫
- 引数
Abstract Types
- interfaceは共通の振る舞いを表現するのに使うこと
- 例: GitHubのGraphQL APIのStarable
- interfaceの濫用はしないこと
- 単に共通のフィールドを持っているからという理由で、interfaceを定義するのはいまいち
- 実装時にコードを再利用したいからといってinterfaceを定義するのはいまいち
Designing for Static Queries
- GraphQLのQueryは動的に組み立てるのではなく、静的に記述するのが良い(Queryに渡す引数を変えるのはありらしい)
- 静的Queryの利点
- Clientが必要としていることが分かりやすい
- Lint、IDEなどのツールの恩恵を受けられる
- サーバがQueryを保存できる
- IDのリストからオブジェクトを一括取得するときに動的Queryを使いたくなるかもしれないが、それはBatch Read用のQueryを用意することで避けられる
Mutations
- mutationごとに固有のPayload型を定義し、それを戻り値にする
- 付随的な情報を返せるのがメリット
- mutationごとに固有のInputを1つ定義し、それを引数にする
- mutation時に渡すパラメータを増やしやすい
以下、具体的イメージ。
input PostMessageInput {
roomID: String!
text: String!
}
type PostMessagePayload {
message: Message
errors: [UserError!]!
}
type Mutation {
postMessage(input: PostMessageInput!): PostMessagePayload
}
Fine-Grained or Coarse-Grained(粒度)
- mutationについて、どれくらいの粒度が良いかは状況次第
- 作成mutationの粒度は荒く、更新mutationの粒度は細かい方が良いことが多い
- 複数のデータの更新が必要 かつ 整合性が求められる場合は、1つの粒度の粗い更新mutationを作る
- 別解として、1つのmutationのInputのフィールドに、実行したい操作のリストを持たせるやり方もある
Errors
- Developer/Clientエラーは、GraphQLの標準のerrorsで表現すると良い
- Timeout、Rate Limitedなど
- Userエラーは、独自の表現が良い
- サインアップ画面のパスワードの文字種が間違っている、2重支払いをしたなど
- mutationのErrorを独自に表現する方法には2つある
- カスタムエラー型を定義し、Payload型のフィールドにその配列を持たせる
- mutationの結果となるPayload型をunionとして定義する
どういうエラーが起きるか分かりやすい
union SignUpPayload = SignUpSuccess | UserNameTaken | PasswordTooWeak
- エラー型を定義するときは、interfaceを定義しそれを実装する形にすると良い
- エラー型を追加したとき、古いAPI Clientでもハンドリングできる
- 上記2つのエラーの定義方法のどちらを選択するかは好み
Schema Organization
- Namespaces
- 適切な名付けをしていればnamespaceは大体いらない
- namespaceのようなものが欲しいのであれば、Prefixを付ける
- 例:Instagram_User、Fracebook_User
- GraphQLスキーマとサーバ側の実装は別物
- サーバ側の実装としては、namespace、モジュール、共通関数を使うことは当然あり
- Mutations
- グループを表す言葉を先頭にするのではなく、読みやすい命名にする
- いまいち:productCreate、productDelete
- Good:createProduct、deleteProduct
- directiveの"tags"のようなものを作り、それでmutationのgroupを表現する考えもある
- グループを表す言葉を先頭にするのではなく、読みやすい命名にする
Asynchronous Behavior
- 非同期の処理中であることを表すなら、処理中であることを表現する型なりenumなりを定義する
- Shopifyの非同期処理の状態の表現方法は参考になる
- Shopifyの非同期処理(Bulk Operation)の定義方法も参考になる
Data-Driven Schema vs Use-Case-Driven Schema
- Use-Case-Driven Schemaの方が良い
- GitHubのGraphQL APIのスキーマは、Use-Case-Drivenの良い例
-
動詞の揺れは悪くないと思っている。一貫性をもたせようとすると新規登録は全部createになりそうだが、createは一般的すぎる。チャットのAPIを作るとして、チャットルームを作るAPIはCreateChatで、メッセージ投稿APIはPostMessageというような命名の方が分かりやすい。メッセージ投稿をCreateMessageにすると、メッセージ送信なのかメッセージの下書きを作るのか分かりづらい。 ↩
-
"Aの場合はαという動作になる、Bの場合はβという動作になる、Cの場合はγになる、・・・"というようにエッジケースがつらつら書かれている場合の話だと思う。一覧取得APIがあって、limitで"巨大な値を設定しても1000に設定する"というようなDescriptionなら有りだと思う。 ↩
-
フィールドを安直にnullableにしてしまうと、一般的でお利口なスキーマになりがちな印象。本で示されている悪い例はnullableなスカラー型のフィールドが多かった。 ↩
-
APIの使い手が多いときは汎化もありだと思う。 ↩
-
IDの値の形式をAPI Clientから意識させないようにするということ。 ↩