外に出るねくら

~ 外に出たって結局やることは自宅と同じ ~

Base64とmulti-part/form-dataのファイルアップロードを実践した

背景

ファイルのアップロード方法を考えたので、いくつかのパターンを実践してみた。

超おさらい

こちらのポストで考えていた内容。

ファイルをS3にアップロードするときはサーバー?クライアント? - 外に出るねくら

サーバー側でアップロードを実装する場合の方針は3つ。

  1. 画像アップロード用のエンドポイントを作り、multi-part/form-dataを使う。
  2. Base64 Encodeを使う。
  3. 全てmulti-part/form-dataを使う。

クライアント側でアップロードを実装する場合の方針は1つ。

  1. aws-sdkを使って一時URLを発行し、クライアント側から直接アップロード。

リソース、時間との相談でサーバー側の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を使うにはこちらのガイドを参考にしてます。

  1. bundle exec rails active_storage:installを実行
  2. active_storage_attachmentsとactive_storage_blobsのマイグレーションファイルができていることを確認
  3. bundle exec rails db:migrateを実行
  4. テーブルが作成されていることを確認
  5. 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
  1. bundle exec rails db:migrateを実行
  2. テーブルが作成されていること、app/model内にitem.rbができていることを確認
  3. item.rbに以下を追記
    これを追記することでActiveStorageで追加したテーブルと、itemとのリレーションを張って、item.attach(image)みたいな感じでアイテムに画像をアタッチすることができる
  has_many_attached :image

これで事前準備完了。
保存処理に着手できる。

corsの設定

クライアントとサーバーとで分けてJSONで通信するような仕組みを採用する場合、大体オリジンが異なるのでcorsの設定をしておかないとクライアントからのリクエストをサーバー側で受け付けられなくなる。

クライアントはVueを使いますが、Vueを使うとデフォルトがlocalhost:8080ドメインなのに対して、Railslocalhost: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に追記

  1. インスタンス(item)を作成
  2. アイテムの保存

アイテムの保存に成功した場合
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の場合と変わらない。

  1. インスタンス(item)を作成
  2. アイテムの保存

アイテムの保存に成功した場合
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を使う方がいい気がしました。