Open22

GraphQL の Fragment Colocation について

ピン留めされたアイテム
Masayuki IzumiMasayuki Izumi

TL;DR

考え方

  • コンポーネントとセットで、そのコンポーネントが必要なデータを Fragment で宣言する、React の宣言的 UI に合わせた宣言的データ取得(declarative data-fetching)という考え方
  • Component Scopedに取得データを宣言することで「そのコンポーネントで必要なデータはなにか」を管理しやすくなることで、under-fetching / over-fetching が抑制でき、変更にも強くなる

お悩みポイント

  • Fragment-scoped な変数を定義するための標準機構がない
    • Relay は directive を利用した独自の機構を持つ

便利そうなライブラリ

  • graphql-anywhere
    • クエリ結果を Fragment の形に整形する
    • not actively support
  • apollo-link-fragment-argument
    • Relay と同じように fragment-scoped な変数を Apollo で実現する
    • (これはまだ自分で試してない)
Masayuki IzumiMasayuki Izumi

Apollo の Fragments に関するドキュメントより

The tree-like structure of a GraphQL response resembles the hierarchy of a frontend's rendered components. Because of this similarity, you can use fragments to split query logic up between components, so that each component requests exactly the fields that it uses. This helps you make your component logic more succinct.

Relay の Quick Start Guide いわく、Relay の "most important principle" であるらしい。理由も簡単に書いてある。

The above code highlights one of Relay's most important principles which is colocation of components with their data dependencies. This is beneficial for a few reasons:

  • It becomes obvious at a glance what data is required to render a given component, without having to search which query in our app is fetching the required data.
  • As a corollary, the component is de-coupled from the query that renders it. We can change the data dependencies for the component without having to update the queries that render them or worrying about breaking other components.
Masayuki IzumiMasayuki Izumi

Relay の Principle & Architecture に関するドキュメント Thinking in Relay に重要な考え方はだいたい書いてた

ルートコンポーネントですべてのデータをまとめて fetch したいけど、それは末端のコンポーネントの描画に必要な詳細をルートが知る必要が出てきてしまう。末端に変更を入れようとすると、ルートやその間のコンポーネントにも影響が出る。
そういう謎の結合はバグが起きる原因になりやすいし、生産性も低い。

Relay はコンポーネントとセットで、そのコンポーネントが必要なデータを Fragment で宣言する。React の宣言的 UI に合わせた宣言的データ取得(declarative data-fetching)を提供している、と言っている。

Masayuki IzumiMasayuki Izumi

@Quramy さんの GraphQLとクライアントサイドの実装指針 という資料より

under-fetching / over-fetching をさけるため、特に over-fetching (データとりすぎ)は多くの場合エラーにはならないが、ブラウザ・ネットワーク・バックエンドなどに余計な負荷をかけ続けることになる。
それを避けるために、Component Scopedに取得データを宣言することで「そのコンポーネントで必要なデータはなにか」を管理しやすくなり、余計なフィールドにも気づきやすい と述べている。

Masayuki IzumiMasayuki Izumi

あとから over-fetching が発生するのは、コンポーネントに変更があったケース。
コンポーネントの横に Fragment が宣言されていることで、不要になった Props を消すのと同じタイミングで不要になったフィールドを Fragment から消しやすくなりそう。

逆に under-fetching が起きるのは、同じフィールドに依存していた2つのコンポーネントがあって、片方でそのフィールドを使わなくなったので消したらもう一方ではまだ使っていた みたいなケース
これも互いが必要なフィールドを Fragment として宣言していれば、その和集合をとってデータ取得をしていれば問題は起きなくなりそう。

Masayuki IzumiMasayuki Izumi

Zenn のスクラップで例を書いてみる

スキーマ定義

type Scrap {
  comments: [Comment!]!
}

type Comment {
  author: User!
  body: String!
  comments: [Comment!]
}

type User {
  name: String!
  avatarUrl: String!
}

実装

const COMMENT_FRAGMENT = gql`
  fragment ScrapComment on Comment {
    ...ScrapCommentHeader
    ...ScrapCommentBody
    ...ScrapCommentFooter
  }
  ${HEADER_FRAGMENT}
  ${BODY_FRAGMENT}
  ${FOOTER_FRAGMENT}
`;

export const ScrapComment = ({ fragment }) => {
  return (
    <div>
      <ScrapHeader fragment={filter(HEADER_FRAGMENT, fragment)} />
      <ScrapBody fragment={filter(BODY_FRAGMENT, fragment)} />
      <ScrapFooter fragment={filter(FOOTER_FRAGMENT, fragment)} />
    </div>
  );
};
const SCRAP_QUERY = gql`
  scrap(id: String!) {
    ...Scrap
  }
  ${COMMENT_FRAGMENT}
`;

