yigarashiのブログ

学んだことや考えていることを書きます

Apollo ClientのInMemoryCacheとMutationに関する調査・考察

本記事は はてなエンジニアAdvent Calendar 2019 3日目のエントリーです。

はじめに

Apollo Client (執筆時 v2.6.4)は、Apollo プラットフォームが提供する JavaScript で実装された GraphQL クライアントです。Apollo Client には InMemoryCache (執筆時 v1.6.3)という強力なキャッシュ機構が用意されており、クエリの結果をキャッシュすることができます。このキャッシュ機構は、多くの場面で API サーバーとの通信回数を削減しパフォーマンスの向上に貢献します。しかし、Mutation によるオブジェクトの作成・更新・削除を行う際には、Apollo Client を使う側がその変更をキャッシュに反映するよう意識する必要があります。例えば、オブジェクトAを含むリストがキャッシュされていたとして、オブジェクトAを Mutation で削除した場合、キャッシュに含まれるオブジェクトAも無効化されるのが望ましいでしょう。そうした操作を効果的に行うには、InMemoryCache の動作を理解することが不可欠です。本記事では、まず Apollo Client の InMemoryCache の動作を具体例を用いて解説します。そのあとで、Mutation を行う際のキャッシュの取り扱いについて調査・考察した内容をまとめます。

InMemoryCache の動作

InMemoryCache はクエリの結果のオブジェクトをキャッシュします。クエリの結果はツリー状のデータ構造ですが、キャッシュするときはデータを正規化して保存します。正規化の方法については こちら で詳しく述べられています。大雑把な動作としては、ツリーをたどって見つかった各オブジェクトについて、以下のようにキーを決定して、単純なキーバリューの組に分解します。

  • id (ないし_id)フィールドと __typename フィールドが存在するオブジェクトは "(__typename):(id)" というキーで保存する
    • (補足): Apollo Client はデフォルトで __typename フィールドを補完してクエリを発行するため、プログラマ__typename フィールドを明示する必要はない
  • 上の条件を満たさなければ、ツリーにおけるそのオブジェクトまでの経路をキーとして保存する

GitHub が提供する GraphQL API を使って例を見てみます。以下のようなクエリを Apollo Client で実行します。

query  {
  viewer {
    id
    name
    repositories(first: 10) {
      edges {
        node {
          id
          name
        }
      }
    }
  }
}

結果は以下のような JSON になります。

{
  "viewer": {
    "id": "yigarashi",
    "name": "Yuu Igarashi",
    "repositories": {
      "edges": [
        { "node": { 
            "id": "repo0", 
            "name": "repository_0" 
          } 
        }, 
        { "node": { 
            "id": "repo1", 
            "name": "repository_1" 
          } 
        }, 
        ... 
      ]
    }
  }
}

キャッシュされているデータは client.store.cache.data で参照できます。以下のようなキーが並んでいます。

  • ROOT_QUERY
  • User:yigarashi
  • $User:yigarashi.repositories({"first":10})
  • $User:yigarashi.repositories({"first":10}).edges.0
  • ...
  • $User:yigarashi.repositories({"first":10}).edges.9
  • Repository:repo0
  • ...
  • Repository:repo9

ROOT_QUERY はトップレベルのデータを表す特殊なキーです。先頭が $ で始まっているキーはオブジェクトまでのパスから生成されたものです。それ以外は (__typename):(id) の形式で生成されたものです。確かに、RepositoryConnectionRepositoryEdgeid を引いておらず、それ以外のオブジェクトでは id を引いており、上で説明した正規化戦略と結果が一致していることがわかります。

各キーに対応する値は基本的にクエリの結果で返されたデータですが、参照先がキャッシュ内に存在する場合はそのキーだけを持つようになっています。例えば $User:yigarashi.repositories({"first":10}).edges.0 というキーの値は { node: { id: " Repository:repo0", ... } } というようになっており、キャッシュ内にある Repository オブジェクトを指すようになっています。

次回のクエリ実行では、デフォルトではまずキャッシュのデータからクエリの結果を構築しようとし、データが足りればそのまま返して、足りなければサーバーに問い合わせることになります。

InMemoryCache と Mutation

書き込み操作を行うときにマスターデータとキャッシュの一貫性を気にする必要があります。例えば、キーが Repository:repo0 のキャッシュが存在する状態で、repo0 の名前を変更する Mutation を発行したら、キャッシュされているリポジトリのデータも更新しなければいけません。さもなくば、キャッシュが維持されている間は画面にリポジトリ名の変更が反映されません。私が知る限り、キャッシュの恩恵を受けながらキャッシュを更新する方法として、4つが Apollo Client のドキュメントにまとめられています。以下では、それらを簡単に紹介するとともに、メリット・デメリットについてまとめます。また、最後にそれらを組み合わせた実践的な使い方を考察します。

