[Rails] ActiveStorageを使って画像を複数枚アップロード&削除機能

学習記録

はじめに

ActiveStorageを使って画像を複数枚アップロード&削除機能を実装していきます。

複数枚のアップロードに対応した画像を設定

Siteモデルがあるとします。site に複数枚の画像アップロードに対応したmain_imagesを持たせたいので、has_many_attached で設定します。

class Site < ApplicationRecord
  has_many_attached :main_images
end

has_many_attached: レコードとファイルの間に1対多の関係を設定します。各レコードには、多数の添付ファイルをアタッチできます。

ストロングパラメータに追記

トップ画像用のmain_imagesをストロングパラメータに追記します。

def site_params
  params.require(:site).permit(:name, :subtitle, :description, :favicon, :og_image, main_images: [])
end

main_images: []: 複数画像が入ることを想定するので配列を渡しています。末尾に追加していきます。

ビューファイル

= f.input :main_images, as: :file, hint: 'JPEG/PNG (1200x400)', input_html: { multiple: true }     # 入力フォーム部分(simple_form使用)

- if @site.main_images.attached?                  # 画像がある場合、
  .main_images_box
    - @site.main_images.each do |main_image|      # 配列の中の画像をeach文で回す
      .main_image
        = image_tag main_image.variant(resize: '300x100').processed  

multiple: true: simple_formには無い機能なので、input_htmlと記述することで、有効になります。
variant: 画像のサイズを変更するにはresizeを引数として渡します。高さを100pxとして指定していますが、横幅を優先した状態でアスペクト比を保つため、高さは指定したとおりにはなりません。
processed: すでにそのサイズで保存されて画像があれば、変換処理は行われず、即時にURLが返されます。

ActiveStorageのカスタムバリデーション

実はActiveStorageは非常に便利な機能なのですが、バリデーション機能がありません。現状、手動で作成しなければいけません。(2021.11月現在) 複数画像に対応するようなカスタムバリデーターを作成しましょう。

モデルにバリデーションを定義

まずはSiteモデルにバリデーションを定義します。

validates :main_images, attachment: { purge: true, content_type: %r{\\Aimage/(png|jpeg)\\Z}, maximum: 524_288_000 }

content_type: 画像のファイルタイプ
maximum: 画像のファイルサイズ

カスタムバリデーター

個別のバリデーションをattachment_validator.rbに記入していきます。 モデルで定義した2つのメソッドを定義していきます。 クラス名は<検証名>Validatorの形式で命名し、ActiveModel::EachValidatorクラスを継承させます。 また、必ずvalidate_eachメソッドを実装する必要があることに注意しましょう。


class AttachmentValidator < ActiveModel::EachValidator                 # モデルで呼び出す際はattachmentで呼び出す
  include ActiveSupport::NumberHelper

  def validate_each(record, attribute, value)                          # recordにはモデルのインスタンス、attributeには属性名、valueには属性値 が入る
    return if value.blank? || !value.attached?                         # 属性値が空もしくは、属性値がアタッチされてない場合、

    has_error = false                                                  # 「エラーは無し」ということでreturnし終了

    if options[:maximum]                                         # オプションにmaximumがある場合、
      if value.is_a?(ActiveStorage::Attached::Many)              # 継承関係を遡ってどのクラスに属しているか調べている。複数の画像が保存された時にtrueとなる。一枚の画像を受け取るレコードの時にもこのメソッドを使えるように、条件分岐する
        value.each do |one_value|                                # 属性値をeach文で回す
          unless validate_maximum(record, attribute, one_value)  # それぞれの属性値にvalidate_maximumを実行し、falseなら
            has_error = true                                     # has_errorにtrueを代入
            break                                                # 繰り返し終了
          end
        end
      else                                                       # 一枚の画像を受け取るレコードの時、
        has_error = true unless validate_maximum(record, attribute, value)          # validate_maximumを実行し、falseならhas_errorにtrueを代入
      end
    end

    if options[:content_type]                                    # オプションにcontent_typeがあれば、
      if value.is_a?(ActiveStorage::Attached::Many)              # 継承関係を遡ってどのクラスに属しているか調べている。複数の画像が保存された時にtrueとなる。一枚の画像を受け取るレコードの時にもこのメソッドを使えるように、条件分岐する
        value.each do |one_value|                                # 属性値をeach文で回す
          unless validate_content_type(record, attribute, one_value)      # それぞれの属性値にvalidate_content_typeを実行し、falseなら
            has_error = true                                              # has_errorにtrueを代入
            break                                                         # 繰り返し終了
          end
        end
      else                                                                # 一枚の画像を受け取るレコードの時、
        has_error = true unless validate_content_type(record, attribute, value)    # validate_content_typeを実行し、falseならhas_errorにtrueを代入
      end
    end

    record.send(attribute).purge if options[:purge] && has_error         # オプションのpurgeがtrueかつhas_errorがtrueなら属性値を削除。
  end

  private

  def validate_maximum(record, attribute, value)                                   # valueのbyte_size(ファイルサイズ)がSiteモデルで定義したバイト数を超えている場合はエラーメッセージが出る
    if value.byte_size > options[:maximum]
      record.errors[attribute] << (options[:message] || "は#{number_to_human_size(options[:maximum])}以下にしてください")
      false
    else
      true
    end
  end

  def validate_content_type(record, attribute, value)                              # valueのファイルタイプがcontent_typeで指定しているものでなければエラーメッセージが出る
    if value.content_type.match?(options[:content_type])
      true
    else
      record.errors[attribute] << (options[:message] || 'は対応できないファイル形式です')
      false
    end
  end
