1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【Rails 7】Active Storage × Cloudinary で画像保存+libvipsで画像フォーマット

Last updated at Posted at 2025-06-16

はじめに

こんにちは、こんばんは、初めまして。
プログラミングスクールRUNTEQで学習し、現在就活フェイズに入ったmassanです。

先週の記事でも触れましたが、現在RUNTEQのアウトプットリレーという企画に参加しており、毎週記事を書いています。

今週からテーマがフリーになるので、今後は自分がRUNTEQで学んだことなどをアウトプットしていこうかなと考えています

今回も僕のRUNTEQでの卒業制作、「Music Hour」で実装したことについてアウトプットがてら記事にしていきます
Music Hourについては先週の記事にざっくりと書いてあるのでそちらをご覧ください

今回は画像投稿機能の実装ですが、タイトルの通り、スタンダードな構成とは少し違っており、逆張り的な構成での実装になっています。
この構成に至った経緯などはNoteにまとめました

本文のほとんどで実装手順を書いていき、最後のまとめでざっくりと今回の構成とスタンダードな構成の違いについて感想を書こうと思います。

対象読者

  • MVCやRailsの基本的な動作が理解できる
  • 画像投稿機能や関連するgemがある程度わかるもしくは自分で調べられる
  • 画像投稿機能実装してみたい
  • libvipsを用いた画像処理について知りたい
  • スタンダードな構成ではなく別の構成の画像投稿機能も試してみたい
  • Music Hourの画像投稿機能について知りたい

この記事を書くのが精いっぱい、かつ長くなりそうだったのでCarrierWaveやMiniMagick、ruby-vipsがどんなものかについては触れることができませんでした。申し訳ありません🙇‍♂️

環境

  • Windows11
  • Ubuntu 24.04 LTS (WSL2)
  • Docker
  • Ruby 3.3.6
  • Rails 7.2.1

今回の目標

  • Active Storageを用いて、レコードに関連付けた画像をCloudinaryに保存できるようにする
  • livbipsを用いて画像を保存する前にリサイズと形式の変換(webpへ変換)をする

実装

画像保存部分の設定

Active Storageの準備

Gemfileで必要なgemを追加します

Gemfile
# Rails標準のActiveStorageで使うので標準で用意されているが、コメントアウトされているので外す
gem "image_processing", "~> 1.2"

gemfileに記述終わったらbundle install

# ローカルの場合はbundle installのみ
$ docker compose run web bundle install

ちなみにimage_processingをインストールするとmini_magickもインストールされています(gemfile lockを確認)


必要なgemのインストールが終わったら、次はActiveStorageの有効化を行っていきます

# コンテナに入る
$ docker compose exec web bash
# コンテナ内で(ローカルの場合はここから)実行
$ rails active_storage:install  # 自動的にActive Storage用のマイグレーションファイルが生成される
$ rails db:migrate  # マイグレーションファイルは特に編集せずにmigrate

モデルに以下を追加して画像を保存できるようにします。今回は例としてpostモデルにします。

app/models/post.rb
class Post < ApplicationRecord
# 省略
  has_one_attached :image   # imageはカラム名
# 省略
end

has_one_attachedは読んで字のごとく一つの画像のみを保存するためのものです
複数保存したい場合はhas_many_attachedを使用してください

参考:https://railsguides.jp/active_storage_overview.html#%E3%83%95%E3%82%A1%E3%82%A4%E3%83%AB%E3%82%92%E3%83%AC%E3%82%B3%E3%83%BC%E3%83%89%E3%81%AB%E6%B7%BB%E4%BB%98%E3%81%99%E3%82%8B


画像用のバリデーションを追加

ActiveStorageは標準のバリデーションメソッドを用意してくれていないのでgemを使って直感的にバリデーションをかけられるようにします
いくつかありましたが今回はgem ActiveStorage Validationを使うことにします

Gemfileに追記しbundle installを行います

Gemfile
gem 'active_storage_validations'
$ docker compose run web bundle install

Postモデルに画像用バリデーションの記述追加

app/models/post.rb
class Post < ApplicationRecord
  # 省略
  has_one_attached :image   # さっき追加したやつ
  
  # ファイルの種類とサイズのバリデーション(gem ActiveStorage Validationを使用)
  ACCEPTED_CONTENT_TYPES = %w[image/jpeg image/png image/gif image/webp].freeze
  validates :image, content_type: ACCEPTED_CONTENT_TYPES,
                    size: { less_than_or_equal_to: 5.megabytes }

  # 省略
end

image/jpeg, image/png, image/gif, image/webpのみを受け付けるようにし、ファイルサイズも5MB以下のものを受け付けるようにします

