Playwrightでライブプレビューツールを実装する

こんにちは、yebis0942です。

先日、@basemachina/bm-view-previewというツールをnpmで公開しました。ベースマキナのビュー機能ソースコードをローカル環境で編集しながら、Next.jsのnpm run devのようにライブプレビューで動作を確認できるツールです。

ライブプレビューはPlaywrightによってコントロールされたChromiumで表示しています。PlaywrightをE2Eテストや自動化以外の用途で使うのは珍しい事例かと思いますので、その背景と実装の裏側についてご紹介します。

ビュー機能とは

ベースマキナではビュー機能というReactベースの管理画面作成機能を提供しています。ベースマキナのデフォルトの画面では不十分な場合に、Reactによって柔軟に画面を実装することができます。専用の関数を実行することで、ベースマキナのアクション実行やジョブ実行などの機能を呼び出すことができます。

編集画面では、以下のようにコードエディタとプレビューエリアを用意しています。コードエディタにReactのコードを入力すると、プレビューエリアが更新されます。

ビュー機能の編集画面

ローカル環境で開発したい

ビュー機能でまとまった画面を開発する事例が増えるにつれて、ローカル環境で開発を行いたいというご要望をいただくようになりました。

具体的には

  • 使い慣れたエディタでコードを書きたい
  • tsc(TypeScript)のようなトランスパイラーや、Webpackのようなバンドラーを使いたい

といったご要望です。

この記事では詳細は割愛しますが、ビュー機能ではレンダリング時にクライアントサイドJavaScriptソースコードのトランスパイルやモジュール解決を行っており、ベースマキナのアクションなどの呼び出しに関してはサーバーサイドの機能に大きく依存しています。

このような背景から、ローカル環境のみで完結するプレビュー機能を提供することは現実的ではありませんでした。

bm-view-previewの内部構成

今回は、Playwrightを組み込んだNode.jsアプリケーションを提供することで課題の解決を試みました。

bm-view-previewはnpmでパッケージとして配布されているNode.jsアプリケーションです。bm-view-previewコマンドを起動すると、Playwrightを通してChromiumを立ち上げ、basemachina.comのビューの新規作成ページを開きます。そして、ローカル環境のソースファイルの更新を監視して、ビューの新規作成ページ内のコードエディタに随時その内容を流し込んでいきます。

このような構成にすることで、既存の実装を利用する形でローカル環境でのライブプレビューを実現しています。

ライブラリとしてのPlaywright

Playwrightには、E2Eテストなどの機能を提供する@playwright/testパッケージのほかに、ブラウザの操作などの基本的な機能を提供するplaywrightパッケージがあります。

以下のようにplaywrightライブラリを直接使うことで、ブラウザを起動して操作することができます。

// 操作対象のブラウザとしてchromium, firefox, webkitがimportできる。今回はchromiumを使う。
import { chromium } from "playwright";

(async () => {
  // ブラウザを起動する
  const browser = await chromium.launch();

  // タブを開く
  const page = await browser.newPage();

  // 開いたタブでhttp://example.com/にアクセスする
  await page.goto("http://example.com/");
})();

ブラウザを人の手で操作できるようにする

これでブラウザを動かすことができましたが、Playwrightはデフォルトでは自動テストやオートメーションに使われることを想定した設定となっています。そのままでは人の手でブラウザを操作するには不便なので、設定をいくつか変更します。

ウィンドウを表示する

