tomoima525's blog

Androidとか技術とかその他気になったことを書いているブログ。世界の秘密はカレーの中にある!サンフランシスコから発信中。

Mutation 後のキャッシュ更新処理についての設計指針(GraphQL, Apollo)

前回は、Apollo GraphQL における Mutation(トランザクション処理)の前提知識としてキャッシュ機構について説明しました。(記事はこちら)
今回は React Apollo(v2) における Mutation 後のキャッシュ更新処理について整理しつつ、どのパターンをどのユースケースで採用すべきかについてまとめてみました。

Mutation 後のキャッシュ更新処理

Mutation後のキャッシュ更新処理は大きく分けて3種類あります。それぞれ順番に紹介していきます。

Mutation の返り値を使う(Optimistic UI/update)

まず、一番ベーシックな方法として、Mutation の返り値を使って、キャッシュを更新しビューに反映させる方法があります。
例えば記事に対してコメントを追加するトランザクション処理の場合、その返り値として追加したいコメントを返し(通常は引数として渡した値と同じはず)、update でキャッシュのコメントオブジェクトに追加することができます。

const UPDATE_COMMENT = gql`
  mutation UpdateComment($commentId: ID!, $commentContent: String!) {
    updateComment(commentId: $commentId, commentContent: $commentContent) {
      id
      __typename
      content
    }
  }
`;

const CommentPageWithData = () => (
  <Mutation mutation={UPDATE_COMMENT}>
    {mutate => {
      <Comment
        updateComment={({ commentId, commentContent }) =>
          mutate({
            variables: { commentId, commentContent },
            update: (cache, result) => {
              const comments = cache.readQuery(COMMENT);
              const newComments = Object.assign(comments, result.content);
              cache.writeQuery(newComments);
            },
          })
        }
      />;
    }}
  </Mutation>
);

なお、レスポンスの値でキャッシュを上書きしたいだけの場合は、レスポンスに id を返すようにします。すると Apollo Client は id に紐付くキャッシュを自動的に更新します。(詳細は前回の記事にあります)

さらに、この応用として Optimistic Response を設定することができます。これはサーバーのレスポンスに期待される値を設定することで、リクエストの結果を待たずしてビューを更新するというものです。本当のレスポンスが返ってきた場合は、Optimistic Response によるキャッシュ更新はロールバックされて、本来の値に置き換えられます。
上の例のコメント追加の場合、追加されるコメントはサーバーからのレスポンスをまたずとも明らかなので、それを Optimistic Response として設定します。

const UPDATE_COMMENT = gql`
  mutation UpdateComment($commentId: ID!, $commentContent: String!) {
    updateComment(commentId: $commentId, commentContent: $commentContent) {
      id
      __typename
      content
    }
  }
`;

const CommentPageWithData = () => (
  <Mutation mutation={UPDATE_COMMENT}>
    {mutate => {
      <Comment
        updateComment={({ commentId, commentContent }) =>
          mutate({
            variables: { commentId, commentContent },
            // 更新処理が成功した場合のレスポンスを返す
            optimisticResponse: {
              __typename: "Mutation",
              updateComment: {
                id: commentId,
                __typename: "Comment",
                content: commentContent
              }
            }
            update: (cache, result) => {
              const comments = cache.readQuery(COMMENT_QUERY);
              const newComments = Object.assign(comments, result.content);
              cache.writeQuery(newComments);
            },
          })
        }
      />;
    }}
  </Mutation>
);

この場合 update は Optimistic Response と実際のサーバーレスポンスの2回処理が呼ばれます。

Refetch

Mutation 後に、クエリをリクエストし、必要な Query を更新する Refetch という方法があります。例えば、コメントした後に、コメンターの返信ステータスや通知ステータスを更新したい場合を考えてみます。それぞれの Query が別々であっても、以下のように refetchQueries内でクエリをリクエストしてキャッシュを更新することが可能です。複数のキャッシュを更新できるのが Refetch のメリットですね。

const CommentPageWithData = () => (
  <Mutation mutation={UPDATE_COMMENT}>
    {mutate => {
      <Comment
        updateComment={({ commentId, commentContent }) =>
          mutate({
            variables: { commentId, commentContent },
            ...
            refetchQueries: [
              {
                query: COMMENTER_STATUS,
              },
              {
                query: NOTIFICATION_STATUS,
              }
              ]
          })
        }
      />;
    }}
  </Mutation>
);

Subscription

もう一つの方法として、サーバー側の通知を受け取り更新クエリを実行する、いわゆる Subscription を利用する方法があります。 Subscription の実装方法については公式ドキュメントを参照してもらうとして、React Apolloコンポーネントを利用したキャッシュ更新について説明します。具体的には React Apollo の Query コンポーネントに提供されている subscribeToMore を利用します。
ここでは記事(thread)にコメントが追加されたことを想定して、 COMMENTS_SUBSCRIPTION という Subscription 用のクエリを用意します。

const COMMENT_QUERY = gql`
  query Comment($threadId: String!) {
    entry(threadId: $threadId) {
      comments {
        id
        content
      }
    }
  }
`;

const COMMENTS_SUBSCRIPTION = gql`
  subscription onCommentAdded($threadId: String!) {
    commentAdded(threadId: $threadId) {
      id
      content
    }
  }
`;

