Ruby on Rails

【Ruby on Rails】CarrierWaveを利用した画像アップロード機能を実装しサムネイルとかに使う方法まとめ

Ruby on Railsチュートリアル拡張版の第一弾!Railsチュートリアルでは投稿に直接画像を追加するというロジックなのですが、WordPressのように画像は画像ボックスに保存して、保存した画像一覧からサムネイルを選んで記事を保存するのを作りたいと思いまして。今回は作成したメモをRailsチュートリアルみたいにまとめてみました!

CarrierWaveを利用した画像アップロード機能をRailsに実装しサムネイルとかに使う方法まとめ

本記事で実装する画像アップロード機能のゴール

Railsチュートリアルや他の画像アップローダを紹介するサイトでは、元々作成中のモデルの属性に画像カラムを追加していく方法で説明されています。

本記事では、WordPressのような画像投稿機能を実現するために、画像保存専用のテーブルを作成していくことを目標にします。

そして、最終的には画像一覧から選択して別モデル(Articleモデル)にサムネイルを保存するまでを実装します。

今回作成する機能の具体的なユーザフローは以下のようになっています。

  1. ユーザが画像投稿フォームにて画像、タイトル、代替テキストを送信
  2. 画像一覧画面から画像を選択してArticleにサムネイルのURLを保存

今回作成するアップローダの仕様を以下にまとめます。

  • 画像保存専用のモデル Pictureを作成
  • ユーザが画像をCarrierWaveのアップローダで保存
  • 画像をアップロードするフォームを作成
    • 画像のタイトル、代替テキスト、ファイルを保存
  • 画像のバリデーション
  • 画像のリサイズ、サムネイル作成
  • 画像のサムネイルを選択し、Articleモデルに保存

ロジック部分をメインに実装していくので、みためなどのCSSに本記事ではこだわりません。
見た目部分は雑な作りになっているので、適宜修正してください。

また、メモ書きを無理矢理記事にしているため、説明不足な部分が多いです。
随時、加筆修正していくので、質問・コメント等宜しくお願いします。

本記事のアップローダーを実装するために必要な事前準備

Railsチュートリアルを拡張する形で進めていきます。本記事に必要な事前準備を以下に記します。

  • できればRailsチュートリアル第11章まで進める
    • 進めたことが無い方は、完成形をcloneしてしまうのが早いかもしれません
    • 諸事情によりMicropostモデルをArticleモデルとして作成しています
  • もしくはUserモデルとArticleモデルを作成する
    • UserモデルとArticleモデルさえあれば動くので、手っ取り早く試してみたい方はこちらで

本記事で利用するファイルアップロード用のgem CarrierWave

Ruby on Railsでファイルアップロードを実装するには、PaperclipCarrierWaveという2つのgemがよく使われます。
2つのGemの違いは、

  • Paperclipは機能がシンプル
  • CarrierWaveは機能が豊富で応用が効く

です。

「rails 画像 アップロード」で調べたところ、CarrierWaveを紹介している記事が多いです。(2016年8月14日現在)
また、CarrierWaveが「Railsチュートリアル」や「パーフェクトRuby on Rails」で紹介されていることから、今回はCarrierWaveを使うことに決めました。
Railsチュートリアルを参考に拡張していくイメージで実装していきます。

CarrierWaveを利用した画像アップロード機能の実装手順

Railsチュートリアル第11章のマイクロポストを真似てピクチャを作成していきます。
ピクチャモデルを作成した後、CarrierWaveを仕様し画像のアップロード機能を実装します。
最後に、mini-magickを利用してアップロードされた画像を選択してサムネイルを記事に設定する機能を作ります。

  1. Pictureのモデルまわりを実装
    • Pictureのバリデーション・デフォルトスコープ
    • PictureとUserのリレーション定義
  2. Pictureのコントローラまわりを実装
  3. CarrierWaveまわり
    • CarrierWaveのUploaderクラスを生成
    • Viewにフォームを作成
    • バリデーションを設定
    • 画像アップロード部分のテスト
  4. MiniMagickまわり
  5. ピクチャリストからサムネイル画像を選択

1. Pictureのモデルまわりを実装

Railsチュートリアル第11章のmicropostモデルを真似しながら、pictureモデルを実装していく

以下のコマンドでPicutreモデルを生成

$ rails generate model Picture url:string title:string alt:string user:references

Pictureのマイグレーションファイルdb/migrate/[timestamp]_create_pictures.rbにインデックスを付与する記述を追記

class CreatePictures < ActiveRecord::Migration
  def change
    create_table :pictures do |t|
      t.string :title
      t.string :alt
      t.references :user, index: true, foreign_key: true

      t.timestamps null: false
    end
    add_index :pictures, [:user_id, :created_at]
  end
end

マイグレーションを叩く

$ rake db:migrate

PicutreモデルのバリデーションとUserモデルとのアソシエーションを定義

Pictureのモデルにuser_idのバリデーションを追記

