Joseph Kain bio photo

Joseph Kain

Professional Software Engineer learning Elixir.

Twitter LinkedIn Github

I’ve been writing a few posts about Ecto lately and in this post I want to talk about how to publish changes to an Ecto model to a Phoenix channel. This will be my first post on Phoenix.

By the way, I know that “Ecto model” is a deprecated term, but what is the right term to use?

Motivation

In a private project that I’ve been working on I wanted to publish model updates to a channel so that the front end could receive them and make updates to the user interface. I started by reading the Phoenix channel guide which was quite helpful for getting setup. But one thing that I was missing was how to write the model updates to the channel from my controller.

Most of what I’ve read about channels have data that originates within the channel. For example, a client writes data to the topic and the Channel broadcasts it to all other subscribers. This stays completely within the channel code.

But, my use case was different. I have RESTful interfaces that update my model and based on that update I want to broadcast the state of the model to the channel topic subscribers. Arguably, I could have changed my interfaces to send updates on the channel but I didn’t want to :)

The Phoenix Endpoint API

I spent some time digging and eventually found what I needed in order to do what I wanted. The interface is in the Endpoint:

broadcast(topic, event, msg) - broadcasts a msg with as event in the given topic.

The Endpoint provides a very useful API. I highly recommend reading through the Phoenix framework docs. There is so much useful information in there.

An Example

Let’s build up an example to see how to use Endpoint.broadcast/3. There’s a lot to do before we get to the point where we can use Endpoint.broadcast/3 so let’s dive in and write some code. First we’ll setup a basic Phoenix application following the Up And Running Guide:

$ mix phoenix.new play_channel
$ cd play_channel
$ mix ecto.create

I answered Y when it asked if I wanted to fetch and install the dependencies.

I’ve pushed the example code to a github repo if you want to follow along.

Building a Model

Let’s generate a model with a page that can be used to update it.

$ mix phoenix.gen.html Toy toys name:string color:string age:integer
* creating web/controllers/toy_controller.ex
* creating web/templates/toy/edit.html.eex
* creating web/templates/toy/form.html.eex
* creating web/templates/toy/index.html.eex
* creating web/templates/toy/new.html.eex
* creating web/templates/toy/show.html.eex
* creating web/views/toy_view.ex
* creating test/controllers/toy_controller_test.exs
* creating priv/repo/migrations/20160217224802_create_toy.exs
* creating web/models/toy.ex
* creating test/models/toy_test.exs

Add the resource to your browser scope in web/router.ex:

    resources "/toys", ToyController

Remember to update your repository by running migrations:

    $ mix ecto.migrate

I’ll follow the instructions and add the resource to the router and migrate the db.

Next I’ll fire up the server with mix phoenix.server and then navigate to http://localhost:4000/toys. On this page I can add toys. To follow along with the rest of this post make sure you add at least one toy.

Publishing a Model On a Channel

Now I want to create a channel that broadcasts changes to a given toy. We’ll start by generating a channel for toys using Phoenix’s generator:

$ mix phoenix.gen.channel Toy toys
* creating web/channels/toy_channel.ex
* creating test/channels/toy_channel_test.exs

Add the channel to your `web/channels/user_socket.ex` handler, for example:

    channel "toys:lobby", PlayChannel.ToyChannel

Again, we follow the instructions and update the user socket:

@@ -4,6 +4,8 @@ defmodule PlayChannel.UserSocket do
   ## Channels
   # channel "rooms:*", PlayChannel.RoomChannel

+  channel "toys:*", PlayChannel.ToyChannel
+
   ## Transports
   transport :websocket, Phoenix.Transports.WebSocket
   # transport :longpoll, Phoenix.Transports.LongPoll

Next, let’s take a look at the channel code that was generated:

defmodule PlayChannel.ToyChannel do
  use PlayChannel.Web, :channel

  def join("toys:lobby", payload, socket) do
    if authorized?(payload) do
      {:ok, socket}
    else
      {:error, %{reason: "unauthorized"}}
    end
  end

  # Channels can be used in a request/response fashion
  # by sending replies to requests from the client
  def handle_in("ping", payload, socket) do
    {:reply, {:ok, payload}, socket}
  end

  # It is also common to receive messages from the client and
  # broadcast to everyone in the current topic (toys:lobby).
  def handle_in("shout", payload, socket) do
    broadcast socket, "shout", payload
    {:noreply, socket}
  end

  # This is invoked every time a notification is being broadcast
  # to the client. The default implementation is just to push it
  # downstream but one could filter or change the event.
  def handle_out(event, payload, socket) do
    push socket, event, payload
    {:noreply, socket}
  end

  # Add authorization logic here as required.
  defp authorized?(_payload) do
    true
  end
