GraphQLスキーマ設計
参考
TL;DR
GitHub v4 API に倣う
Relay向けサーバ仕様へ準拠すると良い
GraphQLは、Relayを使いやすくするための仕様追加が行われている
✍ID設計
IDの形式の選択肢として
人間が識別可能なものにする("テーブル名:カラム名:P-KEY"のような形式)
符号化する(上記の"テーブル名:カラム名:P-KEY"をハッシュ化する、など)
があり、それぞれ一長一短ある。
なお、符号化には以下のようなメリットがある
1. ID の書式を自由に変更可能
2. 書式バージョンを埋め込み、過去の形式を判別し内部で変換可能にする
3. 書式にユーザが依存しないようにする
GitHubでは後者が採用されており、「特定の法則で羅列された文字列をBase64でエンコードしたもの」をIDとしている。
どちらを選択するにせよ、クライアント側でIDを生成できると圧倒的に楽とのこと。
✍命名規約
Query
Queryの名前 - リソースの名前をそのまま使う
リスト形式を返却する場合は、リソースを複数形にしたものを使う
Mutation
Mutationの名前 - "動詞 + 名詞"
GitHubではinsertという動詞は使われておらず、addやcreateが使われている
このことから分かるように、DBを意識せず、あくまでもドメインベースで考える
input object - "Mutationの名前" + Input
戻り値 - "Mutationの名前" + Payload
✍Global Object Identification
GraphQLクライアントがデータのキャッシュと再取得を可能にするための仕様。
具体的には interface Node { id: ID! }というinterfaceを定義し、Queryのrootに node(id: ID!) Nodeというフィールドを定義する。
code:graphql
interface Node {
id: ID!
}
type Query {
node(id: ID!): Node
}
type User implements Node {
id: ID!
name: String!
}
こうすることで、nodeクエリから全てのデータを取得することができる。
まず普通にUserからデータをクエリする。
code:graphql
user(mail: "example@mail.co.jp") {
__typename
id
name
}
"data": {
"user": {
"__typename": "User",
"id: "i3AB49V43NFRxA6j",
"name": "tanaka"
}
}
次に nodeクエリで先程取得したUserデータと同じものを取得する。
code:graphql
node(id: "i3AB49V43NFRxA6j") {
__typename
... on User {
id
name
}
}
"data": {
"node": {
"__typename": "User",
"id: "i3AB49V43NFRxA6j",
"name": "tanaka"
}
}
実際これが役に立つ場面としては、開発中ぐらいらしい。
Apolloに関しては __typename と idの組み合わせ、もしくはidで識別するので、Apolloを使う場合はこの限りではない。
✍Cursor Connections
ページングの仕様。
1. リスト型のフィールドの代わりに、Connection サフィックスの型を作る
2. first: Int!と after: String を引数に設ける
3. Connection は cursor を持てる Edge と、ページング情報をもつ PageInfo をもつ
具体的な型の配列を返す代わりにEdgeという抽象化した型の配列を、Connection型でラップ返している。(DDDでいうリストオブジェクトに近いかも)
ConnectionにはEdgeの配列以外に、ページネーションを実現するための情報であるPageInfoも持っている。
例を以下に記載
code: graphql
type Query {
repositories(
# 取得件数
first: Int,
# 起点となるカーソル(指定されたカーソルは含まない)
after: String
): RepositoryConnection!
}
type RepositoryConnection {
# エッジの配列
# ページネーションのための情報
pageInfo: PageInfo!
}
type RepositoryEdge {
# データ本体
node: Repository!
# このエッジを識別するためのカーソル
cursor: String!
}
type PageInfo {
# 次ページがあるか
hasNextPage: Boolean!
# 前ページがあるか
hasPreviousPage: Boolean!
# 前方向へのカーソル(厳密にはConecction.edgesの先頭Edgeがもつカーソル)
startCursor: String
# 次方向へのカーソル(厳密にはConecction.edgesの末尾Edgeがもつカーソル)
endCursor: String
}
type Repository implements Node {
id: ID!
name: String!
}
発行するクエリはこんな感じ
code:graphql
repositories(first: 10) {
edges: {
cursor
node: {
id
name
}
}
pageInfo: {
hasNextPage
hasPreviousPage
startCursor
endCursor
}
}
first: 10は10件取得する指定を行っている。
afterは指定がないので、「先頭から10件取得してくれ」の意味になる。
クエリの実行結果例
code:json
{
"data": {
"repositories": {
"edges": [
{ cursor: "cursor:1", node: { id: "repo:1", name: "repository name" },
...
{ cursor: "cursor:10", node: { id: "repo:10", name: "repository name" },
],
"pageInfo": {
"hasNextPage" : true,
"hasPreviousPage": false,
"startCursor" : "cursor:1",
"endCursor" : "cursor:10"
}
}
}
}
edgesフィールドにはEdgeでラップされたRepositoryが格納されている。
hasNextPageがtrueなので、次ページを要求することができる。
次ページを要求する場合は repositoriesクエリの引数afterに、endCursorの値を指定すればよい。
code: graphql
repositories(first: 10, after: "cursor:10") {
✍Input Object Mutations
Mutationのinput objectにclientMutationId: Stringを持たせて、レスポンスにも同様の値を含めるというもの。リクエストとレスポンスの紐付けを容易にするためらしい。
✍スキーマ定義を複数のファイルに分割する
スキーマ定義を1ファイルに納めていくのは、メンテナンス性や可読性の観点から見ても良くない。
GraphQLには、extendsというスキーマを分割して定義する仕組みが備わっている。
code:schema.graphql
interface Node {
id: ID!
}
type Query {
...
}
type Mutation {
...
}
code:repository.graphql
extends type Query {
repository(first: Int, after: String): Repository
}
extends type Mutation {
createRepository(input: CreateRepositoryInput): CreateRepositoryPayload!
}
type Repository implements Node {
id: ID!
name: String!
}
✍既存のDBやREST APIの仕様に引きずられない
「GraphQLらしいか」で判断すること。
RDBのスキーマとGraphQLのスキーマは類似させても良いが、完全一致させる必要性は無い。
REST APIのIFに引きずられるとresolverの実装がカオスになることもあるらしいので、スキーマファーストで。