Active Storage導入しました

背景、経緯、目的

業務でRailsを使ってWebサービスを開発しているのですが、
ユーザーの画像設定やファイル添付機能等をシンプルに実施したく、
またRailsのバージョンアップ等のタイミングもあり、 Active Storageの導入を決めました。
(CarrierWave vs Active Storageみたいな議論は本記事では扱いません)
純粋にRailsからアップロードし、AWS S3に保存するような設計です。

環境

主な使用方法

  • 以下コマンド等でテーブルを作成、migrationを実施
rails active_storage:install
rails db:migrate
  • 各種設定 今回は開発環境ではlocal、本番環境等ではAWS S3に保存するイメージです。
# Gemfile
gem 'aws-sdk-s3'
# config/environments/development.rb
config.active_storage.service = :local
# config/environments/production.rb
config.active_storage.service = :amazon
# config/storage.yml
local:
  service: Disk
  root: <%= Rails.root.join("storage") %>
/storage/*
!/storage/.keep

amazon:
  service: S3
  access_key_id: <%= ENV['AWS_S3_ACCESS_KEY_ID'] %>
  secret_access_key: <%= ENV['AWS_S3_SECRET_ACCESS_KEY'] %>
  region: <%= ENV['AWS_S3_REGION'] %>
  bucket: <%= ENV['AWS_S3_BUCKET'] %>
  • 該当Modelに紐付け
# app/models/book.rb
class AdminUser < ApplicationRecord
  # 1対1の場合
  has_one_attached :image
  # 1対複数の場合
  has_many_attached :images
end
  • フォーム等イメージ
-# app/views/books/edit.html.haml
-# 複数の場合 multiple: trueを指定、accept: 'image/jpeg,image/png'等で受付可能なMIMEタイプ指定可能
= f.file_field :files, accept: 
  • 表示イメージ
-# app/views/books/show.html.haml
%dl
  %dt 表紙イメージ
  %dd= image_tag @book.image

Tips

  • attribute代入タイミング

    有名な話なのですが、Rails5.2〜6.0までは、attributeに代入した時点で、
    アップロードしたファイルがストレージに保存されてしまいます。
    以下のようにすることが良くあるかと思いますが、バリデーションエラー時等に、
    そのままファイルが残ってしまうという問題点があります。

@book = Book.new(title: 'サンプル本', image: params[:image])
if @book.save
  # 省略
  redirect_to dashboard_path
else
  # 省略
  render 'edit'
end

Rails6.0以上ではsave後に保存されるようになったので、気にする必要は無いのですが、 それ以前のバージョンでは、以下等の対応が必要になります。

  • バリデーションエラーの場合、purgeを呼び出す
@book.image.purge
errors.add(:image, '指定のファイル形式ではありません。')
  • バリデーション等実施、save後にattachするようにする(別途save前にimageのバリデーションも必要)
if @book.save
  @book.image = params[:image] if params[:image]
else
end
  • purge_later

    ActiveStorageでは、has_one_attachedhas_many_attachedにおいて、
    デフォルトでdependent: :purge_laterとなっており、該当インスタンスが削除された場合、
    Active Jobを介して、関連付けられているモデルと実際のリソースファイルを非同期で破棄します。
    また、キューは以下のように必要に応じて指定が必要です。 (Rails6.1以降はqueues.analysis, queues.purgeに分割して指定することが推奨されています。)

# config/application.rb
config.active_storage.queue = 'default'
  • nginx設定、413エラー等

    個人的に一番ハマったポイントです。 まずデフォルトではnginxのPOST最大サイズは1MBのようです。 必要に応じて、以下のようにclient_max_body_sizeを指定する必要があります。

# script/nginx/nginx-development.conf
server {
  listen 8080;
  server_name  xxx.xxx.xx.xxx;
  root         /app/public;
  client_max_body_size 10m;
}

ただこのままでは、サイズ超過したファイルをアップロード時に、 413エラー画面がnginxのデフォルトの画面になってしまいます。

f:id:dtanakab:20211012173727p:plain
413エラー画面
本件を解決する方法はいくつかあるかと思いますが、Webアプリ側はGET http(s)://{host}/payload_too_largeでエラーページを表示できるようにし、
413エラーを以下のようにリダイレクトを使い、
POSTをGETとし、オリジナルのエラーページを表示できるようにしています。

location / {
  error_page 413 @413;
}

location @413 {
  return 303 https://$host/payload_too_large;
}

これで全て解決と思ったのですが、 ChromeFirefoxでは実際にサイズ超過したファイルをアップロードすると、 数百MBまでは問題ないのですが1GB等を超過すると、以下エラーが出るようになりました。
Safariではどれだけサイズが大きくても、問題なくリダイレクトされる)

f:id:dtanakab:20211004160903p:plain
ERR_CONNECTION_RESETエラー

ChromeFirefoxでは、nginxが413エラーを返した場合も、
アップロード処理を継続してしまうという仕様のようです...
よって、JS等フロントで予め必要以上のサイズのファイルは、
アップロードできないよう制御しておくのが良さそうです。
以下サンプルではファイルの複数アップロードを想定し、
ファイル数およびファイルサイズで制御をかけています。

// book_file_upload.js
document.addEventListener('DOMContentLoaded', function() {
  const lengthLimit = 3
  const sizeLimit = 1024 * 1024 * 10
  const inputTemplate = document.getElementById('book_files');
  var fileLength = 0

  $('.file-uploads').on('change', function(e){
    var files = e.target.files;
    // 添付ファイルボタンのinputは空に戻す
    e.target.files = new DataTransfer().files;
    fileLength += files.length
    // ファイル数バリデーションエラー表示
    if (fileLength > lengthLimit) {
      alert('1つのBookに添付できるファイル数は最大3つです。');
      fileLength -= files.length
      return;
    }

    var error_message = ''
    for (let i = 0; i < files.length; i++) {
      var file = files[i]
      if (file.size > sizeLimit) {
        error_message = 'Bookに添付できる1ファイルの最大サイズは10MBです。';
        continue;
      }
      // アップロード予定ファイルプレビュー表示
      var file_preview = $('<div>', { class: 'file-preview' });
      file_preview.append(`<i class= "fa fa-file"></i>${file.name}`);
      file_preview.append('<span class="btn-delete">削除</span>');
      // ファイルを個別に削除等できるよう分けてinputへ格納
      var dt = new DataTransfer();
      dt.items.add(file);
      var input = inputTemplate.cloneNode(false);
      input.files = dt.files;
      input.id = `book_files_${file.name}`;
      file_preview.append(input);

      $('.uploaded-files').append(file_preview);
    };
    // ファイルサイズバリデーションエラー表示
    if (error_message != '') alert(error_message);
  })

  // アップロードファイルの削除
  $('.uploaded-files').on('click', '.btn-delete', function(){
    fileLength -= 1
    $(this).parent().remove();
  });
});
  • spec

    上記等のバリデーションテスト等で、サイズ上限のバリデーション等を試したいこともあるかと思います。
    ただその為だけに、大きいサイズのファイルを用意はできないケースが多いと思うので、モック等で対応するのが良いかと思います。

let(:images) { [image] }
let(:image) { Rack::Test::UploadedFile.new(Rails.root.join('app', 'assets', 'images', 'sample.png'), 'image/png') }
before do
  allow(image).to receive(:size).and_return(11.megabytes)
end

感想

  • Rails6.0以降改善されているとのことですが、
    やはりsave前のattribute代入でアップロードされてしまうのはやや面倒
  • シンプルなアップロード機能であれば、特に問題は感じませんでした!

参考記事