end

Some notes:

  • We have a join function though it doesn’t handle the rooms we want. I’ll strip it down to the bare minimum and we’ll come back to it later.
  • We have handle_in to receive “ping” and “shout”. At this point I don’t really need any input so I’ll remove these functions.
  • We have handle_out to modify broadcast events. I’ll leave this as is.
  • We have authorized? used by join. I’m going to forego authorization for now and will remove this function.

We’re left with this:

defmodule PlayChannel.ToyChannel do
  use PlayChannel.Web, :channel

  def join("toys:lobby", payload, socket) do
    {:ok, socket}
  end

  def handle_out(event, payload, socket) do
    push socket, event, payload
    {:noreply, socket}
  end
end

Now for the join function, I want one topic per toy. So the pattern match will look like this:

def join("toys:" <> toy_id, payload, socket)

This matches any topic that starts with “toys:” followed by another string. We interpret that second string as the model id.

Let’s also send back a message on join so that we can make sure things are working.

def join("toys:" <> toy_id, payload, socket)
  {:ok, "Joined toys:#{toy_id}", socket}
end

Connecting the Front End to the Phoenix Channel

It’s time to start working on the front end. I’ll admit that I’m not expert here so if you have any suggestions on how to improve the Javascript presented here I’d be happy to hear them.

We’ll use Javascript to update the show page. Currently, the page template looks like this:

<h2>Show toy</h2>

<ul>

  <li>
    <strong>Name:</strong>
    <%= @toy.name %>
  </li>

  <li>
    <strong>Color:</strong>
    <%= @toy.color %>
  </li>

  <li>
    <strong>Age:</strong>
    <%= @toy.age %>
  </li>

</ul>

<%= link "Edit", to: toy_path(@conn, :edit, @toy) %>
<%= link "Back", to: toy_path(@conn, :index) %>

We’ll want to generate the content of <ul> dynamically. Let’s remove the <li> elements and add an id so we can reference the <ul>. We’ll also add a data item to communicate in the relevant toy id to our Javascript:

<h2>Show toy</h2>

<ul id="show-list" data-id=<%= @toy.id %>>

</ul>

<%= link "Edit", to: toy_path(@conn, :edit, @toy) %>
<%= link "Back", to: toy_path(@conn, :index) %>

Before we go any further, let’s add jquery to the project using bower. First we’ll need an initial bower.json:

{
  "name": "play_channel"
}

Then we can install jquery:

$ bower install jquery --save
bower cached        git://github.com/jquery/jquery-dist.git#2.2.0
bower validate      2.2.0 against git://github.com/jquery/jquery-dist.git#*
bower install       jquery#2.2.0

jquery#2.2.0 bower_components/jquery

And that’s it. Now brunch will compile jquery into our app.js and make it available for us.

Next, let’s create a new file: web/static/js/toy.js. It needs to join the channel:

import socket from "./socket"

$(function() {
  let ul = $("ul#show-list")
  if (ul.length) {
    var id = ul.data("id")
    var topic = "toys:" + id

    // Join the topic
    let channel = socket.channel(topic, {})
    channel.join()
      .receive("ok", data => {
        console.log("Joined topic", topic)
      })
      .receive("error", resp => {
        console.log("Unable to join topic", topic)
      })
  }
});

This code first imports the socket library that Phoenix generated for us. Then it has a document ready function that builds the topic name from our toy id and then joins the topic. If successful, it logs “Joined topic”.

Before we can make use of toy.js we need to import it into our app.js by adding a line like this:

import toy from "./toy"

This works! When I navigate to http://localhost:4000/toys/1 I see

[Log] Joined topic – "toys:1" (app.js, line 2878)

on my Javascript console.

Sending Model Data Over the Channel

Let’s add a new function to our channel module. This function will provide an interface to other parts of our application to allow it to broadcast a Toy model over the channel.

