mizdra's blog

ぽよぐらみんぐ

コードジャンプ可能な CSS Modules を実現する happy-css-modules の紹介

弊社では React で CSS を書くための手法として CSS Modules を全面的に採用しています。そこで CSS Modules を使った開発をより快適にするために、「happy-css-modules」というツールを作りました。

TSX ファイルから CSS Modules のクラス名を Command+Click して、.module.css ファイルの定義場所にコードジャンプしている様子の動画です。
happy-css-modules のデモ。

この記事ではこのツールが必要になった背景、導入方法、そしてツールの技術的な仕組みについて紹介します。

CSS Modules の問題点と、typed-css-modules による解決

CSS Modules では、デフォルトでは存在しないクラス名を使用しても、(プロジェクトの設定次第ですが) TypeScript のコンパイルエラーが出ることはありません。

import styles from './Button.module.css';

function Button() {
  return (
    <button
      className={styles.botton}>
  //             ^^^^^^^^^^^^^ (u を o に typo してる)
  // (プロジェクトが用意している型定義ファイルによるが) create-react-app で作られたプロジェクトでは
  // 存在しないクラス名も実在するかのように見せかける緩い型定義ファイルがあるため、
  // styles.undefined_selector は string になる。そのためコンパイルエラーにもならない。
  // ref: https://github.com/facebook/create-react-app/blob/f99167c014a728ec856bda14f87181d90b050813/packages/react-scripts/lib/react-app.d.ts#L58-L61
      Click me!
    </button>
  );
}

そこでこの問題を解決するために、typed-css-modules というツールが存在します (sass/less 向けに、それぞれ typed-scss-modules/typed-less-modules というツールもあります)。このツールを使うと、*.module.cssファイルに定義されているクラス名を検出し、そのクラス名にのみアクセス可能な型定義ファイルを生成できます。これにより未定義なクラス名を参照したとしても、コンパイルエラーとして検出できます。

