7 Practical Tips for ActiveStorage on Rails 5.2
What is ActiveStorage?
Handle (image) file upload in Rails like carrier_wave, paperclip, and dragonfly. Only difference is that it's extracted from Basecamp production code and made into the rails ecosystem.
So not only is the framework already used in production, it was born from production.
http://weblog.rubyonrails.org/2018/3/20/Rails-5-2-RC2/
Guess that's a pretty great sales pitch.
Here is an overview from the official guides that covers all the basic usage: from installing to quick get-started code examples. If you haven't checked it, that's gonna be your first stop for sure.
In Bloggie, we've been using ActiveStorage from day one. Here are 7 practical tips to help you kick it off. ️
Understand Blob and Attachment models
ActiveStorage is designed with Blob and Attachment models, so when you add this to any existing Rails applications, you won't need to modify your tables (like adding any image_id
column or so). This is great!
A key difference to how Active Storage works compared to other attachment solutions in Rails is through the use of built-in Blob and Attachment models (backed by Active Record). This means existing application models do not need to be modified with additional columns to associate with files. Active Storage uses polymorphic associations via the Attachment join model, which then connects to the actual Blob.
https://github.com/rails/rails/blob/master/activestorage/README.md#compared-to-other-storage-solutions
Let's see how the tables look like when we upload a user avatar image.
Assume we have a user with id 101.
active_storage_attachments
: the "join" model connects polymorphic records (users
in this case) with blobs.
id | 202
name | avatar
record_type | User
record_id | 101
blob_id | 301
created_at | 2017-12-08 09:31:28.503057
active_storage_blobs
: contains the metadata about a file and a key.
id | 301
key | spsqwM32xZSyM4T3cdhu11ZK
filename | kinopyo.jpg
content_type | image/png
metadata | {"width":254,"height":254,"analyzed":true}
byte_size | 5937
checksum | IOam5/yjCufSlj9owd39wQ==
created_at | 2018-01-13 08:30:13.869949
Now we can visualize the connections.
Say, when you have another model Post
that also associated with images, that'll be saved into the active_storage_attachments
with record_type
as Post
and record_id
as the post id.
Solve N+1 with eager loading
So far so good. But with this type of model design, it also means that you need to be extra careful on the N+1 queries in a has_many situation.
Imagine a typical blog application where a post has many images.
class Post < ApplicationRecord
has_many_attached :images
end
Then in the post page, you simply find and render it.
def show
@post = Post.find(params[:id])
end
If the post has many images attached, you may see something like this in your rails server logs:
ActiveStorage::Attachment Load (0.7ms) SELECT "active_storage_attachments".* FROM "active_storage_attachments" WHERE "active_storage_attachments"."record_id" = $1 AND "active_storage_attachments"."record_type" = $2 AND "active_storage_attachments"."name" = $3 [["record_id", 74], ["record_type", "Post"], ["name", "images"]]
ActiveStorage::Blob Load (0.4ms) SELECT "active_storage_blobs".* FROM "active_storage_blobs" WHERE "active_storage_blobs"."id" = $1 LIMIT $2 [["id", 154], ["LIMIT", 1]]
ActiveStorage::Blob Load (0.4ms) SELECT "active_storage_blobs".* FROM "active_storage_blobs" WHERE "active_storage_blobs"."id" = $1 LIMIT $2 [["id", 155], ["LIMIT", 1]]
ActiveStorage::Blob Load (0.2ms) SELECT "active_storage_blobs".* FROM "active_storage_blobs" WHERE "active_storage_blobs"."id" = $1 LIMIT $2 [["id", 156], ["LIMIT", 1]]
This is because when rendering the image, internally it'll need to load the image record from the database and then perform a redirection (more on this topic in another post).
Fortunately, ActiveStorage is shipped with one scope:with_attached_images
. Let's add it to the controller.
def show
@post = Post.with_attached_images.find(params[:id])
end
ActiveStorage::Attachment Load (0.3ms) SELECT "active_storage_attachments".* FROM "active_storage_attachments" WHERE "active_storage_attachments"."record_type" = $1 AND "active_storage_attachments"."name" = $2 AND "active_storage_attachments"."record_id" = $3 [["record_type", "Post"], ["name", "images"], ["record_id", 74]]
ActiveStorage::Blob Load (0.6ms) SELECT "active_storage_blobs".* FROM "active_storage_blobs" WHERE "active_storage_blobs"."id" IN ($1, $2, $3) [["id", 154], ["id", 155], ["id", 156]]
Now previous N+1 queries are all loaded in one.
Under the hood, with_attached_images
simply is a macro of includes("#{name}_attachment": :blob)
, where name
is whatever you defined with has_many_attached :the_name
in your model.
Upload image by passing the image url
We love omniauth! We use it to get users login via whatever services they like.
During the authentication, say Twitter, it would be nice that we can pass the twitter avatar url that is shipped with omniauth to ActiveStorage, so the user would have the same avatar instead of blank one.
Sadly, ActiveStorage doesn't support this yet (there was an PR but got closed), but can be easily handled with a few lines of code.
user.avatar.attach(url: "http://example.com/avatar.png") # won't work
file = download_remote_file(url)
user.avatar.attach(io: file, filename: "user_avatar_#{user.id}.jpg", content_type: "image/jpg")
private
def download_remote_file(url)
response = Net::HTTP.get_response(URI.parse(url))
StringIO.new(response.body)
end
I'll leave it to you to handle the filename and content_type, but you get the gist .
Get image width and height for free
One of the great features of ActiveStorage is Analyzer
!
After uploading an image, it'll add the analyze into ActiveJob queue, and when it's executed, it'll return the image width and height for you!
user.avatar.attach(io: File.open("/path/to/avatar.jpg"), filename: "avatar.jpg", content_type: "image/jpg")
# Enqueued ActiveStorage::AnalyzeJob (Job ID: 60687e...)
# Performing ActiveStorage::AnalyzeJob (Job ID: 60687e...)
# ActiveStorage::Blob Update (0.4ms) UPDATE "active_storage_blobs" SET "metadata" = $1 WHERE "active_storage_blobs"."id" = $2 [["metadata", "{\"identified\":true,\"width\":254,\"height\":254,\"analyzed\":true}"], ["id", 157]]
user.avatar.metadata
=> { "identified" => true, "width" => 254, "height" => 254, "analyzed" => true}
Note that it requires MiniMagick so make sure it's in your Gemfile:
# Gemfile
gem 'mini_magick'
Besides image analyzer, there's also an VideoAnalyzer that would give back the duration, angle, and display aspect ratio (e.g. "4:3") for the uploaded video.
(In Rails 5.2, mini_magick is already added to the Gemfile by default ️)
Get file name and extension
Straightforward
user.avatar.filename
=> #<ActiveStorage::Filename:0x007fec0f32e0a8 @filename="bloggie:dev.png">
user.avatar.filename.to_s
=> "bloggie-dev.png" # sanitized by default
user.avatar.filename.base
=> "bloggie:dev"
user.avatar.filename.extension_with_delimiter
=> ".png"
user.avatar.filename.extension_without_delimiter
=> "png"
Read more about ActiveStorage filenames.
Attach images in tests
Suppose you're using RSpec with FactoryBot, and in your model spec you want to create your main object with images attached. (apologize for the made-up/not-convincing spec example )
it "creates a post with an image" do
post = create(:post)
# how to create this file?
post.images.attach(need_to_create_this_file)
expect(post.images.any?).to eq(true)
end
Well, we can "borrow" it from the rails repo! In activestorage/test/test_helper.rb, there is a helper method called create_file_blob
which does exactly what we wanted.
I don't know if there's better way of doing it, but at least you could port it like this:
module ActiveStorageHelpers
# ported from https://github.com/rails/rails/blob/4a17b26c6850dd0892dc0b58a6a3f1cce3169593/activestorage/test/test_helper.rb#L52
def create_file_blob(filename: "image.jpg", content_type: "image/jpeg", metadata: nil)
ActiveStorage::Blob.create_after_upload! io: file_fixture(filename).open, filename: filename, content_type: content_type, metadata: metadata
end
end
RSpec.configure do |config|
config.include ActiveStorageHelpers
end
Then put one tiniest image file you could find and save it as spec/fixtures/file/images.jpg
, that's where the file_fixture
method would look for. (Can also download one from https://github.com/rails/rails/blob/master/activestorage/test/fixtures/files)
That's it! Back to the previous spec:
it "creates a post with an image" do
post = create(:post)
post.images.attach(create_file_blob)
expect(post.images.any?).to eq(true)
end
You can also use the helper method to stub the metadata.
For example, in bloggie we're setting a cover image that'll be used to make the post look good when sharing to Twitter. We want this to be as transparent as possible, so what we'd done is to see if the image width and height are bigger than a threshold. The actual spec looks like this:
it "sets cover image when publishing if image size > threshold" do
post = create(:post)
file = create_file_blob(metadata: { width: 1250, height: 900 })
image = post.images.attach(file)
post.publish
expect(post.cover_image).to eq(image)
end
Be careful with git clean --force -d
When developing with ActiveStorage on my local machine, I had frequently ran into the situation where all my local images seemed to be lost. I couldn't figure out why, until...
If you follow the default configuration, in development mode, all your uploaded files will be saved under storage
folder.
local:
service: Disk
root: <%= Rails.root.join("storage") %>
and your .gitignore will surely have it covered:
/storage/*
If you're in the habit of running git clean -fd
blindly like me, to get a clean state, you may get yourself tricked like I did.
git clean -h
usage: git clean [-d] [-f] [-i] [-n] [-q] [-e <pattern>] [-x | -X] [--] <paths>...
-n, --dry-run dry run
-f, --force force
-d remove whole directories
-e, --exclude <pattern>
add <pattern> to ignore rules
Let's have a dry run first:
> mkdir foobar
> git clean -fdn
Would remove foobar/
Would remove storage/
That was why it tended to happen when I switched branch... as I usually cleaned off all unstaged files and folders with that command, and in turn it'd remove the entire storage/
folder that contains all the images
So now guess I'd need to use the --exclude
argument:
git clean -fdn -e storage
Would remove foobar/
Putting this into my .zshrc
alias gclean="git clean --force -d -e storage"
That's it! Hope these are helpful! If you have other great tips or any suggestions, please leave a comment below! Cheers
(This post is written with Rails 5.2.0.RC2)
Clap to support the author, help others find it, and make your opinion count.