実務では絶対に使わないけどrails/jbuilderのpartialレンダリングを無理矢理早くする方法。
partialでviewを使いまわしたい場合、特に1件のときはオブジェクト、複数件は配列を返したい場合、通常は以下のように書きます。
json.hoge do
json.partial! 'api/hoge/record', collection: @records, as: :record
end
が、これはN+1 partial renderingが発生します。100件くらいの数値配列を出すだけでも遅いです。ログは以下のようにRenderedがたくさん表示されます。
...
Rendered api/hoge/_record.json.jbuilder (0.2ms)
Rendered api/hoge/_record.json.jbuilder (0.1ms)
Rendered api/hoge/_record.json.jbuilder (0.1ms)
Rendered api/hoge/index.json.jbuilder (143.0ms)
Completed 200 OK in 196ms (Views: 189.0ms | ActiveRecord: 0.0ms)
これはpartial!を使うと、collectionのパーシャルレンダリングとして扱われず、renderを複数回呼び出した結果をjbuilder内で連結しており、処理の重いfind_template_pathsがN+1回分呼びだされるためです。
jbuilderファイル内でrenderを直接呼び出すとcollectionのパーシャルレンダリングとして扱われるのでfind_templateも1回だけです。が、オブジェクトの文字列が単純に連結されてしまうので、カンマで無理矢理連結させて配列にした上でJSON.loadすればjbuilderのattibutesに配列としてセットされることになります。
records = render partial: 'api/hoge/record', collection: @records
json.hoge JSON.load("[#{records.gsub('}{', '},{')}]")
gsubでかなり雑に置換しているのが微妙ですが、これだけでもある程度の高速化が見込めます。
ベンチマーク
以下のスクリプトでベンチマークを取りました。require 'benchmark/ips'
module Api
class HogeController < ApplicationController
def index
slow = render_to_string 'slow'
fast = render_to_string 'fast'
raise "slow != fast" unless slow == fast
result = Benchmark.ips do |x|
x.report('slow') { render_to_string 'slow' }
x.report('fast') { render_to_string 'fast' }
x.compare!
end
render plain: result.data.to_json
end
end
end
app/views/api/hoge/slow.json.jbuilder
json.hoge do
json.partial! 'api/hoge/record', collection: (1..100), as: :record
end
app/views/api/hoge/fast.json.jbuilder
records = render partial: 'api/hoge/record', collection: (1..100)
json.hoge JSON.load("[#{records.gsub('}{', '},{')}]")
これをproductionで起動してエンドポイントにアクセスしてベンチマークを取ったところ、2倍くらい性能差が出ました。
Warming up --------------------------------------
slow 7.000 i/100ms
fast 19.000 i/100ms
Calculating -------------------------------------
slow 71.877 (±16.7%) i/s - 350.000 in 5.044258s
fast 155.584 (±26.4%) i/s - 703.000 in 5.032553s
Comparison:
fast: 155.6 i/s
slow: 71.9 i/s - 2.16x slower
stackprofをとったら以下のようになりました。これがpartial!を使ったバージョン
==================================
Mode: wall(1000)
Samples: 364 (23.85% miss rate)
GC: 15 (4.12%)
==================================
TOTAL (pct) SAMPLES (pct) FRAME
241 (66.2%) 133 (36.5%) ActionView::PathResolver#find_template_paths
19 (5.2%) 19 (5.2%) Concurrent::Collection::NonConcurrentMapBackend#[]
210 (57.7%) 13 (3.6%) ActionView::PathResolver#query
29 (8.0%) 13 (3.6%) Logger::LogDevice#write
12 (3.3%) 12 (3.3%) Puma::Single#run
こちらがrenderとgsubでごにょごにょしたバージョン
==================================
Mode: wall(1000)
Samples: 67 (30.21% miss rate)
GC: 0 (0.00%)
==================================
TOTAL (pct) SAMPLES (pct) FRAME
22 (32.8%) 19 (28.4%) ActionView::PathResolver#find_template_paths
4 (6.0%) 4 (6.0%) Puma::Single#run
6 (9.0%) 3 (4.5%) Rack::MiniProfiler::FileStore::FileCache#[]=
6 (9.0%) 3 (4.5%) Logger::LogDevice#write
2 (3.0%) 2 (3.0%) ActionView::LookupContext#registered_details
やはり、find_template_pathsがボトルネックになっています。