Jun 30, 2015

Lazy image creation with Carrierwave

I don't know about you but I kind of grown to like Carrierwave. Yes, there are other uploaders on the block but that's not the point. Everybody I know are using it wrong.

If you are storing images on your own server I bet you are always concerned about versions. Let's say for an average web site you need at least three versions:

  • Featured: Big cropped wide screen image for heading
  • Default: Smaller image for gallery
  • Thumb: Thumbnail for the list

These are just for the sake of the example but there could be more. And for each image you need only a subset of versions. Only one image per article is displayed as a featured image, not every image goes to gallery etc. But you create them all anyway, because that's how it is.

Stop

You're doing it wrong. While versions creation is indeed the Carrierwave's responsibility, the decision on which version to create should not be made when image is uploaded. And certainly you don't need to create all versions at the same time or check if the file exists.

Come to think of it, you don't know upfront what version you might need so you create all possible versions or create some inventive schemes with conditional versioning thanks to Carrierwave abilities.

But in fact there is a better way. Lazily create only the versions that are actually being used just-in-time when the request comes.

Hammer time

Okay let's roll our sleeves. The idea is to create the route matching the image path. If the asset is missing the request falls through to Rails and image is created. Next time the request comes to this url, nginx takes care of serving the static asset.

To start off let's delay the versions creation until the moment we actually need them. Let's add the now modifier to uploader and make sure no versions are created until we say so:

# app/uploaders/image_uploader.rb

  version :default, if: :now? do
    ...
  end

  version :featured, if: :now? do
    ...
  end

  version :thumb, if: :now? do
    ...
  end

  def now
    @now = true
    self
  end

  protected

  def now?(_)
    @now
  end

In the Image class we are going to add a method that actually creates a version. All it does - sets @now to true and then calls recreate_versions! for a given version. Lazy, not crazy, we don't want to create anything uncontrollably.

# app/models/image.rb

  def create_version(process)
    file.now.recreate_versions!(process)
  end

I am using almost the out of the box uploader with the small addition. Here is an inflector that breaks long id strings so you don't end up with too many images in one folder.

# lib/inflector.rb

module Inflector
  def self.call(id)
    path = ''
    name = id.to_s.dup
    (name.size - 2).times { path += name.slice!(0) + '/' }
    path += id.to_s
  end
end
# spec/lib/inflector_spec.rb

require 'spec_helper'

describe Inflector do

  def inflect(id)
    Inflector.call id
  end

  specify do
    expect(inflect 1).to eq('1')
    expect(inflect 10).to eq('10')
    expect(inflect 100).to eq('1/100')
    expect(inflect 1_000).to eq('1/0/1000')
    expect(inflect 10_000).to eq('1/0/0/10000')
    expect(inflect 100_000).to eq('1/0/0/0/100000')
  end

end

Here is the store_dir from image_uploader.rb:

# app/uploaders/image_uploader.rb

  def store_dir
    "uploads/#{model.class.to_s.underscore}/#{Inflector.call model.id}"
  end

Let's write a routing test to make sure requests are landing right at our MissingController. Missing like "image is missing". And test a couple of routes with and without inflected paths just to be sure:

# spec/routing/missing_spec.rb

require 'rails_helper'

describe 'Routes', type: :routing do
  describe 'Missing Image' do
    specify do
      expect(
        get '/uploads/image/40/thumb_5f5e5c3b-a665-494c-9c21-be82d49f6451.jpg'
      ).to route_to(
        controller: 'missing',
        action: 'show',
        klass: 'image',
        id: '40',
        version: 'thumb',
        file: '5f5e5c3b-a665-494c-9c21-be82d49f6451',
        format: 'jpg'
      )
    end
  end

  specify do
    expect(
      get '/uploads/image/1/0/1003/featured_c3ad3865-93f0-4032-a6fe-5c2ada381456.JPG'
    ).to route_to(
      controller: 'missing',
      action: 'show',
      klass: 'image',
      inflection: '1/0',
      id: '1003',
      version: 'featured',
      file: 'c3ad3865-93f0-4032-a6fe-5c2ada381456',
      format: 'JPG'
    )
  end
end

Okay now it's time to make a route. Fortunately we can use constraints to make matters easier for everybody. Rails doesn't have to think how to parse it and here is what I came up with:

# config/routes.rb

# /uploads/block/image/1/default_7fd5acc8-5c06-4dcc-955e-f4fc5f76be01.jpg
get 'uploads/:klass/(*inflection)/:id/(:version)_(:file)',
    to: 'missing#show',
    constraints: {
        klass: /\D*/,   # anything but numbers
        id: /\d*/       # numbers only
    }

And the MissingController itself:

# app/controllers/missing_controller.rb

class MissingController < ApplicationController
  def show
    redirect_to env['PATH_INFO'] if create_version
  end

  private

  def create_version
    image.create_version(version)
  end

  def image
    params[:klass].camelize.constantize.find(params[:id])
  end

  def version
    params[:version].to_sym
  end
end

All it does - creates a version according to params and once image is created the same url is being requested again. But this time the created image will be served by nginx on top of the Rails app.

Nginx

If you have any special settings for static files in uploads folder you might want to amend your nginx config for the given site like so:

; /etc/nginx/sites-available/site

location ^~ /uploads/ {
    ...
    try_files $uri @backend;
}

location @backend {
      proxy_pass ...
}

Broken Images

Say you have some broken images that can't be recreated and causing errors. Let's get rid of them automatically. Via some augmentation of our MissingController we can do that now.

I would suggest to add some default "broken" image to /assets/images/layout/missing_image.png so you can see any sign that the image is deleted. Then we are changing the show method:

# app/controller/missing_controller.rb

class MissingController < ApplicationController

  EXCEPTIONS = [
    Errno::ENOENT, # Original Image not found
    CarrierWave::ProcessingError # Image is broken
  ]

  def show
    redirect_to env['PATH_INFO'] if create_version
  rescue *EXCEPTIONS
    image.destroy
    redirect_to self.class.helpers.asset_url('layout/missing_image.png')
  end

  private

  def create_version
    image.create_version(version)
  end

  def image
    params[:klass].camelize.constantize.find(params[:id])
  end

  def version
    params[:version].to_sym
  end
end

Now you have fast upload, delayed image creation and the nice part is that you can delete the whole uploads folder. Versions will be re-created. You can change formats on-the-fly when design changes. Broken files will be automatically deleted as well. In other words -- you don't need to worry about images.

This is pretty much the complete implementation taken out of one of the projects I am working on. Please let me know if that helped you. Thanks.

Hello!

Nick Ostrovsky

Full-stack designer. I like to keep things as minimal and as simple as possible.

Started to program with Sinclair Spectrum and has moved to user experience and interfaces since. Developed and contributed to projects for major brands such as L'Oreal, JTI and AB InBev among others. I am currently working as an independent contractor.

I live in Phuket, Thailand and enjoy the beautiful scenery with wife and two daughters.