35
27

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

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

Posted at

バックエンドは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

参考

35
27
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
35
27

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?