【Rails API + Vue】Active Storageを使って画像をアップロード・表示する


バックエンドはRails、フロントエンドはVueといった構成のときにActive Storageを使って画像をアップロード・表示する方法を、プロジェクトを1から作りながらまとめます
ソースコードはGitHubで公開しています

画像をアップロード・表示する処理の流れをざっくりと

  • Vueで画像を選択して送信するための画面を作る
  • 送信ボタンを押した時、画像をアップロードする処理を行うRails APIを呼び出す
  • Railsは受け取った画像をstorageディレクトリに保存し、保存した画像のURLを返す
  • Vueで画像のURLを受け取り、表示する

Railsプロジェクトを作成する

↓のようなディレクトリ構成で作成していきます

rails-vue-file-uploader-sample
└── backend   # Railsプロジェクト
└── frontend  # Vueプロジェクト

まずはRailsプロジェクトをAPIモードで作成します

$ mkdir rails-vue-file-uploader-sample
$ cd rails-vue-file-uploader-sample
$ rails _6.0_ new backend --api
$ cd backend
$ rails db:create

Active Storageを使えるようにする

$ rails active_storage:install
$ rails db:migrate

これらを実行するとactive_storage_blobsactive_storage_attachmentsという名前の2つのテーブルが作成されます
これらはActiveStorage::BlobActiveStorage::Attachmentの2つのモデルで扱われます

  • ActiveStorage::Blob:アップロードファイルのメタ情報を管理するためのモデル
  • ActiveStorage::Attachment:主となるモデルとActiveStorage::Blobとの中間テーブルに相当するモデル

例えばPostモデルに画像を持たせる場合は次のような関係になります
スクリーンショット 2020-11-15 16.54.33.png

モデルを作成する

titleとimageを属性に持つPostモデルを作成します
imageの型にはattachmentを指定します

$ rails g model post title:string image:attachment
$ rails db:migrate

これらを実行するとpostsテーブルが作成されます
マイグレーションファイルを見てみるとわかるのですが、postsテーブルにimageカラムは作られません
image属性の中身はActiveStorage::Blob及びActiveStorage::Attachmentに保存され、それを参照するようになります

生成されたapp/models/post.rbを見ると、has_one_attached :imageが指定されています
この指定によって画像を参照できるようになります

app/models/post.rb
class Post < ApplicationRecord
  has_one_attached :image
end

コントローラを作成する

$ rails g controller posts
app/controllers/posts.rb
class PostsController < ApplicationController
  def index
    render json: Post.all
  end

  def create
    post = Post.new(post_params)
    if post.save
      render json: post
    else
      render json: post.errors, status: 422
    end
  end

  def destroy
    post = Post.find(params[:id])
    post.destroy!
    render json: post
  end

  private

  def post_params
    params.permit(:title, :image)
  end
end

とりあえず普通に書きます
routesも設定します

config/routes.rb
Rails.application.routes.draw do
  scope :api do
    resources :posts, only: [:index, :create, :destroy]
  end
end

保存したファイルのURLを返すようにする

Postモデルに、紐づいている画像のURLを取得するメソッドを追加します
url_forメソッドを使うためにRails.application.routes.url_helpersをincludeする必要があります

app/models/post.rb
class Post < ApplicationRecord
  include Rails.application.routes.url_helpers

  has_one_attached :image

  def image_url
    # 紐づいている画像のURLを取得する
    image.attached? ? url_for(image) : nil
  end
end

アクションで返すJSONにimage_urlの値を追加します

app/controllers/posts.rb
class PostsController < ApplicationController
  def index
    render json: Post.all, methods: [:image_url]  # ここを変更
  end

  def create
    post = Post.new(post_params)
    if post.save
      render json: post, methods: [:image_url]  # ここを変更
    else
      render json: post.errors, status: 422
    end
  end

  def destroy
    post = Post.find(params[:id])
    post.destroy!
    render json: post
  end

  private

  def post_params
    params.permit(:title, :image)
  end
end

画像のURLを取得するためにconfig/environments/development.rbに次の設定を追加する必要があります

config/environments/development.rb
Rails.application.configure do
  ...

  # これを追加
  Rails.application.routes.default_url_options[:host] = 'localhost'
  Rails.application.routes.default_url_options[:port] = 3000
end

VueとのAPI通信をするためにCORSの設定をしておきます
Gemfileのgem 'rack-cors'のコメントを外してbundle installし、config/initializers/cors.rbを次のように書きます

config/initializers/cors.rb
Rails.application.config.middleware.insert_before 0, Rack::Cors do
  allow do
    origins 'http://localhost:8080'

    resource '*',
      headers: :any,
      methods: [:get, :post, :put, :patch, :delete, :options, :head]
  end
end

Vueプロジェクトを作成する

ここからはVueを書いていきます
まずはルートディレクトリに戻ってVueプロジェクトを作成します

$ cd rails-vue-file-uploader-sample
$ vue create frontend
$ cd frontend