このコードのように許可されたファイルの種類を定数として保存しておけば、viewのformでも再利用できますし、バリデーションを変更した場合にも即時にviewのformに反映させることができます。(ってgemのページに書いてありました。笑)


:imageをストロングパラメーターに追加

postsコントローラーで:imageを受け取れるようにストロングパラメーターに追加します

app/controllers/psots_controller.rb
class PostsController < ApplicationController
  # 省略
  private
  # 省略
    def post_params
      params.require(:post).permit(:title, :body, :image)
    end
end

formに画像投稿用のものを追加

app/views/posts/_form.html.erb
  <%= form_with(model: post) do |form| %>
    <% if post.errors.any? %>
      <div>
        <%= pluralize(post.errors.count, "error") %>
        <ul>
          <% post.errors.each do |error| %>
            <li><%= error.full_message %></li>
          <% end %>
        </ul>
      </div>
    <% end %>
    
    <%# 省略 %>
    
    <%= form.label :image, "画像", class: "form-label" %>
    <%= form.file_field :image,
        class: "form-control",
        accept: Post::ACCEPTED_CONTENT_TYPES.join(",")
        %>
    <small class="form-text text-white-50">
      ※ JPEG、PNG、GIF、WebP形式(5MB以下)のみアップロード可能です
    </small>

    <%# 省略 %>
    
    <%= form.submit %>
  <% end %>

ile_fieldのオプションで先ほどの定数を用いてaccept: Post::ACCEPTED_CONTENT_TYPES.join(",")のように設定しています
これにより最初に指定したファイルしかリストに現れないのでユーザビリティが向上します。


i18nにエラーメッセージ追加

※追加しなくても自動でgem "active_storage_validations”が用意してくれてるけど違和感あるので追加します。

https://github.com/igorkasyanchuk/active_storage_validations?tab=readme-ov-file#internationalization-i18n

ja:
  activerecord:
    # 省略
    errors:
      models:
        # 省略
        post:
          # 省略
            image:
              content_type_invalid: "はJPEG、PNG、GIF、WebP形式のみアップロード可能です"
              file_size_not_less_than_or_equal_to: " %{max}以下にしてください (現在のサイズは %{file_size})"

ちなみにですが、このActiveStorage Validationはテスト用のマッチャーも用意してくれていて、そのテスト内容もとてもしっかりしています
gem shoulda-matchersと併用するととても簡単に画像アップロード時のテストが書けるので超おすすめです!
今回は扱いませんがCarrierWaveにも同じようなバリデーション用のgemがあったのでもしかすると同じようなことができるかもしれません

参考:
https://github.com/igorkasyanchuk/active_storage_validations?tab=readme-ov-file#test-matchers
https://github.com/thoughtbot/shoulda-matchers


Cloudinary用の設定

Active StorageでCloudinaryに保存するための各種設定を追加していきます

事前に以下の動画を参考にユーザー登録とapiキーなどの取得を行っておいてください

まずは必要なgemをGemfileに追加してbundle installします

Gemfile
# 画像保存用ストレージcloudinaryのgem
gem "cloudinary"
$ docker compose run web bundle install

config/storage.ymlにcloudinaryを使うときの設定を追加します

config/storage.yml
cloudinary:
  service: Cloudinary

config/environments/production.rbconfig/environments/development.rbにActiveStorage用の設定が出てきてるはずなので以下のように編集します 
※ develop環境で必要なければ config/environments/development.rbのモノはデフォルトのままでOK

- config.active_storage.service = :local       # 削除
+ config.active_storage.service = :cloudinary  # 追加

cloudinaryの設定ファイルをconfig以下に作成&記述します

$ touch config/cloudinary.yml
config/cloudinary.yml
production:
  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) %>
  secure: true

development:
  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) %>
  secure: true

上記で使う秘匿情報をクレデンシャルファイルconfig/credentials.yml.encに追加

# bashに入ってからvimでcredentialsを開く
$ docker compose exec web bash
EDITOR="vi" rails credentials:edit
config/credentials.yml.enc
# cloudinaryの秘匿情報
cloudinary:
  cloud_name: "hoge"
  api_key: "huga"
  api_secret: "uho"

※cloud_nameはCloudinaryのダッシュボードから確認できます


ここまで設定できればOK!

ここまで設定できていれば画像の投稿はできると思います!

ですがこのままだとアップロードされた画像をそのまま保存しているのでDBに負担がかかったり、画像の拡張子がwebpでなかったりする場合があるので、この画像を表示する画面の読み込みが遅くなったりするということがあります。

画像サイズをそろえたりwebpに変換するだけならばCloudinaryの機能で可能ですが、大きすぎる画像は必要ありませんし、DBの容量も節約したいので保存前にリサイズ&形式の変換もしてしまいましょう。

