Rails Webook

自社のECを開発している会社で働いています。Rails情報やサービスを成長させる方法を書いていきます

ActiveStorageでS3にダイレクトアップロードする

f:id:nipe880324:20180818220123p:plain:w420

Rails 5.2から入ったファイルアップロード機能のActive Storageを使ってS3にダイレクトアップロードを実装します。 クライアントからS3にファイルを直接アップロードすることにより、ファイルアップロード時の負荷を削減できるメリットがあり、最近ファイルをAWSにアップロードするとなるとダイレクトアップロードで実装することが多いと思います。 ファイルをアップロードするにあたり、S3の設定やユーザ認証、ファイルのバリデーションなど実運用で必要と思われる箇所についても合わせて実装していきます。

Active Storageに興味がある方は、わかりやすくまとまっているActive Storageの概要 | Rails ガイドをぜひ読んでみてください。

ソースコードはこちらです。rails_samples/sample_active_storage at master · nipe0324/rails_samples · GitHub

・確認バージョン

・目次

1. Active Storageの設計

DB設計

Active Storageでは下記2つのテーブルを新規に追加します。

  • active_storage_blobs - ファイル名やファイルサイズ、コンテンツタイプなどのファイル情報を保持するテーブル)
  • active_storage_attachments - active_storage_blobsとファイル添付するモデルを紐づける関連テーブル)

f:id:nipe880324:20180818192239p:plain

このような設計のメリットとして、ファイル添付をいろんな箇所でやりたいといったニーズはあるので共通化ができるからよいと思います。 逆にデメリットとして、各モデルに紐づく画像が1つのテーブルに集約されてしまうのでパフォーマンスやサービス分割などの点で問題になるかもしれないとも思います。 個人的には、各ファイルごとに別テーブルを作り、処理は可能な限り共通にしたほうがよいと思いますので、少しDB設計はいまいちかなと思いました。

S3へのダイレクトアップロードのシーケンス図

クライアントからS3へファイルをダイレクトアップロードする際には、クライアントはサーバー経由でS3の署名つきURL(Pre-Signed URL)を取得し、そのURLを使いS3に直接ファイルをアップロードします。 Active Storageの実装で特徴的だなと思ったのは、サーバーで署名つきURLを取得するときに、active_storage_blobsレコードを作るのが面白い設計だと思いました。こうすることで、S3にファイルをアップロードしたけど、レコードと紐づいていないということを回避して、バッチでS3常にあるゴミファイルを削除できるようになります。

f:id:nipe880324:20180818193924p:plain:w420

S3のファイルへのリンク

RailsへのURLを返し、そこにアクセスするとエンドポイントへのURLを返すような実装になっています。 こうすることで、S3やGCSなどの複数のクラウドサービスにミラーリングしているときに可用性を高めれたり、ファイルの閲覧に対してユーザー認証を実装したりしやすくなる効果があると思います。 また、エンドポイントのURLは5分ほどキャッシュするのでRailsへのアクセスが無駄に増えることも防止しています。

f:id:nipe880324:20180818195454p:plain

2. 実装する機能

deviseでユーザー認証し、ログインしたユーザーはメッセージを作成でき、ファイルを添付できるというシンプルなWebアプリケーションを作ります。
また、ファイル添付時にはユーザー認証やファイルのバリデーションをできるようにします。
さらに、ファイルへの閲覧は、メッセージをアップロードしたユーザーしか閲覧できないようにします。

3. deviseでユーザ認証機能を追加

プロジェクトを作ります。

rails new sample_active_storage
cd sample_active_storage

deviseをインストールします。

echo "gem 'devise'" >> Gemfile
bundle install
bin/rails generate devise:install

mailerのdefault_url_optionsを修正します。

# config/environments/development.rb
  Rails.application.configure do
    ...
+   config.action_mailer.default_url_options = { host: 'localhost', port: 3000 }
    ...
  end

ログイン/ログアウトのリンクをつけます。

# app/views/layouts/application.html.erb
  ....
  <body>
+   <header>
+     <nav>
+       <% if user_signed_in? %>
+         Logged in as <strong><%= current_user.email %></strong>.
+         <%= link_to "ログアウト", destroy_user_session_path, method: :delete %>
+       <% else %>
+         <%= link_to "サインイン", new_user_registration_path %> |
+         <%= link_to "ログイン", new_user_session_path %>
+       <% end %>
+     </nav>
+   </header>

+   <p class="notice"><%= notice %></p>
+   <p class="alert"><%= alert %></p>

    <%= yield %>

  </body>

Userモデルを作ります。

