December 13, 2013 by by name: "Drew Kerrigan"

“Elixir looks cool, what does distributed elixir look like?”

One common question that I’ve received when talking to people about Elixir is "what does distributed elixir look like?" This post uses code I wrote for Erlangdc R13B this year. It’s essentially the same content as the slides for my talk ( kerrigan.io/erlangdc ) if you prefer that format.

Getting Started

Create an OTP application

When you create a default application with mix (as of 0.11.*) it will create an OTP application along with a supervisor skeleton for you.

mix new ex_messenger

Application: lib/ex_messenger.ex

Supervisor: lib/ex_messenger/supervisor.ex

Here’s what lib/ex_messenger/supervisor.ex looks like without modifications:

def init([]) do
children = [
    # Define workers and child supervisors to be supervised
    # worker(ExMessenger.Worker, [])
]

# See http://elixir-lang.org/docs/stable/   Supervisor.Behaviour.html
# for other strategies and supported options
supervise(children, strategy: :one_for_one)
end

As you can see, the application has a supervisor, but doesn’t have anything to supervise yet, lets go ahead and add a reference to a worker we’ll call ExMessenger.Server

def init([]) do
  # Define workers and child supervisors to be supervised
  children = [ worker(ExMessenger.Server, [[]]) ]
  supervise children, strategy: :one_for_one
end

Create a (gen) server

Create a new file at lib/ex_messenger/server.ex

Respond to calls: connect and disconnect
defmodule ExMessenger.Server do
  use GenServer.Behaviour

  def start_link([]) do
      :gen_server.start_link({ :local, :message_server }, __MODULE__, [], [])
  end

  def init([]) do
      { :ok, HashDict.new() }
  end

  def handle_call({:connect, nick}, {pid, _}, users) do
      newusers = users |> HashDict.put(nick, node(pid))
      userlist = newusers |> HashDict.keys |> Enum.join ":"
      {:reply, {:ok, userlist}, newusers}
  end

  def handle_call({:disconnect, nick}, {pid, _}, users) do
      newusers = users |> HashDict.delete nick
      {:reply, :ok, newusers}
  end

  def handle_call(_, _, users), do: {:reply, :error, users}

  def handle_cast(_, users), do: {:noreply, users}
end

I won’t go into the details of what a gen_server or behaviour is because it’s out of the scope for this post, but feel free to dig into Erlang OTP and gen_servers here: erlang.org/doc/design_principles/gen_server_concepts.html

In the code above, there are 3 things to note:

  1. {:ok, HashDict.new()}: This is the initial state of our chat server. We need our server to keep track of all of the connected users, and we can pretty easily do so with a HashDict.
  2. newusers = users |> HashDict.put(nick, node(pid)): When we receive a call from a client to connect to the chat server, we’ll add them to the HashDict of users.
  3. newusers = users |> HashDict.delete nick: As you might expect, when we get a disconnect call, that client is removed from the list.

One more thing: The key for the HashDict records is the client’s nickname, and the values are the erlang node names. An example nodename might be "client@192.168.1.10"

Broadcast a message to all clients

Here is what a broadcast function might look like

defp broadcast(users, from, msg) do
  Enum.each(users, fn { _, node } -> :gen_server.cast({:message_handler, node}, {:message, from, msg}) end)
end

Here we are applying an operation to each member of the users HashDict. We’re sending a gen_server call to a registered server called :message_handler that doesn’t exist yet, but it will once we create the client.

Respond to calls: say and private_message
def handle_cast({:say, nick, msg}, users) do
  ears = HashDict.delete(users, nick)
  broadcast(ears, nick, "#{msg}")

  {:noreply, users}
end

def handle_cast({:private_message, nick, receiver, msg}, users) do
  case users |> HashDict.get receiver do
      nil -> :ok
      r ->
          :gen_server.cast({:message_handler, r}, {:message, nick, "(#{msg})"})
  end
  {:noreply, users}
end

We are defining the :say cast to just be a broadcast of msg from nick to all connected users.

The :private_message cast is the important bit from the broadcast function above, but with only a single target.

The reason for choosing handle_cast over handle_call for these is that they do not modify the state of the server, and therefore shouldn’t require responses to the client that sent them.

Chat Server Code

The code thus far can be found at github.com/drewkerrigan/ex_messenger. Please check it out and try to run it, the above code is simplified and missing a few important validation pieces like making sure two clients with the same name cannot connect.

Running the Server

For local testing:

iex --sname server --cookie chocolate-chip -S mix

For external testing (find your LAN ip with ifconfig):

iex --name server@ --cookie chocolate-chip -S mix

Full Code: ex_messenger

(CLI) Client

Create a new mix project with the —bare flag

mix new ex_messenger_client --bare

This generates a simple application with lib/ex_messenger_client.ex

defmodule ExMessengerClient do
end

Process Server Calls

We will get to the CLI application soon, but first lets respond to calls from the server

lib/ex_messenger_client.ex

defmodule ExMessengerClient do
end

