Jbuilderが遅いから、MVCを保ちつつjsonのレンダリングを速くできる仕組みを作った話です。
Gemを公開したので、よかったら使ってみてください!
詳しくは Github で!
この記事はAkatsuki Advent Calendar 7日目の記事です。
前回は jyllsarta さんのほぼ素のRailsアプリだった「スピカとチロル」が本番環境で安定動作するまででした。
なぜ作るか
RailsでAPIサーバーを書き始めて2年くらいの頃、負荷改善が好きで、毎日New Relic を眺め、APIの負荷改善をしていました。遅いAPIの改善をしていた際に、partialを展開することでレスポンス時間がかなり改善したことがありました。それで疑問を持ち始め、今回の話の始まりでした。
Jbuilderはどう動いているのか
Jbuilderの仕組みを読んでみる。
まず、Jbuilderを書いたことがある人なら、JbuilderのDSLでjsonという変数が使われていることは気になりますよね?
ActionView用のHandler
class JbuilderHandler
  cattr_accessor :default_format
  self.default_format = :json
  def self.call(template, source = nil)
    source ||= template.source
    # this juggling is required to keep line numbers right in the error
    %{__already_defined = defined?(json); json||=JbuilderTemplate.new(self); #{source}
      json.target! unless (__already_defined && __already_defined != "method")}
  end
end
Handlerは call でrubyコードをActionViewに渡す役割をしています。Jbuilderでいつも出てくる謎の json はJbuilderTemplateのインスタンスであることが分かりますね。
ActionViewも読んでみる
def compile(mod)
  source = encode!
  code = @handler.call(self, source)
  # Make sure that the resulting String to be eval'd is in the
  # encoding of the code
  original_source = source
  source = +<<-end_src
    def #{method_name}(local_assigns, output_buffer)
      @virtual_path = #{@virtual_path.inspect};#{locals_code};#{code}
    end
  end_src
...
ActionViewでローカル変数の代入がされるようになっていますね。ちなみに、パーシャルの方で必要な引数を明確に定義していないのは、パーシャルを使うときに困りますよね、といつも思ってました。
次に、気になるのは、 json.XXX のDSL
  def method_missing(*args, &block)
    if ::Kernel.block_given?
      set!(*args, &block)
    else
      set!(*args)
    end
  end
method_missing が使われてて、メソッドが存在しなかったら、その名前でキーをセットしてますね!このような書き方にすると、callの回数が2倍になりますね。本当にわかりやすいかというと、そうでもないような気もするので、やめたいですね。
partialが重い問題
  def _render_partial(options)
    options[:locals].merge! json: self
    @context.render options
  end