class Micropost < ActiveRecord::Base
   belongs_to :user
   validates :user_id, presence: true
end

UserのモデルにPictureを複数所有する(hasmany)関連付け

ユーザが削除された時にPictureも削除されるように依存関係を記述する

class User < ActiveRecord::Base
  has_many :pictures, dependent: :destroy
  .
  .
  .
end

Picture用のfixtureを追記

orange:
  title: "I just ate an orange!"
  alt: "orange"
  created_at: <%= 10.minutes.ago %>

tau_manifesto:
  title: "Check out the @tauday site by @mhartl: http://tauday.com"
  alt: "tau_manifesto"
  created_at: <%= 3.years.ago %>

cat_video:
  title: "Sad cats are sad: http://youtu.be/PKffm2uI4dk"
  alt: "cat_video"
  created_at: <%= 2.hours.ago %>

most_recent:
  title: "Writing a short test"
  alt: "most_recent"
  created_at: <%= Time.zone.now %>

Picutreモデルの有効性テストをtest/models/picture_test.rbに書く

require 'test_helper'

class PictureTest < ActiveSupport::TestCase

  def setup
    @user = users(:michael)
    @picture = @user.pictures.new(title: "title", alt: "alt")
  end

  test "画像が有効か" do
    assert @picture.valid?
  end

  test "画像にuser_idが存在するか" do
    @picture.user_id = nil
    assert_not @picture.valid?
  end

  test "画像が最新順か" do
    assert_equal pictures(:most_recent), Picture.first
  end
end

Userモデルのテストをtest/models/user_test.rbに追記

require 'test_helper'

class UserTest < ActiveSupport::TestCase

  def setup
    @user = User.new(name: "Example User", email: "user@example.com",
                     password: "foobar", password_confirmation: "foobar")
  end
  .
  .
  .
  test "ユーザが削除されると同時に画像も削除されるか" do
    @user.save
    @user.pictures.create!(title: "title", alt: "alt")
    assert_difference 'Picture.count', -1 do
      @user.destroy
    end
  end
end

2. Pictureのコントローラまわりを実装

users/:user_id/picutresにアクセスすると、画像の一覧を表示
さらに、ピクチャのCRUDを記述していく

ホーム画面にピクチャ一覧をページネーション表示

Pictureのコントローラをコマンドで生成する

$ rails generate controller Pictures

Userコントローラーにpicturesを記述

...
  def pictures
    @user = User.find(params[:id])
    @pictures = @user.pictures.paginate(page: params[:page])
  end
...

1つのPictureを表示するパーシャル

<li id="picture--<%= picture.id %>">
  <div class="picture">
  </div>
  <div class="picture__title"><%= picture.title %></div>
  <div class="picture__alt"><%= picture.alt %></div>
</li>

user_picturesにアクセスするためのルーティング

Rails.application.routes.draw do
  ...
  resources :users do
    resources :pictures, only: [:index]
  end
  resources :users
  resources :pictures
  ...
end

サンプルデータdb/seeds.rbにPictureを追加

...
users = User.order(:created_at).take(6)
50.times do
  title = Faker::Name.title
  content = Faker::Lorem.sentence(5)
  alt = Faker::Name.title
  users.each { |user| 
    user.articles.create!(title: title, content: content)
    user.pictures.create!(title: title, alt: alt)
  }
end

シードデータをリセット

$ bundle exec rake db:migrate:reset
$ bundle exec rake db:seed

ユーザのピクチャ一覧画面におけるテスト

$ rails generate integration_test user_pictures

ユーザと関連付けされたピクチャのfixture
test/fixtures/pictures.yml

orange:
  title: "I just ate an orange!"
  alt: "orange"
  created_at: <%= 10.minutes.ago %>
  user: michael

tau_manifesto:
  title: "Check out the @tauday site by @mhartl: http://tauday.com"
  alt: "tau_manifesto"
  created_at: <%= 3.years.ago %>
  user: michael

cat_video:
  title: "Sad cats are sad: http://youtu.be/PKffm2uI4dk"
  alt: "cat_video"
  created_at: <%= 2.hours.ago %>
  user: michael

most_recent:
  title: "Writing a short test"
  alt: "most_recent"
  created_at: <%= Time.zone.now %>
  user: michael

<% 30.times do |n| %>
picture_<%= n %>:
  title: <%= Faker::Lorem.sentence(5) %>
  alt: <%= Faker::Name.title %>
  created_at: <%= 42.days.ago %>
  user: michael
<% end %>

ユーザのピクチャ一覧画面におけるテスト
test/integration/user_pictures_test.rb

require 'test_helper'

