crystal
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 駆使すればできると思うが、果たしてそれは書きやすいのだろうか。大量の型キャストも生じてしまう...。