RailsがJSONをレンダーするときに何が起きているのか

# はじめに

この記事は[Ruby on Rails Advent Calendar 2022](https://qiita.com/advent-calendar/2022/ruby-on-rails)の14日目の記事です。

## 概要

この記事ではRailsの`render`メソッドを読み、内部で何が起きているのかを理解していきます。

## 動機

私は個人で[Alba](https://github.com/okuramasafumi/alba)というJSONシリアライザを開発しています。その際、Railsの機能に合わせて以下のような動作をさせるように[しました](https://github.com/okuramasafumi/alba/pull/252)。

```ruby
render json: FooResource.new(foo)
```

これは

```ruby
render json: FooResource.new(foo).serialize
```

と同じ挙動になっています。

なぜこうなるのか？をまとめるのがこの記事の目的です。そしてその目的のため、Railsのソースコードを読んでいきます。

## 注意

以下のコードは全てRails7.0.4時点のものとなります。

## 本編

### renderメソッドの定義箇所を特定する

コントローラ内に以下のスニペットを置くことで`render`メソッドの定義箇所がわかります。

```ruby
pp method(:render).source_location
```

私は適当に`rails new`したアプリに空っぽのコントローラを作ってそこに書きました。その結果、

https://github.com/rails/rails/blob/v7.0.4/actionpack/lib/action_controller/metal/instrumentation.rb#L19-L25

がヒットしました。ここでは22行目で`super`を読んでいるので、そちらに処理が移っているようです。ファイル名からしても（計測用モジュールらしいので）、`super`先を見に行くべきでしょう。

ここでは先ほどのスニペットを修正して`super`先のメソッドの定義箇所を調べます。

```ruby
pp method(:render).super_method.source_location
```

その結果、

https://github.com/rails/rails/blob/v7.0.4/actionpack/lib/action_controller/metal/rendering.rb#L28-L31

が出ましたが、これも内容がないのでもう一回`super`を調べます。

```ruby
pp method(:render).super_method.super_method.source_location
```

そうしたら

https://github.com/rails/rails/blob/v7.0.4/actionpack/lib/abstract_controller/rendering.rb#L23-L33

が出ました。ここには`super`がないので、このメソッドを見ていきましょう。

### `render`メソッドの中身を調べる

上記メソッドの内容は以下の通りです。

```ruby
# Normalizes arguments, options and then delegates render_to_body and
# sticks the result in <tt>self.response_body</tt>.
def render(*args, &block)
  options = _normalize_render(*args, &block)
  rendered_body = render_to_body(options)
  if options[:html]
    _set_html_content_type
  else
    _set_rendered_content_type rendered_format
  end
  _set_vary_header
  self.response_body = rendered_body
end
```

`_normalize_render`が`render`に渡されたオプションを処理する箇所のようです。

https://github.com/rails/rails/blob/v7.0.4/actionpack/lib/abstract_controller/rendering.rb#L116-L121

`_normalize_args`が中心的な処理のようです。

https://github.com/rails/rails/blob/v7.0.4/actionpack/lib/abstract_controller/rendering.rb#L75-L87

`render json: something`において、このメソッドの`action`引数に渡されるのは`json: something`であり、`Hash`であるので`action`、すなわち`json: something`が返ります。返り値は`_normalize_render`内の`options`変数に代入され、`_process_variant`に渡されます。

https://github.com/rails/rails/blob/v7.0.4/actionpack/lib/abstract_controller/rendering.rb#L103-L104

`_process_variant`は空です。これまでの継承チェーンのどこかに同名のメソッドがあるでしょうか。

https://github.com/rails/rails/blob/v7.0.4/actionpack/lib/action_controller/metal/rendering.rb#L56-L60

ありました。条件を満たしていれば`:variant`のキーに対して値をセットするようです。

次の`_normalize_options`も同様で、

https://github.com/rails/rails/blob/v7.0.4/actionpack/lib/action_controller/metal/rendering.rb#L94-L106

で`:status`などを`options`に追加しています。

コメントに「`render_to_body`に委譲する」と書いてあります。先ほどまでで作られた`options`がそれに渡されているようです。それも見てみましょう。

https://github.com/rails/rails/blob/v7.0.4/actionpack/lib/abstract_controller/rendering.rb#L50-L52

なんと中身は空です。ということは、これまで見てきたファイルの中の同名のメソッドがあるはずです。再びRailsアプリケーションにスニペットを仕込んでみましょう。

```ruby
pp method(:render_to_body).source_location
```

すると、

https://github.com/rails/rails/blob/v7.0.4/actionpack/lib/action_controller/metal/renderers.rb#L140-L142

が出てきます。このメソッドは`_render_to_body_with_renderer`を呼んでいます。

https://github.com/rails/rails/blob/v7.0.4/actionpack/lib/action_controller/metal/renderers.rb#L144-L153

`_renderers`という変数が出てきました。これはどこでセットされているのでしょうか。

### `renderer`の概念

この`Renderers`というファイルに何かありそうです。このファイルを`_renderers`で検索すると、

https://github.com/rails/rails/blob/v7.0.4/actionpack/lib/action_controller/metal/renderers.rb#L41

と

https://github.com/rails/rails/blob/v7.0.4/actionpack/lib/action_controller/metal/renderers.rb#L130

で代入しているのが見つかります。コメントを読むに怪しそうなのは前者です。`All`というモジュールが`include`されるとこのコードが呼ばれるようです。また、代入されるオブジェクトは`RENDERERS`という定数になっています。`All`でRailsのコードベースを検索すると、

https://github.com/rails/rails/blob/v7.0.4/actionpack/lib/action_controller/base.rb#L216

が出てくるので、`ActionController::Base`は`Renderers::All`を`include`していることがわかりました。では、`RENDERERS`には何が入っているのでしょうか。まず、

https://github.com/rails/rails/blob/v7.0.4/actionpack/lib/action_controller/metal/renderers.rb#L28

から、`RENDERERS`は`Set`です。そして、

https://github.com/rails/rails/blob/v7.0.4/actionpack/lib/action_controller/metal/renderers.rb#L74-L77

から、`add`というクラスメソッドを呼ぶと`RENDERERS`にオブジェクトが追加されるようです。この`add`は同じファイル内でも使われていて、

https://github.com/rails/rails/blob/v7.0.4/actionpack/lib/action_controller/metal/renderers.rb#L155-L168

でやっとお目当てのJSONレンダーにたどり着きました。

### JSONレンダリングの中身

当該の`add`の中身は以下です。

```ruby
add :json do |json, options|
  json = json.to_json(options) unless json.kind_of?(String)

  if options[:callback].present?
    if media_type.nil? || media_type == Mime[:json]
      self.content_type = Mime[:js]
    end

    "/**/#{options[:callback]}(#{json})"
  else
    self.content_type = Mime[:json] if media_type.nil?
    json
  end
end
```

`add`内の1行目で引数が文字列でなければ`to_json(options)`を呼ぶようになっています。これが今回私がAlbaの開発で探していた行でした。ここでの`options`がくせ者で、これまで見てきた`:status`のようなキーを含んだハッシュとなっています。必ず渡されるのと、Ruby3.0以降のキーワード引数の仕様変更の影響を受けるらしく、そのため

https://github.com/okuramasafumi/alba/blob/main/lib/alba/resource.rb#L56-L75

のような書き方をする必要がありました。

### `add`から`render`に戻る

さて、`add`の処理に戻ると、渡したブロックの中身でメソッドが定義されます。このメソッドは

https://github.com/rails/rails/blob/v7.0.4/actionpack/lib/action_controller/metal/renderers.rb#L148

で`_render_to_body_with_renderer`から呼ばれます。この部分に到達するには、

https://github.com/rails/rails/blob/v7.0.4/actionpack/lib/action_controller/metal/renderers.rb#L145-L146

で`_renderers_に含まれる`:json`のシンボルが`options`のキーに含まれている必要があり、先ほどまでの話と合致しています。ここで返された値は`render`メソッドまで戻り、

https://github.com/rails/rails/blob/v7.0.4/actionpack/lib/abstract_controller/rendering.rb#L32

で`self.response_body`に格納されます。まだ続きがありそうですが、長くなったので今日はここまでとします。

## まとめ

当初の想定よりだいぶガッツリとしたコードリーディングになってしまいました。Railsにおいては一見単純に見える処理もだいぶ複雑な工程を経て処理されていることがわかりますね。

今回は特にそうだったのですが、Railsのコードリーディングにおいてはgrepするだけでは目的を達成できないことが多いです。`source_location`などを駆使する必要があるのですが、慣れれば結構楽しいので皆さんも気になる箇所を読んでみてはいかがでしょうか。

## 宣伝

コードを読むのが好きなあなたに、こんなコミュニティはいかがでしょうか。

https://rubygems-code-reading.connpass.com/

主にgemのコードを読む会です。メインの読み手は私が務め、それに参加者の方がガヤを入れる形式となります。次回開催は未定ですが、よければメンバーになってみてください。