📎

Railsでgem shrineを使ってみよう

に公開

はじめに

皆さんが開発している Rails アプリケーションに、ファイルアップロード機能はありますか?ファイルアップロード機能ではActive Storage や CarrierWave が有名ですよね?

今回、Shrineというファイルアップロード等に使用するgemに触れる機会があったので、基本的な説明から具体的な活用例までを紹介していきたいと思います。

1. Shrine とは?

ファイルアップロードを行う「gem」です。

公式サイト: https://shrinerb.com/

Shrine の公式サイトには、Shrine を次のように定義しています。

File attachment toolkit for Ruby applications (Ruby アプリケーションのためのファイル添付ツールキット)

これは、Rails に標準搭載されている Active Storage や、長年使われてきた CarrierWave とは根本的に異なる設計思想を示しています。

Active Storage は、Rails に統合されており、設定不要ですぐに使える反面、カスタマイズの自由度は低めです。CarrierWave は、高機能ですが、すべての機能が最初から詰め込まれており使わない機能も含まれがちです。

それに対し Shrineは、必要なものだけを自分で選んで組み立てるというモジュラーな思想で設計されています。

Shrine の本体は、ファイルのアップロードと保存という最小限の責務だけを持つように小さく保たれています。画像のリサイズ、MIME タイプの検証、ダイレクトアップロード、非同期処理といった、ファイルアップロード機能は、プラグインとして個別に提供されています。

これにより、アプリケーションに不要な機能や依存関係が含まれることなく、軽量でスリムな実装を維持できるという大きなメリットがあります。開発者は、プロジェクトの要件に合わせて、必要な道具だけをツールキットから取り出して使うことができます。

2 Shrine の設計思想

Shrineの設計思想のポイントに分けて見ていきます。

プラグインシステム

class ImageUploader < Shrine
  plugin :validation_helpers    # バリデーション機能
  plugin :store_dimensions      # 画像サイズの保存
  plugin :derivatives           # サムネイル生成
  plugin :backgrounding         # 非同期処理
end

Shrine の機能は、すべてが独立したモジュール(プラグイン)として提供されます。plugin メソッドで必要なものだけを明示的に読み込むことで、初めてその機能が Uploader クラスで有効になります。

例えば、動画ファイル用の Uploader を作る時、 :store_dimensions:derivatives(画像処理系)は不要かもしれません。その場合、それらを読み込まなければよいため、Uploader はその責務に集中したクリーンな状態を保つことができます。

ストレージの分離

Shrine は cache(一時)store(永続) という2つのストレージ概念を持ちます

Shrine.storages = {
  cache: Shrine::Storage::FileSystem.new("public", prefix: "uploads/cache"),
  store: Shrine::Storage::FileSystem.new("public", prefix: "uploads")
}
  • cache: バリデーション前の一時的な置き場所(バリデーション失敗時の再表示に便利)
  • store: バリデーション後の永続的な保存先

Uploader クラス

ファイル関連のロジック(画像処理、バリデーションなど)は、ImageUploader のような専用の Uploader クラスに分離できます。そうすることで、モデルにビジネスロジックとファイル処理ロジックが混在することを避け、モデルは本来の責務に集中でき、コードの見通しが良くなります。

class ImageUploader < Shrine
  # アップロードに関するロジックをここに集約
end

class Photo < ApplicationRecord
  include ImageUploader::Attachment(:image)
end

3. 基本的な使い方

実際にShrineを導入する手順をみていきましょう。

3.1 インストール

まず、Gemfile に shrine を追加します。

Gemfile

gem "shrine"
bundle install

3.2 初期設定

次に、config/initializers/shrine.rb を作成し、ストレージと基本的なプラグインを設定します。ここでは開発用にローカルのファイルシステムを利用します。

config/initializers/shrine.rb
require "shrine"
require "shrine/storage/file_system"

# ローカルストレージの設定
Shrine.storages = {
  cache: Shrine::Storage::FileSystem.new("public", prefix: "uploads/cache"),
  store: Shrine::Storage::FileSystem.new("public", prefix: "uploads")
}