class UserPicturesTest < ActionDispatch::IntegrationTest
  include ApplicationHelper

  def setup
    @user = users(:michael)
  end

  test "ユーザのピクチャ一覧画面を表示" do
    get user_pictures_path(@user)
    assert_template 'pictures/index'
    assert_select 'title', full_title(@user.name)
    assert_select 'h1', text: @user.name
    assert_select 'h1>img.gravatar'
    assert_match @user.pictures.count.to_s, response.body
    assert_select 'div.pagination'
    @user.pictures.paginate(page: 1).each do |picture|
      assert_match picture.title, response.body
      assert_match picture.alt, response.body
    end
  end
end

ピクチャのルーティング設定・アクセス制御

logged_in_userメソッドをApplicationコントローラに移す
app/controllers/application_controller.rb

class ApplicationController < ActionController::Base
  protect_from_forgery with: :exception
  include SessionsHelper

  private

    # ユーザーのログインを確認する
    def logged_in_user
      unless logged_in?
        store_location
        flash[:danger] = "Please log in."
        redirect_to login_url
      end
    end
end

Usersコントローラからlogged_in_userメソッドは消しておく

Pictureコントローラに各アクションを追記

class PicturesController < ApplicationController
  before_action :logged_in_user, only: [:show, :new, :create, :edit, :update, :destroy]

  def index
    @user = User.find(params[:user_id])
    @pictures = @user.pictures.paginate(page: params[:page])
  end

  def show
  end

  def new
  end

  def create
  end

  def edit
  end

  def update
  end

  def destroy
  end

end

各アクションのルーティングをテスト

require 'test_helper'

class PicturesControllerTest < ActionController::TestCase

  def setup
    @picture = pictures(:orange)
  end

  test "ログインしていない時にshowにアクセスしたらリダイレクトされるか" do
    get :show, id: @picture
    assert_not flash.empty?
    assert_redirected_to login_url
  end

  test "ログインしていない時にnewにアクセスしたらリダイレクトされるか" do
    get :new
    assert_not flash.empty?
    assert_redirected_to login_url
  end

  test "ログインしていない時にcreateにアクセスしたらリダイレクトされるか" do
    assert_no_difference 'Picture.count' do
      post :create, picture: { url: "http://localhost:3000/", title: "title", alt: "alt" }
    end
    assert_redirected_to login_url
  end

  test "ログインしていない時にeditにアクセスしたらリダイレクトされるか" do
    get :edit, id: @picture
    assert_not flash.empty?
    assert_redirected_to login_url
  end

  test "ログインしていない時にupdateにアクセスしたらリダイレクトされるか" do
    patch :update, id: @picture, picture: { url: "http://localhost:3000/", title: "title", alt: "alt" }
    assert_not flash.empty?
    assert_redirected_to login_url
  end

  test "ログインしていない時にdestroyにアクセスしたらリダイレクトされるか" do
    assert_no_difference 'Picture.count' do
      delete :destroy, id: @picture
    end
    assert_redirected_to login_url
  end

end

ピクチャの作成 new/create

Picturesコントローラのnewcreateアクションを記述

class PicturesController < ApplicationController
  before_action :logged_in_user, only: [:show, :new, :create, :edit, :update, :destroy]

  ...

  def new
    @picture = current_user.pictures.build if logged_in?
  end

  def create
    @picture = current_user.pictures.build(picture_params)
    if @picture.save
      flash[:success] = "Picture created!"
      redirect_to user_pictures_path(current_user)
    else
      render 'new'
    end
  end

  ...

  private

    def picture_params
      params.require(:picture).permit(:title, :alt)
    end
end

Pictureのnewにピクチャ投稿フォームを作成
app/views/pictures/new.html.erb

<div class="row">
  <div class="col-md-8">
    <section class="picture_form">
      <%= render 'shared/picture_form' %>
    </section>
  </div>
  <div class="col-md-4">
    <section class="user_info">
      <%= render 'shared/user_info' %>
    </section>
  </div>
</div>

app/views/shared/_picture_form.html.erb

<%= form_for(@picture) do |f| %>
  <%= render 'shared/error_messages', object: f.object %>
  <div class="field">
    <%= f.text_field :title, placeholder: "title..." %>
  </div>
  <div class="field">
    <%= f.text_field :alt, placeholder: "alt..." %>
  </div>
  <%= f.submit "Post", class: "btn btn-primary" %>
<% end %>

ピクチャの削除 destroy

ピクチャのパーシャルに削除リンクを追加
app/views/pictures/_picture.html.erb

<li id="picture--<%= picture.id %>">
  <div class="picture">
    <%= image_tag(picture.url, alt: picture.alt) %>
  </div>
  <div class="picture__title"><%= picture.title %></div>
  <div class="picture__alt"><%= picture.alt %></div>
  <div class="picture__info">
    <% if current_user?(picture.user) %>
      <%= link_to "delete", picture, method: :delete, data: { confirm: "You sure?" } %>
    <% end %>
  </div>
</li>

Picturesコントローラのdestroyアクション

