2017-11-14

YAMLでJSONを返すRails Viewハンドラーを書いた

実用性皆無の勉強用でRailsのViewハンドラーを書いてみました。YAMLとERBで記述して、JSONを返すハンドラです。

インストールと使い方

Gemfileにyjを書いてインストールするだけ
gem 'yj'

使い方はviewファイルを拡張子 json.yjで作成してYAMLとERBでそれっぽく書いていきます。

string_property: <%= @string %>
integer_property: 123
array_property:
<% @items.each do |item| -%>
- <%= item %>
<% end -%>
object_property:
  single_partial: <%= r.(partial: 'api/record', locals: { record: { name: 123, code: 100 } })%>
  collection_partial: <%= r.(partial: 'api/record', collection: @records, as: :record) %>

YAMLがJSONとして解釈されるだけで、内部では MultiJson.dump(YAML.load(source)) を呼び出しています。

こんな感じなレスポンスを返してくれます

{
  "string_property": "string",
  "integer_property": 123,
  "array": [
    1,
    2,
    3,
    4,
    5
  ],
  "object_property": {
    "single_partial": {
      "a": 123,
      "b": 100
    },
    "collection_partial": [
      {
        "a": 123,
        "b": 234
      },
      # ...
    ]
  }
}

patialを使うときは r.(partial: 'partial_template', ...)の形式で呼び出します。内部的にはrenderを呼び出していますが、collectionを指定した場合は、YAMLの配列となるように調整しています。

例えば以下のようなpartialを呼び出すとき、

foo: <%= record.foo %>
bar: <%= record.bar %>

単体であればこのままJSONで出力すれば良いですが配列の場合は

- foo: foo_value1
  bar: bar_value1
- foo: foo_value2
  bar: bar_value2

というようにハイフンとインデントを良い感じに配置したいので、そこを調整しています。

パフォーマンスについて

jbuilderの1.5倍遅いです!w

developmentだと〜3倍程度のパフォーマンスが出ます。以下ベンチマークスクリプトと結果です。

require 'action_dispatch/testing/integration'

puts '* Rendering 1 partials via render_partial'
ActionDispatch::Integration::Session.new(Rails.application).get '/api.json?bench=1&n=1'

puts '* Rendering 10 partials via render_partial'
ActionDispatch::Integration::Session.new(Rails.application).get '/api.json?bench=1&n=10'

puts
puts '* Rendering 100 partials via render_partial'
ActionDispatch::Integration::Session.new(Rails.application).get '/api.json?bench=1&n=100'

puts
puts '* Rendering 1000 partials via render_partial'
ActionDispatch::Integration::Session.new(Rails.application).get '/api.json?bench=1&n=1000'

development環境

* Rendering 10 partials via render_partial
Warming up --------------------------------------
            jbuilder     8.000  i/100ms
                  yj     7.000  i/100ms
Calculating -------------------------------------
            jbuilder     74.519  (±18.8%) i/s -    360.000  in   5.106019s
                  yj     72.851  (±12.4%) i/s -    357.000  in   5.013330s

Comparison:
            jbuilder:       74.5 i/s
                  yj:       72.9 i/s - same-ish: difference falls within error


* Rendering 100 partials via render_partial
Warming up --------------------------------------
            jbuilder     1.000  i/100ms
                  yj     3.000  i/100ms
Calculating -------------------------------------
            jbuilder     13.339  (±22.5%) i/s -     65.000  in   5.235478s
                  yj     32.640  (±12.3%) i/s -    162.000  in   5.073978s

Comparison:
                  yj:       32.6 i/s
            jbuilder:       13.3 i/s - 2.45x  slower


* Rendering 1000 partials via render_partial
Warming up --------------------------------------
            jbuilder     1.000  i/100ms
                  yj     1.000  i/100ms
Calculating -------------------------------------
            jbuilder      1.688  (± 0.0%) i/s -      9.000  in   5.333550s
                  yj      4.924  (± 0.0%) i/s -     25.000  in   5.090824s

Comparison:
                  yj:        4.9 i/s
            jbuilder:        1.7 i/s - 2.92x  slower

production環境

* Rendering 10 partials via render_partial
Warming up --------------------------------------
            jbuilder    53.000  i/100ms
                  yj    28.000  i/100ms
Calculating -------------------------------------
            jbuilder    533.308  (± 5.6%) i/s -      2.703k in   5.084701s
                  yj    283.096  (± 6.4%) i/s -      1.428k in   5.065776s

Comparison:
            jbuilder:      533.3 i/s
                  yj:      283.1 i/s - 1.88x  slower


* Rendering 100 partials via render_partial
Warming up --------------------------------------
            jbuilder     7.000  i/100ms
                  yj     4.000  i/100ms
Calculating -------------------------------------
            jbuilder     70.163  (± 7.1%) i/s -    350.000  in   5.019945s
                  yj     49.175  (± 4.1%) i/s -    248.000  in   5.054820s

Comparison:
            jbuilder:       70.2 i/s
                  yj:       49.2 i/s - 1.43x  slower


* Rendering 1000 partials via render_partial
Warming up --------------------------------------
            jbuilder     1.000  i/100ms
                  yj     1.000  i/100ms
Calculating -------------------------------------
            jbuilder      6.514  (± 0.0%) i/s -     33.000  in   5.076581s
                  yj      4.959  (± 0.0%) i/s -     25.000  in   5.056460s

Comparison:
            jbuilder:        6.5 i/s
                  yj:        5.0 i/s - 1.31x  slower

ちなみにproductionで1 partialで2.6倍遅いとか言われたので、ERBやYAMLロードのオーバーヘッドが高そうです。

実際stackprofで雑にプロファイリングしてみましたが、ERBがボトルネックになっていそうでした。うむー

     TOTAL    (pct)     SAMPLES    (pct)     FRAME
       167  (65.0%)          48  (18.7%)     ActionView::CompiledTemplates#_app_views_api__record_json_yj___1428987586935647713_70240367401040
        55  (21.4%)          42  (16.3%)     ERB::Compiler::ExplicitScanner#scan
        14   (5.4%)          14   (5.4%)     Psych::Nodes::Scalar#initialize
        11   (4.3%)          11   (4.3%)     ActiveSupport::ToJsonWithActiveSupportEncoder#to_json
        14   (5.4%)           9   (3.5%)     Psych::ScalarScanner#tokenize

jbuilderはオブジェクト経由でハッシュを作成して最後にMultiJson.dumpする方式なので、ERBなどの余計なコストはかからないです。また、yjを作るにあたって

という無駄なこだわりがあったのですが、ここらへんを解消しつつYAMLのハンドラーで速くするのはちょっと難しかった。

collectionのpartial renderingはArray#join, String#html_safeがかかってくるので、ここをモンキーパッチしないとなると文字列連結で頑張って表現するしかないんですが、yjの場合は一旦JSONに変換してごにょごにょしていて、それも遅さの原因になっていそうです。

ということで実用性皆無のgemが出来上がりましたが、ハンドラー作るに当たってはjbuilderjbやRailsのView周りのコードを読んでとても勉強になりました。

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