# プラグインの読み込み
Shrine.plugin :activerecord # ActiveRecordとの連携を有効化

3.3 データベーススキーマの準備

アップロードされたファイルの情報を保存するためのカラムをモデルのテーブルに追加します。カラム名は[シンボル名]_dataという命名規則に従い、型はtextにします。

rails generate model Photo image_data:text
rails db:migrate
class CreatePhotos < ActiveRecord::Migration[8.1]
  def change
    create_table :photos do |t|
      t.text :image_data

      t.timestamps
    end
  end
end

3.4 Uploader の作成

ファイル関連のロジックを記述するUploaderクラスを作成します。

app/uploaders/image_uploader.rb
class ImageUploader < Shrine
  # ここに画像処理やバリデーションのロジックを追加していく
end

3.5 Model との連携

モデルファイル内で、作成したUploaderを include します。

app/models/photo.rb
class Photo < ApplicationRecord
  include ImageUploader::Attachment(:image) # :image というシンボルでアタッチ
end

この設定により、image_dataカラムに、アップロードされたファイルのメタデータが保存されます。
※ ここで、:image_dataではなく、:imageとすることに注意が必要です。

サンプルメタデータ
{
  "id": "path/to/image.jpg",
  "storage": "store",
  "metadata": {
    "filename": "original_filename.jpg",
    "size": 123456,
    "mime_type": "image/jpeg"
  }
}

この設定で以下のメソッドが使えるようになります:

  • photo.image - アップロードされたファイルオブジェクト
  • photo.image_url - ファイルの URL
  • photo.image_data - メタデータ

3.6 Controller / View

必要なControllerとViewを作成します。

app/controllers/photos_controller.rb
class PhotosController < ApplicationController
  def index
    @photos = Photo.all
  end

  def new
    @photo = Photo.new
  end

  def create
    @photo = Photo.new(photo_params)
    
    if @photo.save
      redirect_to @photo, notice: "Photo was successfully uploaded."
    else
      render :new, status: :unprocessable_entity
    end
  end

  def show
    @photo = Photo.find(params[:id])
  end

  private

  def photo_params
    params.require(:photo).permit(:image) #ここもimage_dataではなく:image
  end
end
app/views/photos/new.html.erb
<h1>Upload New Photo</h1>

<%= form_with model: @photo, local: true do |f| %>
  <%= f.label :image, "Select Image" %>
  <%= f.file_field :image, accept: "image/*" %>
  <%= f.submit "Upload Photo" %>
<% end %>

<br>
<%= link_to "Back to Photos", photos_path %>
app/views/photos/index.html.erb
<h1>Photo Gallery</h1>

<%= link_to "Upload New Photo", new_photo_path %>

<% if @photos.any? %>
  <div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 20px; margin-top: 20px;">
    <% @photos.each do |photo| %>
      <div style="border: 1px solid #ddd; padding: 10px;">
        <% if photo.image %>
          <%= link_to photo do %>
            <%= image_tag photo.image_url, alt: "Photo", style: "width: 100%; height: auto;" %>
          <% end %>
          <p style="margin-top: 10px; font-size: 12px;">
            <%= photo.image.original_filename %>
          </p>
        <% end %>
      </div>
    <% end %>
  </div>
<% else %>
  <p>No photos uploaded yet.</p>
<% end %>
app/views/photos/show.html.erb
<h1>Photo Details</h1>

<% if @photo.image %>
  <div>
    <h2>Original Image</h2>
    <%= image_tag @photo.image_url, alt: "Photo" %>
  </div>

  <div>
    <h3>Metadata</h3>
    <ul>
      <li>Filename: <%= @photo.image.original_filename %></li>
      <li>Size: <%= number_to_human_size(@photo.image.size) %></li>
      <li>MIME Type: <%= @photo.image.mime_type %></li>
    </ul>
  </div>
<% else %>
  <p>No image attached.</p>
<% end %>

