JavaScript
Facebook
flux
reactjs
Relay

Facebook Relayについてまとめ

More than 3 years have passed since last update.

Reactがもっと広まって欲しいと思っている今日このごろ。React EuropeでJoseph Savona氏の講演Relayについての「モヤっと」がいっきにかなり解消された気がするので、要点を本編を翻訳しながら自分なりにまとめておきます。 私の理解が誤っている可能性は十二分にありえるので、ご指摘いただければ幸いです。

はじめに

ReactとFluxって組み合わせと共によく目にするのが↓の図。
スクリーンショット 2015-08-14 16.55.39.png

矢印は一方向にしか進まないのが特徴で、わかりやすいってのがいろんなところで書かれているんですけど、 結局データをサーバからとってくるところってどうなってるの?ってのが疑問として残ります。つまり、図で表現すると↓の部分の仕組みがどうなっているかってところです。

スクリーンショット 2015-08-14 21.01.33.png

その部分を、Instagramのようなサービスを例に説明しています。

クライアントはどのようにしてサーバからデータを取得すべきか

まず、Instagramのようなサービスでフィードを例にとった場合、かなりシンプルに考えた場合でこれは3つのコンポーネント(Feed, Media, Comment)で構成できることがわかります。

スクリーンショット 2015-08-14 21.03.07.png

たった3つのコンポーネントでInstagramっぽいサービスが作れる っていうのはやたらと簡単に聞こえるんですけど、この構成でデータってどうやって取ってくるの?ってとこが課題です。 よくある話を例にいろんな案を見てみます。

A案:再利用可能なエンドポイント

なるべく再利用可能なエンドポイントを用意するというやり方で、

  • /feed 各ユーザのFeedをとってくるエンドポイント
  • /photo/:id 画像を取得する為のエンドポイント
  • /comment/:id コメントを取得する為のエンドポイント

のようにエンドポイントを用意するというもの。開発者からすると作りやすい手法なんですけど、サーバへのリクエストも多ければ、エントリが読み込まれる順番が↓のように順不同になるようなUXを招いてしまうこともしばしば。

スクリーンショット 2015-08-14 21.50.45.png

B案:オールインワンなカスタムエンドポイント

それだったらオールインワンで情報提供してくれるエンドポイント用意したらいいじゃんってことで

/feed_with_photos_and_comments

ってのを提供してみます。そうした場合のUXがこちら↓
スクリーンショット 2015-08-14 21.57.14.png

全データ取得するまでLoading...が表示されて、取得後にいっきに全部表示されるっていう、今どき考えられないようなUXですね。レスポンシブではない!

C案:色んなカスタムエンドポイント

最終的によく取られる手法が複数のいろんなカスタムエンドポイント。

  • /feedというエンドポイントを用意
  • /feedMedia Componentに必要な情報を提供
  • /feedはクエリパラメータで/feed?count=1&offset=1のようにどれだけのMedia情報が必要か指定できる
  • ↑の機能があるおかげで、全てのMediaをいっきに取得するのではなく、別々にAPIを叩いてMediaを取得可能

この機能を実現することで↓のような いい感じな(順にMediaが読み込まれるような) UXが実現できます。
スクリーンショット 2015-08-14 22.05.15.png

UXもいい感じだしこれで解決かと思いきや、この手法で実装した場合のトレードオフももちろん存在します。(こういうサービス作ったことある人なら全部 あるある な課題)

  • クライアントの状態管理
  • Mediaを順番通りに読み込みたかったらどうする?
  • スクロール中に新しいMediaが生成されたらoffsetはどうなる?ページネーションもどうなる?
  • エラー時処理どうする?(タイムアウトとか)

デザイン時の課題

デザイン時のあるある課題。Commentコンポーネントを例に考えてみます。コメントの投稿者とコメントの内容を表示している簡単なコンポーネントです。

