はじめに
この記事が、GraphQL はじめてみたいけど、スキーマ設計とかクライアントライブラリとか、色々な情報が溢れていてわからないといった初学者向けに、まずはこれで書いてみよう!と迷わず実際に触るための一助になれば嬉しいです。
スキーマファーストとコードファースト
GraphQLを使ったAPIの開発では、2つの進め方があります。
GraphQLってなにー??な状態の方はまずこちらの記事の前半部分をどうぞ。
進め方1つ目のスキーマファーストでは、まずGraphQLスキーマ(型定義)をサーバーチーム、フロントチームが一緒に話して作成します。作成したスキーマの設計を基盤として、両チームが実装を並行して進めていくことができます。
2つ目のコードファーストは、GraphQLスキーマ(型定義)を、主にサーバーチームが作成します。まずサーバーのコードを書き、そこから自動的にスキーマが生成されます。
NestJSを例にすると、デコレーター(@Resolver
、@Query
など)を使ってリゾルバ関数を定義し、TypeScriptの型情報からスキーマを生成します。フロントチームはこのスキーマを元に実装を進めていきます。(参考:GraphQL + TypeScript | NestJS)
この記事では、1つ目のスキーマファーストに初めて取り組む方を対象に、GraphQLスキーマを書く基本的なステップをまとめます。
スキーマファイルの作成は、バックエンドとフロントエンドで一緒に進める協働作業です。
スキーマ設計は一緒に議論しながら進められる環境がベストです。
ステップ1:扱うリソースの定義
今回はTODOアプリを作る例で進めます。
扱うリソースは、 Todo, Userと考えて、スキーマ定義は下記のようにします。
type User {
id: ID!
name: String!
}
type Todo {
id: ID!
description: String
ExpireDate: Timestamp
user: User ←ココでUserリソースのグラフ構造を定義
}
ここでのポイントは、id参照している箇所(リレーションのFKを持っているリソースなど)は、idだけではなく、参照先のリソースをグラフ構造で持つことです。
このようにすることで、実際にQueryを使用するとき、グラフ構造を使って1度のリクエストで、複数のリソースを一気に取得することができます。
クライアント側の例
import gql from "graphql-tag"
gql`
flagment todoFields on Todo {
id
description
}
;`
const query = gql`
query {
findTodoByUserId(id: 1) {
...todoFields
user {
name
}
}
}
;`
ステップ2:GraphQL APIの定義 Query編
では、先ほど上げたQueryを例にAPIの定義編に入ります。
今回は特定のユーザーに紐づくTODOを全て取得するAPIを定義してみます。
type Query {
findTodoByUserId(id: ID!): [Todo]!
}
今回は全件取得なので複数のデータが返る想定で、Todoの配列を返す定義にしています。
もしTodoが必ず1つは存在するようなデータなら、[Todo!]!
として配列と、その中身も必須の型にするとよいでしょう。
一方で、今回のような空データもあり得る場合に、[Todo]
としてしまうと、レスポンス結果がnullであることを許容してしまいます。フロント側からすると、返る可能性のある型がnull
, []
, [Todo, Todo]
となってしまい、不便である場合が多いです。
そのため、まずは0件があり得る複数データの場合、必ず空配列が返る[Todo]!としておくと良いと思います。
ステップ3:GraphQL APIの定義 Mutation編
スキーマ定義の最後のステップです。
TODOを作成するMutationを定義します。
type Mutation {
createTodo(input: CreateTodoInput!): CreateTodoPayload!
}
input CreateTodoInput {
user_id: ID!
description: String!
}
type CreateTodoPayload {
todo: Todo
errors: [CrateTodoError]!
}
type CreateTodoError {
message: String!
}
上記の例で特に始めのうち、守るべきところはMutationの返り値に専用の型を用意しているところです。
このようにしておけば、返したい値の追加も簡単に行うことができます。
また、先程の例ではQueryの返り値に直接リソースの型を定義していましたが、もしtotalなど別の値も返したい場合は、Payloadを定義すると良いでしょう。
業務エラーはGraphQLレスポンスのerrorsではなく、Payloadに定義したerrorsで返すようにしましょう!
詳しい理由が知りたい方は下記エラーハンドリングの記事と、ステップ5で紹介している記事に目を通してみてください。
Payloadのerrorsキーの型CreateTodoErrorはベースのinterface型を定義しておいて、それだけを使うでもいいし、個別のtypeに継承させてより厳密に定めてもいいです。
GraphQLのエラーハンドリングに関する参考記事
GraphQLにおけるエラーハンドリングの選択肢と検討
GraphQLにおけるエラーハンドリングの仕方 - ZOZO TECH BLOG
クライアント側の例
const mutation = gql`
mutation {
createTodo(input: CreateTodoInput!) {
todo {
...todoFields
}
errors {
... on CrateTodoError {
message
}
}
}
}
;`
これで一通りのGraphQLスキーマが完成しました!
スキーマファイルさえ完成してしまえば、サーバーサイドとフロントサイドの関心事はスキーマファイルを起点に分離されるため、お互いが後は煮るなり焼くなり自由に開発を進めることができます。
ですが、本当にGraphQLを活かすためにはまだもう少しステップもあります。
今回はとっつきやすい参考記事のみ紹介できればと思います。
ステップ3.5:その他APIの定義
複雑な業務ロジック、エラーハンドリング、キャッシュ制御など、初めてのGraphQLスキーマ設計には様々な困難が待ち受けています。好き放題やれば簡単に破綻します。。。
しかし常に、設計原則に立ち返りつつ定義していけばきっと大丈夫です。
- GraphQLスキーマ設計の勘所 - Speaker Deck
- 快適にスキーマ駆動開発をするためのGraphQLエラー設計 - バイセル Tech Blog
- GraphQLのFragmentについての話
ステップ4:フロントサイドのGraphQLクライアントを検討しよう
GraphQLは、ただFetchAPIでリクエストすることもできます。
しかし、一般的には何かしらのクライアントライブラリを用いることが多いと思います。
graphql-requestやApolloClientが有名ですね。
まずはgraphql-requestを使って、ステップ5のコードの自動生成ができるところまで検討&実際に触ってみることがいいと思います。
少しイメージが湧いてきたら、下記のような記事で、他の選択肢を検討してみましょう。
- プロダクトのタイプ別 GraphQL クライアントの選び方 - Speaker Deck
- あなたのプロダクトに Apollo Client は必要ないかもしれない - 一休.com Developers Blog
ちなみに、、、今回提案した、とにかく書いてみるスキーマは、Apollo Clientのキャッシュ機能に最低限準拠した書き方ですので、ApolloClientを採用する場合は、CachePolicyだけ知っておけば早速キャッシュを体験することができます。
ステップ5:GraphQLスキーマを元に型を生成
GoやTypeScriptなど、静的型付け言語を用いる場合、サーバーサイド、フロントサイド共に、やり取りする型をスキーマから自動生成することができます。
- GraphQL Code Generator で TypeScript の型を自動生成する - クックパッド開発者ブログ
- ent + gqlgenによる爆速GraphQLバックエンド開発 #Go - Qiita
型の自動生成は、スキーマファーストで開発を行うなら必ずやりたいです。
また、型情報だけでなく、リゾルバのコード生成やORMとの連携など、スキーマを中心にした自動生成はぜひ検討してみましょう。
さいごに
ここまで来れば、あなたも立派なGraphQLユーザーです!
GraphQLは関心事の分離のみならず、自動生成やキャッシュ、Fragment Colocationなど、様々なメリットが存在していますので、フロントエンドエンジニア、バックエンドエンジニアそれぞれの領域でぜひ活用してみてください。
付録
今回書き上げたスキーマファイルは最終的にこのような状態です。
type Query {
findTodoByUserId(id: ID!): [Todo]!
}
type Mutation {
createTodo(input: CreateTodoInput!): CreateTodoPayload!
}
type User {
id: ID!
name: String!
}
type Todo {
id: ID!
description: String
ExpireDate: Timestamp
user: User
}
input CreateTodoInput {
user_id: ID!
description: String!
}
type CreateTodoPayload {
todo: Todo
errors: [CrateTodoError]!
}
type CreateTodoError {
message: String!
}