<br>
<%= link_to "Upload Another Photo", new_photo_path %> |
<%= link_to "Back to Photos", photos_path %>

これで基本的なファイルアップロード機能が実装できました。


4 動作確認

サーバーを起動して動作確認を行いましょう。


画像を選択して、uploadしてみます。無事、画像ファイルがアップされたことが確認できたかと思います。

画像詳細画面

この時のログを確認すると、画像は以下の手順で処理されていることがわかります:

  1. "storage":"cache"に保存される。
TRANSACTION (0.0ms)  BEGIN immediate TRANSACTION 
Photo Create (4.1ms)  INSERT INTO "photos" ("image_data", "created_at", "updated_at") VALUES ('{"id":"65e369b9c98fc3b2e837d226a8cae11f.png","storage":"cache","metadata":{"filename":"neko.png","size":356755,"mime_type":"image/png","width":762,"height":743}}', '2025-10-25 10:44:49.378069', '2025-10-25 10:44:49.378069') RETURNING "id"
TRANSACTION (0.0ms)  COMMIT TRANSACTION
  1. バリデーション後にUPDATE処理が実行され、"storage":"store"に保存される。
TRANSACTION (0.0ms)  BEGIN immediate TRANSACTION 
  Photo Update (1.0ms)  UPDATE "photos" SET "image_data" = '{"id":"6efe6ff6616164d5d6040744896e0d5c.png","storage":"store","metadata":{"filename":"neko.png","size":356755,"mime_type":"image/png","width":762,"height":743}}', "updated_at" = '2025-10-25 10:44:49.390962' WHERE "photos"."id" = 13
TRANSACTION (0.0ms)  COMMIT TRANSACTION
  1. 処理が完了。
詳細ログ
  TRANSACTION (2.0ms)  BEGIN immediate TRANSACTION /*action='create',application='ShrineSample',controller='photos'*/
  ↳ app/controllers/photos_controller.rb:13:in 'PhotosController#create'
  Photo Create (4.1ms)  INSERT INTO "photos" ("image_data", "created_at", "updated_at") VALUES ('{"id":"65e369b9c98fc3b2e837d226a8cae11f.png","storage":"cache","metadata":{"filename":"neko.png","size":356755,"mime_type":"image/png","width":762,"height":743}}', '2025-10-25 10:44:49.378069', '2025-10-25 10:44:49.378069') RETURNING "id" /*action='create',application='ShrineSample',controller='photos'*/
  ↳ app/controllers/photos_controller.rb:13:in 'PhotosController#create'
  TRANSACTION (0.4ms)  COMMIT TRANSACTION /*action='create',application='ShrineSample',controller='photos'*/
  ↳ app/controllers/photos_controller.rb:13:in 'PhotosController#create'
  TRANSACTION (0.0ms)  BEGIN immediate TRANSACTION /*action='create',application='ShrineSample',controller='photos'*/
  ↳ app/controllers/photos_controller.rb:13:in 'PhotosController#create'
  Photo Update (1.0ms)  UPDATE "photos" SET "image_data" = '{"id":"6efe6ff6616164d5d6040744896e0d5c.png","storage":"store","metadata":{"filename":"neko.png","size":356755,"mime_type":"image/png","width":762,"height":743}}', "updated_at" = '2025-10-25 10:44:49.390962' WHERE "photos"."id" = 13 /*action='create',application='ShrineSample',controller='photos'*/
  ↳ app/controllers/photos_controller.rb:13:in 'PhotosController#create'
  TRANSACTION (0.0ms)  COMMIT TRANSACTION /*action='create',application='ShrineSample',controller='photos'*/

5. Shrine 様々なプラグイン

最初に説明したように、Shrineでは様々なプラグインが用意されています。今回はそのうち基本的な2つを紹介します。

5.1 画像処理 (Derivatives) 🖼️

image_processing gemとShrineの derivatives プラグインを組み合わせることで、画像サイズを変換することができます。例えばユーザーがアップロードした 1 枚の画像から、一覧ページ用の小さいサムネイル、詳細ページ用の中サイズ画像など、複数のバージョンを効率的に生成することができます。

