I think this work clearly captures the spirit of Ring: abstract
implementation details behind a minimal, clean, and composable
interface. I agree with all the advantages that you described; both
that they are the goals such an interface should try to achieve and
that this interface has the potential to achieve them.
I also agree with your sentiment that it is useful to start by working
backwards from the abstraction. From here I think there are several
things we should do to validate the theory that this is the
appropriate abstraction for duplexed communication in Clojure web
apps.
* Continue to elicit feedback from Ring community members. I am
particularly interested in hearing from people that have production
apps that use duplexed channels extensively, in Clojure or otherwise.
* Sketch implementations of the interface for various backing transports.
* Sketch handlers and middleware that use various aspects of the abstraction.
Ultimately, any change to the Ring SPEC needs to be both carefully
designed and validated by working, non-trivial, end-to-end Clojure web
application code.
It also might be useful to compare this abstraction to interfaces
offered by duplexing libraries in other languages:
* http://github.com/LearnBoost/Socket.IO-node
* http://github.com/igrigorik/em-websocket
I do have a few particular questions about your proposal:
How do you feel about reserving IFn responses exclusively for
channels, and about using clojure.core/fn? as the way to determine if
a response is a channel? Do you think it would ever be useful to be
able to return IFn's as a type of regular synchronous Ring response?
When/how would the channel-enabled handler be invoked and the
corresponding channel returned? For example, you suggested that the
following could implement broadcasting:
(defn broadcaster [request]
(let [senders (atom #{})]
(fn [send]
(swap! senders conj send)
(fn [message]
(doseq [s @senders] (s message)))))
This seems to imply that the broadcaster handler is invoked only once
in the lifetime of the server as `senders` is let'd within its
invocation instead of outside of it.
A related question; is exactly 1 listener closure created per client?
Looking at the echo server example:
(fn [send]
(fn [message]
(send message))
The message callback does not contain any information about what
client sent the message, so presumably it is the single one to which
send corresponds. If this is indeed the case, are :connect messages
redundant?
Do I understand correctly that channel middleware effects both
incoming and outgoing messages by altering the channel response, as
apposed to regular middleware that operations on both sides of the
handler? Is there way to determine as a request moves up from the
adapter through middleware to the handler - before it returns a
channel - if the request is one corresponding to an eventual channel
response?
Do you have any additional comments about representing channels as
functions that presumably dispatch on :type vs. function maps keyed by
type vs implementations of a Channel protocol?. In particular, can you
elaborate on why you chose to represent a channel as single function
instead of 3 - one for each of connect, message, and disconnect?
I'd like to explore the possibility of simplifying the protocol by
removing a layer of function-combination. For example, what do you
think about a protocol that supported the following echo server?
(fn [message]
((:send message) message))
I ask about this because the multiple layers of higher order functions
makes middleware harder to unstand. For example, I've studied this
snippet for a while and do think I fully understand it:
(defn wrap-channel-filter [handler reader writer]
(wrap-channel
(fn [channel]
(fn [send]
(let [sender #(send (update-in % [:body] writer))
listener (channel sender)]
#(listener (update-in % [:body] reader)))))))
Perhaps you provide a few more middleware example snippets, like ones
that just alter incoming message bodies, or that filter incoming
messages, or the inject extra outgoing messages.
Finally, how do you feel about connect/disconnect vs open/close?
Thanks again James for your work on this proposal.
- Mark
http://armstrongonsoftware.blogspot.com/2009/12/comet-is-dead-long-live-websockets.html
On Jul 11, 2010, at 1:42 PM, Mark McGranaghan <mmcg...@gmail.com> wrote:
...
I agree completely. I'll add a new branch to the repository to
experiment with an implementation.
> How do you feel about reserving IFn responses exclusively for
> channels, and about using clojure.core/fn? as the way to determine if
> a response is a channel? Do you think it would ever be useful to be
> able to return IFn's as a type of regular synchronous Ring response?
I think it's reasonable to have a function representing a dynamic
channel, and a map representing a normal, static response.
I need to consider how I'd work this into Compojure, since returning a
function in Compojure is currently equivalent to a nested route.
However, I believe I can work around this.
> (defn broadcaster [request]
> (let [senders (atom #{})]
> (fn [send]
> (swap! senders conj send)
> (fn [message]
> (doseq [s @senders] (s message)))))
>
> This seems to imply that the broadcaster handler is invoked only once
> in the lifetime of the server as `senders` is let'd within its
> invocation instead of outside of it.
Ah, that's an error on my part. It should be:
(let [senders (atom #{})]
(defn broadcaster [request]
(fn [send]
(swap! senders conj send)
(fn [message]
(doseq [s @senders] (s message)))))
As you point out, the sender setup would have to be located outside
the handler function, otherwise we'd just create a new set of senders
every request.
> A related question; is exactly 1 listener closure created per client?
> Looking at the echo server example:
>
> (fn [send]
> (fn [message]
> (send message))
>
> The message callback does not contain any information about what
> client sent the message, so presumably it is the single one to which
> send corresponds. If this is indeed the case, are :connect messages
> redundant?
Yes, the send function corresponds to one client.
I'm somewhat undecided on connect messages, but I think they're
useful. In some ways they are redundant, but they perform a subtly
different job than the initial channel function.
The channel function is called immediately after the initial request,
but there might be some data the server is required to send before the
channel is considered completely open. For instance, the client might
open a channel, and then the server sends a unique session ID. The
channel is not considered "connected" until that initial session ID is
sent.
Another advantage of connect messages is that middleware can add
arbitrary keys to them.
There's also a certain neatness in having a connect message to go
along with the disconnect message.
> Do I understand correctly that channel middleware effects both
> incoming and outgoing messages by altering the channel response, as
> apposed to regular middleware that operations on both sides of the
> handler?
Mostly, but not necessarily.
A common Comet technique is to use long polling to simulate a stream
of data. The client sends an XHR to the server, waits for a response,
and then upon receiving a response immediately sends another XHR.
So in effect a duplex channel is constructed by stitching together a
bunch of smaller streams that expire after one message (or a timeout).
We could use Ring middleware to stitch these streams together. So
whilst the middleware receives multiple requests with many short-lived
channels, the wrapped handler only sees one request with one
long-lived channel.
This means that so long as an adapter supports long-polling, we can
use middleware to implement any Comet protocol on the top.
> Is there way to determine as a request moves up from the
> adapter through middleware to the handler - before it returns a
> channel - if the request is one corresponding to an eventual channel
> response?
No. I mean, if the request is a websocket, you can tell by the
headers. But there's no standard way of telling whether a request
expects a Comet response or not, as far as I know.
> Do you have any additional comments about representing channels as
> functions that presumably dispatch on :type vs. function maps keyed by
> type vs implementations of a Channel protocol?. In particular, can you
> elaborate on why you chose to represent a channel as single function
> instead of 3 - one for each of connect, message, and disconnect?
There were a few reasons.
First, it seemed more in line with how requests work currently. To
tell whether a request is a GET, POST, etc., you check the
:request-method key on the request map. Likewise, you'd check the
:type of the channel message.
It would also be more concise to ignore message types you don't want,
for example:
(fn [msg]
(if (= (:type msg) :data)
(do-something msg)))
Another advantage is that you can turn a connect/data/disconnect
protocol or function map into a single listener function fairly
easily, but you can't easily do the reverse.
I was also thinking that perhaps custom types could be added by
middleware (although perhaps that would be best achieved with a
separate key).
> I'd like to explore the possibility of simplifying the protocol by
> removing a layer of function-combination. For example, what do you
> think about a protocol that supported the following echo server?
>
> (fn [message]
> ((:send message) message))
Well... it seems a little inelegant to add a side-effectful function
to a message map, and you wouldn't be able to serialize the map unless
you dissoc'd the function first.
I'm also not sure it would make things that much simpler. Consider
some middleware that broadcasts any response:
(defn wrap-broadcast [handler]
(let [senders (atom #{})
send-all #(doseq [s @senders] (s %))]
(fn [request]
(let [response (handler request)]
(if (fn? response)
(fn [send]
(swap! senders conj send)
(response send-all))
response)))))
And now the same middleware using a sender embedded in the message:
(defn wrap-broadcast [handler]
(let [senders (atom #{})
send-all #(doseq [s @senders] (s %))]
(fn [request]
(let [response (handler request)]
(if (fn? response)
(fn [message]
(when (= (:type message) :connect)
(swap! senders conj (:send message))
(response (assoc message :send send-all)))
response)))))
If anything, it's just complicated things :/
It's also a little less safe. By embedding the sender in the message,
middleware can potentially monkey around with the :send key, so you
can't guarantee that the sender is the same. I guess you might *want*
to do this, but it makes globally overriding the sender more
difficult. To be certain, you'd need to check on each message that the
sender wasn't changed by some higher middleware.
> Perhaps you provide a few more middleware example snippets, like ones
> that just alter incoming message bodies, or that filter incoming
> messages, or the inject extra outgoing messages.
I'll see about posting some more snippets tomorrow. I think that
channel middleware lends itself to high-level functions, so I'll begin
by defining a few functions that do various generic tasks, like filter
and map.
But I think this post is probably too long as it is, without half a
dozen examples cluttering it up. Also, it's past midnight here :)
> Finally, how do you feel about connect/disconnect vs open/close?
I'm ambivalent, but connect/disconnect seems to be used in a lot of
other places (Jetty's Websocket class, Comet Sessions spec, etc.).
> Thanks again James for your work on this proposal.
Not a problem.
- James