end

value.is_a?(ActiveStorage::Attached::Many): 継承関係を遡ってどのクラスに属しているか調べています。一枚の画像を受け取るレコードの時にもこのメソッドを使えるように、条件分岐を使って定義しています。

ActiveStorageの削除機能

ActiveStorageを使って画像のアップロードの仕方を学んできましたが、削除はどうやるのでしょうか。今回のような場合、新たに削除機能を持つコントローラを作成したほうが良いでしょう。なぜなら、sites_controllerはSiteモデルに関するリソースを担うコントローラなので、destroyアクションを実装するとしたら、siteを削除するためのアクションになるでしょう。 また、sites_controllerに画像削除用の独自アクションを命名し、作成することもできますが、RESTに基づく7つのアクションで設計したほうが管理しやすいです。

削除機能専用のコントローラを作成

admin/siteディレクトリ配下にattachmentsコントローラを作成します。

$ bundle exec rails g controller admin::site::attachments destroy

ルーティング設定

routingではadminsiteのresourceにnestさせて定義すると既存のアクションとの関連性が分かりやすいです。 admin配下にあるのでadminの内側、さらにsiteの配下にあるのでsiteの内側に追記しましょう。

resource :site, only: %i[edit update] do
  resources :attachments, controller: 'site/attachments', only: %i[destroy]
end

controller: 'site/attachments': これを付けないと生成されるルーティングが、admin/attachments#destroy となり、エラーになります。 付けることでadmin/site/attachments#destroy となります。

削除用コントローラを追記

画像削除の処理はparamsで条件分岐をせず、ActiveStorage::Attachmentから取得するようにしましょう。

[BAD] 条件分岐をした場合の書き方

# BAD
class Admin::Site::AttachmentsController < Admin::SitesController
  def destroy
    @site.favicon.purge if params[:status] == 'favicon'
    @site.og_image.purge if params[:status] == 'og_image'
    @site.main_images.find(params[:id]).purge if params[:status] == 'main_images'
    redirect_to edit_admin_site_path
  end
end

[GOOD] ActiveStorage::Attachmentを利用した書き方

# GOOD
class Admin::Site::AttachmentsController < ApplicationController
  def destroy
    authorize(current_site)       # current_site は Site.first で対応できるが、今後の拡張を考慮して権限管理する
    image = ActiveStorage::Attachment.find(params[:id])
    image.purge
    redirect_to edit_admin_site_path
  end
end

ActiveStorage::Attachment.find(params[:id])をすることによって、favicon og_image main_images全ての中からidを取得するのでどの画像からの削除リクエストがきても対応できます。
current_site: application_controller.rbの中で定義されています。
image.purge: 探し出した画像を削除します。Active Storageで使う削除メソッドです。

削除ボタンを追加

      = f.input :main_images, as: :file, hint: 'JPEG/PNG (1200x400)', input_html: { multiple: true }

      - if @site.main_images.attached?
        .main_images_box
          - @site.main_images.each do |main_image|
            .main_image
              = image_tag main_image.variant(resize: '300x100').processed
              = link_to '削除', admin_site_attachment_path(main_image.id), method: :delete, class: 'btn btn-danger'

Siteの削除権限

Siteの画像はadminユーザー以外は削除できないようにしておくことが望ましいでしょう。

def destroy?
  user.admin?
end

参考記事

Active Storage の概要 - Railsガイド
Active Record のモデルにファイルを添付する方法について解説します。
Active Record バリデーション - Railsガイド
Active Recordのバリデーション機能について解説します。

コメント

タイトルとURLをコピーしました