sugar-framework / sugar

Modular web framework for Elixir
https://sugar-framework.github.io/
MIT License
431 stars 26 forks source link

[RFC] Soft real-time items #51

Open slogsdon opened 9 years ago

slogsdon commented 9 years ago

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

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.

slogsdon commented 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.

Router

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.

Controller

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.

Events

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.

rubencaro commented 9 years ago

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!

slogsdon commented 9 years ago

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.

YellowApple commented 9 years ago

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.

YellowApple commented 9 years ago

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 :)

rubencaro commented 9 years ago

+1 for all that!

slogsdon commented 9 years ago

: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.

slogsdon commented 9 years ago

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.

slogsdon commented 9 years ago

For anyone interested in the development, you can follow along here: https://github.com/slogsdon/plug-web-socket

slogsdon commented 9 years ago

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

Walkthrough

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.

slogsdon commented 9 years ago

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
slogsdon commented 9 years ago

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.