Rails でも JSON.generate を使って高速にシリアライズしたい 〜Rails の to_json は遅いが何を実現しているのか〜

Rails では JSON モジュールの提供している to_json を上書きしています
JSON モジュールの to_json は C 言語のレイヤーで再帰処理しますが、Active Support の提供している to_json (ActiveSupport::JSON.encode) は Ruby のレイヤーで再帰処理する上、文字列の処理は毎回 ActiveSupport::JSON::Encoding::JSONGemEncoder::EscapedString を生成するので格段に遅くなります。
そのため、速度が求められる箇所での JSON のシリアライズには JSON.generate を使うことを検討すると良いです。

では、JSON.generate を使うべきでないケースにはどのようなものがあるのでしょう?

ActiveSupport::JSON.encode が実現していること

僕が調べた限り、Rails 5.1 の ActiveSupport::JSON.encode は次の機能を提供しているようです。

  • オプションに only, except が指定できる
    • 出力される JSON の各 object に含まれるキーを制御できる
  • 時刻のフォーマットを ISO 8601 形式にできる
    • ミリ秒にも対応している(ActiveSupport::JSON::Encoding.time_precision で制御)
    • JSON.generate だと #strftime('%Y-%m-%d %H:%M:%S %z') 相当の形式になる(UTC は %z ではなく UTC という表記になる)
  • 時刻の年月日の区切り文字に / が使える
    • ActiveSupport.use_standard_json_time_format で制御
  • Float::INFINITY, Float::NAN を含んだオブジェクトをシリアライズできる
    • いずれも null に変換される
  • \u2028, \u2029, >, <, & をエスケープしてくれる
  • as_json インスタンスメソッドを定義することで任意のクラスを所望の形式にシリアライズできる
  • to_hash インスタンスメソッドを定義することでシリアライズする際に Hash として扱うことができる
  • Struct で生成されたクラスのインスタンスを適切にシリアライズできる
    • メンバを key-value にする
  • 一般的なクラスのインスタンスを適切にシリアライズできる
    • インスタンス変数を key-value にする
  • Enumerator を適切にシリアライズできる
  • Process::Status を適切にシリアライズできる
    • {"exitstatus":0,"pid":2225} のような形式にする

なお、JSON.generate一部のクラスを除いては to_json インスタンスメソッドを定義すれば任意の形式に変換できます
例えば、Process::Status を例にすると次のように to_json を定義することで ActiveSupport::JSON.encode と同じ出力を得ることができます。

