Relay入門シリーズも最後となりました。
この翻訳記事ではGraphQLのベストプラクティスの1つであるGlobal Object IdentificationとNode
インターフェースの解説になります。
Relayにとっては、fragmentと同じくあらゆる便利機能を生み出す源泉となっている仕組みです。ぜひご覧ください!
Relay入門シリーズ(全4記事) |
---|
Relay:あなたのために泥臭い仕事をしてくれるGraphQLクライアント |
GraphQLでのConnectionベースのページネーション |
Relayを使って最小限の努力でページネーションをする |
Nodeインターフェースの魔法(本記事) |
この連載は、Gabriel NordebornとSean Groveが執筆しています。GabrielはスウェーデンのITコンサルタント会社Arizonのフロントエンド開発者でありパートナーでもあり、長い間Relayを利用してきました。SeanはサードパーティのAPIをGraphQLで統一するOneGraph.comの共同創業者です。
GraphQLの公式サイトは最近、ベストプラクティス集に「Global Object Identification(グローバルなオブジェクトの識別)」というセクションを追加しました。この記事では、Global Object Identificationとは何か、なぜそれが有用なのか、そしてそれによってどのような種類の開発者体験が可能になるのかについて掘り下げていきます。
Global Object Identificationとは何か?
GraphQLのGlobal Object Identificationには2つの意味があります。
- グラフ内の個々のオブジェクトは、型を超えてグローバルに一意なIDで識別できます。つまり、異なる型であっても、2つのオブジェクトが同じIDを持つことはできません。
id
を持つ任意の1つのオブジェクトはそのid
だけでqueryできます。これは後ほど詳しく説明するNode
インターフェースを介して行われます。
これにより、ライブラリは開発者が自分で実装したり管理したりしなければならないいくつかのことを以下のように安全に自動化することができます。
- すべてのオブジェクトが一意のIDを持っているので、GraphQLフレームワークは任意のオブジェクトのキャッシュを自動的に更新することができます。
- オブジェクトの新しいフィールドや更新されたフィールドを取得する必要がある場合、私たちが知る必要があるのは、そのオブジェクトのGraphQLの型とその
id
だけです。
この2つのポイントがなぜ素晴らしい開発者体験を可能にするのかについて、詳しく説明していきましょう。
そもそもNode
インターフェースとは何か?
Node
インターフェースを実装しているスキーマでは、node
と呼ばれるQuery
オブジェクトのトップレベルのフィールドを見ることができます。node
はid
という単一の引数を取り、単一のフィールドid
を返します。
「何だそれ」と思いましたか? 初めて見ると確かに少し不思議な感じがしますよね!
query NodeInterfaceExample1 {
gitHub {
node(id: "MDQ6VXNlcjM1Mjk2") {
id # 予想通り、これは"MDQ6VXNlcjM1Mjk2"になります
}
}
}
node
はインターフェースなので、スキーマ内のすべてのGraphQLオブジェクトが実装しており、id
を介してオブジェクトを取得することができます。
query NodeInterfaceExample2 {
gitHub {
node(id: "MDQ6VXNlcjM1Mjk2") {
id
... on GitHubUser {
login # "sgrove"
followers {
totalCount # 9000人以上!!!!
}
}
}
}
}
なぜそれが役に立つのか?
これが意味するのは、あるオブジェクトのidとGraphQLの型を知っていれば、私たちはいつでもオブジェクトを取得する方法が分かっているということです。この代替案としては次のようなものができるかもしれません。
query WithoutInterfaceExample {
gitHub {
user(login: "sgrove") {
login
followers {
totalCount
}
}
}
}
しかしこのqueryには2つの課題があります。これらの課題は開発者にとっては余分な作業が多くなる、そつまり何かを間違えてしまう機会が増えることを意味しています。
- 上記の方法で
GitHubUser
を見つけようとしても、id
という引数は受け付けずにlogin
という引数のみを受け付けます。つまり、たとえばownerId
の値を既に持っていたとしても、login
の値を追加で知る必要があるということです。あらゆる種類のオブジェクトで異なるキーを使って探すことになる可能性があるため、ますます面倒なことになってしまいます。 - 仮に
login
の値を持っていたとしても、追加で必要なフィールドがあった時に、私たちはネストされた項目(この場合はgitHub.user
)を使って複雑になる可能性のあるqueryを構築しなければなりません2。
結局のところ、これらは両方とも暗黙的に分かっていることです。私たちはそれらを開発者として知っており、頭の中にその知識を持っています。それはつまり、コンピューターとツールはその知識を持っていないということです。
しかし、もし私たちのツールがid
を与えたオブジェクトを探す方法を知っていれば、それは素晴らしい方法で私たちを助けてくれるでしょう。
これはバックエンドの負荷を軽減することにもなります。queryの中で深くネストされているノードを再取得する必要がある場合は常に、バックエンドは対象のノードにたどり着くために、対象のノードより上のすべての階層を解決(データを取得)しなければなりません。しかし、id
を取得して単一のノードを直接解決することができれば、そのような負荷はなくなります。
なぜグローバルに一意なIDなのか? 整数で十分では?
Node
インターフェースを使えば、どんなオブジェクトでもid
で検索できるようになります。いいですね! ただ、これはAPI全体の中で2つのオブジェクトが同じidを持つことができないことを暗示しています。
余談:この仕組みは最初は難しいと思うかもしれませんが、どんなスキーマでも簡単にこれを行う方法があるとすぐに分かるようになります。
id
フィールド(User.id
やPost.id
など)を持つすべてのGraphQLオブジェクトは、他のGraphQLオブジェクトが持っていない一意のIDを持たなければなりません。次のqueryを例に考えてみましょう。
query FirstUserAndFirstPost {
user {
id
}
post {
id
}
}
多くのデータベースではインクリメントする整数をidとして使用するのが一般的なので、次のようなレスポンスを期待するかもしれません。
{
"data":{
"user":{
"id":1
}
"post":{
"id":1
}
}
}
しかしNode
インターフェースと組み合わせるとこれはうまくいきません。例えば、投稿(post)のidを持っていて、いくつかの追加フィールド(title
など)を取得したいとします。その場合、次のようなqueryを実行できるかと思います。
query NodeInterfaceExample3 {
node(id: 1) {
id
... on Post {
title
}
}
}
しかし、1
のid
で探すことができるオブジェクトが2つも存在しています! 戻り値のオブジェクトがUser
になるのかPost
になるのかは今のところ分かりません。
がっかりしないでください! 簡単な解決策があります!
内部的には整数をIDに使い続けつつ、グローバルに一意なIDを持つにはどうすればいいのでしょうか? GitHubで使われているIDの例を見てみましょう(APIは美しく、Relayとの互換性もあります!)。node(id: "MDQ6VXNlcjM1Mjk2")
というqueryを使ってアクセスできます。ここで使われているIDを見てみましょう。
> atob("MDQ6VXNlcjM1Mjk2")
"04:User35296"
なんと、整数が入ってますね3。
基本的にはnodeId
を構築するの手法は以下のようになります。
`base64Encode(${apiVersion}:${GraphQLObjectTypeName}${ObjectId})
よってAPIバージョン1のpost
とuser
については、次のようになると期待します。
"MDE6UG9zdDE=" # "01:Post1" にデコード
"MDE6VXNlcjE=" # "01:User1" にデコード
競合があった前のnode
の例に戻って、新しいIDを使用すると次のようになります。
query NodeInterfaceExample4 {
node(id: "MDE6UG9zdDE=") {
id
... on Post {
title
}
}
}
これでnode
のリゾルバはid
をbase64でデコードし、id
が1
のPost
オブジェクトを探していることが分かるため、正しいオブジェクトを返すことができるようになりました。
グローバルidにAPIのバージョンをprefixとしてつけるのは、必要になった時に既存のグローバルidを壊さずに後でエンコード方法を変更できるようにするためです。これは実質的には何もしなくても獲得できるちょっとした柔軟性です。 4
もしまだピンときてない場合(特に、なぜid
がそのオブジェクトのデータベース上のID以外のものになるのか理解できない場合)、GraphQLの型のid
フィールドを次のように考えると良いかもしれません。つまりGraphQLの型のidを見るときは、実際にはデータベースのテーブルではなくグラフのノードを見ているということです。このように考えると、GraphQL型のidをグローバルに一意にすることはより意味のあることです5。
これは何を可能にするのか?
最初に見たように、ほとんどのスキーマでは、トップレベルのnode
フィールドを使用していなくても、ほとんどのものは単一のアイテムを解決する(探す)何らかの方法をすでに持っています。ではなぜnode
フィールドが重要なのでしょうか? それは単に解決をより便利にするためでしょうか?
もちろんそれも役立ちます。しかし、真の力は標準化から生まれます。
考えてみれば、スキーマにpostById(id: ID!)
というトップレベルのフィールドがあって、それが実際にid
でPost
を解決していたとしても、スキーマの作成者以外にはそのフィールドが実際に何をしているのかを知る方法はありません。postById
の意味を解析して、かつ、実際に「単一のPost
を与えられたid
で探す」という意味であることを推論しなければ、外部ツールやフレームワークはその特定のフィールドが何をするのかを安全に仮定することができません。1つのPost
を解決するフィールドは、次のようにいくつでも持つことができます。postByDatabaseId(id: ID!): Post
やmostLikedPost(id: ID!): Post
、latestPost(id: ID!): Post
などなど。 こうなると外部ツールが単一のPost
を再取得する際にどのフィールドを使用するかを安全に仮定することができなくなります。
しかし、Node
インターフェースは公式のGraphQLのベストプラクティスであるため、フレームワークやツールはその上に構築することができ、それを実装する人は誰でも恩恵を受けることができます。そしてそれを使って構築できるものには、いくつかとてもクールなものがあります。Relayの具体的な例を見てみましょう。
ページネーションqueryの自動生成
Relayの最新版(今はRelayとのみ呼んでいますが、一般的にはRelay Modernとして知られています)では、Node
インターフェースを介して任意のGraphQLオブジェクトを再取得できるため、データの再取得やページネーションのためのqueryを自動的に生成することができます。これにより、非常に人間工学的な開発体験を実現できます。基本的にRelayでは次のようにします。
fragment UserFriendsList_user on User {
id
friends(first: $first, after: $after) {
edges {
node {
firstName
lastName
}
}
}
}
このようなfragmentがあったら、このためのページネーションqueryを下記のように自動的に生成します。
query UserFriendsListPaginationQuery($id: ID!, $first: Int!, $after: String) {
node(id: $id) {
... on User {
id
friends(first: $first, after: $after) {
edges {
node {
firstName
lastName
}
}
}
}
}
}
…なぜこんなことができるかと言うと、ページネーションで定義しているUserFriendsList_user
fragmentの型であるUser
がNode
インターフェースを実装していることを知っているため、id
を使って再取得できるということが分かっているからです。
ページネーションの対象となるUser
が最初のquery内でどこにいたかは関係ありません。Node
インターフェースがあれば、そのUser
のid
さえあればRelayは任意のUser
を再取得することができます。
Relayでのページページネーションについてはこちらの記事に書いているので参照してみてください。
典型的な「もっと見る」機能のためのqueryの自動生成
同様に、Relayを使えば定番の「もっと見る」機能6を簡単に構築することができます。Relayを使えば、以下のようにできます。
fragment ProfilePage_user on User {
id
name
avatarUrl
bio @include(if: $showMore)
}
…そしてRelayはこのフラグメントを再取得できるようにするqueryを生成しますが、そこでは$showMore
をtrue
に設定してbio
をフィールドに含めます。
「もっと見る」機能の構築は、基本的にはユーザが「もっと見る」ボタンを押したときにrefetch({showMore: true})
を実行するだけのシンプルなものになります。残りの処理はRelayが行います。これは、User
がquery中のどこにいるかに関係なく、Node
インターフェースでUser
のid
を使って再取得する方法を知っているためにできることです。
まとめ
グローバルに一意なIDとNode
インターフェースがなぜ良いアイデアなのか、理解していただけていたら幸いです。Relayはこの仕組みをうまく活用していますが、これは公式のGraphQLのベストプラクティスなので他のツールでもその上に構築することができます。
-
訳注:sgroveはこの記事の作者の名前、Sean Groveのハンドルネーム。
↩ -
訳注:後述されますが、idを使ってオブジェクトを取得できるのであればグラフから必要なところだけオブジェクトの取得ができるため、不要な上位階層を解決する(取得する)必要がなく、基本的にはネストが浅くなります。
↩ -
訳注:atob関数はBase64をデコードするための関数です。「githubで使うidをBase64でデコードしたら整数が入ってるように見えるんですけど?」ということです。 https://developer.mozilla.org/ja/docs/Web/API/WindowBase64/Base64\_encoding\_and\_decoding
↩ -
標準的な実装であるgraphql-relay-jsはidと型名のみで構成しています https://github.com/graphql/graphql-relay-js/blob/3bd6838d41808674b97d024210c11937881b5ffd/src/node/node.js#L90 ただ、idを必ず一意にしなければならない関係上、このidのエンコード方法には自由度が求められます。githubの例のようにAPIバージョンが違えば別の値が返るようにしたい、などです。そのため型名とidで構成すれば絶対に大丈夫とは(世界中のユースケースで)言い切れません。
↩ -
DB(一般的なRelational Database)の場合はテーブルに名前がついていて、その中でidがユニークになることが求められます。GraphQLでは様々な型が1つのグラフの中に入っているので、その中で1つをIDだけで指し示すためにはグラフ全体で一意のIDが必要になるということです。
↩ -
ここで言う「もっと見る」とは、ページネーションで残りのアイテムをロードすることを指すのではなく、文章の頭だけ表示されていてクリックすることで全体が読めるようになるようなUIのことを指しています。「詳細を見る」のような感じです。
↩