これは何?
先日のツイート
の実装。
状況
タイムスタンプが信用できないファイルが絡むビルドをしたいことがある。
- 手元で生成できるファイルが、 git でも管理されている
- 自動更新されるデータ(スクレイピングの結果からの抽出物とか)をソースにする
など。
具体的には、たとえばとこんな rakefile(抜粋)があったとしよう。
file "generated/cooked.json" => "hoge.json" do |a|
cook_json( a.sources, a.name )
end
hoge.json
をなんか処理して、 cooked.json
を作っている。
cooked.json
自体はそんなに大きくはない。
しかし、なにかわからない事情によって hoge.json
のタイムスタンプは信用できないので、上記の rakefile
はうまく機能しない。
touch hoge.json
などとした上で rake するとビルドされるが、 cooked.json
を生成する処理は割と重いので、hoge.json
が変わっていないのであればそっとしておきたい。
解決方法
file digest_filename_of("generated/cooked.json") => digest_filename_of("hoge.json") do |a|
cook_json( "hoge.json", a.name )
end
まずは、こんな風にして、解決を狙う。
依存ファイルにタイムスタンプが信用できないファイルがある場合、 digest_filename_of
でくるむと解決するように digest_filename_of
を定義しておく。
中身はこんな感じ。
def digest_filename_of(fn)
FileUtils.mkdir_p( INTERMEDIATE_DIR )
digest = ->(x){ Digest::SHA256.hexdigest(x).strip }
fread = ->(x){ File.open(x,&:read) rescue "" }
dfn = File.join(INTERMEDIATE_DIR, digest[fn])
d = digest[fread[fn]]
if ! File.exist?(dfn) || fread[dfn] != d
File.open( dfn, "w" ) do |f|
f.write(d)
end
end
dfn
end
まあ見ての通り。
雑に説明すると、ダイジェスト(ハッシュ)をおぼえておいて、前回と同じかどうかを利用する。
ダイジェストが一致していればファイルを更新しない。
この作戦の欠点
見ての通り、
file "generated/cooked.json" => "hoge.json" do |a|
cook_json( a.sources, a.name )
end
でやっていたような a.sources
が使えないのが弱点。
あと、生成されるファイルのタイムスタンプが信用できない場合はうまく行かない。
この記事を公開した当初はうまくいくと思っていたんだけど、駄目っぽい。
もっといい解決案
Qiita に書いたおかげで地味に広まり
と、task というツールを教えていただいた。
このツール、タイムスタンプではなくチェックサム(「サム」と単に加算していそうな名前だけど、おそらくダイジェスト)を見るようになっているので、なにもしなくてもこの記事のタイトルのような動作になる。
素晴らしい。
amasawa_seiji 様、ありがとうございます。
rake に手を入れる
rake に手を入れれば解決できるので、手を入れてみた。
ユーザーコード側
require_relative "./contents_task.rb"
contents "generated/cooked.json" => "hoge.json" do |a|
cook_json( a.sources, a.name )
end
file
の代わりに contents
と書くとタイムスタンプではなく中身の比較でビルドするかどうかを判断する。
a.sources
や a.name
も普通に使える。
実装側
問題の contents_task.rb
は、以下の通り:
# frozen_string_literal: true
require "rake/task"
require "digest/sha2"
require "fileutils"
require "json"
module Rake
class ContentsTask < Task
def initialize(*args)
super
@intermediate_dir = ".intermediate"
end
attr_accessor :intermediate_dir
def execute(args=nil)
super
update_state
end
def needed?
should_build?(name) || @application.options.build_all
end
private
def state_fn(files)
File.join(intermediate_dir, Digest::SHA256.hexdigest(files.sort.to_json))
end
def diget_files(files)
a=Digest::SHA256.new
files.sort.each do |fn|
return nil unless File.exist?(fn)
a.file(fn)
end
a.hexdigest
end
def should_build?(name)
files = ([name]+prerequisites).flatten
dfn = state_fn(files)
return true unless File.exist?(dfn)
digest_val = diget_files(files)
return true unless digest_val
return digest_val != File.open(dfn, &:read).strip
end
def update_state
files = ([name]+prerequisites).flatten
dfn = state_fn(files)
FileUtils.mkdir_p( File.split(dfn)[0])
File.open(dfn, "w") do |f|
f.write(diget_files(files))
end
end
class << self
def scope_name(scope, task_name)
Rake.from_pathname(task_name)
end
end
end
end
module Rake
module DSL
def contents(*args, &block)
Rake::ContentsTask.define_task(*args, &block)
end
end
end
七十余行。
そんなに真剣にテストしてなけど、うまく行っている気がする。
どうだろう。