Node.jsのツールで--cacheフラグを実装するためのライブラリを書いた
ESLint、Prettierなどは--cache
フラグという一度チェックしたファイルは、ファイルが変更されるまで再チェックしないキャッシュの仕組みを実装しています。
同様の仕組みをtextlintを実装したことはありますが、file-entry-cacheを使い結構煩雑な実装が必要になります。ファイル変更を元に処理結果をキャッシュする仕組みはある程度定型化されているのに、毎回同じような実装をツールごとに書くのは微妙だなと思ったので、ライブラリを書きました。
--cache
フラグの仕組み
--cache
フラグの仕組みとしては、ファイルの内容のハッシュ値 or ファイルの更新時刻をキャッシュファイルとして保存して置きます。
次回に実行する際には、キャッシュファイルと実際のファイルの情報を比較して、変更がなければ処理をスキップするという仕組みで、処理時間の短縮を目的にしています。
(エラー結果をキャッシュするかはツールによります。しないツールの方が多い気はしています)
たとえば、A.js
に対してLintする場合には、A.js
にLintの処理をしてパスした場合はキャッシュに保存しておきます。
次回、LintしたときにA.js
の内容が変更されていない場合は、あらためてチェックはせずにそのままスキップします。
これに加えて、強制的にキャッシュをクリアしたいパターンもあります。 たとえば、ツール自体のバージョンを更新した場合は処理内容が変わる可能性があるので、キャッシュを利用せずに常に再チェックするのが正しいです。 また、ツールの設定内容が変更された場合も処理結果が変わる可能性があるので、キャッシュを利用せずに常に再チェックするのが正しいです。
@file-cacheでの実装
@file-cacheは、@file-cache/core
パッケージとキャッシュのkeyを作るパッケージからなっています。
多くのツールでは、パッケージのバージョンでキャッシュをクリアしたいので@file-cache/npm
というパッケージを合わせてインストールします。
npm install @file-cache/core @file-cache/npm
@file-cacheでは、次のようなイメージでキャッシュの処理を実装できます。
import { createCache } from "@file-cache/core";
import { createNpmPackageKey } from "@file-cache/npm"
const config = {/* ... */ };
const cache = await createCache({
// Use hash value of the content for detecting changes
mode: "content", // or "metadata"
// create key for cache
keys: [
// use dependency(version) as cache key
() => createNpmPackageKey(["your-package-name"]),
// use custom key
() => {
return JSON.stringify(config);
}
],
noCache: process.env.NO_CACHE === "true" // disable cache by the flag
});
const targetFiles = ["a.js", "b.js", "c.js"];
const doHeavyTask = (filePath) => {
// do heavy task
}
for (const targetFile of targetFiles) {
const result = await cache.getAndUpdateCache(targetFile);
if (result.error) {
throw result.error
}
if (!result.changed) {
continue; // no need to update
}
doHeavyTask(targetFile);
}
// write cache state to file for persistence
await cache.reconcile();
このサンプルコードでは、次の場合にdoHeavyTask
の処理をファイルに対して行います。
- ファイルのコンテンツの中身が変更された場合
your-package-name
パッケージのversion
が変更されたときconfig
の中身が変更されたときNO_CACHE=true
で実行されたとき
オプションはそれぞれ次のような意味合いです。
- mode: “content” or “metadata”
- “content” はファイルのハッシュ値を使う
- “metadata” はファイルの変更時間を使う
- “content”のようにファイルを読み込む必要がないため、早い
- CIなどではgit cloneすると時間が更新されるため、キャッシュは利用できない
- keys:
- キャッシュの複合キーを設定します
@file-cache/npm
は指定したパッケージのバージョンをキーとして扱う関数を提供しています
- noCache:
- trueの場合は、結果はキャッシュせずに、常に
changed: true
を返します --cache
フラグがあるときだけキャッシュするような実装に便利です
- trueの場合は、結果はキャッシュせずに、常に
file-entry-cacheもそうですが、キャッシュファイルに実際に書き込みが行われるのはreconcile()
を呼び出したタイミングです。
これは、書き込まれるまではメモリ上で管理していて、I/Oへのアクセスを減らしてパフォーマンスを安定させるためです。
デフォルトでは、キャッシュファイルはnode_modules/.cache/
の下に保存されます。
cacheDirectory
オプションで保存先を変更できます。
|- node_modules
|- .cache
|- <pkg-name>
|- <hash-of-cache-key>-<mode>
create-validator-tsではv4.0.0で、この@file-cacheを使った--cache
フラグを実装しています。
create-validator-tsではデフォルトはキャッシュしないのでnoCache
オプションを使い、--cache
フラグがついた時だけキャッシュを使うようにしています。
@file-cacheのmonorepo
@file-cacheはmonorepoになっていて、moonとPackemonを使ってパッケージを公開しています。 次のスライドで、moonとPackemonについて紹介しています。
@file-cache自体はtype: module
のESMとして作っていますが、Packemonを使ってCJS/ESM両方に対応したdual packageとして公開しています。
そのため、CJSとESMどちらのツールからも利用できるようにしています。
また、CJSとESM両方に対応したパッケージとして公開していますが、内部的にはPure ESMの外部パッケージを読み込んでいます。
CJSからもimport()
を使うことでPure ESMのパッケージであるpkg-dirを読み込むことができます。
CJS/ESMのdual packagesは色々な問題がありますが、なんとか動くものができた感じがします。
TypeScript単独だと.cjs
と.mjs
の出し分けは現実的に難しいです。
なぜなら、TypeScriptはimport a from "./a.js"
するときに.js
suffixのパスを要求しますが、そのパスを書き換えずに出力するため、.cjs
と.mts
の出しわけがtsc
のみだとできません。
(根本的にNode.jsのtype
/exports
の仕組みとTypeScript Design Goalsが噛み合ってない気がします)
.cjs
と.mjs
を出しわけしない方法として、どちらかをメインにしてラッパーを作るアプローチもあります。
どちらにしても何かしらのtranspilerやbundlerがないと、CJS/MJSに対応したdual packagesを作るのは難しいかなというのが感想です。
Packemonは、その中でもメンテコストが少ないかなーという印象です。
(というよりもexports
の仕様が複雑すぎて、exportsを解釈するツールのバグがあったりと、本当に難しい)
お知らせ欄
JavaScript Primerの書籍版がAmazonで購入できます。
JavaScriptに関する最新情報は週一でJSer.infoを更新しています。
GitHub Sponsorsでの支援を募集しています。