class Process::Status
  def to_json(options = nil)
    %Q[{"exitstatus":#{exitstatus},"pid":#{pid}}]
  end
end

system('true')
JSON.generate($?)  #=> "{\"exitstatus\":0,\"pid\":2412}"
ActiveSupport::JSON.encode($?)  #=> "{\"exitstatus\":0,\"pid\":2412}"

以上から、次のようなケースは JSON.generate を使う際に適切に前処理しなければいけないと言えます。

  • シリアライズオプションの only, except を使っているケース
  • 時刻のフォーマットを ISO 8601 形式にしたいケース
  • 時刻のミリ秒も保持したいケース
  • 時刻の年月日の区切り文字に / が使いたいケース
  • Float::INFINITY, Float::NAN を含んだオブジェクトをシリアライズしているケース
  • 独自のクラスをシリアライズしているケース
    • インスタンス変数をよしなにシリアライズしているケース
    • as_jsonto_hash を実装してシリアライズしているケース
  • Struct で生成したクラスのインスタンスをシリアライズしているケース
  • Enumerator, Process::Status をシリアライズしているケース
  • JS・HTML にシリアライズ結果を埋め込むケース

シリアライズ結果の具体例

実際にどのような違いになるか見てみます。コード一式はこちらにまとめてあります。
serialize.rb を実行すると次のような結果になります。

Time serializations

Time Type time_precision use_standard_json_time_format JSON.generate ActiveSupport::JSON.encode
time_with_zone 3 true “2017-01-01 09:00:00 +0900” “2017-01-01T09:00:00.123+09:00”
date_time 3 true “2017-01-01T00:00:00+00:00” “2017-01-01T00:00:00.123+00:00”
utc_time 3 true “2017-01-01 00:00:00 UTC” “2017-01-01T00:00:00.123Z”
local_time 3 true “2017-01-01 00:00:00 +0900” “2017-01-01T00:00:00.123+09:00”
date 3 true “2017-01-01” “2017-01-01”
time_with_zone 0 true “2017-01-01 09:00:00 +0900” “2017-01-01T09:00:00+09:00”
date_time 0 true “2017-01-01T00:00:00+00:00” “2017-01-01T00:00:00+00:00”
utc_time 0 true “2017-01-01 00:00:00 UTC” “2017-01-01T00:00:00Z”
local_time 0 true “2017-01-01 00:00:00 +0900” “2017-01-01T00:00:00+09:00”
date 0 true “2017-01-01” “2017-01-01”
time_with_zone 3 true “2017-01-01 09:00:00 +0900” “2017-01-01T09:00:00.123+09:00”
date_time 3 true “2017-01-01T00:00:00+00:00” “2017-01-01T00:00:00.123+00:00”
utc_time 3 true “2017-01-01 00:00:00 UTC” “2017-01-01T00:00:00.123Z”
local_time 3 true “2017-01-01 00:00:00 +0900” “2017-01-01T00:00:00.123+09:00”
date 3 true “2017-01-01” “2017-01-01”
time_with_zone 3 false “2017-01-01 09:00:00 +0900” “2017/01/01 09:00:00 +0900”
date_time 3 false “2017-01-01T00:00:00+00:00” “2017/01/01 00:00:00 +0000”
utc_time 3 false “2017-01-01 00:00:00 UTC” “2017/01/01 00:00:00 +0000”
local_time 3 false “2017-01-01 00:00:00 +0900” “2017/01/01 00:00:00 +0900”
date 3 false “2017-01-01” “2017/01/01”

Other serializations

Type options JSON.generate ActiveSupport::JSON.encode
standard_class   ”#<StandardClass:0x007f8dc2a33e80>” {“a”:1}
hash_with_only {:only=>:a} {“a”:1,”b”:2} {“a”:1}
hash_with_except {:except=>:a} {“a”:1,”b”:2} {“b”:2}
with_to_hash   ”#<WithToHash:0x007f8dc2a33cf0>” {“a”:1}
with_as_json   ”#<WithAsJson:0x007f8dc2a33c00>” ”{:a=\u003e1}”
struct   ”#<struct a=1>” {“a”:1}
infinity   - null
nan   - null
enumerator   ”#<Enumerator:0x007f8dc2a337c8>” []
process_status   “pid 5414 exit 0” {“exitstatus”:0,”pid”:5414}
special_chars   “  ><&” “\u2028\u2029\u003e\u003c\u0026”

シリアライズ速度の比較

次のようなコードで速度に定評のある oj も比較対象に入れてベンチマークを取ってみました。それなりに容量の大きい JSON を対象にしたかったので、Solr のサンプルデータを使っています。
JSON.fast_generate なるものが存在することを知ったので、それも比較対象に入れています。

require 'open-uri'
require 'json'
require 'active_support/time'
require 'active_support/json'
require 'oj'
require 'benchmark/ips'

films = JSON.parse(open('https://raw.githubusercontent.com/apache/lucene-solr/releases/lucene-solr/6.5.1/solr/example/films/films.json').read)

Benchmark.ips do |x|
  x.report('JSON.generate') { JSON.generate(films) }
  x.report('JSON.fast_generate') { JSON.fast_generate(films) }
  x.report('ActiveSupport::JSON.encode') { ActiveSupport::JSON.encode(films) }
  x.report('Oj.dump (compat)') { Oj.dump(films, mode: :compat) }
  x.report('Oj.dump (rails)') { Oj.dump(films, mode: :rails) }
  x.compare!
end

結果は次のとおりで、JSON.generateActiveSupport::JSON.encode には 10 倍以上の差があります。JSON.fast_generate は別に fast じゃないですね・・・(ネストが深い場合に高速?)

Warming up --------------------------------------
       JSON.generate    16.000  i/100ms
  JSON.fast_generate    15.000  i/100ms
ActiveSupport::JSON.encode
                         1.000  i/100ms
    Oj.dump (compat)    57.000  i/100ms
     Oj.dump (rails)    59.000  i/100ms
Calculating -------------------------------------
       JSON.generate    161.295  (± 9.3%) i/s -    800.000  in   5.015347s
  JSON.fast_generate    163.443  (± 9.2%) i/s -    810.000  in   5.001334s
ActiveSupport::JSON.encode
                         14.141  (± 7.1%) i/s -     70.000  in   5.014242s
    Oj.dump (compat)    587.586  (±10.9%) i/s -      2.907k in   5.013221s
     Oj.dump (rails)    612.870  (±10.1%) i/s -      3.068k in   5.065790s

Comparison:
     Oj.dump (rails):      612.9 i/s
    Oj.dump (compat):      587.6 i/s - same-ish: difference falls within error
  JSON.fast_generate:      163.4 i/s - 3.75x  slower
       JSON.generate:      161.3 i/s - 3.80x  slower
ActiveSupport::JSON.encode:       14.1 i/s - 43.34x  slower

oj は圧倒的な速さに見えますが、少し条件を変えるだけで極端に遅くなるので注意が必要です。
例えば次のように文字列ではなく時刻のデータを扱うだけで途端に遅くなります。

films.each do |film|
  film['initial_release_date'] = Time.parse(film['initial_release_date']) if film['initial_release_date']
end

結果は次のように変わります。

Warming up --------------------------------------
       JSON.generate    10.000  i/100ms
  JSON.fast_generate     9.000  i/100ms
ActiveSupport::JSON.encode
                         1.000  i/100ms
    Oj.dump (compat)     5.000  i/100ms
     Oj.dump (rails)    17.000  i/100ms
Calculating -------------------------------------
       JSON.generate    101.192  (± 7.9%) i/s -    510.000  in   5.075571s
  JSON.fast_generate    100.530  (± 9.9%) i/s -    504.000  in   5.072653s
ActiveSupport::JSON.encode
                         14.108  (±14.2%) i/s -     70.000  in   5.040475s
    Oj.dump (compat)     61.948  (± 9.7%) i/s -    310.000  in   5.051292s
     Oj.dump (rails)    171.232  (± 8.8%) i/s -    850.000  in   5.006759s

Comparison:
     Oj.dump (rails):      171.2 i/s
       JSON.generate:      101.2 i/s - 1.69x  slower
  JSON.fast_generate:      100.5 i/s - 1.70x  slower
    Oj.dump (compat):       61.9 i/s - 2.76x  slower
ActiveSupport::JSON.encode:       14.1 i/s - 12.14x  slower

まとめ

  • Rails の to_json (ActiveSupport::JSON.encode) はたいていのケースで JSON.generate に置き換えが可能そう
  • JSON.generate を使うことで何倍も速くなる

おまけ 〜JSON.generate と ActiveSupport::JSON.encode の差分の確認方法〜

コードを眺めるだけだとイメージがつかめなかったので、Rails に用意されているテストで ActiveSupport::JSON.encode が使われている箇所を JSON.generate に変更して実行することで差分を確認しました。

% git clone git@github.com:rails/rails.git
% cd rails
% git checkout v5.1.0
% bundle install
% curl https://gist.githubusercontent.com/abicky/779d0f34763e755f594d16a376a81c49/raw/b2d59a3b5108ae92a85e025081feff26560ffd60/encoding_test.rb.patch | patch -p1
% cd activesupport
% ./bin/test test/json/encoding_test.rb