Home

Awesome

Stories in Ready masada

Castra

Web application RPC library for Clojure/Script and Ring.

[hoplon/castra "3.0.0-alpha7"] ;; latest release

The Big Picture

The purpose of Castra is to make async server calls feel like expression evaluation, providing a more cohesive programming experience across the front end and back end of your system. Instead of thinking "I'm going to send this data bag in a POST to the /xyz endpoint," supports you in thinking thoughts like "I'm going to evaluate the expression (update-record 123 {:x 1 :y 2}) on the server."

Castra's front-end and back-end libraries implement this RPC pattern.

RPC does come with baggage, though. Implementing an RPC framework that provides a seamless and transparent remote execution model is a very difficult distributed systems problem. Castra does not attempt this. Instead, Castra's RPC model embraces the asynchronous nature of client server communication.

Applicable buzzwords, if you're not into the whole brevity thing, are unidirectional dataflow and the CQRS pattern.

Overview

Castra spans the gap between server and client. This makes documentation a bit more difficult. This section will jump back and forth between client and server code — it should be understood that the server code is Clojure and client is ClojureScript.

Server

Most of the magic happens in Castra's castra.middleware/wrap-castra ring middleware. This middleware looks for an expression under the :body key of the ring request map. It looks for a request that looks something like:

{:request-method :post
 :body "(my.app/update-record 123 {:x 1 :y 2})"}

You can think of the response as if it were this:

{:status 200
 :body (pr-str (my.app/update-record 123 {:x 1 :y 2}))}

Obviously, we don't want the client to be able to evaluate arbitrary expressions on the server (we already have nREPL for that). We want to be able to mark certain functions as part of our application's RPC interface. This is accomplished with castra.core/defrpc:

(ns my.app
  (:require
    [castra.core :as c]
    [some.database :as db]))

(c/defrpc get-record
  [id]
  (first (db/query "SELECT * FROM record WHERE id = ?" id)))

(c/defrpc update-record
  [id {:keys [x y]}]
  (db/execute "UPDATE IN record SET x = ?, y = ? WHERE id = ?" x y id)
  (get-record id))

Client

Castra provides a ClojureScript library for creating the RPC stub functions the client will call. These are constructed by the castra.core/mkremote function.

(ns my.app.client
  (:require
    [castra.core :as c]
    [javelin.core :as j :include-macros true]))

(j/defc record  nil)
(j/defc error   nil)
(j/defc loading nil)

(def get-record    (c/mkremote 'my.app/get-record    record error loading))
(def update-record (c/mkremote 'my.app/update-record record error loading))

The mkremote function takes four arguments, three of which are Javelin cells:

  1. The fully qualified symbol associated with the defrpc on the server.
  2. The state cell, where the result will go if the operation succeeded.
  3. The error cell, where the exception will go if the operation failed.
  4. The loading cell, which will contain a vector of all in-flight RPC requests.

This defines the get-record and update-record functions in the client, which can be called like any other ClojureScript function.

Hoplon Example:

Using the server and client code above, we can make a little webapp that shows us the contents of a record.

Here is a simple Hoplon page that satisfies these requirements:

(page "index.html"
  (:require
    [my.app.client :as c]))

(defc= loading?      (some-> c/loading seq count))      ; contains count of in-flight commands
(defc= error-message (some-> c/error .-message))        ; contains the last command's error message

(c/get-record 1)                                        ; get record 1 when page first loads

(html
  (head)
  (body
    (p :toggle loading?                                 ; show when things are being processed
      (text "Processing ~{loading?} requests..."))

    (p :toggle error-message                            ; show when an operation fails
      (text "Error: ~{error-message}"))

    (p (text "Record: ~{c/record}"))                    ; display the current record

    (let [id (cell nil)]
      (form :submit #(c/get-record @id)                 ; edit and submit form to load record
        (p (label "Record ID: ")
            (input :value id :keyup #(reset! id @%)))
        (p (button :type "submit" "submit"))))))

There are a few things to notice about this application:

Dataflow

<img src="img/arch-full.png" width="300" align="right">

The diagram to the right illustrates the flow of data through the application. There are actually two dataflow loops in the program:

Local Loop

  1. The user types in the input element.
  2. The keyup DOM event fires, triggering a state transition (callback).
  3. The callback calls reset! on the id cell, updating its value.
  4. The value property of the input element is bound to the id cell, so it is automatically updated when id changes.
  5. The user sees the new value in the input element.

Remote Loop

  1. The user submits the form.
  2. The submit DOM event fires, triggering the callback.
  3. The callback performs an RPC command, get-record.
  4. When the RPC command completes the c/record cell is automatically updated with the new current record.
  5. The textContent property of the text node displaying the current record is bound to a formula cell that updates when c/record changes.
  6. The user sees the new record's value in the page.

Notice how similar the two cases are. The main difference is at step 3. In the local loop the callback directly updates the id cell with the synchronously with reset!. In the remote loop the c/record cell is updated asynchronously with Castra.

Examples

The Hoplon Demos repo contains demo apps using Castra.

TODO

License

Copyright © 2013 FIXME

Distributed under the Eclipse Public License, the same as Clojure.