vue createの設定は以下のように選択しました

? Please pick a preset: Manually select features
? Check the features needed for your project: Vuex, Linter
? Pick a linter / formatter config: Prettier
? Pick additional lint features: Lint on save
? Where do you prefer placing config for Babel, ESLint, etc.? In dedicated config files
? Save this as a preset for future projects? No

Vuexストアを作成する

Vuexを次のように書きます
axiosを使用するのでインストールしておきます

$ npm install --save axios
src/store/modules/posts.js
import axios from "axios";

const apiUrlBase = "http://localhost:3000/api/posts";
const headers = { "Content-Type": "multipart/form-data" };

const state = {
  posts: []
};

const getters = {
  posts: state => state.posts.sort((a, b) => b.id - a.id)
};

const mutations = {
  setPosts: (state, posts) => (state.posts = posts),
  appendPost: (state, post) => (state.posts = [...state.posts, post]),
  removePost: (state, id) =>
    (state.posts = state.posts.filter(post => post.id !== id))
};

const actions = {
  async fetchPosts({ commit }) {
    try {
      const response = await axios.get(`${apiUrlBase}`);
      commit("setPosts", response.data);
    } catch (e) {
      console.error(e);
    }
  },
  async createPost({ commit }, post) {
    try {
      const response = await axios.post(`${apiUrlBase}`, post, headers);
      commit("appendPost", response.data);
    } catch (e) {
      console.error(e);
    }
  },
  async deletePost({ commit }, id) {
    try {
      axios.delete(`${apiUrlBase}/${id}`);
      commit("removePost", id);
    } catch (e) {
      console.error(e);
    }
  }
};

export default {
  namespaced: true,
  state,
  getters,
  mutations,
  actions
};
src/store/index.js
import Vue from "vue";
import Vuex from "vuex";
import posts from "./modules/posts";

Vue.use(Vuex);

export default new Vuex.Store({
  modules: {
    posts
  }
});

画像をアップロードするコンポーネントを作成する

画像を選択して送信するフォームを表示するためのsrc/components/PostForm.vueを作成します

src/components/PostForm.vue
<template>
  <div>
    <h2>PostForm</h2>
    <section>
      <label for="title">title: </label>
      <input type="text" name="title" v-model="title" placeholder="title" />
    </section>
    <section>
      <label for="image">image: </label>
      <input type="file" id="image" name="image" accept="image/png,image/jpeg" @change="setImage" />
    </section>
    <section>
      <button type="submit" @click="upload" :disabled="title === ''">upload</button>
    </section>
  </div>
</template>

<script>
import { mapActions } from "vuex";

export default {
  name: "PostForm",
  data: () => ({
    title: "",
    imageFile: null
  }),
  methods: {
    ...mapActions("posts", ["createPost"]),
    setImage(e) {
      e.preventDefault();
      this.imageFile = e.target.files[0];
    },
    async upload() {
      let formData = new FormData();
      formData.append("title", this.title);
      if (this.imageFile !== null) {
        formData.append("image", this.imageFile);
      }
      this.createPost(formData);
      this.resetForm();
    },
    resetForm() {
      this.title = "";
      this.imageFile = null;
    }
  }
};
</script>

選択された画像はe.target.filesで取り出すことができます
POSTリクエストを送信するときはFormDataに必要な値をappendしたものをパラメータとして指定します

画像を表示するコンポーネントを作成する

保存されている画像を取得して表示するためのsrc/components/PostList.vueを作成します

src/components/PostList.vue
<template>
  <div>
    <h2>PostList</h2>
    <div v-for="post in posts" :key="post.id">
      <h3>{{ post.title }}</h3>
      <img :src="post.image_url" />
      <br />
      <button type="submit" @click="del(post.id)">delete</button>
    </div>
  </div>
</template>

<script>
import { mapActions, mapGetters } from "vuex";

export default {
  name: "PostList",
  created() {
    this.fetchPosts();
  },
  computed: {
    ...mapGetters("posts", ["posts"])
  },
  methods: {
    ...mapActions("posts", ["fetchPosts", "deletePost"]),
    del(id) {
      this.deletePost(id);
    }
  }
};
</script>

<img :src="post.image_url" />でsrcに取得したURLを指定して表示させます

最後にApp.vueを編集してコンポーネントを表示します

src/App.vue
<template>
  <div id="app">
    <PostForm />
    <PostList />
  </div>
</template>

<script>
import PostForm from "./components/PostForm.vue";
import PostList from "./components/PostList.vue";

export default {
  name: "App",
  components: {
    PostForm,
    PostList
  }
};
</script>

完成

画像を選択してアップロードボタンを押すと、画像が保存されて表示されます
画像はbackend/storageディレクトリにバイナリ形式で保存されます

スクリーンショット 2020-11-15 17.43.33.png

ソースコードはGitHubで公開しています
参考になれば嬉しいです

https://github.com/youichiro/rails-vue-file-uploader-sample

参考