🍕

【GraphQL】Relayのサンプルコードで学ぶ、宣言的データフェッチとFragment Colocation

2021/05/18に公開

はじめに

ReactやVueが世の中を席巻している昨今、Webアプリケーションの世界ではコンポネント分割、宣言的UIが当たり前のように取り扱われるようになりました。
https://zenn.dev/arei/articles/f59e263aa3edf2
これまでのRailsのような一般的なMVCフレームワークの世界では、Viewは一つの大きなページを指し示していることが大半でしたが、現在ではいわゆる「見た目」は、小さなパーツに分割した状態で管理されることが大半です。
Webサイトの改善活動も、ページ単位での価値評価からコンポネント単位での評価へシフトしています。

Styled ComponentCustom Hooksなど、「どのようなデザインであるか」、「どのような挙動をするか」などの情報をコンポネントに閉じ込め、そのコンポネントにまつわる興味関心が外にもれないようにする手法が多くあります。

ところで、これらの点に関してはどうでしょうか?

  • コンポネントがどんなデータを欲するか
  • どこからデータを取得するか
  • どうやって取得するか

UIや挙動に関する知識をコンポネントに閉じ込めて責務を分離するのと同じように、コンポネントが欲するデータに関しても正しく責務・知識を分断できていますか?


この記事は、コンポネントのデータ取得方法に関して、GraphQLのFragment Colocationをコンセプトとして、Relayを用いた宣言的データフェッチに関して書きたいと思います。
https://relay.dev/

前提

わかりやすいように、全体を通して同じようなページを例として扱いたいと思います。
物件情報を検索し、一覧で表示するページをサンプルにします。左側がページ内のコンポネントの構成、右側が各コンポネントを表示するために必要なデータです。

(図的センスが壊滅的なのはどうかお許しください。。。)

またデータソースはGraphQLを前提としています。

中央集権的なデータフェッチ

1つ目にデータフェッチ及びデータの宣言を中央管理する手法に関してです。
わかりやすく考えると、一般的なMVCモデルに似ています。コントローラーからモデルを呼び出して一括でデータを取得し、ビューに渡すという行為がまさにそうです。
Next.jsでSSGする場合には、pageファイルのgetStaticPrposでデータを一括で取得する必要がありますので、なにも考えずに実装するとこのようなデータフェッチになります。

この手法は、データフェッチを一括で管理しているため、オーバーフェッチ(重複して同じデータを取得すること)の発生や、データベースとの通信で発生するオーバヘッドを最小に抑えることができます。
しかし、各コンポネントがどんなデータを欲しているかということを、上位層(ページ)が把握する必要があります。
各コンポネントの知識をページが持つということは、コンポネント同士あるいはページとの依存関係が強くなってしまい、あるコンポネントを変更した際に、同時にページも変更してあげなければなりません。
UIは各コンポネントで宣言的に行っているのに、データの宣言は一箇所にまとまっているというのは、どこか中途半端である感がいなめません。

  • Pros
    • 通信回数が少ないためオーバヘッドが最小
    • オーバーフェッチが発生しづらい
    • つまりパフォーマンスが良い
  • Cons
    • コンポネントとページの依存関係が強くなってしまう
    • 単一のコンポネントの変更の際の影響が他にも及ぶ

地方分権的なデータフェッチ

2つ目は逆に、各コンポネントでデータフェッチとデータ定義をすることに振り切ります。
わかりやすい例を上げると、CSRでページを構築し、各コンポネントが自立してデータソースと通信を行うケースです。

