strptime gemで高速に文字列をTimeに変換する


## 背景

Rubyで文字列の時間表現を時間に変換するには`Time.strptime`をよく使います．以下は公式リファレンスの例です．

```rb
Time.strptime('2001-02-03T04:05:06+09:00', '%Y-%m-%dT%H:%M:%S%z')
#=> 2001-02-03 06:05:06 +0900
```

これは楽でとても便利なんですが，`Time.strptime`には遅いという致命的な問題があります．例えばFluentdのようなひたすらログを読み込んでパースするソフトウェアの場合，`Time.strptime`そのものがボトルネックになります．これは`Time.strptime`は毎回文字列フォーマットをパースしないといけないなど，いくつかの要因があります．

Fluentdでこの問題をどう解決していたかと言うと，文字列をキャッシュして，前と文字列が同じであれば`Time.strptime`をskipするという方法をとってます([該当コード](https://github.com/fluent/fluentd/blob/c5d22cf2f082ceda47a57375f9e2d7c511c75a1b/lib/fluent/parser.rb#L74))．

今まではこれで結構うまく行ってたんですが，ミリ秒やナノ秒レベルの時間のパースをちゃんとサポートしようとすると，キャッシュが効かないという問題が出てきました(例えば「[Support nanosecond](https://github.com/fluent/fluentd/pull/653)」の機能とか)．で困ったなぁということで，目の前に座っているRubyコミッタの方に相談したところ，`strptime`というそのものずばりなgemを作ってくれました．

https://github.com/nurse/strptime

## 使い方

インストールは`gem install strptime`するだけです．コードはREADMEにも例が載ってますが，以下のようになります．`Strptime`クラスをパースしたいフォーマットで生成し，あとで`exec`メソッドでパースしたい文字列を渡すだけです．結果は`Time`オブジェクトで返ってくるので，あとは煮るなり焼くなり好きにするだけです．

```rb
require 'strptime'

parser = Strptime.new('%Y-%m-%dT%H:%M:%S%z')
parser.exec('2015-12-25T12:34:56+09') #=> 2015-12-25 12:34:56 +09:00
parser.execi('2015-12-25T12:34:56+09') #=> 1451014496
```

`Strptime`はあらかじめフォーマットをパースして専用の命令セットを構築し，変換する時にはその命令セットをなぞるだけになっています．なので`Time.strptime`で行われるような毎回のフォーマットのパースをスキップでき，その分はるかに高速です(その他いくつかの最適化とかが入ってます)．

## ベンチマーク

`Strptime`を使えば，キャッシュがなくてもかなり高速に動くようになります．以下が簡単なベンチマークスクリプトと手元のMBPでの結果になりますが(`Time.strptime`はキャッシュ機能付き)，`Strptime`バージョンでも十分高速に処理出来てることがわかります．360000件のパースに0.4秒しか掛かってないので，Fluentdでも十分利用出来ます．

- 結果

```sh
                           user     system      total        real
sec:Time.strptime      0.110000   0.000000   0.110000 (  0.110357)
sec:Strptime           0.360000   0.010000   0.370000 (  0.360605)
msec:Time.strptime     5.580000   0.080000   5.660000 (  5.707343)
msec:Strptime          0.360000   0.000000   0.360000 (  0.358431)
```

- スクリプト

```rb
require 'benchmark'
require 'time'
require 'strptime'

class ParserError < StandardError
end

# Copied from Fluentd's TimeParser
class TimeParserWithTimeFormat
  def initialize(time_format)
    @cache1_key = nil
    @cache1_time = nil
    @cache2_key = nil
    @cache2_time = nil
    @parser = Proc.new { |value| Time.strptime(value, time_format) }
  end

  def parse(value)
    if @cache1_key == value
      return @cache1_time
    elsif @cache2_key == value
      return @cache2_time
    else
      begin
        time = @parser.call(value).to_i
      rescue => e
        raise ParserError, "invalid time format: value = #{value}, error_class = #{e.class.name}, error = #{e.message}"
      end
      @cache1_key = @cache2_key
      @cache1_time = @cache2_time
      @cache2_key = value
      @cache2_time = time
      return time
    end
  end
end

class TimeParserWithStrptime
  def initialize(time_format)
    @strptime = Strptime.new(time_format)
    @parser = @strptime.method(:exec)
  end

  def parse(value)
    begin
      return @parser.call(value).to_i
    rescue => e
      raise ParserError, "invalid time format: value = #{value}, error_class = #{e.class.name}, error = #{e.message}"
    end
  end
end

sec_times = []
60.times { |i|
  60.times { |j|
    100.times {
      sec_times << "28/Feb/2015:10:%02d:%02d +0900" % [i, j]
    }
  }
}

msec_times = []
60.times { |i|
  60.times { |j|
    100.times { |k|
      msec_times << "28/Feb/2015:10:%02d:%02d.%03d +0900" % [i, j, k]
    }
  }
}

sec_time_format = "%d/%b/%Y:%H:%M:%S %z"
msec_time_format = "%d/%b/%Y:%H:%M:%S.%N %z"

Benchmark.bm(20) do |x|
  x.report('sec:Time.strptime') {
    parser = TimeParserWithTimeFormat.new(sec_time_format)
    sec_times.each { |t|
      parser.parse(t)
    }
  }
  x.report('sec:Strptime') {
    parser = TimeParserWithStrptime.new(sec_time_format)
    sec_times.each { |t|
      parser.parse(t)
    }
  }
  x.report('msec:Time.strptime') {
    parser = TimeParserWithTimeFormat.new(msec_time_format)
    msec_times.each { |t|
      parser.parse(t)
    }
  }
  x.report('msec:Strptime') {
    parser = TimeParserWithStrptime.new(msec_time_format)
    msec_times.each { |t|
      parser.parse(t)
    }
  }
end
```

## まとめ

ということで，もし`Time.strptime`を結構な頻度で呼び出すアプリケーションを書いてる人がいれば，`strptime` gemを使うと，パフォーマンスが改善すると思います．まぁ`Time.strptime`がボトルネックになるようなRubyアプリケーションがそんなにあるとは思いませんが…