class PicturesController < ApplicationController
  before_action :logged_in_user, only: [:show, :new, :create, :edit, :update, :destroy]
  before_action :correct_user, only: :destroy

  ...

  def destroy
    @picture.destroy
    flash[:success] = "Picture deleted"
    redirect_to request.referrer || user_pictures_path(current_user)
  end

  private

    ...

    def correct_user
      @picture = current_user.pictures.find_by(id: params[:id])
      redirect_to user_pictures_path(current_user) if @picture.nil?
    end
end

fixturesに別のユーザに所属しているピクチャを追加

...

ants:
  title: "Oh, is that what you want? Because that's how you get ants!"
  alt: "ants"
  created_at: <%= 2.years.ago %>
  user: archer

zone:
  title: "Danger zone!"
  alt: "zone"
  created_at: <%= 3.days.ago %>
  user: archer

tone:
  title: "I'm sorry. Your words made sense, but your sarcastic tone did not."
  alt: "tone"
  created_at: <%= 10.minutes.ago %>
  user: lana

van:
  title: "Dude, this van's, like, rolling probable cause."
  alt: "van"
  created_at: <%= 4.hours.ago %>
  user: lana

間違ったユーザによるピクチャの削除をテスト

require 'test_helper'

class PicturesControllerTest < ActionController::TestCase

  ...

  test "間違ったユーザによるピクチャの削除は失敗しリダイレクトされるか" do
    @user = users(:michael)
    log_in_as(@user)
    picture = pictures(:ants)
    assert_no_difference 'Picture.count' do
      delete :destroy, id: picture
    end
    assert_redirected_to user_pictures_path(@user)
  end
end

ピクチャの更新 edit/update

ピクチャのパーシャルに編集リンクを追加

<li id="picture--<%= picture.id %>">
  <div class="picture">
  </div>
  <div class="picture__title"><%= picture.title %></div>
  <div class="picture__alt"><%= picture.alt %></div>
  <div class="picture__info">
    <% if current_user?(picture.user) %>
      <%= link_to "edit", picture, edit_picture_path(picture) %>
      <%= link_to "delete", picture, method: :delete, data: { confirm: "You sure?" } %>
    <% end %>
  </div>
</li>

Picturesコントローラのeditupdateアクションを追記

class PicturesController < ApplicationController

  ...

  def edit
    @picture = Picture.find(params[:id])
  end

  def update
    @picture = Picture.find(params[:id])
    if @picture.update_attributes(picture_params)
      flash[:success] = "Picture updated"
      redirect_to user_pictures_path(current_user)
    else
      render 'edit'
    end
  end

  ...

end

Picturesコントローラのテスト

require 'test_helper'

class PicturesControllerTest < ActionController::TestCase

  ...

  test "間違ったユーザによるピクチャの編集editは失敗しリダイレクトされるか" do
    @user = users(:michael)
    log_in_as(@user)
    # 他人のピクチャ
    picture = pictures(:ants)
    get :edit, id: picture
    assert_redirected_to user_pictures_path(@user)
  end

  test "間違ったユーザによるピクチャの更新updateは失敗しリダイレクトされるか" do
    @user = users(:michael)
    log_in_as(@user)
    # 他人のピクチャ
    picture = pictures(:ants)
    patch :update, id: picture, picture: { title: "title", alt: "alt" }
    assert_redirected_to user_pictures_path(@user)
  end

  ...

end

ピクチャまわりの統合テスト

ピクチャまわりの統合テストを生成

$ rails generate integration_test pictures_interface

ピクチャのUIに対する統合テスト

require 'test_helper'

class PicturesInterfaceTest < ActionDispatch::IntegrationTest

  def setup
    @user = users(:michael)
  end

  test "ピクチャのインターフェース" do
    log_in_as(@user)
    get new_picture_path
    # 無効な送信は今のところなし(ゆくゆく画像のURLがないとNGにする)
    # 有効な送信
    title = "This article really ties the room together"
    alt = "This article content is very nice"
    assert_difference 'Picture.count', 1 do
      post pictures_path, picture: { title: title, alt: alt }
    end
    assert_redirected_to user_pictures_path(@user)
    follow_redirect!
    assert_match title, response.body
    # ピクチャを編集する
    assert_select 'a', text: 'edit'
    first_picture = @user.pictures.paginate(page: 1).first
    get edit_picture_path(first_picture)
    assert_template 'pictures/edit'
    # 無効な更新は今のところなし(ゆくゆく画像のURLがないとNGにする)
    # 有効な更新
    new_title = 'new title'
    new_alt = 'new alt'
    patch picture_path(first_picture), picture: { title: new_title,
                                                  alt: new_alt }
    assert_not flash.empty?
    assert_redirected_to user_pictures_path(@user)
    first_picture.reload
    assert_equal new_title, first_picture.title
    assert_equal new_alt, first_picture.alt
    # 投稿の削除
    follow_redirect!
    assert_select 'a', text: 'delete'
    assert_difference 'Picture.count', -1 do
      delete picture_path(first_picture)
    end

  end
end

3. CarrierWaveまわり

