概要
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の設定をクリック
下記を追加して保存。
<?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へのダイレクトアップロード時はファイルは添付されたものをそのままアップロードする。