Chat rooms app in Elixir in 15 minutes

This is the showcase post that touches a tiny part of what Elixir and the accompanying tooling can help you build in very little time.

The goal

In this post we are going to build a very simple chat app with multiple rooms. Anyone with the link will be able to connect and say something. We won't be storing the history of messages for the case of simplicity.

If you are lost, or just want to skip over some hoops, the source of the app we build here is available at Github.

Preparations

This walkthrough will require Elixir 1.0.2+ as a dependency for Phoenix Framework. Please install it before you start.

Get the latest Phoenix Framework.

$ git clone https://github.com/phoenixframework/phoenix.git && cd phoenix && git checkout v0.6.2 && mix do deps.get, compile

Create a new phoenix app. This creates a new app that is configured to use Phoenix Framework. You don't need the phoenix folder that was checked out of the repo in the previous step.

$ mix phoenix.new chatex ~/tmp/chatex

Compile and launch your app skeleton.

$ cd ~/tmp/chatex
$ mix do deps.get, compile
$ mix phoenix.start

You should see the root page at http://localhost:4000/ now.

Layouts and assets

When we created a Phoenix app, the mix task initialized directory structure for us:

  • config folder is for your config files
  • lib is for standalone library code
  • Static files go into the priv directory (much like public in Rails)
  • test folder has familiar to Rails developers structure for tests
  • web folder contains your web application code (much list app in Rails)

Throw in jquery-2.1.1.min.js and bootstrap.min.js (3.3.1) into priv/static/js and bootstrap.min.css into priv/static/css.

Update the layout web/templates/layout/application.html.eex to include our new assets and clean up a bit. Here's what I got:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <meta name="description" content="">
    <meta name="author" content="">

    <title>Chatex</title>
    <link rel="stylesheet" href="/css/bootstrap.min.css">
  </head>

  <body>
    <div class="container">
      <%= @inner %>
    </div>

    <script type="text/javascript" src="/js/jquery-2.1.1.min.js"></script>
    <script type="text/javascript" src="/js/bootstrap.min.js"></script>
  </body>
</html>

If you are familiar with Rails layouts, you should be at home with the notion of inserting generated pages inside the broader layout file. Here we have <%= @inner %> marking the place where the generated page content goes.

Routing

Open the web/router.ex file. What you see shouldn't be terribly hard to comprehend. You can read more on routes in officinal Routing guide. Let's just define three routes.

scope "/", Chatex do
  pipe_through :browser
  get "/",    RoomsController, :show
  get "/:id", RoomsController, :show
  post "/message/:room_id", MessagesController, :create, as: :new_message
end

Controllers

Let's add Chatex.RoomsController:

defmodule Chatex.RoomsController do
  use Phoenix.Controller

  @default_room "Lobby"

  @doc "shows a specific room with room_id specified"
  def show(conn, %{ "id" => room_id }) do
    conn |> render_room room_id
  end

  @doc "shows a default room -- Lobby"
  def show(conn, _params) do
    conn |> render_room @default_room
  end


  # renders the room with given ID
  defp render_room(conn, room_id) do
    conn |> render :show, room_id: room_id
  end

end

We add two show clauses -- one for the default room and one for the room with ID.

You can read more about controllers in the official controllers guide.

With that out of the way, let's move to views. We will get back to controllers when sending actual messages.

Views

Views are slightly different from what we've seen in Rails. They consist of two parts -- presenter module and templates. Read more on views in the official views guide and templates guide.

For now we'll create an empty view module web/views/rooms_view.ex:

defmodule Chatex.RoomsView do
  use Chatex.View
end

And then create the template for the show action:

<div class="row">
  <div class="col-sm-4">
    <h2>Rooms</h2>
    <ul id="rooms">
      <li><a href="<%= Chatex.Router.Helpers.lobby_path(:show) %>">Lobby</a></li>
      <li><a href="<%= Chatex.Router.Helpers.room_path(:show, "Help") %>">Help</a></li>
    </ul>
  </div>
  <div class="col-sm-8">
    <h1><%= @room_id %></h1>
    <div id="chatbox"></div>

    <div id="chatline">
      <form class="form" action="<%= Chatex.Router.Helpers.new_message_path(:create, @room_id) %>">
        <input type="hidden" name="room_id" id="room_id" value="<%= @room_id %>">
        <div class="form-group">
          <div class="row">
            <div class="col-sm-4">
              <input type="text" name="user" id="user" placholder="User name" class="form-control"/>
            </div>
            <div class="col-sm-7">
              <input type="text" name="body" id="body" placholder="Message" class="form-control"/>
            </div>
            <div class="col-sm-1">
              <input type="submit" class="btn btn-default" value="Say" />
            </div>
          </div>
        </div>
      </form>
    </div>
  </div>