内部で直接呼べなくて、ループ回数分遠回りしてActionViewのcontextからrenderで呼んでますね。テンプレート検索も必要なので、重いのは当然ですね。
無駄な変数のアロケーションが多い
階層が深いので、ここではお見せできないですが、想像だけでわかる気がします。
まだまだありますが、まあ、不満を言うのはここまでにします。
既存の改善ソリューション:Jb
あの松田さんが作った、partialのレンダリングが速く、DSLをやめてシンプルなrubyでテンプレートを書けるGemです。Jbuilderの悩みを解決してくれて素晴らしいものの、そもそもActionViewはJSONレンダリングに向いていないので、まだまだパフォーマンスが上がる余地があると考えました。
それで、自分で実装することにしました。
simple_jsonの仕組みを設計する
やりたいことは、シンプルな仕組み&最小限の改造で、最大限に速くすることです。
前提の一つとして、MVCの構造を保ちたいと思いました。(ただし、VはActionViewである必要はない。)モデルでJSON変換するようなやり方もありますが、直感的でなくなるし、複雑なフォーマットに対応しにくいかも、と感じました。
改善したいと思ったところ
ActionViewはスキップして、独自のViewを実装する
ActionViewのテンプレートの仕組みは、やはりJSONに向いていない気がしました。
- テンプレートはプログラムとしてメモリに載せたい!
- テンプレートのパス補完のためにファイル検索などで時間がかかっていますが、実際コードを書く場合、フルパスで1対1でいい!(hashで検索できる)
- 例えばshowの中身をindexで使いたい場合はたくさんありますよね!パーシャルとテンプレートを区別しないほうがいい!
テンプレートの構文については、DSLをやめたいと思いました。
- 素のrubyで書きたい
- Layoutはいらない、あるいは、あとで実装するでいい
- メモリに載せやすい & 引数がわかりやすい → 形はlambdaですね
- helperは使えるようにしたい
- できればJSON encodingも早くできればいいですね
実装
実装はここに公開しています。
https://github.com/aktsk/simple_json
少しだけ、解説をしますね!
入口: controllerでのオーバーライド
controllerの default_render と render_to_body をオーバーライドします。
default_render は render が明示的に呼ばれていない時に呼ばれるもので、テンプレートが存在すれば render すればいい。
render_to_body はレスポンスのテキスト内容を返すメソッドで、ここで simple_json のレンダリングを行えば良い。
simple_json の使われる条件に合わない場合は super で通常のレンダリングが実行されます。
レンダリング
SimpleJsonRenderer というクラスのインスタンスでレンダリングを実行するようにします。使われたテンプレートはインスタンスでキャッシュされます。また、メモリに事前に読む設定をオンに( SimpleJson.enable_template_cache = true )すれば、テンプレートは SimpleJsonRenderer クラスに全部事前ロードされます。
このインスタンス上でレンダリングするので、ヘルパーや、Viewに渡す変数(インスタンス変数)をこのインスタンスにbindされた状態で、テンプレートのlambdaをinstance_execで実行させます。
テンプレート
テンプレートは単純にrubyをevalでlambdaに変換しています。引数がない場合はいちいち-> {  }を書くのが面倒なので、省略可能な仕様にし、自動的に補完するようにしました。
他の便利機能
- キャッシュ helper
- C実装の JSON encoder をデフォルトで使うように
- 複数のパス設定
また、マイグレート用の機能も作りました。Jbuilderから乗り換える時に少しでも便利にできるように。使う時に下記のようにincludeをすればよい。
include SimpleJson::SimpleJsonRenderable
include SimpleJson::Migratable
もちろん、Gem化するので、テストも必要で追加しました(大変でした)。
そして、リリース!
https://rubygems.org/gems/simple_json
実際使ってみて、速さを測ってみる
railsプロジェクトを新規作成する
rails new simple-json-test
simple_jsonのGemを入れる。bundle installを忘れずにね!
gem 'simple_json'
開発環境は開発しながら見るので、ファイルを毎回アクセスすればいいですが、本番環境やテスト環境はテンプレートのキャッシュを有効にするとパフォーマンスが上がります。
Rails.application.configure do
  ...
  SimpleJson.enable_template_cache
end
postコントローラなどを作成する。
class PostsController < ApplicationController
  include SimpleJson::SimpleJsonRenderable
  def index
    @posts = (1..100).map { |i|
      Post.new(i, "title #{i}", "body #{i}")
    }
  end
end
モデルはシンプルにStructを使う。
Post = Struct.new :id, :title, :body do
end
simple_jsonのテンプレートはこのように2つのファイルを作りました。
引数がない場合は、-> { と } は省略でき、普通のhashに見えますね。
{
  posts: @posts.map { |post|
    partial!("posts/post", post: post)
  }
}
partialはこんな感じ。
->(post:) {
  {
    id: post.id,
    title: post.title,
    body: post.body,
    content: "#{post.title}: #{post.body}",
  }
}
対照組として、JbuilderとJbも作りました。
結果
# jbuilder(RAILS_ENV=production 100 iteration * 100 partial)
Time spent: 0.822655, average: 0.00822655
# jb(RAILS_ENV=production 100 iteration * 100 partial)
Time spent: 0.756204, average: 0.00756204
# simple_json(RAILS_ENV=production 100 iteration * 100 partial)
Time spent: 0.575028, average: 0.00575028
アプリが簡単なので、数msでしかないですが、8.2ms -> 5.8ms という結果になりました。
速いですね!めでたし、めでたし!
最後の前にちょっとつぶやき&ひとりごと
初めての記事で無理やり1本の直線にまとめましたwww
実際は、Rails のプロジェクトで試行錯誤を繰り返して、このような形にこぎつけられました。そして、レンダリング仕組みの移行に伴いで数百のファイルを共に書き直したチームの仲間たちに大変感謝しています!
松田さんにも大変感謝しています。この仕組みのGem化で、テストの基盤などが作られている松田さんのjbをベースとしていました。そして、社内で公開した時に見てもらいまして、早速松田さんからタイポ修正の Pull Request が2つも来ました。恥ずかしくて面白かったですよねww
本番公開後にGem化したので、実際使いづらいところがあるかもしれません(特にマイグレートのほうは心配w)。まあ、一緒に simple_json をよくしていきましょう!Pull Request を待っています!
まとめ
Railsのjsonのレンダリングを速くできる仕組み simple_json を作りました。
Jbuilderが遅いと思ったら、simple_jsonを使ってみてください!
そして、星くださいww
https://github.com/aktsk/simple_json
以上
ありがとうございました!