1. Mutation の結果としてオブジェクトを取得する

こちら で述べられているように、Mutation の結果としてオブジェクトを取得し、それによってキャッシュを更新します。例えば GitHubリポジトリ名の更新は、次のような Mutation を発行するだけでオブジェクトが再取得され、キャッシュの Repository:repo0 というキーに対応するオブジェクトが更新されます。

mutation {
  updateRepository(input: { repositoryId: "repo0", name: "NewRepoName0" }) {
    id
    name
  }
}
  • メリット
    • Mutation で必要なフィールドを含むように気をつけるだけでよく簡単
  • デメリット
    • 更新操作でしか機能しない(要素の作成や削除は自動的に反映されない)

2. 変更したオブジェクトを含みうるクエリを明示的に発行する

こちら の前半で述べられているように、mutation 関数のオプショナル引数である refetchQueries を用いて、Mutation による変更が反映されるべきクエリを明示的に引き直してキャッシュを更新します。GitHub API の例に沿うなら、createRepository mutation を行なった後に viewer.repositories を引き直すといった場面が考えられるでしょう。キャッシュと Mutation について考える時、「作成・削除操作とそのオブジェクトを含むリスト」という場面が代表的な悩みのタネになりますが、この方法はその解決策の1つとなります。

  • メリット
    • オブジェクトの作成や削除に伴うキャッシュの更新に対応できる
  • デメリット
    • 大規模アプリケーションで引き直すべきクエリが増えたとき管理が難しい
    • 実際にサーバーにリクエストを送信する(軽微なデメリット)

3. キャッシュを直接書き換える

こちら の後半で述べられているように、mutation 関数のオプショナル引数である upadte を用いて、キャッシュを直接操作して更新を反映します。readQuery でキャッシュからデータを読み出し、それを書き換えて、writeQuery で再びキャッシュに書き込むのが基本の形になります。オブジェクトの作成や削除に対応できるという点で2番目の方法と類似しています。

  • メリット
    • オブジェクトの作成や削除に伴うキャッシュの更新に対応できる
    • キャッシュの操作のみで完結するので通信のオーバーヘッドがない
  • デメリット
    • 明示的なキャッシュ操作が必要で実装コストが高い
    • 大規模アプリケーションで操作すべきキャッシュを網羅するのが難しい

4. キャッシュポリシーを調整する

こちら で述べられているように、query 関数の fetchPolicy をキャッシュしないような設定にすることで、パフォーマンスや通信コストと引き換えに煩雑なキャッシュ更新操作を避けることができます。fetchPolicyここ にまとめられており、クエリごとに用途にあったポリシーを選択することができます。このメリット・デメリットは、キャッシュポリシーや扱うデータの特性に大きく依存するため、ここで一概にまとめるのは難しいでしょう。

以上の手法をどのように組み合わせるか

実際のアプリケーションでは、データの特性に応じて異なる戦略を取るのが効果的であると考えます。例えば以下のようなことが考えられます。

  • トップページの「新着コンテンツ」のような箇所では、ユーザーのコンテンツ操作に応じて頻繁にデータが更新される必要はないことが多いです。そのような場合はキャッシュをしても問題はなく、Mutation 時の明示的な更新も考える必要がないかもしれません。
  • ログインユーザーに見せる「自分のコンテンツ一覧」のような箇所では、ユーザーのコンテンツ作成・削除が直ちに反映されるのが望ましいでしょう。そのような箇所に限って 、2. や 3. の手法をとったり、キャッシュポリシーを必ずサーバーに問い合わせるものに設定するのは効果的です。
  • 作成・削除のUIと一覧表示が同じ画面内に存在するときは、特に 3. が有効です。ページ遷移がない分、通信のオーバーヘッドが目立ちやすい箇所なので、ブラウザ内の計算のみで完結することでユーザー体験を向上させることができるでしょう。

まとめ

本記事では、まず Apollo Client の InMemoryCache の動作についてまとめました。InMemoryCache はクエリの結果を正規化して保存しており、平らなキーバリュー型のデータ構造になっていることを説明しました。次に、そうした知識を踏まえて、Mutation のあとにどのようにキャッシュを更新したら良いかについてまとめました。キャッシュの更新は大きく分けて4つの方法があり、データの特性に合わせて戦略を変えたり、複数の方法を組み合わせるのが良いでしょう。

Python処理系入門 〜1 + 1 で学ぶ処理系解読の基礎〜

この記事は CAMPHOR- Advent Calendar 2018 4日目の記事です.

1. はじめに

プログラミング言語 Python は汎用の動的型付き言語で,機械学習や Web 開発を中心に幅広く使われています.特にここ数年の Python 人気は凄まじいものがあり,某大学生協の本屋では,プログラミング系の平積みコーナーが一面Python 関連書籍で埋め尽くされています.所属しているコミュニティの関係でプログラミング初心者の学生にもよく会うのですが,第一言語Python という方が非常に多く,まさに猫も杓子も Python といった状況です.