</div>

If you restart your app at this point, you should be able to change pages by clicking on the Lobby and Help in the sidebar.

Channels

We will need some interactivity in the app. Phoenix Framework comes with built-in WebSockets and PubSup support, which are super fun to work with.

First, we define the channel in the routes and mount it on the /ws path:

defmodule Chatex.Router do
  use Phoenix.Router
  use Phoenix.Router.Socket, mount: "/ws"

  channel "messages", Chatex.MessagesChannel

  # ...
end

Second, let's create the web/channel/messages_channel.ex that will be talking to the WebSockets clients:

defmodule Chatex.MessagesChannel do
  use Phoenix.Channel

  @doc "Called when the user connects to the room."
  def join(socket, _room_id, _message) do
    { :ok, socket }
  end

  @doc "Called when the user disconnects."
  def leave(socket, _message) do
    socket
  end

end

This is a bare bones channel that does literally nothing. It accepts connections to any rooms and does nothing when a user leaves.

On the client side, we need to create a JavaScript file priv/static/js/room.js and include it in the layout file along with /js/phoenix.js after jQuery and Bootstrap.

$(function() {
  // new message form submission handler
  var form = $("form");
  form.on("submit", function(e) {
    e.preventDefault();

    var url  = form.attr('action'),
        body = $("#body"),
        user = $("#user");

    $.post(url, { message: { user: user.val(), body: body.val() } }, function(data) {
      body.val('').focus();
    });
  });

  // connection to the channel
  var socket = new Phoenix.Socket("/ws");
  socket.join("messages", $("#room_id").val(), {}, function(channel) {
    channel.on("new:message", function(data) {
      var div = $("<div class='alert alert-info'></div>").text(data.user + " said: " + data.body);
      $("#chatbox").append(div);
    });
  });
});

Here we listen for form submissions and then sending message[user] and message[body] to the MessagesController#create. We also connect to the room topic via WebSocket and listen for new messages to display.

Now let's create MessagesController that will broadcast our message to the members of the same room.

defmodule Chatex.MessagesController do
  use Phoenix.Controller

  plug :action

  @doc "Broadcasts a message to the members of the #room_id."
  def create(conn, %{ "room_id" => room_id, "message" => %{ "user" => user, "body" => body } }) do
    Phoenix.Channel.broadcast "messages", room_id, "new:message", %{ user: user, body: body }
    conn |> text "ok" 
  end

end

The controller handles the POST request to create a message in a certain room by broadcasting it to all users in the "messages" channel listening to topic with the ID of the room.

Try restarting the app, opening several browsers windows (some in the same room, some in different) and test how messages are delivered.

Testing

In this demo app I completely ignored the testing aspect of development for the sake of time and space. Testing deserves a separate series of posts. It's vast territory.

Exercises to consider

  • Try using phoenix_haml instead of Eex
  • Think about how could you list the history of 10 last entered rooms in the sidebar instead of static links
  • Do you think you can create a separate channel that will send :new_room messages whenever someone enters a new room and then update the sidebar list of rooms dynamically?
  • How about treating rooms as hashtags (#lobby), and broadcast messages to listeners of all hashtags found in a message ("#lobby and #help users, hello!")?
  • Can you sanitize room id so that it allows only alphanumerics and dashes?

Conclusion

We haven't even scratched the surface of what's possible in Elixir and Phoenix Framework, but I hope you've got an idea of how simple it is to build interactive web apps with them.

The general idea is that it's not a request-response infrastructure, like Rails. Your application gears are always spinning. You can have long running processes with progress reported, lengthy calculations, real-time games, dynamic interfaces and much more. All of this sits on the base of reliable multi-core-ready foundation with supervisors, processes, self-recovery and native tools for distribution, scaling and monitoring.

Have fun! Any comments or questions are welcome.

comments powered by Disqus