画像アップロード用のGemであるCarrierWaveを使って、画像アップロード機能を実装していく
Railsチュートリアル第11章まわりとほぼ同じである

gemにCarrierWaveを追加する

Gemfileにcarrierwaveを追加して、bundle installコマンドを叩いてgemをインストール
Railsチュートリアルだと、0.10.0を指定してインストールするが、0.10.0にはバグがある
今回は、0.11.0をインストール

source 'https://rubygems.org'

gem 'rails',                      '4.2.2'
gem 'bcrypt',                     '3.1.7'
gem 'faker',                      '1.4.2'
gem 'carrierwave',                '0.11.0'
...

CarrierWaveのUploaderクラスを生成する

CarrierWaveのジェネレーターでPictureアップローダーを作成する

$ rails generate uploader Picture

Pictureモデルにfileカラムを追加するためマイグレーション生成

$ rails generate migration add_file_to_pictures file:string
$ bundle exec rake db:migrate

Pictureモデルにfileカラムを追加し、Pictureアップローダを設定

class Picture < ActiveRecord::Base
  belongs_to :user
  default_scope -> { order(created_at: :desc) }
  mount_uploader :file, PictureUploader
  validates :user_id, presence: true
end

ここで一旦Rail serverを再起動

Viewに画像アップロードフォームを追記

ピクチャフォームに画像アップローダーを追加
app/views/shared/_picture_form.html.erb

<%= form_for(@picture, html: { multipart: true }) do |f| %>
  <%= render 'shared/error_messages', object: f.object %>
  <span class="picture">
    <%= f.file_field :file %>
  </span>
  <div class="field">
    <%= f.text_field :title, placeholder: "title..." %>
  </div>
  <div class="field">
    <%= f.text_field :alt, placeholder: "alt..." %>
  </div>
  <%= f.submit "Post", class: "btn btn-primary" %>
<% end %>

fileを許可するparamsのリストに追加

class PicturesController < ApplicationController

  ...

  private

    def picture_params
      params.require(:picture).permit(:title, :alt, :file)
    end

    def correct_user
      @picture = current_user.pictures.find_by(id: params[:id])
      redirect_to user_pictures_path(current_user) if @picture.nil?
    end
end

ピクチャのパーシャルに画像を追加
app/views/pictures/_picture.html.erb

<li id="picture--<%= picture.id %>">
  <div class="picture__file">
    <%= image_tag picture.file.url if picture.file? %>:
  </div>

  ...

</li>

アップローダのバリデーションを設定する

Carrierwaveはデフォルトでは、アップロードされた画像に対する制限がないなどいくつかの欠点がある。
欠点を直すためにバリデーションを設定していく必要がある。

画像フォーマットのバリデーション

app/uploaders/picture_uploader.rbに追記

class PictureUploader < CarrierWave::Uploader::Base

  # Include RMagick or MiniMagick support:
  # include CarrierWave::RMagick
  # include CarrierWave::MiniMagick

  # Choose what kind of storage to use for this uploader:
  storage :file
  # storage :fog

  # Override the directory where uploaded files will be stored.
  # This is a sensible default for uploaders that are meant to be mounted:
  def store_dir
    "uploads/#{model.class.to_s.underscore}/#{mounted_as}/#{model.id}"
  end


  # アップロード可能な拡張子のリスト
  def extension_white_list
    %w(jpg jpeg gif png)
  end

  ...

end
画像サイズに対するバリデーション

ファイルサイズに対するバリデーションはデフォルトのRailsにはない
独自のバリデーションをPictureモデルに定義する

class Picture < ActiveRecord::Base
  belongs_to :user
  default_scope -> { order(created_at: :desc) }
  mount_uploader :file, PictureUploader
  validates :user_id, presence: true
  validate :file_size

  private

    # アップロード画像のサイズを検証する
    def file_size
      if file.size > 5.megabytes
        errors.add(:picture, "should be less than 5MB")
      end
    end
end
フロント側でのバリデーション制御

大きすぎるファイルのバリデーションは、バックエンドだけでなくフロントエンドでも行う
そうすることで、長過ぎるアップロード時間を防いだり、サーバーの負荷を軽減できる

ファイルサイズとファイルの拡張子をjQueryでチェックする

<%= form_for(@picture, html: { multipart: true }) do |f| %>
  <%= render 'shared/error_messages', object: f.object %>
  <div class="picture">
    <%= f.file_field :file, accept: 'image/jpeg,image/git,image/png' %>
  </div>
  <div class="field">
    <%= f.text_field :title, placeholder: "title..." %>
  </div>
  <div class="field">
    <%= f.text_field :alt, placeholder: "alt..." %>
  </div>
  <%= f.submit "Post", class: "btn btn-primary" %>
<% end %>

<script type="text/javascript">
$('#picture_file').bind('change', function() {
  var size_in_megabytes = this.files[0].size/1024/1024;
  if (size_in_megabytes > 1) {
    alert('Maximum file size is 5MB. Please choose a smaller file.');
  }
});
</script>
.gitignoreファイルに画像用ディレクトリを追記
...

