In our last article, we transplanted the front-end of a simple Meteor example application into a Phoenix project and wired up a Blaze template to use Phoenix Channels rather than DDP.
Today we’ll be finishing our Franken-stack by replacing the hard-coded data we’re sending down to our clients with data persisted in a database. We’ll also implement the “Add Points” functionality using Channel events.
Let’s get to it!
Creating A Player Model
Before we start pulling data from a database, we need to lay some groundwork. We’ll be using Ecto to create a model of our player, and creating some seed data to initially populate our database.
In our Phoenix project directory, we’ll use mix
to generate a new model for us:
mix phoenix.gen.model Player players name:string score:integer
The phoenix.gen.modal
mix task will create both a PhoenixLeaderboard.Player
model, and a migration file for us. The migration file will create our players
table in PostgreSQL (Phoenix’s default database) when we run this command:
mix ecto.create
The out-of-the-box PhoenixLeaderboard.Player
(web/models/player.ex
) model is very close to what we want. It defines name
as a string
, score
as an integer
and a set of created/updated at timestamps.
The only change we need to make here is to specify how we want each Player
to be serialized into JSON. We can do this by deriving the Poison.Encoder
implementation:
defmodule PhoenixLeaderboard.Player do
use PhoenixLeaderboard.Web, :model
@derive { Poison.Encoder, only: [:id, :name, :score] }
...
Seeding Our Database
Now that we have a working Player
model, let’s insert some seed data into our database.
By default, seeding a database in a Phoenix project is done by writing a script that manually inserts models into your repository. To insert all of our players, we could add the following to priv/repo/seeds.exs
:
alias PhoenixLeaderboard.Repo
alias PhoenixLeaderboard.Player
Repo.insert! %Player{ name: "Ada Lovelace", score: 5 }
Repo.insert! %Player{ name: "Grace Hopper", score: 10 }
Repo.insert! %Player{ name: "Marie Curie", score: 15 }
Repo.insert! %Player{ name: "Carl Friedrich Gauss", score: 20 }
Repo.insert! %Player{ name: "Nikola Tesla", score: 25 }
Repo.insert! %Player{ name: "Claude Shannon", score: 30 }
We can run this seed script with the following mix task:
mix run priv/repo/seeds.exs
If all went well, all six of our players should be stored in our database!
Publishing Players
Let’s revisit the join
function in our PhoenixLeaderboard.PlayersChannel
(web/channels/players_channel.ex
) module.
Last time, we simply returned a hard-coded list of cleaners whenever a client joined the "players"
channel. Instead, let’s return all of the players stored in the database.
To shorten references, we’ll start by aliasing PhoenixLeaderboard.Repo
and PhoenixLeaderboard.Player
, just like we did in our seed file:
defmodule PhoenixLeaderboard.PlayersChannel do
use Phoenix.Channel
alias PhoenixLeaderboard.Repo
alias PhoenixLeaderboard.Player
...
Now, refactoring our join
function to return all players is as simple as calling Repo.all
and passing in our Player
model:
def join("players", _message, socket) do
{:ok, Repo.all(Player), socket}
end
Looking back at our Leaderboard application, our player list should still be filled with our scientists.
Adding Points With Events
Now we get to the truly interesting part of this experiment.
In our original Meteor application, we updated each player’s score on the client and depended on DDP to propagate that change up to our server:
'click .inc': function () {
Players.update(Session.get("selectedPlayer"), {$inc: {score: 5}});
}
Since we’re moving away from DDP, we can no longer rely on Meteor to do this for us. We’ll need to manage this update process ourselves.
Our plan for handling these updates is to push an "add_points"
channel event up to the server whenever a user clicks on the .inc
button:
Template.instance().channel.push("add_points", {
id: Session.get("selectedPlayer")
});
In our PlayersChannel
, we can handle any incoming "add_points"
events using the handle_in
function:
def handle_in("add_points", %{"id" => id}, socket) do
player = Repo.get!(Player, id)
Player.changeset(player, %{score: player.score + 5})
|> Repo.update
|> handle_player_update(socket)
end
Out logic here is fairly straightforward: get the Player
with the given id
, increment his score
by 5
, and then update the database with our changes.
The handle_player_update
function handles the result of our Repo.update
. If the update was successful, we’ll broadcast the "add_points"
event down to all connected clients, passing the affected Player
as the event’s payload:
defp handle_player_update({:ok, player}, socket) do
broadcast! socket, "add_points", %{id: player.id, score: player.score}
{:noreply, socket}
end
defp handle_player_update({:error, changeset}, socket) do
{:reply, {:error, changeset}, socket}
end
The last piece of this puzzle is handling the "add_points"
events we receive on the client. Every time we receive an "add_points"
event from the server, we’ll want to update the provided Player
in our Players
Minimongo collection:
this.channel.on("add_points", (player) => {
Players.update(player.id, {
$set: {
score: player.score
}
});
});
And that’s it!
Now if we navigate back to our Leaderboard application and start adding points to players, we’ll see their score and position change in the interface. If we connect multiple clients, we’ll see these changes in real-time as they happen.
Final Thoughts
As fun as this was, we don’t recommend you tear your Meteor application in half like we did. This was an experiment and a learning experience, not a production ready migration path.
In the future, we may investigate more reasonable and production ready migrations routes from an application built with Meteor to a Elixir/Phoenix environment. Stay tuned!
Lastly, we realized while building this Franken-stack that Meteor’s DDP and Phoenix Channels are not one-to-one replacements for each other. Try imagining how you would implement a Meteor-style pub/sub system in Channels. It’s an interesting problem, and one we’re excited to tackle in future posts.
If you want to run the Leaderboard yourself, check out the full project on GitHub. Feel free to open an issue if you have any questions, comments, or suggestions!