Hope you had a wonderful Christmas and are looking forward to 2023. Let's wrap this year by understanding the Rails rendering stack... 😉
This post assumes an understanding of metaprogramming concepts in Ruby. If you aren't comfortable with it, an excellent place to start is by reading the book Metaprogramming Ruby 2.
Ruby on Rails makes it very easy to render JSON data from the controller using the render
method.
Ever wondered what really goes on behind the scenes? How does the render
method convert the list of tasks to JSON format and sends it in the response? How Rails creates the renderers? This article will try to explain how rendering works in Rails.
By the end of the article, you should have a much better understanding of what really happens when you call the render
method from the controller.
Let's start our journey into the rendering process by considering the Rails controller hierarchy.
Your typical Rails controllers inherit from the ApplicationController
class which itself inherits from the ActionController::Base
class. This class includes the AbstractController::Rendering
module, providing the render
method. Hence the render
method becomes available in all your controllers.
When you call render
from the PostsController
, Rails calls the AbstractController::Rendering#render
method and sets its result on the response.
class PostsController < ApplicationController
def show
@post = Post.first
render json: @post
end
end
# actionpack/lib/abstract_controller/rendering.rb
module AbstractController
module Rendering
def render(*args, &block)
options = _normalize_render(*args, &block)
rendered_body = render_to_body(options)
self.response_body = rendered_body
end
end
end
The normalize_render
method creates the hash of options containing the passed data @posts
along with the template and the layout. It looks like this:
options = {
:json=>#<Post:0x00000001145d0470>,
:template=> ..,
:layout=> ..
}
Ignore the template
and layout
keys for now. The important point is that the options[:json]
contains the post object we passed in the controller.
Now let's move to the next line in the render
method, which calls the render_to_body
method, passing the options
hash. This method resides in the  action_controller/metal/renderers.rb
file.
# action_controller/metal/renderers.rb
module ActionController
module Renderers
def render_to_body(options)
_render_to_body_with_renderer(options) || super
end
end
end
The _render_to_body_with_renderer
method loops over a set of pre-defined renderers and checks if it includes the renderer we need (:json
in our case). If it does, then it dynamically generates and calls a method named method_name
using the Object#send
method.
# action_controller/metal/renderers.rb
module ActionController
module Renderers
def _render_to_body_with_renderer(options)
_renderers.each do |name|
if options.key?(name)
_process_options(options)
method_name = Renderers._render_with_renderer_method_name(name)
return send(method_name, options.delete(name), options)
end
end
nil
end
end
end
To understand this method, we need two pieces of information:
- How renderers are initialized in the
_renderers
variable. - How
method_name
is generated and what it does when called.
The _renderers
variable
It's a Set
containing renderer names that correspond to available renderers. Default renderers are json
, js
, and xml
.
This variable is defined in the Renderers::All
concern. As soon as it's included in the Base
class, it includes the ActionController::Renderers
module. This module sets up the RENDERERS
variable to an empty set and then calls the add
method which fills this set.
# action_controller/metal/renderers.rb
module ActionController
module Renderers
RENDERERS = Set.new
module All
extend ActiveSupport::Concern
include Renderers
included do
self._renderers = RENDERERS
end
end
add :json do |json, options|
json.to_json(options) # simplified
end
add :xml { ... }
add :js { ... }
end
end
The add
method takes the name of the renderer (:json
) and a block, and
- Dynamically defines a method that will execute the block later.
- Add the name of the renderer to
RENDERERS
, a set of renderer names.
# action_controller/metal/renderers.rb
module ActionController
module Renderers
def self.add(key, &block)
define_method(_render_with_renderer_method_name(key), &block)
RENDERERS << key.to_sym
end
end
end
Finally, the RENDERERS
variable contains this array: [:json, :js, :xml]
which gets assigned to the _renderers
variable.
This brings us to the next point, that is, how the method named method_name
is generated.
How method_name is generated
Let's inspect the above add
method in detail. It defines a method dynamically using the Module#define_method
and passes it a name which it generates using the _render_with_renderer_method_name
helper.
def self._render_with_renderer_method_name(key)
"_render_with_renderer_#{key}"
end
Passing :json
will return the string _render_with_renderer_json
. This is the name of the dynamic method. Its body is the block we passed to the add
method.
To summarize, calling add :json
as above defines the following method:
def _render_with_renderer_json(json, options)
json.to_json(options) # simplified
end
And this gives us the final piece of the puzzle: What the method defined by method_name
does when called. It simply calls to_json
on the object you passed to the render
method and returns it.
To generalize, it renders the given object, depending on the renderer.
Let's unwind the stack and go back to where we started, the render_to_body_with_renderer
method.
# action_controller/metal/renderers.rb
module ActionController
module Renderers
def _render_to_body_with_renderer(options)
_renderers.each do |name|
if options.key?(name)
_process_options(options)
method_name = Renderers._render_with_renderer_method_name(name)
return send(method_name, options.delete(name), options)
end
end
nil
end
end
end
As we saw earlier, the options
hash looks like this:
options = {
:json=>#<Post:0x00000001145d0470>,
:template=> ..,
:layout=> ..
}
Since options
contains the :json
renderer, the control enters the if conditional block and calls the dynamically generated _render_with_renderer_json
method. Additionally, it passes the value corresponding to the :json
key, which is an instance of the Post
class.
Let's revisit the dynamically generated _render_with_renderer_json
method body again.
def _render_with_renderer_json(json, options)
json.to_json(options)
end
It will call post.to_json
and return the following output.
"{\"title\":\"hello world\"}"
Let's unwind the stack once more and return to the render
method we saw at the beginning.
# abstract_controller/rendering.rb (simplified)
def render(*args, &block)
options = _normalize_render(*args, &block)
rendered_body = render_to_body(options)
self.response_body = rendered_body
end
The above JSON string is assigned to the response_body
on the controller, which is ultimately sent to the client.
If you're curious how response_body
actually works, keep on reading.
Sending Response
Let's inspect the Rails controller hierarchy once again.
Ultimately, your PostsController
class inherits from the ActionController::Metal
class. To learn more about the metal controller, check out the following article.
The ActionController::Metal
class provides the setter for the response_body
, which assigns the value to the response
body.
# action_controller/metal.rb
module ActionController
class Metal
def response_body=(body)
body = [body] unless body.nil? || body.respond_to?(:each)
if body
response.body = body
super
else
response.reset_body!
end
end
end
end
This whole request lifecycle was part of the dispatch
method. Once the action is processed, the dispatch
method calls to_a
method, returning the response.to_a
result.
# action_controller/metal.rb
module ActionController
class Metal
def dispatch(name, request, response)
set_request!(request)
set_response!(response)
process(name)
request.commit_flash
to_a
end
def to_a
response.to_a
end
end
end
The to_a
method on the Response
calls the rack_response
method, which returns the final result, the Rack-compatible array of the status, headers, Â and body. To learn more about Rack, check out the following article:
# action_dispatch/http/response.rb
module ActionDispatch
class Response
def to_a
rack_response @status, @header.to_hash
end
def rack_response(status, header)
[status, header, RackBody.new(self)]
end
end
end
Which ultimately returns the following JSON you see in the browser.
Conclusion
Whenever we invoke the following method in our application
render json: @post
it invokes the block associated with the :json
renderer.
add :json do |json, options|
json.to_json(options) # simplified version
end
The local variable json
inside the block points to the @post
object, and the other options passed to render
will be available in the options
variable. The block returns the JSON representation of the post and sends it to the client.
And that's what happens when you call render json: @post
from your controller.
I hope you found this article useful and that you learned something new.
If you have any questions or feedback, or didn't understand something, please leave a comment below or send me an email. I look forward to hearing from you.
Please subscribe to my blog below if you'd like to receive future articles directly in your email. If you're already a subscriber, thank you.