Writing an Evented WebSocket Client

Posted on 15 Nov 2018 by Eric Oestrich

I recently did a pretty big refactor for the Gossip Elixir client. I'd like to show off what I did for that. For a brief background, Gossip sends events over the websocket connection. A sample event might be:

{
  "event": "channels/broadcast",
  "ref": "89036074-446f-41ab-b87a-44ef1f962f2e",
  "payload": {
    "channel": "gossip",
    "message": "Hello everyone!",
    "game": "ExVenture",
    "name": "Player"
  }
}

You can see all of the events over at the Gossip Docs.

Client Flow

The client connects to Gossip via the Gossip.Socket server. This is a Websockex process, which is a little different than your standard GenServer. It might eventually be swapped out to be a Gun client.

The flow of data goes as such:

New websocket frame

Full code

defmodule Gossip.Socket do
  # ...

  def handle_frame({:text, message}, state) do
    case Events.receive(state, message) do
      {:ok, state} ->
        {:ok, state}

      {:reply, message, state} ->
        {:reply, {:text, Poison.encode!(message)}, state}

      # other return values
    end
  end

  # ...
end

When the client gets a new websocket frame, the socket process calls down to a lower level module.

Handling the event

Full code

defmodule Gossip.Socket.Events do
  # ...

  def receive(state, message) do
    with {:ok, message} <- Poison.decode(message),
         {:ok, state} <- process(state, message) do
      {:ok, state}
    else
      {:reply, message, state} ->
        {:reply, message, state}
    end
  end

  # ...
end

The frame is decoded to an event and then processed.

Pattern matching on the event

Full code

defmodule Gossip.Socket.Events do
  # ...

  def process(state, message = %{"event" => "channels/" <> _}) do
    Core.handle_receive(state, message)
  end

  def process(state, message = %{"event" => "players/" <> _}) do
    Players.handle_receive(state, message)
  end

  # ...
end

Each event has a general scheme of noun/verb, such as channels/broadcast. This pattern matches on the just the noun part and pushes it lower into the module for processing.

Pattern matched on the specific event

Full code

Full code

defmodule Gossip.Socket.Core do
  # ...

  def handle_receive(state, message = %{"event" => "channels/broadcast"}) do
    process_channel_broadcast(state, message)
  end

  def process_channel_broadcast(state, %{"payload" => payload}) do
    message = %Message{
      channel: payload["channel"],
      game: payload["game"],
      name: payload["name"],
      message: payload["message"],
    }

    core_module(state).message_broadcast(message)

    {:ok, state}
  end

  # ...
end

Here the event is fully pattern matched and calls to the internal function. I like to do it this way to keep it similar to GenServers and keeping the handle_* functions skinny.

Testing

With the client broken up into fairly small pieces, it helps encourage testing and keeps it simple to test.

Prior to this refactor, the Gossip client had almost no tests. This is due to each event module being broken up into separate elixir modules and having separate processing functions for each event.

See testing in action.

Conclusion

I hope you poke around the rest of the client. I also recommend taking a look around the server side of Gossip since that got a big refactor as well. One really cool section is the "event router" macro I set up to generate the server side receive functions.

comments powered by Disqus
Creative Commons License
This site's content is licensed under a Creative Commons Attribution-ShareAlike 4.0 International License unless otherwise specified. Code on this site is licensed under the MIT License unless otherwise specified.