Lesson 03 / 13
Controllers and Filters
The controller is built anew on every request and the filter runs before the action. The lesson measures that the filter list is bound to the class, that the controller instance is bound to the request, and that the flash message is carried only to the next request.
Published Updated
In this lesson
- Measures that the filter runs before the action and that the filter list is bound to the class
- Shows that the controller instance is rebuilt on every request and that an instance variable stays in the request
- Measures the stay of the flash message in the save, redirect, show-the-notice flow
- Shows that the same mechanism can fall into a different stay depending on how it is used
Contents
The previous lesson showed that routing gives the products#show target and that the 7 in the path is read only in that request. The class that answers the target is the controller. The question of this lesson has two parts: does the controller object live from one request to another, and what does a filter that runs at the start of an action remember? A third question is tied to both: after a product is saved and the request redirects to another page, why does the flash message that is shown appear once?
M10/K02 asked in which scope the language ends the lifetime of a value; that was the compiler’s decision. Here the lifetime is decided not by the language but by the request cycle: @product lives as long as the object does in Ruby, but because the controller is rebuilt on every request, the value stays for this request.
The core’s stay.rb file is given below once more. The block is source, it has no output, and it is pulled in with require_relative.
# stay.rb — a small request loop and a helper that classifies how many requests a value stays for
# Flash: a value written in this request is carried to the next request only.
class Flash
def initialize(incoming)
@incoming = incoming
@fresh = {}
end
def [](key) = @fresh.fetch(key) { @incoming[key] }
def []=(key, value)
@fresh[key] = value
end
def passed_on = @fresh
end
# Every request gets a new context: params, flash and the request store start empty each time.
class Context
attr_reader :app, :params, :session, :flash, :request_store
def initialize(app, session)
@app = app
@params = {}
@session = session
@flash = Flash.new(session.delete(:flash) || {})
@request_store = {}
end
end
# The application store is the same across all requests and all clients;
# the session is chosen per client.
class App
attr_reader :store
def initialize
@sessions = Hash.new { |h, client| h[client] = {} }
@store = { products: {}, schema: { products: %i[name price] }, templates: {},
queue: [], cache: {}, counter: 0 }
end
def request(client)
context = Context.new(self, @sessions[client])
result = yield context
context.session[:flash] = context.flash.passed_on unless context.flash.passed_on.empty?
result
end
end
# Rule: request 1 (client A) writes the value; requests 2 and 3 (A) and request 4 (B) read it.
# If B sees it, persistent; if A's third request sees it, session; if only the second
# request sees it, next; if none sees it, this_request.
def stay(write, read)
app = App.new
first = app.request("A") { |b| write.call(b); read.call(b) }
raise ArgumentError, "value could not be read in the request that wrote it" if first.nil?
seen = [["A", 2], ["A", 3], ["B", 4]].map do |client, order|
app.request(client) { |b| read.call(b) }.nil? ? "--" : "#{client}#{order}"
end
kind = if seen[2] != "--" then "persistent"
elsif seen[1] != "--" then "session"
elsif seen[0] != "--" then "next"
else "this_request"
end
[kind, ["A1", *seen].join(" "), first]
end
The Filter and a Class-Bound List
The core’s controller declares the filter with the line before :load_product. This is a call, and it adds a name to a list of the class; before calling the action, run runs every name in that list in order. The block below first looks at the list itself, then at an empty class derived from the same class, and then runs the action when there is no product and when there is one.
# filter.rb — the filter list and the product the filter found
require_relative "stay"
# Object-relational mapper: a row maps to a record, a table maps to a model.
module Product
def self.insert(b, attributes)
errors = []
errors << "name cannot be blank" if attributes[:name].to_s.empty?
errors << "price cannot be negative" if attributes[:price].to_i.negative?
return { errors: } unless errors.empty?
table = b.app.store[:products]
id = table.size + 1
table[id] = attributes.merge(id:)
{ id:, errors: }
end
# The same query does not go to the database twice in the same request.
def self.lookup(b, id)
queries = (b.request_store[:queries] ||= {})
queries.fetch([:products, id]) do
b.request_store[:query_count] = b.request_store.fetch(:query_count, 0) + 1
queries[[:products, id]] = b.app.store[:products][id]
end
end
end
# Controller: a new instance on every request; a filter runs before the action.
class ProductsController
def self.before(*names) = (@filters ||= []).concat(names)
def self.filters = @filters || []
before :load_product
def initialize(context)
@context = context
end
def run(action)
self.class.filters.each { |filter| send(filter) }
public_send(action)
end
def show = @product
private
def load_product
@product = Product.lookup(@context, @context.params[:id])
end
end
pen = ->(b) { Product.insert(b, name: "pen", price: 12) }
p ProductsController.filters
class Sub < ProductsController; end
p Sub.filters
app = App.new
p app.request("A") { |b| b.params[:id] = 99; ProductsController.new(b).run(:show) }
p app.request("A") { |b| pen.call(b); b.params[:id] = 1; ProductsController.new(b).run(:show) }
[:load_product]
[]
nil
{name: "pen", price: 12, id: 1}
The first line is the list, carrying the name load_product. The second line is surprising: although Sub derives from the same class, its list is empty. The filter list lives in the class’s own instance variable and does not pass to a subclass; a subclass must declare these filters itself. FS13. This measurement is for a single declaration form; a framework that keeps the filter list in a class-level constant may behave differently toward a subclass. The third line shows that when there is no product with the identifier 99, the action gave nil: the filter could not find the product, the action returned an empty instance variable, and no error was raised. In the fourth line the product was inserted first and the filter found it. The action never touched the identifier; it is the filter that prepares the product. This division of labor is the actual reason for the filter: the line that finds the product is not repeated in every action, it stands in one place, and the action only uses the prepared value. The price is a dependency; code that calls the action without the filter running notices that show gave nil only when it looks at the result. Because the previous lesson’s routing wrote the identifier into params, the filter can rely on it, but if routing did not run, the filter looks up with a nil identifier and finds no product.
The core’s model in this lesson makes a convenience, and it should be said once: Product.lookup gives the stored object itself. Many object–relational mappers build a new object from the table on every read. This difference does not change the measured stay, because the stay looks at how many requests the value can be read for, not at whether the object is the same.
The Controller Is New on Every Request
In the previous courses we saw that an object holds its values as long as it lives: in Ruby an object lives as long as code that can reach it remains. On the web this needs an addition. The controller object may live, but because the framework rebuilds it on every request, an instance variable stays in that request. The block below builds the controller in two consecutive requests and, in the second, looks at whether it is the same as the first one’s instance.
# new_instance.rb — the controller instance is not carried from request to request
require_relative "stay"
# Object-relational mapper: a row maps to a record, a table maps to a model.
module Product
def self.insert(b, attributes)
errors = []
errors << "name cannot be blank" if attributes[:name].to_s.empty?
errors << "price cannot be negative" if attributes[:price].to_i.negative?
return { errors: } unless errors.empty?
table = b.app.store[:products]
id = table.size + 1
table[id] = attributes.merge(id:)
{ id:, errors: }
end
# The same query does not go to the database twice in the same request.
def self.lookup(b, id)
queries = (b.request_store[:queries] ||= {})
queries.fetch([:products, id]) do
b.request_store[:query_count] = b.request_store.fetch(:query_count, 0) + 1
queries[[:products, id]] = b.app.store[:products][id]
end
end
end
# Controller: a new instance on every request; a filter runs before the action.
class ProductsController
def self.before(*names) = (@filters ||= []).concat(names)
def self.filters = @filters || []
before :load_product
def initialize(context)
@context = context
end
def run(action)
self.class.filters.each { |filter| send(filter) }
public_send(action)
end
def show = @product
private
def load_product
@product = Product.lookup(@context, @context.params[:id])
end
end
pen = ->(b) { Product.insert(b, name: "pen", price: 12) }
app = App.new
app.request("A") { |b| pen.call(b) }
first_instance = nil
result = app.request("A") do |b|
b.params[:id] = 1
first_instance = ProductsController.new(b)
first_instance.run(:show)
end
p result
result = app.request("A") do |b|
second_instance = ProductsController.new(b)
[second_instance.equal?(first_instance), second_instance.show]
end
p result
{name: "pen", price: 12, id: 1}
[false, nil]
In the first request the filter found the product and placed it in @product, and the action gave it. The second request built a new instance: the equal? answer is false, and show gave nil, because the filter did not run in this instance and @product was never assigned. The instance from the first request was still in memory, but the second request did not reach it. For this reason a controller’s instance variables are in the this request stay. If a value must be carried to the next request, it must be written somewhere outside the controller.
FS14. The measurement rests on the assumption that the core builds a new Context and a new instance on every request. A server that reuses the instance may run the same class with a different stay.
Filter Order and an Interrupted Chain
When more than one filter is declared, the order is the declaration order. What happens if a filter raises an error also follows from the structure of the core’s run. The class below is this lesson’s own example and is not added to the core’s ProductsController; it rebuilds the same two-line mechanism in a class that keeps an event log.
# order.rb — filter order and a filter that raises an error
class Trial
def self.before(*names) = (@filters ||= []).concat(names)
def self.filters = @filters || []
before :first_step, :second_step
attr_reader :log
def initialize = @log = []
def run(action)
self.class.filters.each { |filter| send(filter) }
public_send(action)
end
def action = @log << :action
private
def first_step = @log << :first_step
def second_step = raise(ArgumentError, "second filter stopped")
end
t = Trial.new
begin
t.run(:action)
rescue ArgumentError => e
p e.class
end
p t.log
ArgumentError [:first_step]
The first filter ran, the second raised an error, and the action was never called: there is no :action in the log. The chain is cut at the point where the error arose. In this core it is a design choice, because each stops when an error is raised and the public_send line is never reached. In a real framework it is also possible for a filter to give the response itself and stop the chain; the core does not model this. FS15. The measurement rests on the assumption that the core runs in order with each and does not catch the error.
Save, Redirect, Show the Flash Message
In this lesson’s example, the submission of a form is followed by three requests. In the first, the product is saved and the flash message is written. The action then redirects to another page. In the second request the new page reads the message and shows it. In the third request the page is refreshed and there is no message.
# notice.rb — save, redirect, show the message
require_relative "stay"
# Object-relational mapper: a row maps to a record, a table maps to a model.
module Product
def self.insert(b, attributes)
errors = []
errors << "name cannot be blank" if attributes[:name].to_s.empty?
errors << "price cannot be negative" if attributes[:price].to_i.negative?
return { errors: } unless errors.empty?
table = b.app.store[:products]
id = table.size + 1
table[id] = attributes.merge(id:)
{ id:, errors: }
end
# The same query does not go to the database twice in the same request.
def self.lookup(b, id)
queries = (b.request_store[:queries] ||= {})
queries.fetch([:products, id]) do
b.request_store[:query_count] = b.request_store.fetch(:query_count, 0) + 1
queries[[:products, id]] = b.app.store[:products][id]
end
end
end
app = App.new
p app.request("A") { |b| saved = Product.insert(b, name: "pen", price: 12); b.flash[:notice] = "product saved"; saved }
p app.request("A") { |b| b.flash[:notice] }
p app.request("A") { |b| b.flash[:notice] }
{id: 1, errors: []}
"product saved"
nil
The first request inserted the record and gave the identifier 1; errors is empty. The second request read the message: "product saved". The third request read the same key and got nil. The message was carried from the request it was written in to the next one and was consumed there. The core’s Context class reads by deleting the :flash key in the session on every request; so a message can be read once. The record itself, though, stayed in the store in all three requests, because the product had been written to the application store.
A Flash Message That Is Not Read or Is Overwritten
Two more cases can be asked: if the message is not read in the next request, is it still there in the third request, and if the same key is written twice in the same request, which value is carried?
# unread.rb — a flash message that is not read and one that is overwritten
require_relative "stay"
app = App.new
p app.request("A") { |b| b.flash[:notice] = "first"; b.flash[:notice] = "last"; b.flash[:notice] }
p app.request("A") { |b| b.params[:page] = 1 }
p app.request("A") { |b| b.flash[:notice] }
"last" 1 nil
In the first request there were two writes, and the read gave the last value. The second request did not read the message at all, it only did another job. The third request got nil. The life of a message depends not on its being read but on a request starting: every new Context takes the message in the session and deletes it. A message that is not read goes at the same moment from the place where it would have gone had it been read. FS16. This measurement is for three requests and a single key.
Measuring the same mechanism with the core’s classifier names the stay of the message with one word.
# controllers.rb — the stay of the flash message
require_relative "stay"
write = ->(b) { b.flash[:notice] = "product saved" }
read = ->(b) { b.flash[:notice] }
p stay(write, read)
["next", "A1 A2 -- --", "product saved"]
The sequence A1 A2 -- -- says that the message was seen in the request it was written in and in the next one, and not seen in the third request or by another client. The stay is next request: it is read only in the same client’s next request. This is the result of only one of the core’s thirteen observations; none of the other observations fell into this stay.
The Bounding Measurement
That the flash message is carried to the next request is a result not of the mechanism but of how it is used. If the reading code rewrites the value every time it reads it, the same mechanism carries the value through the session.
# limit.rb — the same mechanism can fall into another stay
require_relative "stay"
write = ->(b) { b.flash[:counter] = 1 }
read = ->(b) { v = b.flash[:counter]; b.flash[:counter] = v if v; v }
p stay(write, read)
["session", "A1 A2 A3 --", 1]
The message was this time classified as ["session", "A1 A2 A3 --", 1]: it was read in the same client’s third request too, and not read by another client. What was changed is not the mechanism but the behavior of the reading code. Every request that rewrites while reading passes the value to the next one; the chain breaks only when a request reads without writing. FS17. This measurement is only for a single key and three requests; how long the chain lasted on another server was not measured.
Summary
- The filter runs before the action and prepares the value the action will use; the filter list is bound to the class and does not pass to a subclass.
- The controller is built as a new instance on every request; an instance variable stays only in that request.
- In the save, redirect, show flow, the flash message is carried from the request it is written in to the next one and is consumed there.
- The stay of this lesson is next request: the message is read only in the next request.
- The same mechanism falls into another stay depending on how it is used: code that rewrites while reading turns the message into a session value.
Next Step
The controller prepared the product and carried the message, but did not produce the output. The next lesson measures the view and the template: how many times a template is compiled, how local values are escaped, and in which stay the compiled template stands. The flash message was carried to the next request in this lesson; the compiled form of a template will stand not in the request but in a place bound to the application, and will fall into another stay.
To keep your progress and take notes, Log in
My notes
Log in to take notes.