Image Upload is a very basic requirement in any Web/Mobile application. This service, if not written properly can prove to be very problematic to scale very soon.
We had an image upload API exposed which took in multipart images as input and returned a json
response of the created object. Picture something like below:
image = Image.create!(image_params)
render json: image
I had used carrierwave
to handle the image upload.
Problems?
We had several versions of the image, like small
, logo
, medium
, low
etc for rendering in various places. What carrierwave does, it processes the images for these versions during the time of upload only. And that takes time. A simple request for a 2.2 MB
image used to take somewhere around 3-4 seconds
which wasn't acceptable.
There was no ready made solution that worked good. There was carrierwave backgrounder
there, but it wasn't under active maintenance.
I decided to employ sidekiq
for the purpose. I am a great fan of this gem and I try to use it wherever possible.
I added a column temp_image
in my db table, leaving image
column as such.
# == Schema Information
#
# Table name: images
#
# id :bigint not null, primary key
# height :decimal(, )
# image :string
# meta :string default([]), is an Array
# rekognition_meta :jsonb is an Array
# temp_image :string not null
# width :decimal(, )
# created_at :datetime not null
# updated_at :datetime not null
and started uploading the images to temp_image
field, created another uploader
for the same, but with no version, and triggered a sidekiq
worker after the db commit.
My Image
class looked something like below:
# Image ORM
class Image < ApplicationRecord
mount_uploader :temp_image, TempImageUploader
mount_uploader :image, ImageUploader
after_commit :create_versions, on: :create
validate :temp_image_presence
validate :image_dimensions, on: :create
attr_accessor :creation_token
def url
image&.url || temp_image.url
end
alias original_image_url url
alias upload_id id
%w[small logo fixed high medium low].each do |version_type|
define_method "#{version_type}_version_url" do
return temp_image.url unless image&.send(version_type)&.url
image.send(version_type).url
end
end
def create_versions
return if Rails.env.test? || Rails.env.development?
::ImageProcessingWorker.perform_async(id)
end
def temp_image_presence
# didn't use validates :temp_image, presence: true because wanted to add error to image, not temp image
errors.add(:image, "can't be blank") unless temp_image.present?
end
def image_dimensions
return if width.to_i > 120 && height.to_i > 120
errors.add(:image, 'Dimensions of uploaded image should be not less than 120x120 pixels.')
end
end
TempImageUploader
was left plain and it looked like:
class TempImageUploader < BaseUploader
def extension_whitelist
%w[jpg jpeg gif png webp]
end
def content_type_whitelist
%r{image/}
end
before :cache, :capture_size_before_cache
def capture_size_before_cache(file)
if model.width.nil? || model.height.nil?
model.width, model.height = `identify -format "%wx %h" #{file.path}`.split(/x/).map { |dim| dim.to_i }
end
rescue StandardError => _e
::Rails.logger.info 'Image reading failed!'
end
end
While the ImageUploader
looked like:
class ImageUploader < BaseUploader
# Include RMagick or MiniMagick support:
# include CarrierWave::RMagick
include CarrierWave::MiniMagick
def extension_whitelist
%w[jpg jpeg gif png webp]
end
def content_type_whitelist
%r{image/}
end
# Process files as they are uploaded:
# process resize_to_fit: [200, 300]
# process resize_to_fit: [800, 800]
# Create different versions of your uploaded files:
version :small do
process resize_to_fit: [300, 300]
end
version :logo do
process resize_to_fit: [150, 150]
end
version :fixed do
process resize_and_pad: [400, 400]
end
version :high do
process quality: 90
end
version :medium do
process quality: 80
end
version :low do
process quality: 40
end
end
The ImageProcessingWorker
looked like:
class ImageProcessingWorker
include Sidekiq::Worker
sidekiq_options queue: 'carrierwave', retry: 10
def perform(upload_id)
object = Image.find(upload_id)
object.image = object.temp_image
object.save
raise ActiveRecord::RecordInvalid unless object.image.present?
end
end
And voila, now the response time improved drastically to below 100ms
.
I have many more interesting use cases of sidekiq|background processing
that I'll write up shortly.