Posted at
CrystalDay 14

jpholidayp を Crystal へ移植した話

More than 1 year has passed since last update.


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 オプションを追加で実装しました。

Python 版: https://github.com/emasaka/jpholidayp/blob/cf4b90d00251fdea2585a82cf7ca11994a11fa0c/jpholidayp#L90-L98

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 版がシンプルに見える理由の一つです。

Python 版:

https://github.com/emasaka/jpholidayp/blob/cf4b90d00251fdea2585a82cf7ca11994a11fa0c/jpholidayp#L65-L83


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 を渡しているのは、テストを書きやすくするためです。

Python 版: https://github.com/emasaka/jpholidayp/blob/cf4b90d00251fdea2585a82cf7ca11994a11fa0c/jpholidayp#L48-L63

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 形式へ変換した後、キャッシュとして保存しています。

Python 版: https://github.com/emasaka/jpholidayp/blob/cf4b90d00251fdea2585a82cf7ca11994a11fa0c/jpholidayp#L16-L46

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 を触ったこと無い方は、ぜひ試しに触ってみてください。





  1. Python と比較してるのは、たまたま元コードが Python だっただけで他意はありません。 



  2. union 駆使すればできると思うが、果たしてそれは書きやすいのだろうか。大量の型キャストも生じてしまう...。