MAGAZINE

MAGAZINE

2018.04.10

ブログ

Vue.jsからS3に直接ファイルをアップロードする

ブラウザから自前のサーバーを通さずAmazon S3に直接画像ファイルをアップロードする実装をしてみました。

背景

サーバーサイドはRailsで作っていて、もともとはcarrierwaveというgemを使ってファイルアップロードを実装していました。

このgemはサーバーにアップロードされたファイルを簡単な設定でS3に保存できるスグレモノなのですが、パフォーマンスに難点がありました。

サーバーはherokuでの運用を考えているのですが、この設計だとブラウザからherokuにアップロードしたファイルをherokuからS3に送信することになるので、ネットワークのコストが高い上、herokuのサーバーは貧弱なのでサーバー内の処理時間も大きいです。

解決策を探したところ、herokuのドキュメントにDirect to S3 Image Uploads in Railsという記事を見つけました。この記事ではjQuery-File-Upload を使っていますが、今回はVue.jsとelement-uiのFileUploadコンポーネントを利用した実装を行いました。

方針

おおまかには以下のような設計です。

  • S3へのアップロードはpre-signed POSTを利用しフロントエンドで行う
  • pre-signed POSTはRailsサーバーでaws-sdk gemを利用して生成する
  • DBにはアップロード先のS3のkeyを保存する

pre-signed POSTというのは、特定のkeyにアップロードするための有効期限付きのパラメーターです。フロントエンドにAWSのアクセスキーを晒すことなくフロントエンドからS3へのアップロードを実現できます。

事前準備

  • S3アカウント
  • アクセスキー
  • アップロード先bucketの設定

実装

Userモデルにavator画像をアップロードする機能を作る、という例で書いてみます。

pre-signed POSTの生成

まずはaws-sdkのインストール。Gemfileに

gem 'aws-sdk', '~> 2'

を追記して
 bundle install

環境変数にAWSのアクセスキー、シークレットキー、バケツの名前を設定して、config/initializers/aws.rbでS3bucketのインスタンスを作ります。

Aws.config.update({
  region: 'ap-northeast-1',
  credentials: Aws::Credentials.new(ENV['AWS_ACCESS_KEY_ID'], ENV['AWS_SECRET_ACCESS_KEY']),
})

S3_BUCKET = Aws::S3::Resource.new.bucket(ENV['S3_BUCKET'])

これで準備完了。あとはpre-signed POSTを生成してフロントに返すためのアクションを追加します。

config/routes.rbにて

get '/users/:id/upload', to: 'users#upload'

app/controllers/users_controller.rbにて

def upload
  @s3_direct_post = S3_BUCKET.presigned_post(
    key: "uploads/user/#{params[:id]}/${filename}", # アップロード先のkey
    success_action_status: '201', # アップロード成功時のステータスコード
    acl: 'public-read', # アップロードファイルは公開
    expires: Time.now + 60) # 60秒後に失効させる
end

keyにある${filename}は、アップロード時に対象ファイルの名前が代入されます。

success_action_statusを201にすることで、S3からのレスポンスにfilenameが代入された後のkeyが入ってきます。

最後にpre-signed POSTをJSONにして返すjbuilderを作ります。

json.url @s3_direct_post.url
json.data @s3_direct_post.fields

これで以下のようなレスポンスを返すAPIができあがりました。

{
  "url": "https://your-bucket.s3.ap-northeast-1.amazonaws.com",
  "data": {
    "key": "uploads/user/#{params[:id]}/${filename}",
    "success_action_status": "201",
    "acl": "public-read",
    "policy": "eyJleHBpcmF0aW9uIjoiMjAxOC0wNC0xMFQwNDo0MzoyOFoiLCJjb25kaXRpb25zIjpbeyJidWNrZXQiOiJrYXRhcml0ZS1kZXYifSxbInN0YXJ0cy13aXRoIiwiJGtleSIsInVwbG9hZHMvMS9zZXJ2aWNlLy9qWXZSRHJwaHRjdU9ZbmdJazRyMjRRLyJdLHsic3VjY2Vzc19hY3Rpb25fc3RhdHVzIjoiMjAxIn0seyJhY2wiOiJwdWJsaWMtcmVhZCJ9LHsieC1hbXotY3JlZGVudGlhbCI6IkFLSUFJNzRXSVlKN1BRQU1VSlZBLzIwMTgwNDEwL2FwLW5vcnRoZWFzdC0xL3MzL2F3czRfcmVxdWVzdCJ9LHsieC1hbXotYWxnb3JpdGhtIjoiQVdTNC1ITUFDLVNIQTI1NiJ9LHsieC1hbXotZGF0ZSI6IjIwMTgwNDEwVDAzNDMyOVoifV19",
    "x-amz-credential": "AKIAI74WIYJ7PQAMUJVA/20180410/ap-northeast-1/s3/aws4_request",
    "x-amz-algorithm": "AWS4-HMAC-SHA256",
    "x-amz-date": "20180410T034329Z",
    "x-amz-signature": "e617ed91f559f1a9151b3482b75936abb8e2156121ae8b9d9c91e6be0805d032"
  }
}

フロントエンドでファイルアップロード

今回は Vue.jsとelement-uiのFileUploadコンポーネントを利用した例ですが、おおまかな方針は

  • 上で作ったAPIからpre-signed POSTを取得する
  • pre-signed POSTにファイルを乗っけてS3にPOSTする

という話なので、フロントエンドのライブラリに合わせてアレンジしてください。

まず、S3のレスポンスはxmlドキュメントを返しますが、axiosはxmlをパースしてくれないのでaxiosの共通処理にxmlのパースを差し込んでおきます。

import xml2js from 'xml2js'

axios.defaults.transformResponse = _.concat(axios.defaults.transformResponse, [
  function (data, header) {
    if (header['content-type'] = 'application/xml' && typeof data === 'string') {
      let result
      xml2js.parseString(data, (err, data) => {
        if (err) {
          throw err
        }
        result = data
      })
      return result
    }
    return data
  }
])

FileUploadコンポーネントの設定はこんな感じ

<el-upload action="action" :accept="fileTypes" :auto-upload="false":on-change="uploadFile">

ポイントはauto-uploadをfalseにしてon-changeイベントハンドラでアップロードを自前のメソッドで行うようにしている点です。

そしてuploadFileメソッドの実装は以下の通り。

uploadFile: async function (file) {
  # pre-signed POSTを取得
  const getRes = await axios.get('/users/' + this.id + '/upload')
  const preSignedPost = getRes.data
  # S3にアップロード
  const res = await axios.post(preSignedPost.url, Object.assign(preSignedPost.data, {file: file.raw})) # file.rawがFileオブジェクト(FileUploadの仕様)
}

最後に、S3のCORS設定を行い、フロントエンドからのアップロードを許可します。

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

これでフロントエンドから直接S3にファイルをアップロードできるようになりました。あとはuploadFileメソッドのresにアップロード先のkeyなどの情報が入っているので、他のカラムと同様にDBに保存させればOKです。

終わりに

思っていたよりも簡単に実装できたと思います。

元ネタのherokuドキュメントAWSのドキュメントの例ではサーバーサイドでviewのレンダリング時にHTMLフォームにpre-signed POSTの値を埋め込む実装でしたが、アップロードの直前にpre-signed POSTを生成するほうがpre-signed POSTの失効の可能性を無視できるのでベターかなと思ってこのような実装にアレンジしてみました。

この記事を書いた人
吉岡 周

CONTACT US

関連記事

RELATED ARTICLE