2017-11-16

jbuilderより速いRails Viewハンドラーを書いた

YAMLとERBでJSONを書けるハンドラを書いたものの遅くなってしまったので、一旦YAMLとERBのことは忘れ、evalとしては最速になるようにhashをJSONに変換するアプローチで再度書いてみました。

また前回同様、

というのをコンセプトでやっています。

できあがったものはこれ↓

インストールと使い方

インストールは
gem 'jv'

でbundle installするだけ。

使い方は amatsuda/jb とほぼ同様の使い方になりますが、パーシャルレンダリングの方法が違います。

json = {}
json[:string_property] = @string
json[:integer_property] = 123
json[:array_property] = @integers
json[:object_property] = {
  single_partial: r.(partial: 'api/record', locals: { record: { a: 123, b: 100 } }),
  collection_partial: r.(partial: 'api/record', collection: @records, as: :record)
}
json

パーシャルレンダリングしたい場合は r.(partial: 'xxxx', ....)を呼び出します。

内部のしくみ

Railsのcollectionのパーシャルレンダリングの仕組みを使ってしまうと、レンダリングされた個々のレコードのビューがjoinメソッドによって文字列化+連結されてしまいます。JSONの場合は配列を期待しているので、カンマ区切り連結しつつ先頭と末尾に[]が付いてほしいのですが仕組み上できません。

なので、jvではハンドラー側でよしなに配列のJSON文字列にしています。

rはJv::Renderのインスタンスで、callメソッドで実際のパーシャルレンダリングを行っています。

module Jv
  class Render
    def initialize(context)
      @context = context
    end

    def call(options)
      render_collection = options[:collection].present?
      options[:locals] ||= {}
      options[:locals].merge!(r: self, _partial: true) if render_collection
      json = @context.render options
      if render_collection
        json.prepend('[')
        json[-1] = ']'
      end
      JSON.load(json)
    end
  end
end

collectionの引数が指定された場合は_partial: trueを指定してレンダリングします。_partialが定義されていると、hash.to_jsonの後ろにカンマを付けています。

require 'jv/view'
require 'jv/render'

module Jv
  class Handler
    cattr_accessor :default_format
    self.default_format = Mime[:json]

    def self.call(template)
      <<~SRC
        r ||= Jv::Render.new(self)
        _partial ||= nil
        hash = ->{ #{template.source} }.()
        _partial.nil? ? hash.to_json : "\#{hash.to_json},"
      SRC
    end
  end
end

このままjoinすると後ろに余計なカンマができるので削除しつつ、配列の括弧で囲んであげて最後にJSON.loadしてハッシュ化します。

if render_collection
  json.prepend('[')
  json[-1] = ']'
end
JSON.load(json)

配列結合が無理矢理感満載ですが一応動いているようです。

ベンチマーク

今回は本番環境でjbuilderより〜3倍程度速かったです!

Development

* Rendering 10 partials via render_partial
Warming up --------------------------------------
            jbuilder     8.000  i/100ms
                  jv    10.000  i/100ms
Calculating -------------------------------------
            jbuilder     79.193  (± 7.6%) i/s -    400.000  in   5.085408s
                  jv    106.160  (± 6.6%) i/s -    530.000  in   5.013174s

Comparison:
                  jv:      106.2 i/s
            jbuilder:       79.2 i/s - 1.34x  slower


* Rendering 100 partials via render_partial
Warming up --------------------------------------
            jbuilder     1.000  i/100ms
                  jv     7.000  i/100ms
Calculating -------------------------------------
            jbuilder     14.927  (±13.4%) i/s -     74.000  in   5.029316s
                  jv     71.601  (± 7.0%) i/s -    357.000  in   5.019427s

Comparison:
                  jv:       71.6 i/s
            jbuilder:       14.9 i/s - 4.80x  slower


* Rendering 1000 partials via render_partial
Warming up --------------------------------------
            jbuilder     1.000  i/100ms
                  jv     1.000  i/100ms
Calculating -------------------------------------
            jbuilder      1.614  (± 0.0%) i/s -      9.000  in   5.587806s
                  jv     19.176  (±10.4%) i/s -     95.000  in   5.001148s

Comparison:
                  jv:       19.2 i/s
            jbuilder:        1.6 i/s - 11.88x  slower<code>
</code>

Production

* Rendering 10 partials via render_partial
Warming up --------------------------------------
            jbuilder    48.000  i/100ms
                  jv    88.000  i/100ms
Calculating -------------------------------------
            jbuilder    496.263  (± 8.7%) i/s -      2.496k in   5.075655s
                  jv    888.175  (± 7.0%) i/s -      4.488k in   5.080282s

Comparison:
                  jv:      888.2 i/s
            jbuilder:      496.3 i/s - 1.79x  slower


* Rendering 100 partials via render_partial
Warming up --------------------------------------
            jbuilder     6.000  i/100ms
                  jv    18.000  i/100ms
Calculating -------------------------------------
            jbuilder     64.780  (±10.8%) i/s -    324.000  in   5.069040s
                  jv    192.008  (± 6.2%) i/s -    972.000  in   5.082385s

Comparison:
                  jv:      192.0 i/s
            jbuilder:       64.8 i/s - 2.96x  slower


* Rendering 1000 partials via render_partial
Warming up --------------------------------------
            jbuilder     1.000  i/100ms
                  jv     2.000  i/100ms
Calculating -------------------------------------
            jbuilder      6.645  (±15.0%) i/s -     33.000  in   5.001550s
                  jv     23.705  (± 8.4%) i/s -    118.000  in   5.000942s

Comparison:
                  jv:       23.7 i/s
            jbuilder:        6.6 i/s - 3.57x  slower

1000件のパーシャルレンダリングでstackprofを取ってみたらこんな感じでした。

     TOTAL    (pct)     SAMPLES    (pct)     FRAME
         8  (13.1%)           8  (13.1%)     ActiveSupport::ToJsonWithActiveSupportEncoder#to_json
        16  (26.2%)           8  (13.1%)     ActiveSupport::JSON::Encoding::JSONGemEncoder::EscapedString#to_json
        11  (18.0%)           7  (11.5%)     ActionView::PathResolver#find_template_paths
        24  (39.3%)           7  (11.5%)     Hash#as_json
        30  (49.2%)           6   (9.8%)     ActiveSupport::JSON::Encoding::JSONGemEncoder#jsonify
         4   (6.6%)           4   (6.6%)     ActionView::Template#instrument_payload
        18  (29.5%)           2   (3.3%)     #<Module:0x007faaf62d1d30>.generate

特に大きなボトルネックになっているところは無さそうですが、さすがにto_jsonしまくっているのでそこは少し目立ってます。

このエントリーをはてなブックマークに追加