Custom Rack Middleware in Rails 8: Een Praktische Gids met Concrete Voorbeelden
Elk Rails-request passeert een stack van middleware voordat het je controller bereikt. De meeste developers denken nooit na over deze stack totdat er iets misgaat — een ontbrekend request ID in logs, CORS headers die verdwijnen in productie, of response times die zonder verklaring omhoogschieten.
Rails 8 wordt geleverd met ongeveer 25 middleware standaard. Je kunt de jouwe nu bekijken:
bin/rails middleware
Elke middleware is een simpel Ruby-object dat een request ontvangt, het optioneel aanpast, het doorgeeft aan de stack, en optioneel de response aanpast op de terugweg. Dat is alles. Geen magie, geen framework — gewoon de Rack-interface.
De Rack-interface in 30 seconden
Een Rack middleware is elk object dat reageert op call(env) en een array met drie elementen retourneert: [status, headers, body]. Dit is het skelet:
class MyMiddleware
def initialize(app)
@app = app
end
def call(env)
# Voordat het request Rails bereikt
status, headers, body = @app.call(env)
# Nadat de response terugkomt
[status, headers, body]
end
end
Het app-argument is de volgende middleware in de stack (of je Rails-app helemaal onderaan). Je roept het aan, krijgt de response, en retourneert het. Dat is het hele contract.
Voorbeeld 1: Request timing middleware
Laten we iets nuttigs bouwen. Deze middleware meet hoe lang elk request duurt en voegt de timing toe als response header:
# lib/middleware/request_timer.rb
class RequestTimer
def initialize(app)
@app = app
end
def call(env)
start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
status, headers, body = @app.call(env)
elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start
headers["X-Request-Time"] = format("%.4f", elapsed)
[status, headers, body]
end
end
Registreer het in config/application.rb:
require_relative "../lib/middleware/request_timer"
module MyApp
class Application < Rails::Application
config.middleware.use RequestTimer
end
end
Process.clock_gettime(Process::CLOCK_MONOTONIC) gebruiken in plaats van Time.now maakt hier uit. Monotone klokken worden niet beïnvloed door NTP-aanpassingen of systeemklokwijzigingen, dus je timings blijven nauwkeurig, zelfs tijdens zomer-/wintertijdovergangen.
Verifieer dat het werkt:
curl -I http://localhost:3000/
# X-Request-Time: 0.0234
Voorbeeld 2: Tenant detectie voor multi-tenant apps
In een multi-tenant SaaS-app moet je vaak de tenant identificeren voordat er controllercode draait. Middleware is de juiste plek hiervoor:
# lib/middleware/tenant_resolver.rb
class TenantResolver
def initialize(app)
@app = app
end
def call(env)
request = Rack::Request.new(env)
host = request.host
tenant = Tenant.find_by(domain: host)
if tenant
env["app.current_tenant"] = tenant
@app.call(env)
else
[404, { "content-type" => "text/plain" }, ["Unknown tenant"]]
end
end
end
Je controllers kunnen de tenant dan benaderen via request.env["app.current_tenant"]. Dit draait vóór routing, vóór authenticatie, vóór alles — precies wat je wilt voor tenant-isolatie.
Als je een multi-tenant Rails-applicatie bouwt, houdt tenant-resolutie in middleware het uit ApplicationController-callbacks waar de executievolgorde moeilijker te garanderen is.
Waar je middleware in de stack hoort
Hier gaat het mis bij veel developers. De volgorde maakt uit, en Rails geeft je vier methoden om het te bepalen:
# Voeg toe aan het einde van de stack
config.middleware.use MyMiddleware
# Voeg toe vóór een specifieke middleware
config.middleware.insert_before ActionDispatch::Static, MyMiddleware
# Voeg toe ná een specifieke middleware
config.middleware.insert_after Rails::Rack::Logger, MyMiddleware
# Vervang een bestaande middleware
config.middleware.swap ActionDispatch::ShowExceptions, MyCustomExceptions
Een veelgemaakte fout: timing middleware plaatsen na ActionDispatch::Static. Statische bestandsrequests worden afgehandeld voordat je middleware ze ziet, dus je timingnummers missen die requests. Wil je alles timen, plaats dan vóór ActionDispatch::Static:
config.middleware.insert_before ActionDispatch::Static, RequestTimer
Draai bin/rails middleware na wijzigingen om te verifiëren dat je middleware op de verwachte plek staat.
Voorbeeld 3: Request ID propagatie voor distributed tracing
Rails 8 bevat standaard ActionDispatch::RequestId, die een UUID genereert per request. Maar in microservice-architecturen wil je een bestaand request ID van upstream services propageren:
# lib/middleware/request_id_propagator.rb
class RequestIdPropagator
HEADER = "X-Request-Id"
def initialize(app)
@app = app
end
def call(env)
request_id = env["HTTP_X_REQUEST_ID"] || SecureRandom.uuid
env["HTTP_X_REQUEST_ID"] = request_id
status, headers, body = @app.call(env)
headers[HEADER] = request_id
[status, headers, body]
end
end
Voeg dit toe vóór ActionDispatch::RequestId zodat Rails je gepropageerde ID oppikt in plaats van een nieuwe te genereren:
config.middleware.insert_before ActionDispatch::RequestId, RequestIdPropagator
Dit werkt goed samen met gestructureerde logging — zodra elke logregel het request ID bevat, wordt het traceren van een request over services heen eenvoudig.
Middleware testen in isolatie
Je hoeft Rails niet op te starten om middleware te testen. Rack middleware is gewoon Ruby:
# test/middleware/request_timer_test.rb
require "test_helper"
require "rack/test"
class RequestTimerTest < ActiveSupport::TestCase
include Rack::Test::Methods
def app
inner_app = ->(env) { [200, {}, ["OK"]] }
RequestTimer.new(inner_app)
end
test "voegt X-Request-Time header toe" do
get "/"
assert last_response.headers.key?("X-Request-Time")
assert_match(/\d+\.\d{4}/, last_response.headers["X-Request-Time"])
end
test "geeft status en body door" do
get "/"
assert_equal 200, last_response.status
assert_equal "OK", last_response.body
end
end
De inner_app lambda fungeert als stand-in voor de rest van je applicatie. Geen database, geen routes, geen Rails boot — tests draaien in milliseconden.
Response bodies correct afhandelen
Een valkuil die mensen in productie bijt: Rack response bodies moeten reageren op each. Als je middleware de body aanpast, moet je dit goed afhandelen:
def call(env)
status, headers, body = @app.call(env)
# Fout: body kan een Rack::BodyProxy zijn, geen string
# modified = body + "<script>...</script>"
# Goed: verzamel de body eerst
response_body = []
body.each { |chunk| response_body << chunk }
body.close if body.respond_to?(:close)
modified = response_body.join
modified.gsub!("</body>", "<script>...</script></body>")
headers["content-length"] = modified.bytesize.to_s
[status, headers, [modified]]
end
Roep altijd body.close aan als de body erop reageert. Dit overslaan lekt file descriptors wanneer Rails responses streamt vanaf schijf.
Performanceoverwegingen
Middleware draait bij elk request. Een middleware die 1ms kost voegt 1ms toe aan elke pageload, elke API-call, elke health check. Dat telt snel op.
Regels die ik volg in productie:
- Geen database queries in middleware tenzij absoluut noodzakelijk (tenant-resolutie is een uitzondering). Gebruik caching als je moet querien.
- Geen zware berekeningen. Als je request bodies moet parsen, doe het dan lazy.
- Short-circuit vroeg. Als je middleware alleen geldt voor bepaalde paden, check het pad eerst en roep
@app.call(env)direct aan voor niet-matchende requests.
def call(env)
return @app.call(env) unless env["PATH_INFO"].start_with?("/api")
# Dure logica alleen voor API-requests
# ...
end
Als je onverklaarde response time-stijgingen ziet, kan Rails performance profiling met YJIT helpen identificeren of middleware de boosdoener is.
Conditionele middleware in Rails 8
Soms wil je middleware alleen in bepaalde omgevingen. Rails maakt dit netjes:
# config/environments/development.rb
config.middleware.use WebConsole::Middleware
# config/environments/production.rb
config.middleware.use RateLimiter, requests_per_minute: 60
Je kunt ook config.middleware.delete gebruiken om standaard middleware te verwijderen die je niet nodig hebt:
# Als je API-only bent en geen statische bestanden serveert
config.middleware.delete ActionDispatch::Static
# Als je cookies anders afhandelt
config.middleware.delete ActionDispatch::Cookies
Voor API-only Rails-apps (config.api_only = true) verwijdert Rails al diverse middleware (cookies, session, flash). Check wat er overblijft met bin/rails middleware voordat je meer toevoegt.
Wanneer je middleware niet moet gebruiken
Middleware is niet altijd het antwoord. Gebruik een controller concern of before_action wanneer:
- De logica alleen geldt voor specifieke controllers of actions
- Je toegang nodig hebt tot Rails routing-informatie (params, current user)
- Het gedrag afhangt van het response content type
Gebruik middleware wanneer:
- De logica moet draaien voor elk request ongeacht de route
- Je werkt op HTTP-niveau (headers, timing, request IDs)
- Je wilt dat de logica draait vóór Rails routing
- Je responses moet aanpassen nadat de controller klaar is
Als je authenticatie- of autorisatiecontroles toevoegt, horen die meestal in controllers of een dedicated authenticatielaag, niet in middleware. Middleware kan niet makkelijk je User-model of sessie benaderen — het draait te vroeg in de stack.
FAQ
Hoeveel middleware is te veel in een Rails-app?
Er is geen harde limiet, maar elke middleware voegt latency toe. Rails 8 wordt geleverd met ongeveer 25 standaard, en de meeste productie-apps voegen er 2-5 custom bij. Als je boven de 10 custom middleware zit, overweeg dan of sommige logica in controllers thuishoort. Profileer met bin/rails middleware en meet de request-overhead.
Kan middleware de request body aanpassen voordat het de controller bereikt?
Ja. Je kunt env["rack.input"] lezen en vervangen door een nieuw StringIO-object. Zo werkt request body encryption middleware. Let op: env["rack.input"] moet reageren op read, rewind en gets. Vervang het door een StringIO die je aangepaste content wrapt.
Wat is het verschil tussen Rack middleware en Rails middleware?
Rack middleware volgt de Rack-specificatie — elk Ruby-object met call(env) dat [status, headers, body] retourneert. Rails middleware is gewoon Rack middleware die geregistreerd wordt via de Rails-configuratie. Rails voegt gemak toe (de config.middleware API, omgevingsspecifieke stacks), maar de onderliggende interface is puur Rack.
Hoe debug ik de middleware-executievolgorde?
Draai bin/rails middleware om de volledige stack te zien. Voor runtime debugging, voeg een simpele logger toe aan het begin en einde van je call-methode: Rails.logger.debug "#{self.class.name} START". Je ziet het nesting-patroon — requests stromen naar beneden door de stack en responses stromen weer omhoog.
Werkt middleware met Rails Action Cable en WebSockets?
Action Cable heeft zijn eigen middleware-stack, gescheiden van de HTTP-stack. Je HTTP middleware draait standaard niet voor WebSocket-verbindingen. Als je middleware nodig hebt voor WebSocket-verbindingen, configureer het dan via config.action_cable.middleware of bekijk hoe Solid Cable WebSocket-verbindingen afhandelt.
About the Author
Roger Heykoop is een senior Ruby on Rails ontwikkelaar met 19+ jaar Rails ervaring en 35+ jaar ervaring in softwareontwikkeling. Hij is gespecialiseerd in Rails modernisering, performance optimalisatie, en AI-ondersteunde ontwikkeling.
Get in TouchRelated Articles
Need Expert Rails Development?
Let's discuss how we can help you build or modernize your Rails application with 19+ years of expertise
Schedule a Free Consultation