7 Practical Tips for ActiveStorage on Rails 5.2

kinopyo avatar

kinopyo

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)

kinopyo avatar
Written By

kinopyo

Indoor enthusiast, web developer, and former hardcore RTS gamer. #parenting
Published in Rails
Enjoyed the post?

Clap to support the author, help others find it, and make your opinion count.