RailsのAPIサーバーのレンダリングを速くしました

Jbuilderが遅いから、MVCを保ちつつjsonのレンダリングを速くできる仕組みを作った話です。
[Gem](https://rubygems.org/gems/simple_json)を公開したので、よかったら使ってみてください！
詳しくは [Github](https://github.com/aktsk/simple_json) で！

この記事は[Akatsuki Advent Calendar](https://adventar.org/calendars/6566) 7日目の記事です。
前回は jyllsarta さんの[ほぼ素のRailsアプリだった「スピカとチロル」が本番環境で安定動作するまで](https://jyllsarta.net/stray_release)でした。

# なぜ作るか
RailsでAPIサーバーを書き始めて2年くらいの頃、負荷改善が好きで、毎日New Relic を眺め、APIの負荷改善をしていました。遅いAPIの改善をしていた際に、partialを展開することでレスポンス時間がかなり改善したことがありました。それで疑問を持ち始め、今回の話の始まりでした。

## Jbuilderはどう動いているのか
https://github.com/rails/jbuilder/blob/master/lib/jbuilder/jbuilder_template.rb

https://github.com/rails/jbuilder/blob/master/lib/jbuilder.rb

Jbuilderの仕組みを読んでみる。

まず、Jbuilderを書いたことがある人なら、JbuilderのDSLで`json`という変数が使われていることは気になりますよね？

### ActionView用のHandler

```rb:jbuilder/jbuilder_template.rb
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も読んでみる

```rb:action_view/template.rb
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

```rb:lib/jbuilder.rb
  def method_missing(*args, &block)
    if ::Kernel.block_given?
      set!(*args, &block)
    else
      set!(*args)
    end
  end
```
`method_missing` が使われてて、メソッドが存在しなかったら、その名前でキーをセットしてますね！このような書き方にすると、callの回数が2倍になりますね。本当にわかりやすいかというと、そうでもないような気もするので、やめたいですね。

### partialが重い問題

```rb:jbuilder/jbuilder_template.rb
  def _render_partial(options)
    options[:locals].merge! json: self
    @context.render options
  end
```

内部で直接呼べなくて、ループ回数分遠回りしてActionViewのcontextからrenderで呼んでますね。テンプレート検索も必要なので、重いのは当然ですね。

### 無駄な変数のアロケーションが多い
階層が深いので、ここではお見せできないですが、想像だけでわかる気がします。

まだまだありますが、まあ、不満を言うのはここまでにします。

## 既存の改善ソリューション：Jb
https://github.com/amatsuda/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でのオーバーライド

https://github.com/aktsk/simple_json/blob/master/lib/simple_json/simple_json_renderable.rb

controllerの `default_render` と `render_to_body` をオーバーライドします。

`default_render` は `render` が明示的に呼ばれていない時に呼ばれるもので、テンプレートが存在すれば `render` すればいい。

`render_to_body` はレスポンスのテキスト内容を返すメソッドで、ここで simple_json のレンダリングを行えば良い。

simple_json の使われる条件に合わない場合は `super` で通常のレンダリングが実行されます。

### レンダリング
https://github.com/aktsk/simple_json/blob/master/lib/simple_json/simple_json_renderer.rb

`SimpleJsonRenderer` というクラスのインスタンスでレンダリングを実行するようにします。使われたテンプレートはインスタンスでキャッシュされます。また、メモリに事前に読む設定をオンに（ `SimpleJson.enable_template_cache = true` ）すれば、テンプレートは `SimpleJsonRenderer` クラスに全部事前ロードされます。

このインスタンス上でレンダリングするので、ヘルパーや、Viewに渡す変数（インスタンス変数）をこのインスタンスにbindされた状態で、テンプレートのlambdaを`instance_exec`で実行させます。

### テンプレート
https://github.com/aktsk/simple_json/blob/master/lib/simple_json/simple_json_template.rb

テンプレートは単純にrubyをevalでlambdaに変換しています。引数がない場合はいちいち`-> {  }`を書くのが面倒なので、省略可能な仕様にし、自動的に補完するようにしました。

### 他の便利機能

- キャッシュ helper
- C実装の JSON encoder をデフォルトで使うように
- 複数のパス設定

また、マイグレート用の機能も作りました。Jbuilderから乗り換える時に少しでも便利にできるように。使う時に下記のようにincludeをすればよい。

```
include SimpleJson::SimpleJsonRenderable
include SimpleJson::Migratable
```

もちろん、Gem化するので、テストも必要で追加しました（大変でした）。
そして、リリース！
https://rubygems.org/gems/simple_json

# 実際使ってみて、速さを測ってみる
railsプロジェクトを新規作成する

```shell
rails new simple-json-test
```

simple_jsonのGemを入れる。`bundle install`を忘れずにね！

```rb:Gemfile
gem 'simple_json'
```

開発環境は開発しながら見るので、ファイルを毎回アクセスすればいいですが、本番環境やテスト環境はテンプレートのキャッシュを有効にするとパフォーマンスが上がります。

```rb:config/environments/production.rb|test.rb
Rails.application.configure do
  ...
  SimpleJson.enable_template_cache
end
```

postコントローラなどを作成する。

```rb:app/controllers/posts_controller.rb
class PostsController < ApplicationController
  include SimpleJson::SimpleJsonRenderable
  def index
    @posts = (1..100).map { |i|
      Post.new(i, "title #{i}", "body #{i}")
    }
  end
end
```

モデルはシンプルにStructを使う。

```rb:app/models/post.rb
Post = Struct.new :id, :title, :body do
end
```

simple_jsonのテンプレートはこのように2つのファイルを作りました。
引数がない場合は、`-> {` と `}` は省略でき、普通のhashに見えますね。

```rb:app/views/posts/index.simple_json.rb
{
  posts: @posts.map { |post|
    partial!("posts/post", post: post)
  }
}
```

partialはこんな感じ。

```rb:app/views/posts/post.simple_json.rb
->(post:) {
  {
    id: post.id,
    title: post.title,
    body: post.body,
    content: "#{post.title}: #{post.body}",
  }
}
```

対照組として、JbuilderとJbも作りました。

### 結果
```shell
# 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

以上
ありがとうございました！