Concerns with Ring
Part Two of a series introducing yada
So we've decided to use Clojure for our next web API, why would we choose yada over more established libraries such as Ring, Compojure, Liberator, Pedestal, etc.?
One way to answer this is to criticise and expose the weaknesses of these other approaches. Before we do so, please note that these critiques are in the spirit of advancing a technical argument and we hope they'll attract counter-arguments and debate in the comments section at the end of each post. Our sincere hope and intention is that these articles and debates serve in some small way to educate, inform and further advance our understanding and tools. With that said, let's proceed.
This post will be primarily targetting Ring, we'll shift our focus to other libraries in future posts.
Rack and Ring
Clojure's dominant web technology is Ring. It as chiefly inspired by Ruby's Rack, among other influences.
Like Rack, Ring provides 2 main features:
- A common interface to a wide variety of web-servers (we love this!)
- A set of modules (called 'middleware') which provide reusable functionality.
The common interface to web-servers that Ring provides is extremely useful. Ring defines a request map (modelling the HTTP request) and a response map (modelling the HTTP response). No complaints. Let's move on to the matter of Ring middleware.
These middleware modules are implemented as higher-order functions. Like Ruby, Clojure makes this easy. As these functions compose by wrapping each other, most of these function names begin with a
When coding up a web service with Ring middleware, you first write a handler function which conforms to the common interface
A middleware function is applied to the handler to provide a new, modified handler which exhibits some extra functionality. Typically, a middleware function will adapt the incoming request map, or the outgoing response map. Since these functions compose, further Ring middleware can be applied to this new handler function, ad infinitum. Thus, Ring middleware is a modular system: each feature can be delivered as an individual module (in this case, higher-order function).
(-> my-handler (wrap-params) (wrap-cookies) (wrap-session) (wrap-other-stuff)) => #fn-opaque-gobbledygook
Functional languages support 'higher-order' functions, where functions can receive other functions as arguments or return them as results. This enables function composition, where functions can be combined to bundle their functionality into a single composed function.
This is a powerful idea and Ring starts with the basis that functions and function composition is a good solution to the problem of developing production HTTP services. This idea likely stemmed from a fascination with functional programming ideas in the Ruby community when they built Rack. Discovering the power of function composition is certainly one of the 'wow' moments of learning a functional programming language and it is easy to become enthralled by it.
But we'll show that this approach is less than ideal and explain how it fails to deliver on some important HTTP requirements.
Our first example is Ring's
In the HTTP standard, the purpose of the HEAD method is to provide a user-agent (browser or other device) with the option to just query the headers that would be returned from a normal GET request. It may be that the user-agent wants to see what the result of the content negotiation process will be or when the resource was last modified.
Here's the source code, from Ring 1.5's
ring.middleware.head namespace. Remember, the whole point of issuing a HEAD request is to avoid the cost of generating the body of the response in the first place.
(defn head-request "Turns a HEAD request into a GET." [request] (if (= :head (:request-method request)) (assoc request :request-method :get) request)) (defn head-response "Returns a nil body if original request was a HEAD." [response request] (if (and response (= :head (:request-method request))) (assoc response :body nil) response)) (defn wrap-head "Middleware that turns any HEAD request into a GET, and then sets the response body to nil." [handler] (fn [request] (-> request head-request handler (head-response request))))
wrap-head works in the following way:
- Change the HEAD request to a GET
- Proceed with the GET request
- Wipe out the body from the response
- Return the response with the missing body
This algorithm is naïve and sub-optimal, but Ring's middleware design does not allow for a better one. In comparison, the approach taken in yada is to avoid asking the resource to generate a body for the response, so there is no wasted work.
HTTP has a feature called Conditional Requests. This allows a web server to avoid the unnecessary (and possibly costly) generation of a response body if a user agent already has an identical body in its cache.
Here's the source code, from Ring 1.5's
(defn wrap-not-modified "Middleware that returns a 304 Not Modified from the wrapped handler if the handler response has an ETag or Last-Modified header, and the request has a If-None-Match or If-Modified-Since header that matches the response." [handler] (fn [request] (-> (handler request) (not-modified-response request))))
This code works like this:
- Perform the request as usual
- Check the response headers, and return a 304 (Not Modified) if appropriate
Again, this implementation defeats the purpose or HTTP's conditional request mechanisms. Each request adds load to the server or database, and causes the whole response to be recreated, regardless of whether it is going to be used. The only saving is in network bandwidth as the 304 does not actually send the body back to the user-agent.
Without going into too much detail, yada is based on a data-model which treats the resource's meta-data (whether it exists, when it was modified, and so on) separately from its data. The resource's meta-data is therefore queried in a separate step. This allows for better optimization (although the programmer is ultimatley in control of whether the meta-data and data are retrieved together or separately).
Lack of multi-threading
Another problem with function composition is that it ties the processing of an entire request to a single thread. This can be a problem when I/O is required, for reading data from a file or database for instance. The thread serving the web request must then block, waiting for the disk or network I/O to complete. For some small-scale applications this isn't much of a problem but it does constrain the architectural choices available to us if we ever need to scale up.
In contrast, yada allows a great deal more flexibility when it comes to utilizing multiple threads to service requests. For example, it's possible to build systems which delegate file, database and network I/O to separate thread-pools, use futures and promises with timeouts, and even harness core.async channels to efficiently process requests. Unlike platforms like Ruby and NodeJS, the JVM is tailor-made for efficient multi-threading, and the Clojure language is perfect for harnessing this capability.
There are other features of the HTTP standard that are difficult or impossible to implement using Ring middleware. Among the most difficult are those that require access to the resource's meta-data, such as its status and what representations it is capable of producing.
How can a middleware function responsible for Proactive Negotiation determine which representations a resource is capable of producing?
Or if that middleware function is the sole owner of this set of representations, how can it make them available to other middleware functions that need to know what they are?
Such middleware functions might include one that handles an If-Match header, one that produces a response's
Vary header and one that produces a choice of representations for a 300 Multiple Representations response. If the information needs to be communicated via augmentation of the incoming request, this requires both careful ordering in the application of Ring middleware, plus the additional out-of-band semantics are then part of the interface. Middleware functions are no longer as independent as they first appear.
The result is that developers using Rack or Ring become highly selective about which HTTP features are 'switched on' in various contexts. Producing JSON? Wrap in some middleware that sets the Content-Type to
application/json and encodes the existing body of the response to a JSON string. Need to read a query parameter? Make sure you've remembered to add the middleware function or it will be missing. Content Negotiation? Forget it! As viewed from the perspective of the HTTP standard, this selectivity results in web resources that are often non-compliant, and post-facto justified as 'pragmatic'.
By contrast, the approach taken by yada is to provide a complete out-of-the-box implementation of the HTTP standard by default. Of course, developers are free to configure, modify and extend the behaviour of their services but do not need to study the many details of the HTTP protocol if they choose not to.
The road ahead
The next post will present another area where web developers have traditionally complected concerns. Until then, if you want to jump into yada, visit the GitHub project page. We also have a documentation website at juxt.pro/yada with a user manual and examples. Plus, there's a low-volume discussion list you can join for announcements and discussion, and many yadarians hang out on the Slack channel. There's a Gitter channel too.
Images in this series taken from Lord of the Rings are Copyright © Middle-earth Enterprises and used under fair-dealing for the purposes of caricature, parody, or pastiche.
There's a good discussion happening on Reddit.