bin/rails generate devise User
bin/rails db:migrate

4. メッセージを投稿機能を追加

次に、ユーザーがメッセージを投稿できるようにします。 Scaffoldでさくさくっと作っていきます。

まずはScaffoldでMessageのモデル、コントローラー、ビューを一気に作ります。

bin/rails g scaffold Message user:references content:text
bin/rails db:migrate

ルートを修正します。

# config/routes.rb
Rails.application.routes.draw do
  resources :messages
  devise_for :users
  root to: "messages#index"
end

モデルにアソシエーションをはります。

# app/models/user.rb
  class User < ApplicationRecord
    # Include default devise modules. Others available are:
    # :confirmable, :lockable, :timeoutable, :trackable and :omniauthable
    devise :database_authenticatable, :registerable,
           :recoverable, :rememberable, :validatable

+   has_many :messages
  end

# app/models/message.rb
  class Message < ApplicationRecord
+   belongs_to :user
  end

Messagesコントローラーでは、current_userに紐づくメッセージを作成、参照、削除するようにします。

# app/controllers/messages_controller.rb
  class MessagesController < ApplicationController
+   before_action :authenticate_user!
    before_action :set_message, only: [:show, :edit, :update, :destroy]

    def index
-     @messages = Message.all
+     @messages = current_user.messages.all
    end

    def show
    end

    def new
-     @message = Message.new
+     @message = current_user.messages.build
    end

    def create
-     @message = Message.new(message_params)
+     @message = current_user.messages.build(message_params)

      ...
   end

   private
      # Use callbacks to share common setup or constraints between actions.
      def set_message
-       @message = Message.find(params[:id])
+       @message = current_user.messages.find(params[:id])
      end

      # Never trust parameters from the scary internet, only allow the white list through.
      def message_params
-       params.require(:message).permit(:user, :content)
+       params.require(:message).permit(:content)
      end
  end

ビューも必要ない箇所を修正していきます。

# app/views/messages/index.html.erb
- <p id="notice"><%= notice %></p>

- <h1>Messages</h1>
+ <h1><%= current_user.email %>'s messages</h1>

  <table>
    <thead>
      <tr>
-       <th>User</th>
        <th>Content</th>
        <th colspan="3"></th>
      </tr>
    </thead>

    <tbody>
      <% @messages.each do |message| %>
        <tr>
-         <td><%= message.user %></td>
          <td><%= message.content %></td>
          <td><%= link_to 'Show', message %></td>
          <td><%= link_to 'Edit', edit_message_path(message) %></td>
          <td><%= link_to 'Destroy', message, method: :delete, data: { confirm: 'Are you sure?' } %></td>
        </tr>
      <% end %>
    </tbody>
  </table>

  <br>

  <%= link_to 'New Message', new_message_path %>

フォーム画面でユーザーを削除。

# app/views/messages/_form.html.erb
  <%= form_with(model: message, local: true) do |form| %>
    ...

-   <div class="field">
-     <%= form.label :user %>
-     <%= form.text_field :user %>
-   </div>

    <div class="field">
      <%= form.label :content %>
      <%= form.text_area :content %>
    </div>

    <div class="actions">
      <%= form.submit %>
    </div>
  <% end %>

詳細画面でユーザー情報を削除。

# app/views/messages/show.html.erb
- <p id="notice"><%= notice %></p>

- <p>
-   <strong>User:</strong>
-   <%= @message.user %>
- </p>

  <p>
    <strong>Content:</strong>
    <%= @message.content %>
  </p>

  <%= link_to 'Edit', edit_message_path(@message) %> |
  <%= link_to 'Back', messages_path %>

では、ログインして、メッセージを送信できることが確認できます。

f:id:nipe880324:20180818215336p:plain:w420

5. ファイルをアップロード

では、メッセージを投稿できるようになったので、Active Storageでメッセージにファイルを添付できるようにします。

まずは、Active Storageのテーブルを作成します。

bin/rake active_storage:install
bin/rake db:migrate

Messageモデルにファイル添付できるようにします。 has_one_attachedhas_many_attachedActiveRecord::Attachmentと紐づくことを定義できます。

# app/models/message.rb
  class Message < ApplicationRecord
    belongs_to :user
+   has_many_attached :images
  end

フォームからファイルを添付できるようにするため、ファイルの入力フィールドを追加します。

# app/views/messages/_form.html.erb
  <%= form_with(model: message, local: true) do |form| %>
    ...

    <div class="field">
      <%= form.label :content %>
      <%= form.text_area :content %>
    </div>

