🧙‍♂️

現代開発を加速させる古来の術式

2022/09/19に公開
1

浮かない顔をしておるな。ワケを話してみよ。

npmの依存パッケージが増えた

ふむ。npmで依存パッケージを増やしたと。それで? なに、他の開発者から 動かない と言われたのか。で、毎回 npm ciをしてくれ と頼んでいるわけか。

…その問題、半世紀ほど前に解決されておるぞ。

何かの縁じゃ。お主に開発環境を自動更新する古来の術式を教えてやろう。

詠唱準備

手始めに適当なパッケージを作るかの。今からの操作は空ディレクトリの中で作業していくぞ。
お主がNode.jsをインストール済であれば、

$ npm init -y
$ npm install --save express

これで、package.jsonpackage-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_modulespackage.jsonpackage-lock.jsonから構築できるからじゃ。

術式

node_modulespacakge.jsonpackage-lock.json からできる。この考え方は非常に重要じゃ。

この依存関係を以下のような術式で表す。

node_modules: package.json package-lock.json

: より左側をターゲット(target)、右側を必要条件(prerequisite) という。

今回、必要条件からターゲットを構築するためには npm ci が必要じゃ。これらを踏まえ以下のような術式になる。

Makefile
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_modulespackage.jsonpackage-lock.json に依存しておる。

依存関係をグラフにするとこういった形じゃな。

そしてここからがキモじゃが
makeの術式は依存関係の中でターゲットが最新となるよう実行される。

つまり

node_modulespackage.jsonpacakge-lock.json より新しい
node_modules の再作成は不要

node_modulespackage.jsonpacakge-lock.json より古い
node_modules の再作成は必要

とみなされる。

UNIX系ファイルにはatime(アクセス時間)、mtime(更新時間)、ctime(属性変更時間[1])と呼ばれる時刻情報があるのじゃが[2]
このうちmakeでは mtimeの大小関係によってターゲットの再構築が必要かが判断される のじゃ。[3]

アプリ起動と組み合わせる

これだけじゃとまだ開発の恩恵は得られんの。実際にアプリ開発にどう活かすかも見せよう。

まずexpressのHello world exampleのコードでシンプルなWebサーバーを立ててみよう:

server.js
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全体はこのようになっておる。

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
server.js
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}`)
})
一応差分をここに置いておく。
server.js
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.jsonpackage-lock.jsonのmtimeが更新され、node_modulesのmtimeがpackage.jsonらのmtimeよりも古くなるため、アプリ起動時にnode_modulesターゲットの再生成処理が発生した、というわけじゃ。

ブランチ切り替えの他にも、git pullでファイルが更新されたばあいなども同様に自動インストールされるぞよ。

Dockerのビルドを自動化する

同じ考え方で Dockerのビルドも自動化してみよう。

準備

先程のアプリをDockerで起動するために、まず簡単なDockerfileを作成するかの。

image/Dockerfile
FROM node:14-alpine

CMD ["node"]

image/ディレクトリを作成しimage/Dockerfileとしてこれを保存するのじゃ。

術式を変えるぞ。

Makefile
 .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以外のファイルが変更されてもターゲットが再生成されるからの。
DockerfileCOPYコンテキストを利用するばあいなど、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 xvalueを設定する 利用される箇所で遅延展開
x := value xvalueを設定する 宣言箇所で直ちに展開
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)ではシェルのコマンド展開記法$(...)$をエスケープしておる。

術式完成

さて、以下のような術式ができた。

Makefile
.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-alpineFROM 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_modulesdocker-imagesは互いに依存してないからの。並列実行しても問題ないゆえ、よしなにmakeが並列化してくれるというわけじゃ。[8]

環境差異の吸収

開発者ごとの環境差異を吸収するのにもmakeの術式は役立つぞよ。
術式を少し変えてみよう。

Makefile
NPM := npm
NODE := node

-include myconf.mk

.PHONY: app
app: node_modules
	$(NODE) server.js

node_modules: package.json package-lock.json
	$(NPM) ci
差分もここに置いておく。
Makefile
+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を作成し以下のような術式を展開する。

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の開発であれば、ランタイムを例えばnodenvvoltaで管理したい開発者もおるじゃろう。
そのばあいもmyconf.mkに記述すれば簡単にランタイムを変更できる。

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実行時の環境変数になる。

myconf.mk
# 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版に詳しく載っておる。

https://www.oreilly.co.jp/library/4873112699/

なんと上記リンクから無料PDFで公開されておるのじゃ。[12]

もちろん紙の書籍を購入することもできる。
より奥深い世界に興味があれば一読してみるとよい。


あとがき

makeの起源は 「コードを直したのに実行ファイルを更新しないままデバッグして度々無駄な時間を費やす」という同僚の問題 から、1976年にスチュアート・フェルドマン博士が閃いたものです。[13]
スチュアート博士はmakeを生み出したことでACM Software System Award2004年に受賞しています

実際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の良さに気づかせてもらえたのはクラスメソッドさんの記事でした。この場を借りてお礼申しあげます。

https://dev.classmethod.jp/articles/lambda-deploy-with-make/

脚注
  1. Windowsではファイル作成時間を指すようです ↩︎

  2. MAC times - Wikipedia ↩︎

  3. mtimeはls -lコマンドで確認できる ↩︎

  4. touchは標準でファイルのmtimeを現在時刻に更新する。ファイルがなければ空ファイルを作成する ↩︎

  5. docker run ... some-name node server.jsとできる ↩︎

  6. touchを「印を結ぶ」と表現したかったが伝わりにくいと考えボツにした ↩︎

  7. GNU Make 第3版 3.4「変数はいつ展開されるか」を参考とした。一部の刷に誤植があるので注意 ↩︎

  8. 依存関係の解決はトポロジカルソートのアルゴリズムを使っているそうです: makefile - How does the make "-j" option actually work? - Stack Overflow ↩︎

  9. Makefileの一般的な拡張子。.makefileと書くことも ↩︎

  10. 例えばchange-node-runtime.mk.sampleというファイルをリポジトリに含める ↩︎

  11. どのオプションでも違いはない ↩︎

  12. GNU Free Documentation Licenseに基づき公開されている ↩︎

  13. Origin -- Make (software) - Wikipedia ↩︎

  14. 「Go言語ではMakefileを推奨する」といった文献は知りませんが、言語文化としてMakefileを利用したプロジェクトは多いと考えています。異議あればコメント願います ↩︎

GitHubで編集を提案

Discussion