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