TL; DR
- jpholidayp という平日のみ特定のコマンドを動作させるコマンドがある
- jpholidayp は Python で書かれているが、今回それを Crystal へ移植した
- この記事では Python で記述されたオリジナル版と Crystal のコードを比較し考察している
jpholidayp とは?
jpholidayp は Python で記述されたコマンドラインツールで、平日のみ・休日のみ実行したいコマンドを楽に記述することができます。Python 2 系と 3 系のどちらでも動作します。
以下のように、他のコマンドと合わせて使うのが一般的です。crontab と合わせて使うと、非常に便利です。
休日のみ、特定のコマンドを動作させたい場合:
jpholidayp && some-command
平日のみ、特定のコマンドを動作させたい場合:
jpholidayp || some-command
Crystal 版の jpholidayc を開発しました
jpholidayp と同じ挙動をするコマンドを Crystal でも作成しました。本家の名前の末尾の p
を Python の p
であると予想し、Crystal の c
に変えた非常に安直な命名です。
それぞれ、元となった Python のコードと比較して、コードを解説していきたいと思います 1
エントリーポイント
どちらも読みやすい簡潔なコードになっています。Crystal 版にはオリジナル版にはない -v
オプションを追加で実装しました。
def jpholidayp():
today = date.today()
if JpHoliday.is_holiday(today):
sys.exit(EXIT_HOLIDAY)
else:
sys.exit(EXIT_WEEKDAY)
if __name__ == "__main__":
jpholidayp()
Crystal 版: https://github.com/pine/jpholidayc/blob/master/src/jpholiday.cr
if ARGV.includes? "-v"
puts "v#{JpHoliday::VERSION}"
end
calendar = JpHoliday::Calendar.new
if calendar.is_holiday(Time.now)
exit JpHoliday::EXIT_HOLIDAY
else
exit JpHoliday::EXIT_WEEKDAY
end
祝日判定処理
ここは Crystal で書いたほうがシンプルに書けた部分です。
Crystal の日時型 (Time) には sunday?
のような曜日を判定するメソッドが標準で生えていて、数値比較いらずで曜日を判定することができます。オリジナル版は、曜日判定部分の可読性を上げるために定数への代入を行っていますが、Crystal ではそれは不要でした。
Python とは違い、if 自体が値を返すことも Crystal 版がシンプルに見える理由の一つです。
class JpHoliday:
@classmethod
def is_national_holiday(self, dt):
holiday_jp = HolidayJp()
return holiday_jp.is_holiday(dt)
# value of datetime.weekday()
SATURDAY = 5
SUNDAY = 6
@classmethod
def is_holiday(self, dt):
w = dt.weekday()
if w == self.SATURDAY or w == self.SUNDAY:
return True
elif self.is_national_holiday(dt):
return True
else:
return False
Crystal 版: https://github.com/pine/jpholidayc/blob/master/src/jpholiday/calendar.cr
class JpHoliday::Calendar
def is_holiday(dt : Time) : Bool
if dt.saturday? || dt.sunday?
true
elsif is_national_holiday(dt)
true
else
false
end
end
def is_national_holiday(dt : Time) : Bool
holiday_jp = HolidayJp.new
holiday_jp.is_holiday(dt)
end
end
YAML ファイルダウンロード部分
ここは Python での記述の方が圧倒的に簡潔でした。Crystal は静的言語である点と nil 安全であることが原因で、Python よりも複雑に見えるコードになってしまっています (not_nil! とか使えば短くはなるけど、それは何か違う)。
Crystal では YAML は YAML.mapping
を使って型定義を書くことで、配列・オブジェクトへ直接変換できます。今回の要件ではキーが存在するか見れれば良いのでそれは行っていません。
コンストラクタ引数で cache
を渡しているのは、テストを書きやすくするためです。
class HolidayJp:
URL = 'https://raw.githubusercontent.com/k1LoW/holiday_jp/master/holidays.yml'
def __init__(self):
cache = Cache()
c = cache.get()
if c:
dat = c["holiday_jp"]
else:
res = urlopen(self.URL)
dat = yaml.load(res)
cache.set({"holiday_jp": dat})
self.holiday_jp = dat
def is_holiday(self, dt):
return dt in self.holiday_jp.keys()
Crystal 版: https://github.com/pine/jpholidayc/blob/master/src/jpholiday/holiday_jp.cr
class JpHoliday::HolidayJp
URL = "https://raw.githubusercontent.com/k1LoW/holiday_jp/master/holidays.yml"
@holiday_jp : Hash(String, Bool)
def initialize(cache = Cache.new)
if dat = cache.get
@holiday_jp = dat
return
end
res = HTTP::Client.get(URL)
dat = {} of String => Bool
if res.success?
obj = YAML.parse(res.body)
dat = obj.as_h.keys.map { |k| {k.to_s, true} }.to_h
cache.set(dat)
else
puts "#{res.status_code} #{res.status_message}"
exit EXIT_ERROR
end
@holiday_jp = dat
end
def is_holiday(dt : Time) : Bool
@holiday_jp.has_key? dt.to_s("%Y-%m-%d")
end
end
キャッシュ処理部分
キャッシュ部分は、正直実装がかなり面倒でした。
Python 版では、ネット上から拾ってきた YAML を Python のリスト・辞書へ変換し、さらにそれを pickle モジュールを使いそのままシリアライズしています。これは、静的言語である Crystal では超えられない壁です 2。
そのため Crystal ではその方法はきっぱり諦めて、データの中間形式を用意しています。そのため Crystal 版は一旦 JSON 形式へ変換した後、キャッシュとして保存しています。
class Cache:
def __init__(self):
try:
os.mkdir(os.path.expanduser(datadir))
except OSError:
# get exception instance on Python <= 2.5, 2.6/2.7 and 3.x
if sys.exc_info()[1].errno != errno.EEXIST:
raise
def get(self):
file = os.path.join(os.path.expanduser(datadir), cachefile)
if not os.path.exists(file):
return None
today = date.today()
with open(file, 'rb') as f:
dat = pickle.load(f)
if dat["expires"] <= today:
return None
else:
return dat["val"]
def set(self, val):
expires = date.today() + timedelta(cachedays)
dat = {"expires": expires, "val": val}
file = os.path.join(os.path.expanduser(datadir), cachefile)
with open(file, 'wb') as f:
pickle.dump(dat, f, protocol=2) # for Python 2.x and 3.x
Crystal 版: https://github.com/pine/jpholidayc/blob/master/src/jpholiday/cache.cr
class JpHoliday::Cache
DATA_DIR = "~/.jpholidayc"
CACHE_DAYS = 5
CACHE_FILE = "cache"
def initialize
@cache_dir = File.expand_path(DATA_DIR)
@cache_path = File.join(@cache_dir, CACHE_FILE)
Dir.mkdir_p @cache_dir
end
def get : Hash(String, Bool)?
begin
dat = Container.from_json(File.read(@cache_path, encoding: "utf-8"))
if dat.expires <= Time.now
nil
else
dat.val
end
rescue e : JSON::ParseException
nil
rescue e : Errno
unless e.errno == Errno::ENOENT
raise e
end
nil
end
end
def set(val : Hash(String, Bool))
expires = Time.now + CACHE_DAYS.days
dat = Container.new(expires, val)
File.write(@cache_path, dat.to_json, encoding: "utf-8")
end
class Container
JSON.mapping(
expires: Time,
val: Hash(String, Bool),
)
def initialize(@expires : Time, @val : Hash(String, Bool))
end
end
end
テスト
jpholidayc では、それぞれのファイル別にテストを記述してあります。Crystal は Ruby から引き継いだオープンクラスの概念があるので、かなり強引にモックしてテストしています。テストには、組み込みのライブラリである spec
を使っています。
テストコード: https://github.com/pine/jpholidayc/tree/master/spec
実行速度について
Python 版と比較したときの実行速度を比較してみました。どちらも、データはキャッシュされており、HTTPS 通信は発生していない状態です。
Crystal 版は Python 版に比べ、非常に高速に動作していることが分かります。
$ time -p jpholidayp
real 0.16
user 0.12
sys 0.04
$ time -p jpholidayc
real 0.00
user 0.00
sys 0.00
ちなみに、コンパイル速度は結構かかります。
$ time -p crystal build src/jpholiday.cr -o bin/jpholidayc
real 1.58
user 1.61
sys 0.81
全体を通しての所感
やはり Crystal は標準ライブラリがかなり充実しているので、外部ライブラリへ依存しなくても完成することができました。動作も非常に高速です。
言語の仕様は相変わらず流動的でエコシステムも未熟ですが、簡単なコンソールアプリケーションを使うには十分だと思います。まだ Crystal を触ったこと無い方は、ぜひ試しに触ってみてください。