zshのキャッシュ機能を利用して高速に補完する

Zsh では、たいていのものは補完できるのだが、場合によって補完が行われるまでに時間が掛かるという問題に遭遇する。たとえば補完候補リストを生成するのに、ネットワークを通じてリモートのリソースにアクセスする場合など、コストのかかる処理が必要なときだ。

補完の重要な目的のひとつはコマンドライン編集を加速させることだが、補完自体に時間が掛かって編集が待たされてしまっては本末転倒である。そこで Zsh には、コストのかかる補完データをキャッシングして、その後の補完を高速に行うための機能が用意されている。今回は、私が補完スクリプトを実装する際に、キャッシュ機能を利用する必要が生じた実際例に基づいて、その使い方と得られた知見を説明したい。


## 実際に使った例
キャッシュ機能を利用する必要が生じたのは、[jhbuild] [link-jhbuild] コマンド用の[補完スクリプト] [link-jhbuild-completion]を作成したときだ。jhbuild とは、[GNOME] [link-gnome] デスクトップの各種プログラムをビルドするためのツールである。jhbuild コマンドには、ビルドしたいプログラム (モジュールと呼ばれる) の名前を引数として指定する。モジュール数は数百件あり、覚えていられないので補完する必要が生じた。補完候補を得るまでのポイントは以下のとおりで別段難しくはない。

- jhbuildには引数としてビルドしたいのプログラム(モジュールと呼ばれる)名を指定する
- ビルド可能なモジュール一覧は、`% jhbuild list` コマンドで取得できる
- `jhbuild list` で得られたモジュール一覧を補完候補として利用すればよい

問題は、`jhbuild list` は結果を返すまでに数十秒も掛かり、その結果を補完候補として利用するには遅すぎるということだった (リモートにあるGNOMEの最新のGitリポジトリを参照しに行ったり、モジュール間の依存関係を計算したりと非常にコストの高い処理となっている)。キャッシュ機能でこの問題を解決できそうであることを知り、活用してみた。実際にキャッシュの有無で性能差を比較してみたところ、以下のとおり約2000倍の性能向上が得られた(補完スクリプトのトップ関数をサブシェル化してtimeで測定)。

> キャッシュなし:
> 2.24s user 0.17s system 4% cpu 49.010 total
> 
> キャッシュあり:
> 0.02s user 0.00s system 98% cpu 0.024 total

## 実際に作成したスクリプトの抜粋
参考までに、以下に補完スクリプトの抜粋をキャッシュ関連の箇所だけ抜き出しておく。スクリプト全体は以下のURLから参照できる:
https://github.com/jmatsuzawa/zsh-comp-jhbuild/blob/master/_jhbuild

```zsh
(( $+functions[_jhbuild_modules] )) || _jhbuild_modules() {
  local update_policy
  zstyle -s ":completion:${curcontext}:" cache-policy update_policy
  if [[ -z "$update_policy" ]]; then
    zstyle ":completion:${curcontext}:" cache-policy _jhbuild_caching_policy
  fi

  if ( [[ ${+_jhbuild_module_list} -eq 0 ]] || _cache_invalid _jhbuild_modules ) &&
     ! _retrieve_cache _jhbuild_modules; then
    _jhbuild_module_list=(${(@f)"$(jhbuild list -a 2>/dev/null)"})
    _store_cache _jhbuild_modules _jhbuild_module_list
  fi
  _describe -t modules 'modules' _jhbuild_module_list
}

(( $+functions[_jhbuild_caching_policy] )) || _jhbuild_caching_policy() {
  local -a oldp
  oldp=( "$1"(Nm+7) )
  (( $#oldp ))
}
```

## キーとなる関数
キャシュ機能を使用するうえで、ポイントとなるZshが提供する関数が三つある。

- `_store_cache`

- `_retrieve_cache`

- `_cache_invalid`


### `_store_cache`

データをキャッシュとして保存するために使用する。`_store_cache <キャッシュ識別子> <パラメーター>...` という構文を取る。キャッシュデータの保存先は、デフォルトでは、`${ZDOTDIR}/.zcompcache`ディレクトリ、ZDOTDIRが未設定であれば`${HOME}/.zcompcache`ディレクトリの配下となり、<キャッシュ識別子>に指定した名称でキャッシュファイルが保存される。保存先を変更したい場合は、cache-pathスタイルに保存場所のディレクトリ名を設定する。jhbuild用スクリプトの実装例 (`_store_cache _jhbuild_modules _jhbuild_module_list` _jhbuild_module_listは配列変数)では、`${ZDOTDIR}/.zcompcache/_jhbuild_modules`というファイルが生成される。ファイルの中身は、_store_cache に指定した`_jhbuild_module_list`という配列変数が、その要素も含めてダンプされている。正常に保存できた場合は、0が返る。(なお、キャッシュファイルの形式は、そのままzshスクリプトとして解釈可能になっており、sourceや.(ドット)コマンドでキャッシュファイルを読み込ませれば、<パラメーター>に指定した変数を起動中のzshセッションに復元することもできる。そのため、キャッシングレイヤーの外部(メタ的な処理など)でキャッシュの中身を参照したい場合は、sourceやドットコマンドを利用して簡単に復元できる)。