この手法のメリットデメリットは先程の「中央集権的なデータフェッチ」と真逆です。
データフェッチ及びデータ定義が完全にコンポネントに閉じているので、変更容易性は高いです。他との依存関係もありません。
一方で、データフェッチの回数はコンポネントの数に比例して増加しますので、その分オーバーヘッドも増加します。また、オーバフェッチが発生しやすい状態であるため、パフォーマンスが高いとは言えません。データリクエスト数ごとに費用が発生するようなデータソースを使用している場合には、コスト対策に関しても考える必要性も先程の例と比較すれば高くなります。

  • Pros
    • コンポネント同士、コンポネントとページの依存関係は少ない
    • 変更容易性が高い
  • Cons
    • オーバーフェッチが発生しやすい
    • 通信数増加によるオーバーヘッドやコスト増の懸念

Fragment Colocation

上にあげた2つの手法のいいとこをとった手法が Fragment Colocation です。Colocationは「共用スペースで物事を管理する」という意味があるそうです。

https://blog.ravn.co/data-requirement-colocation-and-dynamic-queries-with-react-and-apollo/

簡単にまとめると、データの宣言は各コンポネントでフラグメントを定義し、フェッチは上位層で一括で行うという手法です。

上位層は各コンポネントで定義されたフラグメントのキーだけを知れば良いため、知識の横断は最低限に抑えることができます。

わかりやすくするために図を載せたつもりですが、あまりにもセンスが壊滅的でした。。。

サンプルコード

今回あげるサンプルコードでは RelayというReact用のパッケージを利用しています。Relayに関しては後述します。ここではとりあえず、流れだけ掴んでいただければ十分です。

データスキーマの例です。

# schema.graphql
type Room {
  id: ID!
  name: String!
  address: String!
  city: City!
  price: Int!
  ...省略
}

type RoomResult {
  items: [Room!]!
  total: Int!
}

type City {
  id: ID!
  name: String!
  prefecture: String!
  ...省略
}

type CityResult {
  items: [City!]!
  total: Int!
}

type Post {
  id: ID!
  title: String!
  thumbnail: String!
  url: String!
}

type PostResult {
  items: [Post!]!
  total: Int!
}

各コンポネントはこんな感じです。フラグメントの定義をコンポネントと同時に記述します。
propsには、フラグメントの定義と対になるキーを取り、フラグメントとキーをuseFragmentに通すことで、データを実体化します。(キーや実際に取得されるデータの型は Relayが自動生成してくれます。)

// Room.tsx
import { useFragment } from 'react-relay'
import { graphql } from 'relay-runtime'
import { Room_rooms$key } from './__generated__/Room_rooms.graphql'

const roomFragment = graqhql`
  fragment Rooms_rooms on RoomResult {
    items {
      name
      price
      address
      ...省略
    }
  }
`

// 物件コンポネント
export const Rooms: FC<{ rooms: Room_rooms$key }> = ({ rooms }) => {
  const data = useFragment(roomFragment, rooms)
  
  return (
    <div>
      {data.items.map(() => (
        // 省略
      ))}
    </div>
  )
}
// AvgPrice.tsx
import { useFragment } from 'react-relay'
import { graphql } from 'relay-runtime'
import { AvgPrice_prices$key } from './__generated__/AvgPrice_prices.graphql'

const agvFragment = graqhql`
  fragment AvgPrice_prices on RoomResult {
    items {
      price
    }
    total
  }
`

// 平均価格コンポネント
export const AvgPrice: FC<{ prices: AvgPrice_prices$key }> = ({ prices }) => {
  const data = useFragment(agvFragment, prices)
  const average = sum(data.items.map(({ price }) => price)) / data.total

  return (
      // 省略
  )
}
// Cities.tsx
import { useFragment } from 'react-relay'
import { graphql } from 'relay-runtime'
import { Cities_cities$key } from './__generated__/Cities_cities.graphql'
import { Cities_cityRooms$key } from './__generated__/Cities_cityRooms.graphql'

const citiesFragment = graqhql`
  fragment Cities_cities on CityResult {
    items {
      id
      name
    }
  }
`

const roomsFragment = graqhql`
  fragment Cities_cityRooms on RoomResult {
    items {
      id
    }
  }
`