Comment.js
class Comment extends React.Component {
  render() {
    var {comment} = this.props;
    return (
      <View>
        <Text>{comment.author.name}</Text>
        <Text>{comment.text}</Text>
      </View>
    );
  }
}

ここに投稿者の画像も表示しよう!ってなった場合にどうなるかって言うと

Comment.js
class Comment extends React.Component {
  render() {
    var {comment} = this.props;
    return (
      <View>
        <Image uri={comment.author.photo} />
        <Text>{comment.author.name}</Text>
        <Text>{comment.text}</Text>
      </View>
    );
  }
}

1行増やすだけ!完了!
ってのはこのCommentコンポーネントの観点だけでいくと確かにそうなんですけど、その画像をどうやってサーバから取得するのかっていうところとか考え始めると、結局影響範囲ってのは計り知れなかったりします。(必要な以上にいろんなファイルをいじらないといけなくなる)

データ書き込み時の課題

データ読み込み以上に複雑なのがデータ書き込み。どんなことを考えないといけないかというと、

  • あるユーザがコメントを2つ生成した場合で、2つ目のコメントがサーバに先に到着した場合はどうする?
  • あるいはそのレスポンスが順不同で返ってきたらどうする?
  • コメントを2つ生成して、一つ目のコメントの書き込みが失敗したらどうする?
  • サーバへのリクエストが単純に遅い場合はどうする?ペンディングしているのか失敗しているのかっていうのをユーザにどう伝える?
  • 複数のユーザが同時にMediaに対してコメントした場合、そのコメントを正しい順番で表示するためにはどうする?

などなど、いろいろな課題が存在します。

D案:上以外の全く新しい方法

上のそれぞれの案を見てもわかるように、本筋とは違うところで開発者はいろんなことを考えないといけないです(いや、本当にそう思います)。それに加え、単純な変更をしようにも新しく入ったメンバーは影響範囲がどうなるかわからないのでラーニングコストが極めて高くなりがちです。コンポーネントが使う情報をサーバから宣言的(declaritive)に取得できる方法はないのか???

そこでfacebookが作ったのがRelayです。

Relay

RelayGraphQLを使っています。まず、Commentコンポーネントで使うデータをGraphQLで書くと↓のようになります。

// GraphQL
Comment {
  text,
  author {
    name,
    photo
  }
}

// ↑によってサーバから取得できるJSONの例↓
{
  text: "Excited for React Europe!",
  author: {
    name: "Joe",
    photo: "https://..."
  }
}

これをRelayCommentコンポーネントを組み合わせた場合にどのような実装になるかというと、↓みたいになります。

Comment.js
class Comment extends React.Component {...} // 中身は省略

module.exports = Relay.createContainer(Comment, {
  queries: {
    comment: graphql`
      Comment {
        text,
        author {
          name,
          photo
        }
      }
    `
  }
});

今までReactで作ったコンポーネントは そのまま使えますRelaycreateContainerの第一引数にReactのコンポーネントを受け付けて、そのコンポーネントに必要なpropsをサーバから取得できた情報を基に渡してくれる役目を果たしてくれるのです。

概要(単一コンポーネント)

通常、Reactのコンポーネントは自身が使う情報をpropsという形で受け付けます。このpropsコンポーネントを使う側が、適切な情報を渡す責任があります

スクリーンショット 2015-08-14 22.51.51.png

使う側が情報を取得してこないといけない(ajaxやらでとってきてからpropsとして渡してあげにといけない)ので、これが意外とめんどくさかったりするんですけど、Relayは、Reactのコンポーネントをラップして、サーバから必要な情報を取得した後にコンポーネントにpropsとして渡してくれる役割を果たしてくれます。

スクリーンショット 2015-08-14 22.58.58.png

つまりRelayは例えばコメントのidpropsとして受けとって、そのidをもとにサーバにリクエストを投げて、その結果をCommentコンポーネントのpropsとして渡してくれるんです。

関数型な考え方で表現すると、通常のReactコンポーネントは

UI = view(props)

