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

バックエンドはRails、フロントエンドはVueといった構成のときにActive Storageを使って画像をアップロード・表示する方法を、プロジェクトを1から作りながらまとめます
ソースコードは[GitHub](https://github.com/youichiro/rails-vue-file-uploader-sample)で公開しています

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

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


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

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

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

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

```sh
$ 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を使えるようにする

```sh
$ 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モデルに画像を持たせる場合は次のような関係になります
![スクリーンショット 2020-11-15 16.54.33.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/145075/56fe8f28-dce0-2171-1c09-2781f1225de9.png)

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

```sh
$ 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`が指定されています
この指定によって画像を参照できるようになります

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

## コントローラを作成する

```sh
$ rails g controller posts
```

```ruby: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も設定します

```ruby: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する必要があります

```ruby: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`の値を追加します

```ruby: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`に次の設定を追加する必要があります

```ruby: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`を次のように書きます

```ruby: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プロジェクトを作成します

```sh
$ 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を使用するのでインストールしておきます

```sh
$ npm install --save axios
```

```js: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
};
```


```js: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`を作成します

```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`を作成します

```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を編集してコンポーネントを表示します

```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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/145075/05cdae6c-fbb3-9e7b-3b73-a9326a94cddf.png)


ソースコードはGitHubで公開しています
参考になれば嬉しいです
https://github.com/youichiro/rails-vue-file-uploader-sample


## 参考
- [Active Storage の概要](https://railsguides.jp/active_storage_overview.html)
- [VueとRailsで画像投稿](https://web-tips.2rta.com/vue-rails-activestrage/)
- [Active StorageをAPIで使用する](http://sato-s.github.io/2018/08/25/active-storage-in-API.html)