export const ScrapPage = () => {
  const { data } = useQuery(SCRAP_QUERY);
  return (
    <div>
      {filter(COMMENT_FRAGMENT, data).map(c => <ScrapComment fragment={c} />)}
    </div>
  );
};
Masayuki IzumiMasayuki Izumi

上の例でさらっと使ったけど、レスポンスから Fragment の形で取り出すのに graphql-anywhere が便利そう。事例がいくつかあった。

graphql-anywhere は Apollo 2系に含まれてたんだけど、3になるときに "You can continue to use the graphql-anywhere package, but Apollo no longer uses it and will not actively support it moving forward." になってしまった。
便利なやつなので問題が起きれば代替が生まれそうな気はしつつも、留意しとく必要はありそう。

Masayuki IzumiMasayuki Izumi

fragment の詳細をコンポーネントに隠蔽しようとしたときに悩ましくなるのが variables
fragment 中の field に引数を渡したいことは普通に有り得るが、fragment に閉じた変数は利用できない(変数は親でしか宣言できず、クエリ内でグローバルになる)

親階層の知識を、子である側が意識しなければならないという点が端的にいって気分悪いのです。

https://qiita.com/Quramy/items/1f9431b42d95ebdc59a8

Masayuki IzumiMasayuki Izumi

Relay modern は @argumentDefinitions@arguments という独自の directive を定義し、fragment-scope な引数を実現している
https://relay.dev/docs/en/fragment-container#passing-arguments-to-a-fragment

fragment TodoList_list on TodoList @argumentDefinitions(
  count: {type: "Int", defaultValue: 10},  # Optional argument
  userID: {type: "ID"},                    # Required argument
) {
  title
  todoItems(userID: $userID, first: $count) {  # Use fragment arguments here as variables
    ...TodoItem_item
  }
}
query TodoListQuery($count: Int, $userID: ID) {
  ...TodoList_list @arguments(count: $count, userID: $userID) # Pass arguments here
}
Masayuki IzumiMasayuki Izumi

Apollo にも同様の変更の feature request が出ているが、とくに動きはない
https://github.com/apollographql/apollo-feature-requests/issues/18

@Quramy さんが Relay と同様の機構を Apollo Link で実現したものを公開してくれている
いまはこれを使うのが良い?
https://github.com/Quramy/apollo-link-fragment-argument

(いわれてみれば Apollo Link でできるのはわかるんだけど、なるほどその手があったかという感じ。すごい。)

Masayuki IzumiMasayuki Izumi

ところで Apollo の useMutation には refetchQueries という機能がある。
名の通りで、mutation 完了後に refetch してほしい query (と variables)を指定するオプション。
https://www.apollographql.com/blog/when-to-use-refetch-queries-in-apollo-client/

これは Fragment Colocation とめちゃくちゃ相性が悪いかも。

Masayuki IzumiMasayuki Izumi

refetchQueries は子コンポーネントが親コンポーネントのクエリを import することになる。これで Fragment の定義を子コンポーネントと一緒に書くようになると、すごくきれいな循環インポートになる。JS は(ESModule も CommonJS も)循環インポートを禁止しているわけではないが、初期化順によっては当然壊れることもある。
Fragment Colocation を使うと親コンポーネントの定義ファイル内で、import した Fragment をトップレベルで参照することになるため、「よくわからんけど壊れた」が起きる確率が上がる気がする

Masayuki IzumiMasayuki Izumi

refetchQueries を避けろという話もあるかもだが、表示ロジックをバックエンドに寄せたほうが良いプロダクトもあるので一概に言えない(e.g. マルチプラットフォームなプロダクト)

回避策は「refetch に必要な情報を直接 export / import するのではなく、context などで伝搬する」ことか。
クエリ定義でもいいし、別で refetch 関数を作ってもいい。

Masayuki IzumiMasayuki Izumi

これは Fragment Colocation 関係なく、単純に「子が親を import するのはやめたほうがいい」というだけか。
refetchQueries を使いたいときは直接参照じゃなくて伝搬させてきましょう、って話。

Masayuki IzumiMasayuki Izumi

graphql-anywhere でレスポンスを fragment で宣言した形に切り取ることで、「暗黙的に取得されていたフィールドへの依存ができなくなる」というメリットがある。
他のコンポーネントが利用を宣言していたことで使えていた値に実は依存していた、みたいなのがエラーとして可視化される。