という式で表現できるのに対して、Relayを使った場合は

UI = view(query(props))

という式で表現することができます。

概要(複数のコンポーネント)

Reactを使っていくと、コンポーネント基本的に様々なコンポーネントで構成されています(コンポジション)。例えばMediaコンポーネントにはCommentコンポーネントが含まれています。Mediaコンポーネントのコードは、↓のように書けます。

Media.js
class Media extends React.Component {
  render() {
    var {media} = this.props;
    return (
      <View>
        <Header user={media.author} />
        <Image uri={media.uri} />
        <View>
          {media.comments.map(comment =>
            <Comment comment={comment} />
          )}
        </View>
      </View>
    );
  }
}

これにRelayを適用すると↓のようになります。

Media.js
class Media extends React.Component {...} // 省略

module.exports = Relay.createContainer(Media, {
  queries: {
    media: graphql`
      Media {
        comments(first: 10) {
          edges {
            node {
              ${Comment.getQuery('comment')}
            }
          }
        }
      }
    `
  }
});

Relayで包むことで以下のことが起きます

  1. エンドポイントからMediaを取得
  2. その際、そのMediaにくっついてる最初の10個のcommentを取得
  3. 結果をMediaコンポーネントのpropsとして渡す

GraphQLの構文についてはこのエントリで説明しません。全く知らない人は、とりあえず「こんなJSONがほしいの!」ってクエリを投げたらその形のJSONがサーバから返ってくるっていうふわっとした理解でこの場はOKです(多分)。

何が素晴らしいかって、特に素晴らしいのはやっぱりRelayが勝手にReactコンポーネントにいい感じにpropsを渡してくれるところでしょう。例えばMediaコンポーネントが新たにもっと情報がほしくなったときはRelayGraphQLを書き換えることで魔法のようにpropsが新たに提供されることになるんです。今までのように、自前で作ったajaxの処理を触ったことによって、 思わぬところに影響を及ぼしてしまうという問題から解放されます。
さらに、Relayはコンポーネントのrenderが走る前に要求されたデータを取得済みにします。図で表現した場合↓のような流れになっています。
スクリーンショット 2015-08-14 23.35.39.png

これって、でもようするに B案:オールインワンなカスタムエンドポイント と一緒なんじゃないの?って疑問が浮かび上がってきます。render前に全情報を取得するってことは、その情報が全部揃わなかったら画面が表示されないってことになりますよね。
この問題を解決する方法もRelayには準備されています。
まず、先ほどのMediaコンポーネントを↓のようにリファクタします。コメントを管理するためのコンポーネントCommentListを用意しました。

Media.js
class Media extends React.Component {
  render() {
    var {media} = this.props;
    return (
      <View>
        <Header user={media.author} />
        <Image uri={media.uri} />
        <CommentList comments={media.comments} />
      </View>
    );
  }
}

あとは、このコンポーネントでcommentsがあれば表示するし、そうでなければスピナー(読み込み中)を表示するロジックを入れておきます。

Media.js
class Media extends React.Component {
  render() {
    var {media} = this.props;
    var comments;
    if (media.comments) {
      comments = <CommentList comments={media.comments} />;
    } else {
      comments = <Spinner />
    }
    return (
      <View>
        <Header user={media.author} />
        <Image uri={media.uri} />
        <CommentList comments={media.comments} />
      </View>
    );
  }
}

では、Relayは全部の情報が取得できてからコンポーネントをrenderするのですから、このままではSpinnerが表示されることはありません。そこで、どの情報は 遅延してもいいかというのをGraphQLdeferを使って指定できるのです。↓のようになります。

Media.js
class Media extends React.Component {...} // 省略

module.exports = Relay.createContainer(Media, {
  queries: {
    media: graphql`
      Media {
        ${CommentList.getQuery('comments').defer()},
        ... // 他のロジックは省略
      }
    `
  }
});

こうすることで↓のようなUXが実現可能になります。(コメントがあとから読み込まれている)
スクリーンショット 2015-08-14 23.56.45.png

