cockscomblog?

cockscomb on hatena blog

Relayに学ぶGraphQLのスキーマ設計

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!
}

組み合わせ

例えば Userfriends(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!
}

このとき、TweetNode なので、GraphQLクライアントは id の値をキーとしてキャッシュをノーマライズする。Mutationとして like(tweetID: ID!): Tweet! が用意されていれば、これの戻り値を使ってキャッシュを更新できるため、「いいね」状態の一貫性が維持される。

要するに、特に Node の更新については、戻り値は Node そのものであればよい。

作成

作成されたオブジェクトが一覧にも表示される場合、取得されたConnectionのキャッシュにも要素を付け加える必要がある。

大まかには、戻り値が Node であればよい。RelayのConnectionを利用している場合、戻り値をEdgeにしてもよい。戻り値を使ってConnectionのキャッシュを書き換える必要がある。

Relayの場合、@appendEdge / @prependEdge もしくは @appendNode / @prependNode といったディレクティブが用意されており、Connectionの前後に要素を付け加えるのが少し簡単になっている。

削除

オブジェクトを削除する場合も、やはり一覧での表示から取り除く必要がある。

Connectionから取り除くには、Nodeid さえわかっていればいい。従って戻り値は ID! で十分である。

Relayでは @deleteEdge ディレクティブが用意されている。

まとめると

GraphQLのスキーマを設計するとき、とりあえず従っておくべき指針をRelayから得た。

まずはRelayのGraphQL Server Specificationに従っておく。NodeとConnectionを実装する。

そして直接的に取得可能な、type Query のフィールドやNode、Viewerパターンを意識する。

Mutationの戻り値は、作成や更新ならNode、削除はIDが基本になる。

例外はいくらでもあると思うが、最初はこれくらいから考えると概ね妥当と思う。