image_processing Gem との連携

gem "image_processing"
class ImageUploader < Shrine
  plugin :validation_helpers #バリデーション
  plugin :derivatives # サムネイルを生成する機能

  # バリデーション
  Attacher.validate do
    validate_max_size 10 * 1024 * 1024 # 最大サイズ 10 MB
    validate_mime_type %w[image/jpeg image/png image/gif image/webp]
  end

  # サムネイル生成
  Attacher.derivatives do |original|
    magick = ImageProcessing::MiniMagick.source(original)

    {
      small:  magick.resize_to_limit!(300, 300),
      medium: magick.resize_to_limit!(500, 500)
    }
  end
end

Controller

サムネイル生成処理をコントローラーに追加

  def create
    @photo = Photo.new(photo_params)
    # Derivatives(サムネイル)を生成
    @photo.image_derivatives! 
    
    if @photo.save
      redirect_to @photo, notice: "Photo was successfully uploaded."
    else
      render :new, status: :unprocessable_entity
    end
  end

View

生成したサムネ画像は次のように表示できます。

<!-- 表示例 -->
<%= image_tag @photo.image_url(:small) %>
<%= image_tag @photo.image_url(:medium) %>

表示する際は:small:mediumといったImageUploaderクラス内のイメージ処理で定義したシンボルを指定する必要があります。

サンプル画像

app/views/photos/show.html.erb
<h1>Photo Details</h1>

<% if @photo.image %>
  <div>
    <h2>Original Image</h2>
    <%= image_tag @photo.image_url, alt: "Photo" %>
  </div>

  <% if @photo.image_derivatives %>
    <div>
      <h3>Thumbnails</h3>
      <% if @photo.image_derivatives[:small] %>
        <div>
          <p>Small (300x300)</p>
          <%= image_tag @photo.image_url(:small), alt: "Small thumbnail" %>
        </div>
      <% end %>

      <% if @photo.image_derivatives[:medium] %>
        <div>
          <p>Medium (500x500)</p>
          <%= image_tag @photo.image_url(:medium), alt: "Medium thumbnail" %>
        </div>
      <% end %>
    </div>
  <% end %>

  <div>
    <h3>Metadata</h3>
    <ul>
      <li>Filename: <%= @photo.image.original_filename %></li>
      <li>Size: <%= number_to_human_size(@photo.image.size) %></li>
      <li>MIME Type: <%= @photo.image.mime_type %></li>
    </ul>
  </div>
<% else %>
  <p>No image attached.</p>
<% end %>

<br>
<%= link_to "Upload Another Photo", new_photo_path %> |
<%= link_to "Back to Photos", photos_path %>

サムネ画像を生成した際、そのメタデータは次のような形でimage_data内に保存されます。
サムネ画像はderivativesというキーの中にsmallやmediumのサムネ画像のメタデータが保存されています。

※実際には(TEXT型カラムなどに)1行の文字列として保存されていますが、見やすいようにJSONとして整形しています。

{
   "id":"6efe6ff6616164d5d6040744896e0d5c.png",
   "storage":"store",
   "metadata":{
      "filename":"neko.png",
      "size":356755,
      "mime_type":"image/png",
      "width":762,
      "height":743
   },
   "derivatives":{
      "small":{
         "id":"ff6dbd3cd1d053b687928fd558ebf33c.png",
         "storage":"store",
         "metadata":{
            "filename":"image_processing20251103-39119-g0n78z.png",
            "size":59656,
            "mime_type":null,
            "width":300,
            "height":293
         }
      },
      "medium":{
         "id":"57b4a940f67274a042f4767a0ec6608c.png",
         "storage":"store",
         "metadata":{
            "filename":"image_processing20251103-39119-8din8.png",
            "size":141650,
            "mime_type":null,
            "width":500,
            "height":488
         }
      }
   }
}
詳細ログ
  Photo Update (0.6ms)  UPDATE "photos" SET "image_data" = '{"id":"6efe6ff6616164d5d6040744896e0d5c.png","storage":"store","metadata":{"filename":"neko.png","size":356755,"mime_type":"image/png","width":762,"height":743},"derivatives":{"small":{"id":"ff6dbd3cd1d053b687928fd558ebf33c.png","storage":"store","metadata":{"filename":"image_processing20251103-39119-g0n78z.png","size":59656,"mime_type":null,"width":300,"height":293}},"medium":{"id":"57b4a940f67274a042f4767a0ec6608c.png","storage":"store","metadata":{"filename":"image_processing20251103-39119-8din8.png","size":141650,"mime_type":null,"width":500,"height":488}}}}', "updated_at" = '2025-10-15 10:44:49.709091' WHERE "photos"."id" = 13 /*action='create',application='ShrineSample',controller='photos'*/