### `_retrieve_cache`

保存したキャッシュを参照するために使用する。`_retrieve_cache <キャッシュ識別子>` という構文を取る。<キャッシュ識別子>で指定されたキャッシュファイルがキャッシュディレクトリに生成済みであれば、それを読み込み、`_store_cache` に指定した<パラメーター>変数が現在のzshセッションに復元される。正常に復元出来た場合は0が返る。jhbuild用スクリプトの実装例(`_retrieve_cache _jhbuild_modules`)では、`$ZDOTDIR/.zcompcache/_jhbuild_modules`が読み込まれ、_jhbuild_module_list 配列変数が復元される。なお、上記`_store_cache`の説明の末尾で、キャッシュファイルはそのままドットコマンドで復元できると述べたが、`_retrieve_cache`は内部的にドットコマンドでキャッシュを復元している。


### `_cache_invalid`

キャッシュの更新が必要かどうかを判断するのに使用する。キャッシュの中身が補完候補リストとして古すぎる場合には、キャッシュを更新する必要がある。

`_cache_invalid <キャッシュ識別子>` という構文を取る。`_cache_invalid` の返す値は0(真)、または1(偽)である。真が返る場合はキャッシュが無効(invalid)であると判断されたとき、偽が返る場合はキャッシュが有効である(invalidではない)と判断されたときである。つまり、 `_cache_invalid` が真を返す場合には、キャッシュされるべき補完リストデータを再生成して、そのデータを再度 `_store_cache` することでキャッシュの再構築ができる。(個人的には、`_cache_invalid` の真偽値の意味が少し混乱してしまうので、関数名を `_cache_valid` として真偽値の意味を逆にした方が直感的になったのではないかと思う...)

では、有効/無効は具体的にどう判定されるのか? `_cache_invalid`関数は、内部的にcache-policyスタイルで指定した関数(以下、ポリシー関数と呼ぶ)を呼び出し、ポリシー関数の返した真偽結果を返す。ポリシー関数には、キャッシュファイルの絶対パスが第一引数かつ唯一の引数として渡される。補完スクリプト作成者は、以下の処理を実装しておくことで、ユーザーが補完を実行した時にキャッシュの更新を自動的に行うことができる:

- ポリシー関数を実装する

- ポリシー関数の名前を文字列としてcache-policyスタイルに設定する

- _cache_invalid が真を返す場合にキャッシュの再構築を行う

なお、ポリシー関数の実装例が、zshcompsysの[公式マニュアル] [link-zshcompsys-doc]に以下のとおり紹介されており、zsh標準の補完スクリプトを含め、多くの補完スクリプトがこれに近いポリシー関数を実装している。

```zsh
_example_caching_policy () {
    # rebuild if cache is more than a week old
    local -a oldp
    oldp=( "$1"(Nm+7) )
    (( $#oldp ))
}
```

多くの場合、ユーザーは、与えられたポリシーに満足できない場合、各補完スクリプト用の自前のポリシーを適用することができる。どういうことかというと、基本的に、zsh標準の補完スクリプトは、cache-policyがすでに設定済みであればそれを上書きしない。要するに、補完スクリプトユーザーが自前のポリシー関数を実装してそれをcache-policyに設定しておくことで、自分オリジナルのキャッシュ更新ポリシーを適用することができる。自分が補完スクリプトを実装する場合にもこの点を考慮に入れておくと汎用性を高めることができるだろう。


## スタイル

キャッシュ関連の重要なスタイルを紹介する。キャッシュ関数の説明ですでに紹介済みだが、整理のために改めて個別に説明する。もちろんスタイルとはzstyleで設定するスタイルのことである。


### use-cache

キャッシュ機能の有効/無効を切り替えるブール値を指定する。有効にする場合はtrue。補完スクリプト自身ではなく、そのユーザー側で、キャッシュを利用したいコンテキストに応じて設定する。use-cacheでキャッシュを有効にしていない場合は、補完スクリプトがキャッシング処理を実装していてもキャッシュ機能が働かない。要するに、`_store_cache`、`_retrieve_cache`、`_cache_invalid`の各関数は何もせず、即座に1を返す。


### cache-policy

キャッシュの更新が必要かどうかを判断する関数名を文字列で指定する。cache-policyに指定した関数が、`_cache_invalid`起動時に呼ばれる。


### cache-path

キャッシュファイルを保存するディレクトリを文字列として指定する。cache-pathが指定されていない場合は、`${ZDOTDIR}/.zcompcache`ディレクトリが使用される。ZDOTDIRも未設定であれば、`${HOME}/.zcompcache`ディレクトリが使用される。


[link-jhbuild]: http://gihyo.jp/admin/serial/01/ubuntu-recipe/0244 
[link-gnome]: http://www.gnome.org/
[link-jhbuild-completion]: https://github.com/jmatsuzawa/zsh-comp-jhbuild/blob/master/_jhbuild
[link-zshcompsys-doc]: http://zsh.sourceforge.net/Doc/Release/Completion-System.html#index-_005fcache_005finvalid