データの書き込みについて

サーバサイドで何が起きているか、までは詳しく述べられていませんが、概念てきには以下のような流れになるようです。(サーバサイドで何が起きているかはGraphQLをもっと勉強する必要がありそう。。。)

スクリーンショット 2015-08-15 0.04.11.png

  • なんらかのActionが発生(例:登録ボタンのクリックとか)
  • Relayが検知して、クエリをサーバに発行
  • サーバからレスポンスを取得
  • 対象コンポーネントにpropsとして情報を提供

再描画の検知方法について

Reactは様々なコンポーネントが組み合わさって作られていますが、Relayを使った場合にあるデータに変化があった場合にどのコンポーネントを再描画すべきなのか、というのはどのように判断されているのでしょうか???
FluxだったらコンポーネントがStoreの特定のイベントを監視して、変化があった場合にpropsが変わるので再描画が走ります。よって、たいていの場合コンポーネントマウント時にイベント購読して、コンポーネントがアンマウントされるタイミングでイベントの購読を解除します(開発者にそれを定義する義務があります)。
Relayはそれらを自動でやってくれるのに加えて、 どのコンポーネントがどのデータを必要としているかという情報を持っているので、データに変化があった場合にどのコンポーネントを再描画すべきかを 自動で判定してくれます。コンポーネントのshouldComponentUpdateもいい感じに管理してくれるので、再描画が不要なコンポーネントもすべて管理してくれます。

結局Relayってなんなの?

Relayの立ち位置について考えるよりも、Relayを使うことでどういった課題が解決できるか、というのをまとめています。

今まで開発者が抱えていた問題

  • どうやってデータを取得すべきか
  • サーバへのリクエスト時の整合性をどうすべきか
  • サーバへの書き込み時の整合性をどうすべきか
  • データの再取得をどうすべきか
  • エラー時処理をどう考えるべきか
  • サーバサイド・クライアントサイドの同期をどうとるべきか
  • 画面の再描画をどう扱うべきか
  • データのキャッシュをどう管理すべきか
  • ↑の内容を頭の中でどう整理すべきか。。。

Relayを導入するとどうなるか

  • どうやってデータを取得すべきか
  • サーバへのリクエストの整合性をどうすべきか→どの順番でデータを取得すべきか
  • サーバへの書き込みの整合性をどうすべきか
  • データの再取得をどうすべきか
  • エラー時処理をどう考えるべきか
  • サーバサイド・クライアントサイドの同期をどうとるべきか
  • 画面の再描画をどう扱うべきか
  • データのキャッシュをどう管理すべきか
  • ↑の内容を頭の中でどう整理すべきか。。。

かなり大胆なことを言ってますが、

Good APIs simplify concerns.
Declarative APIs eliminate them.

優れたAPIはいろんな懸念事項をシンプルにしてくれる。
宣言的なAPIにはそもそも懸念事項が存在しない。
(↑意訳しすぎ???)

ReactDOMの懸念事項を抹殺してくれたのと同じようにRelayはデータ取得時の懸念事項を抹殺してくれるそうです。夢のような話ですね。facebookではproductionで使われている(GroupsとかAds Managerアプリは実際にRelayで作られているようです)ので、 これは夢物語じゃなくてまじだ!って言ってますけど、実際に使ったことがまだ無いので私はまだ半信半疑です(データ再取得とかエラー時処理とか実際はどうなってるのかとか)。。。が、上の内容が本当だとしたらとんでもないことになりそうですね!

ちなみにRelayのTechnical Previewは先日オープンソース化されているので、気になる方は色々と触ってみるしかないですね!私もワクワクが止まりません!(GraphQLの勉強も必須ですね)

ここまで読んでくださった方、ありがとうございます。冒頭にも書いていますが、理解が誤っている点や、わかりづらい点、誤字脱字等指摘がありましたらコメント欄にてお願いします。