そして Subscription は以下のように Query に組み込みます。

const CommentsPageWithData = ({ params }) => (
  <Query
    query={COMMENT_QUERY}
    variables={{ threadId: params.id }}
  >
    {({ subscribeToMore, ...result }) => (
      <CommentsPage
        {...result}
        subscribeToNewComments={() =>
          subscribeToMore({ //(1)
            document: COMMENTS_SUBSCRIPTION, // (2)
            variables: { threadId: params.id }, // (3)
            updateQuery: (prev, { subscriptionData }) => { // (4)
              if (!subscriptionData.data) return prev;
              const newFeedItem = subscriptionData.data.commentAdded;

              return Object.assign({}, prev, { // (5)
                entry: {
                  comments: [newFeedItem, ...prev.entry.comments]
                }
              });
            }
          })
        }
      />
    )}
  </Query>
);

少々複雑なので、順を追って説明しましょう。
(1) では Query から提供された subscribeToMoresubscribeToNewComments の関数として呼び出しています。 subscribeToMore では (2) gql (3) 引数 (4) updateQuery が option として設定されています。 updateQuery は一つ前の Query によって取得されたデータと、Subscription による結果を返します((4)の prev, subscriptionData)。Subscription の結果を追加して返すこと(5) で、キャッシュは更新され、ビューの再描画が実行されるわけです。

さて、この Subscription を利用する場合は、このコンポーネントを呼び出すコンテナで Subscribe する必要があります。Subscribe する箇所は Subscription の重複購読を避けられる componentDidMount ないしは Query による初期データが揃ったことが確認できる getDerivedStateFromProps良いプラクティスのようです。

export class CommentsPage extends Component {
  componentDidMount() {
    this.props.subscribeToNewComments();
  }
  // または
  static getDerivedStateFromProps(nextProps: Props, prevState: State) {
    // すでに Subscription が設定されているか確認する
    if(nextProps.result && !prevState.unsubscribe) {
      // subscribeToMore は unsubscribe を返すので、stateに保存しておく (後述)
      return {
        unsubscribe: this.subscribeToNewComments(),
      }
    }
    return null;
  }

}

また subscribeToMoreunsubscribe 関数を返します。これにより画面を閉じたときに Subscription を終了させることも容易にできます。

export class CommentsPage extends Component {
  unsubscribe: () => void,

  componentDidMount() {
    unsubscribe = this.props.subscribeToNewComments();
  }

  componentWillUnmount() {
    unsubscribe();
  }
}

https://www.apollographql.com/docs/react/advanced/subscriptions/

どう使いわけるか

さて、これらのキャッシュ更新処理はどう使い分けるのが良いでしょうか。下図のような分類パターンを考えてみました。

f:id:tomoima525:20190806163745p:plain

まず、Mutation のレスポンスだけでなく、サーバー側からの通知によってキャッシュを更新したい場合は Subscription を選択するべきでしょう。*1
次に確認すべきは、Mutation 後に更新すべきキャッシュが複数ある、ないしはキャッシュのデータ構造が複雑かどうかです。例えば認証系のトランザクションではユーザーデータの一括更新をするケースがあります。このような場合は Refetch を検討すべきです。 キャッシュ更新がシンプルである、または GQL のデータ構造の一部のみ更新が必要なケースであれば、Mutation の返り値をupdate() で受け取るのが素直な実装でしょう。 もちろんそれぞれの実装についてトレードオフはあります。

  • Subscription

    • websocket などの少々複雑な実装がクライアント、サーバーともに必要
  • Refetch

    • モバイルアプリの場合、ネットワーク環境は不安定であるため、Refetch が失敗し、データ不整合が起きる可能性がある
    • Query のすべてのキャッシュを上書きしてしまうので、1 データを追加/削除するような更新には適してない
  • Update(Optimisitic UI)

    • Mutation の返り値についてしっかりとした認識合わせをサーバーサイドとする必要がある

最後に、twitter 上でもこの更新処理に関する活発な議論があったので、ここにスレッドといくつかの意見(意訳)を掲載しておきます。

どのようなユースケースで、シンプルに Refetch するよりも Mutation の返り値を使ったほうがよいでしょう? 返り値で UI を更新するのは、わずかなパフォーマンス改善に対して割りに合わないと僕は思うのです

Mutation がサーバー側で実際のデータを生成するときに Mutation の返り値を使うのだと思います。僕たちは Braintree(決済サービス) API を wrap していて、支払時に Mutation の結果で id を返すことで、支払い方法をクエリすることができるようになります

自分のアプリでは、大した負荷にもならないので、アプリ内のすべての State を Refetch で更新することもあるね。まずはシンプルに実装して、パフォーマンスに問題出てから他の方法を検討すればよいのでは。

自分の中でMutationとは、単にデータを返す関数ではなく、グラフを古い状態から新しい状態へ変化させる関数という認識です。返り値はグラフの変化をたどるエントリーポイントです。

GraphQLの更新処理のハンドリングについては皆が手探りの状態で、ベストプラクティスというものがまだ存在していません。それゆえにどういう方針で更新処理を実装すべきかは、チームで話し合っておくべきかもしれませんね。

*1:リアルタイム性を重視しないなら、Polling も可能です