Biome v2で導入されたGritQLを用いたプラグイン機能はまだまだマニュアルもユーザーからの発信も少なく、またGritQLには実装されているがBiomeのGritQLでは使えないという機能も多く手探りの状態です。
例に漏れず私も探り探りで実装していましたが、サンプルコードを組み合わせてオレオレルールを作成したところリントの速度、特にVSCode拡張機能のBiomeを使った自動整形に数秒かかるようになってしまいました。
そこでルールを見直したところ、リント速度が平均1,055ms→451msと約57%改善しました。
改善前のコード
以下は最適化前のコードです。
`$function` where {
$function <: !within or {
// successResponse
`const successResponse = ($_) => { $_ }`,
`const successResponse: $_ = ($_) => { $_ }`,
// errorResponse
`const errorResponse = ($_) => { $_ }`,
`const errorResponse: $_ = ($_) => { $_ }`,
},
$function <: !contains `req`,
$function <: or {
`return c.json($_)`,
`return ctx.json($_)`,
`return context.json($_)`,
},
register_diagnostic(
span = $function,
message = "Use successResponse()/errorResponse() instead of *.json()",
severity = "error"
)
}
このコードはバックエンドにHonoを採用しているプロジェクトで、Honoのc.json()
をsuccessResponse
もしくはerrorResponse
という関数以外で使うことを禁止するコードです。
つまり各エンドポイントのhandler内で独自にc.json()
を使うことができなくなります。
まず1行目の$function
でマッチする対象を指定します(ここが今回の問題点、後ほど解説)。
次にwhere
キーワードで条件を定義していきます。
-
within
キーワードを否定(!within
)で使い、successResponse
orerrorResponse
内ではないこと -
contains
キーワードを否定(!contains
)で使い、req
が含まれないこと(c.req.json()
を想定) -
or
条件でreturn (c|ctx|context).json($_)
であること
GritQLではwhere
ブロック内にカンマ区切りで条件を並べるとand条件になるので、これら全てtrue
の場合register_diagnostic
のspan
に指定した箇所に対してseverity
レベルでmessage
が表示される、という寸法です。
私はHonoでクライアントに返す値を固定化させたいので毎度ラッパーを書いているのですがこのおかげでリンターがエラーを指摘してくれるようになり、c.json()
の誤った使用が0になりました。
Honoのラッパーのサンプルコード
念の為イメージが付きやすいように`successResponse`のコードを以下に掲載します。import { API_VERSION } from '@/shared/constants/api/apiConstants';
import { setResponseLog } from '../../utils/logger/setResponseLog';
import type { SuccessResponse, SuccessResponseHandler } from '@/shared/types/response/response';
export const successResponse: SuccessResponseHandler = (params) => {
const { context, data, status } = params;
setResponseLog(context, 'info', 'success', { data });
const response: SuccessResponse<typeof data> = {
ok: true,
status,
data,
meta: {
timestamp: new Date().toISOString(),
endpoint: context.req.path,
apiVersion: API_VERSION,
},
};
context.status(status);
return context.json(response);
};
この関数でラップすることでok
やstatus
(HTTPコード)、決まったmeta
データを持ったレスポンスを保証するようになったり、必ずロガーを通したりができるようになります。
c.json()
は型を決めておかないとなんでも返せてしまうので、個人的にはラッパーをいい感じに定義して必ず使うようにしています。
今回のGritQLプラグインでは、このsuccessResponse
関数とerrorResponse
関数内でのみc.json()
の利用を許容しています。
余談ですが私のプロジェクトでは意味のある単語を使うように定めているので、c.json()
ではなくプロジェクト内で使うcontext.json()
を入れてあります。
それと念の為よく使われそうなctx.json()
も対象に含めています。
簡単なGritQLの解説
コード | 意味 |
---|---|
where |
検索条件 |
<: |
左辺が右辺のパターンにマッチするかどうか判定 |
$_ |
全てにマッチ、いわゆるワイルドカード |
within |
祖先ノード(コードを上方向に)走査 |
contains |
子孫ノード(コードを下方向に)走査 |
register_diagnostic |
Biomeの表示に使う関数 |
register_diagnostic -> span
|
CLIやIDEで赤線を引く部分 |
register_diagnostic -> message
|
エラーメッセージ |
register_diagnostic -> severity
|
レベル |
問題点
さて、上記の問題点です。
`$function` where {}
このコードは$function
とマッチする条件を記述していますが、別にfunction
=関数構文の予約語ではなくただの変数名です、$hoge
でも$foo
でもなんでもOK。
という訳でこのコードの$function
はほぼ全てに対して評価してしまいます。
全てというのは関数はもちろん文、式、演算子、各種リテラル、宣言、型全てです。
つまり私が書いた$function
というマッチ条件はほぼ全てのコードに対して上記のwhere
条件を判定していたわけですね。そら重くもなるわ
公式マニュアルのコード例も似たようなコードが使われています。
`$fn($args)` where {
$fn <: `Object.assign`,
register_diagnostic(
span = $fn,
message = "Prefer object spread instead of `Object.assign()`"
)
}
上記のサンプルコードは引数を取る関数呼び出しですが、他にもメソッドの呼び出し(foo.bar(baz)
)等にもヒットします。
このサンプルコードを例にルール作りを始めると、「とりあえず最初のマッチ条件は$fn
とだけ書いておこうかな」というコードが爆誕します。
ええ、まさに私はこのルートでGritQLに触り始めたので最初のコードはこのようにして生まれました。
実際に上記のルールでかかっていた時間を測定してみました。
測定にはhyperfine
を使ってCLI上での簡易測定です、測定方法として全てウォームアップを5回行いその後30回リントを実装し平均値を算出しています。
$ hyperfine --warmup 5 --runs 30 'pnpm exec biome lint .'
Benchmark 1: pnpm exec biome lint .
Time (mean ± σ): 1.055 s ± 0.068 s [User: 4.826 s, System: 0.096 s]
Range (min … max): 0.959 s … 1.242 s 30 runs
平均1,055ms、最大1,242msと1秒以上かかっています。
ちなみにプラグインを完全にオフにした場合は以下です。
$ hyperfine --warmup 5 --runs 30 'pnpm exec biome lint .'
Benchmark 1: pnpm exec biome lint .
Time (mean ± σ): 371.5 ms ± 11.5 ms [User: 414.6 ms, System: 64.7 ms]
Range (min … max): 356.0 ms … 404.9 ms 30 runs
平均371msとプラグインの導入で0.7秒近く時間がかかっているようですね。
しかもこれは単体のCLIでの動作結果で、VSCode拡張機能の場合言語サーバー(LSP)を経由し他にも様々な拡張機能が様々なタイミングで動作していることもあり私の環境では1ファイルを保存するとeditor.formatOnSave
やeditor.codeActionsOnSave
の動作で保存が完了するまで2〜3秒、負荷がかかった状態か被ってしまうと10秒近くかかってしまうこともありました。
さすがに体感できる速度で保存が遅延するのは精神衛生上良くないのでGritQLプラグインの改善を行いました。
改善後のコード
以下が改善後のコードです。
`$function.json($_)` as $call_span where {
// c, ctx, contextいずれかにヒット
$function <: contains or {
`c`,
`ctx`,
`context`,
},
// context.req.json()を除外
$function <: !contains `req`,
// 特定の関数内ではないこと
$function <: !within or {
// successResponse
`const successResponse = ($_) => { $_ }`,
`const successResponse: $_ = ($_) => { $_ }`,
// errorResponse
`const errorResponse = ($_) => { $_ }`,
`const errorResponse: $_ = ($_) => { $_ }`,
},
register_diagnostic(
span = $call_span,
message = "Use successResponse()/errorResponse() instead of *.json()",
severity = "error"
)
}
上記のコードではas
キーワードを使うようになっていたり、contains
キーワードの部分の条件が変わっていますが今回の本質ではないのでここでは触れません
やったことは大きく以下の2つです。
- マッチ条件を
$function
から$function.json($_)
に変更 -
where
内の条件をより早くfalse
になるように並び替え
まず単純に全てにマッチしていた条件を*.json(*)
形式に限定するように修正しました。
この条件ではとにかく「なんとか1.json(なんとか2)」だけにヒットするようになるので、対象は大幅に絞られます。
これだけで大幅に改善されました。
$ hyperfine --warmup 5 --runs 30 'pnpm exec biome lint .'
Benchmark 1: pnpm exec biome lint .
Time (mean ± σ): 451.6 ms ± 49.5 ms [User: 850.1 ms, System: 72.9 ms]
Range (min … max): 420.0 ms … 634.4 ms 30 runs
平均451ms、約57%の改善が見られました。
状態 | 平均時間 (ms) | 最大時間 (ms) |
---|---|---|
プラグインオフ | 371 | 405 |
改善前 | 1,055 | 1,242 |
改善後 | 451 | 634 |
VSCodeで保存時に走る拡張機能でも体感プラグイン無しと同じ程度になり、GritQLプラグインの導入による負荷は感じていません。
ちなみに上記のように$function.json($_)
としなくても、実はwhere
条件を入れ替えるだけで大幅に改善されます。
`$function` where {
$function <: or {
`return c.json($_)`,
`return ctx.json($_)`,
`return context.json($_)`,
},
$function <: !contains `req`,
$function <: !within or {
// successResponse
`const successResponse = ($_) => { $_ }`,
`const successResponse: $_ = ($_) => { $_ }`,
// errorResponse
`const errorResponse = ($_) => { $_ }`,
`const errorResponse: $_ = ($_) => { $_ }`,
},
register_diagnostic(
span = $function,
message = "Use successResponse()/errorResponse() instead of *.json()",
severity = "error"
)
}
$ hyperfine --warmup 5 --runs 30 'pnpm exec biome lint .'
Benchmark 1: pnpm exec biome lint .
Time (mean ± σ): 469.0 ms ± 52.9 ms [User: 909.6 ms, System: 76.3 ms]
Range (min … max): 438.9 ms … 720.8 ms 30 runs
451msには届きませんが、十分許容範囲というかほぼ同じ速度になりました。
このコードは改善前のコードのwhere
条件のうち、!contains
と!within
キーワードを使った条件を最後に持ってきています。
false
になれば対象ノードに対して以降の評価は行われないようなので、いち早くfalse
になるルールを上に書くというのがとても大事なようです。
私のコードの場合、
-
within
キーワードを否定(!within
)で使い、successResponse
orerrorResponse
内ではないこと -
contains
キーワードを否定(!contains
)で使い、req
が含まれないこと(c.req.json()
を想定) -
or
条件でreturn (c|ctx|context).json($_)
であること
という条件でした。
この場合、最も早くfalse
になるのが早いのは3番目の「return (c|ctx|context).json($_)
であること」です。
within
は上方向に、contains
は下方向にASTノードを走査します。
一方「return (c|ctx|context).json($_)
であること」は$function
自身だけを見れば良く、上下にコードを走査する必要がありません。
そのためwhere
条件を書く場合、「いち早くfalse
になる条件をとにかく一番最初に書く」ということを意識しておけば私のように間抜けなコードを書かずに済みそうです。
ただしこのコードはまだ開発中の小規模で100ファイル程度のプロジェクトでの実行なので、巨大なプロジェクトになればやはり差が出てくるかもなあという予想をしています。
基本的にはマッチ条件はなるべく絞るようにした方が良いかなと思います。
GritQLプラグインの高速化で必要な考え方
- マッチ条件はできるだけ狭くする
- なるべく早く
false
になる順番でwhere
条件を記述する
GritQL
GritQL自体はまだまだ「誰やねん」という存在で、私自身もBiomeに採用されて初めて知りました。
先日Biome v2リリースパーティにオンライン参加させて頂き、その中でご登壇された@pal4deさんが「GritQLという気味の悪い拡張子」と仰っていたのが印象的というかめちゃくちゃ笑わせて貰ったのですがそれぐらい情報がなく、繰り返しになりますが手探り状態です。
またAIに聞いても「本家GritQLには実装済みだがBiomeでは未実装」というコードをとにかく回答するため、まずもってコピペで理想的なGritQLプラグインを作成することは現状難しいと言えます。
ですが考え方が分かれば最適化してとても快適に使えるのは間違いないので、もっともっと色んな人がオリジナルのプラグインを書いてどんどん世に出して盛り上がればいいなあと思っています。
慣れてくればオリジナルのルールをぱぱっと手軽に実装できるので、プロジェクト固有のルールをリントレベルに落とし込むのは割と簡単にできると思います。
Biomeのリポジトリをチェックしてみると$filenameが使えなかったり、call_expressionが正しく利用できないと様々なissueが上がっており、BiomeのGritQLプラグイン黎明期の楽しい時期を迎えています。
ということはこれからどんどん公式側からの発信はもちろん、ユーザーからの発信が続々出てくることが期待されます。
皆さんもなにか「こんなルール作ったぜ!」があればぜひ教えてください〜!