Playwrightはデフォルトではウィンドウを非表示となっています。launchメソッドのオプションに headless: false を渡すことでウィンドウを表示させることができます。

 import { chromium } from "playwright";

 (async () => {
-  const browser = await chromium.launch();
+  const browser = await chromium.launch({
+    headless: false,
+  });
   const page = await browser.newPage();
   await page.goto("http://example.com/");

ウィンドウのリサイズに対応する

Playwrightはデフォルトではviewport(ページの描画領域)のサイズが1280x720に固定されています。そのため、ウィンドウを広げても描画領域は広がらず、以下のように空白の領域が現れます。

ウィンドウを広げても表示領域は広がらず、画面右と画面下に空白が生じる

これは自動テストの実行結果を安定させるための意図的な挙動です*1

とはいえユーザーがウィンドウを操作する場合には不便なので、以下のように設定して無効にしておきます。

 (async () => {
   const browser = await chromium.launch({
     headless: false,
+    viewport: null,
   });
   const page = await browser.newPage();
   await page.goto("http://example.com/");

ダイアログを自動で閉じない

Playwrightはデフォルトではダイアログ(window.alert, window.confirm, window.promptによって開かれるネイティブのダイアログ)をすべて自動で閉じます。ダイアログが開いたままではページの操作がブロックされ、テストが進められなくなってしまうためです。

ですが、ユーザーがウィンドウを操作する場合にはこのような挙動は不要です。以下のように、dialogイベントに対して何もしないハンドラを明示的に指定して無効にしておきます。

page.on('dialog', () => {});

できなかったこと

アプリケーションモードでの起動

ChromiumGoogle Chromeではchromium --app=https://example.comのように起動オプションを指定することでアプリケーションモードとなり、URLバーやタブ一覧が隠された状態で起動します。

プレビュー画面だけを表示するのにちょうど良さそうな機能ですが、Playwrightではこの機能は無効化されており、使うことができません。

自動テスト

bm-view-preview自体のE2Eテストを実装することができていません。PlaywrightによってコントロールされているChromiumを、さらに別のテストコードから制御して動作を検証するための適切な手法が見つけられていないためです。

そのため、現在はリリース前の動作確認を手動で行っています。

まとめ

Playwrightによるライブプレビューツールの実装についてご紹介しました。

このようにライブラリとしてのPlaywrightを利用することで、通常のウェブブラウザだけでは実現できない機能を比較的容易に追加することができます。また、Webアプリケーション本体に変更を加える必要がないという点も、場合によっては大きなメリットになりうると感じました。

一方で、Playwrightの制御下のブラウザはアプリケーションとして利用することが想定されていないため、いくつかの課題があることも分かりました。

ビュー機能では、今回ご紹介したライブプレビュー機能と「コード取得設定」を合わせて使うことで、ベースマキナの管理画面の構築機能を活用しながら、通常のフロントエンド開発に近い開発体験で画面を作り込むことができるようになっています。今後とも、利用体験の改善の裏側をご紹介できればと思っています。

*1:It makes the execution of the tests non-deterministic. Playwright - viewport

Cloudflare WorkersのCron Triggersでリリース当番通知botを作った話

こんにちは、syumaiです!

ベースマキナでは、現在Cloudflare WorkersのCron Triggersを活用したリリース当番通知botを社内で運用しています。このbotは、リリース対象日の朝にリリース担当メンバーにメンションを行います。実装はTypeScriptで行われています。

今回の記事では、

  • なぜリリース当番通知botを作ることにしたのか
  • なぜCloudflare Workersを使ったのか
  • Cloudflare Workersによる定期実行Workerの実装例

などについて紹介させていただきます。

なぜリリース当番通知botを作ることにしたのか

もともと、ベースマキナでは、リリース担当のメンバーを特に決めていませんでした。リリース担当が決まっていないと、自然と「直近機能開発を行ったメンバー」がリリースを自主的に行うようになります。すると、タスクの持ち具合によって、設計主体のメンバーはリリース作業の頻度が低く、開発主体のメンバーはリリース作業の頻度が高くなり、不均等な状態が生まれていました。

リリース作業の頻度が不均等になると、

  • リリースそのものにかかる作業時間
  • 作業工程に対する理解度

についても同様に均等性が失われてしまうという問題があります。リリース作業は極力自動化されているとはいえ、リリース前に確認しないといけないことが色々ありますし、作業中の待ち時間が発生することもあります。また、リリースの作業工程に対する理解度が低いと、よくリリース作業を行っているメンバーが欠席した場合や、緊急時のHotfix対応が必要になった場合に作業がうまく行えないリスクがあります。

こうした問題を防ぐために、「リリース当番を輪番制で自動的に割り当て、チームメンバー全員が確認しやすい形で通知する」というのが今回実現したいことでした。

なぜCloudflare Workersを使ったのか

Cloudflare Workersは、実は「定期的にJavaScriptを実行する基盤」として非常に手軽です。

本来、Cloudflare WorkersはCDNエッジでJavaScriptの実行を行い、クライアントからのリクエストや、オリジンサーバーからのレスポンスに対して加工を行なう、リバースプロキシ的な用途が主体となっています。 しかしながら、ここ近年、リバースプロキシ的な用途だけに限らず、Cloudflare Workers単体でアプリケーションとして利用するのに十分な機能が提供されています。 例えば、Cloudflare WorkersからJavaScript SDKで簡単に呼び出せるWorkers KV (キーバリューストア)、D1 (リレーショナルデータベース)、R2 (オブジェクトストレージ)などがあります。

いまや、Cloudflare Workersは単にJavaScript製アプリケーションの実行環境としてみなすことができ、リバースプロキシ的な使い方はあらゆる用途の中の一つでしかないと考えるのが適切でしょう。

今回は、Cloudflare WorkersのCron Triggersという機能をWorkerの定期実行に利用しました。

Cron Triggersとは

Cloudflare Workersは、Triggersと呼ばれる機構によって起動されます。 設定できるTriggersの種類の一覧は、Cloudflareのダッシュボード内にあるWorkerの設定ページの、「Triggers」タブから確認できます。 Cloudflare Workersを起動するための最も一般的な方法はWorkerのURLに対するHTTPリクエストですが、これもTriggerのひとつとして見ることができます。そして、HTTPリクエストなしでWorkerを起動する方法もあります。 例えば、メールの受信をTriggerとしてWorkerを起動するEmail Triggersがあります。 こうしたHTTPリクエスト以外の方法で起動されるWorkerは、HTTPによるエンドポイントを完全に無効化して使うこともできます。

Cron Triggersは、cronスケジュールを使ってWorkerを定期実行するためのTriggerです。 無料プランでは全部で5つまで、有料プランでは250個までと、作成数に限りはありますが、追加料金無しで気軽に使うことのできる機能となっています。

Cloudflare Workersによる定期実行Workerの実装例

実装したのは以下の機能です。

  • Slack通知
  • 当番の割り当て
  • 土日・祝日と、その前日のSlack通知の停止機能

完成形のコードは以下のリポジトリにて公開しています。

github.com

Slack通知

jsx-slackを使っています。jsx-slackを使うと、Slackのメッセージを表現するBlockをJSXで簡単に書くことができます。 本来、SlackのメッセージのBlockはJSONで表現する必要があり、直接JSONを書くか、何かしらのライブラリを導入した上で(JSXではなく)コードで組み立てる形になります。 これをJSXによって代替することにより、大幅に学習コストを下げられていると感じます。メッセージの構造が視覚的にわかりやすいので、レビューもしやすいです。

export const postReminder = async (webHookUrl: string, members: string) => {
  const parsedMembers = parseMemberSlackIds(members);
  const blocks = (
    <Blocks>
      <Section>
        <a href={`@${getTodayMemberSlackAccountId(parsedMembers)}`} />
        <br />
        本日のリリース確認作業をしましょう!
      </Section>
    </Blocks>
  );
  return await postBlocksToSlack(webHookUrl, blocks);
};

https://github.com/basemachina/release-reminder-worker/blob/85044854e6ba795b6b738fd9cef83be56b254433/src/reminder.tsx#L24-L36

実際に送信されるメッセージの表示例

メッセージの送信処理は、単にWebHook URLにPOSTでJSON化したBlockを送っているだけです。 Cloudflare Workersからは、(当然ですが)このようにfetchを呼び出して外部にリクエストを送ることが簡単にできます。 ややこしいのがメッセージのBlockを組み立てる箇所だけだったので、Slack通知関連でjsx-slack以外のライブラリは導入せずに済みました。

export const postBlocksToSlack = async (webHookUrl: string, blocks: any) => {
  return await fetch(webHookUrl, {
    body: JSON.stringify({ blocks }),
    method: "POST",
    headers: { "Content-Type": "application/json" },
  });
};

https://github.com/basemachina/release-reminder-worker/blob/85044854e6ba795b6b738fd9cef83be56b254433/src/slack.ts#L1-L7

当番の割り当て

当番の割り当ては、単にメンバーのリストを日で割って行っています。今は均等に回せていますが、メンバー数が7の倍数になると曜日が固定になってしまうので、何かしらの調整方法を考えます…! メンバーのリストは、名前:SlackIDの組でCloudflare Workersの環境変数に持つようにしています。これはハードコードしてしまってもよかったのですが、公開リポジトリに切り替えやすくしたかったためこのようにしています。 実際の運用としては、以下のような設定内容をwrangler.tomlに直接commitしています。

[vars]
# `名前:Slack ID`の形式で改行区切りで指定する
MEMBER_SLACK_IDS = """
example1:UXXXXXXXXXX
example2:UXXXXXXXXXX
"""

https://github.com/basemachina/release-reminder-worker/blob/85044854e6ba795b6b738fd9cef83be56b254433/wrangler.toml#L13-L18

土日・祝日と、その前日のSlack通知の停止機能

土日・祝日と、その前日は原則リリース作業を行わないため、通知を行っていません。

まず、wrangler.tomlに設定するCron Triggersのスケジュール自体を、金・土・日を除外した内容にします。

[triggers]
crons = ["0 1 * * MON-THU"] # 日本時間の月〜木の朝10時に実行

https://github.com/basemachina/release-reminder-worker/blob/3eba148ddc2bccfa84259c544c821519b3eee46f/wrangler.toml#L7-L11

この上で、祝日を考慮した除外処理を追加します。 日本の祝日情報を配布しているholiday_jpと言う便利なライブラリがあったので、今回はこちらを利用させていただきました。

export const isHolidayOrBeforeHoliday = () => {
  const today = new Date();
  const tomorrow = getTomorrow();
  return holiday.isHoliday(today) || holiday.isHoliday(tomorrow);
};

https://github.com/basemachina/release-reminder-worker/blob/1c74af5b0865574b1aa565e675e88014d363de2d/src/holiday.ts#L10-L14

あとは、Cron Triggersによって起動されるscheduledハンドラーに、この除外処理によるフィルタを行った上で送信処理を実装するだけです。

const postMessages = async (webHookUrl: string, members: string) => {
  if (isHolidayOrBeforeHoliday()) {
    return;
  }
  await postReminder(webHookUrl, members);
};

export default {
  scheduled: async (_, env) => {
    await postMessages(env.SLACK_WEBHOOK_URL, env.MEMBER_SLACK_IDS);
  },
} satisfies ExportedHandler<Env>;

開発・運用してみた感想

開発自体は、元々Cloudflare Workersを使ったことがあったのですが、やはり楽でした。

まず、Cloudflare Workersの開発・デプロイに使うWranglerは、TypeScriptのトランスパイル、成果物のバンドルを巻き取ってくれるので、Webpackの設定などを特に書く必要がなく非常に手軽でした。

また、Cron Triggersのデバッグも、開発サーバーをwrangler dev --test-scheduledのフラグ付きで起動すれば、Cronスケジュールを待たずに済む、デバッグ用のエンドポイントを生やしてくれるので便利でした。(curl "http://localhost:8787/__scheduled?cron=*+*+*+*+*" で呼べます)

特に不具合もなく、運用もつつがなく回っています。日次でメンションされたメンバーが担当者となることでリリース機会が均等化されましたし、「あとでリリースしないと」という意識を持たなくても自然とリリースが行われるようになったので、心理的負荷が減ったと感じています。

おわりに

Cloudflare Workersは、思った以上に手軽に導入できる、あらゆる用途に利用可能なJavaScriptアプリケーションの実行環境です。 今回のように運用オペレーションの改善にも活用することができます。 JavaScriptを定期的に実行したいと思った時に、ぜひ選択肢として検討してみてください。

少々個人的な宣伝ですが、Software Design 2024年8月号にて、Cloudflare WorkersのCron Triggersについてのより詳しい解説を執筆しました。もし興味があれば、ぜひこちらもご覧ください。

gihyo.jp

TypeScript/JavaScriptの不要なコードを削除するツール「Knip」の紹介

こんにちは、taroです!

今回は、ベースマキナのTypeScriptのプロジェクトで不要なコードの検知・削除で使用しているKnipについて紹介します。

Knip とは

Knipは、TypeScript/JavaScriptのコードベースの不要なコードを検出するCLIツールです。

以下が検出できる不要なコードの例です。

  • package.jsondependencies/devDependenciesの中で使われていないpackage
  • exportされているがどこからもimportされていない変数、関数、型など
  • 使用していないファイル

その他、検出できる内容の一覧はこちらで確認できます。

またExperimentalな機能(2024年7月現在)として不要なコードの自動削除も可能です。

ちなみにTypeScript/JavaScriptの不要なコードの検出するツールではts-pruneも知られていますが、ts-pruneは2023年12月にPublic archiveされており、READMEでもKnipの使用が案内されています。

🚨 ts-prune is going into maintanence mode

Please use knip which carries on the spirit.

https://github.com/nadeesha/ts-prune?tab=readme-ov-file#-ts-prune-is-going-into-maintanence-mode

Knipの使い方

Knipの使い方はとてもシンプルです。

対象のプロジェクトにインストールして、ルートディレクトリで実行すると不要なコードを検出できます。

# インストール
npm install -D knip typescript @types/node

# 実行
npx knip

# 出力例
Unused files (1)
company.ts
Unused devDependencies (1)
@testing-library/jest-dom  package.json
Unlisted dependencies (1)
jest-watch-select-projects  jest.config.ts
Unused exports (1)
userIds  unknown  user.ts:1:14
Unused exported types (1)
User  type  user.ts:2:13

Knipは"zero config"を目指しているツールであり、

  • 各種linterやtest runner、ビルドツールの設定で、import文を使わず暗黙的に読み込むpackageがある
  • monorepoで複数のpackage.jsonがある

といった場合でも、ほとんど追加の設定なしで使用できます。

個人的にこういった開発の補助的なツールは、設定が複雑だと導入後にエラーが発生した場合に、対処が放置されがちなのでzero configな点はとてもありがたいです。

ではKnipはどのようにして不要なコードの検出をzero configで実現しているのでしょうか?

Knipが不要なコードを検出する仕組み

Knipが不要なコードを検出する仕組みを理解する上で、重要な概念が以下の2つです。

エントリーファイル

エントリーファイルは、不要なコードを検出する際の起点となるファイルです。

デフォルトではindex.tsmain.tsxなどがエントリーファイルです。*1

Knipはエントリーファイルから順番にimportされているファイルを解析していき、使用しているpackageやexportされている変数、関数、型などを検出します。

ファイルの解析後、使用しているpackageをpackage.jsondependencies/devDependenciesと比較して、差分がある場合は

  • unused(使用していない)
  • unlisted(使用しているがdependencies/devDependenciesにない)

dependencyとして出力します。

Unused devDependencies (1)
dayjs  package.json
Unlisted dependencies (1)
date-fns  src/utils/date.ts

しかしindex.tsmain.tsxを解析するだけでは検出できないパターンがあります。

そこで登場するのがプラグインです。

プラグイン

プラグインは各ライブラリ(各種linterやtest runner、ビルドツールなど)で使用しているファイルから不要なコードを検出する機能です。

プラグインは主に以下の3つを行います。

  • 各ライブラリに応じたプラグインの有効化
  • 各ライブラリに応じたエントリーファイルの追加
  • 各ライブラリの設定ファイル内で使用しているpackageの検出

それぞれJestのプラグインを例に説明します。

各ライブラリに応じたプラグインの有効化

まずは各ライブラリに応じたプラグインの有効化です。

プラグインの有効化に追加の設定は不要です。

Jestのプラグインでは、package.jsondependencies/devDependenciesjestがあれば自動で有効化されます。*2

各ライブラリに応じたエントリーファイルの追加

次にエントリーファイルの追加です。

Jestのプラグインでは、**/__tests__/**/*.[jt]s?(x)**/?(*.)+(spec|test).[jt]s?(x)がエントリーファイルに追加され、テストファイルのみで使用しているpackageやexportされている変数、関数、型なども検出できるようになります。

各ライブラリの設定ファイル内で使用しているpackageの検出

最後に設定ファイル内で使用しているpackageの検出です。

Jestのプラグインでは、以下の設定ファイルを解析して使用しているpackageを検出します。

  • jest.config.{js,ts,mjs,cjs,json}
  • package.jsonjestの値

例えば、以下の設定ファイルがある場合、Knipはjest-watch-select-projectsを使用しているpackageとして検出します。

// jest.config.ts

export default {
  watchPlugins: ["jest-watch-select-projects"],
};

以上のようにKnipでは各種ライブラリを使用している場合でも、プラグインを使用してzero configで不要なコードを検出できるようになっています。

プラグインは多数用意されており、以下が一例です。

プラグインの一覧はこちらで確認できます。

プラグインでpackageが正しく検出されない場合の対処方法

Knipはzero configとはいえ、追加設定が必要な場合もあります。

ベースマキナで導入した際は、プラグインに関連したpackageが

  • 使用しているのにunusedなdependencyとして検出されてしまう
  • 使用していないのにunlistedなdependencyとして検出されてしまう

のように、正しく検出されない場合があったので、最後にその対処方法を紹介します。

正しく検出されないpackageを無視する前に検出できない原因を考える

この場合最初に思いつく方法は、Knipのプラグインの誤検出として正しく検出されないpackageをignoreDependenciesに追加する設定です。

// knip.ts

export default {
  ignoreDependencies: ["jest-watch-select-projects"],
};

私も当初はこの方法を取ることが多かったのですが、よくよく調べてみると誤検出ではなく、そもそもプラグインが有効化されてなかったり、ファイルが解析されていないことがほとんどでした。

そのため現在は、無視する前に一度そのpackageが検出できない原因を以下の流れで考えるようにしています。

プラグインが有効化されているか確認する

まずはそのライブラリのプラグインが有効化されているか確認します。

Knipでは--debugオプションをつけると、実行結果に有効化されたプラグインが表示されます。

npx knip --debug

# 出力例
# ...
[.] Enabled plugins
[ 'ESLint', 'Jest' ]
# ...

プラグインが有効化されていない場合

もし有効化されていない場合は、原因を調査してみます。

プラグインが有効化される条件は、ドキュメントの各プラグインのページの"Enabled"に記載されています。

例えばJestのプラグインが有効化される条件は以下のように記載されています。

This plugin is enabled when there’s a match in dependencies or devDependencies in package.json:

  • jest

https://knip.dev/reference/plugins/jest#enabled

またKnipはTypeScriptで実装されているためソースコードからも比較的簡単にプラグインが有効化される条件を確認できます。

https://github.com/webpro-nl/knip/tree/main/packages/knip/src/plugins

プラグインPlugin interfaceを満たすオブジェクトをexportしており、その中のisEnabledメソッドで有効化されるかどうかを判定しています。

例えば以下がJestのプラグインisEnabledメソッドです。

// https://github.com/webpro-nl/knip/blob/main/packages/knip/src/plugins/jest/index.ts#L11-L14

const enablers = ["jest"];

const isEnabled: IsPluginEnabled = ({ dependencies, manifest }) =>
  hasDependency(dependencies, enablers) ||
  Boolean(manifest.name?.startsWith("jest-presets"));

基本的にプラグインに対応したライブラリがpackage.jsondependencies/devDependenciesに含まれているかどうかが、プラグインが有効化される条件ですが、プラグインによってはそれ以外の条件が含まれている場合もあるので、一度ドキュメントやソースコードを確認するのがおすすめです。

プラグインが有効化されているが正しく検出されない場合

次はプラグインが有効化されているが正しく検出されない場合です。

Knipが不要なコードを検出する仕組み」で述べたように、Knipは主に以下の2箇所からpackageを検出しています。*3

そのため、次はプラグインが追加するエントリーファイルとプラグインに対応したライブラリの設定ファイルを確認してみます。

ドキュメントの各プラグインのページの"Default configuration"の

に記載されています。

例えばJestのプラグインでは以下のように記載されています。

This configuration is added automatically if the plugin is enabled:

{
  "jest": {
    "config": ["jest.config.{js,ts,mjs,cjs,json}", "package.json"],
    "entry": ["**/**tests**/**/_.[jt]s?(x)", "**/?(_.)+(spec|test).[jt]s?(x)"]
  }
}

https://knip.dev/reference/plugins/jest#default-configuration

有効化の条件と同様に、エントリーファイルと設定ファイルも該当する実装箇所を確認してみます。

エントリーファイルと設定ファイルは、Pluginentryconfigに対応しています。

以下がJestのプラグインentryconfigです。

// https://github.com/webpro-nl/knip/blob/main/packages/knip/src/plugins/jest/index.ts#L16-L18

const entry = ["**/__tests__/**/*.[jt]s?(x)", "**/?(*.)+(spec|test).[jt]s?(x)"];
const config = ["jest.config.{js,ts,mjs,cjs,json}", "package.json"];

entryconfigを確認して、正しく検出されないpackageを使用しているファイルが含まれていない場合は、Knipの設定ファイルに追加します。

// knip.ts

export default {
  jest: {
    entry: ["**/__custom_tests__/**/*.ts"],
    config: ["jest.config.ts"],
  },
};

entryconfigを追加する場合は、デフォルトの値は上書きされるためご注意ください。

どうしても正しく検出されない場合はKnipにコントリビュートするチャンスかも?

もしどうしても正しく検出されない場合は、Knipにコントリビュートするチャンスかもしれません。

ベースマキナのリポジトリに導入した際には、以下の修正を行いました。

  • 不要コードの自動修正でignoreignoreDependenciesなどの設定が適用されないバグの修正

    github.com

  • GraphQL-Codegenプラグインで、正しく検出できるのが@graphql-codegen/で始まるpackageのみだったので、その他のpackageでも検出できるように修正

    github.com

  • GraphQL-Codegenプラグインで、GraphQL Configの設定ファイルのサポート

    github.com

  • webpackのプラグインで、webpackの設定ファイルのoneOfで使用しているpackageのサポート

    github.com

Knipは実装がTypeScriptで、またプラグインのみであればとてもシンプルなので、比較的理解がしやすいと感じました。

作者のLarsさんもとても親切でPull Requestを送るたびに、Tweetにも反応をもらえてとても嬉しかったです!

おわりに

今回は、TypeScript/JavaScriptの不要なコードを検出するツール「Knip」について紹介しました。

Knipはzero configを目指しているツールで、簡単に導入できるのでよかったらぜひ使ってみてください。

*1:正確には{index,main,cli}.{js,cjs,mjs,jsx,ts,cts,mts,tsx}、src/{index,main,cli}.{js,cjs,mjs,jsx,ts,cts,mts,tsx}、package.jsonのmain,bin,exportsのファイル、package.jsonのscriptsで指定されているファイルがエントリーファイルとなります。https://knip.dev/explanations/entry-files

*2:正確にはpackage.jsonのnameがjest-presetsで始まる場合にも有効化されます。https://github.com/webpro-nl/knip/blob/main/packages/knip/src/plugins/jest/index.ts#L13-L14

*3:正確にはpackage.jsonのscriptsからも検出しています。https://knip.dev/explanations/entry-files#scripts-in-packagejson

Goで0秒待つとどうなるか

こんにちは。yebis0942です。GoとTypeScriptを書いています。夏祭りのおみくじで「待ち人来る」を引いたので、最近のちょっとした待ち事例についてご紹介します。

Goでタイムアウト時間を指定する関数を呼び出したとき、待機時間を0秒にすると何が起きるのか?という点が社内のレビューで少し話題になりました。

気になって調べてみたところ、同じ0秒のタイムアウト処理でも、内部の実装によって振る舞いが異なるケースがあることが分かりました。

よく見るタイムアウト処理

Go言語では、一定時間だけあるchannelを待つというタイムアウト処理は以下のように time.After() を使って書くことができます。

func timeAfter(c chan int, duration time.Duration) {
    select {
    case <-time.After(duration): // durationの経過後に値が入るchannelを返す
        fmt.Println("Timeout")
    case <-c:
        fmt.Println("OK")
    }
}

context.WithTimeout() を使って、次のように書き換えることもできます。

func contextWithTimeout(c chan int, duration time.Duration) {
    ctx, _ := context.WithTimeout(context.Background(), duration)

    select {
    case <-ctx.Done(): // durationの経過後に値が入るchannelを返す
        fmt.Println("Timeout")
    case <-c:
        fmt.Println("OK")
    }
}

ちなみにtime.After()context.WithTimeout()も、内部的にはtime.NewTimer()を使ってタイマー処理を実現しています。

振る舞いの違い

外側も内側もよく似たコードですが、durationを0秒にして、値を送信済みのchannel c を渡してみると、振る舞いが異なることが分かります。

timeAfter() はほぼ常にOK を表示しますが、contextWithTimeout()OKTimeout を半々の確率で表示します。

どちらも内部では time.NewTimer() を使っていて、同じようにselectで待ち受けているのに、いったいなぜ…?

理由

この振る舞いの違いは、それぞれのchannelからの受信がブロックされるかどうかの違いによります。

たとえばバッファなしのchannelの場合、channelからの受信がブロックされるとは以下のような状況を指します。

c := make(chan int)
go func() { c <- 1 }()
<-c // 別のgoroutineから値を送信しているのでブロックされない
<-c // 受信できる値がないのでブロックされる

time.After() は待機時間が0秒であってもGoの内部のタイマーに登録して待ち受け処理を行なう

待機時間として0以下の値が指定された場合には、以下のように発火予定時刻を現在時刻に丸めてからタイマーに登録します。

func when(d Duration) int64 {
    if d <= 0 {
        return runtimeNano() // 注: 現在時刻をナノ秒で返す関数
    }
    // 略
}

time/sleep.go#L29-L31

そのため、タイマーが発火する前にselectの評価が開始されると、time.After()のchannelからの受信はまだブロックされたままです。

context.WithTimeout() は待機時間が0秒以下であれば即座に自身をキャンセルする

一方で、context.WithTimeout() は待機時間が0秒以下であればタイマーを登録しません。

以下のように、即座に自身をキャンセルします。

// 注: 実際の処理はWithDeadlineCause()に集約されている
func WithDeadlineCause(parent Context, d time.Time, cause error) (Context, CancelFunc) {
    // 略
    dur := time.Until(d)
    if dur <= 0 {
        c.cancel(true, DeadlineExceeded, cause) // deadline has already passed
        return c, func() { c.cancel(false, Canceled, nil) }
    }
    // 略
}

context.go#L630-L634

そのため、contextWithTimeoutではctx.Done()のchannelからの受信はブロックされていないことが保証されます。

select はブロックされていないchannelをランダムに選択する

では、なぜselectで上に置かれているchannel ctx.Done()ではなくchannel cが選択されることがあるのでしょうか?

The Go Programming Language Specification - Select statementsでは、selectのchannelの選び方を次のように説明しています。

If one or more of the communications can proceed, a single one that can proceed is chosen via a uniform pseudo-random selection.

参考訳: 1つ以上の通信が進行可能であれば、偏りのない擬似ランダムな選択方法によって、進行可能な通信を1つ選ぶ。

これにより、<-ctx.Done()<-cが均等に選択されることになります。

むすび

Go言語のタイムアウト処理のコーナーケースについてご紹介しました。

ちなみにGo 1.23ではtimerのchannelの仕様が改善されており、Reset()Stop()が安心して利用できるようになっています。8月中盤のリリースを楽しみに待ちたいと思います。

ベースマキナのエンジニアブログを始めます

こんにちは、syumaiです!

このたびベースマキナのエンジニアブログを始めることになりました!

本記事では、エンジニアブログを始めることになった経緯や、これからどういった内容について書いていくのかなどについて簡単に紹介します!

ベースマキナとは

今回は、一本目の記事ということで、簡単にサービスの紹介もさせてください!

ベースマキナは、一言で言うと管理画面のSaaSです。特に社内向けの管理画面をメインの用途としたプロダクトです。

社内向けの管理画面は専任のチームが存在しないことも多く、

  • ライブラリの更新、新機能追加のメンテナンスが滞る
  • 権限管理の実装を十分に行えない

といった課題を抱えている場合があります。

ベースマキナでは、任意のデータベースやAPI (REST API / gRPCなど) への接続を行ったうえで、

  • 処理実行用のフォームの生成
  • 入力値へのバリデーション
  • 権限管理、承認フローの追加

といったことが組み込みの機能で実現できます。

管理画面を開発するにあたって特に面倒な機能を一通り揃えているので、管理画面の維持コストの削減にご活用いただけます。

詳しくは、サービスの紹介サイトをご覧ください。

about.basemachina.com

それでは、ここから本題に入っていきます。

ベースマキナ エンジニアブログの目的

ブログ開設の目的は、端的に言うと広報です!

ベースマキナはリリースから2年以上が経過し、非常にありがたいことに、ご利用いただいているお客さまの数が順調に増えています。

その結果として数多くのご要望をいただいており、サービスを利用したいユースケースの幅もどんどん広がっています。自分たちでは想像もつかなかった提案をいただくこともあり、まさにお客さまと並走しながらサービスを開発している実感があります。

しかしながら、いただいたご要望や新しいユースケースに対応するスピードにはまだ満足できていません。 開発のスピードを上げていくには、当然のことですが組織の規模を拡大していく必要があります。 そこで最近ぶつかっているのが、サービス認知度の壁です。

YOUTRUSTさんの募集機能を使って社長のtimakinがエンジニアの方を対象に面談の募集を行っていましたが、面談に来てくださった方のほとんどが、もともとベースマキナのサービスを知っていて来たと言うより、YOUTRUST上で偶然募集を見付けていらしたようでした。

採用に関しても、新規のお客さまによるサービスの導入に関しても、まずは私たちのことを知っていただかないと始まらないということを痛感しています。

本ブログは、継続的にベースマキナの開発メンバーが発信を行うことで、ベースマキナのことを知っていただく機会を増やすことを目的としています。

ブログのコンテンツ

コンテンツとしては、

  • 新機能の技術的な背景
  • 意思決定の裏側
  • 開発に関する小ネタ

など、あまりテーマを限定しすぎず書いていこうと思っています。

ベースマキナでは、BackendにGo (gqlgen)、FrontendにNext.jsを採用しているので、それらに関連した記事が多くなると思います。

また、継続することを第一目標としているので、小さめの記事の割合が高くなりそうです。大きな機能のリリースに際しては、実装に大きく踏み込んだ紹介も行いたいです。

始めることになった経緯

(やや個人的な話ですが)最近オフラインのカンファレンスに行く機会が増え、会場で出会った方とお話しする機会が何度かあったのですが、「ベースマキナ」のサービス名を十分に認知いただけているとは言えないと感じました。 この状況をそのままにしておくことは出来ないということで、打ち手を考えないといけないと社内で相談を行った結果、会社のメンバーでブログを書いていくことになりました。

また、Go Conference 2024の懇親会でお話ししたcatatsuyさんからいただいたアドバイスや、tenntenn Conference 2024での「自社の魅力を候補者に知ってもらう方法」についてのパネルディスカッション (tenntennさん、赤川さん、パウリさんが登壇) も強い後押しとなりました。継続的な発信が組織を強化するということを、きちんと認識する機会をいただけました。パネルディスカッションについてはアーカイブも残っているので、気になる方はぜひご覧ください!

なぜはてなブログを選んだのか?

会社の開発チームの顔として技術記事を投稿する場を考えた時、ヘッダーなど、自分たちのサイトとしてカスタマイズしたページを表示できる点は重要だと思いました。

技術記事を投稿するプラットフォームに組織のアカウントを作成して記事を書いていくのも、執筆体験や、サービス内での露出の面で有利な点が多々存在すると思います。しかしながら、あくまでそのプラットフォーム自体に目が行ってしまい、「どの組織によって書かれた記事なのか」という意識はやや薄くなるという感覚を個人的には持っています。(個人差があると思います)

はてなブログはその点、細かいデザインのカスタマイズが出来ますし、独自ドメインを利用することもできます。 はてなさんは、企業の技術ブログを特集するページも用意されていたり、技術ブログが読まれやすくなるよう支援する取り組みを行っています。 ブログ記事をGitHubで管理・レビューできるようにするためのボイラープレートも用意してくださっていて、執筆体験も他のプラットフォームとほぼ遜色ないと感じています。

これらを踏まえて、今回ははてなブログさんを採用することに決めました。

最後に

今回は第1回目の記事ということで、サービスの紹介に始まり、エンジニアブログを始めるに至った経緯や目的について書かせていただきました。

これから、(無理のない範囲で)できる限り多くの記事を出していきたいと思っているので、もし応援していただけるという方はブログの購読をぜひお願いします!