Base64とmulti-part/form-dataのファイルアップロードを実践した
背景
ファイルのアップロード方法を考えたので、いくつかのパターンを実践してみた。
超おさらい
こちらのポストで考えていた内容。
ファイルをS3にアップロードするときはサーバー?クライアント? - 外に出るねくら
サーバー側でアップロードを実装する場合の方針は3つ。
- 画像アップロード用のエンドポイントを作り、multi-part/form-dataを使う。
- Base64 Encodeを使う。
- 全てmulti-part/form-dataを使う。
クライアント側でアップロードを実装する場合の方針は1つ。
リソース、時間との相談でサーバー側の2もしくは3がいいだろうというのが結論。
今回は前回いいだろうという結論だった
Base64 Encodeを使う 全てmulti-part/form-data
のパターンで実装してみます。
以下、環境情報。
$ cat /etc/lsb-release DISTRIB_ID=Ubuntu DISTRIB_RELEASE=16.04 DISTRIB_CODENAME=xenial DISTRIB_DESCRIPTION="Ubuntu 16.04.4 LTS" $ ruby --version ruby 2.5.0p0 (2017-12-25 revision 61468) [x86_64-linux] $ rails --version Rails 5.2.3
さぁ、いってみよう
今回のソースコードはこちらに置いています。
サーバー側
https://github.com/kennyTakimura/file-uploader
クライアント側
https://github.com/kennyTakimura/file-uploader-client
今回はユースケースとして、
とある管理画面で、アイテムの名前と個数、そして画像を登録する
の想定で実装します。
ただし、ログイン機能等は実装しない。
エンドポイントが叩ける状態、アップロードできることを確認できる状態にすることがゴール。
サーバー側で実装する場合
共通部分
ActiveStorageの準備
どの選択肢にしても、S3へのアップロードの処理にはActiveStorageを使います。
Railsネイティブの機能で、画像に限らずファイルのアップロード機能を提供します。
たぶんGoogleで「Rails ファイル アップロード」とかって調べるとCarrierWaveというgem(プラグインみたいなもの)を使った方法が結構出てくると思いますが、従前から処理が重くなりがちという課題があります。
ActiveStorageが追加されたのがRails 5.2からで、これが大体1年前なのでここから先ActiveStorageを使った方法が主流になるんじゃないかなと思ってます。
実際、英語縛りで「Rails file upload」で調べると、ActiveStorageを使った方法が結構出ててきます。
英語ほどの量はないですが、もちろん日本語でも出てきます。
ActiveStorageを使うにはこちらのガイドを参考にしてます。
bundle exec rails active_storage:install
を実行- active_storage_attachmentsとactive_storage_blobsのマイグレーションファイルができていることを確認
bundle exec rails db:migrate
を実行- テーブルが作成されていることを確認
config/storage.yml
を確認し、以下のようになっていることを確認
ただし、今回はS3にアップロードするので、testとlocalはなくてもいい Rails.application.credentials.digって何だって人はこちらに記事書いてるので見てください。
Rails 5.2のCredential管理方法 - 外に出るねくら
test: service: Disk root: <%= Rails.root.join("tmp/storage") %> local: service: Disk root: <%= Rails.root.join("storage") %> # 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: your_region bucket: your_own_bucket
6.config/environments/production.rb
に以下の記述を追記
config.active_storage.service = :amazon
7.aws-sdk-s3
のgemをGemfileに追加し、bundle instal --path vendor/bundle
を実行
model作り
アイテムのmodelを作る。
1. bundle exec rails g model item
を実行
2. itemのマイグレーションファイルができていることを確認
3. 以下の記述にする
名前、個数をカラムに用意する。
画像はActiveStorage側でよしなに保存されるのでここにカラムを持つ必要は無い。
class CreateItems < ActiveRecord::Migration[5.2] def change create_table :items do |t| t.string :name t.integer :count t.timestamps end end end
bundle exec rails db:migrate
を実行- テーブルが作成されていること、
app/model
内にitem.rb
ができていることを確認 item.rb
に以下を追記
これを追記することでActiveStorageで追加したテーブルと、itemとのリレーションを張って、item.attach(image)
みたいな感じでアイテムに画像をアタッチすることができる
has_many_attached :image
これで事前準備完了。
保存処理に着手できる。
corsの設定
クライアントとサーバーとで分けてJSONで通信するような仕組みを採用する場合、大体オリジンが異なるのでcorsの設定をしておかないとクライアントからのリクエストをサーバー側で受け付けられなくなる。
クライアントはVueを使いますが、Vueを使うとデフォルトがlocalhost:8080
のドメインなのに対して、Railsはlocalhost:3000
と、オリジンが異なるので、サーバー側でcorsの設定をしないとクライアントからのリクエストがサーバー側で受け付けられないということになります。
Gemfileにgem "rack-cors"
を追記
bundle install --path vendor/bundle
を実行
config/initializer/cors.rb
のコメントアウトを解除し、オリジンを編集。
Rails.application.config.middleware.insert_before 0, Rack::Cors do allow do origins 'localhost:8080' resource '*', headers: :any, methods: [:get, :post, :put, :patch, :delete, :options, :head] end end
Base64 Encodeを使う。
この選択肢を採用する場合は、クライアント側でBase64にエンコードする処理、サーバー側でBase64をデコードする処理を実装する必要がある。
ダウンロードするなら逆に、クライアント側でBase64をデコードし、サーバー側でBase64にエンコードする処理が必要ですが、今回は割愛。
サーバー側
modelの修正
作成したitem.rbにデコード処理を追加する。
1. 拡張子を調べる(png or jpeg)
2. data:image/${拡張子};base64より後の部分を抜き出す
3. 抜き出した部分をデコード
4. デコードすると文字列なので、一旦ファイルに書き込んで一時保存
5. 保存したデータをActiveStorageでitemにアタッチ
6. アタッチしたら一時保存したファイルを削除
class Item < ApplicationRecord has_many_attached :image def attached_image=(image) if image.present? || regex_image(image) == '' extension = make_extension(image) encoded_data = image.sub(/data:(image|application)\/(jpeg|png);base64,/, '') decoded_data = Base64.decode64(encoded_data) filename = Time.zone.now.to_s + '.' + extension File.open("#{Rails.root}/tmp/#{filename}", 'wb') do |f| f.write(decoded_data) end attach_image(filename) end end private def make_extension(image) content_type = regex_image(image) content_type[/\b(?!.*\/).*/] end def regex_image(image) image[/(image\/[a-z]{3,4})|(application\/[a-z]{3,4})/] end def attach_image(filename) image.detach if image.attached? image.attach( io: File.open("#{Rails.root}/tmp/#{filename}"), filename: filename) FileUtils.rm("#{Rails.root}/tmp/#{filename}") end end
controllerに追記
- インスタンス(item)を作成
- アイテムの保存
アイテムの保存に成功した場合
3. itemの保存に成功したら場合は、画像のアップロード処理
4. 保存したアイテムの情報を返す
アイテムの保存に失敗した場合
3. unprocessable_entity(422)を返す
class ItemsController < ApplicationController def create_via_base64 item = Item.new(name: item_params[:name]) if item.save item.attached_image = item_params[:image] render json: { data: item, status: :created } else render json: { data: '', status: :unprocessable_entity } end end private def item_base64_params params.require(:item).permit(:name, :count, :image) end end
ルーティングに追記
config/routes.rb
に以下を追記する。
post '/item/base64', to: 'items#create_via_base64'
以上でサーバー側の実装は完了。
やっぱりBase64をデコードする部分が一番面倒くさい。
本来はこの処理はitem.rb
ではなくて、別クラスに切り出した方がいいけど今回はそこまでしない。
クライアント側
続いてクライアント。
Vueを使う。
すでにプロジェクトは作っている前提で進めます。
componentを分けるとかいうことは一切考えず、全パターン1ファイルに書いていきます。
とにかくアップロード処理を作ることが優先。
vue-base64-file-uploadを入れる
外部のコンポーネント。
これのおかげでめちゃくちゃ簡単に機能を実装できる。
こちらがREADME
npm i -g vue-base64-file-upload
を実行。
まずはJS部分から書いていきます。
import axios from 'axios' import VueBase64FileUpload from 'vue-base64-file-upload' export default { name: 'app', components: { ~~~ 省略 ~~~ VueBase64FileUpload, }, data: function () { return { ~~~ 省略 ~~~ name_base64: '', count_base64: 0, image_base64: '' } }, methods: { ~~~ 省略 ~~~ async postItem() { const res = await axios.post( "http://localhost:3000/item/base64", { item: { name: '', count: 0, image: this.image_base64 } } ) this.clear() }, onLoad(dataUri) { this.image_base64 = dataUri }, clear() { this.name_base64 = '' this.count_base64 = 0 this.image_base64 = '' } } }
次にtemplate部分。 名前、個数、画像をアップロードするので、フォームを用意する。
<template> <div id="app"> <h2>アイテム投稿</h2> ~~~ 省略 ~~~ <h3>Base64</h3> <form> <p> <label>Name </label> <input name="name-base64" type="text" v-model="name_base64"/> </p> <p> <label>Count </label> <input name="count-base64" type="number" v-model="count_base64"> </p> <p> <vue-base64-file-upload accept="image/png, image/jpeg" @load="onLoad"></vue-base64-file-upload> </p> <button v-on:click.prevent="postItem()">Submit</button> </form> ~~~ 省略 ~~~ </div> </template>
これで完了。
すごいスッキリ書ける。
これでBase64を使った実装は終わり。
全てmulti-part/form-dataを使う。
王道での実装。
サーバー側
controllerに追記
処理の概要としてはBase64 Encodeの場合と変わらない。
- インスタンス(item)を作成
- アイテムの保存
アイテムの保存に成功した場合
3. itemの保存に成功したら場合は、画像のアップロード処理
4. 保存したアイテムの情報を返す
アイテムの保存に失敗した場合
3. unprocessable_entity(422)を返す
class ItemsController < ApplicationController def create_via_multipart_formdata item = Item.new(name: item_multipart_params[:name], count: item_multipart_params[:count]) if item.save item.image.attach(item_multipart_params[:image]) render json: { data: item, status: :created } else render json: { data: '', status: :unprocessable_entity } end end ~~~ 省略 ~~~ private ~~~ 省略 ~~~ def item_multipart_params params.permit(:name, :count, :image) end end
Base64 Encodeのときは画像の情報が文字列だったので、Decodeの処理が必要だったけど、
今回はその処理が必要ないので直接ActiveStorageのattachメソッドを使うことができる。
つまりcontroller以外に編集する必要がない。
サーバー側の修正はこれで終わり。
ルーティングに追記
config/routes.rb
に以下を追記する。
post '/item/multi_part', to: 'items#create_via_multipart_formdata'
クライアント側
まずはJS部分から書いていく。
multi-part/form-dataを使う場合は外部のコンポーネントを入れる必要ない。
import axios from 'axios' import VueBase64FileUpload from 'vue-base64-file-upload' export default { ~~~ 省略 ~~~ data: function () { return { ~~~ 省略 ~~~ name_multi_part: '', count_multi_part: 0, image_multi_part: '', } }, methods: { async postItemViaMultiPart() { let formData = new FormData() formData.append("name", this.name_multi_part) formData.append("count", this.count_multi_part.toString()) formData.append("image", this.image_multi_part) const res = await axios.post( "http://localhost:3000/item/multi_part", formData, { headers: { 'Content-Type': 'multipart/form-data' } } ) if (res.status === 200) { this.clear() alert("Item is created") } else { alert("Failed to register item") } }, loadMultiPart() { this.image_multi_part = this.$refs.file.files[0] }, ~~~ 省略 ~~~ } }
FormDataを作って、データを入れて、axiosで送信する。
簡単に実装できる。
次にtemplate部分。
<template> <div id="app"> <h2>アイテム投稿</h2> ~~~ 省略 ~~~ <h3>全部multi-part/form-data</h3> <form> <p> <label>Name </label> <input name="name-multi-part" type="text" v-model="name_multi_part"/> </p> <p> <label>Count </label> <input name="count-multi-part" type="number" v-model="count_multi_part"/> </p> <p> <label>Image </label> <input name="image-multi-part" type="file" ref="file" @change.prevent="loadMultiPart"/> </p> <button v-on:click.prevent="postItemViaMultiPart">Submit</button> </form> </div> </template>
なんら難しいことはないですね。
まとめ
なんか最初から分かってたことではあるけど、multi-part/form-dataで実装する方がはるかに楽ですね。
基本的にmulti-part/form-dataを使う方法でいいんじゃないかと思った。
APIを外部に公開するときはBase64 Encodeを使って、 そうではなく、自システム内に閉じているなら、multi-part/form-data。
前回の投稿では思考実験的な感じで、時間があればBase64 Encode、なければmulti-part/form-dataと結論づけました。
今回はそれぞれのパターンで実践してみました。
画像に関係するところはmulti-part/form-dataで、そうでないところはJSONで、とすることでコードの見通しが悪くなったりするのかなと思ってたけど、案外そうでもない。
実装量は大した差はないけど、multi-part/form-dataの方が当然少ない。
時間があろうとなかろうと、普通にmulti-part/form-dataを使う方がいい気がしました。