あとは interface や union を使っていて、__typename による分岐を書いていたケースも。
__typename を使うときはちゃんと Fragment で宣言しないといけなくなる。noImplicitTypename だ。

Masayuki IzumiMasayuki Izumi

@argumentDefinitions@arguments の型定義ってどうなってるんだろう
引数は任意の数・任意の型になるような気がするが、GraphQL って args: ...Any なんて書けないような

aiji42aiji42

NextJS の getStaticProps みたいな、データフェッチを一箇所で行わなければいけない世界において、
どうやって、各コンポネントが必要とするデータの管理をすべきか丁度迷っていました。

こちらの記事 Relayで見るNext.jsとSSGの未来 を見て、Relay の導入の方向で考えているところです。

このスクラップでは、Relay を比較対象に出しつつも、Apollo での対策をメインで書かれているのを見ると、「なんとか Apollo で頑張る」という感じを読み取ったのですが、Relayに振り切らない理由があれば、是非参考までに教えていただきたいです。
(「なんとか Apollo で頑張る」が勝手なわたしの解釈なので、意図とずれていましたら申し訳ないです。)

Masayuki IzumiMasayuki Izumi

Apollo 中心になっているのは 自分が Apollo を導入しているプロジェクトに関わっているからなので、大した理由はないです!

どっちを採用するかについては Fragment Colocation だけにとどまらない一般的な技術選定の話になりそうです。

offtopic

紹介いただいた記事にない視点でいうと、Apollo が標準で持っているキャッシュ機構をどう捉えるか が1つ選定のポイントになりそうです。
Redux などが担っていた、アプリケーションの状態管理をすべて Apollo に任せられる。一方で、ふつうにやると全体に適用されるので、ある意味剥がしづらい・全コンポーネントが暗黙的にキャッシュに依存することになる…みたいな。

また、SSR/SSG/ISR を適用するにしても、実プロダクトではクライアントでしか実行されないクエリもありうるはずです。そうなると Apollo の useQuery による宣言的データ取得は強い武器になるでしょう。Apollo のhooks は本体に組み込まれた stable な API なので、 Relay に比べて優位かもしれません。

他にも差異・ポイントはあるでしょう。

もちろん、Relay を使うと Fragment の利用が快適というのもありますし、それ以上に「Relay によって Fragment Colocation を使うほうに自然と矯正される環境になる」というのはチーム開発上で大きなメリットになると思います。

「いまからやるなら Relay を使う」ぜんぜんありうる一方で、稼働中のプロダクトにおいては移行のコストを払うことになるので、それを払うだけの価値があるか という話になるはずです。
まだどちらも利用していないのであれば、いろいろな視点で比較検討して採用を決めるといいと思います!

aiji42aiji42

リプライありがとうございます!非常に参考になりました。

移行コストに関しては大方予想はしていたのですが、補足にあげてただいたように、キャッシュとhooksは比較に値する重要トピックであるという点、確かに納得です。

既出な内容も含まれていますが、gistに比較があったので、備忘録がてら共有させていただきます。

Masayuki IzumiMasayuki Izumi

fragment とともにエラーも伝搬してほしい
エラー中に「どのフィールドで起きたか」という情報はあるので、それを元に伝搬していくことはできるはず

エラーが発生した箇所によっては、「一部のコンポーネントを省略しつつ全体としては正常に動いているように振る舞う」という戦略を取りたいことがある。
そういうときに「このエラーは重要なのか」は Fragment を定義している場所で判断したい。

Masayuki IzumiMasayuki Izumi

presentational な component のすぐ側まで fragment colocation を徹底しておくことで、「UI に出してる情報がバックエンドのどのモデルから来てるか」がかなり特定しやすくなるなと思った

ある情報のソースが知りたいとき、いままでは

  1. まずそれっぽい React Component に当たりをつけて、
  2. そのなかで対象の情報がどの props から来てるかを特定して、
  3. Component Tree を遡っていって バックエンドの API 叩いてるところを探し、
  4. その API の実装を読みに行く

みたいなフローになっていて、特に 3. が激重だった。
Redux とかが絡んでくると更に厄介で、そのコードベースにある程度詳しくないと追うためのカロリーはかなりでかい。
(ちょっと React を知ってる Backend Engineer が探しに行くのは厳しい)

Fragment Colocation になると上記のフローでいう 2 と 3 が合体して「同じファイル or 1つ上のコンポーネントにあるであろう Fragment 定義を見に行く」となり、Fragment 定義がわかればスキーマ上のどの type かがわかり、じゃあ GraphQL server の resolver を見に行こうというだけになって比較的容易にバックエンドに到達できる。