この記事は「Qiita Advent Calendar 2019 DSLで自作ビルドツールを作ろう」の7日目の記事です。
7日目 タイムスタンプでルール実行をスキップする
いよいよビルドツールの本命ともいえる機能です。タイムスタンプを使用して、更新のないファイルに対sるビルドをスキップすることで、ビルドを高速化する機能を追加します。この機能について調べたくてビルドツールDSLを作り始めたといっても過言ではありません。
しかし、そもそもMakeのビルドツールはどのようにしてタイムスタンプを判定しているのでしょうか。いろいろ文献を調べてみたのですが、やはりターゲットファイルとソースファイルタイムスタンプ差を判別して、ビルドのスキップを決めているようです。例えば、下記のようなMakeルールがあった場合、
makefile
test.exe : test1.c test2.c
gcc -o $^ $@
test.exe
はtest1.c
とtest2.c
をベースに作られるため、test.o
のタイムスタンプはtest1.c
とtest2.c
よりも新しいはずです。もしそうでなければ、test.exe
が作られた後にtest1.c
とtest2.c
は更新されたと考えるべきです。その場合は、再度ルールを実行してtest.exe
を作り直すべきです。このように考えると、
- ソースファイルのタイムスタンプとターゲットのタイムスタンプを比較して、ソースのうちどれか1つでもターゲットよりも新しいタイムスタンプがあればターゲットを実行する。
とルールを定めれば実現できそうです(最初私はすべてのファイルとターゲットのタイムスタンプをどこかに記録してるのかと思ったのですが、もっと単純に実現できるのですね...)。
逆にいうと、シンボルに対するタイムスタンプという概念は存在しないため、ターゲットがシンボルの場合はタイムスタンプを確認せずに何度も再実行されるのですね。例えば、以下の2つのルールは根本的に違うようで、
testA.exe : test1.c test2.c gcc -o testA.exe test1.c test2.c
test_compile: test1.c test2.c # ターゲットがファイル名ではなくシンボル! gcc -o testA.exe test1.c test2.c
1番目はtestA.exe
が一度生成されれば、test1.c
とtest2.c
が更新されない限り一度だけビルドが実行されますが、2番目はtest_compile
というシンボルにはタイムスタンプが存在しないため、再実行すると何度でもコンパイルが実行されてしまいます。大変!
そこで、ターゲット名がファイル名として存在している場合はタイムスタンプをチェックし、コンパイル必要がないならばスキップ、ターゲット名がラベルの場合は問答無用でビルド実行、というフローを作ってみます。
Rubyでファイルのタイムスタンプを取得する仕組み
これも少しだけ調べただけですが、File::Stat
というオブジェクト使用することでファイル名からファイルオブジェクトを取得し、タイムスタンプを調査することができるようです。
test_stat = File::Stat.new("test.c") test_stat.mtime 最終更新時刻 test_stat.ctime 最終状態変更時刻 (状態の変更とは chmod などによるもので、Unix では i-node の変更を意味します) test_stat.atime 最終アクセス時刻
この機能を使ってタイムスタンプを取得しながらビルドスキップの判定を行います。ざっくりとしたフローは以下です。
// ターゲットは依存するファイルのうちどれか1つよりもタイムスタンプが古い? target_older_1of_depends = False ターゲットが文字列(ファイル名)ならば { すべての依存するターゲットについて { 依存するターゲットが文字列(ファイル名)ならば { 依存数ファイルのタイムスタンプを取得。ターゲットファイルのタイムスタンプを取得 ターゲットの方が古い? { target_older_1of_depends = True } } else { // 依存ターゲットの中にシンボルがあればタイムスタンプが分からないのでスキップしない target_older_1of_depends = True } } } else { // ターゲット名がシンボル名の場合はタイムスタンプが分からないのでスキップしない target_older_1of_depends = True } target_older_1of_depends が False? { ビルド実行 }
これをそのまま書き下します。do_target
の中に記述します。
private def do_target (name) if @target_list.key?(name) then target = @target_list[name] # Execute dependent commands at first check_target = false if name.kind_of?(String) and File.exist?(name) then target_stat = File::Stat.new(name) puts "[DEBUG] : target mtime: #{target_stat.mtime}" check_target = true end target_older_1of_depends = false target.depend_targets.each{|dep| if not @target_list.key?(dep) and not Symbol.all_symbols.include?(dep) then puts "[DEBUG] : Depend Tareget \"#{dep}\" is skip because it's file." if check_target == true and dep.kind_of?(String) then dep_stat = File::Stat.new(dep) puts "[DEBUG] : depends mtime: #{dep_stat.mtime}" if target_stat.mtime < dep_stat.mtime then target_older_1of_depends = true end else # If dependence list includes symbol, need to execute target_older_1of_depends = true end next else # one of depends are symbol => forcely re-execute target target_older_1of_depends = true end puts "[DEBUG] : Depends Target \"#{dep}\" execute." do_target(dep) } if target_older_1of_depends then # Execute commands! target.commands.each {|command| puts "#{command}" result = `#{command}` puts result } end else puts "Error: target \"#{name}\" not found." end end
ちょっと長いですが、結局やっているのはここで、
dep_stat = File::Stat.new(dep) puts "[DEBUG] : depends mtime: #{dep_stat.mtime}" if target_stat.mtime < dep_stat.mtime then target_older_1of_depends = true end
タイムスタンプに応じて最後おターゲット実行を行うかどうか決めているだけです。
タイムスタンプチェック機能をテストする
では、テストとして以下を用意します。
tests/check_timestamp.rb
#!/usr/bin/ruby load "rumy-exec.rb" make_target "test" do global depends ["test.c"] executes ["gcc test.c -o test"] end exec_target "test"
めっちゃ単純ですがとりあえずこれで行きます。まずは普通に実行します。
$ ruby ../tests/check_timestamp.rb [DEBUG] : Target Created = test, Depends = , Commands = ["gcc test.c -o test"] [DEBUG] : Depend Tareget "test.c" is skip because it's file. gcc test.c -o test
最後の1行です。コンパイルが実行されました。では、再度同じコマンドを流します。
ruby ../tests/check_timestamp.rb [DEBUG] : Target Created = test, Depends = , Commands = ["gcc test.c -o test"] [DEBUG] : target mtime: 2019-10-25 23:19:30 +0800 [DEBUG] : Depend Tareget "test.c" is skip because it's file. [DEBUG] : depends mtime: 2019-10-25 22:24:03 +0800
デバッグメッセージにも表示されていますが、ターゲットと依存ターゲットのタイムスタンプは、ターゲットの方が古いためビルドは実行されていません。上手く行きました。さらに、再びtest.c
を触ってソースファイルのタイムスタンプを更新してみます。
$ touch test.c # test.cのタイムスタンプを更新する $ ruby ../tests/check_timestamp.rb [DEBUG] : Target Created = test, Depends = , Commands = ["gcc test.c -o test"] [DEBUG] : target mtime: 2019-10-25 23:19:30 +0800 [DEBUG] : Depend Tareget "test.c" is skip because it's file. [DEBUG] : depends mtime: 2019-10-25 23:21:08 +0800 gcc test.c -o test
タイムスタンプを確認して、ビルドが再実行されています!基本的なタイムスタンプによるビルド省略システムは正しく動いているようです。ビルドツールっぽくなってきました!