dnlgrv

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.