現代開発を加速させる古来の術式
浮かない顔をしておるな。ワケを話してみよ。
npmの依存パッケージが増えた
ふむ。npmで依存パッケージを増やしたと。それで? なに、他の開発者から 動かない と言われたのか。で、毎回 npm ci
をしてくれ と頼んでいるわけか。
…その問題、半世紀ほど前に解決されておるぞ。
何かの縁じゃ。お主に開発環境を自動更新する古来の術式を教えてやろう。
詠唱準備
手始めに適当なパッケージを作るかの。今からの操作は空ディレクトリの中で作業していくぞ。
お主がNode.jsをインストール済であれば、
$ npm init -y
$ npm install --save express
これで、package.json
と package-lock.json
、そして node_modules
ができるはずじゃ。
$ ls
node_modules package-lock.json package.json
これをgitリポジトリで管理するかの。
$ git init
お主がnpmを利用したアプリケーションをgitで管理したことがあるなら、node_modules
ディレクトリはリポジトリに含めないことを知っておるじゃろう
echo 'node_modules/' > .gitignore # node_modules以下はgitの管理外
git add .gitignore package.json package-lock.json
git commit -m 'Initial commit'
なぜか?
理由はいくつかあるじゃろうが大前提として
node_modules
は package.json
や package-lock.json
から構築できるからじゃ。
術式
node_modules
は pacakge.json
や package-lock.json
からできる。この考え方は非常に重要じゃ。
この依存関係を以下のような術式で表す。
node_modules: package.json package-lock.json
:
より左側をターゲット(target)、右側を必要条件(prerequisite) という。
今回、必要条件からターゲットを構築するためには npm ci
が必要じゃ。これらを踏まえ以下のような術式になる。
node_modules: package.json package-lock.json
npm ci
npm ci
に利いている インデントはタブ であることに注意するのじゃ。古来の術式ゆえな。丁寧に扱う必要がある。
この術式ひとまとまりをルールと呼ぶ。
さて、この術式を Makefile
という名で保存するのじゃ。
…そうじゃ。古来の術式とは、makeのことじゃ。
詠唱
node_modules
は一度消しておくぞ。
$ rm -rf node_modules
よし。術式を起動するぞよ。
$ make node_modules
うむ。無事に node_modules
が作成されたな。
ここで、もう一度術式を起動してみよ。
先程と振る舞いが違うじゃろう。
そうじゃ。 node_modules
は再作成されないのじゃ。
術式解説
原理はシンプルじゃ。
先程組んだ術式を見ると
node_modules: package.json package-lock.json
node_modules
は package.json
と package-lock.json
に依存しておる。
依存関係をグラフにするとこういった形じゃな。
そしてここからがキモじゃが
makeの術式は依存関係の中でターゲットが最新となるよう実行される。
つまり
node_modules
が package.json
、pacakge-lock.json
より新しい
→ node_modules
の再作成は不要
node_modules
が package.json
、pacakge-lock.json
より古い
→ node_modules
の再作成は必要
とみなされる。
UNIX系ファイルにはatime(アクセス時間)、mtime(更新時間)、ctime(属性変更時間[1])と呼ばれる時刻情報があるのじゃが[2]
このうちmakeでは mtimeの大小関係によってターゲットの再構築が必要かが判断される のじゃ。[3]
アプリ起動と組み合わせる
これだけじゃとまだ開発の恩恵は得られんの。実際にアプリ開発にどう活かすかも見せよう。
まずexpressのHello world exampleのコードでシンプルなWebサーバーを立ててみよう:
const express = require('express')
const app = express()
const port = 3000
app.get('/', (req, res) => {
res.send('Hello World!')
})
app.listen(port, () => {
console.log(`Example app listening on port ${port}`)
})
これを server.js
の名前で保存し、次の術式をMakefile
の先頭に書くのじゃ。
.PHONY: app
app: node_modules
node server.js
インデントはタブであることを忘れるでないぞ。
.PHONY
は疑似ターゲットのことじゃ。ここでは app
というファイルは作成されないが術式起動のキーワードとして便宜的に作成するターゲットじゃ。
つまり .PHONY
ターゲットルールのコマンドスクリプトは常に実行されるということじゃ。
いまMakefile
全体はこのようになっておる。
.PHONY: app
app: node_modules
node server.js
node_modules: package.json package-lock.json
npm ci
依存関係をグラフ化しておくとこんな感じじゃな。
術式起動
術式を起動するぞよ。
$ make app
うむ。無事に起動したの。
Ctrl+Cを押して一度終了し、node_modulesを消して今一度やってみよ。
$ rm -rf node_modules
$ make app
npm ci
が自動で実行されてからアプリが起動したじゃろう。
app
ターゲットは node_modules
に依存しておる。makeは依存関係を解決するために、node_modules
の作成を先に行ってからapp
ルールのコマンドスクリプトを実行したわけじゃ。
ちなみに術式の起動は make
だけでもできる。詠唱破棄じゃな。
$ make
先程 app
ターゲットをMakefile
の先頭に書いたが、make
はターゲットを指定しなければ最初に記述されたターゲットを実行するのじゃ。
git開発
これらの術式はgitを使う上で非常に役立つ。
これまでの作業を一度コミットしておこう。
$ git add Makefile server.js
$ git commit
一度ブランチを変えるぞ。topic
ブランチを作成してチェックアウトする。
$ git checkout -b topic
パッケージを追加し先程のアプリを改修する。ログのフォーマットでも変えるかの。
$ npm install --save winston
const express = require('express')
const winston = require('winston')
const app = express()
const port = 3000
const logger = winston.createLogger({
transports: [new winston.transports.Console()]
});
app.get('/', (req, res) => {
res.send('Hello World!')
})
app.listen(port, () => {
logger.info(`Example app listening on port ${port}`)
})
一応差分をここに置いておく。
diff --git a/server.js b/server.js
index cf5d04b..2ab3fec 100644
--- a/server.js
+++ b/server.js
@@ -1,11 +1,15 @@
const express = require('express')
+const winston = require('winston')
const app = express()
const port = 3000
+const logger = winston.createLogger({
+ transports: [new winston.transports.Console()]
+});
app.get('/', (req, res) => {
res.send('Hello World!')
})
app.listen(port, () => {
- console.log(`Example app listening on port ${port}`)
+ logger.info(`Example app listening on port ${port}`)
})
起動するとログフォーマットが無事に変わるかの。
$ make
node server.js
{"level":"info","message":"Example app listening on port 3000"}
うまくいっておれば、今一度コミットするぞ。
$ git add -u # 変更のあったファイルをステージング
$ git commit
依存パッケージの自動解決
元のブランチに切り替え、この後のわかりやすさのためにnode_modules
を削除するぞ。
$ git checkout main # 元のブランチへ
$ rm -rf node_modules
先程と同様、アプリ起動時にはexpressを含めた依存パッケージがインストールされる。
$ make
npm ci
added 57 packages in 0.139s
node server.js
Example app listening on port 3000
そしてもう一度topic
ブランチをチェックアウトしてアプリを起動すると…
$ git checkout topic
Switched to branch 'topic'
~/tmp/node-test on topic
$ make
npm ci
npm WARN prepare removing existing node_modules/ before installation
added 84 packages in 0.213s
node server.js
{"level":"info","message":"Example app listening on port 3000"}
topicブランチで追加したパッケージが自動でインストールされてからアプリが起動することがわかるかの。
gitのブランチを切り替えた際、ブランチ間で差異があるファイルは書き換えが発生する。 書き換えが発生すればmtimeは更新される。
ブランチを切り替えるとpackage.json
とpackage-lock.json
のmtimeが更新され、node_modules
のmtimeがpackage.json
らのmtimeよりも古くなるため、アプリ起動時にnode_modules
ターゲットの再生成処理が発生した、というわけじゃ。
ブランチ切り替えの他にも、git pull
でファイルが更新されたばあいなども同様に自動インストールされるぞよ。
Dockerのビルドを自動化する
同じ考え方で Dockerのビルドも自動化してみよう。
準備
先程のアプリをDockerで起動するために、まず簡単なDockerfile
を作成するかの。
FROM node:14-alpine
CMD ["node"]
image/
ディレクトリを作成しimage/Dockerfile
としてこれを保存するのじゃ。
術式を変えるぞ。
.PHONY: app
-app: node_modules
- node server.js
+app: node_modules docker-image
+ docker run --rm -it --volume=$(PWD):/home/node --user=node --workdir=/home/node \
+ $$(cat docker-image) node server.js
node_modules: package.json package-lock.json
npm ci
+
+docker-image: image/Dockerfile
+ docker build --iidfile=docker-image image/
順を追って見ていこうかの。
docker-imageターゲット
まず docker-image
ターゲットを追加し、image/Dockerfile
を必要条件とした。
docker-image: image/Dockerfile
ただ必要条件はimage
でも構わん。
docker-image: image
これなら image
ディレクトリ内でDockerfile
以外のファイルが変更されてもターゲットが再生成されるからの。
Dockerfile
でCOPY
コンテキストを利用するばあいなど、image
ディレクトリ内にファイルを追加することがあれば、こっちの方がよいかもしれんの。
docker-image
の生成はdocker build
コマンドで行う。
docker-image: image/Dockerfile
docker build --iidfile=docker-image image/
docker build
で--iidfile
オプションを利用すると、ビルド後のイメージハッシュを指定のファイルに書き出すことができる。今回のばあいdocker-image
ファイルにイメージハッシュを書き出したというわけじゃ。
ちなみにビルドしたコンテナイメージにタグ名をつけて管理するなら、docker-image
ファイルはtouch
コマンドで作成してもよい。[4]
docker-image: image/Dockerfile
docker build -t some-name image # some-name というタグ名を付与
touch docker-image # 空ファイルを作成
この例だとsome-name
というタグ名を付与しておる。いざイメージを使おうというときにsome-name
を利用できるから、イメージハッシュを保存しておく必要はないというわけじゃな。[5]
ただこのばあいでも docker-image
ファイルはtouch
コマンドで生成しておる。[6]
touch docker-image # 空ファイルを作成
docker-image
を ハッシュを書き込んで作成する にしても touch
を利用し空ファイルとして作成する にしても、このファイルはmtimeを保持することになり、最後にビルドした時刻が記録されることになるのじゃ。
なおdocker-image
ファイルはgitで管理する必要がないため、ignoreしておくのじゃ。
$ echo docker-image >> .gitignore
appターゲット
app
ターゲットの必要条件にdocker-image
を追加した。
.PHONY: app
app: node_modules docker-image
これによってアプリの起動はdocker-image
ターゲットに依存することになる。
アプリの起動方法はdocker
を利用した起動に変えた。
.PHONY: app
app: node_modules docker-image
docker run --rm -it --volume=$(PWD):/home/node --user=node --workdir=/home/node \
$$(cat docker-image) node server.js
新しい術式が増えたためいくつか解説しておく。このあたりも少々クセがあるが、難しいものではないじゃろう。
変数
$(PWD)
は変数PWD
をコマンドに埋め込んでおる。
変数の値を利用するばあい $
記号の後に変数名を記述するのじゃが、1文字を超える変数は丸括弧()
で囲む必要がある。
この変数は環境変数からも読み込まれるため、変数PWD
はカレントディレクトリパスになる。
もちろん自身で定義することもできるし、術式起動時にも渡すこともできる。
変数の代入にはいくつか方法がある。詳しい解説は省くが、いくつか紹介しておこう。[7]
記述 | 挙動 | 右辺が展開されるタイミング |
---|---|---|
x = value |
x にvalue を設定する |
利用される箇所で遅延展開 |
x := value |
x にvalue を設定する |
宣言箇所で直ちに展開 |
x ?= value |
x が未定義ならvalue を設定する |
遅延展開か直ちに展開 |
x += value |
x の末尾にvalue を追加する |
遅延展開か直ちに展開 |
術式起動時には同様の記法で変数を設定することができる。
$ make target key:=value # 変数keyにvalueを設定
コマンドスクリプトと改行
各ルールのコマンドスクリプトには1行に1つのコマンドを記述する。
複数行に渡って書きたいばあいは、末尾をバックスラッシュ(\
)でエスケープする必要がある。
このあたりは一般的なシェルスクリプトと同様じゃな。
docker run --rm -it --volume=$(PWD):/home/node --user=node --workdir=/home/node \
$$(cat docker-image) node server.js
注意点として、コマンドごとに別のシェルプロセスが起動するため、cdコマンドを利用してカレントディレクトリを変えながらコマンドを実行したいばあいなどは、1つのシェルプロセス内で処理を完結する必要がある。
some-target:
echo $(PWD) # /home/user
cd somewhere
echo $(PWD) # /home/user # cdコマンド実行プロセスとは別プロセス
cd somewhere; echo $(PWD) # /home/user/somewhere
$
のエスケープ
コマンドで$
を表現したいばあいには $$
のように書くことでエスケープできる。 サンプルの$$(cat docker-image)
ではシェルのコマンド展開記法$(...)
の$
をエスケープしておる。
術式完成
さて、以下のような術式ができた。
.PHONY: app
app: node_modules docker-image
docker run --rm -it --volume=$(PWD):/home/node --user=node --workdir=/home/node \
$$(cat docker-image) node server.js
node_modules: package.json package-lock.json
npm ci
docker-image: image/Dockerfile
docker build --iidfile=docker-image image/
依存関係をグラフにするとこうじゃ。
術式を試してみよう。
$ rm -rf node_modules docker-image # 動作確認のため一度消す
$ make
npm ci
added 57 packages in 0.133s
docker build --iidfile=docker-image image/
[+] Building 0.1s (5/5) FINISHED
...
docker run --rm -it --volume=/home/user/sample-app:/home/node --user=node --workdir=/home/node \
$(cat docker-image) node server.js
Example app listening on port 3000
パッケージのインストールやイメージのビルドが自動化されたの。
image/Dockerfile
内のFROM node:14-alpine
をFROM node:16-alpine
にしてみるなどして更新すると、次の起動時には自動でビルドが行われる。 これは自身で試してみるとよい。
応用
同様の発想で例えばマイグレーション用のSQLファイルが追加されたら自動でマイグレーションを行うといったこともできる。
app: migrate
# sqlディレクトリが更新されたら実行されるルール
migrate: sql
bin/migrate.sh
touch migrate # マイグレーション最終実行時刻を保持
「何かをしたあとに何かを実行する」という依存関係があるならば、これらはmakeのルールで書き起こして自動化するチャンスかもしれんの。
他役に立つこと
ついでにいくつかの知識を授けておこう。
ルールの並列実行
make
コマンドに-j
オプションを指定すると、並列化可能なルールは並列実行される。 例えば-j2
とすれば、最大2つのルールを並列実行できる。
例えばサンプルの術式起動では
$ make -j2 app
とすることで、npmパッケージのインストールとDockerイメージのビルドを並列で実行することができるのじゃ。
app
ターゲットの必要条件である、node_modules
とdocker-images
は互いに依存してないからの。並列実行しても問題ないゆえ、よしなにmakeが並列化してくれるというわけじゃ。[8]
環境差異の吸収
開発者ごとの環境差異を吸収するのにもmakeの術式は役立つぞよ。
術式を少し変えてみよう。
NPM := npm
NODE := node
-include myconf.mk
.PHONY: app
app: node_modules
$(NODE) server.js
node_modules: package.json package-lock.json
$(NPM) ci
差分もここに置いておく。
+NPM := npm
+NODE := node
+
+-include myconf.mk
+
.PHONY: app
-app: node_modules docker-image
- docker run --rm -it --volume=$(PWD):/home/node --user=node --workdir=/home/node \
- $$(cat docker-image) node server.js
+app: node_modules
+ $(NODE) server.js
node_modules: package.json package-lock.json
- npm ci
-
-docker-image: image/Dockerfile
- docker build --iidfile=docker-image image/
+ $(NPM) ci
前半と同様Dockerコンテナを使わないルールじゃが、コマンド部分を変数に変えておるな。
NPM := npm
NODE := node
$(NPM) ci
そしてinclude
文が登場しておる。これにより、別のMakefileを読み込むことができる。
-include myconf.mk
頭に-
をつけるとファイルが存在しないばあいもエラー終了せずに処理を続行することができる。
つまり myconf.mk
で術式をカスタマイズすることができるのじゃ。後述詠唱とでも呼ぼうかの。
例えばmyconf.mk
を作成し以下のような術式を展開する。
NODE := docker run --rm -it --volume=$(PWD):/home/node --user=node --workdir=/home/node \
$$(cat docker-image)
app: docker-image # 依存関係の追加
docker-image: image/Dockerfile
docker build --iidfile=docker-image image/
NODE
変数をdocker
コマンドで置き換え、app
ターゲットの依存関係を追加した。
つまり前述のDockerコンテナによるアプリ起動はオプションになったのじゃ。
Node.jsの開発であれば、ランタイムを例えばnodenvやvoltaで管理したい開発者もおるじゃろう。
そのばあいもmyconf.mk
に記述すれば簡単にランタイムを変更できる。
# nodenvでバージョン指定
NODE := NODENV_VERSION=14.17.0 node
# make app時にこうなる
# $ NODENV_VERSION=14.17.0 node server.js
# voltaでバージョン指定
NODE := volta run --node=14.17.0 node
# make app時にこうなる
# $ volta run --node=14.17.0 node server.js
また export
文を用いればmake
実行時の環境変数になる。
# make実行時の環境変数
export NODENV_VERSION := 14.17.0
コマンドは変数にしておきカスタマイズ用のinclude文を記述しておく。 この術式パターンは鉄板じゃ。
コマンド置き換えなどのサンプルは、READMEやサンプル用.mk
ファイル[9]をリポジトリに含めるなどしておくと親切じゃな。[10]
またカスタム用のファイルは各開発者に依存したファイルになるため、gitリポジトリの管理対象外にしておくことを忘れるでないぞ。
$ echo myconf.mk >> .gitignore
自動インストール時のmtime更新
例えばPHPの開発じゃとcomposer install
で依存ライブラリをインストールするじゃろう。
ただcomposerはインストールすべきライブラリがないときにvendor
ディレクトリを更新しないため、mtimeが更新されないことがある。
こういったばあい、touch
して明示的にmtimeを更新するしておくとよいぞ。
vendor: composer.json
composer install
touch vendor # mtimeを更新
dry-run起動
各ルールが正しく実行されるか、術式起動前に確認したいこともあるじゃろう。
-n
、--just-print
、--dry-run
、--recon
オプションはいずれもdry-runのためのオプションで、術式起動時のコマンドを実行せずに出力してくれる。[11]
Makefile
作成時には役に立つことじゃろう。
偉大なる書
makeの各術式や活用例はオライリー・ジャパンGNU Make 第3版に詳しく載っておる。
なんと上記リンクから無料PDFで公開されておるのじゃ。[12]
もちろん紙の書籍を購入することもできる。
より奥深い世界に興味があれば一読してみるとよい。
あとがき
makeの起源は 「コードを直したのに実行ファイルを更新しないままデバッグして度々無駄な時間を費やす」という同僚の問題 から、1976年にスチュアート・フェルドマン博士が閃いたものです。[13]
スチュアート博士はmakeを生み出したことでACM Software System Awardを2004年に受賞しています。
実際makeは今年イチコスパのよい勉強だったなと思っており、開発における恩恵を日々感じています。
例えば最後に紹介した Makefile
はたった10行です。
NPM := npm
NODE := node
-include myconf.mk
.PHONY: app
app: node_modules
$(NODE) server.js
node_modules: package.json package-lock.json
$(NPM) ci
こういった記述をちょろっとするだけで開発上のフラストレーションがどっと削減され、また 各操作で何を行うのか といったことも--dry-run
オプションですぐに確認できるわかりやすさがあります。
makeと言うと 「あんな古いものをどうして今更…」 という反応を何度か体験したことがありますが、makeはGo言語の開発でも一般的に使われている[14]ように、優れたビルド・自動化ツールとして生き残り続けています。
開発の体験をよりよくしたい開発者の方は、ぜひ一度利用してみることをオススメします。
動作確認環境について
makeにはGNU実装とBSD実装とがあり、方言の違いがあります。
本記事の動作はmacOSに標準でインストールされていたGNU Make3.81を利用して確認しましたが、なるべく基本的な構文のみを利用するよう配慮しました。
$ make -v
GNU Make 3.81
Copyright (C) 2006 Free Software Foundation, Inc.
This is free software; see the source for copying conditions.
There is NO warranty; not even for MERCHANTABILITY or FITNESS FOR A
PARTICULAR PURPOSE.
This program built for i386-apple-darwin11.3.0
文中の表現について
「make」という言葉で記事をそっ閉じされずにmakeの忘れられた良さを如何に伝えられるか が本記事の課題でした。
表現としてこういった形になり賛否あるかと思いますが、あしからず受け取っていただけますと幸いです。
語弊がある表現などあればコメントやGitHubからの編集提案でご連絡ください。
謝辞
末筆ながらmakeの良さに気づかせてもらえたのはクラスメソッドさんの記事でした。この場を借りてお礼申しあげます。
-
mtimeは
ls -l
コマンドで確認できる ↩︎ -
touch
は標準でファイルのmtimeを現在時刻に更新する。ファイルがなければ空ファイルを作成する ↩︎ -
docker run ... some-name node server.js
とできる ↩︎ -
touch
を「印を結ぶ」と表現したかったが伝わりにくいと考えボツにした ↩︎ -
GNU Make 第3版 3.4「変数はいつ展開されるか」を参考とした。一部の刷に誤植があるので注意 ↩︎
-
依存関係の解決はトポロジカルソートのアルゴリズムを使っているそうです: makefile - How does the make "-j" option actually work? - Stack Overflow ↩︎
-
Makefileの一般的な拡張子。
.makefile
と書くことも ↩︎ -
例えば
change-node-runtime.mk.sample
というファイルをリポジトリに含める ↩︎ -
どのオプションでも違いはない ↩︎
-
GNU Free Documentation Licenseに基づき公開されている ↩︎
-
「Go言語ではMakefileを推奨する」といった文献は知りませんが、言語文化としてMakefileを利用したプロジェクトは多いと考えています。異議あればコメント願います ↩︎
Discussion
英語記事も書いてるのでもし英語圏にシェアされたい場合はぜひこちらをお使いください。