概要

Heroku経由でS3にファイルアップロードする際、Herokuのリクエストタイムアウトは30秒に設定されているので、大容量ファイルをアップロードすると時間が足りずタイムアウトエラーとなってしまう。 そのため、Herokuで4MB以上のファイルをアップロードする際は、Herokuを介さずブラウザから直接アップロードすることが推奨されている。

https://devcenter.heroku.com/articles/s3#direct-upload

In a direct upload, a file is uploaded to your S3 bucket from a user’s browser, without first passing through your app. This method is recommended for user uploads that might exceed 4MB in size. Although this method reduces the amount of processing your application needs to perform, it can be more complex to implement. It also limits the ability to modify (transform, filter, resize, etc.) files before storing them in S3.

直接アップロードでは、最初にアプリを経由せずに、ユーザーのブラウザーからファイルがS3バケットにアップロードされます。この方法は、サイズが4MBを超える可能性があるユーザーアップロードに推奨されます。 この方法では、アプリケーションで実行する必要のある処理量は減りますが、実装が複雑になる場合があります。また、S3に保存する前にファイルを変更(変換、フィルター、サイズ変更など)する機能も制限されます。

とのこと。つまり、アップロードするファイルにバリデーションなどが必要になる場合、ファイルサイズが4MB以下の場合は通常通りサーバサイドを経由してアップロードするのが良いが、4MB以上のファイルはS3へ直接アップロードすべしとのこと。ただし、S3に保存する前にバリデーションをかけることはできないので、アップロード後バリデーション処理やファイル編集を行う必要があるとのこと。

Railsではファイルアップロード機能を実装する場合、CarrierwaveもしくはActive Storageを使うことが大半だと思うので、今回はCarrierwaveを使用した方法について記述する。

ちなみに、carrierwave_directというgemが存在するが、こちらは最後のPRが2016年12月で止まっているので使用しない方が良いと思う。

Version

Rails 6.0.0 Carrierwave 2.0.2 Vue.js 2.6.11
なお、この記事ではVue.jsの設定などの解説は行わず、WebpackerでVue.jsの基本的な設定を行っていることを前提にして解説する。

処理の流れ

S3へのアップロードまでの流れは下記の通りとなる。

①Vue.js → Rails : レコード生成とS3にアップロードするための署名付きリンクをリクエスト ②Rails → Vue.js : 署名付きリンクを返却 ③Vue.js → S3 : 取得した署名付きリンクでファイルアップロード

S3 Bucketの設定

S3にアクセスし、対象のBucketに入り、アクセス権限 → CORSの設定をクリック S3_bucket

下記を追加して保存。

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

Rails側の設定と処理

RailsからS3を操作出来るようにするために、aws-sdkをインストールし設定を行う。

# Gemfile

gem 'aws-sdk'
# config/initializers/aws.rb

unless Rails.env.test? || Rails.env.development?
  credentials = Aws::Credentials.new(
    ENV["S3_ACCESS_KEY_ID"],     # アクセスキーID
    ENV["S3_SECRET_ACCESS_KEY"]  # シークレットアクセスキー
  )

  s3_resource = Aws::S3::Resource.new(region: 'bucketのリージョン名', credentials: credentials)
  S3_BUCKET = s3_resource.bucket('S3のバケット名')
end

URL生成のメソッドはCarrierwaveのUploaderで記述し、ファイル取得はCarrierwaveで行えるようにする。

# app/uploaders/application_uploader.rb

class ApplicationUploader < CarrierWave::Uploader::Base
  def store_dir
    "uploads/#{model.class.to_s.underscore}/#{mounted_as}/#{model.id}"
  end

  def presigned_url(file_name = nil)
    file_name ||= self.model.attributes[mounted_as.to_s]
    object = S3_BUCKET.object([store_dir, file_name].join('/'))
    # 署名付きリンクは10分で失効させる(デフォルトでは15分)
    object.presigned_url(:put, expires_in: 10.minutes.to_i, acl: 'private')
  end
end

次にAPIを作っていく。

# app/controllers/api/v1/products_controller.rb

class ProductsController < ApplicationController
  def create
    @product = Image.new(product_params)
    # 先に@productのオブジェクトを作成し、後からファイルカラムを更新する
    if @product.save!
      @product.update_column('image', product_params[:image])
        render json: {
          id: @product.id,                          # ファイルのID
          image_url: @product.image.presigned_url   # ファイルをアップロードするための署名付きリンク
        }
    else
      render json: @product.errors, status: :unprocessable_entity
    end
  end
end

Vue.js側の処理

次にフロント側のフォームと処理を書いていく。 まずはテンプレートのフォームから。

// app/javascript/packs/app.vue

<template>
<input
  class="custom-file-input"
  type="file"
  name="products[image]"
  ref="productImage"
>

<div class="form-group">
  <input
    type="submit"
    name="commit"
    value="アップロード"
    class="btn btn-success submit"
    data-disable-with="アップロード"
    @click="postProduct"
  >
</div>
</template>

次にメソッドの処理

// app/javascript/packs/app.vue

<script>
export default {
  data: () => ({
    presignedUrl: '',  // Rails側で発行される署名付きリンク
    uploadFile: {},    // アップロードする予定のファイル
    productId: '',     // アップロードするファイルのID
  }),

  // ...

  methods: {
    // ファイルを保存するためのレコードを作成するためにpostする
    async postProduct () {
      try {
        // フォームにref="productImage"と記述することで、ファイルをこのように取り出せる
        this.uploadFile = this.$refs.productImage.files[0]
        let postingUrl = `/api/v1/products`
        let payload = {
          product: {
            image: this.uploadFile.name
          }
        }
        let res = await axios.post(postingUrl, payload)
        // res = { data: { id: 1, image_url: "..." } }の形
        this.presignedUrl = res.data.image_url
        this.productId = res.data.id
        // サーバサイドでファイルに紐づくレコードが保存出来たので、S3へアップロード
        this.fileUpload()
      } catch(e) {
        console.error(e)
      }
    },

    async fileUpload () {
      try {
        const config = {
          headers: {
            'content-type': 'multipart/form-data'
          }
        }
        // formDataは使わずファイルをそのままアップロードする
        await this.$axios.put(this.presignedUrl, this.uploadFile, config)
      } catch(e) {
        console.error(e)
      }
    }
  }
}
</script>

以上の処理でS3へのアップロードは完了する。 注意点としては、S3へアップロードするファイルはformDataで成形せず、添付されたファイルをそのままアップロードする事。 ファイルをFormDataに添付すると、FormDataがシリアル化され、そのシリアル化されたデータがS3に格納される。 これによってファイルが破損してしまうので、S3へのダイレクトアップロード時はファイルは添付されたものをそのままアップロードする。