🚀

今更だけどApollo Client v3にあげた話

2021/12/14に公開

最近、社内のフロントエンドのApollo Clientをv2からv3にあげ切ったので供養目的でまとめます。

Apollo Client v2 と v3 の違い

Migrating to Apollo Client 3.0に大きな変更点がまとまっています。

https://www.apollographql.com/docs/react/migrating/apollo-client-3-migration/

大きな違いは以下の2点です。

  • パッケージの構成が変わった
    • apollo-client から @apollo/client にパッケージが変わった
    • 具体的には以下などのパッケージが変更になった
      • @apollo/react-hooks@apollo/client
      • @apollo/react-testing@apollo/client/testing
      • apollo-link-state@apollo/client
      • apollo-link-error@apollo/client/link/error
  • キャッシュの機構が変わった
    • キャッシュのポリシーが指定できるようになり、より柔軟になった(詳しくは後述)

マイグレーション時の問題

キャッシュの機構が変わったため、Apollo Client v2では、いい感じにキャッシュしていた部分についてもポリシーを明示的に追加する必要が発生し、正しく動いているかをチェックする必要がありました。

さらにパッケージの構成も変わっているため、パスの変更も行う必要があり、ファイル変更箇所が膨大になってしまいます。なので、すぐにmasterとコンフリクトしてしまうという問題や変更箇所がぼやけてしまうと言う問題があり、問題をさらに複雑にしていました。

上記の理由より、開発のスキマ時間にv3に上げるのが困難になり長らく放置していました。しかし、ずっと放置するわけにもいかないので、Migrationの戦略を考え直し、上げることを決意しました。

変更点を小さくする

まず、問題を小さくするために、パスが変わるため、ファイル変更箇所が膨大になってしまう問題にまず対応しました。と言っても、やることは単純で、以下の対応を行いました。

  • @apollo/react-hooks, @apollo/react-testing を v4.0.0 にあげる

上記のパッケージはありがたいことに、v4.0.0(最後のUpdate)で、 依存関係に "@apollo/client": "latest" が指定され、裏で @apollo/client を呼び出すだけのパッケージとなっているので、このパッケージ経由で、Apollo Client v3を呼び出すことができます。

https://github.com/apollographql/react-apollo/blob/master/Changelog.md#400-2020-07-20

なので、プロジェクトの package.json を以下のように修正することで、パッケージの構成が変わったことによる修正について、大部分を後回しにすることができます。

"@apollo/client": "^3.0.0",
"@apollo/react-hooks": "^4.0.0",
"@apollo/react-testing": "^4.0.0",

特に多くのアプリケーションコードで @apollo/react-hooks を指定しており、このパスの修正を行わなくて済むようになったのはありがたかったです。これにより、ファイルの変更量を半分以下に減らすことができ、キャッシュの機構の変更による問題に集中して取り組むことができました。

キャッシュの問題に取り組む

Apollo Client v3 ではキャッシュをより効率的に扱うために、正規化されていないオブジェクトがデフォルトでマージされなくなり上書きされるようになります。そして、この問題を修正するためには、Apollo Client v3のキャッシュ戦略について理解する必要があります。

PolicyによるApolloのキャッシュ戦略

Apollo Client v3では、 Policyによりオブジェクトごとにキャッシュの制御ができるようになりました。具体的には TypePolicy, FieldPolicy を使って以下のようなキャッシュ制御を行います。

  • TypePolicy による正規化条件の制御
  • FieldPolicy によるキャッシュのマイグレーションの制御

https://www.apollographql.com/docs/react/caching/cache-configuration/

TypePolicy による正規化条件の制御

まず前提ですが、正規化されたオブジェクトは、新しいデータを古いデータとマージします。正規化の条件は id or _id の識別子が指定されていることです。

TypePolicy を使うことで、この正規化の条件を変更できます。