そんなわけで,人々がこぞって Python でプログラムを書いているわけですが,「Python 自体がプログラムである」という事実に目を向けたことのある人は非常に少ないと思います.みなさんが Python のプログラムを書いて実行する時,実際には,処理系(インタプリタ)と呼ばれるプログラムが起動し,みなさんが書いたプログラムを読み込んで実行しています.みなさんが日々入力している python というコマンドもまた,処理系というプログラムなのです.

最も主要な Python の処理系(つまり Pythonソースコードを読み込んで実行するプログラム)はC言語で書かれた CPython で,「Pythonをインストールする」と言った時は,大抵この CPython をコンパイルしてできた実行ファイルをインストールしています.CPython はオープンソースソフトウェアで,誰でもソースコードを閲覧したり開発に参加することができます.CPython のソースコードPython の振る舞いをより深く知るのにはうってつけの資料で,CPython の開発に携わっていなくとも,サービスのボトルネック発見といった課題のためにC言語で書かれたソースコードを読む破目になる人もいることでしょう.

しかしこの CPython,ソースコードが膨大で,最初は読むべき場所を探し当てるのが非常に難しいです.CPython のデータ構造や実行の全体像といった内容については有志の解説記事がそれなりに存在しますが,コードの読み方にまで踏み込んだ情報はなかなか存在せず,とっかかりを見つけるのがとても大変です.

この記事は,そうした厳しい状況を少しでも改善することを目的とした,CPython 入門者のためのハンドブックです.「CPython を改造して 1 + 1 = 3 にする」というお題を通して,CPython の基礎知識から,CPython のコードの読み方まで解説します.次の2章では,CPython に関する最低限(本記事を理解するのに必要なだけ)の知識を説明します.3章では,「CPython を改造して 1 + 1 = 3にする」というお題を通して,実際のソースコードを追いかけてみます.ここでは,ただ変更箇所を示すのではなく,どのように変更箇所を探し当てるのかという部分に焦点を当てて解説します.4章では,3章のコードリーディングからエッセンスを抽出し,CPython のソースコードを自力で読んで行くための一般的な知見を説明します.5章でさらに掘り下げていくための資料をいくつか紹介し,6章でまとめとします.CPython の構造体や実行方法についてぼんやり知っているという人は,2章を飛ばして3章から読んでも良いでしょう.

続きを読む

漸進的型付けの未来を考える

この記事はCAMPHOR- Advent Calendar 2017 11日目の記事です.

アブストラク

漸進的型付けは,ひとつの言語の中で静的型付けと動的型付けをスムーズに組み合わせるための技術です. よく知られた特徴は any 型を使った静的型付けで, TypeScript や Python といったプログラミング言語には既に実装されています. しかし,理論と実際のプログラミング言語の間には大きなギャップが存在します. 特に,漸進的型付けの理論で提案されているキャストを用いた動的型検査が実装されていないために, 静的型付けの恩恵を十分に得られていないという問題があります. この記事では,まず漸進的型付けの理論をコード例を用いて紹介し, 現状の漸進的型付き言語が抱える問題を解説します. そのあとで,漸進的型付き言語が目指すべき目標を理論的視点から論じます. それらの目標は,静的型付けを行うこと,キャストを用いた動的型検査を行うこと, 動的型検査を効率よく行うこと,型情報に基づいて最適化を行うことの4段階に分類することができます. 未だ実現されてないこれらの目標を論じることで, 多くの人に漸進的型付けの未来を考えてもらうのが本記事の目的です.

続きを読む

型クラスを含んだ型推論を概観する 〜Typing Haskell in Haskell より〜

この記事は CAMPHOR- Advent Calendar 18日目の記事です.

「Typing Haskell in Haskell (通称 thih)」から型クラスを含んだ型推論のエッセンスを紹介したいと思います. 多少専門的な内容ですが,なるべく多くの人に理解してもらえるように頑張ります.

論文のホームページは以下になります.論文本体とオリジナルの実装が利用できます.

Mark P Jones: Typing Haskell in Haskell

続きを読む

System F をHaskellとPythonで実装した

無事夏休みに突入して時間ができたので再びTaPLを読み始めました.半年触っていなくても進行,保存の証明が書けるあたり,春の自分はずいぶんしっかりと勉強していたようです.春は22章の型再構築まで読んでいたので,ひとまず23章のSystem Fから読んでサクッと実装しました.Pythonが書きたかったので,パーサーだけHaskellで書いてそれ以外をPythonで書くというよく分からない構成で実装していますが,いろいろと学びがあったのでそれを記していきます.

続きを読む