Rails 7.2 × Cloudinary 完全実装ガイド
📚 目次
- はじめに
- Cloudinaryアカウント作成
- Gemのインストール
- Active Storageのセットアップ
- Cloudinaryの設定
- モデルの設定
- コントローラーの実装
- ビューの実装
- 画像表示の最適化
- 画像削除機能
- JavaScript実装(ドラッグ&ドロップ)
- パフォーマンス最適化
- 本番環境へのデプロイ
- トラブルシューティング
はじめに
初実装のため、Claudeに教科書作っていただきました。
アプリ開発、備忘録用にぜひ!
Cloudinaryを選ぶメリット
- ✅ 無料枠が充実(月25GB)
- ✅ 画像の自動最適化(WebP変換、圧縮)
- ✅ グローバルCDN標準搭載
- ✅ URLパラメータだけで画像変換可能
- ✅ 設定が簡単
Cloudinaryアカウント作成
手順
- Cloudinary公式サイトにアクセス
- 「Sign Up for Free」をクリック
- メールアドレスで登録(またはGitHub/Google連携)
- ダッシュボードで以下の情報を確認:
- Cloud name
- API Key
- API Secret
💡 これらの情報は後で環境変数として使用します。
Gemのインストール
Gemfileに追加
# Gemfile
# Cloudinary本体
gem 'cloudinary'
# Active StorageとCloudinaryを連携
gem 'activestorage-cloudinary-service'
# 環境変数管理(推奨)
gem 'dotenv-rails', groups: [:development, :test]
インストール実行
bundle install
Active Storageのセットアップ
インストール
# Active Storageをインストール
rails active_storage:install
# マイグレーション実行
rails db:migrate
これにより以下のテーブルが作成されます:
-
active_storage_blobs- ファイルのメタデータ -
active_storage_attachments- モデルとファイルの関連 -
active_storage_variant_records- 画像変換のキャッシュ
Cloudinaryの設定
1. 環境変数の設定
# .env ファイルを作成
touch .env
# .gitignoreに追加(重要!)
echo ".env" >> .gitignore
# .env
CLOUDINARY_CLOUD_NAME=your_cloud_name
CLOUDINARY_API_KEY=your_api_key
CLOUDINARY_API_SECRET=your_api_secret
2. storage.ymlの設定
# config/storage.yml
# 開発環境用(ローカルストレージ)
local:
service: Disk
root: <%= Rails.root.join("storage") %>
# 本番環境用(Cloudinary)
cloudinary:
service: Cloudinary
cloud_name: <%= ENV['CLOUDINARY_CLOUD_NAME'] %>
api_key: <%= ENV['CLOUDINARY_API_KEY'] %>
api_secret: <%= ENV['CLOUDINARY_API_SECRET'] %>
3. 環境別設定
# config/environments/development.rb
Rails.application.configure do
# 開発環境ではローカルストレージを使用
config.active_storage.service = :local
end
# config/environments/production.rb
Rails.application.configure do
# 本番環境ではCloudinaryを使用
config.active_storage.service = :cloudinary
end
モデルの設定
Gameモデルの例
# app/models/game.rb
class Game < ApplicationRecord
# 単一画像の場合(カバー画像など)
has_one_attached :cover_image
# 複数画像の場合(スクリーンショットなど)
has_many_attached :screenshots
# バリデーション
validates :title, presence: true
# 画像のバリデーション(オプション)
validates :cover_image,
content_type: ['image/png', 'image/jpg', 'image/jpeg', 'image/gif', 'image/webp'],
size: { less_than: 5.megabytes, message: '5MB以下にしてください' }
# Eager loading用スコープ
scope :with_images, -> {
includes(
cover_image_attachment: :blob,
screenshots_attachments: :blob
)
}
end
マイグレーションの作成
rails generate model Game title:string hardware:string genre:string played_age:integer memo:text
rails db:migrate
コントローラーの実装
GamesController
# app/controllers/games_controller.rb
class GamesController < ApplicationController
before_action :set_game, only: [:show, :edit, :update, :destroy]
def index
@games = Game.with_images.order(created_at: :desc)
end
def show
end
def new
@game = Game.new
end
def create
@game = Game.new(game_params)
if @game.save
redirect_to @game, notice: 'ゲームを登録しました'
else
render :new, status: :unprocessable_entity
end
end
def edit
end
def update
if @game.update(game_params)
redirect_to @game, notice: 'ゲームを更新しました'
else
render :edit, status: :unprocessable_entity
end
end
def destroy
@game.destroy
redirect_to games_path, notice: 'ゲームを削除しました'
end
private
def set_game
@game = Game.find(params[:id])
end
def game_params
params.require(:game).permit(
:title,
:hardware,
:genre,
:played_age,
:memo,
:cover_image, # 単一画像
screenshots: [] # 複数画像(配列)
)
end
end
ルーティング
# config/routes.rb
Rails.application.routes.draw do
resources :games do
member do
delete :remove_cover_image
delete 'remove_screenshot/:screenshot_id',
action: :remove_screenshot,
as: :remove_screenshot
end
end
root 'games#index'
end
ビューの実装
フォーム(新規作成・編集)
<!-- app/views/games/_form.html.erb -->
<%= form_with(model: game, local: true, html: { multipart: true, class: "space-y-6" }) do |form| %>
<% if game.errors.any? %>
<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded">
<h2 class="font-bold mb-2"><%= pluralize(game.errors.count, "error") %> がありました:</h2>
<ul class="list-disc list-inside">
<% game.errors.full_messages.each do |message| %>
<li><%= message %></li>
<% end %>
</ul>
</div>
<% end %>
<!-- タイトル -->
<div class="mb-4">
<%= form.label :title, "ゲームタイトル", class: "block text-sm font-medium mb-2" %>
<%= form.text_field :title,
class: "w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500",
placeholder: "例: ゼルダの伝説" %>
</div>
<!-- ハードウェア -->
<div class="mb-4">
<%= form.label :hardware, "ハードウェア", class: "block text-sm font-medium mb-2" %>
<%= form.text_field :hardware,
class: "w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500",
placeholder: "例: Nintendo Switch" %>
</div>
<!-- ジャンル -->
<div class="mb-4">
<%= form.label :genre, "ジャンル", class: "block text-sm font-medium mb-2" %>
<%= form.text_field :genre,
class: "w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500",
placeholder: "例: アクションRPG" %>
</div>
<!-- プレイした年齢 -->
<div class="mb-4">
<%= form.label :played_age, "プレイした年齢", class: "block text-sm font-medium mb-2" %>
<%= form.number_field :played_age,
class: "w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500",
placeholder: "例: 25" %>
</div>
<!-- カバー画像(単一) -->
<div class="mb-4">
<%= form.label :cover_image, "カバー画像", class: "block text-sm font-medium mb-2" %>
<div class="border-2 border-dashed border-gray-300 rounded-lg p-6 text-center hover:border-blue-500 transition drop-zone">
<%= form.file_field :cover_image,
accept: "image/png,image/jpg,image/jpeg,image/gif,image/webp",
class: "hidden",
id: "cover_image_input",
onchange: "previewCoverImage(event)" %>
<label for="cover_image_input" class="cursor-pointer">
<div class="text-gray-600">
<svg class="mx-auto h-12 w-12 text-gray-400" stroke="currentColor" fill="none" viewBox="0 0 48 48">
<path d="M28 8H12a4 4 0 00-4 4v20m32-12v8m0 0v8a4 4 0 01-4 4H12a4 4 0 01-4-4v-4m32-4l-3.172-3.172a4 4 0 00-5.656 0L28 28M8 32l9.172-9.172a4 4 0 015.656 0L28 28m0 0l4 4m4-24h8m-4-4v8m-12 4h.02" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
<p class="mt-1">クリックまたはドラッグ&ドロップで画像を選択</p>
<p class="text-xs text-gray-500 mt-1">PNG, JPG, GIF (最大5MB)</p>
</div>
</label>
</div>
<!-- 既存画像のプレビュー -->
<% if game.cover_image.attached? %>
<div class="mt-4 relative inline-block">
<%= image_tag game.cover_image.variant(resize_to_limit: [300, 300]),
class: "rounded-lg border border-gray-300 shadow-sm" %>
<%= link_to "削除",
remove_cover_image_game_path(game),
method: :delete,
data: { confirm: "本当に削除しますか?" },
class: "absolute top-2 right-2 bg-red-500 text-white px-3 py-1 rounded-lg text-sm hover:bg-red-600" if game.persisted? %>
</div>
<% end %>
<!-- 新規画像のプレビュー -->
<div id="cover_preview" class="mt-4"></div>
</div>
<!-- スクリーンショット(複数) -->
<div class="mb-4">
<%= form.label :screenshots, "スクリーンショット(複数選択可)", class: "block text-sm font-medium mb-2" %>
<div class="border-2 border-dashed border-gray-300 rounded-lg p-6 text-center hover:border-blue-500 transition">
<%= form.file_field :screenshots,
multiple: true,
accept: "image/png,image/jpg,image/jpeg,image/gif,image/webp",
class: "hidden",
id: "screenshots_input",
onchange: "previewScreenshots(event)" %>
<label for="screenshots_input" class="cursor-pointer">
<div class="text-gray-600">
<svg class="mx-auto h-12 w-12 text-gray-400" stroke="currentColor" fill="none" viewBox="0 0 48 48">
<path d="M28 8H12a4 4 0 00-4 4v20m32-12v8m0 0v8a4 4 0 01-4 4H12a4 4 0 01-4-4v-4m32-4l-3.172-3.172a4 4 0 00-5.656 0L28 28M8 32l9.172-9.172a4 4 0 015.656 0L28 28m0 0l4 4m4-24h8m-4-4v8m-12 4h.02" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
<p class="mt-1">クリックまたはドラッグ&ドロップで複数画像を選択</p>
<p class="text-xs text-gray-500 mt-1">PNG, JPG, GIF (各最大5MB)</p>
</div>
</label>
</div>
<!-- 既存画像のプレビュー -->
<% if game.screenshots.attached? %>
<div class="mt-4 grid grid-cols-2 md:grid-cols-4 gap-4">
<% game.screenshots.each_with_index do |screenshot, index| %>
<div class="relative group">
<%= image_tag screenshot.variant(resize_to_fill: [200, 200]),
class: "w-full h-48 object-cover rounded-lg border border-gray-300 shadow-sm" %>
<% if game.persisted? %>
<%= link_to "×",
remove_screenshot_game_path(game, screenshot_id: screenshot.id),
method: :delete,
data: { confirm: "本当に削除しますか?" },
class: "absolute top-2 right-2 bg-red-500 text-white w-8 h-8 rounded-full flex items-center justify-center text-xl hover:bg-red-600 opacity-0 group-hover:opacity-100 transition" %>
<% end %>
</div>
<% end %>
</div>
<% end %>
<!-- 新規画像のプレビュー -->
<div id="screenshots_preview" class="mt-4 grid grid-cols-2 md:grid-cols-4 gap-4"></div>
</div>
<!-- メモ -->
<div class="mb-4">
<%= form.label :memo, "メモ", class: "block text-sm font-medium mb-2" %>
<%= form.text_area :memo,
rows: 4,
class: "w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500",
placeholder: "ゲームについての感想やメモ" %>
</div>
<!-- 送信ボタン -->
<div class="flex gap-4">
<%= form.submit "保存",
class: "bg-blue-600 text-white px-6 py-3 rounded-lg font-medium hover:bg-blue-700 transition cursor-pointer" %>
<%= link_to "キャンセル",
games_path,
class: "bg-gray-200 text-gray-700 px-6 py-3 rounded-lg font-medium hover:bg-gray-300 transition" %>
</div>
<% end %>
<script>
// カバー画像のプレビュー
function previewCoverImage(event) {
const file = event.target.files[0];
const preview = document.getElementById('cover_preview');
if (file) {
const reader = new FileReader();
reader.onload = function(e) {
preview.innerHTML = `
<img src="${e.target.result}" class="rounded-lg border border-gray-300 shadow-sm max-w-xs">
`;
};
reader.readAsDataURL(file);
}
}
// スクリーンショットのプレビュー
function previewScreenshots(event) {
const files = event.target.files;
const preview = document.getElementById('screenshots_preview');
preview.innerHTML = '';
Array.from(files).forEach(file => {
const reader = new FileReader();
reader.onload = function(e) {
const div = document.createElement('div');
div.innerHTML = `
<img src="${e.target.result}" class="w-full h-48 object-cover rounded-lg border border-gray-300 shadow-sm">
`;
preview.appendChild(div);
};
reader.readAsDataURL(file);
});
}
</script>
一覧表示(Dashboard)
<!-- app/views/games/index.html.erb -->
<div class="container mx-auto px-4 py-8">
<div class="flex justify-between items-center mb-8">
<h1 class="text-3xl font-bold text-gray-800">ゲームコレクション</h1>
<%= link_to "新規登録",
new_game_path,
class: "bg-blue-600 text-white px-6 py-3 rounded-lg hover:bg-blue-700 transition" %>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<% @games.each do |game| %>
<div class="bg-white rounded-xl overflow-hidden shadow-lg hover:shadow-xl transition group">
<!-- カバー画像 -->
<%= link_to game_path(game) do %>
<% if game.cover_image.attached? %>
<%= image_tag game.cover_image.variant(resize_to_fill: [400, 300]),
class: "w-full h-48 object-cover group-hover:scale-105 transition duration-300",
loading: "lazy",
alt: game.title %>
<% else %>
<div class="w-full h-48 bg-gray-200 flex items-center justify-center">
<svg class="w-16 h-16 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"></path>
</svg>
</div>
<% end %>
<% end %>
<div class="p-5">
<!-- タイトル -->
<h3 class="text-xl font-bold mb-2 text-gray-800">
<%= link_to game.title, game_path(game), class: "hover:text-blue-600" %>
</h3>
<!-- メタ情報 -->
<p class="text-gray-600 text-sm mb-3">
<%= [game.hardware, game.genre, (game.played_age ? "#{game.played_age}歳" : nil)].compact.join(" / ") %>
</p>
<!-- スクリーンショットサムネイル -->
<% if game.screenshots.attached? %>
<div class="flex gap-2 mb-3">
<% game.screenshots.first(4).each do |screenshot| %>
<%= image_tag screenshot.variant(resize_to_fill: [80, 80]),
class: "w-16 h-16 object-cover rounded border border-gray-200",
loading: "lazy" %>
<% end %>
<% if game.screenshots.count > 4 %>
<div class="w-16 h-16 bg-gray-100 rounded border border-gray-200 flex items-center justify-center text-gray-500 text-sm">
+<%= game.screenshots.count - 4 %>
</div>
<% end %>
</div>
<% end %>
<!-- アクションボタン -->
<div class="flex gap-2 mt-4">
<%= link_to "編集",
edit_game_path(game),
class: "flex-1 text-center bg-gray-100 text-gray-700 px-4 py-2 rounded hover:bg-gray-200 transition text-sm" %>
<%= link_to "削除",
game_path(game),
method: :delete,
data: { confirm: "本当に削除しますか?" },
class: "flex-1 text-center bg-red-100 text-red-700 px-4 py-2 rounded hover:bg-red-200 transition text-sm" %>
</div>
</div>
</div>
<% end %>
</div>
<% if @games.empty? %>
<div class="text-center py-16">
<svg class="mx-auto h-24 w-24 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"></path>
</svg>
<h3 class="mt-4 text-xl font-medium text-gray-900">ゲームが登録されていません</h3>
<p class="mt-2 text-gray-500">最初のゲームを登録してみましょう!</p>
<%= link_to "新規登録",
new_game_path,
class: "mt-6 inline-block bg-blue-600 text-white px-6 py-3 rounded-lg hover:bg-blue-700 transition" %>
</div>
<% end %>
</div>
詳細表示
<!-- app/views/games/show.html.erb -->
<div class="container mx-auto px-4 py-8 max-w-4xl">
<div class="bg-white rounded-xl shadow-lg overflow-hidden">
<!-- カバー画像 -->
<% if @game.cover_image.attached? %>
<%= image_tag @game.cover_image.variant(resize_to_limit: [800, 600]),
class: "w-full h-96 object-cover",
alt: @game.title %>
<% end %>
<div class="p-8">
<!-- タイトル -->
<h1 class="text-4xl font-bold mb-4 text-gray-800"><%= @game.title %></h1>
<!-- メタ情報 -->
<div class="flex flex-wrap gap-4 mb-6">
<% if @game.hardware.present? %>
<span class="bg-blue-100 text-blue-800 px-4 py-2 rounded-full text-sm font-medium">
🎮 <%= @game.hardware %>
</span>
<% end %>
<% if @game.genre.present? %>
<span class="bg-purple-100 text-purple-800 px-4 py-2 rounded-full text-sm font-medium">
🎯 <%= @game.genre %>
</span>
<% end %>
<% if @game.played_age.present? %>
<span class="bg-green-100 text-green-800 px-4 py-2 rounded-full text-sm font-medium">
👤 <%= @game.played_age %>歳でプレイ
</span>
<% end %>
</div>
<!-- メモ -->
<% if @game.memo.present? %>
<div class="mb-6">
<h2 class="text-xl font-bold mb-2 text-gray-800">📝 メモ</h2>
<p class="text-gray-700 whitespace-pre-wrap"><%= @game.memo %></p>
</div>
<% end %>
<!-- スクリーンショット -->
<% if @game.screenshots.attached? %>
<div class="mb-6">
<h2 class="text-xl font-bold mb-4 text-gray-800">📸 スクリーンショット</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<% @game.screenshots.each do |screenshot| %>
<%= link_to screenshot.url, target: "_blank", class: "block group" do %>
<%= image_tag screenshot.variant(resize_to_fill: [400, 300]),
class: "w-full h-64 object-cover rounded-lg shadow-md group-hover:shadow-xl transition",
loading: "lazy" %>
<% end %>
<% end %>
</div>
</div>
<% end %>
<!-- アクションボタン -->
<div class="flex gap-4 mt-8">
<%= link_to "編集",
edit_game_path(@game),
class: "bg-blue-600 text-white px-6 py-3 rounded-lg hover:bg-blue-700 transition" %>
<%= link_to "削除",
game_path(@game),
method: :delete,
data: { confirm: "本当に削除しますか?" },
class: "bg-red-600 text-white px-6 py-3 rounded-lg hover:bg-red-700 transition" %>
<%= link_to "戻る",
games_path,
class: "bg-gray-200 text-gray-700 px-6 py-3 rounded-lg hover:bg-gray-300 transition" %>
</div>
</div>
</div>
</div>
画像表示の最適化
Helperメソッドの作成
# app/helpers/games_helper.rb
module GamesHelper
# サムネイル画像(正方形)
def game_thumbnail(game, size: 300)
return unless game.cover_image.attached?
image_tag game.cover_image.variant(
resize_to_fill: [size, size],
format: :webp,
quality: 80
),
class: "rounded-lg",
loading: "lazy"
end
# カバー画像(横長)
def game_cover(game, width: 800, height: 600)
return unless game.cover_image.attached?
image_tag game.cover_image.variant(
resize_to_fill: [width, height],
format: :webp,
quality: 85
),
class: "w-full",
loading: "lazy"
end
# レスポンシブ画像
def game_responsive_image(game, alt: game.title)
return unless game.cover_image.attached?
image_tag game.cover_image.variant(resize_to_limit: [800, 600]),
srcset: {
game.cover_image.variant(resize_to_limit: [400, 300]).url => '400w',
game.cover_image.variant(resize_to_limit: [800, 600]).url => '800w',
game.cover_image.variant(resize_to_limit: [1200, 900]).url => '1200w'
},
sizes: '(max-width: 640px) 400px, (max-width: 1024px) 800px, 1200px',
alt: alt,
loading: "lazy",
class: "w-full h-auto"
end
# 画像が存在するかチェック
def has_image?(game)
game.cover_image.attached?
end
end
使用例
<!-- ビューでの使用 -->
<%= game_thumbnail(@game, size: 200) %>
<%= game_cover(@game) %>
<%= game_responsive_image(@game) %>
画像削除機能
コントローラーに追加
# app/controllers/games_controller.rb
class GamesController < ApplicationController
# カバー画像を削除
def remove_cover_image
@game = Game.find(params[:id])
@game.cover_image.purge
redirect_to edit_game_path(@game), notice: 'カバー画像を削除しました'
end
# スクリーンショットを削除
def remove_screenshot
@game = Game.find(params[:id])
screenshot = @game.screenshots.find(params[:screenshot_id])
screenshot.purge
redirect_to edit_game_path(@game), notice: 'スクリーンショットを削除しました'
end
end
JavaScript実装
ドラッグ&ドロップ対応
// app/javascript/application.js
document.addEventListener('turbo:load', () => {
setupDragAndDrop();
});
function setupDragAndDrop() {
const dropZones = document.querySelectorAll('.drop-zone');
dropZones.forEach(dropZone => {
const fileInput = dropZone.querySelector('input[type="file"]');
if (!fileInput) return;
// ドラッグイベントのデフォルト動作を防止
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
dropZone.addEventListener(eventName, preventDefaults, false);
document.body.addEventListener(eventName, preventDefaults, false);
});
// ハイライト表示
['dragenter', 'dragover'].forEach(eventName => {
dropZone.addEventListener(eventName, () => {
dropZone.classList.add('border-blue-500', 'bg-blue-50');
}, false);
});
['dragleave', 'drop'].forEach(eventName => {
dropZone.addEventListener(eventName, () => {
dropZone.classList.remove('border-blue-500', 'bg-blue-50');
}, false);
});
// ドロップ処理
dropZone.addEventListener('drop', (e) => {
const files = e.dataTransfer.files;
fileInput.files = files;
// プレビュー表示をトリガー
const event = new Event('change', { bubbles: true });
fileInput.dispatchEvent(event);
}, false);
});
}
function preventDefaults(e) {
e.preventDefault();
e.stopPropagation();
}
パフォーマンス最適化
1. Eager Loadingの実装
# app/models/game.rb
class Game < ApplicationRecord
# ... 既存のコード ...
# 画像を含めて取得するスコープ
scope :with_images, -> {
includes(
cover_image_attachment: :blob,
screenshots_attachments: :blob
)
}
end
# app/controllers/games_controller.rb
class GamesController < ApplicationController
def index
# N+1問題を回避
@games = Game.with_images.order(created_at: :desc)
end
end
2. キャッシュの実装
# app/models/game.rb
class Game < ApplicationRecord
# サムネイルURLをキャッシュ
def thumbnail_url(size: 300)
Rails.cache.fetch(
['game_thumbnail', id, cover_image.blob.checksum, size],
expires_in: 1.day
) do
cover_image.variant(
resize_to_fill: [size, size],
format: :webp
).processed.url
end if cover_image.attached?
end
# カバー画像URLをキャッシュ
def cover_url
Rails.cache.fetch(
['game_cover', id, cover_image.blob.checksum],
expires_in: 1.day
) do
cover_image.variant(
resize_to_fill: [800, 600],
format: :webp
).processed.url
end if cover_image.attached?
end
end
3. ビューでのキャッシュ使用
<!-- app/views/games/index.html.erb -->
<% @games.each do |game| %>
<% cache ['game-card', game, game.cover_image&.blob] do %>
<div class="game-card">
<% if game.cover_image.attached? %>
<%= image_tag game.thumbnail_url(size: 400),
class: "w-full h-48 object-cover",
loading: "lazy" %>
<% end %>
<!-- ... 他のコンテンツ ... -->
</div>
<% end %>
<% end %>
本番環境へのデプロイ
1. 環境変数の設定(Heroku)
# Herokuで環境変数を設定
heroku config:set CLOUDINARY_CLOUD_NAME=your_cloud_name
heroku config:set CLOUDINARY_API_KEY=your_api_key
heroku config:set CLOUDINARY_API_SECRET=your_api_secret
2. credentials.yml.encを使用する方法
# credentialsを編集
EDITOR="code --wait" rails credentials:edit
# または vim を使用
EDITOR="vim" rails credentials:edit
# config/credentials.yml.enc に追加
cloudinary:
cloud_name: your_cloud_name
api_key: your_api_key
api_secret: your_api_secret
# config/storage.yml で参照
cloudinary:
service: Cloudinary
cloud_name: <%= Rails.application.credentials.dig(:cloudinary, :cloud_name) %>
api_key: <%= Rails.application.credentials.dig(:cloudinary, :api_key) %>
api_secret: <%= Rails.application.credentials.dig(:cloudinary, :api_secret) %>
3. デプロイ
# 変更をコミット
git add .
git commit -m "Add Cloudinary image upload feature"
# Herokuにプッシュ
git push heroku main
# マイグレーション実行
heroku run rails db:migrate
トラブルシューティング
よくある問題と解決方法
1. 画像が表示されない
原因: Active Storageが正しくインストールされていない
解決方法:
rails active_storage:install
rails db:migrate
2. Cloudinaryに画像がアップロードされない
原因: 環境変数が正しく設定されていない
解決方法:
# .envファイルを確認
cat .env
# Railsコンソールで確認
rails console
ENV['CLOUDINARY_CLOUD_NAME']
3. 画像変換が遅い
原因: 画像処理ライブラリの問題
解決方法:
# Gemfileに追加
gem 'image_processing', '~> 1.2'
# libvipsをインストール(Macの場合)
brew install vips
# config/application.rbに追加
config.active_storage.variant_processor = :vips
4. 開発環境で画像が表示されない
原因: storage.ymlの設定ミス
解決方法:
# config/environments/development.rb
config.active_storage.service = :local
5. 本番環境で画像が表示されない
原因: 環境変数が設定されていない、またはstorage.ymlの設定ミス
解決方法:
# Herokuで確認
heroku config
# 設定されていない場合
heroku config:set CLOUDINARY_CLOUD_NAME=your_cloud_name
heroku config:set CLOUDINARY_API_KEY=your_api_key
heroku config:set CLOUDINARY_API_SECRET=your_api_secret
まとめ
この記事では、Rails 7.2でCloudinaryを使った画像投稿機能の実装方法を解説しました。
実装のポイント
✅ Active StorageとCloudinaryの連携
✅ 単一画像と複数画像の扱い方
✅ 画像の最適化(WebP変換、リサイズ)
✅ パフォーマンス最適化(Eager Loading、キャッシュ)
✅ ドラッグ&ドロップ対応
✅ 本番環境へのデプロイ
次のステップ
- 画像の直接アップロード実装
- 画像の一括削除機能
- 画像のタグ付け機能
- AIによる画像認識・タグ自動生成
参考リンク: