ActiveStorageを使った複数画像アップロードアプリを作る -S3へのアップロード

はじめに 

前回の記事の続きです。

kykt35.hatenablog.com

Rails 5.2で追加されたActive Storageを使って、画像をクラウドストレージのAmazon S3にアップロードするの設定についてまとめます。

Amazon S3の設定

Amazon S3(Amazon Simple Storage Service)は、Amazon Web Service(AWS)で提供されているストレージサービスです。

https://aws.amazon.com/jp/

まずは、AWSにアカウントログインしアプリのバケットを作成します、次にアクセスするユーザーを登録しアクセスキーとパスワードを取得します。
作成したバケットの情報とアクセスキーとパスワードをRailsのcrediential.ymlに登録します。

バケットの作成

AWSのコンソールにログインし、サービスタブからストレージ>S3を選択します。

Amazon S3の画面でバケットを作成をクリックします。 S3サービス画面

バケット名(例:sample-app-bucket)を入力し、次へをクリックします。 バケット作成

特に必要がなければ、そのまま次へをクリックします。 バケット作成

チェックを全て外します。 アクセス許可設定は、今回は行いません。 アクセス許可設定

確認して、バケットの作成をクリックします。 バケット作成

IAMユーザーの作成

アプリからS3へアクセスするIAMユーザーを作成します。 AWSのサービスタブから、IAMを選択します。

IAMページのユーザーをクリックします。 IAM

ユーザー画面のユーザーを追加をクリックします。 IMAユーザー画面

ユーザー名(例:sample-app)を入力し、プログラムによるアクセスにチェックを入れます。 IAMユーザーを追加

既存のポリシーを直接アタッチをクリックする。入力欄にs3と入力すると関連するポリシーが表示されるので、AmazonS3FullAccessにチェックを入れる。 IAMポリシー

今回は特に必要がないので、そのまま次へのステップをクリックします。 IAMタグ

確認して、ユーザーを作成をクリックします。 IAM確認画面

ユーザーが作成され、アクセスキーとシークレットアクセスキーが表示ます。
これをメモするか、csvのダウンロードをクリックして保存します。 IAMユーサー作成完了

画像保存先の設定

次に、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

アプリを起動して、適当な画像をアップロードしてみます。 f:id:kykt35:20190219011110p:plain

作成したS3のバケットにアクセスして画像がアップされているか確認します。 f:id:kykt35:20190219011248j:plain ランダムなファイル名のファイルが追加されているのが確認できます。これはActiveStorageがアップロードする際に付与したkey属性になります。

ファイルをクリックした画面で、開けるをクリックします。 f:id:kykt35:20190219012141j:plain

アップロードした画像が表示されました。 f:id:kykt35:20190219012653p:plain

まとめ

ActiveStorageでS3にアップロードする手順をまとめました。
S3の設定とRailsの設定だけで実装できるので本当に楽です。

ActiveStorageを使った複数画像アップロードアプリを作る

はじめに

 Active Storageを使って複数の画像を扱う方法についてまとめます。 Active Storageは、Rails 5.2で追加されたファイルアップロードの機能です。必要なテーブルも自動で生成してくれるので、モデルに関連つけを記述しクラウドストレージへの設定するだけで画像の添付やクラウドストレージへのアップロードを簡単に実装できます。  複数の画像を扱う場合も、モデルへの関連つけを記述するだけで機能を実装できます。ただし、実際に実装しているとプレビューや更新で詰まったところも多く、詳しい記事があまりなかったので記録のため、まとめておきます。

アプリ概要

  • Itemに複数画像を添付
  • 画像の削除、変更が可能
  • 画像保存先はひとまずローカルで

f:id:kykt35:20190116173159p:plain

環境

準備

プロジェクト作成

$ 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();
    }
  }

実装結果

f:id:kykt35:20190116120654g:plain

バリデーションエラー時

画像は表示しています。 f:id:kykt35:20190116173727p:plain

Item編集時

f:id:kykt35:20190116174305p:plain

画像の削除、変更

削除は、削除リンクをクリックしたときに親要素ごと消すことで実装します。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で複数画像をアップロードするアプリを作成しました。
  • バリデーションやストレージの設定などなどについてはまたいつか書きたいと思います。

参考