const cache = new InMemoryCache({
  typePolicies: {
    Product: {
      // Product#upc を識別子として正規化する
      keyFields: ["upc"],
    }
  }
}

dataIdFromObject でも同様なことが行えますが、TypePolicy はより以下のようにより柔軟な指定が可能です。

Person: {
  // 2つ以上の組み合わせを識別子に用いることもできる
  keyFields: ["name", "email"],
},
Book: {
  // author.name のようにネストされたオブジェクトを指定することもできる
  keyFields: ["author", ["name"]],
},
AllProducts: {
  // そのオブジェクトが唯一の場合には空配列を指定できる
  keyFields: [],
}

FieldPolicy によるキャッシュのマイグレーションの制御

TypePolicy の keyFields を指定してあげれば基本的には大体うまくいきますが、識別子で指定できない場合は、FieldPolicyを使うと、キャッシュの更新タイミングで制御を行うことができます。

以下は、Bookの中にあるauthorのキャッシュを制御する例です。

const cache = new InMemoryCache({
  typePolicies: {
    Book: {
      fields: {
        author: {
   	  // merge の中でどうキャッシュを更新するかを決められる
          merge(existing, incoming) {
            return incoming;
          },
        },
      },
    },
  },
});

正規化と同様に、2つのオブジェクトをマージしたい場合は、 merge: true で省略できます。

const cache = new InMemoryCache({
  typePolicies: {
    Book: {
      fields: {
        author: {
          merge: true
        },
      },
    },
  },
});

Author全てで、同じキャッシュ更新を行いたい場合は、さらに省略できます。

const cache = new InMemoryCache({
  typePolicies: {
    Author: {
      merge: true
    }
  },
});

キャッシュ機構の変更問題の対応

クエリ定義は変えずに、Policyに追加する形で対応しました。
具体的には以下の方針で修正を行いました。

    1. 共通の識別子がある場合は、 TypePolicy に追加していく
    1. 共通の識別子がない場合は、 FieldPolicy に merge を追加していく

注意する必要があることとしては、スキーマ定義には、id があっても、クエリやフラグメントで id がない場合は、キャッシュが壊れる可能性があると言うことです。なので、クエリの定義を注意深く見ていく必要があります。

今回対象にしたプロジェクトでは、変更対象が大きく、オブジェクトがどこで定義されているかを全て調査しきるのが難しかったため、機能を一つずつ手動でチェックしながら、壊れていた場合にのみ上記の対応を行うことにしました。

問題が起きていた箇所の具体例を挙げると、以下のようなケースがありました。

  • 「続きを読む」などのページローディング
  • モードの切り替え

また、一見動いているような場合でも、正規化されていないネストされたオブジェクトがあると、クエリを実行する際に、キャッシュからデータが取れずに、クエリの再取得が大量に行われるケースもあります。具体的には以下のようなケースです。

query A {
  user {
    id
    userInfo {
      name
    }
  }
}
query B {
  user {
    id
    userInfo {
      age
    }
  }
}

同一ページでクエリAが走った後にクエリBが走ると、 UserInfo が正規化されていない場合に、キャッシュが上書きされます。それによって、クエリAにある userInfoのnameの情報を失うため、再度クエリAが実行されます。このような理由で何回もリクエストが走ってしまうケースがありました。

なので、ネットワークで余分なリクエストが走っていないかも、注意深く検証する必要がありました。

その他の修正点

キャッシュの機構の変更に伴う修正以外にもいくつか修正をしました。大きいものだとuseLazyQuery で無限ループが起きてしまうという問題がありました。

この問題については、 useLazyQuery の option に直接 variables を渡すようにしました。全ての箇所で起きていたわけではなかった(と記憶している)のですが、一律で以下のように変更しました。

// 🙅‍♂️
const fn1 = useLazuQuery(QUERY);
fn1({variables});

// 🙆‍♂️
const fn2 = useLazyQuery(QUERY, { variables })
fn2();

関連Issue: https://github.com/apollographql/apollo-client/issues/5912

マージ前には、テストケースを作成し、他のメンバーとQAチェックを行った後にマージしました。最終的なdiffは以下のようになりました。

Apollo Client v3 PR

まとめと反省

Apollo Clientをv2からv3にあげる手順として、変更箇所を少なくする方法と、キャッシュの機構の変更に伴う修正について、主に記述しました。

反省点ですが、キャッシュの修正箇所を見つけるために、オブジェクトがどこで定義されているかを全て調査しきるのが難しかったと書いたのですが、これはある程度は仕組みで解決できそうだと思っています。

例えば、idがないクエリ定義に対してWarningを出すLinkのルールを作る、だったり、もしくは @identifier のような custom directive を作ることで、どこでキャッシュをする必要があるのかをある程度明文化できるのでは?と考えています。また、この調査をより丁寧に行うことで、安全にApollo Clientのアップデートが行えるようになると思います。

最後に、v3へのアップデートやキャッシュの理解の際には以下の記事がとても参考になりました。

https://tomoima525.hatenablog.com/entry/2020/10/13/170332

https://zenn.dev/kazu777/articles/b64935ea7d6fee

この記事がApollo Client v3へのアップデートやキャッシュの理解の参考になれば幸いです。

Discussion