5.2 バリデーション

画像のバリデーションはplugin :validation_helpersAttacher.validateを組み合わせることで実装できます。ここでは画像サイズのバリデーションを実装していきます。

ImageUploaderの中にAttacher.validateブロックを追加してその中にバリデーションしたい内容を記載していきます。

class ImageUploader < Shrine
  plugin :validation_helpers #バリデーション

  # バリデーション
  Attacher.validate do
    # バリデーション項目を記述していく
  end
end

Shrineでは基本的なValidation Helpersが用意されています。

  • ファイルサイズ
  • MIME type
  • ファイル拡張子
  • 画像のサイズ(widthheight)

今回は次のようにファイルサイズのバリデーションを追加します。

class ImageUploader < Shrine
  plugin :validation_helpers #バリデーション

  # バリデーション
  Attacher.validate do
    validate_max_size 10 * 1024 * 1024 # 最大サイズ 10 MB
  end
end

viewファイル側もエラーメッセージを表示するように修正します。

pp/views/photos/new.html.erb
<h1>Upload New Photo</h1>

<%= form_with model: @photo, local: true do |f| %>
  <% if @photo.errors.any? %>
    <div style="color: red">
      <h2><%= pluralize(@photo.errors.count, "error") %> prohibited this photo from being saved:</h2>
      <ul>
        <% @photo.errors.full_messages.each do |message| %>
          <li><%= message %></li>
        <% end %>
      </ul>
    </div>
  <% end %>

  <div>
    <%= f.label :image, "Select Image" %>
    <%= f.file_field :image, accept: "image/*" %>
  </div>

  <div>
    <%= f.submit "Upload Photo" %>
  </div>
<% end %>

<br>
<%= link_to "Back to Photos", photos_path %>

10MB以上の画像をアップロードしようとすると次のようにバリデーションエラーが表示されるようになります。

6. まとめ

今回は、ファイルアップロード Gem「Shrine」について、その特徴的な設計思想から基本的な使い方、さらにはプラグインを活用した画像処理とバリデーションの実装までを紹介しました。

  • 「ツールキット」としての思想: 最小限のコアに必要な機能だけをプラグインで追加する設計。

  • クリーンな設計: cache / store の分離による堅牢なUXと、Uploader クラスによるモデルからのロジック分離。

  • 高い拡張性: derivatives(画像処理)や validation_helpers(バリデーション)など、豊富なプラグインで柔軟に要件に対応可能。

Shrine のポテンシャルはこれだけではありません。今回は紹介しきれませんでしたが、実運用では欠かせない以下のような、さらに強力なプラグインも用意されています。

  • S3 へのアップロード: 本番環境の定番である Amazon S3 への保存。

  • ダイレクトアップロード (Direct Uploads): サーバーを経由せずブラウザから直接 S3 にアップロード。

  • 非同期処理 (Backgrounding): Sidekiq などと連携し、重い画像処理をバックグラウンドジョブで実行する機能。

  • テスト: Shrine を使ったテストのサポート。

これらについても、また別の機会にご紹介できればと思います。 柔軟性とクリーンな設計を両立させた Shrine、ぜひ次のプロジェクトで検討してみてください。

合同会社春秋テックブログ

Discussion