Production Ready GraphQLを読んで
Production Ready GraphQL: https://book.productionreadygraphql.com/
Introduction
APIの歴史的な話
- GraphQLが出てくる前はいわゆるエンドポイントベースのAPIが主流だった
- RESTとかは1つのエンドポイントが1つのユースケースに対応していてわかりやすかった
- が、最近ではAPIの利用者の種類が増大したためその分ユースケースも増えた
- ウェブブラウザだけではなく、スマホやタブレットなどからもAPIが呼び出されるようになった
- その結果、1つのエンドポイントがいろんなユースケースに対応できるようになろうとした
GET /producs
というAPI
example とある企業のゲーム製品一覧を取得するAPIがあったとする。多様なユースケースへの対応の一例として、プラットフォーム別に製品一覧がほしいという要望が考えられる。
その要望に対する解決策の一つは、エンドポイントを以下のように分けること。
GET /api/playstation/products
GET /api/mobile/producs
しかし、これではプラットフォームの数に対してスケールしない。他にはクエリパラメータで対応するというアプローチも考えられる。
GET /api/products?version=mobile
こうした多様なユースケースに1つのエンドポイントで対応しようとすると、どうしてもAPIがより汎用的なものになっていきがちである。汎用的なAPIは、カスタマイズ性を担保する代わりに最適性(1つのエンドポイントのユースケースがシンプルな状態)を犠牲にしている。
この本で後に記述があるが、良いAPIは正しく使うことが簡単でかつ、間違った使い方をするのが困難である。
という主張がある。そういった観点で見ると、汎用的になったAPIは間違った使い方をしてしまう可能性をはらんでいる。
GraphQLの登場
2015年にFacebookがGraphQLをリリースした。Facebookは、APIを利用する側がほしいデータの構造とAPI側が要求するクエリの構造が異なることに苛立ちを感じていて、「JSON形式でデータがほしいんだからJSONライクにクエリさせろや」となった。
GraphQLとは
GraphQLとは、APIに対するクエリ言語とそのクエリに対する実装をそなえたサーバーサイドである
- 実際我々がGraphQLを使うぞ!!!となったら、スキーマ定義やクエリを書くだけではなくクエリに対する具体的なサーバーサイドの実装も書く必要がある。
文法とか型システムの話は割愛
GraphQL Schema Design
よいAPIとは?
APIs should be easy to use and hard to misuse
つまり、正しく使うのは簡単で間違った使い方をするのが難しいような設計にしようということ。
どんなリクエストを送ればどんなレスポンスが返ってくるかが、見れば簡単に理解できるという状態を目指すべきである。
注意すべきなのは、GraphQLはそういった良いAPIを設計することを容易にはしてくれない。
Design First
よいスキーマをデザインするためには、APIを実際につかうエンドユーザーのこと考えてあげることが大事である。デザインを考えることをおろそかにして実装を始めてしまうと、どうしても内部の実装のこととか利用するライブラリの事情といったことがスキーマ設計に影響を及ぼしてしまう。
API設計をする際は、GraphQLの専門家とドメインの専門家が協力してデザインを考えると良い。
Client First
GraphQLはClient (APIを利用する側) を中心に考えられていて、スキーマを設計する上でこの考えは重要である。clientのことよりもserver sideのことを中心に設計を考えると、
- DBのリレーションやテーブル構造
- バックエンドのエンティティ
といった事情に引きづられてしまう恐れがある。
Client側を中心に設計を考えることで、利用者にとって必要最低限の設計をすることができるのでAPIの誤用を防ぐことができる。
Naming
命名をいい感じにするのは難しいのだが、考えるのはとても重要なことだ。
良い命名はそのAPIがどんなもなのかを即座に理解させてくれる。
Consistency (一貫性)
以下のようなスキーマ定義を見てみよう。
type Query {
products(ids: [ID!]): [Product!]!
findPosts(ids: [ID!]): [Post!]!
}
定義された2つのクエリは、どちらも引数として受け取ったidの配列をもとにほしいオブジェクトを取得するクエリである。しかし、片方は取得されるオブジェクト名のみでもう一方はfind
というprefixがついている。このような、対象となるオブジェクトは異なるが動作は同じAPIに対して一貫性のない命名をすると利用者は混乱する。「じゃあUser一覧を取得するクエリはどういう名前なんだ?」、「やってることはおなじに見えるが、名前が違うってことは動作も違うのか?」などの疑問が生まれてしまうだろう。したがって、これらのクエリには一貫性のある命名が求められる。例えば以下に示すようにどちらも取得されるオブジェクト名だけで命名する。
type Query {
Products(ids: [ID!]): [Product!]!
Posts(ids: [ID!]): [Post!]!
}
他には、オブジェクトに対する命名も一貫しているべきである。あるクエリでは投稿されたブログはPost
と命名されているのに、他のクエリではBlogPost
と命名されていては、これらが異なるオブジェクトなのかと誤解を生じさせる。
Description
GraphQLではスキーマ定義の中にコメントを書くことができる。descriptionはスキーマ内に直接情報を書き込めるという点で良い。descriptionを型が何を表現するのかやクエリが何をするのかを明確に示すために利用するのはいいアイデアであるが、descriptionが多ければ多いほど、スキーマ設計が不十分であることを意味する。(補足しないとわからないということなので)
理想的には、スキーマに対する理解はスキーマをみればできるようになっていることである。したがって、descriptionはケーキの上にあるアイスのようにちょっとしたレベルであるべきである。
Use the Schema, Luke!
以下のようなProduct
という型の定義があったとする。
type Product {
name: String!
priceInCents: Int!
type: String!
}
この定義を理解しようとする上でハードルとなりうる点の一つは、type
の型がString!
なのでどんな値が入りうるのかが理解しづらいということだ。もしtype
に入りうる値は数個の決まったものであるならば、自身の定義がそれをわかるようにできる。
例えば以下のようにenumeration type
を使う。
enum ProductType {
APPAREL
FOOD
TOYS
}
type Product {
name: String!
priceInCents: Int!
type: ProductType!
}
他によくある問題としては、非構造化データをスキーマの一部として扱うことだ。
type Product {
metaAttributes: JSON!
}
type User {
# JSON encoded string with a list of user tags
tags: String!
}
こんな感じで、JSON
というscalar typeを用意するとか、String
として定義しといてコメントでパースの仕方を書いておくといった方法がよくやられる。よりベターなアプローチはそういったスキーマを定義の時点でより強い表現で定義しておくことた。
type ProductMetaAttribute {
key: String!
value: String!
}
type Product {
metaAttributes: [ProductMetaAttribute!]!
}
スキーマの定義から一意に構造が理解できるような強い表現をすることで、呼び出し側であるClientがデータに対する振る舞いを制御しやすくなる。
Expressive Schema
GraphQLの型システムによって、我々は表現力豊かなAPIをつくることができる。つまり、API利用者がドキュメントを見ずともAPIの定義を読めば正しい使い方や振る舞いを理解することができるようなスキーマ定義が可能だということだ。
表現力豊かなスキーマをつくるためのコツの一つは、nullability
を使いこなすことである。
製品を探すAPIの例を考えてみる。
type Query {
findProduct(id: ID, name: String): Product
}
このクエリはどうにも直感的ではない。例えば、Clientが引数になにも渡さなかったらどんな振る舞いをするのか?片方しか値を渡さなかったらどうなるのか?定義を見るだけではわからない暗黙的な振る舞いが定義されてしまっていることになる。
まず、idとnameを同時に指定して製品を探したいというユースケースはあるのだろうか?(まあないよね、片方だけで探したいことがほとんど)
よりexplicitな定義としては以下のようなものが考えられる。
type Query {
productByID(id: ID!): Product
productByName(name: String!): Product
}
このアプローチは、似たようなクエリが何個もできるので冗長なものに見えるが実際はそうではない。
なぜならClient側が必要なクエリを選択するのに対したオーバーヘッドはかからないからだ。呼び出す側で最適なものを選べば良くて、呼び出される側がひとりでにいろんなユースケースに対応しようとする必要はまったくない。
Specific or Generic
GraphQLのコアとなる理念は、クライアントが必要なものを正確に利用できることである。そのため、一般化されすぎたフィールドは、そのフィールド自体の理解やどう使われるのかが理解しづらいのでよろしくない。
フィールドは一つのことを上手に行うべきで、複数のことをやろうとしている場合はフィールドを分割するなどのアプローチをするとよい。
type Query {
posts(first: Int!, includeArchived: Boolean): [Post!]!
}
例えば上に示したような記事一覧を取得するクエリの引数として、アーカイブされた記事も取得するかどうかを決める値をもたせるとする。このスキーマ定義では、postsというクエリがやることが
- 記事を取得する
- includeArchivedの値に応じてアーカイブされた記事も取ってくる
と複数になってしまっている。
アーカイブされた記事を取得する役割を、以下のように別のクエリとすることで各スキーマの役割が1つになる。
type Query {
posts(first: Int!): [Post!]!
archivedPosts(first: Int!): [Post!]!
}
より実践的な例としては、SQLのWHERE句のようなリッチなフィルターをGraphQLのクエリにも搭載させようとする例だ。
query {
posts(where: [
{attribute: DATE, gt: "2022-05-01},
{attribute: TITLE, includes: "GraphQL"}
]){
id
title
date
}
}
上に示したようなクエリは汎用的でパワフルだが、どのユースケースに対してもフォーカスしているわけではない。なので、Client側がそのクエリをどう使えばいいのか理解するのが困難になる。
フィルターは便利なものではあるが、汎用的になりがちなので本当に必要なのかを精査する必要がある。
特定のユースケースに対応できるような例を考えてみると、
type Query {
filterPostsByTitle(
includeTitle: String!,
afterDate: DateTime
): [Posts!]!
}
このように特定の条件付けだけを引数として取るようにすることで、どういった条件でクエリができるか理解しやすくなる。
Anemic GraphQL
いわゆるドメインモデル貧血症のGraphQL版みたいなもので、スキーマがただのデータを入れる袋になってしまっている状態。
type Dismount {
amount: Money!
}
type Product {
price: Money!
discounts: [Discount!]!
}
例えば上に示したような、自身の値段と割引率をフィールドに持つ商品というオブジェクトを定義したとする。このとき、客は実際にいくら払わなければならないかをクライアント側が表示したいとする。
const discountAmount = accounts.reduce((amount, discount) => { amount + discount.amount
}, 0);
const totalPrice = product.price - discountAmount
このようなロジックをクライアント側で実装することにより、無事必要な割引をすべて適用した上での支払金額を表示することができた!
数カ月後、productオブジェクトが以下のような進化をしたとする。
type Product {
price: Money!
discounts: [Discount!]!
taxes: Money!
}
税金という概念が追加された。つまり、クライアント側の支払金額を計算するロジックも修正する必要がある。ここで考えたいのは、クライアント側が支払金額の計算ロジックをしっている必要性があるのかということだ。今回の例で言えばNoだ。つまり、クライアント側は支払金額がいくらなのかさえわかれば表示ができるので、計算方法はサーバーサイドに任せればよいのだ。
次に、Mutationでの例を考えてみる。e-コマースサービスのトランザクション内におけるチェックアウト状態の更新に関するMutationだ。
type Mutation {
updateCheckout(
input: UpdateCheckoutInput
): UpdateCheckoutPayload
}
input UpdateCheckoutInput {
email: Email
address: Address
items: [ItemInput!]
creditCard: CreditCard
billingAddress: Address
}
このMutationはCheckoutオブジェクトのあらゆるフィールドを更新できるため良さげなMutationに見えるが、以下のような問題点がある。
- Mutationはなんの動作を行うかに焦点をあてるべきもので、updateCheckoutという命名とこのInputではなにが行われるか推測することが難しい。例えば商品を追加したいときはitemsだけInputにあればよいのか?とかである。Client側がどういう変異が起こるのか推測しやすい設計であるべき
- 特定の動作をするために、複数のフィールドを選択する必要があるためにクライアント側の認知負荷が高い
- 全部nullableになっていて表現力が低い
こうした粗い粒度の汎用的なスキーマではなく、より細かい粒度の設計にチャレンジしてみる。
type Mutation {
addItemToCheckout(
input: AddItemToCheckoutInput
): AddItemToCheckoutPayload
}
input AddItemToCheckoutInput {
checkoutID: ID!
item: ItemInput!
}
- このスキーマでは強い型づけがなされており、オプションは一切ない。アイテムを追加するために必要な情報が正確にわかる。
- なにが行われるのかをデータから推測する必要がなく、スキーマ定義を見れば何が起こるのかわかる。
The Relay Specification
FacebookがリリースしたJavascriptのGraphQLクライアントであるRelayは、以下のような前提をAPI側に課している。
- グローバル識別子を用いたオブジェクトのre-fetch
- ページネーションをサポートするコネクションというアイデア
- mutationのための特別な構造
Lists & Pagination
大体のスキーマはリスト型のフィールドを公開する。例えばこう
type Product {
variants: [ProductVariant!]!
}
これは一番シンプルなアプローチだが問題がある。
1つ目は、クライアント側がAPIから返ってくるvariantsの要素数をコントロールすることができないことだ。このため、サーバーサイド側のパフォーマンスの問題でフィールドを消さなければならなかったり、クライアント側でフィルタリングを実装しなければならなかったりすることがある。
そういった場合に、ページネーションというアプローチがよく使われる。ページネーションはその名の通り、データをページという単位に分割する。クライアント側はページ単位でデータを取得できるため、ユーザー側の体験が向上したりする。サーバーサイド側も、データセットの一部だけ読み込むので膨大な全データを読み込むよりパフォーマンスはよい。
Offset Pagination
offset paginationでは、クライアント側がいくつデータがほしいかという情報とどこからデータを取得し始めるかという情報の2つをパラメータとして渡す。GraphQLだと以下のような感じ。
type Query {
products(limit: Int!, page: Int!): [Product!]!
}
このアプローチは、実装が簡単であるという利点がある。それだけでなく、クライアント側は興味のあるページにスキップしたり、位置を追跡するということができるので柔軟性もある。しかし、API側が大きくなると問題点がある。それはデータベース側のパフォーマンスである。例えばproductsフィールドをOffset Paginationで読み込む際の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!]!
}
cursor paginationではページという概念が存在しない。そのため、ページをスキップするということができない。しかし、offset paginationに比べるとデータクエリのパフォーマンスはいくらかマシになる。
SELECT * FROM products WHERE user_id = %user_id AND id >= 15
ORDER BY id DESC
LIMIT 10
A Relay Connection
Relayはカーソルによるページネーションを意識しているが、設計はおもしろい。
query {
products(first: 10, after: "abc123") {
edges {
cursor
node {
name
} }
pageInfo {
endCursor
hasNextPage
hasPreviousPage
} }
}
上に示したように、Relayではコネクションによってページネーションを実現しようとしている。
コネクション(上の例だとproducts)はedgesとpageInfoという2つのフィールドを持っている。
- edges: 要求したデータ
- pageInfo: ページネーションについてのメタデータ
edgesはそのアイテムだけでなくカーソル情報も含めることで、そのアイテムのカーソルをクライアント側が知ることができる。(後でいつでも検索して戻れるとかそういうアレなのか?)
↑に示したクエリに対するレスポンスは例えばこんな感じになる。
{
"data": {
"products": {
"edges": [
{
"cursor": "Y3Vyc29yOnYyOpHOAA28Nw==", "node": {
"name": "Production Ready GraphQL Book"
}
} ],
"pageInfo": {
"endCursor": "Y3Vyc29yOnYyOpHOAA28Nw==", "hasNextPage": true,
"hasPreviousPage": false
} }
} }
しかし、このedgesにほしいアイテムをラップさせるというのは冗長な設計に見える。直接アイテム返せばいいじゃん?と思うのだが、より複雑なシナリオを設計する際には有用になるらしい。たとえばGithub APIではTeam.membersのコネクションやエッジはUser型そのものではなく、チームにおける役割もフィールドとして持つ。
type TeamMemberEdge {
cursor: String!
node: User!
role: TeamMemberRole!
}
offset かcursorかは要件に応じて使い分けたらよさそう
Pagination Summary
- コネクションという概念を持ち込んで設計するとページネーションでいい感じにリレーションを表現できるからおすすめ
- cursor paginationはRelayとの親和性がある
- 複雑なユースケースを考えたときにコネクションパターンはいいぞ
Sharing Types
スキーマの規模が大きくなるにつれて、型を再利用したくなることが多くなるだろう。
それは時にはいいことなのだが、やりすぎると痛い目をみる。その例として、コネクションパターンのスキーマ定義をあげる。組織のユーザーのコネクションがページネーションされているような設計になっている。
type UserConnection {
edges: [UserEdge!]!
pageInfo: PageInfo!
}
type UserEdge {
node: User
}
type User {
login: String!
}
type Organization {
users: UserConnection!
}
次に、ここにチームという概念を追加する。チームはメンバーを持つ。
ここでよくある間違いは、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!
}
こうすると何が問題かというと、将来的に同じ型を持つ異なるフィールドそれぞれが、異なる機能を持つ様になったときに動きにくくなってしまうことだ。今回であればTeam用のUserConnectionとOrganization用のUserConnectionを分ければ、それぞれに固有の情報をもたせることができる。
その他によくある例としては、createHogeとupdateHogeの入力を共通のものとしてしまうことだ。
確かにInputとして求められるフィールドは同じかもしれないが、求められる性質が異なるので問題が起きる。
たとえば、名前無しで新たなユーザーを作成することはできないので、CreateUserのInputではnameフィールドはnon-nullableにしたいが、ユーザー更新では名前がInputにないなら更新しないということなのでnullableでもよい。つまり、共通のInputにしてnameがnullableになってしまうと、CreateUserが検証をする必要がある。(nullだったときのエラーハンドリングもしなくてはならない)
Global Identification
GraphQLでよく知られているコンセプトの一つとして、グローバルな識別子がある。
これももともとRelayから来たものだが、Relay以外でも使われている。
Global Identificationは、グラフ上のノードに一意な識別子を与えることで、クライアント側が任意のノードをフェッチできるようにする。
interface Node {
id: ID!
}
type User implements Node {
id: ID!
name: String!
}
こうすると、node(id: ID!)、nodes(ids: [ID!]!)で任意のノードをフェッチできるようになる。なぜこんなことができるようにしているかというとクライアント側のキャッシュのためである。
Global Identificationは、RESTのURIみたいにオブジェクトに対して一意な識別子を与えるという意味で有用といえる。重要なことは、ユーザーがIDに対してハッキングしたりせず、APIを通してIDを使用できるようにすることだ。そのためにはGlobal Identificationの構築を不透明なものにする必要がある。
最もよく知られる方法は、base64エンコードすることだ。base64であることは見ればわかるのだが、これが不透明な構築がされているということがクライアントに伝われば良い。しかし、これだとクライアント側がどのノードのIDを持っているのかわからないため体験が悪い。そのため、少しだけなんのノードのIDなのかわかるようにするのをおすすめする。Slack APIはオブジェクトのタイプに応じて異なる文字列で始まるトークンを持っていた。こうした、不透明なんだけど開発者をちょっと助けるような情報を持つIDがよさそう。
Nullability
Nullability は、あるフィールドがクエリ時にNULLを返すことができるか否かを定義できるもの。スキーマ定義時に型の後ろに!
をつけることでNULLを許容しない(以降non-nullという)フィールドとして定義できる。
non-nullなフィールドにnullが返ってきたとき、GraphQLサーバーは親のフィールドがnullableかどうかをチェックする。nullableな場合その親のフィールドをnullとして結果が返ってくる。non-nullの場合さらにその親のnullabilityをチェックする。ルートのフィールドまでnullableなフィールドが見つからなかった場合にエラーを返す。
type Product {
# This field is non-null
name: String!
# Price returns null when the product is free (default)
price: Money
# The tags field itself can be null.
# If it does return a list, then every # item within this list is non-null.
tags: [Tag!]
}
type Shop {
name: String!
topProduct: Product!
}
Query {
shop(id: ID!) Shop
}
上のようなスキーマ定義でshopクエリを叩き、topProduct (もしくはその子のフィールドがnull)だったとき、topProductはnon-nullableなので親のShopがnullableかどうかを見に行く。今回はShopがnullableなのでnullが返ってくる。
{
"data": {
"shop": null
}
}
フィールドをnon-nullableにすることには多くの利点がある。
- 表現力豊かで状態が予測可能なスキーマになる
- クライアント側で色々頑張る必要がなくなる
一方で、デメリットとなりうる可能性もある。
- non-nullable -> nullableの変更は壊れる。
- nullが入ってきたときの挙動を考慮していないから
- nullable -> non-nullableはnullで死ぬだけだからまあいいのかな? (こっちも壊れてね?という気がする...w)
著者が設計する際に用いている、non-nullable or nullableの考え方を以下に示す。
- 引数は殆どの場合non-nullableがよい。
- データベースの関連や、ネットワーク呼び出しによって返ってくるオブジェクトはいつか失敗する可能性があるためnullableにしたほうがいい
- 単純なスカラーはnon-nullableで安全
- 絶対にnullにならない(もしくは許容できない)オブジェクトはnon-nullableにしてもよい
Abstract Types
抽象型はインターフェースと具象を切り離すのに役立ち、GraphQL設計においても便利なものである。
type SocialMediaFeedCard {
id: ID!
title: String!
birthdayDate: DateTime
eventDate: DateTime
postContent: String
上に示したスキーマは、SNSへの投稿を表す。こいつはあるときは誕生日について、あるときはイベントについて、また別のときは単なる投稿になる。SocialMediaFeedCardが抽象的なものであるため、それができることをそのまま表現しようとするためにスキーマが意図しない挙動を許容した状態になってしまっている。(例えば、誕生日の投稿なのにイベントの日時を持ててしまうとか)
こうした場合にはInterfaceを活用する。
interface SocialMediaFeedCard {
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!
}
これで、各スキーマがクライアントにとって明確なものになった。またNullableなスキーマを用意する必要もなくなり、不正な状態を許容する必要もない。
Unoin or Interface?
GraphQLにはUnionとInterfaceの2つの抽象型がある。これらをどう使い分けるといいだろうか?
著者の経験的には、Interfaceは動作を共有するものに対して、共通のスキーマを提供する。
例えばGithubでは、スターを付けることができるオブジェクトのためのStarrable interfaceを持っている。
Unionはあるフィールドが異なる型を返す場合に使うとよいが、これらの型が必ずしも同じふるまいをするとは限らない。
Don't Overuse Interface
Interfaceは、複数の型がいくつかのフィールドを共有しているが、これらの型が共通の振る舞いをしない場合はInterfaceを使わないほうがよい。Interfaceは、共通のものを持っていることより、共通の振る舞いを持っているオブジェクトたちを説明するために使ったほうがよい。
Abstract Types and API Evolution
あるフィールドがInterface型を返すとき、そのInterfaceを満たす具象が新たに作られてもそのフィールドに対する破壊的変更にはならない。その一方で、Union型は異なる振る舞いを持つ型を増やすので、破壊的変更になりえることに注意したい。
Designing for Static Queries
SQLなどのようにクエリビルダみたいなツールを使いたくなるが、GraphQLはクエリを直接書くことが明示的であり、最もわかりやすい。
query.products(first: 10).fields(["name", "price"])
query {
products(first: 10) {
name
price
}
}
GraphQLのクエリを明示的に書くことで、どんなデータが求められているのかがわかりやすい。
また、静的なクエリを書くことを心がけるべきだ。ここでいう静的とは、プログラムの変数や条件、状態に応じてクエリの内容が変化しないことを指す。つまり、ソースコードを見ればどんなクエリが叩かれるのかわかる。
そうすることで、IDEのサポートやコード生成など、クライアント側で様々な恩恵を受けることができるし、サーバー側でクエリの保存ができる。
これは動的に複数のクエリを生成するコードである。
const productFields = products.map((id, index) => {
return `product${index}: product(id: "${id}") { name }`;
})
const query = ` query {
`
以下のようなクエリが生成される。
query {
product0: product(id: "abc") { name } product1: product(id: "def") { name } product2: product(id: "ghi") { name } product3: product(id: "klm") { name }
}
こうしたアプローチには
- コードを見てもどんなクエリが送信されるのかわからない
- クエリのエイリアスがproductの数に応じて変化する
といった問題がある。
query FetchProducts($ids: [ID!]!) {
products(ids: $ids) {
name
price
} }
上記のようなクエリにすることで、
- productの数がいくつになろうがクエリは変化しない
- クライアントはidのリストを渡せば良い(以下に示すように単一の値も渡せる)
query {
# This is valid! products(ids: "abc") {
name
price
} }
Mutations
Mutationはつかうのに苦労する人が多いだろう。単なるフィールドだとしても、クエリとは異なるものにみえるかもしれない。しかしそれは当然のことで、実際Mutationにおいてエラーの際に返すべきものや、副作用の結果何を返すべきかは明確ではない。
Input and Payload types
Mutationは、他のフィールドと同様に引数を受け取り特定の型を返すことができる。
しかし、一般的な慣習としていわゆる「ペイロード型」と呼ばれる特定のMutationの結果を表す型を返すことがある。
# An example payload type for a createCheckout mutation
type CreateProductPayload {
product: Product
}
このペイロード型は単にProduct型を返すだけである。じゃあ単にMutationの返す型をProductにすればよくない?と思うだろう。
わざわざペイロード型でラップしている理由の1つ目は、Product型に影響を及ぼすことなく、Mutationが返す内容を進化させることができるからだ。Mutationの結果は変異したもの以外の情報をしばしば返したくなる。
# An example payload type for a createCheckout mutation
type CreateProductPayload {
product: Product
successful: Boolean!
}
このように、作成に成功したか否かを示すフィールドを追加したりというのが考えられる。
Relayの規約では、Mutation一つに対してユニークなInputとPayload型を使う。
Fine-Grained or Coarse-Grained
Anemic GraphQLでみたように、粒度の細かいMutationを用意することはよいプラクティスである。
しかし、クライアントのユースケースによっては粗い粒度でのクエリが求められる場面がある。
1つの機能で解決できることを、クライアント側に5回も異なるクエリを叩かせていてはパフォーマンス上の問題や、クライアント側の実装の複雑度の増加などの懸念が生じてしまう。
なので、クエリの粒度はクライアントのユースケースに応じて慎重に検討する必要がある。
Transaction
addProductToChecktouという変異があったとき、クライアントが3つの製品を追加したいとする。
このとき、いわゆるトランザクション処理のように、全て成功するorすべて失敗にするということがGraphQLでできるだろうか。
mutation {
product1: addProductToCheckout(...) { id } product2: addProductToCheckout(...) { id } product3: addProductToCheckout(...) { id }
}
例えば上に書いたようなアプローチは筋が良くない。まずクライアント側が動的に追加したい製品の数だけクエリ文字列を生成する必要がある。また、各mutationは別々で実行されるので、失敗したり成功sたりでクライアント視点で見ると奇妙な状態になることがある。
多くの人は、複数のmutationを一度に実行できるトランザクションブロックを求めるが、そんなことをする必要はない。なぜなら、1トランザクションを実行する荒い粒度のクエリを設計すればいいからだ。
Batch
トランザクションのような処理を行うもう一つの方法がバッチ処理である。GraphQLにおけるバッチ処理は、複数のクエリを一度にサーバーにおくるバッチ処理だけでなく、1つのクエリの中でバッチmutationを設計することもできる。
type Mutation {
updateCartItems(
input: UpdateCartItemsInput
): UpdateCartItemsPayload
}
input UpdateCartItemsInput {
cartID: ID!
operations: [UpdateCartItemOperationInput!]!
}
input UpdateCartItemOperationInput {
operation: UpdateCardItemOperation!
ids: [ID!]!
}
enum UpdateCartItemOperation {
ADD
REMOVE
}
このmutationのインプットでは複数のoperationを受け取ることができるため、一度に複数のadd / removeの操作を1つのmutationで実現することができる。
Errors
エラーの扱いは、多くの人類が悩む。なぜなら、これをやっとけばいいという方法がないからだ。
そして、どのようにエラーを処理するかはAPIが利用されるコンテキストによる。
基本的なGraphQLエラーはこんな感じ。
{
"message": "Could not connect to product service.", "locations": [ { "line": 6, "column": 7 } ], "path": [ "viewer", "products", 1, "name" ]
}
エラーを説明するメッセージ、クエリ文字列のどこで発生したかを示すlocation, クエリのルートからエラーのあったフィールドにつながる文字列の配列であるpathが用意されている。さらによりおおくの情報を追加する際には、keyの衝突を避けるため、extentionキーの配下に新たなキーを追加する必要がある。
{
"message": "Could not connect to product service.", "locations": [ { "line": 6, "column": 7 } ], "path": [ "viewer", "products", 1, "name" ], "extensions": {
"code": "SERVICE_CONNECT_ERROR" }
}
{
"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
}
]
} }
}
上の例では、id 1 の商品の価格がnullを返している。仕様として、エラーが発生したフィールドはnullとなり、関連するエラーがerrorsキーに追加されることになっている。
以下のような新たに製品を追加するmutationにおいては、どんなエラーが返ってきてもmutationのフィールドがnullとして返ってくる。
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
} }
こうしたエラーの形式にはいくつか欠点がある。
- payloadタイプを用いようとすると、フィールドはnullを返さなければならないので相性が悪い
- エラーのextension キーに追加の情報を入れる必要がある
- エラーのペイロードはGraphQLスキーマの外にあるため、クライアント側が型システムの利点を享受できない
GraphQLのエラーは必ずしもユーザーに公開スべきものではない。
また、エラーというのは大きく2種類に分けることができ
- 開発者/クライアント向けのエラー
- クエリ中の問題(タイムアウトetc...)で、クライアント側の開発者が対応する必要がある場合が多い
- ユーザーエラー
- ユーザー側がなにか間違ったことをした
GraphQLのerrorsは前者のエラーを伝えるために使う。後者のエラーはGraphQLのスキーマとして定義することがベストプラクティスである。
Errors as data
エラーをデータとして扱う一番簡単な方法は、フィールドに追加してしまうことだ。
type SignUpPayload {
emailWasTaken: Boolean!
# nil if the Account could not be created account: Account
}
ただし、エラーに対する扱いをしやすくするためにuserErrors
のような型を定義したほうがよい。
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
}
HTTPのステータスコードのようなものと違って難しいところは、client側はuserErrorsを照会する必要がないことだ。つまり、クライアントはnullのフィールドが返ってきたときにその原因がわからない。
Union / Result Types
別のアプローチでは、ユニオン型を利用して問題のある状態かどうかを表現する。
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
} }
}
利点としては、mutationの実行中に起こりうる問題を強く表現できており、型づけもされている。
Which error style should you pick?
ユーザーエラーが型として定義されていればまあいいんじゃないかな
ただ、ユニオン型は新しい型が増えたときクライアント側に対応を求めることになる。
その点userError型のリストは、新しいエラーが増えても網羅的に取得できるので、とりあえずメッセージを出すだけ出したり、デバッグしたりができる。
新しいエラーに対してクライアントが対処しやすくするアプローチの1つとして、インターフェースの利用がある。
interface UserError {
message: String!
code: ErrorCode!
path: [String!]!
}
type DuplicateProductError implements UserError {
message: String!
code: ErrorCode!
path: [String!]!
duplicateProduct: Product!
}
インターフェースを利用することで、インターフェースで定義されている型は常に利用することができ、各エラー固有のフィールドを利用したいときはクライアント側で指定すれば良い。
Schema Organization
スキーマの規模が大きくなってくると、ユースケースの見つけやすくするため、スキーマを類似するグループとして分類したり整理したくなるかもしれない。
Namespaces
GraphQLにいわゆる名前空間という概念は存在しない。が、命名を十分具体的にしていれば必要になる場面はそうない。名前空間のようなものが必要になった場合は、プレフィックスをつけるなどの戦略が考えられる。
type Instagram_User { # ...
}
type Facebook_User { # ...
}
著者いわく、名前空間がほしいのは、だいたいスキーマステッチからくる。スキーマステッチは複数のスキーマをマージする。
Mutations
mutationの命名に苦労しているチームもある。createProductにすべきかproductCreateにすべきか...
気をつけてほしいことは、他のプログラミング言語やSDLにGraphQLスキーマの命名を引っ張られる必要はないということだ。なので、スキーマにaddProductToCartのような具体的な命名をすることを恐れないでほしい。 他のコンポーネントとの検索性をあげるアプローチとして、tagsディレクティブを使う方法が考えられる。
type Mutation { createProduct(...):
CreateProductPayload @tags(names: ["product"]) createShop(...):
CreateShopPayload @tags(names: ["shop"])
addImageToProduct(...):
AddImageToProductPayload @tags(names: ["product"])
}
Asyncronous Behavior
APIは時に同期的ではないことがある。処理をバックグラウンドでやる必要があるため、レスポンスを返す際に処理の結果を返せない。RESTやHTTPベースのAPIでは202 accepted というステータスコードを返す。
非同期なクエリをGraphQLでモデル化するにはいくつかのアプローチがある。
例えば支払い処理の例だと、以下のように保留中の支払いを型として定義してUnionの中に入れることができる。
type Query {
payment(id: ID!): Payment
}
union Payment = PendingPayment | CompletedPayment
他にも、enumとして定義してしまうアプローチも考えられる。
type Operation {
status: OperationStatus
result: OperationResult
}
enum OperationStatus {
PENDING
CANCELED
FAILED
COMPLETED
}
もう一つは、Shopifyがやったアプローチで、ジョブという概念で管理することだ。
Data-Driven Schema vs Use-Case-Drive Schema
これまで、実際のユースケースに合わせてスキーマをどう設計するかという話をしてきた。著者は、迷ったときはデータ用ではなく動作のためのスキーマを設計すると決めている。
APIにはユースケース以外に明確な用途がある。GraphQLの構文を見て、ビジネスから必要なデータを正確に取得するための大きな可能性を見出す顧客もいるだろう。例えば、5分ごとに全課題の全コメントを動悸する必要がある分析アプリケーションを考えてみる。必要なデータのクエリを作成し、1つのクエリを送信すれば利益を得ることができるが、すぐに問題にぶち当たる。
- ほとんどのクエリは、データを消費するためではなく、ユーザーに見せるために作られているのでページネーションされている。分析目的だとこれはだるい
- タイムアウト: ほとんどのGraphQLプロバイダーは長時間のクエリ実行を望んでいないため、タイムアウトやレート制限を設けている。しかし、データドリブンなクライアントは長時間のクエリを望んでいるかもしれない。
なんにせよ、ユースケース駆動で設計されたAPIでデータ駆動なモチベを満たすのは難しいので、他のアプローチを考える必要がある。
例えば非同期なクエリとするとか
Imprementing GraphQL Servers
これまでは、GraphQLのスキーマ設計について主眼をおいて話してきた。ここからは実際にGraphQLサーバーがどう構築され実装されているかを見ていく。
GraphQL Server Basics
GraphQLサーバーの構築に必要なものは
- 型システムの定義
- 要求されたクエリを型システムに従って実行するためのランタイム実行エンジン
- クエリ文字列と変数を受け入れるHTTPサーバー
ユーザーは型システムと実行時の振る舞いを定義し、各言語のライブラリが実行アルゴリズムを含むGraphQLの仕様を実装するという役割が一般的。
GraphQLでは、特定のデータを満たすために使用される概念をリゾルバと呼ぶ。リゾルバは単なる関数である。
function resolver(parent, arguments, context) {
return parent.name;
}
リゾルバは1つのフィールドのデータを解決することを担当する。GraphQLはあるフィールドに到達すると、そのフィールドが特定のリゾルバを呼び出す。GraphQLのクエリの実行は深さ優先探索のような構造に似ている。
リゾルバは通常3,4この引数を取る。1つは親オブジェクト、これは親のリゾルバーから返されたオブジェクトである。2つ目はフィールドの引数である。3つ目はcontext
query {
user(id: "abc") {
name
} }
例えば上のようなクエリを考えると、まずユーザーリゾルバを呼び出し、ルートオブジェクト、id引数、グローバルコンテキストを渡す。次にユーザーリゾルバの結果を受け取り、ネームリゾルバを呼び出す。ネームリゾルバは第一引数としてユーザーオブジェクトを受け取る。