// 市区町村一覧(各市区町村に属する物件数がほしいため、roomsも必要)
export const Cities: FC<{ cities: Cities_cities$key; cityRooms: Cities_rooms$key }> = ({ cities, cityRooms }) => {
  const citiesData = useFragment(citiesFragment, cities)
  const roomsData = useFragment(roomsFragment, cityRooms)

  return (
      // 省略
  )
}

これまで定義したフラグメントを一つのクエリ定義でまとめます。

// query.ts
import { graphql } from 'relay-runtime'

export const pageQuery = grapql`
  query page_query($prefecture: String!, $city: String!) {
    rooms: listRooms(filter: { prefecture: $prefecture, city: $city }) {
       ...Rooms_rooms
       ...AvgPrice_prices
    }
    cityRooms: listRooms(filter: { prefecture: $prefecture }) {
       ...Cities_cityRooms
    }
    cities: listCities(filter: { prefecture: $prefecture }) {
       ...Cities_cities
    }
  }
`

最終的にページコンポネントでデータをフェッチし、これまで定義したコンポネントにデータを受け渡します。

// index.tsx
const Page: FC<{ prefecture: string; city: string }> = ({ prefecture, city }) => {
  const { data } = useQuery(pageQuery, { prefecture, city })

  if (!data) return null
  return (
    <>
      <Rooms {...data} />
      <AvgPrice prices={data.rooms} />
      <Cities {...data} />
    </>
  )
}

このように、Fragment Colocationの思想を組むと、データの宣言自体は各コンポネント内に閉じ込め、そして、オーバフェッチの発生しないクエリを自動的に生成することが可能になります。

各コンポネントに対して、...dataのようにフェッチしたデータそのものを受け渡していますが、そのコンポネントで宣言したフラグメント以外のデータは参照することができない仕様になっており、安全なデータの受け渡しが可能です。
つまり、データそのものを渡しているように見えて、実はデータストアを参照するためのキーを渡しているだけなのです。

Relayに関して

RelayはFacebook製のGraphQLクライアントです。GraphQLといえばApolloが真っ先に頭に浮かぶと思いますが、RelayはFragmentでデータ宣言をすることが前提になっており、プロジェクト内でFragment Colocationを強制することが可能になります。
サンプルコードのようにフラグメントを定義すると、自動的にコンパイルし、フラグメントキー・レスポンス値を型情報に変換してくれます。(各コンポネントファイルでインポートしている、__generated__/xxxxxx.graphqlがそうです。)
非常に有益なツールであり、データ宣言は各コンポネントで、データフェッチは一括でという思想は、Next.jsのSSGとも相性が良いため、もっと使用者が増えてくれればと願うばかりです。

https://github.com/vercel/next.js/tree/canary/examples/with-react-relay-network-modern
公式にもNext.js x Relayのサンプルがありますが、
https://github.com/htsh-tsyk/nextjs-relay-hook-ssg-examples
こちらのサンプルでは、サーバサイドとクライアントサイドでEnvironmentを共有する方法に関しての記述がありますので、こちらを参考にされるのがよろしいかと。

もちろんApolloでも、graphql-anywhereなどを使用すれば、同様のことが実現できます。もし、現在すでにApolloを利用しているのであれば、無理にRelayを使用する必要はありません。

注意

Relay の v10 から v11 のアップデートで、大きく仕様が変わっています。

https://relay.dev/docs/next/migration-and-compatibility/relay-hooks-and-legacy-container-apis/

特に一番大きいのは、v10以前はフラグメントの定義を createFragmentContainer で行っていたところを、useFragment で行うようになったことをはじめ、全体的にHoCな書き方からHooksな書き方に変更されています。
Relayの残念なところは圧倒的にドキュメントが少ないところであり、この記事の公開現在、公式のドキュメント以外は、大半がv10以前の記述によるサンプルが大半ですので注意してください。(v11では一応旧式の書き方でも動くことが保証されています。)

GitHubで編集を提案

Discussion