Dear readers, I haven’t posted on Learning Elixir in almost a month. Please know that I’m working to get back onto a weekly posting schedule. I have an ongoing project using Elixir and Phoenix which is keeping mey busy, but it should also provide a number of interesting topics for this blog. Speaking of which…
In this project, I needed to upload images and store them on Amazon S3. The Arc library has great support for doing this.
When setting up my image support I had written an Image
module that contained
the Ecto Schema for my images. It also contained a few useful functions for
working with those images.
defmodule MyApp.Image do
use MyApp.Web, :model
alias MyApp.Uploaders
Schema "images" do
field :name, :string
field :upload, :any, virtual: true
belongs_to :rental, MyApp.Rental
timestamps
end
@required_fields ~w(name)
@optional_fields ~w()
def changeset(model, params \\ %{}) do
model
|> cast(params, ~w(upload), [])
|> put_name
|> cast(params, @required_fields, @optional_fields)
end
def put_name(changeset) do
case changeset do
%Ecto.Changeset{
valid?: true,
changes: %{upload: %Plug.Upload{content_type: "image/" <> _, filename: name}}
} ->
put_change(changeset, :name, name)
_ ->
changeset
end
end
def store(%Plug.Upload{} = upload, image) do
Uploaders.RentalImage.store({upload, image})
end
def url(image, version) do
Uploaders.RentalImage.url({image.name, image}, version)
end
end
This Image
module has all the basic model stuff like the schema and
changeset/2
function. It also includes functions store/2
and url/2
which
can be used to store a new image to S3 and to retreive the url for a previously
stored image, respectively. Both functions rely on Arc functionality through
Uploaders.RentalImage
. They also hide the Arc from users of the Image
module. Those users only need to interact with the Image
module and can ignore
the Uploaders.RentalImage
module altogether.
The Uploaders.RentalImage
module follows the the Arc documentation, and
configures the way we want to use Arc.
defmodule MyApp.Uploaders.RentalImage do
use Arc.Definition
@acl :public_read
@versions [:original, :show, :thumb]
@heights %{
show: 315,
thumb: 30
}
def validate({file, _}) do
~w(.jpg .jpeg .gif .png) |> Enum.member?(Path.extname(file.file_name))
end
def transform(:thumb, _file) do
{:convert, "-thumbnail x#{@heights[:thumb]} -gravity center -format jpg"}
end
def transform(:show, _file) do
{:convert, "-strip -resize x#{@heights[:show]} -gravity center -format png"}
end
def storage_dir(version, {_, image}) do
"uploads/rentals/#{image.rental_id}/images/#{image.id}/#{version}"
end
def filename(_version, {file, _}) do
Path.rootname(file.file_name)
end
end
This module provides a number of functions that Arc expects to be able to call
to determine how we want Arc to behave. For example, storage_dir/2
is called
to determine the directory in which to store a given Image.
I’m also following Arc’s use case of having the S3 resource attached to my Image
model. Hence the tuple format for the parameters to validate/1
and filename/2
.
Confusing?
When I showed this to my coworker he was a little confused about the role of
Uploaders.RentalImage
. While he might be going against Arc’s perscription, I
think he had a valid point: why does the uploader need to be a separate module?
When we started on a large refactoring of our application I decided to see
what it would be like to combine Image
and Uploaders.RentalImage
in a single
module.
My plan was to integrate all the code into a single module and then see what could be simplified or improved.
A Single Module with Model and Arc Uploader
I did as a planned and put everything together. I ended up with this:
defmodule MyApp.Rental.Image do
use MyApp.Web, :model
alias MyApp.Rental
schema "images" do
field :name, :string
field :upload, :any, virtual: true
belongs_to :rental, MyApp.Rental
timestamps
end
@required_fields ~w(name)
@optional_fields ~w()
def changeset(model, params \\ %{}) do
model
|> cast(params, ~w(upload), [])
|> put_name
|> cast(params, @required_fields, @optional_fields)
end
defp put_name(changeset) do
case changeset do
%Ecto.Changeset{
valid?: true,
changes: %{
upload: %Plug.Upload{content_type: "image/" <> _, filename: name}
}
} ->
put_change(changeset, :name, name)
_ ->
changeset
end
end
def store(%Plug.Upload{} = upload, image), do: store({upload, image})
def url(%Rental.Image{} = image, version) do
url({image.name, image}, version)
end
########################################################################
# The rest of this file is the Arc definition for uploading
########################################################################
use Arc.Definition
@acl :public_read
@versions [:original, :slider, :card]
@heights %{
card: 230,
slider: 358,
}
def validate({file, _}) do
~w(.jpg .jpeg .gif .png) |> Enum.member?(Path.extname(file.file_name))
end
def transform(:original, _file), do: :noaction
def transform(:slider, _file) do
{:convert, "-strip -resize 612x358 -extent 612x358 -background black -gravity center -format jpg", :jpg}
end
def transform(tag, _file) do
{:convert, "-strip -resize x#{@heights[tag]} -gravity center -format jpg", :jpg}
end
def storage_dir(version, {_, image}) do
"uploads/rentals/#{image.rental_id}/images/#{image.id}/#{version}"
end
def filename(_version, {file, _}), do: Path.rootname(file.file_name)
end
Note, that there are a few differences beyond just combining the two files. For
example, I am now supporting a different set of image versions and transforms (:card
and
:slider
instead of thumb
and :show
).
I drew a small separation between the parts using a comment. Should I do more here?
There wasn’t much in terms of simplification to do with this combination aside
from being able to drop Uploaders.RentalImage
from calls to store/1
and
url/2
.
I still maintained the custom store/2
and url/2
functions, wrappers around
the Arc functions. I find their interfaces simpler than Arc’s tuple format.
Here’s an example of how I use the url/2
function from the view to put the
:slider
version of an image into an image slider:
<img src="<%= Image.url(image, :slider) %>"
alt="" data-bgposition="center bottom" data-bgfit="cover"
data-bgrepeat="no-repeat" data-bgparallax="10"
class="rev-slidebg" data-no-retina>
I like just being able to pass image
instead of having to build a tuple like
{image.name, image}
. The tuple has redundnacy so I prefere to write out the
tuple once within my url/2
function and avoid it in all other uses.
Conclusion
Overall I’m pretty happy with this. On one hand using a separate uploader module separates distinct concerns. But on the other hand the code involved isn’t that large and keeping it all in one module helps keep all my Image related code together in one place.
The Arc uploader concerns itself mostly with naming files and given that the model needs to be aware of some of these naming conventions in order to store the name in the database I think it makes sense to keep these things together.
If this functionality grows over time then splitting this into multiple modules might make sense. But I’m pretty happy with what I have right now.