+   <div class="field">
+     <%= form.label :images %>
+     <%= form.file_field :images, multiple: true %>
+   </div>

    <div class="actions">
      <%= form.submit %>
    </div>
  <% end %>

メッセージ詳細画面で添付したファイルを表示できるようにします。 ファイルがあるかないかは、attached?で確認できます。

# app/views/messages/show.html.erb
  <p>
    <strong>Content:</strong>
    <%= @message.content %>
  </p>

+ <p>
+   <strong>Images:</strong>
+   <% if @message.images.attached? %>
+     <% @message.images.each do |image| %>
+       <%= image_tag image, size: '120x120' %>
+     <% end %>
+   <% end %>
+ </p>

  <%= link_to 'Edit', edit_message_path(@message) %> |
  <%= link_to 'Back', messages_path %>

MessageコントローラーのStrongParameterdでフォームから送られてきたファイルを許可するようにします。

# app/controllers/messages_controller.rb
  class MessagesController < ApplicationController
    ...


      def message_params
-       params.require(:message).permit(:content)
+       params.require(:message).permit(:content, images: [])
      end
  end

ここで、動作確認をするとファイルを添付できることが確認できます。

f:id:nipe880324:20180818215200p:plain:w420

実際にアップロードしたファイルは、Rails.rootのstorage配下に配置されています。 ※storage配下のパスは実行環境によってことなります。

storage/ny/WB/nyWB7Ho65CgkJBfGPqsRWFnf

これは、デフォルトのActive Storageの設定だと、storage.ymllocalRails.root.join("storage")と記載されていて、

# config/storage.yml
local:
  service: Disk
  root: <%= Rails.root.join("storage") %>

また、development.rblocalを使うよう指定されているためです。

# config/environments/development.rb
Rails.application.configure do
  ...

  # Store uploaded files on the local file system (see config/storage.yml for options)
  config.active_storage.service = :local

  ...
end

ついでに、rails consoleで、レコードがどのようにできているか確認してみます。

bin/rails c

# active_storage_blobsファイルにアップロードしたファイルが作られている
rb(main):001:0> blob = Active Storage::Blob.last
  Active Storage::Blob Load (0.1ms)  SELECT  "active_storage_blobs".* FROM "active_storage_blobs" ORDER BY "active_storage_blobs"."id" DESC LIMIT ?  [["LIMIT", 1]]
=> #<Active Storage::Blob id: 1, key: "nyWB7Ho65CgkJBfGPqsRWFnf", filename: "テスト画像.png", content_type: "image/png", metadata: {"identified"=>true, "analyzed"=>true}, byte_size: 283316, checksum: "8YbDQ0j0AGwumgI/1T3/ig==", created_at: "2018-08-16 14:41:12">

# また、active_storage_attachmentsも作成されmessageテーブルと関連づけられている
irb(main):002:0> blob.attachments
  Active Storage::Attachment Load (0.2ms)  SELECT  "active_storage_attachments".* FROM "active_storage_attachments" WHERE "active_storage_attachments"."blob_id" = ? LIMIT ?  [["blob_id", 1], ["LIMIT", 11]]
=> #<ActiveRecord::Associations::CollectionProxy [#<Active Storage::Attachment id: 1, name: "images", record_type: "Message", record_id: 1, blob_id: 1, created_at: "2018-08-16 14:41:12">]>

6. S3の設定(バケット作成、CORS設定、IAM作成)

ファイルを添付できるようになりましたので、ローカルではなくS3にファイルを配置するようにします。
そのために、S3のバケット作成、CORSの設定、IAMの作成をしていきます。

まずは、AWSにログインし、S3にバケットを作ります。
テスト用なのでバージョニングや暗号化はせず、デフォルトの設定で作成します。
アクセス権限もオーナーのみにしておきます。

f:id:nipe880324:20180818215051p:plain:w420

次に、作成したバケットの「アクセス権限」の「CORSの設定」からPUTとGETメソッドを許可するようにします。
PUTはファイルをアップロードするとき、GETはファイルをプレビューするときに必要になります。
※本番環境では必要に応じて、llowedOriginに本番環境のURLを設定することでセキュリティを高めれると思います。

f:id:nipe880324:20180818215109p:plain:w420

CORSの設定内容

<?xml version="1.0" encoding="UTF-8"?>
<CORSConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
    <CORSRule>
        <AllowedOrigin>*</AllowedOrigin>
        <AllowedMethod>GET</AllowedMethod>
        <MaxAgeSeconds>3000</MaxAgeSeconds>
        <AllowedHeader>Authorization</AllowedHeader>
    </CORSRule>
    <CORSRule>
        <AllowedOrigin>*</AllowedOrigin>
        <AllowedMethod>PUT</AllowedMethod>
        <MaxAgeSeconds>3000</MaxAgeSeconds>
        <AllowedHeader>*</AllowedHeader>
    </CORSRule>
