| categories:development
Using Phoenix with docker, Part 2: Implementation
This is part two of a three part series: Part 1 - Part 2 - Part 3
Contents
- Installation
- Let’s go
- Scaffolding
- Simpler times
- Providing a “frontend”
- New routes
- Reading the documentation
- Handle the file
- Resizing images
- Resizing
- Problems of the demo app
- Conclusion
Installation
Before we start, please make sure you install Elixir and Phoenix. If you do not care or have already installed both, you can skip the next section.
Elixir
Installing Elixir is actually not too difficult - it’s not as convenient as just typing
sudo apt-get install elixir
as it equires the installation of Erlang and the Open Telephony Protocol (OTP). A more detailed guide on how to do the installation of Elixir (including the installation of Erlang/OTP can be found on the homepage - regardless of your preferred OS-choice.
Phoenix
Once you have installed Elixir, it’s time to setup Phoenix. This can be done via hex
, which in turn can be installed via mix
:
mix local.hex
and then installing Phoenix via:
mix archive.install https://github.com/phoenixframework/archives/raw/master/phoenix_new.ez
If you need more detail, consider visiting the Phoenix docs.
Let’s go
mix phoenix.new kitteh
should create a new Phoenix project for you in the folder ./kitteh
. When asked to install dependencies, you should probably say yes, although it’s irrelevant since we’re not going to build a complex frontend (but there is additional css, I promise).
You should end up with something like this:
.
├── brunch-config.js
├── _build
├── config
├── deps
├── lib
├── mix.exs
├── mix.lock
├── node_modules
├── package.json
├── priv
├── README.md
├── test
└── web
If you do not wish to do anything yourself, I prepared a repository here. You may use the tag 01-lets-go
to get the initial codebase.
Scaffolding
Scaffolding is a pretty fast and reliable way in Phoenix to get off the ground. We’re not going to use it to its full potential here.
Consider generating a controller:
mix phoenix.gen.html --no-model Image images
This might be a bit counter-intuitive (it is to me) - but generating just a controller and its views requires the all-including gen.html
task, which normally generates a complete resource with views associated, complete with model files included.
Looking at our newly generated controller, we notice that it has been filled with all kind of good stuff:
defmodule Kitteh.ImageController do
use Kitteh.Web, :controller
alias Kitteh.Image
plug :scrub_params, "image" when action in [:create, :update]
def index(conn, _params) do
images = Repo.all(Image)
render(conn, "index.html", images: images)
end
def new(conn, _params) do
changeset = Image.changeset(%Image{})
render(conn, "new.html", changeset: changeset)
end
def create(conn, %{"image" => image_params}) do
changeset = Image.changeset(%Image{}, image_params)
case Repo.insert(changes
# [...]
Woah.
Hold your horses. All I wanted was a simple controller with an action or two.
So, let’s rollback the changes. There is no convenient way (i.e. reverse generators) to do this yet, so
git clean -f && rm -rf web/templates/images
to the rescue. Phew.
Simpler times
Let’s fall back to the already generated PageController
. It already has an action index
ready to use.
At the moment it renders a file called index.html.eex
(link for the lazy). It constitutes a demo partial that together with the app.html.eex
(this one here) forms a complete webpage at the /
route.
We can look at it on the locally running instance at localhost by executing
iex -S mix phoenix.server
Neat.
Providing a “frontend”
This should be easy.
Phoenix includes Bootstrap by default. I could disagree with that, but then again, using Bootstrap is not to inconvenient.
The Phoenix team apparently decided to delegate the frontend choices to the userbase. A wise choice in the short term, as the whole frontend sector is quite fragmented at the moment (early 2016).
That being said, replacing everything in index.html.eex
with
<div class="row">
<div class="col-xs-12">
<div class="panel panel-default">
<div class="panel-heading">
<h1 class="panel-title">Kitteh uploader</h1>
</div>
<div class="panel-body">
<p>Upload a cat picture. Doggies are welcome, too.</p>
<form action="/upload" method="post" enctype="multipart/form-data" class="form">
<div class="form-group">
<label for="image" class="control-label">Image</label>
<input type="file" required id="image" name="image" class="form-control">
</div>
</form>
</div>
</div>
</div>
</div>
should do the trick.
No, we are not using the form builders that Phoenix provides, but feel free to read up on them. We can just use plain HTML instead.
If that is all too much frontend stuff for you, i suggest you look at the 02-simple-frontend
tag here.
New routes
We defined an /upload
path that the form uses, but this route is nowhere to be found. Let’s add it:
# see web/router.ex
# [...]
scope "/", Kitteh do
pipe_through :browser # Use the default browser stack
get "/", PageController, :index
# the new route
post "/upload", PageController, :upload
end
# [...]
Note I also sneakily added a required
attribute to the file-input
, to avoid any validation concerns.
If we try to send the form n… we notice that we’re missing a way to submit the form and add a button first.
If we try to send the form now, it will crash, since no action will take care of the request.
But wait, how do I upload stuff anyway? Should the framework not provide me with some way to make this easier?.
Kids, read your documentation before heading into battle.
Reading the documentation
Turns out, the Phoenix people do provide something to do file uploads.
That means we can actually use the form builders after all since we’re going the changeset route.
I feel silly.
Create a new model:
# note that this is just my initial try, have a look at the migrations for the actual fields used
mix phoenix.gen.model Image original_name:string url:string size:integer
and migrate the database:
mix ecto.create && mix ecto.migrate
If this fails for you, make sure you have a valid configuration for you database. To configure your database, see your local config/dev.exs
(see here).
So, finally, we can create a changeset in the controller and use it in the template:
<!-- index.html.eex, replacing the <form> -->
<%= form_for @changeset, "/upload", [multipart: true], fn f -> %>
<div class="form-group">
<label for="image_file" class="control-label">Image</label>
<%= file_input f, :file, required: true %>
</div>
<div class="form-group">
<%= submit "Upload", class: "btn btn-primary" %>
</div>
<% end %>
The @changeset
is introduced and passed to the view in the controller:
# web/controllers/page_controller.ex
# [...]
alias Kitteh.Repo
alias Kitteh.Image
def index(conn, _params) do
changeset = Image.changeset(%Image{})
render conn, "index.html", changeset: changeset
end
# [...]
If all goes well, this should render our frontend again. I also snuck in the missing Button for submitting the form. Please note that in order to actually use the line
<%= file_input f, :file, required: true %>
a virtual file
attribute has to exist in the Image
model.
If this was all just ramblings of a mad developer for you, you can also check out the tag 03-actually-read-the-docs
here.
Handle the file
It’s time for some action in the controller, because at the moment our application will crash if we try to submit the form with an image.
The controller action has to do the following:
Validate the file given- Move the uploaded file under a new name into a folder we can access
- Save the model with some information on the file uploaded to the DB.
- if that was successful, redirect to the
show
action for the new image - (alt) if not, redirect to the
index
with a message
I will skip the validation on the file - one could do this by validating the size
field of the changeset before inserting.
We’ll skip this here and assume that the file given is something we want.
Phoenix will give us the file as a Plug.Upload
struct in our params
to the newly created upload
function in PageController
:
# web/controllers/page_controller.ex
# [...]
def upload(conn, params) do
# now what?
end
# [...]
Thinking like a Rails developer, the fat model approach comes to mind. Let’s put all the logic for this into a model and let the controller action pass in the params. Be done with it, move on. Have a beer maybe.
This is not viable here, since Elixir ultimately does not care where your functions live. There are no models, just functions and structs.
I decided in favour of a more controller based approach. The controller will do the the copying and transform the file input into a usable params
map:
# web/controllers/page_controller.ex
# [...]
def upload(conn, %{ "image" => %{ "file" => file } }) do
# transform the uploaded file into a changeset
params = file
|> copy_file(unique_name)
changeset = Image.changeset(%Image{}, params)
# try to insert the newly generated changeset
end
# [...]
The copy file function acutally does the more “heavy lifting”:
defp copy_file(file) do
extension = Path.extname(file.filename)
target = target_path <> name <> extension
case File.copy(file.path, target) do
{:ok, size} ->
%{
generated_name: name,
token: String.downcase(name),
path: target,
original_name: file.filename,
content_type: file.content_type,
size: size
}
{:error, _} ->
%{}
end
end
Depending on whether the copying was successful, we either get a proper params
map with all the necessary information filled in or we are left with an empty map that will never pass our validations.
Note that target_path
actually behaves differently from what you would expect. In Phoenix, you do not find the same behaviour as with Rails’ Rails.root
.
For now, we need a target path that lives within our application and we can access. But our codebase will be compiled (in contrast to a Ruby codebase), so we cannot be sure where our bytecode ends up (Hint: It’s in the ./_build
folder).
We can do this though:
Application.app_dir(:kitteh, "priv")
See this StackOverflow answer for more information. The actual implementation used is found here.
After having generated a params
map, the rest is just the same as in any Phoenix tutorial you might find:
- generate a changeset based on
Image
- insert that changeset
- redirect to the show action or abort and rerender the index template
In case you were wondering:
The shorthand generated for the kitty is generated via Image.generate_unique_name
, which uses collected seed data to generate a different combination of these attributes. We have to try again if we actually used the name before in the database. Since Elixir does not have any loops, we resort to recursion until we find a name that we can use.
Note: This has no safety measures - if all combinations of the attributes are used up, our database is “full” and we are screwed.
After the image is persisted we redirect to show
. Additionally, an ImageController
is introduced with a show
action here to actually serve up the image for now. This is intermediary - ultimately, we’ll not use Phoenix to serve assets in “production”.
If all goes well, the upload should work and the original image should be served under a memorable shorthand.
Shortcuts
If this is all to much coding and you would like the easy way out, check out the tag 04-enable-uploading
here.
Resizing images
Remember the image modifiers? Like “Tiny”, “Large” and “Monstrous”? We forgot about those.
It would be nice if we had all the images for the different sized images pre-generated. We could use the same mechanisms we already have implemented to serve them.
GenServer
In a (newer) Rails environment, we could utilize anything that fulfills the interface of ActiveJob, like an adapter to Sidekiq or the delayed_job
gem gem. We basically spin up a second OS process to generate the image, regardless of the solution.
Not an option here though. There are some solutions to queues and background jobs, but we are on the Erlang VM anyway, so we can utilize the technology available to us. After all, BEAM processes are cheap and lightweight.
GenServer might be the answer. But actually having a long running process in the background that we can use as a service might be overkill here.
Let’s use Task
instead. Task
is a wrapper around Elixirs spawn
function and can be used for a multitude of things that are actually more advanced than we do right here, right now.
Looking into the code:
defp create_sizes(image) do
sizes = %{ "Tiny" => "90", "Large" => "300", "Monstrous" => "600" }
original_file = image.path
Enum.each sizes, fn({ label, size }) ->
Task.start fn ->
name = label <> image.generated_name
file_params = resize(image)
|> copy_file name
changeset = Image.changeset(%Image{}, file_params)
|> Repo.insert
end
end
end
Using Task.start
creates a subprocess that is not linked to the current process. Process linking here is not strictly necessary, as this is implemented as a fire-and-forget strategy. In contrast to Task.start_link
, we’re not linking our main process (kitteh
) to the new process. In case it crashes, we do not want to tear down our application (process) as well.
Note: I had some problems finding out on how to match function call in using Enum.each
against the result of the map. The resulting argument is matched against a tuple. Might be trivial, but just in case you were wondering.
This has the notable disadvantage every fire-and-forget strategy has - we do not know if we actually create the images. Good enough for this application, but for something production-ready, one should look for some bidirectional communication. Just in case, you know, you maybe want to connect these images to one another.
Resizing
Resizing images is something one should probably be too lazy to implement oneself. Enter mogrify
- it is a wrapper library for ImageMagick, providing us with functions for handling image-resizing.
We should make a mental note here as we introduce a hard dependency for our docker containers later on. Any container that we want to create for this application now has to provide this dependency.
With another commit, the resize function is introduced:
defp resize(image, name, size) do
new_path = target_path <> name <> Path.extname(image.path)
new_image = open(image.path) |> copy |> resize(size) |> save(new_path)
%{
generated_name: name,
token: String.downcase(name),
path: new_path,
original_name: image.original_name,
content_type: image.content_type,
size: image.size
}
end
This should create all the resized versions. We can also utilize the builtin Mogrify.copy
function to skip manual copying as we did for the initial image. We end up with returning params we can use to create another changeset and insert everything. The rest of the system should now work for the resized images as well.
Note: Somewhere around this point I noticed a screw up in the router as matching order was off. This lead to a redirect to /
after the initial image had been created.
At this point, our image uploader should be feature complete. Altough being the duct-tape ghetto version it now is, it should provide a good basis to play around with in the next part.
If this is all the same to you and you could not care less about how the images are generated and stored exactly, check out the tag 05-resizing-cats
here.
Problems of the demo app
This demo application has quite a few problems, some of them already discussed, some of them a little less obvious:
- no tests - this is a biggie and nothing to sweep under the rug. Since this is not intended for production purposes, we sweep it under the rug
- the amount of images uploaded is limited to the combination limit of the seed data
- Naming is somewhat bad
- no validations on
Image
changesets besides the required fields [...]
The list is not complete, but one can always find things to improve. For example, by just supporting another type
, e.g. “Doggy” in addition to “Kitty”, we could double the image capacity. We could also make sure that all images have been created using Task.await
.
Nevertheless, it should make a good demo app as it has almost everything - a web app, some need for a database, static data that has to be stored and served from somewhere. All the good stuff.
there is one major problem when it comes to Live reload. I personally am not a fan of such a feature, but it is included in Phoenix by default. I had to disable it in dev, since it interfered with the upload feature. the uploaded into a folder that is live reloaded apparently wasn’t the best of my ideas.
Conclusion
In the next part, we’ll finally look into using docker
to gain containers for our project and use docker compose
to orchestrate our system.
If you already forgot what this was all about, check out part 1 to get a re-introduction.