cloudinaryに画像を保存していた場合、表示する際に使う配信リンクにクエリパラメータでオプションを追加して送信することで画像のリサイズや変換、文字の挿入ができたりします。
特に文字の挿入はとても便利で、Music Hourでもこの機能を用いて動的OGPを設定しています

参考:
https://dev.classmethod.jp/articles/cloudinary-transform-images/
https://pote-chil.com/posts/cloudinary


保存前の画像フォーマット

Active Storageには残念ながらデフォルトでの保存前の画像フォーマットがありません。

なので今回はgem image_processing,gem ruby-vipsを通してlibvipsを使い、画像のリサイズと変換を実装していきます

今後のために、postだけでなく他のモデル、例えばuserモデルでプロフィール画像を設定したい場合などでも使えるようにしておきたいので、今回はRailsのお作法に則ってapp/models/concerns配下にモジュールを追加しようと思います


libvipsの準備

libvipsという画像ライブラリが必要なのですがこれは直接インストールしておかないといけないのでDockerを用いている今回はDockerfileに記述を追加してインストールします。
※ Dockerでない場合はHomebrewなどを用いてインストールしてください

Dockerfile
FROM ruby:3.3.6
ENV LANG C.UTF-8
ENV TZ Asia/Tokyo
RUN apt-get update -qq \
&& apt-get install -y ca-certificates curl gnupg \
&& mkdir -p /etc/apt/keyrings \
&& curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg \
&& NODE_MAJOR=20 \
&& echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_$NODE_MAJOR.x nodistro main" | tee /etc/apt/sources.list.d/nodesource.list \
&& wget --quiet -O - /tmp/pubkey.gpg https://dl.yarnpkg.com/debian/pubkey.gpg | apt-key add - \
&& echo "deb https://dl.yarnpkg.com/debian/ stable main" | tee /etc/apt/sources.list.d/yarn.list
RUN apt-get update -qq && apt-get install -y build-essential libpq-dev nodejs yarn vim

# ここから