# Ignore uploaded test images.
/public/uploads

画像アップロード部分のテスト

今回のテストの要件を以下にまとめます。

  • 画像が添付されていない場合は、ピクチャを保存しない
  • タイトルや代替テキストは空でもOK
ピクチャモデルのテスト

まず、サンプル画像をfixtureディレクトリに追加する

cp app/assets/images/rails.png test/fixtures/

画像アップロードがなかった場合バリデーションエラーとするロジックを追記してテスト
ここのテストが通らず多くの時間を費やしてしまいました。
何が起きていたかといいますと、
ピクチャモデルのテストに

  test "ピクチャにfileが存在するか" do
    @picture.file = nil
    assert_not @picture.valid?
  end

と書いていたのですが、これだとなぜか@picture.filenilで上書きできていませんでした。
そこで調べてみると、以下の本家ドキュメントで解決策を見つけることができました。

If you want to remove the file manually, you can call remove_avatar!

とのこと

carrierwaveuploader/carrierwave: Classier solution for file uploads for Rails, Sinatra and other Ruby web frameworks

これを参考に、

  test "ピクチャにfileが存在するか" do
    @picture.remove_file!
    assert_not @picture.valid?
  end

としたら、テストが通りました。

もう一つなかなか解決できなかったのが、fixture_file_upload関数がモデルのテストだと動かないことです。
Railsチュートリアル第11章の画像アップローダのテストでfixture_file_uploadメソッドが使われています。同じやり方でモデルでもテストできると思ったのですが、以下のエラーが発生。

