ActiveStorageを使った複数画像アップロードアプリを作る -S3へのアップロード
はじめに
前回の記事の続きです。
Rails 5.2で追加されたActive Storageを使って、画像をクラウドストレージのAmazon S3にアップロードするの設定についてまとめます。
Amazon S3の設定
Amazon S3(Amazon Simple Storage Service)は、Amazon Web Service(AWS)で提供されているストレージサービスです。
まずは、AWSにアカウントログインしアプリのバケットを作成します、次にアクセスするユーザーを登録しアクセスキーとパスワードを取得します。
作成したバケットの情報とアクセスキーとパスワードをRailsのcrediential.ymlに登録します。
バケットの作成
AWSのコンソールにログインし、サービスタブからストレージ>S3を選択します。
バケット名(例:sample-app-bucket)を入力し、次へをクリックします。
特に必要がなければ、そのまま次へをクリックします。
チェックを全て外します。 アクセス許可設定は、今回は行いません。
確認して、バケットの作成をクリックします。
IAMユーザーの作成
アプリからS3へアクセスするIAMユーザーを作成します。 AWSのサービスタブから、IAMを選択します。
IAMページのユーザーをクリックします。
ユーザー画面のユーザーを追加をクリックします。
ユーザー名(例:sample-app)を入力し、プログラムによるアクセスにチェックを入れます。
既存のポリシーを直接アタッチをクリックする。入力欄にs3と入力すると関連するポリシーが表示されるので、AmazonS3FullAccessにチェックを入れる。
今回は特に必要がないので、そのまま次へのステップをクリックします。
確認して、ユーザーを作成をクリックします。
ユーザーが作成され、アクセスキーとシークレットアクセスキーが表示ます。
これをメモするか、csvのダウンロードをクリックして保存します。
画像保存先の設定
次に、S3へアップロードするためのRailsの設定を行います。
production.rbの編集
今回は本番環境のみS3にアップロードし、開発およびテスト環境ではローカルに保存する設定にします。
config/emviroments/production.rbを編集して、ActiveStorageの保存先を":local"から":amazon"に変更します。
#config/emviroments/production.rb config.active_storage.service = :amazon
ちなみに、開発環境でもS3にアップしたい場合は、config/emviroments/development.rbをおなじように変更します。
storege.ymlの編集
保存先の情報をstorege.ymlに記載します。config/storage.ymlの10行目から15行目のコメントを解除してamazonの設定を有効にします。regionとbucketには先ほど作成したS3のバケットのリージョン名とバケット名を入力します。 ちなみに東京リージョンはap-northeast-1になります。
#config/storege.yml amazon: service: S3 access_key_id: <%= Rails.application.credentials.dig(:aws, :access_key_id) %> secret_access_key: <%= Rails.application.credentials.dig(:aws, :secret_access_key) %> region: ap-northeast-1 bucket: your_own_bucket
crediential.yml.enc
アクセスキーとシークレットアクセスキーは、crediential.yml.encから読み込む設定になっているのでここで設定します。
crediential.yml.encはアクセスキーなどの機密性の高い情報を暗号化して保存できる機能でRails5.2から追加されました。
ターミナルで以下を実行し、crediential.yaml.encを編集します。
EDITOR="vim" bin/rails credentials:edit
EDITOR="vim"
は初回のみ必要になります。また、vim以外のエディターを使う場合はここで指定をします。
エディターが立ち上がると以下の部分をコメント解除し、access_key_idとsecret_access_keyに取得したアクセスキーとシークレットアクセスキーを入力します。
aws: access_key_id: your_access_key_id secret_access_key: your_secret_access_key
*注意*
crediential.yaml.encは、master.keyファイルをつかって暗号化しています。暗号化しているのでGitHubなどで共有することが可能ですが、master.key(正確にはファイル内の文字列)の管理は厳重にしましょう。
gemの追加
最後に必要なgemを追加し、bunndle install
を実行します。
gem "aws-sdk-s3", require: false
確認
ためしにローカル環境でS3にアップロードしてみます。
config/emviroments/development.rbを編集して保存先を変更します。
#config/emviroments/development.rb config.active_storage.service = :amazon
アプリを起動して、適当な画像をアップロードしてみます。
作成したS3のバケットにアクセスして画像がアップされているか確認します。 ランダムなファイル名のファイルが追加されているのが確認できます。これはActiveStorageがアップロードする際に付与したkey属性になります。
ファイルをクリックした画面で、開けるをクリックします。
アップロードした画像が表示されました。
まとめ
ActiveStorageでS3にアップロードする手順をまとめました。
S3の設定とRailsの設定だけで実装できるので本当に楽です。
ActiveStorageを使った複数画像アップロードアプリを作る
はじめに
Active Storageを使って複数の画像を扱う方法についてまとめます。 Active Storageは、Rails 5.2で追加されたファイルアップロードの機能です。必要なテーブルも自動で生成してくれるので、モデルに関連つけを記述しクラウドストレージへの設定するだけで画像の添付やクラウドストレージへのアップロードを簡単に実装できます。 複数の画像を扱う場合も、モデルへの関連つけを記述するだけで機能を実装できます。ただし、実際に実装しているとプレビューや更新で詰まったところも多く、詳しい記事があまりなかったので記録のため、まとめておきます。
アプリ概要
- Itemに複数画像を添付
- 画像の削除、変更が可能
- 画像保存先はひとまずローカルで
環境
- Ruby 2.5.1
- Rails 5.2.2
- ImageMagick 7.0.8
準備
プロジェクト作成
$ rails new activestorage_sample
mini_magick追加
# Gemfile gem 'mini_magick'
Active Storage インストール
$ rails active_storage:install $ rails db:migrate
実装
scaffoldでitemを作成します。
$ rails g scaffold item name:string description:text $ rails db:migrate
Itemモデルに画像(images)を関連付けします。
# app/models/item.rb class Item < ApplicationRecord has_many_attached :images end
コントローラを編集し、ストロングパラメータにimagesを追加します。
# app/controllers/items_controller.rb def item_params params.require(:item).permit(:name, :description, images: []) end
フォームにimagesのfile_fieldを追加します。
# app/views/items/_form.html.erb <div class="field"> <%= form.label :images %> <%= form.file_field :images ,multiple: true%> </div>
showビューに画像の表示を追加します。
# app/views/items/show.html.erb <p> <strong>images:</strong> <% @item.images.each do |image|%> <%= image_tag image.variant(resize: "150x150") %> <% end %> </p>
ここまでで、一応複数画像の添付ができるようになりました。 ただ、これではプレビューがないので添付した画像を確認できません。また、Saveに失敗したときや編集時に画像が消えてしまいます。
画像プレビューを実装
jQuery追加
# Gemfile gem 'jquery-rails'
# app/assets/javascripts/application.js
//= require jquery
ファイルが選択されてときにプレビューを追加するjsを追加。
$(document).on('turbolinks:load', function() { $('#item_images').on('change',function(e){ var files = e.target.files; var d = (new $.Deferred()).resolve(); $.each(files,function(i,file){ d = d.then(function(){return previewImage(file)}); }); }) var previewImage = function(imageFile){ var reader = new FileReader(); var img = new Image(); var def =$.Deferred(); reader.onload = function(e){ $('.images_field').append(img); img.src = e.target.result; def.resolve(img); }; reader.readAsDataURL(imageFile); return def.promise(); } })
編集時やSave失敗の時の画像表示を追加
# app/views/items/_form.html.erb <div class="images-field clearfix"> <div class="field"> <%= form.label :images %> <%= form.file_field :images, multiple: true, class: "file-input" %> </div> <% if item.images.attached? %> <% item.images.each do |image| %> <%= image_tag image.variant(resize: "150x150") %> <% end %> <% end %> </div>
バリデーションエラーでSave失敗した時や編集時に画像を表示することができます。 しかし、file inputの中身は消えてしまうため、このままではフォームから画像を送ることができず、画像を保存することができません。 ちなみに、file inputタグはセキュリティ上、初期値を入れておくことができないので、file inputタグ以外を使う以外の方法が必要になります。
今回は、画像ファイルを先にアップロードしておく方法を使いました。 画像ファイルは、ファイル選択時にアップロードし、アップロードした画像と紐付けのためのimage_idをプレビュー表示の際にhidden fieldに保管します。 フォームのサブミットの時に、このimage_idを送ることでモデルと画像の紐付けを行います。
画像アップロード機能
Active Storageのダイレクトアップロード機能を使いたかったのですが、やり方がよくわからなかったので使わずに実装しています。
item_controllerにupload_imageアクションを追加 さらに、Active Storageのblobを生成するcreate_blobメソッドとformから送られたidからblobを取得するメソッドを追加する。 ストロングパラメータのimagesをblobを渡すように変更。 なお、updateアクションでは一旦すべてのimageの紐付けを解除して更新時に再度紐付けすることで画像を更新しています。
# app/controllers/items_controller.rb def update @item.images.detach #一旦、すべてのimageの紐つけを解除 if @item.update(item_params) redirect_to @item, notice: 'Item was successfully updated.' else render :edit end end def upload_image @image_blob = create_blob(params[:image]) respond_to do |format| format.json { @image_blob.id } end end private def item_params params.require(:item).permit(:name, :description).merge(images: uploaded_images) end def uploaded_images params[:item][:images].map{|id| ActiveStorage::Blob.find(id)} if params[:item][:images] end def create_blob(uploading_file) ActiveStorage::Blob.create_after_upload! \ io: uploading_file.open, filename: uploading_file.original_filename, content_type: uploading_file.content_type end
# app/view/upload_image.json.jbuilder json.image_id @image_blob.id if @image_blob
formにhidden fieldなどを追加。ついでに画像削除や編集のボタンを追加しておく。
# app/views/items/_form.html.erb <div class="images-field clearfix"> <div class="field"> <%= form.label :images %> <%= form.file_field :images, multiple: true, class: "file-input" %> </div> <% if item.images.attached? %> <% item.images.each do |image| %> <div class="image-box"> <%= image_tag image.variant(resize: "150x150") %> <p> <%= image.filename %> </p> <%= form.hidden_field :images , name: "item[images][]", value: "#{image.blob.id}", style: "display: none;", class: "item-images-input" %> <%= link_to "Edit", "", class: "btn-edit" %> <%= file_field "edit-image","" , class: "edit-image-file-input file-input", style: "display: none;"%> <%= link_to "Delete", "", class: "btn-delete" %> </div> <% end %> <% end %> </div>
jsを変更
$('#item_images').on('change',function(e){ var files = e.target.files; var d = (new $.Deferred()).resolve(); $.each(files,function(i,file){ d = d.then(function() { return Uploader.upload(file)}) .then(function(data){ return previewImage(file, data.image_id); }); }); $('#item_images').val(''); }); var previewImage = function(imageFile, image_id){ var reader = new FileReader(); var img = new Image(); var def =$.Deferred(); reader.onload = function(e){ var image_box = $('<div>',{class: 'image-box'}); image_box.append(img); image_box.append($('<p>').html(imageFile.name)); image_box.append($('<input>').attr({ name: "item[images][]", value: image_id, style: "display: none;", type: "hidden", class: "item-images-input"})); image_box.append('<a href="" class= "btn-edit">Edit</a>'); image_box.append($('<input>').attr({ name: "edit-image[]", style: "display: none;", type: "file", class: "edit-image-file-input file-input"})); image_box.append('<a href="" class="btn-delete">Delete</a>'); $('.images-field').append(image_box); img.src = e.target.result; def.resolve(); }; reader.readAsDataURL(imageFile); return def.promise(); } var Uploader = { upload: function(imageFile){ var def =$.Deferred(); var formData = new FormData(); formData.append('image', imageFile); $.ajax({ type: "POST", url: '/items/upload_image', data: formData, dataType: 'json', processData: false, contentType: false, success: def.resolve }) return def.promise(); } }
実装結果
バリデーションエラー時
画像は表示しています。
Item編集時
画像の削除、変更
削除は、削除リンクをクリックしたときに親要素ごと消すことで実装します。hidden要素のimage idがなくなるため、formを送信した時に画像の紐付けが削除されます。 なお、画像ファイル自体は削除されずストレージに残りますが、Active Storageでは以下のコードでモデルに紐ついていないファイルをまとめて消すことができるので今回はこの方法にしました。まじめに削除する必要がある場合は、記述を追加する必要があります。
ActiveStorage::Blob.unattached.find_each(&:purge)
編集リンクをクリックすると、file選択が表示され選択すると画像を差し替える操作をjsに追加します。
$('.images-field').on('click','.btn-edit', function(e){ e.preventDefault(); $(this).parent().find('.edit-image-file-input').trigger("click"); }); $('.images-field').on('change','.edit-image-file-input', function(e){ var file = e.target.files[0]; var image_box = $(this).parent(); Uploader.upload(file).done(function(data){ replaceImage(file, data.image_id, image_box); }); }); $('.images-field').on('click','.btn-delete', function(e){ e.preventDefault(); $(this).parent().remove(); }); var replaceImage = function(imageFile, image_id, element){ var reader = new FileReader(); var img = element.find('img'); var input = element.find('.item-images-input'); var filename = element.find('p'); reader.onload = function(e){ input.attr({value: image_id}); filename.html(imageFile.name); img.attr("src", e.target.result); }; reader.readAsDataURL(imageFile); }
完成コード
主なコード
class Item < ApplicationRecord has_many_attached :images validates :name, :description ,presence: true end
# app/controllers/items_controller.rb class ItemsController < ApplicationController before_action :set_item, only: [:show, :edit, :update, :destroy] def index @items = Item.all end def show end def new @item = Item.new end def edit end def create @item = Item.new(item_params) if @item.save redirect_to @item, notice: 'Item was successfully created.' else render :new end end def update @item.images.detach #一旦、すべてのimageの紐つけを解除 if @item.update(item_params) redirect_to @item, notice: 'Item was successfully updated.' else render :edit end end def destroy @item.destroy redirect_to items_url, notice: 'Item was successfully destroyed.' end def upload_image @image_blob = create_blob(params[:image]) respond_to do |format| format.json { @image_blob } end end private def set_item @item = Item.with_attached_images.find(params[:id]) end def item_params params.require(:item).permit(:name, :description).merge(images: uploaded_images) end def uploaded_images params[:item][:images].map{|id| ActiveStorage::Blob.find(id)} if params[:item][:images] end def create_blob(uploading_file) ActiveStorage::Blob.create_after_upload! \ io: uploading_file.open, filename: uploading_file.original_filename, content_type: uploading_file.content_type end end
# app/views/items/_form.html.erb <%= form_with(model: item, local: true) do |form| %> <% if item.errors.any? %> <div id="error_explanation"> <h2><%= pluralize(item.errors.count, "error") %> prohibited this item from being saved:</h2> <ul> <% item.errors.full_messages.each do |message| %> <li><%= message %></li> <% end %> </ul> </div> <% end %> <div class="field"> <%= form.label :name %> <%= form.text_field :name %> </div> <div class="field"> <%= form.label :description %> <%= form.text_area :description %> </div> <div class="images-field clearfix"> <div class="field"> <%= form.label :images %> <%= form.file_field :images, multiple: true, class: "file-input" %> </div> <% if item.images.attached? %> <% item.images.each do |image| %> <div class="image-box"> <%= image_tag image.variant(resize: "150x150") %> <p> <%= image.filename %> </p> <%= form.hidden_field :images , name: "item[images][]", value: "#{image.blob.id}", style: "display: none;", class: "item-images-input" %> <%= link_to "Edit", "", class: "btn-edit" %> <%= file_field "edit-image","" , class: "edit-image-file-input file-input", style: "display: none;"%> <%= link_to "Delete", "", class: "btn-delete" %> </div> <% end %> <% end %> </div> <div class="actions"> <%= form.submit "Save"%> </div> <% end %>
# app/views/items/show.html.erb <p id="notice"><%= notice %></p> <p> <strong>Name:</strong> <%= @item.name %> </p> <p> <strong>Description:</strong> <%= @item.description %> </p> <p> <strong>images:</strong> </p> <div class= "clearfix"> <% if @item.images.attached? %> <% @item.images.each do |image|%> <div class="image-box"> <%= image_tag image.variant(resize: "150x150") %> <p> <%= image.filename %> </p> </div> <% end %> <% end %> </div> <div> <%= link_to 'Edit', edit_item_path(@item) %> | <%= link_to 'Back', items_path %> </div>
$(document).on('turbolinks:load', function() { $('#item_images').on('change',function(e){ var files = e.target.files; var d = (new $.Deferred()).resolve(); $.each(files,function(i,file){ d = d.then(function() { return Uploader.upload(file)}) .then(function(data){ return previewImage(file, data.image_id); }); }); $('#item_images').val(''); }); $('.images-field').on('click','.btn-edit', function(e){ e.preventDefault(); $(this).parent().find('.edit-image-file-input').trigger("click"); }); $('.images-field').on('change','.edit-image-file-input', function(e){ var file = e.target.files[0]; var image_box = $(this).parent(); Uploader.upload(file).done(function(data){ replaceImage(file, data.image_id, image_box); }); }); $('.images-field').on('click','.btn-delete', function(e){ e.preventDefault(); $(this).parent().remove(); }); var replaceImage = function(imageFile, image_id, element){ var reader = new FileReader(); var img = element.find('img'); var input = element.find('.item-images-input'); var filename = element.find('p'); reader.onload = function(e){ input.attr({value: image_id}); filename.html(imageFile.name); img.attr("src", e.target.result); }; reader.readAsDataURL(imageFile); } var previewImage = function(imageFile, image_id){ var reader = new FileReader(); var img = new Image(); var def =$.Deferred(); reader.onload = function(e){ var image_box = $('<div>',{class: 'image-box'}); image_box.append(img); image_box.append($('<p>').html(imageFile.name)); image_box.append($('<input>').attr({ name: "item[images][]", value: image_id, style: "display: none;", type: "hidden", class: "item-images-input"})); image_box.append('<a href="" class= "btn-edit">Edit</a>'); image_box.append($('<input>').attr({ name: "edit-image[]", style: "display: none;", type: "file", class: "edit-image-file-input file-input"})); image_box.append('<a href="" class="btn-delete">Delete</a>'); $('.images-field').append(image_box); img.src = e.target.result; def.resolve(); }; reader.readAsDataURL(imageFile); return def.promise(); } var Uploader = { upload: function(imageFile){ var def =$.Deferred(); var formData = new FormData(); formData.append('image', imageFile); $.ajax({ type: "POST", url: '/items/upload_image', data: formData, dataType: 'json', processData: false, contentType: false, success: def.resolve }) return def.promise(); } } })
まとめ
- ひとまずActiveStorageで複数画像をアップロードするアプリを作成しました。
- バリデーションやストレージの設定などなどについてはまたいつか書きたいと思います。