Update: Rails and Active Storage. The new approach to file uploads
Since its first shipping, Active Storage has revolutionized attaching files to Ruby on Rails applications. Learn what it is, how to set it up and use it, as well what's new in Rails Active Storage in 2021!
Table of Content
- What is Active Storage in Ruby on Rails?
- Adapting your Rails project to Active Storage
- Config
- Active Storage Tips and Tricks
- Variants and optimization
- Rails Active Storage in 2021: Image processing and others
What is Active Storage in Ruby on Rails?
ActiveStorage, a built-in way for uploading files in web applications was among the features that came with Rails 5.2. The files are uploaded to cloud storage services like Amazon S3, Google Cloud Storage or Microsoft Azure Storage and then attached to Active Record objects in the app.
This means Rails developers no longer have to use third-party libraries like CarrierWave for example. Let's check out what needs to be done when adapting your project to ActiveStorage
.Adapting your Rails project to Active Storage
If you are updating your existing Rails project, right after upgrading Rails to 5.2 you need to run command rails active_storage:install. This will create new migrations for ActiveStorage to use - more precisely, it will create active_storage_blobs and active_storage_attachments tables in your database.
# db/migrate/20180407102457_create_active_storage_tables.active_storage.rb
class CreateActiveStorageTables < ActiveRecord::Migration[5.2]
def change
create_table :active_storage_blobs do |t|
t.string :key, null: false
t.string :filename, null: false
t.string :content_type
t.text :metadata
t.bigint :byte_size, null: false
t.string :checksum, null: false
t.datetime :created_at, null: false
t.index [ :key ], unique: true
end
create_table :active_storage_attachments do |t|
t.string :name, null: false
t.references :record, null: false, polymorphic: true, index: false
t.references :blob, null: false
t.datetime :created_at, null: false
t.index [ :record_type, :record_id, :name, :blob_id ], name: "index_active_storage_attachments_uniqueness", unique: true
end
end
end
So why do we need both of them anyway?
ActiveStorage will use these tables to keep track of your uploads via polymorphic associations.
That's right - no more creating additional columns in your models for images and files.
Blobs are records that contain the metadata of the file (such as content type, size, filename etc.) and an encoded key which points to the file location on the service (be it local or cloud).
Attachments associates your models with blobs.
After the migration, you need to add associations to attachments in your model:
# app/models/blog_post.rb
class BlogPost < ApplicationRecord
has_one_attached :main_picture # one-to-one relationship
has_many_attached :uploads # one-to-many relationship
end
Here I have added has_one_attached :main_picture so that my BlogPost model has one main image, and has_many_attached :uploads, so I can upload many images / documents to it.
The next step is to permit those attributes in controller:
# app/controllers/blog_posts_controller.rb
def blog_post_params
params.require(:blog_post).permit(:title, :content, :main_picture, uploads: [])
end
For has_one association we permit a single parameter, and for has_many we permit an array.
And that's pretty much it - with ActiveStorage it's so simple to add images to your existing models. Again, you don't need to create uploaders, mount them, add additional columns in your model and all that stuff.
Config
When you install ActiveStorage, it will also create a config file storage.yml
# config/storage.yml
test:
service: Disk
root: <%= Rails.root.join("tmp/storage") %>
local:
service: Disk
root: <%= Rails.root.join("storage") %>
# Use rails credentials:edit to set the AWS secrets (as aws:access_key_id|secret_access_key)
# amazon:
# service: S3
# access_key_id: <%= Rails.application.credentials.dig(:aws, :access_key_id) %>
# secret_access_key: <%= Rails.application.credentials.dig(:aws, :secret_access_key) %>
# region: us-east-1
# bucket: your_own_bucket
# Remember not to checkin your GCS keyfile to a repository
# google:
# service: GCS
# project: your_project
# credentials: <%= Rails.root.join("path/to/gcs.keyfile") %>
# bucket: your_own_bucket
# Use rails credentials:edit to set the Azure Storage secret (as azure_storage:storage_access_key)
# microsoft:
# service: AzureStorage
# storage_account_name: your_account_name
# storage_access_key: <%= Rails.application.credentials.dig(:azure_storage, :storage_access_key) %>
# container: your_container_name
# mirror:
# service: Mirror
# primary: local
# mirrors: [ amazon, google, microsoft ]
You can set your root directory for local and test services, as well as set your keys to Amazon S3, Google Cloud Service, Microsoft Azure. You still need gems for those services to work.
Keep in mind that in Rails 5.2 we use Credentials instead of well-known secrets.
ActiveStorage also provides the mirror service - it keeps your uploads in sync across all mirrors, so you don't need to worry about downtime on one of the services.
Remember to set your service in environment file for the option you choose, for example:
# config/environments/development.rb
config.active_storage.service = :amazon
Active Storage Tips and Tricks
In forms, we use normal file_field for our uploads. Add multiple: true when you use has_many_attached!
-# app/views/blog_posts/_form.haml
.field
= form.label :main_picture
= form.file_field :main_picture
.field
= form.label :uploads
= form.file_field :uploads, multiple: true # for multiple uploads
It gets a little trickier when showing the uploads to the user.
ActiveStorage allows to upload any type of files. At the original time of writing this, there were no built-in validations but you can still create one yourself
# app/models/blog_post.rb
validate :main_picture_format
private
def main_picture_format
return unless main_picture.attached?
return if main_picture.blob.content_type.start_with? 'image/'
main_picture.purge_later
errors.add(:main_picture, 'needs to be an image')
end
When showing uploads, we have a few possibilities:
-# app/views/blog_posts/show.haml
%p
%strong Title:
= @blog_post.title
- if @blog_post.main_picture.attached?
%p
%strong Image:
= image_tag @blog_post.main_picture.variant(resize: '700x700') # on-demand processing
= image_tag @blog_post.main_picture.variant(resize: '700x700').processed.service_url # process then fetch the image
%p
%strong Content:
= @blog_post.content
- if @blog_post.uploads.attached?
%p
%strong Uploads:
- @blog_post.uploads.each do |upload|
- if upload.representable?
= link_to image_tag(upload.representation(resize: '500x500')), upload # opens in new browser tab
= link_to image_tag(upload.representation(resize: '500x500')), rails_blob_path(upload, disposition: :attachment) # downloads file
- elsif upload.image?
= link_to image_tag(upload, width: 500), upload
- else
= link_to upload.filename, upload
Let's talk about the options:
1) if the upload can be changed / transformed (it's an image), we can use MiniMagic to process it (ActiveStorage only supports MiniMagic as of now). To check if upload can be processed, we can call upload.variable?. To use MiniMagic's magic :), we must call upload.variant method with transformation which should be applied (resize, trim, flip etc.).
So it looks like this: upload.variant(resize: '500x500').
.One thing to note - .svg files are images, but they are not variable images. MiniMagick cannot transform them, so when you try to apply variant to them, you will get ActiveStorage::InvariableError!
To apply some configuration, you can call image_tag with parameters like:
image_tag(@blog_post.svg_image, width: 200)
.2) if the upload can be previewed (it's a PDF or a video), simply call upload.previewable?. The preview will be the first page of the document or the first frame of the video.
Keep in mind that to use previews, you need to install third-party libraries as ActiveStorage does not provide any, but depends on them - for PDFs it's Poppler or mupdf, and for videos it's ffmpeg.
To make it more compact, ActiveStorage has combined these two modules into one called ActiveStorage::Representable, so you can simply call upload.representation(resize: '500x500') and it will return ActiveStorage::Variant for variable blob, and ActiveStorage::Preview for the previewable one.
One thing with variants is that we have 2 choices with them: we can make variants on-demand / lazy transform by calling upload.variant(resize: '500x500x') - this will issue transformation when the image is requested by browser or calling upload.variant(resize: '500x500x').processed.service_url to force transformation - just keep in mind that the entire file needs to be downloaded, processed, uploaded to the service and then return itself again But don't worry - ActiveStorage first checks if variant exists on the service, and if so, returns it instead of transforming again.
To allow users to download file, we use Rails new helperrails_blob_path with disposition: :attachment - it will start downloading file after clicking, instead of opening it in new tab:
= link_to image_tag(upload.representation(resize: '500x500')), rails_blob_path(upload, disposition: :attachment)
.If you want to attach file in custom controller method or somewhere else in your code, you can call @blog_post.main_picture.attach(params[:main_picture]).
Having multiple files attached to your model, you will get N+1 queries accessing them. Referring one attached items calls your model controllers, ActiveStorage::Attachment to load polymorphic association and finally ActiveStorage::Blob to load the file itself. To avoid that, we can use built-in scope "with_attached_#{attachment_name}" which will include attached blobs in our query, so instead of BlogPost.all, we use BlogPost.with_attached_uploads.
To delete a blob, you can use @blog_post.main_picture.purge which will synchronously delete attachment and resource files or @blog_post.main_picture.purge_later to delete attachment in the background via ActiveJob.
You also do not need to specify dependant: :destroy with an association. It will automatically delete attachment via purge_later method when the parent model is destroyed. If you want to make many records-one blob relationship, you need to specify has_one/many_attached :attachment, dependant: false to prevent destroying blob when parent model is destroyed.
Variants and optimization
As of now, ActiveStorage does not provide any built-in methods for image processing despite using MiniMagick for transformations. So to have the same functionality, as for example CarrierWave offers, you need to do some work.
I created a lib called Uploads where I keep all my code for various resizes based on CarrierWave and optimization based on Google's PageSpeed documentation for image optimization.
# lib/uploads.rb
class Uploads
class << self
def jpeg?(blob)
blob.content_type.include? 'jpeg'
end
def optimize
{
strip: true
}
end
def optimize_jpeg
{
strip: true,
'sampling-factor': '4:2:0',
quality: '85',
interlace: 'JPEG',
colorspace: 'sRGB'
}
end
def optimize_hash(blob)
return optimize_jpeg if jpeg? blob
optimize
end
def resize_to_limit(width:, height:, blob:)
{
resize: "#{width}x#{height}>"
}.merge(optimize_hash(blob))
end
def resize_to_fit(width:, height:, blob:)
{
resize: "#{width}x#{height}"
}.merge(optimize_hash(blob))
end
def resize_to_fill(width:, height:, blob:, gravity: 'Center')
blob.analyze unless blob.analyzed?
cols = blob.metadata[:width].to_f
rows = blob.metadata[:height].to_f
if width != cols || height != rows
scale_x = width / cols
scale_y = height / rows
if scale_x >= scale_y
cols = (scale_x * (cols + 0.5)).round
resize = cols.to_s
else
rows = (scale_y * (rows + 0.5)).round
resize = "x#{rows}"
end
end
{
resize: resize,
gravity: gravity,
background: 'rgba(255,255,255,0.0)',
extent: cols != width || rows != height ? "#{width}x#{height}" : ''
}.merge(optimize_hash(blob))
end
def resize_and_pad(width:, height:, blob:, background: :transparent, gravity: 'Center')
{
thumbnail: "#{width}x#{height}>",
background: background == :transparent ? 'rgba(255, 255, 255, 0.0)' : background,
gravity: gravity,
extent: "#{width}x#{height}"
}.merge(optimize_hash(blob))
end
end
end
After that, what is left to do is to make new instance method for model. I made one for my BlogPost model like this:
# app/models/blog_post.rb
def main_picture_header_variant
variation =
ActiveStorage::Variation.new(Uploads.resize_to_fit(width: 500, height: 200, blob: main_picture.blob))
ActiveStorage::Variant.new(main_picture.blob, variation)
end
If you want to do something similar, do not forget to require your lib in your model by adding require 'uploads' before class definition.
# app/models/blog_post.rb
require 'uploads'
class BlogPost < ApplicationRecord
...
end
This method allows me to just simply call @blog_post.main_picture_header_variant to fetch that particular variant.
Rails Active Storage in 2021: Image processing and others
4 years after the first version of ActiveStorage had been released, lots of smaller and bigger refinements were shipped.
One of the largest ones was the support for image_processing gem, which simplifies a lot of the logic related to variants and optimization, so let’s check how to leverage this.
First of all, image_processing gem relies on the imagemagic or libvips binary for all image manipulations, so be sure that it is installed on your development machine and servers.
The usage is very similar to the solution I proposed earlier in this blog post. So in order to display image that is resized to limit, simply use:
@blog_post.main_picture.variant(resize_to_limit: [700, 700])
and that's all. Very easy and intuitive.
There are lots of popular ImageMagic manipulations available. You can find all the supported methods in the ImageMagic section in image_processing docs.
But there is no stopping here. ActiveStorage is constantly evolving and there are many things to look forward to.
One, for example, is the long-awaited ability to create versions for attachments (like you can do with CarrierWave). It is coming soon and right now it is available on the edge version of Rails if you want to test it out.
To use this functionality, all you have to do is to make one change to your model:
class BlogPost < ApplicationRecord
has_one_attached :main_picture do |attachable|
attachable.variant :header, resize_to_limit: [700, 700]
end
end
and then you will be able to simply use this like this:
@blog_post.main_picture.variant :header
With those two additions, I think that ActiveStorage will be an out-of-box solution that you can use in place of CarrierWave. It will be simple to use, will have all the popular image transformations and Rails magic that you will need in your application and will speed up your work. So, yeah, it sounds very exciting!
Photo from: Gratisography.com