Extending Phoenix Chat App with ETS-based Logs

By Mitchell Henke on July 24th 2015

A good amount of the documentation around Phoenix channels is based on a simple chat application from Chris McCord. The app allows users to choose a username, join a chatroom, and send/receive messages in real time using web sockets through Phoenix channels.

I have been delving deeper into Elixir/Erlang, and Erlang Term Storage (also known as ETS) is a core Erlang feature that I'd been wanting to try out. ETS is basically an in-memory database, somewhat similar to Memcached or Redis. This also means server restarts will drop everything in it. It can be avoided by taking advantage of Erlang's hot code reloading when deploying, or using DETS, which is very similar to ETS but reads and writes to disk, making it much slower.

Since we're in Elixir, I thought it would make sense to wrap the functionality of storing the chat logs in a GenServer named ChatLog. We'll override start_link to give it those options, but could also allow users of ChatLog to pass in whichever options work best for them here as well:

def start_link(opts \\ []) do
  {:ok, _pid} = GenServer.start_link(ChatLog, [
    {:ets_table_name, :chat_log_table},
    {:log_limit, 100}
  ], opts)

def init(args) do
  [{:ets_table_name, ets_table_name}, {:log_limit, log_limit}] = args

  # creates a new table in ETS.  The options passed means:
  # :named_table - we can refer to the table by name
  # :set - the store will be a set table, with one object per key
  # :private - only the owner process can read/write
  :ets.new(ets_table_name, [:named_table, :set, :private])

  {:ok, %{log_limit: log_limit, ets_table_name: ets_table_name}}

The ChatLog accepts two options, one for the ETS table name, and another to limit the number of messages stored. The ChatLog will only have two public helper functions, one to add a message to the log, and another to fetch. They will only make their respective GenServer call:

def log_message(channel, message) do
  GenServer.call(:chat_log, {channel, message})

def get_logs(room) do
  GenServer.call(:chat_log, {room})

The sample chat application this we're building off of does not initially support multiple rooms, but we may eventually, so the ChatLog takes a room argument to specify exactly which room the message was sent in. The next step will actually be interacting with ETS in the GenServer calls to store and fetch messages. Fetching the messages is relatively easy, and it'll be done directly in the call method, but the more complex insert is in its own function.

def handle_call({room, message}, _from, state) do
  %{ets_table_name: ets_table_name} = state
  result = log_message(room, message, ets_table_name)
  {:reply, result, state}

def handle_call({room}, _from, state) do
  %{ets_table_name: ets_table_name} = state
  result = :ets.lookup(ets_table_name, room)
  {:reply, result, state}

defp log_message(channel, message, ets_table_name) do
  case :ets.member(ets_table_name, channel) do
    false ->
      true = :ets.insert(ets_table_name, {channel, [message]})
      {:ok, message}
    true ->
      [{_channel, messages}]= :ets.lookup(ets_table_name, channel)
      :ets.insert(ets_table_name, {channel, [message | messages]})
      {:ok, message}

The insert checks whether the ETS table already exists, because if it doesn't exist, we can't use the head/tail syntax to insert an object into the list. In the case that the table doesn't it exist, we simply insert a list with the first message as the single element. The fetch method does a straightforward lookup on the key.

The work up to this point should cover the internals necessary for the ChatLog, and all that's left is to start using it in the chat application. Since we added a new GenServer, we'll add it to our base app so it gets started when our application starts:

children = [
  # Start the endpoint when the application starts
  supervisor(ChannelChats.Endpoint, []),
  # Start the Ecto repository
  worker(ChannelChats.Repo, []),
  worker(ChatLog, [[name: :chat_log]]),
  # Here you could define other workers and supervisors as children
  # worker(ChannelChats.Worker, [arg1, arg2, arg3]),

We'll also add the chat logging into the channel that receives new messages so they get logged. We'll store them the same way we send them through the web socket and the front-end of the application can treat them the same:

def handle_in("new:msg", msg, socket) do
  broadcast! socket, "new:msg", %{user: msg["user"], body: msg["body"]}
  ChatLog.log_message(socket.topic, %{user: msg["user"], body: msg["body"]})
  {:reply, {:ok, %{msg: msg["body"]}}, assign(socket, :user, msg["user"])}

We can display old messages for people just joining a chat by fetching and then rendering our old messages in the template. :ets.lookup will give us an empty list if nothing exists yet, which works perfectly, but the logs will have the most recent message first.

defmodule ChannelChats.PageController do
  use ChannelChats.Web, :controller

  def index(conn, params) do
    room_name = "lobby"
    room = "rooms:#{room_name}"
    messages = ChatLog.get_logs(room)
    |> parse_logs
    render conn, "index.html", messages: messages

  def parse_logs([]), do: []
  def parse_logs([{_room, messages}]), do: Enum.reverse(messages)

Once the messages are being passed to the view, we iterate over and render HTML for the list of messages:

<div id="messages" class="container">
  <%= for message <- @messages do %>
    <p><a href='#'><%= message[:user] %></a>&nbsp; <%= message[:body]%></p>
  <% end %>

<div id="footer">
  <div class="container">
    <div class="row">
      <div class="col-sm-4">
        <div class="input-group">
          <span class="input-group-addon">@</span>
          <input id="username" type="text" class="form-control" placeholder="username">
        </div><!-- /input-group -->
      </div><!-- /.col-lg-6 -->
      <div class="col-sm-8">
        <input id="message-input" class="form-control" />
      </div><!-- /.col-lg-6 -->
    </div><!-- /.row -->

And now the chat application will display old messages for new users and between refreshes!

I've posted the source on GitHub for those interested, and please reach out on Twitter if you have any questions or comments :)


is a software engineering agency. We build applications and teach businesses how to use new technologies like machine learning and virtual reality. We write about software, business, and business software culture.