NoMethodError: undefined method `fixture_file_upload' for #<PictureTest:0x007fc2f135f960>

調べてみると、統合テストはActionDipatch::TestProcessを継承しているから、fixture_file_uploadメソッドが使えることが判明しました。したがって、モデルでfixture_file_uploadメソッドを使うためには、以下のコードをfixture_file_uploadメソッドを呼ぶ前に追記する必要があります。

extend ActionDispatch::TestProcess

以上の2点を踏まえて、追記したピクチャモデルのテストコードは以下になります。

require 'test_helper'

class PictureTest < ActiveSupport::TestCase

  def setup
    @user = users(:michael)
    extend ActionDispatch::TestProcess
    file = fixture_file_upload('rails.png', 'image/png')
    @picture = @user.pictures.new(title: "title", alt: "alt", file: file)
  end

  ...

  test "ピクチャにuser_idが存在するか" do
    @picture.user_id = nil
    assert_not @picture.valid?
  end

  test "ピクチャにfileが存在するか" do
    @picture.remove_file!
    assert_not @picture.valid?
  end

  ...

end

また、ピクチャモデルのバリデーションを変更したので、今までOKだったユーザモデルのバリデーションが通らなくなったりします。その場合、適宜テストが通るように修正してください。

ピクチャの統合テストに画像アップロード部分を追記する

追記したピクチャの統合テストは以下になります。

require 'test_helper'

class PicturesInterfaceTest < ActionDispatch::IntegrationTest

  def setup
    @user = users(:michael)
  end

  test "ピクチャのインターフェース" do
    log_in_as(@user)
    get new_picture_path
    assert_select 'input[type=file]'
    # 無効な送信
    title = "This article really ties the room together"
    alt = "This article content is very nice"
    assert_no_difference 'Picture.count' do
      post pictures_path, picture: { title: title, alt: alt }
    end
    assert_select 'div#error_explanation'
    # 有効な送信
    title = "This article really ties the room together"
    alt = "This article content is very nice"
    file = fixture_file_upload('test/fixtures/rails.png', 'image/png')
    assert_difference 'Picture.count', 1 do
      post pictures_path, picture: { file: file, title: title, alt: alt }
    end
    assert_redirected_to user_pictures_path(@user)
    picture = assigns(:picture)
    assert picture.file?
    follow_redirect!
    assert_match title, response.body
    # ピクチャを編集する
    assert_select 'a', text: 'edit'
    first_picture = @user.pictures.paginate(page: 1).first
    get edit_picture_path(first_picture)
    assert_template 'pictures/edit'
    # 無効な更新は今のところなし(ゆくゆく画像のURLがないとNGにする)
    # 有効な更新
    new_title = 'new title'
    new_alt = 'new alt'
    patch picture_path(first_picture), picture: { title: new_title,
                                                  alt: new_alt }
    assert_not flash.empty?
    assert_redirected_to user_pictures_path(@user)
    first_picture.reload
    assert_equal new_title, first_picture.title
    assert_equal new_alt, first_picture.alt
    # ピクチャの削除
    follow_redirect!
    assert_select 'a', text: 'delete'
    assert_difference 'Picture.count', -1 do
      delete picture_path(first_picture)
    end

  end
end

4. MiniMagickまわり

CarrierWaveでは画像をアップロードの部分までしか実装できません。
MiniMagickというGemを使って、画像をアップロードした際に自動的にサムネイル画像を生成する機能を実装していきます

画像のリサイズ

前準備として、ImageMagickという画像を操作するためのパッケージが必要

MacでImageMagickをインストール

Homebrewを使うのが簡単

brew install imagemagick
UbuntuなどのDebian系OSでImageMagickをインストール
$ sudo apt-get update
$ sudo apt-get install imagemagick --fix-missing
MiniMagickのインストール

MiniMagickというGemを使って、CarrierWaveからImageMagickを使えるようにする

Gemfileにmini_magickを追加して、bundle installコマンドを叩いてgemをインストール

source 'https://rubygems.org'

gem 'rails',                      '4.2.2'
gem 'bcrypt',                     '3.1.7'
gem 'faker',                      '1.4.2'
gem 'carrierwave',                '0.11.0'
gem 'mini_magick',             '3.8.0'
...

CarrierWaveのアップローダにリサイズを設定

アップロードすると最大1280×720にリサイズされる
リサイズに加え、100×100のサムネイルも生成する

# encoding: utf-8

class PictureUploader < CarrierWave::Uploader::Base

  # Include RMagick or MiniMagick support:
  # include CarrierWave::RMagick
  include CarrierWave::MiniMagick

  # 画像のサイズを変更
  process :resize_to_limit => [1280, 720]

  # サムネイルの生成
  version :thumb do
    process :reseize_to_fill => [100, 100]
  end

  ...

end
ピクチャのパーシャルをサムネイルに変更

app/views/picutres/_picture.html.erbを変更

<li id="picture--<%= picture.id %>">
  <div class="picture__file">
    <%= image_tag picture.file.thumb.url if picture.file? %>
  </div>
  <div class="picture__title"><%= picture.title %></div>
  <div class="picture__alt"><%= picture.alt %></div>
  <div class="picture__info">
    <% if current_user?(picture.user) %>
      <%= link_to "edit", edit_picture_path(picture) %>
      <%= link_to "delete", picture, method: :delete, data: { confirm: "You sure?" } %>
    <% end %>
  </div>
</li>

TODO MiniMagickまわりのテスト

MiniMagickまわりのテストを書いていません。
今後、追加していきたいと思います。

5. ピクチャリストからサムネイル画像を選択

サムネイル画像を選択ボタンを押すと、画像リストが出現
will_paginateを使い、動的に「もっと見る」を実装
サムネイル画像を選択すると記事のpicture_idにセットされるように

記事の作成と編集フォームにサムネイル選択ボタンを追加

記事の作成フォームapp/view/articles/new.html.erb

記事の編集フォームapp/view/articles/edit.html.erb
のサイドバーに以下を追記。

...
    <section class="user_info">
      <%= render 'shared/user_info' %>
    </section>
    <section class="user_pictures">
      <%= render 'shared/user_pictures' %>
    </section>
...

app/views/shared/_user_pictures.html.erbを作成してサムネイル選択ボタンを追記

<%= link_to "Select Thumbnail Picture", user_pictures_path(current_user), page: 1,
    remote: true, id: 'userPictures__moreLink', onclick: "nowLoading();" %>

<script type="text/javascript">
function nowLoading() {
  $("#userPictures__moreLink").replaceWith('<a href="#" id="userPictures__moreLink--nowLoading">Now Loading...</a>');
};
</script>

サムネイル一覧を取得するためのAjax通信部分

Pictureコントローラのindexに以下を追加

class PicturesController < ApplicationController
  before_action :logged_in_user, only: [:show, :new, :create, :edit, :update, :destroy]
  before_action :correct_user, only: [:edit, :update, :destroy]

  def index
    @user = User.find(params[:user_id])
    @pictures = @user.pictures.paginate(page: params[:page])
  end
...

Ajaxでpictures#indexにアクセスしたときに返すHTMLをapp/views/picutures/index.js.erbに書く

$(".user_pictures").append("<%= escape_javascript(render('shared/user_pictures')) %>");
$("#userPictures__moreLink--nowLoading").remove();

サムネイル一覧を表示する部分をapp/views/shared/_user_pictures.html.erbに追記

<% if @pictures %>
  <% @pictures.each do |picture| %>
    <div class="pictureFileThumb">
      <%= image_tag picture.file.thumb.url, data: { id: picture.id } if picture.file? %>
    </div>
  <% end %>
  <% if @pictures.next_page %>
    <%= render 'shared/user_pictures_more' %>
  <% end %>
<% else %>
  <%= link_to "Select Thumbnail Picture", user_pictures_path(current_user), page: 1,
    remote: true, id: 'userPictures__moreLink', onclick: "nowLoading();" %>
<% end %>
...

サムネイルをさらに取得するためのボタンをapp/views/shared/_user_pitcures_more.html.erbに追記

<%= link_to "More...", user_pictures_path(current_user, page: @pictures.next_page), 
  remote: true, id: 'userPictures__moreLink', onclick: "nowLoading();" %>

記事にサムネイルカラムを追加

$ rails generate migration add_thumb_to_articles thumb:text
$ rake db:migrate

サムネイルとクリックすると記事フォームのthumbにpicture.file.thumb.urlがセットされるように

app/views/shared/_article_form.html.erbのフォームにthumbを追加する。

<%= form_for(@article) do |f| %>
  <%= render 'shared/error_messages', object: f.object %>
  <div class="field">
    <%= f.text_field :title, placeholder: "title..." %>
  </div>
  <div class="field">
    <%= f.text_area :content, placeholder: "Compose new article..." %>
  </div>
  <div class="field">
    <%= f.text_field :thumb, placeholder: "url for Thumbnail..." %>
  </div>
  <%= f.submit "Post", class: "btn btn-primary" %>
<% end %>

app/views/article/new.html.erbapp/views/article/edit.html.erbに以下のJavaScriptを追記

...
    <section class="user_pictures">
      <%= render 'shared/user_pictures' %>
    </section>
    <script type="text/javascript">
      function nowLoading() {
        $("#userPictures__moreLink").replaceWith('<a href="#" id="userPictures__moreLink--nowLoading">Now Loading...</a>');
      };
      $(document).on('click', '.pictureFileThumb img', function() {
        $('.pictureFileThumb--selected').each(function() {
          $(this).removeClass('pictureFileThumb--selected');
        });
        $(this).addClass('pictureFileThumb--selected');
        console.log($(this).attr('src'));
        var thumb = $(this).attr("src");
        $('#article_thumb').val(thumb);
      });
    </script>
...

app/controller/articles_controller.rbarticle_paramsthumbを追加

...
  private

    def article_params
      params.require(:article).permit(:title, :content, :thumb)
    end
...

Viewのもろもろを変更

ユーザの記事一覧画面にサムネイルを表示させ、タイトルをクリックすると記事ページヘ

<li id="article--<%= article.id %>">
  <div class="article__thumb">
    <%= image_tag article.thumb %> 
  </div>
  <div class="article__title">
    <%= link_to article.title, article_path(article) %>
  </div>
... 

記事のshow画面でタイトルとコンテンツを表示

<% provide(:title, 'Article show') %>
<h1>Article show</h1>

<h2><%= @article.title %></h2>

<div>
  <%= @article.content %>
</div>

記事周りのテストを追加

記事モデルのバリデーションを追加しテスト
サムネイルを必須項目にする

テストから書く

require 'test_helper'

class ArticleTest < ActiveSupport::TestCase

  def setup
    @user = users(:michael)
    @article = @user.articles.build(title: "title", content: "content", thumb: "thumb")
  end

...

  test "記事にthumbが存在するか" do
    @article.thumb = " "
    assert_not @article.valid?
  end

...
class Article < ActiveRecord::Base
  belongs_to :user
  default_scope -> { order(created_at: :desc) }
  validates :user_id, presence: true
  validates :title, presence: true
  validates :content, presence: true
  validates :thumb, presence: true
end

thumbを必須項目にすると今までのテストでエラーが出るのでそれは適当に修正していく

本記事のまとめ

本記事では、WordPressのような画像投稿機能を実現する方法をRailsチュートリアルのようにまとめてみました。

今回作成した具体的な機能は以下のようになっています。

  • 画像保存専用のモデル Pictureを作成
  • ユーザが画像をCarrierWaveのアップローダで保存
  • 画像をアップロードするフォームを作成
    • 画像のタイトル、代替テキスト、ファイルを保存
  • 画像のバリデーション
  • 画像のリサイズ、サムネイル作成
  • 画像のサムネイルを選択し、ArticleモデルにURLを保存

ロジック部分をメインに実装していくので、みためなどのCSSをほとんど書きませんでした。
見た目部分は雑な作りになっているので、適宜修正してください。

また、最初にも書きましたがメモ書きを無理矢理記事にしているため、説明不足な部分が多いです。
随時、加筆修正していくので、質問・コメント等宜しくお願いします。

本記事の参考サイト

基本的にRailsチュートリアルの第11章を参考にしました。
第11章ユーザーのマイクロポスト | Rails チュートリアル

CarrierWave関連の参考

RailsのファイルをアップロードするgemのCarrierWaveのインストール方法 - Rails Webook
CarrierWaveを利用した画像ファイルのアップロード - blog.beaglesoft.net

mini_magick関連の参考

Carrierwaveで画像をリサイズする - 49hack
CarrierWave + RMagick 画像のリサイズをまとめてみました - 麺処 まつば
RMagick で正方形のサムネイルを作成する - Qiita

will_paginate関連の参考

やればできる子!will_paginateで動的「もっと見る」を実装 - 思い付くまでタイトル未定

-Ruby on Rails
-