Building a web framework from scratch in Elixir
Elixir is a fantastic new functional programming language that targets the Erlang VM, designed to build fault-tolerant distributed systems. There are numerous exciting use-cases, but one that always gets a lot of attention is building web applications. Elixir already has a Rails-like framework called Phoenix, but today, we’ll use a much more simple library, called Plug, to write a Sinatra-like web framework from scratch.
Why you should care
Phoenix is a great framework, and actually uses Plug under the hood. It works well with other Plug-compatible libraries, but tends to put these features behind macros, obfuscating the direct calls to Plug functions. I think that understanding the way the underlying Plug library works is an excellent way to learn both Elixir and Phoenix, both because it helps you understand what Phoenix’s macros are doing under the surface, and also because writing performant code is incredibly simple and easy with just Plug, Elixir, and Erlang’s pattern-matching. We could just browse through Phoenix’s macro definitions, but those are difficult to understand, since there’s a lot of extra code to make the DSLs work properly. Instead, we’ll write straightforward code ourselves, using Plug directly to build our own framework from scratch! Who knows, maybe you’ll even find you prefer the simple router that we build.
Following along
If you are already familiar with the basics of Elixir’s syntax, that’s great, though not required. I’ll do my best to explain the trickier parts in more depth in case you aren’t, but if you get stuck, try taking a look at the Elixir Crash Course. You don’t need to know anything about Phoenix, although I’m assuming you’ve programmed a web app before in some language or framework, and won’t explain simple concepts like headers and query strings.
To follow along with this guide, you’ll need Elixir installed, and you’ll also need to create a new project with mix new helloplug
. Then, add Cowboy, Plug, Ecto, and Sqlite.Ecto to your project’s mix.exs
file by adding the following lines:
def deps do
[{:cowboy, "~> 1.0.0"},
{:plug, "~> 1.0"},
{:sqlite_ecto, "~> 1.0.0"},
{:ecto, "~> 1.0"}]
end
def application do
[applications: [:logger, :sqlite_ecto, :ecto, :cowboy, :plug]]
end
Don’t forget to run mix deps.get
when you’re done! Once the Plug is set up properly, you can write any code in the lib/helloplug.ex
file, although if you’re comfortable with it, Elixir’s standard directory structure1 is cleaner.
What is a Plug?
A Plug is a module that responds to web requests. To create one, we just need two functions, init/1
2 and call/2
.
defmodule Helloplug do
def init(default_opts) do
IO.puts "starting up Helloplug..."
default_opts
end
def call(conn, _opts) do
IO.puts "saying hello!"
Plug.Conn.send_resp(conn, 200, "Hello, world!")
end
end
init/1
is called once when the server is started, and call/2
is called every time a new request comes in. There are two arguments to call/2
.
conn
: This is aPlug.Conn
, the connection with the client. It contains information about the request. We also use it to send responses.opts
: This is whatever the output of ourinit/1
function was. It doesn’t change from request to request.
Any options passed to the module are given to init/1
. We’ll take a look at that later, but for now, just know that whatever init/1
returns gets passed to every subsequent call/2
.
Finally, let’s take a look at send_resp
. This is a function that accepts three arguments: the connection, the HTTP status code to send, and the body of the reply to send. You’ll note that this is the last line of call/2
, and this is intentional. In Elixir, the last line of a function is implicitly returned. send_resp
, as well as the rest of the Plug.Conn
functions, returns a mutated copy of conn
, and it’s important that call/2
returns this mutated conn
so that the outside function calling it knows what has changed.
We can connect Helloplug
to cowboy, the web server, by running iex -S mix
in the project directory, and typing {:ok, _} = Plug.Adapters.Cowboy.http Helloplug, []
into the resulting Elixir REPL.3 If you browse to http://localhost:4000
a few times, you should see “Hello, world!” in your browser, and this in the terminal:
starting up Helloplug...
saying hello!
saying hello!
saying hello!
The call
function is run every time we visit the web page. We can also use the conn
object to set headers.
def call(conn, _opts) do
IO.puts "saying hello!"
conn |> Plug.Conn.put_resp_header("Server", "Plug") |> Plug.Conn.send_resp(200, "Hello, world!")
end
In case you haven’t seen the |>
operator before, A() |> B() |> C()
is equivalent to C(B(A))
. We could rewrite the line above if we wanted a less idomatic and more verbose version:
conn2 = Plug.Conn.put_resp_header(conn, "Server", "Plug")
Plug.Conn.send_resp(conn2, 200, "Hello, world!")
If there are arguments passed to any of the functions, the output of the previous function is inserted before the specified arguments. So conn |> Plug.Conn.put_resp_header("Server", "Plug")
is equivalent to Plug.Conn.put_resp_header(conn, "Server", "Plug")
. Also, remember that the last statement of a function in Elixir is implicitly returned.
put_resp_header
and send_resp
are two examples of functions that manipulate connections. If you’ve done some web programming before, you’ll likely recognize what they do—they add a header to the response Server: Plug
and then send the response “Hello, world!” with status code 200 OK
. Neither of these functions can modify conn
directly, since all variables and values in Elixir are immutable. That’s why we can’t have this:
# This is broken!
Plug.Conn.put_resp_header(conn, "Server", "Plug")
Plug.Conn.send_resp(conn, 200, "Hello, world!")
put_resp_header
doesn’t touch the conn
passed to it, but it does return a duplicated, modified version of the conn
. We need the mutated conn
returned from put_resp_header
to go into send_resp
, which is why we chain the output from one function into the first argument of the next with |>
in the first example. With the broken code, send_resp
is receiving the original conn
with no Server
header, which means that no Server
header would be sent to the client.
There are a number of other Plug.Conn
functions for manipulating connection details. Check out the Plug.Conn documentation for a full list.
The connection object is not just for sending responses. It contains the details of the request, too! We can use Elixir’s powerful pattern matching to match based on conn
’s path_info
(the path requested, split on /
into an array) to call different functions for different pages. We can also match on method
so that a POST
request calls a different function than a GET
request.
def call(conn, _opts) do
route(conn.method, conn.path_info, conn)
end
def route("GET", ["hello"], conn) do
# this route is for /hello
conn |> Plug.Conn.send_resp(200, "Hello, world!")
end
def route("GET", ["users", user_id], conn) do
# this route is for /users/<user_id>
conn |> Plug.Conn.send_resp(200, "You requested user #{user_id}")
end
def route(_method, _path, conn) do
# this route is called if no other routes match
conn |> Plug.Conn.send_resp(404, "Couldn't find that page, sorry!")
end
The second route shows off my favorite part of using this syntax—we can extract a user_id
variable from the URL entirely using Elixir’s pattern matching.
Writing a macro
We may want to have separate routers for different parts of our application, all using this route
syntax. Rather than retyping the same identical call
in every router, we can move it into a macro, and then just call the macro in each router. Macros are evaluated at compile time, so the Erlang VM will receive the same code, and we get to DRY up our code. We can also reuse this macro in future projects.
defmodule Router do
defmacro __using__(_opts) do
quote do
def init(options) do
options
end
def call(conn, _opts) do
route(conn.method, conn.path_info, conn)
end
end
end
end
defmodule Helloplug do
use Router
def route("GET", ["users", user_id], conn) do
conn |> Plug.Conn.send_resp(200, "You requested user #{user_id}")
end
def route(_method, _path, conn) do
conn |> Plug.Conn.send_resp(404, "Couldn't find that page, sorry!")
end
end
Remember that call
needs to return the modified connection object. When Plug runs call/2
, this is how it knows what has changed about the connection. However, it also makes it composable. We can call plugs within other plugs. For instance, let’s say we want to route all calls to user endpoints to a different Plug:
defmodule UserRouter do
use Router
def route("GET", ["users", user_id], conn) do
# this route is for /users/<user_id>
conn |> Plug.Conn.send_resp(200, "You requested user #{user_id}")
end
def route("POST", ["users"], conn) do
# do some sort of database insertion here maybe
end
def route(_method, _path, conn) do
conn |> Plug.Conn.send_resp(404, "Couldn't find that user page, sorry!")
end
end
defmodule WebsiteRouter do
use Router
@user_router_options UserRouter.init([])
def route("GET", ["users" | path], conn) do
UserRouter.call(conn, @user_router_options)
end
def route(_method, _path, conn) do
conn |> Plug.Conn.send_resp(404, "Couldn't find that page, sorry!")
end
end
If you haven’t seen the syntax for module attributes used in @user_router_options UserRouter.init([])
before, when WebsiteRouter
is compiled, it stores the output from UserRouter.init([])
and inserts it anywhere you see @user_router_options
appear. It’s a compile-time constant for the module. We run it once to get the options hash. We don’t use the options hash in UserRouter, but we need to do this in case we’re using a plug that does.
Also, remember when I mentioned init/1
could sometimes be passed options from outside? This is where we could do that! For instance, (to use a somewhat contrived example) maybe we want to treat two different models, User
and Admin
almost identically. We could create one plug, and initialize it twice, passing a different model into the options each time. Our plug’s init/1
would return the options given to it, and our call/2
function would then be able to see via its second parameter whether it should serve User
objects or Admin
objects.
A Plug doesn’t need to be a router—we could use this technique to include Plugs that do authentication, DOS protection, logging, and more. For instance, if there was a third-party ApiLogPlug
module, we could add it as a dependency, and update our user route to use it:
@user_router_options UserRouter.init([])
@api_logger_options ApiLogPlug.init([])
def route("GET", ["users" | _path], conn)
conn |> ApiLogPlug.call(@api_logger_options) |> UserRouter.call(@user_router_options)
end
Basic templates
At some point, we’re probably going to want to serve more than just plain text with our framework. We could continue to write HTML strings manually, but that’s not practical for larger webapps. Fortunately, Elixir comes built-in with a handy templating language. We can use it pretty easily:
def route("GET", ["users", user_id], conn) do
page_contents = EEx.eval_file("templates/show_user.eex", [user_id: user_id])
conn |> Plug.Conn.put_resp_content_type("text/html") |> Plug.Conn.send_resp(200, page_contents)
end
The first argument to eval_file
is the filename of the template, and the second is a map of variables that we’ll be able to use inside the template. We’ll also need to create a template, which we can do in our project directory under templates/show_user.eex
. Elixir’s templating language is similar to many other templating languages, especially Ruby’s erb
.
<!DOCTYPE html>
<html>
<body>
<h1>User Information Page</h1>
<p>Looks like you've requested information for the user with id <%= user_id %>.</p>
<p>Also, 1 + 1 = <%= 1 + 1 %></p>
<%= if user_id == "0" do %>
<%# we should tell everybody that user 0 is really cool %>
User zero is really cool!
<% end %>
</body>
</html>
As you can see above, you can put any sort of Elixir statement in a <% %>
block and it will run. Adding a equal sign like <%= %>
will make it print the statement’s result to the page, and <%# %>
creates a comment that will not be sent to the client. Note how we have a =
before the if statement—everything that prints something to the page needs an =
. The if
statement returns the contents of the block inside of it, and we need the =
to make it print instead of discarding it.
If we visit /users/1092
, we should now see a rendered HTML page stating that we requested information on user 1092.
Precompiling templates
Our template code is a little inefficient, though, since every time the route is called, Elixir has to load the template file, parse the template file into an Elixir function, and then run the function. If we can just parse and compile the template once, and then run the function every time the route is called, we’ll save a lot of time, especially for larger, frequently-called templates.
require EEx # you have to require EEx before using its macros outside of functions
EEx.function_from_file :defp, :template_show_user, "templates/show_user.eex", [:user_id]
def route("GET", ["users", user_id], conn) do
page_contents = template_show_user(user_id)
conn |> Plug.Conn.put_resp_content_type("text/html") |> Plug.Conn.send_resp(200, page_contents)
end
Ecto models
Phoenix, the Rails-like framework we talked about earlier, has a database model library called Ecto.
Fortunately, Ecto is a standalone project, so we can use it too! Let’s update our /users/<user_id>
route to actually retrieve user data from a Sqlite database. In your config.exs file, add:
config :helloplug, Helloplug.Repo,
adapter: Sqlite.Ecto,
database: "hello_plug.sqlite3"
We’re also going to need to create a new module for our database’s “Repo”, a module that holds functions that query our database.
defmodule Helloplug.Repo do
use Ecto.Repo,
otp_app: :helloplug,
adapter: Sqlite.Ecto
end
We’ll also need a User
model.
defmodule User do
use Ecto.Model
schema "users" do
# id field is implicit
field :first_name, :string
field :last_name, :string
timestamps
end
end
The Ecto schema has way too many options to even start covering here. If you start using Ecto more, you should read the docs.
We’re also going to need this User
model to actually exist as a table in the database—let’s do that now. Run mix ecto.gen.migration create_users
to generate an empty migration, and then we’ll update the migration’s change
function.
def change do
create table(:users) do
add :first_name, :string
add :last_name, :string
timestamps
end
end
You can run migrations with mix ecto.migrate
.
We’ll need to modify our UserRouter to actually use this User model:
def route("GET", ["users", user_id], conn) do
case Helloplug.Repo.get(User, user_id) do
nil ->
conn |> Plug.Conn.send_resp(404, "User with that ID not found, sorry")
user ->
page_contents = EEx.eval_file("templates/show_user.eex", [user: user])
conn |> Plug.Conn.put_resp_content_type("text/html") |> Plug.Conn.send_resp(200, page_contents)
end
end
And finally, let’s update our template to list the first and last name of the user.
...
<h1><%= user.first_name] %> <%= user.last_name %>'s page</h1>
<p>Looks like you've requested information for the user with id <%= user.id %>.</p>
<%= if user.first_name == "Fluffums" do %>
Also, let me just say that Fluffums is awesome.
<% end %>
...
Our database is empty, so let’s fix that! If we jump into an Elixir shell with iex -S mix
, we can use Ecto to insert a record.
iex> Helloplug.Repo.start_link
iex> user = %User{id: 1, first_name: "Fluffums", last_name: "the Cat"}
iex> Helloplug.Repo.insert!(user)
The User
struct is something we get for free when we create an Ecto model! We can just pass the struct to Repo
, and it’ll automatically figure out which table to insert it into in the database.
Now, if we run the web server and visit localhost:4000/users/1
, we should see “Fluffums the Cat” appear on our screen! Success!
Testing everything
One of the coolest features of this functional setup is that it’s incredibly simple to test! We don’t need any fancy magic; all we need to do is pass a fake Conn
to our router’s call
function, and inspect the result we get back. Elixir ships with a unit testing framework called ExUnit that we can use. We won’t go in-depth into to the nuances of testing in Elixir, but here is an example Plug test:
defmodule HelloTest do
use ExUnit.Case, async: true
use Plug.Test
@website_router_opts WebsiteRouter.init([])
test "returns a user" do
conn = conn(:get, "/users/1")
conn = WebsiteRouter.call(conn, @website_router_opts)
assert conn.state == :sent
assert conn.status == 200
assert String.match?(conn.resp_body, ~r/Fluffums/)
end
end
use Plug.Test
simply imports functions from Plug.Test
, including the conn
function used above that creates fake connections to pass to the router.
Our framework’s performance
The Erlang VM’s pattern matching code has been heavily optimized. Rather than do a linear search through all our route
definitions, the VM actually does a binary search through all the possibilities, which makes our route lookups run in O(log n)
time instead of O(n)
. You’d likely only see improvements if you had a truly gargantuan number of routes in a single router, but still, it’s cool to know that we get this faster routing lookup for free, with no extra work on our end.
By using just a very thin layer over Plug and Cowboy, we get all of the performance benefits put into those projects with very little overhead. Cowboy can run multiple requests through our Plug simultaneously, and since all values in Elixir and Erlang are immutable, our framework is thread-safe by default. These libraries are also just generally fast—Matthew Rothenberg has a benchmark table comparing various frameworks. In his benchmark, our web framework would rank roughly near the “Plug” listing (it uses Plug’s Router, which works similarly to ours) at 54948 requests per second, which puts it at a higher throughput than even a Go based solution, Gin, which does around 51483 requests per second! These benchmarks are very simplistic, and don’t take into database fetching or a lot of other common web framework tasks, so it’s best to not give much weight to them. Still, it’s exciting to see that our simple framework is competitive with much bigger ones.
Not too shabby, considering we’ve barely written any code, and haven’t been considering performance until now.
Next steps
One of the cool side-effects of writing our own framework from scratch is that we have get to organize our own directory structure! Right now, everything is put together
in the global namespace, which isn’t so great. We could improve this by putting things in sub-modules and sub-directories.4 For instance, models could go in a lib/helloplug/models
folder, and we could call them Helloplug.Models.User
instead of just User
.
We could also add some more macros. For instance, the syntax for chaining Plugs together and precompiling templates is clunky. We also have no way of running a Plug before every single route in a router; this could perhaps be an argument passed to __using__
with a list of Plugs that call
routes through before calling route/3
.
We could also use a library like active to automatically reload modules when they change.
Further reading
-
The documentation for Ecto is a great reference. In particular, I’d look at the query reference, the schema reference, and the migration reference.
-
The Plug documentation is great. A lot of the concepts in this article were based off Plug’s router, which similarly uses the Erlang VM’s pattern matching for faster routing. However, I didn’t use it in this article, since I think understanding how the underlying system works is both exciting and educational. It’s also worth taking a look at the docs for Plug.Test, the testing module we saw briefly.
-
The documentation for Eex is great for learning more about templates.
-
The Phoenix framework is quite cool. Their website has some relevant articles on Ecto, templates, and deployment. Although some parts are Phoenix-specific, there’s plenty of concepts applicable to any Plug-based framework.
-
At the time of writing, I couldn’t actually find any free, online resource explaining how Elixir projects are laid out, although it is explained in Dave Thomas’ Programming Elixir. It mirrors the structure of Ruby’s
lib
folders. The moduleOne:Two:Three
belongs inlib/one/two/three.ex
. Multiple words get separated by underscores, soModuleName
becomesmodule_name.ex
. ↩ -
If you’re not familiar with the meaning of
init/1
—Erlang/Elixir considers the functions with different numbers of arguments to be completely different, so we callinit
with one argumentinit/1
, with two argumentsinit/2
, etc. ↩ -
We’ll be making a lot of changes to our modules very quickly, so if you’d rather not do this repeatedly, you can reload an existing module in the REPL by simply typing
r
followed by the module name, liker Helloplug
. You don’t need to swap out the running cowboy server—the Erlang VM hotswaps your new code in for you. ↩ -
Fun fact: The underlying Erlang VM has no concept of nested modules. The dot gets translated to be literally part of the name before it’s passed to the VM. This is why you can’t access functions in the
Helloplug
module automatically fromHelloplug.Models.User
. ↩