defmodule ExMessengerClient.MessageHandler do
  use GenServer.Behaviour

  def start_link(server) do
      :gen_server.start_link({ :local, :message_handler }, __MODULE__, server, [])
  end

  def init(server) do
      { :ok, server }
  end

  def handle_call(_, _, server), do: {:reply, :error, server}

  def handle_cast({:message, nick, msg}, server) do
      msg = String.rstrip(msg)
      IO.puts "\n#{server}> #{nick}: #{msg}"
      IO.write "#{Node.self()}> "
      {:noreply, server}
  end

  def handle_cast(_, server), do: {:noreply, server}
end

CLI Application

defmodule ExMessengerClient do

  def main(args) do
      args |> parse_args |> process
  end

  def parse_args(args) do
      switches =
          [
          help: :boolean,
          server: :string,
          nick: :string
          ]

      aliases =
          [
          h: :help,
          s: :server,
          n: :nick
          ]

      options = OptionParser.parse(args, switches: switches, aliases: aliases)

      case options do
          { [ help: true], _, _}            -> :help
          { [ server: server], _, _}        -> [server]
          { [ server: server, nick: nick], _, _} -> [server, nick]
          _                                 -> []
      end
  end

  def process(:help) do
      IO.puts """
          Usage:
          ./ex_messenger_client -s server_name [-n nickname]

          Options:
          -s, --server = fully qualified server name
          -n, --nick   = nickname (optional, you will be promted if not specified)

          Example:
          ./ex_messenger_client -s server@192.168.1.1 -n dr00

          Options:
          -h, [--help]      # Show this help message and quit.
      """
      System.halt(0)
  end

  def process([]) do
      process([nil, nil])
  end

  def process([server]) do
      process([server, nil])
  end

  def process([server, nick]) do
      server = case server do
          nil ->
          IO.write "Server Name: "
          IO.read :line
          n -> n
      end

      server = list_to_atom(bitstring_to_list(String.rstrip(server)))

      IO.puts "Connecting to #{server} from #{Node.self()}..."
      Node.set_cookie(Node.self(), :"chocolate-chip")
      case Node.connect(server) do
          true -> :ok
          reason ->
              IO.puts "Could not connect to server, reason: #{reason}"
              System.halt(0)
      end

      ExMessengerClient.MessageHandler.start_link(server)

      IO.puts "Connected"

      nick = case nick do
          nil ->
          IO.write "Nickname: "
          IO.read :line
          n -> n
      end

      nick = String.rstrip(nick)

      case :gen_server.call({:message_server, server}, {:connect, nick}) do
          {:ok, users} ->
              IO.puts "**Joined the chatroom**"
              IO.puts "**Users in room: #{users}**"
              IO.puts "**Type /help for options**"
          reason ->
              IO.puts "Could not join chatroom, reason: #{reason}"
              System.halt(0)
      end

      # Start gen_server to handle input / output from server
      input_loop([server, nick])
  end

  def input_loop([server, nick]) do
      IO.write "#{Node.self()}> "
      command = IO.read :line
      handle_command(command, [server, nick])

      input_loop([server, nick])
  end

  def handle_command(command, [server, nick]) do
      command = String.rstrip(command)
      case command do
          "/help" ->
              IO.puts """
                  Avaliable commands:
                  /leave
                  /join
                  /pm  
                  or just type a message to send
              """
          "/leave" ->
              :gen_server.call({:message_server, server}, {:disconnect, nick})
              IO.puts "You have exited the chatroom, you can rejoin with /join or quit with -c a"
          "/join" ->
              IO.inspect :gen_server.call({:message_server, server}, {:connect, nick})
              IO.puts "Joined the chatroom"
          "" ->
              :ok
          nil ->
              :ok
          message ->
              if String.contains? message, "/pm" do
                  [to|message] = String.split(String.slice(message, 4..-1))
                  message = String.lstrip(List.foldl(message, "", fn(x, acc) -> "#{acc} #{x}" end))
                  :gen_server.cast({:message_server, server}, {:private_message, nick, to, message})
              else
                  :gen_server.cast({:message_server, server}, {:say, nick, message})
          end
      end
  end
end

Mix.exs changes

We also need to make a change to mix.exs for the CLI application to work properly

mix.exs

def project do
  node = System.get_env("node")
  node = case node do
      nil -> "client"
      n -> n
  end

  mode = System.get_env("mode")
  mode = case mode do
      nil -> "-sname"
      "external" -> "-name"
  end

  [ app: :ex_messenger_client,
  version: "0.0.1",
  deps: deps,
  escript_emu_args: "%%!#{mode} #{node}\n"]
end
...

Makefile

In order to compile the escript for multiple clients quickly, I also made a Makefile to make things faster

Makefile

.PHONY: all

all:
mix

run:
rm -f ex_messenger_client
mix escriptize
./ex_messenger_client

Running the Client

For local testing:

node=client make run

For external testing (find your LAN ip with ifconfig):

mode=external node=client@ make run

Note: Mode and Node are 2 different env variables

Full Code: ex_messenger_client

Thanks for Reading!

Resources