Open slogsdon opened 9 years ago
I had some thoughts today about what the end result of an implementation could look like for a developer wanting to use WebSockets in Sugar.
In the router, it should be fairly simple and should remain consistent with the other available macros.
defmodule My.Router do
use Sugar.Router
get "/", My.Controllers.Main, :index
socket "/", My.Controllers.Main, :notifications, name: :notifications
socket "/chat", My.Controllers.Chat, :index, name: :chat
end
Routes should be able to be sent to standard controllers, allowing a developer to blend functionality as he/she sees fit.
Inside of a controller, socket/2
will help define socket handlers.
defmodule My.Controllers.Main do
use Sugar.Controller
def index(conn, []) do
# GET handler for index
render conn
end
socket :notifications do
def handle("notify", socket, data) do
# Do something on "notify" events
socket
end
end
end
defmodule My.Controllers.Chat do
use Sugar.Controller
socket :index do
def handle("message", socket, data) do
socket
|> broadcast(data["message"])
end
def handle("message:" <> user, socket, data) do
socket
|> send(user, data["message"])
end
end
I've not seen implementations like this before. Typically, there is a clear separation between socket handlers and standard actions (i.e. different modules), so there will need to be some testing to see if this is a possibility.
With the :name
option on the routes, modules outside of a set of socket handlers in a controller can still send messages for events.
defmodule My.Queries.User do
import Ecto.Query
alias Sugar.Events
after_insert :notify
def create(data) do
# Build query
My.Repos.Main.insert(query)
end
def notify(changeset) do
Events.broadcast(:notifications, "new:user", changeset)
end
end
This is a rough idea, so I'd love to see any feedback on this direction.
I think the 'handle' functions should be at the same level than the regular actions inside the controller. Actually in that scenario the 'socket' macro in the controller is not needed (and the first rule of macros is...).
Pattern matching can be used to determine the right 'handle' function for a given 'socket' route.
Something like 'def handle(: notifications, "notify", socket, data)'.
That would keep controllers from getting messy when they grow as they would always be one level deep functions. Also allow simpler testability.
To free a controller from some code one can always put it on other module and 'use' it from the controller.
About 'Events' , I can't avoid feeling uncomfortable sending data to a client straight from models, or outside of a controller at all. I have no constructive proposal for this right now, but then again, it's up to the developer to do it that way. So I guess it's ok.
Thanks for asking anyway.
I'm glad sugar keeps growing!
Thanks for the feedback, @rubencaro! I definitely agree that macros should only be used when necessary, and in this case, I'm thinking there will be some work needed at compile time. For things like Sugar.Events.broadcast/3
, handler names will most likely need to be registered so those functions will know what will actually do the handling.
There's always a good possibility that things can be simplified, especially since my first passes in my head aren't always the best plan of attack. I always want to keep things as simple as possible.
The example with the after_insert
Ecto callback was just the first thing that popped into my head. I would probably never do that as well, but I wanted something to clearly demonstrate what that module could be used for.
If you have any other criticisms, please don't hesitate to lay them out.
I'd personally lean toward keeping as much socket-specific stuff in the router (and outside the controller) as possible; it seems more appropriate (and more natural and more intuitive and less confusing) as a routing concern in general (including whatever compile-time stuff is necessary), reserving the controller(s) for performing actions upon sockets without needing to worry nearly as much about the semantics of how/why those actions were called.
In other words, I'd probably prefer something structured like so (though probably not exactly like so; my experience with WebSockets is admittedly not very developed compared to that of more typical HTTP requests, so my imagination of this might be atypical relative to what a more experienced WebSockets guru would expect):
defmodule My.Router do
use Sugar.Router
get "/", My.Controllers.Main, :index
# magic happens here
socket "/" do
# magic also happens here
handle "notify", My.Controllers.Main
end
socket "/chat" do
# Not very DRY in this example, but the point is clarity and intuition
handle "message", My.Controllers.Chat
handle "message:*", My.Controllers.Chat
end
end
defmodule My.Controllers.Main do
use Sugar.Controller
def index(conn, _args) do
conn |> do_something
end
# socket handler defaults to "handle"; uses pattern matching to figure out
# what to handle
def handle("notify", socket, data) do
socket |> do_something_with(data["notification"]) # or somesuch
end
end
defmodule My.Controllers.Chat do
use Sugar.Controller
# same deal here; default to "handle/3", use pattern matching
def handle("message", socket, data) do
socket |> broadcast(data["message"])
end
def handle("message:" <> user, socket, data) do
socket |> send(user, data["message"])
end
end
Basically, if we're going to do before-compile magic in order to get our sockets/handlers setup, I think it should be in the router (where we've already piled on a bunch of macros and where the addition of these particular macros would make the most sense) rather than the controller (which is currently pretty magic-free (other than the automagical view rendering, perhaps) and probably shouldn't be burdened with what really amounts to routing concerns).
I don't know how feasible this will be in practice, but I reckon it should be similar to the way Phoenix.Channel
(for example) works, but with more intuitive terminology in use.
Putting a little more thought into my own suggestion, I think something like
defmodule My.Router do
use Sugar.Router
# ...
socket "/chat", My.Controllers.Chat
end
would probably also be sufficiently terse without sacrificing clarity if it's documented that the router will direct to the specified controller's handle/3
by default (the above recommendation could still work if different events on the same socket should be handled by different controllers).
Of course, if more explicitness is required, we could expand this to accept an optional atom representing a function name, like so:
defmodule My.Router do
use Sugar.Router
# ...
socket "/chat", My.Controllers.Chat, :chat
end
defmodule My.Controllers.Chat do
use Sugar.Controller
# ...
def chat("message:" <> user, socket, data) do
socket |> send(user, data["message"])
end
end
Of course, coming up with ideas is the easy part. No idea how well this will translate to reality :)
+1 for all that!
:thumbsup: I'm going to start poking around, probably within the week, to see what's possible. I'm hoping we can do most of this without being limited too much by getting Cowboy/Plug to play nicely when it comes to WebSockets.
Started in on this tonight and got a demo going with Plug and a WebSocket handler. Documenting here:
Cowboy WebSocket handler:
defmodule WsHandler do
@behaviour :cowboy_websocket_handler
## Init
def init(_transport, _req, _opts) do
{:upgrade, :protocol, :cowboy_websocket}
end
def websocket_init(_transport, req, _opts) do
{:ok, req, :undefined_state}
end
## Handle
def websocket_handle({:text, msg}, req, state) do
{:reply, {:text, "responding to " <> msg}, req, state, :hibernate}
end
def websocket_handle(_any, req, state) do
{:reply, {:text, "whut?"}, req, state, :hibernate}
end
## Info
def websocket_info({:timeout, _ref, msg}, req, state) do
{:reply, {:text, msg}, req, state}
end
def websocket_info(_info, req, state) do
{:ok, req, state, :hibernate}
end
## Terminate
def websocket_terminate(_Reason, _req, _state) do
:ok
end
def terminate(_reason, _req, _state) do
:ok
end
end
Standard Plug:
defmodule Router do
use Plug.Router
plug :match
plug :dispatch
get "/" do
conn
|> send_resp(200, "index")
end
end
Application callback:
defmodule WebSocket do
use Application
def start(_type, _args) do
import Supervisor.Spec, warn: false
children = []
plug = Router
opts = []
dispatch = build_dispatch(plug, opts)
Plug.Adapters.Cowboy.http plug, opts, [dispatch: dispatch]
opts = [strategy: :one_for_one, name: WebSocket.Supervisor]
Supervisor.start_link(children, opts)
end
defp build_dispatch(plug, opts) do
opts = plug.init(opts)
[{:_, [ {"/ws", WsHandler, []},
{:_, Plug.Adapters.Cowboy.Handler, {plug, opts}} ]}]
end
end
Biggest point to note there is the use of build_dispatch/2
. By default, the Cowboy adapter for Plug will use only {:_, Plug.Adapters.Cowboy.Handler, {plug, opts}}
in the dispatch table passed to Cowboy. When sending a custom dispatch table (which is needed in order to add the customer WebSocket handler, we have to add {:_, Plug.Adapters.Cowboy.Handler, {plug, opts}}
on our own as a catch-all for the Cowboy dispatcher to use because the adapter will only use what we pass it.
At compile time, we'll need to build a {"/ws", WsHandler, []}
for each use of the socket/3
macro in the router to pass our controllers and handle functions to future Cowboy.WebSocket.Handler
, which will take the place of the above WsHandler
.
For anyone interested in the development, you can follow along here: https://github.com/slogsdon/plug-web-socket
Alright, I have a good, extremely simple base ready for review: https://github.com/slogsdon/plug-web-socket. If things go well, I'll start on the Event layer or the socket/4
macro.
Tear this up as much as possible. I won't feel bad. Thanks!
/cc @YellowApple @rubencaro
WebSocket.Cowboy.Handler
acts as a general Cowboy handler to accepting connections. It should be used in dispatch tables, getting passed a module and function name responsible for handling WebSocket messages.
The socket/4
macro should be fairly straightforward. All it really needs to accomplish is accumulate the list of routes for the dispatch table, and before the router is compiled, the dispatch table just needs to end up in the format in the demo, ready to be called in the run function of the router.
There's nothing needed in a controller except plain functions. Currently, the handling of websocket_init
and websocket_terminate
are required in the controller using atoms instead of strings for the first argument, :init
and :terminate
respectively. These will eventually be optional. The functions for handling the WebSocket messages can only handle text-based payloads at the moment, which covers more use cases than binary data, but binary data will eventually be supported. Also, some helper functions should be created to simplify building proper replies that the Handler knows how to use when responding to clients.
At the moment, the socket
argument is a slightly modified Plug.Conn
, with the scheme updated to be either :ws
or :wss
. This will most likely need something more, but for the time being, it works (kinda). A big plus is that information about the connected client is available, just as with HTTP clients.
By default, WebSocket messages are to be a WebSocket.Message
, carrying an event name and a data payload to and from a connected client. This allows for the separation of the type of message and the data for the message. This is similar to Phoenix's notion of topics. There is an option to disable this (see the Echo test for an example), allowing for the ability to parse the raw message in the controllers, but I'm not sure how I feel about that. It adds a fair amount of needed complexity to handle the messages for use cases beyond a simple echo server, so I'm thinking no one would actually use this.
I went ahead with a basic Events layer: WebSocket.Events
. Public API looks like this:
defmodule WebSocket.Events do
use GenEvents
def start_link(ref) do
case GenEvent.start_link(name: ref) do
{:ok, pid} ->
GenEvent.add_handler(ref, __MODULE__, [])
{:ok, pid}
{:error, {:already_started, pid}} ->
{:ok, pid}
otherwise ->
otherwise
end
end
def join(ref, pid) do
GenEvent.notify(ref, {:add_client, pid})
end
def leave(ref, pid) do
GenEvent.notify(ref, {:remove_client, pid})
end
def broadcast(ref, event, originator) do
GenEvent.notify(ref, {:send, event, originator})
end
def broadcast!(ref, event) do
broadcast(ref, event, nil)
end
def stop(ref) do
GenEvent.stop(ref)
end
# ...
end
Ok, last update. Worked on the macro, but it's has some room for improvement.
defmodule WebSocket.Router do
use Plug.Router
use WebSocket.Macro
socket "/topic", WebSocket.TopicController, :handle
socket "/echo", WebSocket.EchoController, :echo
# ...
end
Edited: because I'm a dummy.
Need something like this to keep up with newer frameworks and web application development trends. The technologies below all allow the client to receive unsolicited updates from the server, while WebSockets allow the client to also send data back to the server on the same connection.
Technologies involved
Items needed
Sugar.Events
Sugar.Router.socket/3
Notification/event layer
This would allow notifications/events to be published by producers (data layer, api endpoints, etc.) and subscribed by consumers (clients). Would most likely be built upon
GenEvent
or something similar.Interaction layer
This would interface the notification layer with the clients by means of the above technologies, e.g. WebSockets. A behaviour would most likely be created to allow for the differing technologies to talk to the clients via the current Plug adapter (web server) in a consistent manner.
API additions
I already have some macros thought out from working on another project the would remove cruft from a WebSockets handler, which should be easy to expand to include the necessary bits for long polling and SSE.