1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

BiomeのGritQLプラグインを最適化してリント速度を57%改善した

Posted at

Biome v2で導入されたGritQLを用いたプラグイン機能はまだまだマニュアルもユーザーからの発信も少なく、またGritQLには実装されているがBiomeのGritQLでは使えないという機能も多く手探りの状態です。

例に漏れず私も探り探りで実装していましたが、サンプルコードを組み合わせてオレオレルールを作成したところリントの速度、特にVSCode拡張機能のBiomeを使った自動整形に数秒かかるようになってしまいました。

そこでルールを見直したところ、リント速度が平均1,055ms→451msと約57%改善しました。

改善前のコード

以下は最適化前のコードです。

GritQL
`$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 or errorResponse内ではないこと
  • containsキーワードを否定(!contains)で使い、reqが含まれないこと(c.req.json()を想定)
  • or条件でreturn (c|ctx|context).json($_)であること

GritQLではwhereブロック内にカンマ区切りで条件を並べるとand条件になるので、これら全てtrueの場合register_diagnosticspanに指定した箇所に対してseverityレベルでmessageが表示される、という寸法です。

私はHonoでクライアントに返す値を固定化させたいので毎度ラッパーを書いているのですがこのおかげでリンターがエラーを指摘してくれるようになり、c.json()の誤った使用が0になりました。

Honoのラッパーのサンプルコード 念の為イメージが付きやすいように`successResponse`のコードを以下に掲載します。
successResponse.ts
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);
};

この関数でラップすることでokstatus(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 レベル

問題点

さて、上記の問題点です。

GritQL
`$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.formatOnSaveeditor.codeActionsOnSaveの動作で保存が完了するまで2〜3秒、負荷がかかった状態か被ってしまうと10秒近くかかってしまうこともありました。

さすがに体感できる速度で保存が遅延するのは精神衛生上良くないのでGritQLプラグインの改善を行いました。

改善後のコード

以下が改善後のコードです。

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つです。

  1. マッチ条件を$functionから$function.json($_)に変更
  2. 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条件を入れ替えるだけで大幅に改善されます。

GritQL
`$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"
  )
}

where条件の順番入れ替え
$ 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 or errorResponse内ではないこと
  • 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プラグイン黎明期の楽しい時期を迎えています。

ということはこれからどんどん公式側からの発信はもちろん、ユーザーからの発信が続々出てくることが期待されます。

皆さんもなにか「こんなルール作ったぜ!」があればぜひ教えてください〜!

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?