</CORSConfiguration>

そして、作成したバケットに対して、ファイルを取得/追加/削除できる権限をもったポリシーを作ります。
Put, Get, Deleteなど必要最低限の権限のみ付与しています。
バケット名は作成した名前に変更してください。

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "VisualEditor0",
            "Effect": "Allow",
            "Action": [
                "s3:PutObject",
                "s3:GetObject",
                "s3:ListBucket",
                "s3:DeleteObject",
                "s3:PutObjectAcl"
            ],
            "Resource": [
                "arn:aws:s3:::rails-webook-test-bucket",
                "arn:aws:s3:::rails-webook-test-bucket/*"
            ]
        }
    ]
}

ユーザーを作成し、作成したポリシーを追加します。
作成を完了したら、アクセスキーとシークレットアクセスキーはメモしておきます。

f:id:nipe880324:20180818214943p:plain:w420

7. S3へのファイルアップロード

S3のバケット作成やIAMの作成ができましたので、Rails側で設定をおこない、S3にアップロードできるようにします。

Gemfileに'aws-sdk-s3'を追加します。

echo "gem 'aws-sdk-s3'" >> Gemfile
bundle install

そして、先ほど作成したIAMユーザーのアクセスキーとシークレットアクセスキーをcredentialに設定をします。

rails credentials:edit

aws:
  access_key_id: <作成したユーザーのアクセスキー>
  secret_access_key: <作成したユーザーのシークレットアクセスキー>

そして、storage.ymlを修正します。
バケット名は自分で作成したバケット名にしてください。
リージョンもバケットを作ったリージョンに変更してください。(東京の場合ap-northeast-1です)

# config/storage.yml
# Use rails credentials:edit to set the AWS secrets (as aws:access_key_id|secret_access_key)
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: rails-webook-test-bucket

最後に、development.rblocalではなくamazonを使うように変更します。

# config/environments/development.rb
- config.active_storage.service = :local
+ config.active_storage.service = :amazon

画面から画像をアップロードすると、S3で作ったバケットにファイルがアップロードされます。

f:id:nipe880324:20180818215020p:plain:w420

8. S3にダイレクトアップロード

S3にファイルをアップロードできるようになりましたので、ダイレクトアップロードをできるようにします。
Active Storageがダイレクトアップロードも簡単にできるように実装されているのでほとんど手間はありません。

applicaiton.jsactivestorageがrequireされていることを確認します。
Rails 5.2の場合、rails newで作成したときにデフォルトで設定されているはずなので追記は必要ないはずです。

# app/assets/javascripts/application.js
  // ...
  //= require rails-ujs
  //= require activestorage
  //= require turbolinks
  //= require_tree .

そして、file_fieldの引数にdirect_upload: trueを追加します。

# app/views/messages/_form.html.erb
  <%= form_with(model: message, local: true) do |form| %>
    ...

    <div class="field">
      <%= form.label :images %>
-     <%= form.file_field :images+ multiple: true %>
+     <%= form.file_field :images, multiple: true, direct_upload: true %>
    </div>

    <div class="actions">
      <%= form.submit %>
    </div>
  <% end %>

これで完了です。動作を確認するとファイルは添付できますが、見た目としては変わってないようにみえます。

ブラウザの開発者ツールのNetworkネットワークを見ると、 GET /rails/active_storage/direct_uploadにアクセスし署名つきURLを取得し、 そして、バケットに対して、OPTIONSメソッド、そして、PUTでファイルをダイレクトアップロードしています。 最後に、POST messagesでファイル添付つきのメッセージを作成し、302でmessages/7にリダイレクトしています。

f:id:nipe880324:20180818214740p:plain:w420

9. ファイルのアップロード時のユーザー認証

Active Storageは、ダイレクトアップロードの署名つきURLを取得するエンドポイントとして、POST /rails/active_storage/direct_uploadを追加します。
そして、ActiveStorage::DirectUploadsControllerが処理を行います。
このエンドポイントさえ知っていれば、現在はユーザー認証していなくても、ダイレクトアップロードができてしまうので、認証のチェックをするようにします。

Active Storageのroutes.rbで以下のように設定されています。

# ソースコード
# https://github.com/rails/rails/blob/master/activestorage/config/routes.rb#L30

post "/rails/active_storage/direct_uploads" => "active_storage/direct_uploads#create", as: :rails_direct_uploads

