Notes from Carrierwave to Active Storage
I recently migrated Day of the Shirt, my graphic t-shirt sale aggregator, from storing image attachments with Carrierwave to Active Storage. It went ok! 👍
There were a couple of things driving this migration, though Carrierwave had served me very well for nearly a decade:
- For budgetary reasons, I was moving the storage service from S3 to Digital Ocean Spaces. I knew I’d be doing some sort of data migration regardless.
- I was using some monkeypatches of Carrierwave v2 that weren’t compatible with Carrierwave v3. So I knew I’d have to dig into the internals anyways if I wanted to stay up to date.
- I generally trust Rails, and by extension Active Storage, to be reliable stewards when I take them on as a dependency.
And I had a couple of requirements to work though, largely motivated because images in Day of the Shirt are the content with dozens or hundreds displayed on a single page:
- For budget (slash performance), I need to link directly to image assets. No proxying or redirecting through the Rails app.
- For SEO, I need to customize the image filenames so they are relevant to the content.
- For performance (slash availability), I need to pre-process image transformations (convert, scale, crop) before they are published. Dozens of new designs can go up on the homepage at once.
- For availability, I need to validate that the images are (1) transformable and (2) actually transformed before they are published; invalid or missing images are unacceptable.
How’d it go? Great! 🎉 I am now fully switched over to Active Storage. It’s working really well and I was able to meet all of my requirements. Active Storage is very nice, as nice as Carrierwave.
But the errata? Yes, that’s why I’m writing the blog post, and probably why you’re reading. To document all of the stuff I did that wasn’t in the very excellent Active Storage Rails Guide. Let’s go through it:
Direct Linking to images is possible via the method described in this excellent post from Florin Lipan: “Serving Active Storage uploads through a CDN with Rails direct routes”.
Customizing Active Storage filenames is possible with a monkeypatch (maybe someday it will be possible directly). The patch simply adds the specified filename to the end of what otherwise would be a random string; and it seems durable through variants such that the variant extensions will be updated properly when the format is transformed (e.g. from a .png
to a .jpg
):
# config/initializers/active_storage.rb
module MonkeypatchBlobKey
def key
self[:key] ||= begin
# ActiveStorage::Filename doesn't provide an easy nil-check
filename_string = begin
filename.to_s
rescue StandardError
nil
end
unique_token = self.class.generate_unique_secure_token(length: ActiveStorage::Blob::MINIMUM_TOKEN_LENGTH)
if filename_string
# "xyz1234/foobar.jpg"
File.join(unique_token, filename_string)
else
# "xyz1234"
unique_token
end
end
end
end
ActiveSupport.on_load(:active_storage_blob) do
ActiveStorage::Blob.prepend MonkeypatchBlobKey
end
Preprocessing variants required tapping into some private methods to get the variant names back out of the system. Here’s an example of processing all of the variants when the attachment changes. Beware: attachments happen in an after_commit
, which is good, but means that I had to introduce a published
state to the record to ensure it was not visible until the variants were processed (there is a preprocessed:
option to process individual variants async in a background job but that, unfortunately, doesn’t meet my needs for synchronizing them all at once):
class Shirt < ApplicationRecord
has_one_attached :graphic do |attachable|
attachable.variant :full, format: :jpg
attachable.variant :large, resize_to_limit: [1024, 1024], format: :jpg
attachable.variant :square, resize_to_fill: [300, 300], format: :jpg
attachable.variant :thumb, resize_to_fill: [100, 100], format: :jpg
end
after_commit :process_graphic_variants_and_publish, if: -> (shirt){ shirt.graphic&.blob&.saved_changes? }, on: [:create, :update]
def process_graphic_variants
attachment_variants(:graphic).each do |variant|
graphic.variant(variant).processed
end
update(published: true)
end
# All of the named variants for an attachment
# @param attachment [Symbol] the name of the attachment
# @return Array[Symbol] the names of the variants
def attachment_variants(attachment)
send(attachment).attachment.send(:named_variants).keys
end
end
Validating variants was easy with a very nice and well-named gem: active_storage_validations
. It works really well.
You will have N+1s, where you forget to add with_attached_*
scopes to some queries. Unfortunately Active Storage’s schema is laid out in a way that it will emit queries to the same model/table even when it’s loading correctly, so you may get detection false positives too. You can see that clearly in the next example with the doubly-nested blob
association.
Active Storage’s schema is a beast. I get that it’s gone through a lot of changes, and Named Variants are an amazing hack when you see how they’ve been implemented. And it’s wild. You can see that by how the scope for with_attached_*
is generated:
includes("#{name}_attachment": { blob: {
variant_records: { image_attachment: :blob },
preview_image_attachment: { blob: { variant_records: { image_attachment: :blob } } }
} })
I originally thought that when eager-loading through an association (e.g. Merchant.includes(:shirts)
) I’d have to do something like this (🫠):
Merchant.includes(shirts: { blob: {
variant_records: { image_attachment: :blob },
preview_image_attachment: { blob: { variant_records: { image_attachment: :blob } } })
…but fortunately this seems to work too (💅):
Merchant.includes(:shirts).merge(Shirt.with_attached_graphic)
That’s everything. All in all I’m very happy with the migration 🌅