Brandon Rice

Software development with a focus on web technologies.

A Middleware Stack Without Rack

| Comments

Rack is the underlying web server interface used by popular Ruby frameworks like Rails and Sinatra. I previously wrote about making a Rack server from scratch after finding a lack of how-to help while googling. Recently, I found myself digging back in after becoming inspired by Rack’s middleware stack. There are plenty of guides floating around on how to use and create middleware for Rack, but I wanted to take the stack concept itself and use it in a completely different context – something that wasn’t necessarily a web application.

A middleware stack is not the traditional LIFO (last in, first out) data structure that comes to mind for many programmers when they hear the word “stack”. It’s a layered series of code modules, each of which modifies the state of an incoming data structure. After each layer has a turn, the resulting (new) structure is returned.

Here’s the situation which made me want to adapt this pattern:

  • I have a collection of isolated code modules, each of which does a specific task.
  • The sum result of those tasks are used to make decisions somewhere else in the program.
  • The code modules can be assembled and used in various configurable combinations.
  • Sometimes, the order they run in matters.
  • I want to easily communicate the architecture to teammates in a way that is familiar to them.

There are only two types of pieces in this puzzle: The middleware and the builder.

Middleware

A middleware is nothing more than a class that takes an “application” (more on that later) as its constructor argument, and which implements a single method, call. This method takes one argument: A hash of the current “request” environment. In Rack parlance, the request is an incoming HTTP request. There is no HTTP here, so the “request” is really just whatever is making use of the middleware stack. The only requirement is that call must return by passing the new environment (including whatever changes are made) to the next layer of the stack.

1
2
3
4
5
6
7
8
9
10
class SomeMiddleware
  def initialize(app)
    @app = app
  end

  def call(env)
    # Do some stuff to env
    @app.call(env) # on to the next layer
  end
end

Builder

The builder is a class that manages a middleware stack and an associated application. There may be many builder instances depending on the number of desired middleware arrangements. The application is simply an object that responds to call. In its simplest form, it is a lambda.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
# Almost a verbatim copy of the Rack::Builder class
class Builder
  def initialize(&block)
    @middleware = []
    instance_eval(&block)
  end

  # @param [Hash] env The initial environment for this call.
  #
  # @return [Hash] The resulting environment after all the middleware has done something.
  def call(environment = {})
    to_app.call(environment)
  end

  # Makes instances of all of the classes in the current stack of middleware,
  #   injecting the application and any specified arguments given when #use was called.
  def to_app
    application = lambda { |env| env }

    @middleware.reverse.inject(application) do |app, component|
      component.call(app)
    end
  end

  # @param [Class] klass The middleware class that will be instantiated and used
  # @param [Array] args Any arguments that need to be passed when instantiating the middleware
  def use(klass, *args, &block)
    @middleware << lambda do |app|
      klass.new(app, *args, &block)
    end
  end
end

Using the builder means defining a desired stack configuration and then calling it.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
first_config = Builder.new do
  use SomeMiddleware
  use SomeOtherMiddleware
end

second_config = Builder.new do
  use AlternativeMiddleware1, with_constructor_arg
  use AlternativeMiddleware2
end

request_details = {} # an initial env to pass in

first_result = first_config.call(request_details)
second_result = second_config.call(request_details)

The results are hashes that have been manipulated in any number of ways by the various middleware layers.

Takeaways

A shortcoming I’ve identified is the difficulty of parallelizing stack layers that don’t absolutely have to run in a specified order. That’s a solvable problem, but worth noting when considering this pattern. One large benefit is the ease of communicating this architecture to my teammates, which I mentioned above as a primary goal. I can start a knowledge transfer conversation with “it works like Rack middleware”, and immediately establish a shared understanding. Would I use this pattern again? Maybe. It gets the job done and it’s fairly easy to understand. At the very least, I’ve emerged with a deeper understanding of Rack internals.

If you enjoyed this post, please consider subscribing.

Comments