そのため、Active Storage::DirectUploadsControllerを作ればコントローラー内で認証処理を記載できます。
もしくは、自前でコントローラーを作り、routes.rbにルートを設定させる方法もあります。
今回は前者で実装してみます。

まずは、Active Storage::DirectUploadsControllerを作ります。
ソースコードはほぼ本家と同じです。before_actionで認証する処理を追加しています。

# app/controllers/active_storage/direct_uploads_controller.rb
# frozen_string_literal: true

# original: https://github.com/rails/rails/blob/master/activestorage/app/controllers/active_storage/direct_uploads_controller.rb
class Active Storage::DirectUploadsController < Active Storage::BaseController
  before_action :authenticate_user!

  def create
    blob = Active Storage::Blob.create_before_direct_upload!(blob_args)
    render json: direct_upload_json(blob)
  end

  private
    def blob_args
      params.require(:blob).permit(:filename, :byte_size, :checksum, :content_type, :metadata).to_h.symbolize_keys
    end

    def direct_upload_json(blob)
      blob.as_json(root: false, methods: :signed_id).merge(direct_upload: {
        url: blob.service_url_for_direct_upload,
        headers: blob.service_headers_for_direct_upload
      })
    end
end

ログインしてない状態でダイレクトアップロードを実施すると、401 Unauthorizedが返ってくるようになりました。

f:id:nipe880324:20180817020853p:plain:w420

10. ファイルのバリデーション

ユーザー認証ができるようになったので、次はファイルのバリデーションを実装してみます。
上記で作成したDirectUploadsControllerでバリデーションをかけるか、Messageモデルでバリデーションをかける方法などがあります。
DirectUploadControllerは今後増えるさまざまなファイル添付で共通的に使うコントローラーなので、ファイルサイズやコンテンツタイプぐらいは軽くチェックし、モデルで個別に詳細にチェックするのがよいのかなと思います。

ここでは、DirectUploadsControllerではファイルサイズが1GB以上だとアップロードできないようにします。

# app/controllers/active_storage/direct_uploads_controller.rb
  class Active Storage::DirectUploadsController < Active Storage::BaseController
    before_action :authenticate_user!
+   before_action :check_file_size!

    ...

    private
+     def check_file_size!
+       if blob_args[:byte_size] > 1.gigabyte
+         render json: { message: 'File size must be less than 1GB.' }, status: :unprocessable_entity
+       end
+     end

現在のjsのactivestorageではエラーが発生したらalertで表示しているだけなのでエラー表示がユーザーフレンドリーではないのが辛いところです。
JSは自前で作ったほうがよいかもしれません。

f:id:nipe880324:20180817020807p:plain:w420

そして、モデルでコンテンツタイプやファイルサイズをチェックします。
file_validatorsというファイルのバリデーターを追加してくれるgemがあるのでそれを使います。

echo "gem 'file_validators'" >> Gemfile
bundle install

ここでは、メッセージに添付でいるファイルは、100キロバイトから1メガバイトのサイズで、コンテンツタイプはjpegpngのみにしています。

# app/models/message.rb
  class Message < ApplicationRecord
    belongs_to :user
    has_many_attached :images
+   validates :images, file_size: { in: 100.kilobytes..1.megabyte },
+                      file_content_type: { allow: ['image/jpeg', 'image/png'] }
  end

実際にテキストファイルをアップロードすると、次のようにエラーが表示されメッセージを保存することができません。

f:id:nipe880324:20180817020731p:plain:w420

11. ダイレクトアップロード時にゴミとなったファイルの削除

ファイルのバリデーションができるようになりましたが、ダイレクトアップロードをしているのでバリデーションエラーが発生した時は、メッセージレコードに紐づかないファイルがS3にアップロードされてしまいゴミとして残ってしまいます。 そのため、バッチなどで定期的にゴミファイルとゴミレコードの削除が必要です。

Active Storage::Blobにはunattachedというスコープがあるのでそれを使うことで実現ができます。

# ソースコード
# https://github.com/rails/rails/blob/master/activestorage/app/models/active_storage/blob.rb#L36

scope :unattached, -> { left_joins(:attachments).where(Active Storage::Attachment.table_name => { blob_id: nil }) }

具体的には下記のコマンドのどちらかをバッチで実行するようにすれば良いと思います。

# バックグラウンドで非同期ジョブが走って削除する
Active Storage::Blob.unattached.find_each(&:purge_later)

# ジョブが走って削除する
Active Storage::Blob.unattached.find_each(&:purge)

以上です。