2018年の初めくらいから、仕事でGraphQL APIを何度も作っている。サーバーサイドもクライアントサイドも実装している。
最近クライアント側にRelayを使ってみている。
GraphQLのクライアントとしてはApolloを使う場合が多いと思うが、Facebook製のRelayもかなりよくできている。以前はTypeScriptに対応していなかったが、今はTypeScriptも使える。最近のバージョンではhooksのAPIがexperimentalではなくなり、ReactのSuspense API(Suspense for Data Fetchingは使わずに)と合わせて使える。
RelayはGraphQLのスキーマに制約を設けることで、クライアント側のAPIがデータの再取得やページネーションなどを抽象化している。換言すると、Relayからデータの再取得やページネーションに必要なスキーマ上の制約を学べる、ということだ。
ということで、スキーマの設計について抽象的な理解を得たので、それを記す。
RelayのGraphQL Server Specificationに倣う
GraphQL Clientとしてrelayを利用することもそうでないこともあるが、いずれにしても、これに倣っておくとうまくいくことが多い。
RelayのGraphQL Server Specificationは、再取得とページネーションのための仕様である。
Node
id
という ID!
型のフィールドを持った Node
インターフェースを定義する。
interface Node {
id: ID!
}
ID
はグローバルに(型に関わらず)ユニークな識別子。内部的には base64(type + ":" + internal_identifier)
のような実装にすることが多い。クライアントサイドでは透過的な値として扱い、パースを試みるべきではない。
トップレベルのクエリに node(id: ID!): Node
を持つ。Node
インターフェースに準拠する型は、このトップレベルクエリから取得できるようにする。
type Query {
node(id: ID!): Node
}
このようにしておくと、Node
はクライアントから便利に扱える。キャッシュのノーマライズもできるし、再取得も簡単である。
Connection
Connectionはページングに関連している。要するに、ページネーションを行うフィールドで Connection
というのを返したりすると、カーソルベースのページネーションができる。
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String
endCursor: String
}
type User implements Node {
id: ID!
}
type UserEdge {
node: User!
cursor: String!
}
type UserConnection {
edges: [UserEdge!]!
pageInfo: PageInfo!
}
type Query {
users(first: Int, after: String, last: Int, before: String): UserConnection!
}
組み合わせ
例えば User
に friends(first: Int!, after: String!): UserConnection!
というページネーションできるフィールドがあるとき、2ページ目を取得するには User
から指定しなければならない。
type User implements Node {
id: ID!
friends(first: Int!, after: String!): UserConnection!
}
query {
node(id: "xxx") {
... on User {
friends(first: 10, after: "yyy") {
...
}
}
}
}
このようにネストしたフィールドでのページネーションでは、目的のフィールドまでのパスが一意に定まる必要がある。
Viewerパターン
サービスにもよるが、GraphQL APIを認証情報付きで呼び出している利用者を、type Viewer
として表し、トップレベルから viewer
フィールドで取得できるようにする。
type Viewer {
name: String!
}
type Query {
viewer: Viewer
}
このようなフィールドがあると、例えばログインしている利用者の名前を表示する際に便利なショートカットになる。従って利用者自身に紐づくリソースは Viewer
に持たせるとよい。
Viewer 以外にも Visitor のような語彙でもよさそうではあるが、統一されている方が便利なので、特別な理由がなければ合わせるとよい。また、Relayではこのようなフィールドを Viewer に決め打ちして特別扱いしている。
取得可能性
type Query
のフィールド、Node、そしてViewerパターンでは、いずれも目的のオブジェクトを直接取得可能である。使い分けは、グローバルにユニークなオブジェクトなら type Query
のフィールド、そうでなければNode、そして現在の利用者を指すショートカットとしてViewerパターン、となるだろう。
Relayではもうひとつ、@fetchable
ディレクティブと fetch__Xxx
というトップレベルのクエリを組み合わせることができる。
directive @fetchable(field_name: String!) on OBJECT
type User @fetchable(field_name: "id") {
id: ID
}
type Query {
fetch__User(id: ID!): User
}
見ての通り、ほとんどNodeと同じである。
合わせて4つが、直接取得可能なフィールドということになる。Relay Compiler(Rustで書かれている)でも、これらが特別扱いされている。
GraphQLで扱うオブジェクトは、なるべく直接取得ができるように設計されていると、取り回しがよい。例えばパーマリンクとして扱うには直接取得が可能でなければならないだろう。
Mutationの戻り値
Mutationでオブジェクトを作成、更新、もしくは削除したときの戻り値をどうするか。
更新
GraphQLクライアントは普通、内部にノーマライズされたキャッシュを保持している。
例えばTwitterを作るとして、ツイート一覧画面について考える。一覧でツイートを選択して、個別のツイートの画面で「いいね」する。その後ツイート一覧に戻ってきたとき、一覧の中でも該当のツイートが「いいね」状態になっていてほしい。
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String
endCursor: String
}
type Tweet implements Node {
id: ID!
text: String!
likeCount: Int!
viewerLiked: Boolean
}
type TweetEdge {
node: Tweet!
cursor: String!
}
type TweetConnection {
edges: [TweetEdge!]!
pageInfo: PageInfo!
}
type Query {
node(id: ID!): Node
timeline(first: Int!, after: String): TweetConnection!
}
このとき、Tweet
が Node
なので、GraphQLクライアントは id
の値をキーとしてキャッシュをノーマライズする。Mutationとして like(tweetID: ID!): Tweet!
が用意されていれば、これの戻り値を使ってキャッシュを更新できるため、「いいね」状態の一貫性が維持される。
要するに、特に Node
の更新については、戻り値は Node
そのものであればよい。
作成
作成されたオブジェクトが一覧にも表示される場合、取得されたConnectionのキャッシュにも要素を付け加える必要がある。
大まかには、戻り値が Node
であればよい。RelayのConnectionを利用している場合、戻り値をEdgeにしてもよい。戻り値を使ってConnectionのキャッシュを書き換える必要がある。
Relayの場合、@appendEdge
/ @prependEdge
もしくは @appendNode
/ @prependNode
といったディレクティブが用意されており、Connectionの前後に要素を付け加えるのが少し簡単になっている。
削除
オブジェクトを削除する場合も、やはり一覧での表示から取り除く必要がある。
Connectionから取り除くには、Node
の id
さえわかっていればいい。従って戻り値は ID!
で十分である。
Relayでは @deleteEdge
ディレクティブが用意されている。
まとめると
GraphQLのスキーマを設計するとき、とりあえず従っておくべき指針をRelayから得た。
まずはRelayのGraphQL Server Specificationに従っておく。NodeとConnectionを実装する。
そして直接的に取得可能な、type Query
のフィールドやNode、Viewerパターンを意識する。
Mutationの戻り値は、作成や更新ならNode、削除はIDが基本になる。
例外はいくらでもあると思うが、最初はこれくらいから考えると概ね妥当と思う。