GraphQLデザインチュートリアル(前編)の続きです。ミューテーションから最後までカバーしています。編集リクエストもどしどし受け付けています!
ステップ5: ミューテーション
GraphQLのスキーマ設計で最後に残っているのは実際に値を変える機能、つまりは、コレクションや関連するオブジェクトの作成、更新、削除です。
わかりやすい部分から始めるために、今回のケースでは、特定の入出力は気にせず実装したいと思う色々なミューテーションをハイレベルな視点から実装し始めるべきです。
愚直に行くのであれば、CRUDのパラダイムに従ってcreate
、delete
、update
というミューテーションだけを持たせるかもしれません。
それなりに良いスタート地点ではあるのですが、適切なGraphQL APIというには不十分です。
ロジカルなアクションの分離
単純にCRUDにこだわればupdate
ミューテーションがすぐに肥大化し,、
タイトルのような単純なスカラー値を更新するだけではなく、
コレクション内の商品の公開・非公開、追加・削除・順序替え、自動コレクションのルールの変更などのような
複雑なアクションを行う責務までをも持ってしまうことはすぐに気づくでしょう。
これはサーバー上での実装とクライアント側でのAPIの使い方を難しくしてしまいます。
代わりに、GraphQLをうまく利用して一つの大きなミューテーションをより細かい、ロジカルなアクションに分けることができます。
最初にできることとして、update
に含まれた公開・非公開を切り出すことで、ミューテーションの一覧は次のようになります。
- create
- delete
- update
- publish
- unpublish
ルール14: リソースにおいて、別のロジカルなアクションには別のミューテーションを書いてください
リレーションの操作
update
ミューテーションはまだ多くの責務を持ちすぎているので、引き続き別のアクションに切り出したほうがいいです。
ただ、これから切り出すアクションは、別の視点から考えてみる価値があります。1
商品とコレクションの関係については、広く考慮できるスタイルが幾つかあります。
- 更新のミューテーションにリレーション全体 (例:
products: [ProductInput!]!
)を埋め込むのはCRUD形式のデフォルトですが、当然ながらリストが大きければすぐに効率は悪くなります。 - 更新のミューテーションに"差分"のフィールド (例:
productsToAdd: [ID!]!
)を埋め込むと、リスト全体ではなく変更があるIDのみを明記すればよいので、より効率的になります。ただ、アクションは密接に結びついたままです。 - 別のミューテーション(
addProduct
,removeProduct
など)に完全に切り分けるの方法が、一番強力かつ柔軟であるだけではなく、一番うまく行きます。
最後の選択肢が一般的に最も安全です。結局このようなミューテーションがたいてい明確なロジカルアクションになることが大きな理由です。
ただ、考慮すべき要素がたくさんあります。
- リレーション先は大きくてページネーションされているか。もしそうなら、リスト全体を埋め込むのは実用的でないです。
ただ、差分フィールドや別のミューテーションを設ければ大丈夫かもしれません。
リレーション先が常に小さいのであれば (特に一対一であれば)、埋め込みが一番シンプルな選択かもしれません。 - リレーションはオーダリングされているか。もし商品とコレクションのリレーションがオーダリングされており、マニュアルのオーダリングも許可されています。
オーダリングは埋め込まれたリストや別のミューテーション (reorderProducts
ミューテーションを追加すれば良いです) を使えば自然に提供できますが、差分フィールドでは無理です。 - リレーションは必須か。商品とコレクションはどちらも作成・削除のライフサイクルをもっており、リレーションとは関係なく独立して存在することができます。
もしリレーションが必須であれば (例: 商品はコレクションに必ず含まれなければならない)、アクションはリレーションを更新するだけではなく、
実際に商品を作成するため別のミューテーションに分けるのを強く勧めます。 - どちらもIDを持っているか。コレクションとルールの関係は必須ですが (ルールはコレクションなしでは存在しえません)、
ルールはIDを持っていません。ルールがコレクションの配下にあることは明らかです。
このケースでは、リレーションが小さいのでリストを埋め込むのは悪い選択ではありません。
他の方法を取るのであれば個々のルールが特定可能でなければならず、それはやり過ぎな感じがします。
ルール15: リレーションのミューテーションは本当に複雑なのでキレイなルールにまとめるのは簡単ではないです
これら全てを集めると、コレクションに関しては次のようなミューテーションのリストが出来上がります。
- create
- delete
- update
- publish
- unpublish
- addProducts
- removeProducts
- reorderProducts
商品に関しては別のミューテーションにしました。リレーションが大きくて、オーダリングされているからです。
それに対してルールはインラインのままです。リレーションは小さく、IDを持つほど大したものではないからです。
最後に1つ、addProduct
ではなくaddProducts
のようなミューテーションになっていることに気づいた方もいるかもしれません。
これはこうした方が単純にクライアント側で便利だからです。
なぜなら、リレーションを操作する際に、一度に複数の商品を追加、削除、順序替えをするケースが多いからです。
ルール16: リレーションに対して別のミューテーションを記述する際は、一度に複数の要素を操作できたほうが便利かを考慮してください。
入力: 構造 パート1
記述したいミューテーションを洗い出せたので、入力の構造をどういった形にするかを考えてきましょう。
公開されている実際のプロダクションのスキーマのどれを見ても、多くのミューテーションが単一のグローバルなInput
型を定義してミューテーションの全ての引数を持たせているのに気づくかもしれません。このパターンはレガシーなクライアントで見られる要件ですが、新しいコードにはこのパターンは必要ありませんので、無視して構いません。
単純なミューテーションの多くは、単一のIDか幾つかのIDだけが必要なものですので、このステップはかなり簡単です。
コレクションのうち、次のミューテーションの引数は簡単に洗い出すことができます。
-
delete
、publish
、unpublish
は全て単純に一つのコレクションIDが必要です。 -
addProducts
とremoveProducts
はどちらもコレクションIDと商品のID一覧が必要です。
これで設計すべき入力は次の3つの"複雑な"ものだけに絞られました。
- create
- update
- reorderProducts
createから見ていきましょう。かなり愚直に考えると入力はもとの愚直なコレクションモデルのように見えるかもれませんが、
そのモデルよりはすでにうまく設計できるようになっています。最終的なコレクションモデルと上述のリレーションに関する議論をベースにすると、
以下のようなものから始めることができます。
type Mutation {
collectionDelete(id: ID!)
collectionPublish(collectionId: ID!)
collectionUnpublish(collectionId: ID!)
collectionAddProducts(collectionId: ID!, productIds: [ID!]!)
collectionRemoveProducts(collectionId: ID!, productIds: [ID!])
collectionCreate(title: String!, ruleSet: CollectionRuleSetInput, image: ImageInput, description: HTML!)
}
input CollectionRuleSetInput {
rules: [CollectionRuleInput!]!
appliesDisjunctively: Bool!
}
input CollectionRuleInput {
field: CollectionRuleField!
relation: CollectionRuleRelation!
value: String!
}
まずネーミングに関して一つ述べておくと、全てのミューテーションは、<action>Collection
の形のほうが英語ではより自然なのに、あえてcollection<Action>
の形の名前になっていることに気づいた方もいるでしょう。残念ながら、GraphQLはミューテーションをグループ化したり整理する方法を提供していないため、回避策としてアルファベット順を使用します。コアな型を最初に置くことで、すべての関連するミューテーションが最終的なリストにまとめられます。
ルール17: アルファベット順でまとめるために、ミューテーションの名前の先頭にミューテーションを施すオブジェクトの名前を付けてください (例:cancelOrder
ではなくorderCancel
)。
入力: スカラー
これまでで得られたスキーマのドラフトは完全にナイーブなアプローチよりもかなり良くなっていますが、
まだ完璧ではありません。特に、入力フィールドdescripton
には幾つかの問題があります。
HTML
がnull不可能というのは、コレクションの説明文が出力値である場合には理にかなっていますが、
入力の場合には次の理由から、あまりうまくいきません。まず、 !
は出力ではnull不可能性を意味しているのですが、
入力では同じことは意味しません。どちらかというと、フィールドが「必須」であるかどうかの概念を示していると言って良いでしょう。
必須フィールドはリクエストが先に進むためにクライアントが提供しなければならないものですが、description
は必須ではありません。
説明文を提供しないという理由だけでクライアントがコレクションを作れないという事態はあってほしくありません (もしくは、クライアントに意味のない""
の提供を余儀なくさせるようなことはしたくありません)。なので、description
を必須でなくすべきです。
ルール18: 入力フィールドは、ミューテーションの処理に意味的に必要な場合のみ、必須フィールドにしてください。
description
のもう一つの問題は型にあります。すでに強く型付けされていますし、(String
ではなくHTML
)、このチュートリアルではここまでずっと強い型付けでやってきたので、これは直感的でないように見えるかもしれません。
しかし、もう一度いいますが、入力は出力とは挙動が少し違います。
入力における強い型付けのバリデーションは「ユーザースペース」のコードが実行される前にGraphQLのレイヤーで行われます。
つまり、現実的にはクライアントは2つのレイヤーでのエラーを扱わなければならないということです。
1つは、GraphQLレイヤーのバリデーションエラー。もう一つは、ビジネスレイヤーのバリデーションエラー(例えば、現在のストレージで作成できるコレクションの上限に達しました、みたいな)です。このプロセスを簡略化するために、クライアントが前のレイヤーでバリデーションするのが難しい場合は、
入力フィールドを意図的に弱く型付けします。これによって、ビジネスロジック側で全てのバリデーションができ、
クライアントは一箇所からのエラーのみを扱えばよいようになります。
ルール19: フォーマットが曖昧でクライアント側のバリデーションが複雑な場合、入力にはより弱い型を使用します(例えば Email
ではなく String
)。これにより、サーバーはすべての複雑なバリデーションを一度に実行し、エラーを単一の形式で一箇所に返すことができ、クライアントを簡素化します。
注意したいのは、全ての入力を弱く型付けしたほうが良いわけではないということです。
ルールの入力値であるfield
やrelation
には強い型である列挙型を使いますし、
仮に本チュートリアルで他の入力にDateTime
のようなものあれば強い型を使うでしょう。
違いとなる大きな要因は、クライアント側のバリデーションの複雑さとフォーマットの曖昧さです。 HTMLはしっかりと定義されており、明確な仕様ですが、バリデーションするのは非常に複雑です。一方、文字列として日付や時刻を表現するには数百の方法がありますが、それらのすべては比較的簡単です。したがって、強いスカラー型を用いて期待するフィーマットを指定することは大きな利点となります。
Rule #20: フォーマットが曖昧でクライアント側のバリデーションがシンプルな場合は、入力に強い型 (例: String
の代わりにDateTime
)を使うようにしてください。これにより、わかりやすくなり、より厳しい入力制御を使うようにクライアントに促します。(例: フリーテキストフィールドの代わりに日付選択ウィジェットなど)
入力: 構造 パート2
続いて更新するミューテーションを見ていきましょう。次のように書けるでしょう。
type Mutation {
# ...
collectionCreate(title: String!, ruleSet: CollectionRuleSetInput, image: ImageInput, description: String)
collectionUpdate(id: ID!, title: String, ruleSet: CollectionRuleSetInput, image: ImageInput, description: String)
}
作成のミューテーションにかなり似ていることに気づいた方もいるかもしれません。
違いは、更新するコレクションを決めるために引数id
が追加されているのと、title
はすでに存在しているはずなので
必須項目ではなくなっているという点です。
一時的にタイトルの必須項目という制限を無視すると、作成と更新のミューテーションは重複している引数が4つあります。
実際のコレクションモデルではもっと多くの重複があるでしょう。
これらのミューテーションをそのまま残しておくために必要な引数はありますが、
このような状況では、必須項目という情報を失うコストを考慮しても、引数の共通な部分をDRYする必要があると考えました。
これにはいくつかの利点があります。
- コレクションのコンセプトを表し、既存のスキーマにある
Collection
型をミラーリングした一つの入力オブジェクトだけで済むようになりました。 - 同じ種類の入力オブジェクトを操作していることになるので、クライアントは作成と更新のフォーム間でコードを共有できるようになりました。
- ミューテーションが、トップレベルの引数が数個のみになることで、簡素化され読みやすくなりました。
もちろん、スキーマからはタイトルが作成時に必須項目かどうかはもはや分からなくなっているのは大きなコストです。
スキーマは最終的に以下のようになりました。
type Mutation {
# ...
collectionCreate(collection: CollectionInput!)
collectionUpdate(id: ID!, collection: CollectionInput!)
}
input CollectionInput {
title: String
ruleSet: CollectionRuleSetInput
image: ImageInput
description: String
}
ルール21: 特定のフィールドで必須項目の制約を緩和する必要がある場合でも、重複を減らすようにミューテーションの入力を構造化してください。
出力
対処すべき最終的な設計上の問題は、ミューテーションの戻り値です。通常、ミューテーションの結果は成功または失敗のどちらかです。GraphQLはクエリレベルのエラーを明示的にサポートしていますが、ビジネスレベルのミューテーションの失敗には理想的ではありません。代わりに、ユーザーではなくクライアントの失敗(例: 存在しないフィールドのリクエスト)のために、これらのトップレベルのエラーを取っておきます。このように、各ミューテーションは、便利かもしれない他のフィールドに加えて、ユーザエラーフィールドを含む「ペイロード」タイプを定義すべきです。コレクションの作成の場合は、次のようになります。
type CollectionCreatePayload {
userErrors: [UserError!]!
collection: Collection
}
type UserError {
message: String!
# エラーの原因となる入力フィールドへのパス
field: [String!]
}
ここで、ミューテーションが成功すれば、userErrors
は空のリスト、collection
は新しく作成されたコレクションとして返却されます。
失敗すれば1つ以上のUserError
オブジェクトと、nullのコレクションが返却されます。
ルール22: ミューテーションはペイロードのuserErrors
フィールドを通じてユーザー/ビジネスレベルのエラー返すべきです。トップレベルのクエリエラーのエントリーはクライアントとサーバーレベルのエラーで使われるべきです。
多くの実装においては、このような構造のほとんどは自動的に提供されているので、私達はcollection
のフィールドを定義するだけでよいことが多いです。
更新のミューテーションも全く同じパターンに従います。
type CollectionUpdatePayload {
userErrors: [UserError!]!
collection: Collection
}
ここで重要なのは、collection
はここでさえもnull可能性があるということです。
なぜなら、提供されたIDが有効なこれうションを表していない場合、返すべきコレクションはないからです。
ルール23: どのようなエラーの場合でも返す値が本当にある場合を除き、ミューテーションにおけるペイロードのたいていのフィールドはnullを許可すべきです。
おわりに
チュートリアルを読んでいただきありがとうございました! 現時点でGraphQL APIの設計方法に対して十分に理解できているのであれば嬉しいです。
満足のできるAPIが設計できれば、あとは実装するだけです!
-
結構異訳しました。
別の視点というのは、オブジェクトのリレーション(例: 一対多、多対多)を操作するという視点です。
これまですでに読み込み用のAPIで、「ID」対「 オブジェクト埋め込み」、「ページネーションの使用」対「配列」を考えてきましたが、
これらのリレーションをミューテーションする際にも読み込み用のAPIの時と似たような問題があります。 ↩