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