As part of a toy chat server I've been working on, I needed to implement a TCP
server in Elixir. This is something that's so commonly done, there are tons of
resources online showing the usage of :gen_tcp
. However, I wanted to showcase
a neat way of integrating this into an Elixir project, without the use of a
library like Ranch.
Firstly, we define our application supervisor's children
as follows:
children = [
{MyApp.Server, get_port},
{DynamicSupervisor, strategy: :one_for_one, name: MyApp.ClientSupervisor},
]
Supervisor.start_link(children, strategy: :one_for_one)
The Server
component will be responsible for accepting connections from new
clients, spawning a new process to handle that connection, and then handing
over control before listening for new connections again. ClientSupervisor
is
a DynamicSupervisor
which monitors the client processes.
Here's what the Server
code looks like:
defmodule MyApp.Server do
alias MyApp.Client
def start_link(port) do
Task.start_link(__MODULE__, :accept, [port])
end
def accept(port) do
{:ok, listen_socket} = :gen_tcp.listen(
port,
[:binary, packet: :line, active: :once, reuseaddr: true]
)
loop_acceptor(listen_socket)
end
defp loop_acceptor(listen_socket) do
{:ok, socket} = :gen_tcp.accept(listen_socket)
{:ok, pid} = DynamicSupervisor.start_child(
MyApp.ClientSupervisor,
{MyApp.Client, socket}
)
:gen_tcp.controlling_process(socket, pid)
loop_acceptor(listen_socket)
end
end
Note that this is using
gen_tcp
'sactive: true
mode, which isn't documented particularly well. It causes data to be delivered as messages to the controlling process, rather than via the return value of a call to:gen_tcp.recv
.
Now, we can define the Client
, which is a standard
GenServer
, as follows:
defmodule MyApp.Client do
use GenServer
def start_link(socket, opts \\ []) do
GenServer.start_link(__MODULE__, socket, opts)
end
def init(socket) do
%{ socket: socket }
end
def send(pid, data) do
GenServer.cast(pid, {:send, data})
end
# TCP callbacks
def handle_info({:tcp, socket, data}, state) do
state = process_data(data, state)
{:noreply, state}
end
def handle_info({:tcp_closed, _socket}, state) do
Process.exit(self(), :normal)
end
# GenServer callbacks
def handle_cast({:send, data}, %{socket: socket} = state) do
:gen_tcp.send(socket, data)
{:noreply, state}
end
end
The client is initialized with a socket
, which has been passed from the
server. It's stored in the state of the process, alongside any other state we
need to manage for this connection.
I have shown an example server callback above, which can be used to send data
to the client from any other process via Client.send(pid, data)
.
You can see a fully working example in the chat server these examples were taken from here.