Better File Uploads with Shrine: Uploader

This is 2nd part of a series of blog posts about Shrine. The aim of this series is to show the advantages of using Shrine over other file attachment libraries.


In the previous post I talked about motivation behind creating Shrine. In this post I want to show you the foundation that Shrine is built upon – storage, uploader and uploaded file.

Storage

A Shrine “storage” is a plain Ruby object which encapsulates managing files on a particular storage service (filesystem, S3 etc). The storage needs to respond to the following 5 methods:

class MyStorage
  def upload(io, id, **options)
    # uploads the `io` to the given location `id`
  end

  def url(id)
    # returns the URL to the file on location `id`
  end

  def open(id)
    # returns the file on location `id` as an IO-like object
  end

  def exists?(id)
    # returns whether storage has a file on location `id`
  end

  def delete(id)
    # deletes the file on location `id` from the storage
  end
end

Shrine storages are configured directly by passing options to new (inspired by Refile), and should be registered in Shrine.storages:

Shrine.storages[:s3] = Shrine::Storage::S3.new(
  access_key_id: "abc",
  secret_access_key: "xyz",
  region: "eu-west-1",
  bucket: "my-bucket",
)

Currently there are FileSystem, S3, Fog, Flickr, Cloudinary, Transloadit, Uploadcare, Imgix, GridFS and SQL storage for Shrine, so take your pick :wink:

You can also easily write your own storage, there is a guide for it, and a linter which will automatically test if your storage is working corrrectly.

Uploader

Uploaders are subclasses of Shrine, and they encapsulate uploading logic for a specific attachment (inspired by CarrierWave).

class ImageUploader < Shrine
  # image uploading logic goes here
end

Uploader objects act as wrappers around a storage, performing all functionality around uploading that is generic to any storage:

  • processing
  • extracting metadata
  • generating location
  • uploading (this is where the storage is called)
  • closing uploaded file

Uploaders are instantiated with the registered storage name:

Shrine.storages[:disk] = Shrine::Storage::FileSystem.new(...)

uploader = ImageUploader.new(:disk)
uploader.upload(image) #=> #<Shrine::UploadedFile>

Uploaders don’t know about models; they only take a file to be uploaded on the input, and return representation of the uploaded file on the output. As this suggests, uploaders are stateless, which makes their behaviour really easy to reason about.

Uploaded file

When a file is uploaded through the uploader, the #upload method returns a Shrine::UploadedFile value object. This object is a complete representation of the file that was uploaded to the storage.

uploaded_file = uploader.upload(image) #=> #<Shrine::UploadedFile>

uploaded_file.id       #=> "43ksd9gkafg0dsl.jpg"
uploaded_file.storage  #=> #<Shrine::Storage::FileSystem>
uploaded_file.metadata #=> {...}

Since this object knows which storage it was uploaded to, it is able to provide many useful methods:

uploaded_file.url               # generates the URL
uploaded_file.download          # downloads the file to the disk
uploaded_file.exists?           # asks the storage if file exists
uploaded_file.open { |io| ... } # opens the file for reading
uploaded_file.delete            # deletes the file from the storage

This object is defined solely by its data hash. Since the storage can be referenced by its registered name, this hash can now be serialized into JSON, and saved to a database column.

uploaded_file.data #=>
# {
#   "id"       => "df9fk48saflg.jpg",
#   "storage"  => "disk",
#   "metadata" => {...}
# }

uploaded_file.to_json #=> '{"id":"df9fk48saflg.jpg","storage":"disk","metadata":{...}}'

The Shrine::UploadedFile objects are separate from uploaders. This is a contrast to CarrierWave and Paperclip, which have this behaviour mixed in into their CarrierWave::Uploader::Base and Paperclip::Attachment god classes.

IO abstraction

Shrine is able to upload any IO-like object which responds to #read, #size, #rewind, #eof? and #close (inspired by Refile). By definining this strict interface, every Shrine feature now knows they can rely only on these methods, which means they will work correctly regardless of whether you’re uploading File, StringIO, ActionDispatch::Http::UploadedFile, Rack files, or remote files which download themselves as you read them.

Furthermore, Shrine::UploadedFile is itself an IO-like object, wrapping any uploaded file under the same unified interface. This makes reuploading the file from one storage to another really natural. Furthermore, this allows the storage to optimize some uploads by skipping downloading & reuploading, for example use an S3 copy if both files are from S3, or just send the remote URL if the storage supports it.

cache = ImageUploader.new(:s3_temporary)
cached_file = cache.upload(image)

store = ImageUploader.new(:s3_permanent)
store.upload(cached_file) #=> performs an S3 COPY request

Plugin system

Shrine comes with a small core (< 500 LOC) which provides the essential functionality. Any additional features can be loaded via plugins. This gives you the flexibility to choose exactly what and how much Shrine does for you, and load the code only for features that you use.

# Loads the processing feature from "shrine/plugins/logging.rb"
Shrine.plugin :logging, logger: Rails.logger

Shrine ships with over 35 plugins, and it’s easy to write your own. Shrine’s plugin system is an adaptation of Roda’s, which I wrote about in the past.

Also, Shrine uploaders respect inheritance (unlike CarrierWave).

Shrine.plugin :logging # enables logging for all uploaders

class ImageUploader < Shrine
  plugin :backup # stores backups only for this uploader (and its descendants)
end

Dependencies

Most file attachment libraries have pretty heavy dependencies.

  • CarrierWave
    • ActiveSupport – I really don’t want all those monkey patches
    • ActiveModel – Why not implement validations without a library?
    • MIME::Types – It’s better to determine MIME type from file content
  • Paperclip
    • ActiveSupport – Again, I want to have a choice of not having any monkey patches
    • ActiveModel – Ok, both AM and AS are required by ActiveRecord anyway
    • Cocaine – Open3 is already a great standard library for running shell commands
    • MIME::Types – The MIME type spoofing detection has proven very unreliable anyway
    • MimeMagic – I’m already very satisfied with the file utility
  • Refile
    • RestClient – Heavy dependency to use just for downloading
    • Sinatra – That’s fine, although Roda is a much lighter dependency
    • MIME::Types – It’s better to determine MIME type from file content

Shrine, on the other hand, has only one mandatory lightweight dependency – Down. Down is a net/http wrapper for downloading files, which improves upon open-uri and has support for streaming downloads, and is used by almost every Shrine storage.

Furthermore, Shrine in general loads really fast, because you’re loading code only for features that you use. Other file attachment libraries require you to load code for many features that you might not need. To illustrate, Shrine loads 35x faster than CarrierWave without any plugins loaded, and 7x faster with all plugins loaded (source).

Conclusion

Every high-level interface should have good foundation. That way whichever level of abstraction you need to drop to, you can always understand what’s going on. Shrine’s foundation is composed out of Storage, Shrine and Shrine::UploadedFile classes, each having well-defined responsibilities and interface.

In the next post I will talk about Shrine’s high-level attachment interface, and again compare it to existing file upload libraries, so stay tuned!

Janko Marohnić

Janko Marohnić

A passionate Ruby backend developer who fell in love with Roda & Sequel, and told Rails “it’s not me, it’s you”. He enjoys working with JSON APIs and SQL databases, while prioritizing testing, and always tries to find the best library for the job. Creator of Shrine and test.vim.

comments powered by Disqus