“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:
{: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 aHashDict
.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 theHashDict
of users.newusers = users |> HashDict.delete nick
: As you might expect, when we get adisconnect
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
Chat Server: ex_messenger
Client: ex_messenger_client