【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_blobsとactive_storage_attachmentsという名前の2つのテーブルが作成されます
これらはActiveStorage::BlobとActiveStorage::Attachmentの2つのモデルで扱われます
- ActiveStorage::Blob:アップロードファイルのメタ情報を管理するためのモデル
- ActiveStorage::Attachment:主となるモデルとActiveStorage::Blobとの中間テーブルに相当するモデル
例えばPostモデルに画像を持たせる場合は次のような関係になります
モデルを作成する
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
が指定されています
この指定によって画像を参照できるようになります
class Post < ApplicationRecord
has_one_attached :image
end
コントローラを作成する
$ rails g controller posts
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も設定します
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する必要があります
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
の値を追加します
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
に次の設定を追加する必要があります
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
を次のように書きます
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
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
};
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
を作成します
<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
を作成します
<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を編集してコンポーネントを表示します
<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
ディレクトリにバイナリ形式で保存されます
ソースコードはGitHubで公開しています
参考になれば嬉しいです
Discussion