一覧へ

Nodeインターフェースの魔法

Relay入門シリーズも最後となりました。 この翻訳記事ではGraphQLのベストプラクティスの1つであるGlobal Object IdentificationとNodeインターフェースの解説になります。 Relayにとっては、fragmentと同じくあらゆる便利機能を生み出す源泉となっている仕組みです。ぜひご覧ください!

(原文)


Relay入門シリーズ(全4記事)
Relay:あなたのために泥臭い仕事をしてくれるGraphQLクライアント
GraphQLでのConnectionベースのページネーション
Relayを使って最小限の努力でページネーションをする
Nodeインターフェースの魔法(本記事)

この連載は、Gabriel NordebornSean 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オブジェクトのトップレベルのフィールドを見ることができます。nodeidという単一の引数を取り、単一のフィールド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人以上!!!!
        }
      }
    }
  }
}

1

なぜそれが役に立つのか?

これが意味するのは、あるオブジェクトのidGraphQLの型を知っていれば、私たちはいつでもオブジェクトを取得する方法が分かっているということです。この代替案としては次のようなものができるかもしれません。

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.idPost.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
    }
  }
}

しかし、1idで探すことができるオブジェクトが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のpostuserについては、次のようになると期待します。

"MDE6UG9zdDE=" # "01:Post1" にデコード
"MDE6VXNlcjE=" # "01:User1" にデコード

競合があった前のnodeの例に戻って、新しいIDを使用すると次のようになります。

query NodeInterfaceExample4 {
  node(id: "MDE6UG9zdDE=") {
    id
    ... on Post {
      title
    }
  }
}

これでnodeのリゾルバはidをbase64でデコードし、id1Postオブジェクトを探していることが分かるため、正しいオブジェクトを返すことができるようになりました。

グローバルidにAPIのバージョンをprefixとしてつけるのは、必要になった時に既存のグローバルidを壊さずに後でエンコード方法を変更できるようにするためです。これは実質的には何もしなくても獲得できるちょっとした柔軟性です。 4

もしまだピンときてない場合(特に、なぜidがそのオブジェクトのデータベース上のID以外のものになるのか理解できない場合)、GraphQLの型のidフィールドを次のように考えると良いかもしれません。つまりGraphQLの型のidを見るときは、実際にはデータベースのテーブルではなくグラフのノードを見ているということです。このように考えると、GraphQL型のidをグローバルに一意にすることはより意味のあることです5

これは何を可能にするのか?

最初に見たように、ほとんどのスキーマでは、トップレベルのnodeフィールドを使用していなくても、ほとんどのものは単一のアイテムを解決する(探す)何らかの方法をすでに持っています。ではなぜnodeフィールドが重要なのでしょうか? それは単に解決をより便利にするためでしょうか?

もちろんそれも役立ちます。しかし、真の力は標準化から生まれます。

考えてみれば、スキーマにpostById(id: ID!)というトップレベルのフィールドがあって、それが実際にidPostを解決していたとしても、スキーマの作成者以外にはそのフィールドが実際に何をしているのかを知る方法はありません。postByIdの意味を解析して、かつ、実際に「単一のPostを与えられたidで探す」という意味であることを推論しなければ、外部ツールやフレームワークはその特定のフィールドが何をするのかを安全に仮定することができません。1つのPostを解決するフィールドは、次のようにいくつでも持つことができます。postByDatabaseId(id: ID!): PostmostLikedPost(id: ID!): PostlatestPost(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_userfragmentの型であるUserNodeインターフェースを実装していることを知っているため、idを使って再取得できるということが分かっているからです。

ページネーションの対象となるUserが最初のquery内でどこにいたかは関係ありません。Nodeインターフェースがあれば、そのUseridさえあればRelayは任意のUserを再取得することができます。

Relayでのページページネーションについてはこちらの記事に書いているので参照してみてください。

典型的な「もっと見る」機能のためのqueryの自動生成

同様に、Relayを使えば定番の「もっと見る」機能6を簡単に構築することができます。Relayを使えば、以下のようにできます。

fragment ProfilePage_user on User {
  id
  name
  avatarUrl
  bio @include(if: $showMore)
}

…そしてRelayはこのフラグメントを再取得できるようにするqueryを生成しますが、そこでは$showMoretrueに設定してbioをフィールドに含めます。

「もっと見る」機能の構築は、基本的にはユーザが「もっと見る」ボタンを押したときにrefetch({showMore: true})を実行するだけのシンプルなものになります。残りの処理はRelayが行います。これは、Userがquery中のどこにいるかに関係なく、NodeインターフェースでUseridを使って再取得する方法を知っているためにできることです。

まとめ

グローバルに一意なIDとNodeインターフェースがなぜ良いアイデアなのか、理解していただけていたら幸いです。Relayはこの仕組みをうまく活用していますが、これは公式のGraphQLのベストプラクティスなので他のツールでもその上に構築することができます。


  1. 訳注:sgroveはこの記事の作者の名前、Sean Groveのハンドルネーム。

  2. 訳注:後述されますが、idを使ってオブジェクトを取得できるのであればグラフから必要なところだけオブジェクトの取得ができるため、不要な上位階層を解決する(取得する)必要がなく、基本的にはネストが浅くなります。

  3. 訳注:atob関数はBase64をデコードするための関数です。「githubで使うidをBase64でデコードしたら整数が入ってるように見えるんですけど?」ということです。 https://developer.mozilla.org/ja/docs/Web/API/WindowBase64/Base64\_encoding\_and\_decoding

  4. 標準的な実装であるgraphql-relay-jsはidと型名のみで構成しています https://github.com/graphql/graphql-relay-js/blob/3bd6838d41808674b97d024210c11937881b5ffd/src/node/node.js#L90 ただ、idを必ず一意にしなければならない関係上、このidのエンコード方法には自由度が求められます。githubの例のようにAPIバージョンが違えば別の値が返るようにしたい、などです。そのため型名とidで構成すれば絶対に大丈夫とは(世界中のユースケースで)言い切れません。

  5. DB(一般的なRelational Database)の場合はテーブルに名前がついていて、その中でidがユニークになることが求められます。GraphQLでは様々な型が1つのグラフの中に入っているので、その中で1つをIDだけで指し示すためにはグラフ全体で一意のIDが必要になるということです。

  6. ここで言う「もっと見る」とは、ページネーションで残りのアイテムをロードすることを指すのではなく、文章の頭だけ表示されていてクリックすることで全体が読めるようになるようなUIのことを指しています。「詳細を見る」のような感じです。