This post is the first of a series in which I build a Ruby web application using Rack.
Why
A few months ago I was leading an engineering team working on a number of Rails applications. We had a new starter that was new to Ruby and Rails — she had a background in Java — and in her first few weeks we ended up doing a lot of pair programming.
She enjoyed working with Ruby, and the Rails Way of doing things was definitely different from how you would solve the same problems with Spring and Maven (for better or for worse).
As my teammate levelled up her Ruby skills, we realized that most of the mandatory “ah-ah” and “WTF” moments were caused by all the automagic behavior that comes with Rails.
I was reminded of how surprising some things were when I started, and how confusing some of those built-in automatisms must appear at the beginning. So, as a learning exercise, one afternoon we spent a couple of ours building a naive Rails-like framework on top of Rack.
It turned out to be fun and useful for the both of us, and with this post I’d like to share the experience.
A naive and incomplete framework
Thinking of rebuilding Rails from scratch is unrealistic. Rails comes with an awful lot of goodies, and I’m not just referring to the features of the framework, but also to what hides under the hood: performance optimizations, security features, flexible API to plug in different adapters (persistence, assets, template engines, etc). The list is long.
The goal here is to do the bare minimum to understand what happens when an HTTP request comes in. How the different pieces can be joined together. How the MVC abstraction can be built on top of the Rack primitives.
The result will be naive, bare-bones and insecure. Hopefully, getting there will also be fun and useful.
Rack and Rails
As I said, our simple web framework will be built on top of Rack.
Rack provides a minimal interface between webservers that support Ruby and Ruby frameworks. (rack.github.io)
Rack is a Ruby library that abstracts away from the low-level technicalities of HTTP request handling. It provides a Ruby API to work with web requests and responses, and standard interfaces that other Ruby code can implement and consume to act as a “Rack Application” and provide more complex behavior.
In other words, Rack sits between a Ruby HTTP server and a Ruby web application. It wraps the raw HTTP input received by the server into some more accessible objects, it passes them upstream to the Ruby app and, when it receives a response object from the application sitting on top, it transforms it into something that the server can send back to the client.
Ruby on Rails is a Rack application.
It hasn’t always been like that, in fact Rack appeared almost two and half years after Rails. Then, somewhere in 2008, Rails was converted to become Rack compliant.
As a standard, Rack brings a number of advantages, the main one being portability. Most Ruby HTTP servers are built to interface with Rack applications, and that’s the reason why the same Rails application can be served with Unicorn, Puma or Thin with little or no changes.
The simplest Rack app
If you haven’t done it yet, go and check the Rack homepage. Do it now.
As you can see, all it takes for an object to qualify as a Rack application is to implement a public method call
that takes one argument (which we’ll look at in a minute). The returned value should be an Array with the HTTP status code, a Hash of HTTP headers, and an object that responds to each
for the HTTP response body.
The simplest possible Rack application is therefore a proc, which we can save in a file with .ru
extension (for rack up):
1 2 | # rack_demo.ru run Proc.new { |env| ['200', {'Content-Type' => 'text/html'}, ["Hello Rack!"]] } |
To run it we need to first install the rack
gem and then pass the file to the rackup
command:
1 2 3 4 5 6 7 | $ gem install rack # ... $ rackup rack_demo.ru Thin web server (v1.7.0 codename Dunder Mifflin) Maximum connections set to 1024 Listening on localhost:9292, CTRL+C to stop |
With the Rack app running we can open a browser, visit localhost:9292, and view the generated response. The request will also be logged in the terminal window.
Let’s g back for a second to the subject of portability and Rack as a common interface. Notice how rackup
, without an explicit --server
flag, will automatically select a compatible Ruby HTTP server from the ones installed on the system. In the example above it used thin because it was the first that was found available, but with other setups it would choose a different one or fallback to WEBrick from the standard library.
Separation of Concerns
With this working basic setup as a foundation, we can start to build a more modular architecture. For example, we can extract the application out of the rackup file and adopt this project structure:
1 2 3 | rack_demo/ ├─ application.rb └─ rack_demo.ru |
The application.rb
file should contain a simple class:
1 2 3 4 5 | class Application def call(env) ['200', {'Content-Type' => 'text/html'}, ["Hello from the App!"]] end end |
And the rack_demo.ru
file should simply instantiate it and pass it to the Rack bootstapping method run
:
1 2 3 4 | require './application' use Rack::Reloader, 0 run Application.new |
The Rack::Reloader
bit is adding a Rack middleware that will automatically reload the application when a file changes, similar to what Rails does in development mode.
What we’ve done is separating the low-level Rack initilialization (the rackup file) from our custom application logic. The Application class is meant to be the main entry point, and as long as Application#call
returns an Array with the right structure, we can add more moving parts that will process the input to generate different outputs.
The input, in a Rack application, is that env
argument that is passed to call
. That’s the Rack environment. It contains all the information received with the request and, because HTTP is a stateless protocol, it contains only the information received with the current request.
The moving parts we want to add are some of the staple components of a Rails application: a router, controllers to handle the requests, view templates, and models. We won’t implement the exact same mechanics and behaviors of Rails, and everything will look like a heavily simplified version of their Rails counterparts, but the exercise should sill help understand what happens under the hood in a web framework.
Wrap up
This is all for part one. In the next post we’ll look at how to implement a router and controllers.