def broadcast_change(toy) do
  payload = %{
    "name" => toy.name,
    "color" => toy.color,
    "age" => toy.age,
    "id" => toy.id
  }

  PlayChannel.Endpoint.broadcast("toys:#{toy.id}", "change", payload)
end

broadcast_change does two things. First, it creates the payload which is map representing our toy. This map will be serialized to JSON before being broadcast. Second, we use the Endpoint.broadcast function to broadcast the map over the channel.

The first argument to PlayChannel.Endpoint.broadcast is the topic name. We use our topic format of “toys:” followed by the model id. We encapsulate this knowledge inside this function in our channel code. The controller that will end up calling broadcast_change doesn’t need to know the topic name. It just needs a Toy model.

Also, note that we broadcast an event called “change” which caries the payload. We’ll need to respond to this “change” event in the front end.

Now that we have this function we can call it from our controller when a toy is updated:

 def update(conn, %{"id" => id, "toy" => toy_params}) do
   toy = Repo.get!(Toy, id)
   changeset = Toy.changeset(toy, toy_params)

   case Repo.update(changeset) do
   {:ok, toy} ->
+   PlayChannel.ToyChannel.broadcast_change(toy)
+
    conn
    |> put_flash(:info, "Toy updated successfully.")
    |> redirect(to: toy_path(conn, :show, toy))

Rendering the Model on the Show Page

The final step is to go back to the front end Javascript and react to the change event.

We’ll start by logging the event by adding this code to our document ready function after joining the channel.

channel.on("change", toy => {
  console.log("Change:", toy);
})

Now, in order to try this out I need two browser windows open.

In the first window I navigate to the show page for my toy at http://localhost:4000/toys/1. Then I open the Javascript console.

In the second browser window I navigate to the edit page for my toy at: http://localhost:4000/toys/1/edit. I modify the color and press “Submit”.

Back in the first browser window I see the following log entry:

[Log] Change: – {name: "Ball", id: 1, color: "Green", …} (app.js, line 2899)

Our channel is sending us data!

Now, let’s write up some Javascript to generate the show page contents we want. Remember we left ourselves with an empty <ul> list.

We’ll encapsulate our Javascript into a class:

export var Toy = {
  show: function(ul, toy) {
    ul.empty();
    ul
      .append('<li><strong>Name:</strong> ' + toy.name + '</li>')
      .append('<li><strong>Color:</strong> ' + toy.color + '</li>')
      .append('<li><strong>Age:</strong> ' + toy.age + '</li>');
  }
}

Our new function, Toy.show, takes two arguments. The first is the <ul> element to update and the second is an object with our Toy model data. The function cleans out the <ul> and then fills it with our desired content.

We can call Toy.show from our channel on function like this:

channel.on("change", toy => {
  console.log("Change:", toy);
  Toy.show(ul, toy);
});

With this our show page fills with content after we update the Toy.

Sending Initial Data Over the Channel

We have one remaining issue, nothing is shown on the show page until an update occurs. We want the page to show data when it first visited. We can fix this by sending the model data on join like this:

def join("toys:" <> toy_id, payload, socket) do
  case PlayChannel.Repo.get(PlayChannel.Toy, toy_id) do
    nil ->  {:error, %{reason: "channel: No such toy #{toy_id}"}}
    toy ->
      {:ok, toy_to_map(toy), socket}
  end
end

defp toy_to_map(toy) do
  %{
    "name" => toy.name,
    "color" => toy.color,
    "age" => toy.age,
    "id" => toy.id
  }
end

I’ve extracted toy_to_map from broadcast_change.

Note, this is not a change event, but it available in the “ok” handler for join. So we can add a call to Toy.show like this:

channel.join()
  .receive("ok", toy => {
    console.log("Joined topic", topic);
    Toy.show(ul, toy);
  })
  .receive("error", resp => {
    console.log("Unable to join topic", topic)
  });

And with this the model state is shown when we first enter the page and it is updated live whenever and edit occurs.

Next Steps

In this post we showed how to publish models and model updates over a Phoenix channel. This allows the client to update its page in near realtime in response to changes in the server. In building this we learned about the Endpoint.broadcast function.

Next week I want to continue this project by exploring how to decouple the controller and the channel. Perhaps I can use GenEvent to notify the channel of changes rather than having the controller call the channel directly.