RUN apt-get update && \
    apt-get install -y libvips42 libvips-dev && \
    apt-get clean && \
    rm -rf /var/lib/apt/lists/*

# ここまで追記

RUN mkdir /myapp
WORKDIR /myapp
RUN gem install bundler
COPY Gemfile /myapp/Gemfile
COPY Gemfile.lock /myapp/Gemfile.lock
COPY yarn.lock /myapp/yarn.lock
RUN bundle install
RUN yarn install
COPY . /myapp

追記ができたらビルドをし直します。

$ docker-compose build --no-cache

次に、バリアント作成時の処理で使うバリアントプロセッサの設定をvipsにしておきます
今回はバリアントを使いませんが念のためです

config/application.rb
require_relative "boot"

require "rails/all"

Bundler.require(*Rails.groups)

module Myapp
  class Application < Rails::Application
    # 省略
    config.active_storage.variant_processor = :vips   # 追加
  end
end

これでlibvipsの設定は完了しました。
次は実際に画像の処理のコードを追加していきます。


画像処理ロジックの追加

app/models/concerns配下にimage_processable.rbを作成し、ロジックを追加していきます。

$ touch app/models/concerns/image_processable.rb
app/models/concerns/image_processable.rb
module ImageProcessable
  # エラーの出力を行いたいのでStandardErrorクラスをImageProcessingErrorクラスに継承
  class ImageProcessingError < StandardError; end

  # 画像処理メソッド。image_ioにはparams[:post][:image]の中の一時ファイルを渡す
  # widthには横幅の最大値を渡す
  def process_and_transform_image(image_io, width)
    return unless image_io.present?

    begin
      # 画像処理の部分。横幅に合うようにアスペクト比を維持してリサイズ、その後webpに変換する
      processed_image = ImageProcessing::Vips
        .source(image_io)
        .resize_to_fit(width, nil)
        .convert("webp")
        .saver(strip: true, quality: 85)
        .call

      # ActionDispatch::Http::UploadedFileを返す
      ActionDispatch::Http::UploadedFile.new(
        tempfile: processed_image,
        filename: "#{File.basename(image_io.original_filename, '.*')}.webp",
        type: "image/webp"
      )
    rescue => e
      Rails.logger.error "Image processing error: #{e.message}"
      raise ImageProcessingError, "画像の処理中にエラーが発生しました: #{e.message}"
    end
  end
end

画像処理部分は以下を参考に追加
https://github.com/janko/image_processing/blob/master/doc/vips.md#readme

  • .source 読み込み
  • .resize_to_fit(width, nil) リサイズ
  • .convert("webp") webpに変換
  • .saver(strip: true, quality: 85) 保存時の設定
    • strip: true 画像のメタデータを削除。プライバシー保護の目的とデータ量の削減
    • quality: 85 画像の圧縮品質を85%に設定
  • .call 実際に保存を実行

これを一時ファイルを管理しているActionDispatch::Http::UploadedFile.newのインスタンス化し、返す

といったことを行っています

ActionDispatch::Http::UploadedFileについては以下を参照

要するにアップロードされたファイルをRailsで扱いやすいようにしてくれるものです(ざっくり)


コントローラーの記述

画像処理を画像の保存時に実行したいのでコントローラーを少し変更します

app/controllers/posts_controller.rb
class PostsController < ApplicationController
  before_action :set_post, only: %i[ show edit update destroy ]

  # 省略

  def create
    # まずは属性の更新のみを行う
    @post = current_user.posts.build(post_params)

    if @post.valid?
      begin
        # 画像を処理して更新
        @post.image = ImageProcessable.process_and_transform_image(params[:post][:image], 854) if params[:post][:image].present?
        if @post.save
          flash[:notice] = "番組を作成しました"
          redirect_to @post
        end
      # モジュールで設定したエラーのキャッチ
      rescue ImageProcessable::ImageProcessingError => e
        flash.now[:danger] = e.message
        render :new, status: :unprocessable_entity
      end
    else
      flash.now[:danger] = "番組を作成できませんでした、番組作成フォームを確認してください"
      render :new, status: :unprocessable_entity
    end
  end

  def update
    @post.assign_attributes(post_params)

    if @post.valid?
      begin
        @post.image = ImageProcessable.process_and_transform_image(params[:post][:image], 854) if params[:post][:image].present?
        if @post.save
          flash[:notice] = "番組を編集しました"
          redirect_to @post
        end
      rescue ImageProcessable::ImageProcessingError => e
        flash.now[:danger] = e.message
        render :edit, status: :unprocessable_entity
      end
    else
      flash.now[:danger] = "番組を編集できませんでした、番組編集フォームを確認してください"
      render :edit, status: :unprocessable_entity
    end
  end

  private

    def set_post
      @post = Post.find(params[:id])
    end

    def post_params
      params.require(:post).permit(:title, :body, :image)
    end
end

まず保存はせず属性の更新のみ行います
@post.valid?でのバリデーションチェックを通過した場合
ImageProcessable.process_and_transform_image(params[:post][:image], 854) if params[:post][:image].present?
の部分で画像を処理し、帰ってきた一時ファイルでimgaeカラムを更新して保存
といった流れになっています。

imageカラムはActiveStorage::Attached::Oneのオブジェクトで フォームから渡されたファイルはActionDispatch::Http::UploadedFileのオブジェクトになるので本来なら直接渡せません。
しかしActive Storageでは、@post.image = uploaded_fileと代入すると、直接的に@post.imageに設定するのではなく、「添付予定」として保存される仕組みになっているためエラーが発生せず、見た目上は他のカラムと同じように扱えるようになっています。

参考:
https://github.com/rails/rails/blob/22a61df687a5408a37ed0b9dcf73c08b250dc9be/activestorage/lib/active_storage/attached/model.rb#L118
https://zenn.dev/masato_kato/scraps/06a5daff59c73f

画像処理ロジック追加完了!

これで画像処理ロジックの追加が完了しました
画像を保存すれば自動的にリサイズと変換が行われ、保存されます


まとめ

これで画像アップロード機能と保存時の画像フォーマットが完成しました
ここまでやってみてスタンダードな構成と今回の構成を比べた感想としては

  • 画像処理など複雑な処理をするならcarrierwaveのほうがよさそう(デフォルトで画像処理する設定を書く部分がある)
  • Active Storageは簡単に設定できて画像保存ロジックも直感的
  • Active Storageには画像のキャッシュ機能がないためバリデーションエラー時に画像を選択しなおさないといけない
  • 上記の理由からActive Storageよりもcarrierwave使ったほうがよさげ
  • MiniMagickとruby-vipsは互換性が高く簡単に入れ替えられるためどちらでもよさげ(脆弱性や処理速度を考えるとruby-vipsが好感触)
  • S3とcloudinaryは好み(そもそも僕がそんなに深堀りしていないので・・・w)

という感じでした

今回、スタンダードな構成ではなく逆張り的な構成で実装してみたおかげで、なぜスタンダードな構成がよく用いられるのか分かった気がしました。

今後もただ単に流行っているものやスタンダードなものを選んで使うだけでなく、よく使われるものはなぜよく使われるのか、なぜ流行っているのかといったことにもしっかりと目を向けて深堀りをしていきたいと思いました。

こういうのを理解するために、逆張り実装、おすすめです。

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?