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, >, <, & をエスケープしてくれる
ActiveSupport::JSON::Encoding.escape_html_entities_in_json
を false にすると \u2028, \u2029 だけエスケープする- \u2028, \u2029 をエスケープするのはシリアライズした結果を JS のコードに埋め込むことを配慮したらしい cf. Escape of U+2028 and U+2029 in the JSON Encoder by cmaruz · Pull Request #10534 · rails/rails
- >, <, & をエスケープするのは “to be in compliance with the JSON spec.” とのことだけど、JSON にそんな仕様はなさそうだからこれも JS や HTML に埋め込むことを配慮しているんじゃないかと cf. https://github.com/chancancode/rails/commit/c708346688ee3cdd5583795ccd9b10590abd36b1
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_json
やto_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.generate
と ActiveSupport::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