Lambdaカクテル

京都在住Webエンジニアの日記です

Invite link for Scalaわいわいランド

みんなが本当に欲しかったのはMakefileじゃなくてディレクトリレベルで管理できるエイリアスなのでは

あらすじ

こういう記事を見た。

zenn.dev

僭越ながら要約すると、Makefileにタスクを書いてタスクランナーとして用いているとのこと。また、Makefileのコメントを拾ってドキュメンテーションを生成できるようにしたとのこと。

Makefileをタスクランナー代わりに用いるのはよくあることだが、ドキュメンテーションを生成するようにしたところがおもしろポイントである。

そして、この記事への批判文脈として以下の記事を見た(正確には、この記事を見てから上掲の記事を読んだ)。

www.takeokunn.org

これもまた僭越ながら要約させてもらうと、まずドキュメントはMakefileではなくREADME.mdとかに書くべきであるし、makeである必要性がないのならnpmなどが持っているタスクランナー機能を利用したら良いのでは?という話。そして、正規表現はメンテナンス性が低い、という主張である。

Makefile as task runner に対しての意見

Makefileでよく使うタスクを記述することはよく見る。自分もやったことがある。その上で、今のところこう思っている。

まず、百も承知だろうが、Makefileはそういう用途のために開発されたわけではなく、元々の用途は「ファイルを作るためにコマンドの実行が必要で、それぞれに依存関係がある」というビルドである。この基本がズレているので、正規表現を利用してドキュメンテーションを行うというさらに手間のかかったことをしてしまう。本来の目的ではないものの上にさらに苦労したものが建ってしまうというべきで、Makefileを用いてドキュメンテーションのためのエンジニアリングを行うのは筋が悪いのではないか。

個人的には、そもそもMakeってコケるとたいして親切でもないエラーを吐いて止まってしまうので、初心者向けタスクランナー?としてもそんなに優秀ではないように思っている。

偉そうなことを言いやがって、オメーんとこじゃどうしてんだよ、と言われそうだが、自分のチームではだいたい以下のようになっている。

まず、環境構築をDockerに統一してある。がその構築向けのコマンドはCosenseにまとまっていて、チームへのオンボーディング時に1つずつコピペすればよいようにしてある。これを簡略化して1つのタスクに、という抽象化はしていない。どうせ一回しかやらないから抽象化する意味もないであろう。

また、makeは本当にmakeしているときには当然使う。たとえばなんらかのファイルを生成するのになんらかのビルドコマンドが必要で、依存関係があるようなシチュエーションがたまにある(なんらかのスキーマファイルを同期する、といった用途にも使える)。こういう用途ではmakeを使っている。ただし、本当にmakeしたいのはエンジニアだけではなかろうか。なのでエンジニアは自分でMakefile見なさい、ということにできる。

makeしなくてもいいときはpnpm runで動かすようにしている。自分が今見ているプロジェクトはNext.js + TS ときどき LLM まわりのコンポーネントが Python で開発されているという具合で、大抵のワンショットなタスクは TypeScript で記述可能なものである。node_modulesに入っているコマンドを実行したいならpnpm runを使うのが一番手っ取り早い。また、簡単な内容のタスクであればシェルを直接書くかシェルスクリプトで記述し、条件分岐などの面倒なことが起こり始めたらすぐにTypeScriptに移行してしまう。TypeScriptはtsxで実行していて、これをpnpm runで呼び出すという具合にしている。

顧客が本当に欲しいものは、ディレクトリレベルのエイリアスなんじゃないですか?

ここまで書いて考えた。別に俺たちはMakefileを使ってmakeをしたいのではなく、あるコマンド列をまた別のコマンド列に"項書き換え"して、展開した文字列をシェルで実行したいだけなのでは?それってエイリアスってやつではないか?

じゃあエイリアスで全てが解決するかというとそうではなく、シェルのエイリアスはシェルレベルで定義されるものなので、プロジェクトごとにこのコマンドはこう、という設定ができない。これではそんなに役立たない。

もし仮にディレクトリごとにエイリアスが定義できて、好きな名前で好きなコマンドを呼び出せるのであれば、最初からmakeとか使う必要もないのでは?そう考えた。

ところがどっこい、そのような便利なツールは今のところ存在しないのだ。

Allyas: ディレクトリごとにエイリアスを張るツール

そこで、ツールを作って解決してしまった。これをdirenvと組み合わせることで、ディレクトリごとの完全に独立したエイリアシングが可能になる。

github.com

