Looking at TCP and HTTP, using Elixir (part 1)
As web developers, we interact with HTTP every day. But it’s usually in the form of some kind of abstraction (a library or some kind of framework). Typically we’re defining routes, and what happens when that route gets hit by the browser. But what is actually listening to that browser request, and passing it to our router? That’s just what we’re going to take a look at.
TCP
At the core we have TCP (the Transmission Control Protocol). I don’t know all the technical details about TCP, but for our purposes you can think of TCP as the following: a program that listens for and responds to messages, and can be accessed via the Internet. TCP must listen on a particular port, which can be almost any number. For example the default port for HTTP servers is 80 (or 443 for HTTPS), which you have probably come across.
Exploring TCP
So let’s have some fun with TCP. As I said I’m going to be using Elixir to demonstrate these examples, as I find it a fun language and I want to improve with it.
What we’re going to make is an Echo server. If you don’t know what an echo server is, this is a quick summary of what we’ll be doing:
Create a network service that sits on TCP port 12321, which accepts connections on that port, and which echoes complete lines (using a carriage-return/line-feed sequence as line separator) back to clients.
First, we’ll create a new project using mix:
$ mix new echo
$ cd echo
We’ll start by writing a test, which will try to connect to a TCP server, send a message to it, and see whether we get the same message back (the echo).
# test/echo_test.exs
defmodule EchoTest do
use ExUnit.Case
doctest Echo
setup do
{:ok, socket} = :gen_tcp.connect(
'localhost',
12321,
[:binary, packet: :line, active: false]
)
{:ok, [socket: socket]}
end
test "hearing an echo", %{socket: socket} do
:ok = :gen_tcp.send(socket, "Hello, world!\n")
case :gen_tcp.recv(socket, 0) do
{:ok, response} ->
assert response == "Hello, world!\n"
{:error, reason} ->
flunk "Did not receive response: #{reason}"
end
end
end
If you run mix test you should now have a failing test:
$ mix test
Compiled lib/echo.ex
Compiled lib/echo/server.ex
Generated echo app
?
1) EchoTest: failure on setup_all callback, tests invalidated
** (MatchError) no match of right hand side value: {:error, :econnrefused}
stacktrace:
test/echo_test.exs:6: EchoTest.__ex_unit_setup_all_0/1
test/echo_test.exs:1: EchoTest.__ex_unit__/2
Finished in 0.1 seconds (0.1s on load, 0.01s on tests)
1 test, 1 failure, 1 invalid
Randomized with seed 199908
Obviously we haven’t even set up a TCP server yet, never mind the implementation, so the test fails to even connect to a TCP server. We’ll start by getting a TCP server up and running.
Let’s convert Echo to an application and set up a supervisor with a single
worker. This will let us do the work for the TCP server inside the Echo.Server
module.
# lib/echo.ex
defmodule Echo do
use Application
def start(_type, _args) do
import Supervisor.Spec
children = [
worker(Task, [Echo.Server, :start, [12321]])
]
opts = [strategy: :one_for_one, name: Echo.Supervisor]
Supervisor.start_link(children, opts)
end
end
And we’ll edit mix.exs
to make our application run automatically:
# mix.exs
# ...
def application do
[applications: [:logger],
mod: {Echo, []}]
end
# ...
So all we need to do now is implement our TCP server, in the Echo.Server
module. The key functions we need are :gen_tcp.listen
, :gen_tcp.accept
, and
:gen_tcp.send
. These will let us start a server, accept a client connection,
and send a message to a client (we’ve already seen :gen_tcp.send
from the
test). This is the initial implementation that I came up with:
defmodule Echo.Server do
@options [:binary, packet: :line, active: false, reuseaddr: true]
def start(port) do
{:ok, server} = :gen_tcp.listen(port, @options)
accept_client(server)
end
defp accept_client(server) do
{:ok, client} = :gen_tcp.accept(server)
read_message(client)
end
defp read_message(client) do
case :gen_tcp.recv(client, 0) do
{:ok, message} ->
message |> send_message(client)
{:error, _} ->
:gen_tcp.close(client)
end
end
defp send_message(message, client) do
:gen_tcp.send(client, message)
end
end
If you look at read_message and send_message, you can see that whatever message comes from read_message, we pass to send_message. That makes us “echo” the message back to the client.
Overall this is a fairly terrible TCP server, as it only handles a single client, and a single message, before it shuts down. We can make a simple change to handle multiple messages:
defp read_message(client) do
case :gen_tcp.recv(client, 0) do
{:ok, message} ->
message |> send_message(client)
read_message(client) # Call ourself again
{:error, _} ->
:gen_tcp.close(client)
end
end
If you’d like to give this a try outside of a test environment, you can run the server at your terminal by doing:
$ mix run --no-halt
Then you can telnet to your server and start hearing your echo:
$ telnet localhost 12321
Trying ::1...
Trying 127.0.0.1...
Connected to localhost.
Escape character is '^]'.
hello
hello
world
world
End of part 1
This was a quick intro to TCP and how you can have some fun with it. The Elixir website also has an amazing guide on an echo server, and how you can take it further (such as supporting multiple clients), so if you want to see some more you should check it out.
In part 2 we’ll be extending our TCP server to handle HTTP requests.