// Button.module.css
.button { border: 1px solid #333; }
.text { color: red; }
// Button.module.css.d.ts

// typed-css-modules により自動生成される型定義ファイル
declare const styles: {
  readonly button: string;
  readonly text: string;
};
export = styles;
// Button.tsx
import styles from './Button.module.css';

function Button() {
  return (
    <button
      className={styles.botton}>
  //             ^^^^^^^^^^^^^ (u を o に typo してる)
  // styles の型には botton プロパティが生えていないので、styles.botton はコンパイルエラーになる。
      Click me!
    </button>
  );
}

よくあるミスを防げるツールなので、CSS Modules を使用するプロジェクトでは一緒に導入していることが多いと思います。

無意味なコードジャンプ

ただし、typed-css-modules ではクラス名で定義元ジャンプをしようとしても、.module.cssファイルではなく、.module.css.d.tsファイルにジャンプしてしまいます。

typed-css-modules では、TSX ファイルから CSS Modules のクラス名を Command+Click すると.module.css.d.ts にコードジャンプしてしまう様子の動画。
typed-css-modules では .module.css.d.ts にコードジャンプしてしまう。

些細な問題のように感じますが、デザインコーディングをする時は、TSX と.module.cssの間を行ったり来たりすることが多いので、少し不便だなと感じていました。

そもそも.module.css.d.tsにジャンプしても、そこから得られるものはほぼ無いです! せっかくならもう少し有益なものへとジャンプさせて、開発体験の向上を図りたい。id:mizdra はそう考えて、この問題を解決するツールを作ることにしました。

happy-css-modules の紹介

そして作ったものが、冒頭で紹介した「happy-css-modules」です。

TSX ファイルから CSS Modules のクラス名を Command+Click して、.module.css ファイルの定義場所にコードジャンプしている様子の動画です。冒頭に掲載した動画と同じものです。
happy-css-modules のデモ。冒頭に掲載した動画と同じものです。

CSS Modules から import したクラス名を Meta+Click (Mac なら Command+Click、Windows なら Control+Click) で、CSS Modules 側の定義場所へとコードジャンプできます。

導入方法

GitHub の Usage 通りにやってもらえたら導入できると思います。

幅広いユーザのサポートを第一に設計しているため、PostCSS/Sass/Less を使っているプロジェクトにもデフォルトで対応しています。Webpack のresolve.aliasや、Sass の--load-pathオプションを使っているプロジェクトにも、CLI オプションを渡すことで対応できます。

社内では Less/Sass プロジェクトにそれぞれ導入済みで、問題なく動作してます。もし動かないなどあれば https://github.com/mizdra/happy-css-modules/issues から報告してもらえるとありがたいです。


ツールを使いたい方向けの説明はここまでで、以降は happy-css-modules の技術的な解説や、実装の裏側についての話です。

どうやってコードジャンプを実現しているのか

happy-css-modules は .module.css.d.tsを生成するのに加えて、.module.css.d.ts.mapファイルも生成しています。このファイルはgenerated (.module.css.d.ts) <=> source (.module.css) 間のコードの対応を保持している Source Map です。tsserver (VSCode 向けの TypeScript の Language Server) が.module.css.d.ts上のコードにジャンプしようとしたときに、この Source Map を元に.module.css上のコードにマップし直して.module.css側に直接ジャンプできます。

(generated)
(generated)
(original)
(original)
① button プロパティの
型定義にジャンプ
① button プロパティの...
② 元の位置情報を
Source Mapから計算
② 元の位置情報を...
③ 元の位置へと
ジャンプ
③ 元の位置へと...
Viewer does not support full SVG 1.1

Source Map を駆使してジャンプ先を .module.css に切り替える仕組み。Source Map の内容は分かりやすいよう、実際のものから簡略化してます。

同名のクラスが複数ある場合は、ちょっとしたハックを利用してます。というのも、Source Map の仕様上、.module.css.d.ts 上の1つの位置情報 <=> .module.css 上の複数の位置情報の対応を持たせられません (generated:original = 1:1 や 多:1 はできるけど、1:多 は不可能)。そこで、同名のクラス名の型をオブジェクトリテラル型で複数定義しつつ、Intersection Types (a & b) で合成したものを styles の型とし、それぞれのオブジェクトリテラルごとに別々の .module.css 上のクラス定義へとマッピングします。これで同名のクラスが複数あっても、tsserver が複数のジャンプ先を suggest してくれます。

(generated)
(generated)
(original)
(original)
① button プロパティの
型定義にジャンプ
① button プロパティの...
② 元の位置情報を
Source Mapから計算
② 元の位置情報を...
③ 元の位置へと
ジャンプ
③ 元の位置へと...
Viewer does not support full SVG 1.1

同名のクラスが複数ある場合のジャンプ先切り替えの仕組み。

happy-css-modules の着想・作ろうと思ったキッカケ

実はこの型定義ファイルを generated、型定義を生成する元になったファイルを original とする Source Map を作成するアイデアは、tsc (TypeScript コンパイラ) が --declarationMap として先に実装していて、Declaration Map と呼ばれてます。

id:mizdra が初めてこの技術を知った時、「何か面白いことに使えるのではないか」と思いました。Declaration Map を使うと、本来であれば型定義ファイルにコードジャンプするところを、別の場所にジャンプするよう変更できます。つまり、型定義ファイルが何かしらのファイルから自動生成されるような状況であれば、この技術を応用できるはずーー そう考えて、happy-css-modules の着想を得ました。

というものの、CSS Modules でコードジャンプをする実現するツールは、(一部機能に違いはありますが) すでにいくつかありました。

そのため、作ったところで需要はそこまでなかったのですが...面白い技術が狙い通りに応用できるかどうしても試したかった... つまり技術的な好奇心が作ろうと思ったキッカケです。

やるからには上手くやる

せっかく車輪の再発明をするのだから作るのだから、より良いものを作ろう!*1と考えて、以下の目標を置きました。

  • 任意の AltJS をサポートする
    • どうせやるなら全部サポートできるようにしたい
    • 便利な機能を全部の言語に提供できたら、最高のはず
    • typed-css-modules/typed-scss-modules/typed-less-modules をまとめて置き換えられると良い
  • Webpack 互換の import specifier (@import "....";"..." の部分) の解決アルゴリズムにできるだけ準拠する
    • Webpack で CSS を bundle しているユーザが、簡単に導入できるようにしたい
  • import alias もサポートする
    • @ import "@style-dir/global.css"; のように alias を使っているユーザもサポートしたい
  • とにかく使いやすくする
    • 設定ファイル無し、CLI オプションも極力無しで、可能な限り簡単に使えるものを目指す
  • 拡張性を持たせる
    • プログラマブルな API を提供して、いざとなったら "なんでもできる" ようにしたい
  • テストを充実させる
    • 趣味プロダクトはたまにしか触らないので、新機能の実装やバグの修正をするにしても、プロダクト壊してしまわないか、ついつい不安になってしまう
    • テストが充実していれば、コードの変更も気軽に自信を持ってできるようになり、メンテナンスもしやすくなるはず

そういう訳で happy-css-modules は以下のような実装になってます。

欲張りな目標でしたが、車輪の再発明なりに、上手くできたかなと思ってます。是非興味があればコードを読んでみてください。

まとめ

happy-css-modules を導入することで、typed-css-modules のようにミスを防ぎつつ、かつコードジャンプによって快適に開発できます。二番煎じなツールではありますが、従来のものと比較して様々なプロジェクトに簡単に導入できるようこだわって設計してます。是非導入してみてください!happy-css-modules や stylelint-no-unused-selectors を導入すれば CSS-in-JS と大差のない開発体験になりますし、これを機に CSS Modules を使ってくれる人が増えると良いなと思っています。

あわせて読みたい

developer.hatenastaff.com

ある npm package がどこから依存されているか調べる方法

このパッケージってどこからどう依存されてるんだっけ、と調べる時によく打ってるのでメモ。

npm

npm v7 以降であれば npm explain <package> というサブコマンドでできる。npm v7 以前は npm-whynls を別途インストールしてきて、それを使う必要があった。ちなみに npm why という名前の npm explain の alias も用意されている。

$ npm explain ts-node
ts-node@10.9.1 dev
node_modules/ts-node
  dev ts-node@"^10.9.1" from the root project
  peerOptional ts-node@">=9.0.0" from jest-config@28.1.3
  node_modules/jest-config
    jest-config@"^28.1.3" from @jest/core@28.1.3
    node_modules/@jest/core
      @jest/core@"^28.1.3" from jest@28.1.3
      node_modules/jest
        dev jest@"28.1.3" from the root project
        peer jest@"^28.0.0" from ts-jest@28.0.8
        node_modules/ts-jest
          dev ts-jest@"28.0.8" from the root project
      @jest/core@"^28.1.3" from jest-cli@28.1.3
      node_modules/jest-cli
        jest-cli@"^28.1.3" from jest@28.1.3
        node_modules/jest
          dev jest@"28.1.3" from the root project
          peer jest@"^28.0.0" from ts-jest@28.0.8
          node_modules/ts-jest
            dev ts-jest@"28.0.8" from the root project
    jest-config@"^28.1.3" from jest-cli@28.1.3
    node_modules/jest-cli
      jest-cli@"^28.1.3" from jest@28.1.3
      node_modules/jest
        dev jest@"28.1.3" from the root project
        peer jest@"^28.0.0" from ts-jest@28.0.8
        node_modules/ts-jest
          dev ts-jest@"28.0.8" from the root project

実際にインストールされているバージョンに加えて、@^a.b.c みたいに、どういうバージョン制約で依存されているのかまで出るのが便利 (この制約のせいで major バージョンアップができない…みたいなのがひと目で分かる)。

yarn

yarn why <package> でできる。こちらは昔からある。

$ yarn why minimist
yarn why v1.22.19
[1/4] 🤔  Why do we have the module "minimist"...?
[2/4] 🚚  Initialising dependency graph...
[3/4] 🔍  Finding dependency...
[4/4] 🚡  Calculating file sizes...
=> Found "minimist@1.2.5"
info Has been hoisted to "minimist"
info Reasons this module exists
   - Hoisted from "json5#minimist"
   - Hoisted from "netlify-lambda#babel-loader#mkdirp#minimist"
   - Hoisted from "netlify-lambda#webpack#mkdirp#minimist"
   - Hoisted from "webpack-dev-server#portfinder#mkdirp#minimist"
   - Hoisted from "workbox-webpack-plugin#workbox-build#@surma#rollup-plugin-off-main-thread#json5#minimist"
info Disk size without dependencies: "104KB"
info Disk size with unique dependencies: "104KB"
info Disk size with transitive dependencies: "104KB"
info Number of shared dependencies: 0
=> Found "mkdirp#minimist@0.0.8"
info This module exists because "mkdirp" depends on it.
info Disk size without dependencies: "72KB"
info Disk size with unique dependencies: "72KB"
info Disk size with transitive dependencies: "72KB"
info Number of shared dependencies: 0
=> Found "tsconfig-paths#minimist@1.2.0"
info Reasons this module exists
   - "eslint-plugin-import#tsconfig-paths" depends on it
   - Hoisted from "eslint-plugin-import#tsconfig-paths#json5#minimist"
info Disk size without dependencies: "96KB"
info Disk size with unique dependencies: "96KB"
info Disk size with transitive dependencies: "96KB"
info Number of shared dependencies: 0
=> Found "netlify-lambda#json5#minimist@1.2.0"
info This module exists because "netlify-lambda#webpack#loader-utils#json5" depends on it.
info Disk size without dependencies: "96KB"
info Disk size with unique dependencies: "96KB"
info Disk size with transitive dependencies: "96KB"
info Number of shared dependencies: 0
=> Found "babel-loader#json5#minimist@1.2.0"
info This module exists because "netlify-lambda#babel-loader#loader-utils#json5" depends on it.
✨  Done in 0.23s.

yarn whynpm why と違って、どういうバージョン制約で依存されているのかまでは出ない。バージョン制約を調べたければ、cat node_modules/tsconfig-paths/package.json | jq .dependencies.minimist とかやって頑張るしかないはず (もっと良い方法あれば教えて下さい)。

$ cat node_modules/tsconfig-paths/package.json | jq .dependencies.minimist
"^1.2.0"

pnpm

pnpm why <package> でできる。

$ pnpm why postcss
Legend: production dependency, optional only, dev only

happy-css-modules@0.4.0 /Users/mizdra/src/github.com/mizdra/happy-css-modules

dependencies:
postcss 8.4.17
postcss-modules 4.3.1
├── postcss 8.4.17 peer
├─┬ postcss-modules-extract-imports 3.0.0
│ └── postcss 8.4.17 peer
├─┬ postcss-modules-local-by-default 4.0.0
│ ├─┬ icss-utils 5.1.0
│ │ └── postcss 8.4.17 peer
│ └── postcss 8.4.17 peer
├─┬ postcss-modules-scope 3.0.0
│ └── postcss 8.4.17 peer
└─┬ postcss-modules-values 4.0.0
  ├─┬ icss-utils 5.1.0
  │ └── postcss 8.4.17 peer
  └── postcss 8.4.17 peer

だいたい yarn why と同じだけどこちらのほうがスッキリしてる。あと依存の種類 (dependencies なのか devDependencies なのか peerDependencies なのか) が出ているのが特徴。

ちなみに pnpm why -D <package>devDependencies に絞ったりもできる。

まとめ

  • <npm|yarn|pnpm> why と打てば良い
  • npm why はどういうバージョン制約で依存されているのかまで出て便利

qwik の発明、及びマイクロフロントエンドへの活用について

最近調べた qwik というライブラリが結構面白かったので、実際どういうものなのかとか紹介してみます。

qwik とは

qwik は Web 向けの View ライブラリです (React や Vue.js の仲間)。パフォーマンスオタクがパフォーマンスの最適化 (Web Vitals の改善) にこだわって作ったライブラリです *1

すでにいくつも良い紹介資料があるので、まずはこれらをいくつか読んでみると良いと思います。

qwik の詳しい使い方などは先人の記事に譲ることにして、以降は id:mizdra が個人的に面白いと思ったことを書いていきます。

Hydration の問題点

最近は SEO であったりページの初期表示に掛かる時間 (FP/FCP/LCP など) の改善のために、SSR をするのが一般的になっていると思います。SSR では、ユーザからリクエストを受け取った時、サーバー上で React コンポーネントツリーをレンダリングして HTML へとシリアライズし、それがレスポンスとして返されます。HTML 中に最初からコンテンツが描画されているため、CSR と比較すると、コンテンツを早いタイミングでユーザに表示できます。

しかし、@aiji42 さんの Qwikの基本概念である Resumable を理解する で語られているように、SSR には Hydration という処理をどうしても挟む必要があります。ページ中に存在する全てのコンポーネントのソースコードを DL して、それを実行して、(onClick プロパティなどで渡された) イベントリスナーを DOM に登録していきます。コンポーネントのソースコードの DL や評価、などなど様々な計算が発生し、ユーザの貴重な計算リソースを奪ってしまいます。特にこうした計算は、計算リソースがシビアなページの表示の初期に発生するため、FP/FCP/LCP といった Web Vitals に強い影響を与えます。

イベントリスナーが設定されないと、ユーザがクリックしてページに変化を起こすことはできないですし、コンポーネントのソースコードを DL しないと、イベントリスナーの実装や、コンポーネントを再レンダリングした時に、どんな DOM へと書き換えるべきかの情報も得られません。そのため SSR では Hydration がどうしても必要な操作になってきます。

qwik の発明

...のですが、qwik はこれをうまい手法で解決します。qwik はユーザがイベントを発生させるまで、コンポーネントのソースコードを DL しません。逆に、ユーザがイベントを発生させたら、コンポーネントのソースコードを DL して、再レンダリングをします。

ただ、それだけだと DOM にイベントリスナーは設定されていないので、ユーザのイベントをキャッチできません。そこで qwik はランタイム *2 側で一括して window にイベントリスナーを仕掛けます。

イベントリスナーが呼び出されたら、DOM に書いてある on:click のような属性を読みに行きます。ここにはコンポーネントのイベントリスナーのコードが書かれているファイル・ファイル内での位置が書かれているので、これを元に、真のイベントリスナーのコードを DL してきて、それを実行します。

つまり、コンポーネントのソースコードの DL 無しにイベントリスナーを登録し、イベントリスナーが発火した瞬間にコンポーネントのソースコードを DL して、再レンダリングをする仕組みになってます。

コンポーネントのソースコードは、ユーザが何か操作をするまでは DL されません。ページの初期の表示で DL される JS は、qwik のランタイムくらいです。どれだけページが肥大化してバンドルサイズが増えても、初期表示には qwik ランタイム程度の JS しか必要ありません。qwikが「アプリケーションの規模によって初期ロードの JS のサイズが変わらない O(1) フレームワーク」とうたっているのは、これが所以です。

qwik の特徴。Resumable な JavaScript フレームワーク Qwik を学ぶ (p. 10) より引用。

マイクロフロントエンドへの活用

先日 Cloudflare 社から Cloudflare Workers と qwik を組み合わせて、マイクロフロントエンドを実装する PoC の紹介記事が公開されました。

こちらも日本語で解説記事がいくつか出ているので、読んでみると面白いと思います。

マイクロフロントエンドは、巨大なフロントエンドを複数の独立したチームで開発できるようにする開発パターンのことです。とにかく雑に説明すると、チームごとにコンポーネントを作り (動画プレイヤーチームなら動画プレイヤーコンポーネントを、ヘッダーチームならヘッダーコンポーネントを、レコメンドチームなら関連動画一覧コンポーネントを)、チームごとにそれをデプロイして、デプロイしたものを組み合わせて 1 つのページ (動画閲覧ページなど) を作ります。これにより他のチームの手を借りずにチームごとに単独でデプロイしたり、チームごとに好きな技術を選択したり、チームごとにコードの書き直しができたりと、チームごとに独立して開発できる体制を実現できます。

マイクロフロントエンドではチームごとにビルドして、チームごとにその成果物をデプロイします。デプロイがチームごとにされるため、何もしないとコードの共通化が一切されません。例えば複数のチームで、同じバージョンの React のようなライブラリを使っていたとしても、別々に bundle されるので、どちらの成果物にも React のランタイムのコードが bundle されてしまいます。

一応 webpack の Module Federation にこうしたコードを共有する仕組みがありますが、共有するライブラリを手動で選択する必要があり、中々難しいです。

その点 qwik はこの問題を解決するのにうってつけです。というのも、qwik は「プリケーションの規模によって初期ロードの JS のサイズが変わらない O(1) フレームワーク」です。どれだけページが肥大化してバンドルサイズが増えても、初期表示には qwik ランタイム程度の JS しか必要ありません。これはマイクロフロントエンドであっても同じです。ユーザが操作をするまでコンポーネントの JS は DL されないので、どれだけそれぞれのコンポーネントのコードが大きくても、ページの初期表示へのパフォーマンス影響はありません。

qwik 以外の View ライブラリでは、それぞれのチームが初期表示へパフォーマンス影響に注意を払う必要がありましたが、qwik を使うと (一切気にしなくて良いわけでないにせよ) ほとんど気にしなくて良くなります。チーム間で bundle size の上限を巡っておしくらまんじゅうすることなく、よりそれぞれのチームが自律的に動けるようになります。

トレードオフ

夢みたいな技術には多くの場合、それ相応の代償が付いてきます。

イベントリスナー非同期問題

イベントリスナーが呼び出されたら fetch API を叩いて、真のイベントリスナーのソースコードを DL し始めます。そのため、真のイベントリスナーは非同期に呼び出されます。Web 標準のイベントシステムはイベントリスナーが同期的に処理されることを前提に作られているので、非同期にやると色々とほころびが出てきます。qwik 側で独自のキャンセル機構が用意されてますが、標準の方法とは違うのでちょっとびっくりしますね。

一応同期的にイベントリスナーを呼び出す脱出ハッチのようなオプションは用意されているようです。

https://qwik.builder.io/tutorial/events/synchronous/qwik.builder.io

あとはクリックイベントリスナーの実行と CSS のアニメーションのタイミングがユーザからズレて見えたりとか… コンポーネントごとにソースコードを DL する仕組みなので、1回の操作で DL される JS のサイズは小さいはずで、あまり問題にならないような気もする...? 低速な回線を使っているユーザだと違和感を感じられるかもしれない...?

このあたりは Qwik City という Qwik を使ったフレームワークの prefetch で軽減できるようです。ちゃんと用意されているんですねー。

XSS/コンポーネントの DL タイミング問題

on:click="悪意のあるファイル#悪意のあるイベントリスナーの名前" みたいな属性を埋め込まれたらどうなるのかとか… 流石に対策してそうではあるけどどうやってるんでしょうね (Issue を探した限りでは言及されてなさそう)。

...まあそういう感じで他にも色々な代償があると思います。

さいごに

@KawamataRyo さんの発表の中であった「アプリケーションの規模によって初期ロードの JS のサイズが変わらない初の O(1) フレームワーク」という言葉に惹かれて調べてみたのですが、面白い発見がいくつもあって良かったです。id:mizdra はマイクロフロントエンドもやらないし *3、React Server Component のようなアプローチのほうが好きなので Not for me かなという感じですが、刺さる人には刺さるライブラリかなと思っています。皆さんも興味があれば触ってみてください。

*1:実際に公式サイトに「Built by Performance Nerds」と書いてある

*2:ランタイムというのは仮想 DOM の計算などをするライブラリのコアのこと

*3:というか日本でマイクロフロントエンドやってる人果たしてどれくらい居るんだろう…

msw で handler の mock や spy をする

元々「ServiceWorker をベースにした技術をわざわざテストに持ち込む意味とは?」と思って、msw をテスト環境で使う意義について懐疑的だったのですが、いざ使ってみるとすごく便利ですね。ServiceWorker 云々以前に、ネットワークリクエストの mock ライブラリとして、インターフェイスがとても使いやすいです。

zenn.dev

特に handler の mock や spy が簡単にできるのが嬉しいです。Jest と組み合わせた時の例だと、以下のようなイメージ。

// jest.setup.ts
import { server } from './src/test-utils/msw';

beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
// src/test-utils/msw.ts
import { setupServer } from 'msw/node';
import { graphql, rest } from 'msw';

export type TodoAddRequest = { title: string };
export type TodoAddResponse =
  { type: 'success' }
  | { type: 'error', error: string };

const handlers = [
  // デフォルトの handler をここで定義しておく。
  rest.post<TodoAddRequest>('/todo/add', (req, res, ctx) => {
    return res(
      ctx.json<TodoAddResponse>({ type: 'success' }),
    );
  }),
  // ...
];

export const server = setupServer(...handlers);
// src/components/TodoList.test.ts

import { server, TodoAddResponse } from '../test-utils/msw';

// mock の例

test('todo 追加時にエラーになったら、エラーメッセージが表示される', async () => {
  server.use(rest.post('/todo/add', (req, res, ctx) => {
    // 既に `/todo/add` 向けに登録されている handler を無視して、
    // この handler で上書きする (mock)
    return res(
      ctx.status(500),
      ctx.json<TodoAddResponse>({
        type: 'error',
        error: 'TODO は 10 個までしか登録できません。',
      }),
    );
  }));

  render(<TodoList />);
  
  await userEvent.type(screen.getByLabelText('title'), '買い物に行く');
  await userEvent.click(screen.getByRole('button', { name: '追加' }));

  expect(screen.getByRole('alert')).toHaveTextContent(
    'TODO は 10 個までしか登録できません。',
  );
});

// spy の例

test('todo を追加できる', async () => {
  const todoAddSpy = jest.fn();
  // これを `server.use` に渡す。 `return res(...)` していないので、
  // デフォルトの handler まで貫通する
  server.use(rest.post('/todo/add', todoAddSpy));

  render(<TodoList />);
  
  await userEvent.type(screen.getByLabelText('title'), '買い物に行く');

  expect(todoAddSpy).not.toBeCalled();
  await userEvent.click(screen.getByRole('button', { name: '追加' }));

  // handler が期待通りの呼ばれ方をしたかをチェック
  expect(todoAddSpy).toBeCalled();
  expect(todoAddSpy).toBeCalledWith(expect.objectContaining(
    body: {
      title: '買い物に行く',
    },
  ));
}

テストケース側で server.usereturn res(...) を含む handler を登録すれば mock が、return res(...) を含まない handler を登録すれば spy ができます*1。便利ですね。

*1:よく考えてみたら、デフォルトの handler の入力を傍受できるだけで出力は傍受できないので、spy と言えるかどうか微妙な気がしてきた...

過去に git clone した OSS リポジトリを一覧する

ということをふと思いついた。定量的に物事を見れると良い振り返りができて嬉しいと思う。とりあえず集計だけでもやってみる。

shell の history から一覧を作る

どうやって一覧しよう、と思ってまず思いついたのが、shell の history を使う方法。ghq list で探しても良いけど、ディスク容量が減ってきたら見なくなったリポジトリを削除したりするので、やっぱり shell history からたどるのが良いだろうというアイデア。

history 1 の出力を grep すれば良いだけで、ちょっと前にも id:onk さんが似たようなことをされていた。

onk.hatenablog.jp

$ history 1 | awk '{ print $2,$3,$4 }' | grep -e 'git clone' | awk '{ print $3 }'
git@github.com:mizdra/typed-less-modules.git
git@github.com:vercel/next.js.git
git@github.com:madyankin/postcss-modules-example.git
...

普段は git clone ではなく ghq get を使っているので、OR 検索する。

$ history 1 | awk '{ print $2,$3,$4 }' | grep -e 'git clone' -e 'ghq get' | awk '{ print $3 }'
git@github.com:mizdra/typed-less-modules.git
git@github.com:vercel/next.js.git
git@github.com:madyankin/postcss-modules-example.git
git@github.com:mrmckeb/typescript-plugin-css-modules.git
--help
--shallow
...

ghq get --shallow <url> のようなケースが上手く考慮できていないので考慮してやる。ついでに --help は除外する。

$ history 1 | awk '{ print $2,$3,$4,$5 }' | grep -e 'git clone' -e 'ghq get' | grep -v '\-\-help' | awk '{ print $3,$4 }'
git@github.com:mizdra/typed-less-modules.git
git@github.com:vercel/next.js.git
git@github.com:madyankin/postcss-modules-example.git
git@github.com:mrmckeb/typescript-plugin-css-modules.git
--shallow git@github.com:vercel/next.js.git

id:mizdragit sclone という git clone --shallow の alias も使っているので、これも考慮する。

$ history 1 | awk '{ print $2,$3,$4,$5 }' | grep -e 'git clone' -e 'ghq get' -e 'git sclone' | grep -v '\-\-help' | awk '{ print $3,$4 }'
git@github.com:mizdra/typed-less-modules.git
git@github.com:vercel/next.js.git
git@github.com:madyankin/postcss-modules-example.git
git@github.com:mrmckeb/typescript-plugin-css-modules.git
--shallow git@github.com:vercel/next.js.git
git@github.com:garris/BackstopJS.git
...

自分以外の OSS のリポジトリのみに絞りたいので、org が自分のものは除外する (OSS を自分の org に fork したものも除外されてしまうけど…まあそんな数もないので気にせずで)。こういう時ハンドルネームがユニークだと、雑に除外条件を設定しても期待通りになってうれしい。

$ history 1 | awk '{ print $2,$3,$4,$5 }' | grep -e 'git clone' -e 'ghq get' -e 'git sclone' | grep -v -e '\-\-help' -e 'mizdra' | awk '{ print $3,$4 }'
git@github.com:vercel/next.js.git
git@github.com:madyankin/postcss-modules-example.git
git@github.com:mrmckeb/typescript-plugin-css-modules.git
--shallow git@github.com:vercel/next.js.git
git@github.com:garris/BackstopJS.git
...

完成系

という訳で、完成したワンライナーとその実行結果がこちら。private リポジトリ見られると良くないものや、mizdra 以外の org で自分が開発しているものなどは手作業で除外した。

$ history 1 | awk '{ print $2,$3,$4,$5 }' | grep -e 'git clone' -e 'ghq get' -e 'git sclone' | grep -v -e '\-\-help' -e 'mizdra' | awk '{ print $3,$4 }'
git@github.com:vnc0/magic-trackpad-switcher.git
git@github.com:mrmckeb/typescript-plugin-css-modules.git
git@github.com:AriPerkkio/eslint-remote-tester.git
git@github.com:zhouzi/graphql-codegen-factories.git
git@github.com:garris/BackstopJS.git
git@github.com:APIs-guru/graphql-faker.git
git@github.com:isucon/isucon11-qualify.git
git@github.com:vercel/next.js.git
--shallow git@github.com:vercel/next.js.git
git@github.com:vercel/next.js.git
git@github.com:jest-community/vscode-jest.git
https://github.com/jest-community/jest-editor-support.git
git@github.com:prettier/prettier.git
https://github.com/royriojas/file-entry-cache
--shallow git@github.com:microsoft/TypeScript.git
--shallow git@github.com:dotansimha/graphql-code-generator.git
git@github.com:madyankin/postcss-modules-example.git
git@github.com:facebook/docusaurus.git
git@github.com:postcss/postcss.git

同じリポジトリが何回も出力されていたり、--shallow が出ていたりするのが気になるけど、とりあえず良さそうなデータが集計できて満足した!

shell history だけだと clone 日時まではわからないので、なんか色々工夫したほうが良いのかもしれない。HISTTIMEFORMAT を変更して shell history に日付が埋め込まれるようにするのが良い気がするけど、これって過去の履歴壊れたりしないかな…どうしようかな…というところで悩み中。

takami-hiroki.hatenablog.com

おまけ

ところで一覧を見て、「なんか最近触ったリポジトリばかりだな…」と思ったのでちょっと調べてみたら…

$ history 1 | wc -l
    1068
$ echo $HISTSIZE
2000
$ cat ~/.zshenv | grep HISTSIZE
export HISTSIZE=1000000

.zshrc で設定した $HISTSIZE が上手く適用されていないのか、1068 件しか保存されてなさそう。というか値 3 つともバラバラになっている。そんな………………

追記 (2022/08/06 02:12)

どうやら shell history の最大件数は HISTSIZE ではなく HISTFILESIZE で調整するものらしい。

oplern.hatenablog.com

$ echo $HISTFILESIZE

$ cat ~/.zshenv | grep HISTFILESIZE
$ echo $?
1

何も設定していなくて、zsh のデフォルト値が使われていそう。そういうことだったのか…

追記 (2022/08/06 12:23)

もうちょっと調べてみたら、zsh で履歴の最大保存件数を表す環境変数は HISTFILESIZE ではなく SAVEHIST のようだった。これ shell ごとに違うんですね。

timesaving.hatenablog.com

$ echo $SAVEHIST
1000
$ cat ~/.zshenv | grep SAVEHIST
export SAVEHIST=1000000

.zshenvSAVEHIST 設定しているはずなのだけど、なぜか無視されていた。.zshenv で設定している他の環境変数 (export EDITOR="code --wait" とか) はちゃんと認識されているのだけどなー。なんでだろう。

追記 (2022/08/06 13:26)

メーリングリストで何か議論されていないかな…と思ってメーリングリストのアーカイブを検索してみる。アーカイブは https://www.zsh.org/mla/ にあるけど、全文検索機能は付いていないので、ここは Google で <キーワード> site:https://www.zsh.org/mla/ というクエリを打ち込んで調べる。Google 便利。

という訳で ghq get git@github.com:zsh-users/zsh.git する。こうして git clone した OSS リポジトリがまた1つ増えていく…

もう追う体力残ってないので、.zshrc に設定を移動して終わりにした。

ISUCON 12 予選に出場した

id:odan3240 さんと「情報処理部」というチームで出場してきました。

id:odan3240 さんの参加記もあるので合わせてどうぞ:

odan3240.hatenablog.com

結果

  • 最終スコア: 3270

何もできなかった...

時系列

結構前の話なのでうろ覚えだけど…大体こういう流れだったと思う。

  • 10:00 競技開始
  • 10:30 インスタント立てたりマニュアル読み合わせしたり
  • 〜11:30 ソースコード取り出したり、デプロイスクリプト書いたり、Datadog 入れたり
  • 11:30〜
    • id:odan3240 N+1 修正、インデックス貼る
    • id:mizdra sqlite から mysql に移行し始める
  • 14:00
  • 15:00
  • 17:30
    • Redis 導入も mysql 移行も間に合わない!となって、監視設定切ったりパラメータチューニングしてベンチマーク回し始める
    • 3270 点出たところで終了

やったこと

github.com

  • sqlite のスキーマに index を貼った
    • ...のだけど、途中でやっぱり sqlite 脱出しよう!となって中断して、実際に貼ったのは 1 つだけ。
  • sqlite のデータを mysql に移行する (失敗)
    • 最初 sqlite3-to-sql を使って移行を始めたけど、40分くらい? (よく覚えていない) 掛かる上に、完走した後に player_score テーブルのレコード数を数えたら、20万件くらい欠けていることが判明した
      • 虚無へと消えるレコード...
      • 警告とかエラーとかも出てなくて、何が起きていたのかさっぱり分からなかった
      • 「なんで〜」となって試行錯誤しているうちに 1 時間くらい溶けていた
    • そもそも 40 分掛かるのおかしいよね、ということで CSV に変換してから import する方法に切り替え始めた
      • for file in `ls ../../initial_data/*.db`; do
          sqlite3 -csv $file "select * from competition;" >> competition.csv
          sqlite3 -csv $file "select * from player;" >> player.csv
          sqlite3 -csv $file "select * from player_score;" >> player_score.csv
        done
        mysql -uisucon -pisucon -Disuports --enable-local-infile -e"load data local infile 'competition.csv' into table competition fields terminated by ',' OPTIONALLY ENCLOSED BY '\"';";
        mysql -uisucon -pisucon -Disuports --enable-local-infile -e"load data local infile 'player.csv' into table player fields terminated by ',' OPTIONALLY ENCLOSED BY '\"';";
        mysql -uisucon -pisucon -Disuports --enable-local-infile -e"load data local infile 'player_score.csv' into table player_score fields terminated by ',' OPTIONALLY ENCLOSED BY '\"';";
        
      • これで 20 分くらいに短縮された
      • けどレコードが虚無へと消えていくのは直らない...
    • mysql のバッファに余裕がなくてレコードが欠けてしまっているのでは? と思って、mysql のパラメータチューニングをし始める
      • [mysqld]
        innodb_buffer_pool_size=4G
        innodb_buffer_pool_instances=4
        innodb_log_buffer_size=200MB
        innodb_log_file_size=400M
        innodb_flush_method=O_DIRECT
        max_allowed_packet    = 64M
        
      • これで 3 分くらいに短縮された
      • レコードが虚無へと消えることもなくなった!
    • しかしベンチマークを回してみると POST /initialize がタイムアウトしてしまう!
      • webapp/sql/init.sh にこういう初期化用の sql を書いていたのだけど、created_at レコードに index がなくて、そもそも webapp/sql/init.sh に時間がかかりすぎるらしい
      • index 貼った
    • まだタイムアウトする!
      • GET /api/admin/tenants/billing が遅すぎてタイムアウトしてた
      • N+1 直してなかったのが原因っぽかった
      • 直そう、と思ったけど残り 30 分ほどしかなくて、ここであえなく時間切れ
      • https://github.com/odanado/isucon12-qualifier/pull/9
  • その他 import 中の待ち時間に nginx 周りでなにかできることないか探したり、シュッとできそうだったので /api/me をゲストユーザの時は nginx から返すようにしたりしたけど、ほとんど点数は変わらなかった

感想

sqlite to mysql に手を出したのが良くなかった… 危険な匂いを嗅ぎ分けて、先に他のボトルネック (それこそ最後に詰まっていた N+1 とか) から 1 つずつ潰していけばよかった。あとデータ量が多い時の mysql のパラメータチューニングの方法とかも事前に練習しておけばもっとスムーズだったかも。というよりは、業務でデータ量多い DB を扱う経験を積んだりとか、普段から色々なことに手を伸ばしていきましょうという話っぽい。もっと挑戦していきたい。

来年もがんばります。

ポケットモンスター・ポケモン・Pokémon・は任天堂・クリーチャーズ・ゲームフリークの登録商標です.

当ブログは @mizdra 個人により運営されており, 株式会社ポケモン及びその関連会社とは一切関係ありません.