今のところx86_64のLinux用のバイナリしか置いていないが、Scalaの環境とLLVMがあれば好きなOSでビルドできるはずである。

まず、ディレクトリごとのエイリアスをどのように実現しているかについて種明かししておこう。

$PATHを乗っ取る

まず自分が着目したのはdirenvである。これはディレクトリごとに環境変数を変更できるツールで、世の中で広く使われている。例えばJAVA_OPTSのような変数をプロジェクトごとに切り替えたりできる。

しかしdirenv自体にはエイリアスを適用するような機能はないため、もう一つトリックを使うことにする。direnv$PATHに特定のディレクトリを追加してしまえば、ディレクトリごとに異なるコマンドを利用できるはずである。

# .envrc
PATH=${PATH}:$(pwd)/.aliases

そしてこの.envrcが適用される範囲内であればエイリアスを用いてシェルスクリプトを呼べる:

% mkdir .aliases
% cat > .aliases/ll << EOF
#!/bin/sh

ls -lah
EOF
% chmod u+x .aliases/ll
% ll

$0 hack

ところで、この方式では毎回エイリアスをディレクトリにシェルスクリプトとして定義する必要があって面倒だし管理もしづらい。 そこでもう一つのトリックを使うことにする。

シェルスクリプトやPerlなどでは、$0という特殊な変数が用意されており、「自分自身が呼び出されたときのパス」を得ることができる。このパスはシンボリックリンク(symlink)によって変化する

この性質により、エイリアス自体はただのsymlinkとして用意し、リンク先で$0を読み取って「ああ自分はllとして呼び出されて、ls -lahとして振舞えばいいのか」と空気を読んだ動作をすればよい。

これを達成するために具体的には以下のものが必要だ:

  • $0を読み取れること
  • $0 に対してどのコマンドが起動するべきかという対応付け
  • コマンドをシェルを用いて起動する仕組み

拙作のAllyasはこのあたりの処理を一手に引き受ける。エイリアスとコマンドの対応付けは設定ファイルから読み取ることとし、その設定ファイルがどこにあるかは.envrcを用いて環境変数で注入する:

# .envrc
PATH=${PATH}:$(pwd)/.alias
export ALLY_CONF=$(pwd)/.allyconf

allyコマンドはエイリアス経由で実行されると設定ファイルを読み取り、$0に対応するような設定があるかを探索する。対応するコマンド列が設定されていれば$SHELL -cに対してそのコマンド列と、ally自体が受け取った引数をマージして起動する。

例えばllに対してls -lahが設定されているとき、ll ./fooが起動されると、allyllls -lahに展開し、$SHELL -c 'ls -lah ./foo'を起動して待機し、その終了コードをそのまま返す。

これにより動的にエイリアスとして振る舞うプログラムを構成できる。このような仕組みはbusyboxでも使われていたはず(バイナリは同一で、どう呼び出されたかによって挙動が変化する)。

シンボリックリンクの自動化

さて、これでも十分有用だが、まだシンボリックリンクを張るという作業は手で行う必要がある。そこで、allyにはally shimというサブコマンドを導入した。これは設定ファイルを読み取ってこれに対応するシンボリックリンクを自動的に作成する機能である。

allyがシンボリックリンク用のディレクトリを知るために、また環境変数を使う:

# .envrc
export ALLY_CONF=$(pwd)/.allyconf
export ALLY_SHIM_DIR=$(pwd)/.aliases
PATH=${PATH}:$ALLY_SHIM_DIR

このようにすると、allyは自動的にエイリアス用のディレクトリを認識する。ally shimを実行すると、設定ファイルを読み込んだallyが全てのエイリアスに対応するsymlinkを作成する。

% ally shim
Created 4 symlinks in /foo/bar/.aliases:
  typecheck
  now
  l
  greeting

これでユーザは設定ファイルを記述してally shimを叩くだけでディレクトリレベルのエイリアスを得られるようになった。

設定ファイル

単にエイリアスとその展開先を記述するだけなので、設定ファイルの書式は本当になんでも良かったのだが、JSONほどの表現力も不要ということで素朴なS式を書くようにしてある:

(config
  (alias "typecheck" "echo 'Typechecking!'")
  (alias "now" "date")
  (alias "l" "ls -lah")
  (alias "greeting" "echo Greet!"))

パーサを書くのが楽でAIくんに押し付けられそうという邪な理由でこうなった。これなら後からドキュメンテーションを書きたくなってもどうにかなるだろう。

★記事をRTしてもらえると喜びます
Webアプリケーション開発関連の記事を投稿しています.読者になってみませんか?