ユーザーのマイクロポスト
サンプルアプリケーションのコア部分を開発するために、これまでにユーザー、セッション、アカウント有効化、パスワードリセットという4つのリソースについて見てきた。
そして、これらのうち「ユーザー」というリソースだけが、Active Recordによってデータベース上のテーブルと紐付いている。
全ての準備が整った今、ユーザーが短いメッセージを投稿できるようにするためのリソース「マイクロポスト」を追加していく。
Micropostモデル
まずはMicropostリソースの最も本質的な部分を表現するMicropostモデルを作成する。
基本的なモデル
Micropostモデルは、マイクロポストの内容を保存するcontent属性と、特定のユーザーとマイクロポストを関連付けるuser_id属性の2つの属性だけを持つ。
rails generate model Micropost content:text user:references
上のコマンドを実行すると、ApplicationRecordを継承したMicropostモデルが生成される。ただし、今回は生成されたモデルの中に、ユーザーと1対1の関係であることを表すbelongs_toのコードも追加されている。
これは先ほどのコマンドを実行したときにuser:referencesという引数も含めていたから。
class Micropost < ApplicationRecord
belongs_to :user
end
Userモデルとの最大の違いはreferences型を利用している点。
これを利用すると、自動的にインデックスと外部キー参照付きのuser_idカラムが追加され、UserとMicropostを関連付けする下準備をしてくれる。
class CreateMicroposts < ActiveRecord::Migration[5.0]
def change
create_table :microposts do |t|
t.text :content
t.references :user, foreign_key: true
t.timestamps
end
add_index :microposts, [:user_id, :created_at]
end
end
ここでは、user_idとcreated_atカラムにインデックスが付与している。
こうすることで、user_idに関連付けられたすべてのマイクロポストを作成時刻の逆順で取り出しやすくなる。
add_index :microposts, [:user_id, :created_at]
また、user_idとcreated_atの両方を1つの配列に含めている。
こうすることでActive Recordは、両方のキーを同時に扱う複合キーインデックス(Multiple Key Index)を作成する。
rails db:migrate
Micropostのバリデーション
基本的なモデルを作成したので、次に要求される制限を実現するためのバリデーションを追加する。
Micropostモデルを作成したときに、マイクロポストは投稿したユーザーのid(user_id)を持たせるようにした。
これを使って、慣習的に正しくActive Recordの関連付けを実装していくが、まずはMicropostモデル単体を(テスト駆動開発で)動くようにしてみる。
Micropostの初期テストはUserモデルの初期テストと似ている。
まずはsetupのステップで、fixtureのサンプルユーザーと紐付けた新しいマイクロポストを作成している。
次に、作成したマイクロポストが有効かどうかをチェックしてる。
最後に、あらゆるマイクロポストはユーザーのidを持っているべきなので、user_idの存在性のバリデーションに対するテストも追加する。
require 'test_helper'
class MicropostTest < ActiveSupport::TestCase
def setup
@user = users(:michael)
# このコードは慣習的に正しくない
@micropost = Micropost.new(content: "Lorem ipsum", user_id: @user.id)
end
test "should be valid" do
assert @micropost.valid?
end
test "user id should be present" do
@micropost.user_id = nil
assert_not @micropost.valid?
end
end
setupメソッドの中でコメントしているとおり、マイクロポストを作成するコードは動く、慣習的には正しくありません(後で修正する)。
元々あるUserモデルのテストと同じで、1つ目のテストでは、正常な状態かどうかをテスト(sanity check)している。
2つ目のテストでは、user_idが存在しているかどうか(nilではないか)をテストしている。
このテストをパスさせるために、存在性のバリデーションを追加してみる。
class Micropost < ActiveRecord::Base
belongs_to :user
validates :user_id, presence: true
end
ちなみにRails 5では、上のバリデーションを追加しなくてもテストが成功してしまう。
しかしこれは、「慣習的な意味で正しくない」というコードを書いた場合でのみ発生する。
次に、マイクロポストのcontent属性に対するバリデーションを追加する。
user_id属性と同様に、content属性も存在する必要があり、さらにマイクロポストが140文字より長くならないよう制限を加える(これがマイクロポストをマイクロ (micro) と名付けた理由)。
Userモデルにバリデーションを追加したときと同様に、テスト駆動開発でMicropostモデルのバリデーションを追加していく。
require 'test_helper'
class MicropostTest < ActiveSupport::TestCase
def setup
@user = users(:michael)
@micropost = Micropost.new(content: "Lorem ipsum", user_id: @user.id)
end
test "should be valid" do
assert @micropost.valid?
end
test "user id should be present" do
@micropost.user_id = nil
assert_not @micropost.valid?
end
test "content should be present" do
@micropost.content = " "
assert_not @micropost.valid?
end
test "content should be at most 140 characters" do
@micropost.content = "a" * 141
assert_not @micropost.valid?
end
end
class Micropost < ApplicationRecord
belongs_to :user
validates :user_id, presence: true
validates :content, presence: true, length: { maximum: 140 }
end
User/Micropostの関連付け
それぞれのマイクロポストは1人のユーザーと関連付けられ、それぞれのユーザーは(潜在的に)複数のマイクロポストと関連付けられる。
MicropostとそのUserは belongs_to (1対1) の関係性がある。
UserとそのMicropostは has_many (1対多) の関係性がある。
# このようなメソッドではなく、
Micropost.create
Micropost.create!
Micropost.new
# このようなメソッドが正しい
user.microposts.create
user.microposts.create!
user.microposts.build
この後で定義するbelongs_to/has_many関連付けを使うことで、表に示すようなメソッドをRailsで使えるようになる。
これらのメソッドは使うと、紐付いているユーザーを通してマイクロポストを作成することができる(慣習的に正しい方法)。
新規のマイクロポストがこの方法で作成される場合、user_idは自動的に正しい値に設定される。
メソッド | 用途 |
---|---|
micropost.user | Micropostに紐付いたUserオブジェクトを返す |
user.microposts | Userのマイクロポストの集合をかえす |
user.microposts.create(arg) | userに紐付いたマイクロポストを作成する |
user.microposts.create!(arg) | userに紐付いたマイクロポストを作成する (失敗時に例外を発生) |
user.microposts.build(arg) | userに紐付いた新しいMicropostオブジェクトを返す |
user.microposts.find_by(id: 1) | userに紐付いていて、idが1であるマイクロポストを検索する |
user/micropost関連メソッドのまとめ |
@user.microposts.buildのようなコードを使うためには、 UserモデルとMicropostモデルをそれぞれ更新して、関連付ける必要がある。
Micropostモデルの方では、belongs_to :userというコードが必要になるのだが、これはマイグレーションによって自動的に生成されているはず。
一方、Userモデルの方では、has_many :micropostsと手動で追加する必要がある。
class Micropost < ApplicationRecord
belongs_to :user
validates :user_id, presence: true
validates :content, presence: true, length: { maximum: 140 }
end
class User < ApplicationRecord
has_many :microposts
.
.
.
end
正しく関連付けができたら、setupメソッドを修正して、慣習的に正しくマイクロポストを作成してみる。
require 'test_helper'
class MicropostTest < ActiveSupport::TestCase
def setup
@user = users(:michael)
@micropost = @user.microposts.build(content: "Lorem ipsum")
end
test "should be valid" do
assert @micropost.valid?
end
test "user id should be present" do
@micropost.user_id = nil
assert_not @micropost.valid?
end
.
.
.
end
マイクロポストを改良する
ここでは、UserとMicropostの関連付けを改良していく。
具体的には、ユーザーのマイクロポストを特定の順序で取得できるようにしたり、マイクロポストをユーザーに依存させて、ユーザーが削除されたらマイクロポストも自動的に削除されるようにしていく。
デフォルトのスコープ
user.micropostsメソッドはデフォルトでは読み出しの順序に対して何も保証しないが、 ブログやTwitterの慣習に従って、作成時間の逆順、つまり最も新しいマイクロポストを最初に表示するようにしてみる。
これを実装するためには、default scopeというテクニックを使う。
この機能のテストは、見せかけの成功に陥りやすい部分で、「アプリケーション側の実装が本当は間違っているのにテストが成功してしまう」という罠がある。
正しいテストを書くために、ここではテスト駆動開発で進めていく。
具体的には、まずデータベース上の最初のマイクロポストが、fixture内のマイクロポスト(most_recent)と同じであるか検証するテストを書いていく。
require 'test_helper'
class MicropostTest < ActiveSupport::TestCase
.
.
.
test "order should be most recent first" do
assert_equal microposts(:most_recent), Micropost.first
end
end
ここでは、マイクロポスト用のfixtureファイルからサンプルデータを読み出しているので、次のfixtureファイルも必要になる。
orange:
content: "I just ate an orange!"
created_at: <%= 10.minutes.ago %>
tau_manifesto:
content: "Check out the @tauday site by @mhartl: http://tauday.com"
created_at: <%= 3.years.ago %>
cat_video:
content: "Sad cats are sad: http://youtu.be/PKffm2uI4dk"
created_at: <%= 2.hours.ago %>
most_recent:
content: "Writing a short test"
created_at: <%= Time.zone.now %>
ここでは、埋め込みRubyを使ってcreated_atカラムに明示的に値をセットしている。
このマジックカラムはRailsによって自動的に更新されるため、基本的には手動で更新することはできないが、fixtureファイルの中ではそれが可能になっている。
また、原理的には必要はないかもしれないが、ほとんどのシステムでは上から順に作成されるので、fixtureファイルでも意図的に順序をいじっている。
例えばファイル内の一番下のサンプルデータは最後に生成されるので、最も新しい投稿になるように修正する、といった感じ。
ただ、この振る舞いは恐らくシステムに依存していて崩れやすいので、(本来は)この振る舞いに依存したテストは書くべきでは無い。
いま、このテストを実行すると redになるはず。
rails test test/models/micropost_test.rb
次に、Railsのdefault_scopeメソッドを使ってこのテストを成功させる。
このメソッドは、データベースから要素を取得したときの、デフォルトの順序を指定するメソッド。
特定の順序にしたい場合は、default_scopeの引数にorderを与える。
例えば、created_atカラムの順にしたい場合は次のようになる。
order(:created_at)
ただし、残念ながらデフォルトの順序が昇順(ascending)となっているので、このままでは数の小さい値から大きい値にソートされてしまう(最も古い投稿が最初に表示されてしまう)。
順序を逆にしたい場合は、次のように書く。
order(created_at: :desc)
class Micropost < ApplicationRecord
belongs_to :user
default_scope -> { order(created_at: :desc) }
validates :user_id, presence: true
validates :content, presence: true, length: { maximum: 140 }
end
ここでは、新たにラムダ式 (Stabby lambda) という文法を使っている。
これは、Procやlambda(もしくは無名関数)と呼ばれるオブジェクトを作成する文法。
->というラムダ式は、ブロックを引数に取り、Procオブジェクトを返す。
このオブジェクトは、callメソッドが呼ばれたとき、ブロック内の処理を評価する。
この構文をコンソールで確かめてみる。
>> -> { puts "foo" }
=> #<Proc:0x007fab938d0108@(irb):1 (lambda)>
>> -> { puts "foo" }.call
foo
=> nil
Dependent: destroy
今度はマイクロポストに第二の要素を追加してみる。
サイト管理者はユーザーを破棄する権限を持つ。
ユーザーが破棄された場合、ユーザーのマイクロポストも同様に破棄されるべき。
この振る舞いは、has_manyメソッドにオプションを渡してあげることで実装できる。
class User < ApplicationRecord
has_many :microposts, dependent: :destroy
.
.
.
end
dependent: :destroyというオプションを使うと、ユーザーが削除されたときに、そのユーザーに紐付いた(そのユーザーが投稿した)マイクロポストも一緒に削除されるようになる。
これは、管理者がシステムからユーザーを削除したとき、持ち主の存在しないマイクロポストがデータベースに取り残されてしまう問題を防ぐ。
次に、上のコードが正しく動くかどうか、テストを使ってUserモデルを検証してみる。
このテストでは、(idを紐づけるための)ユーザーを作成することと、そのユーザーに紐付いたマイクロポストを作成する必要がある。
その後、ユーザーを削除してみて、マイクロポストの数が1つ減っているかどうかを確認する。
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 "associated microposts should be destroyed" do
@user.save
@user.microposts.create!(content: "Lorem ipsum")
assert_difference 'Micropost.count', -1 do
@user.destroy
end
end
end
マイクロポストを表示する
Web経由でマイクロポストを作成する方法は現時点ではないが、マイクロポストを表示することと、テストすることならできる。
ここでは、Twitterのような独立したマイクロポストのindexページは作らずに、ユーザーのshowページで直接マイクロポストを表示させることにする。
ユーザープロフィールにマイクロポストを表示させるため、最初に極めてシンプルなERbテンプレートを作成する。
次に、サンプルデータ生成タスクにマイクロポストのサンプルを追加して、画面にサンプルデータが表示されるようにしてみる。
マイクロポストの描画
ユーザーのプロフィール画面(show.html.erb)でそのユーザーのマイクロポストを表示させたり、これまでに投稿した総数も表示させたりしていく。
まずは、Micropostのコントローラとビューを作成するために、コントローラを生成する。
rails generate controller Microposts
今回の目的は、ユーザー毎にすべてのマイクロポストを描画できるようにすること。
以前見た次のコードでは、
<ul class="users">
<%= render @users %>
</ul>
_user.html.erbパーシャルを使って自動的に@users変数内のそれぞれのユーザーを出力していた。
これを参考に、_micropost.html.erbパーシャルを使ってマイクロポストのコレクションを表示しようとすると、次のようになる。
<ol class="microposts">
<%= render @microposts %>
</ol>
まずは、順序無しリストのulタグではなく、順序付きリストのolタグを使っている。
これは、マイクロポストが特定の順序 (新しい→古い) に依存しているため。
次に、対応するパーシャルを下のコードに示す。
<li id="micropost-<%= micropost.id %>">
<%= link_to gravatar_for(micropost.user, size: 50), micropost.user %>
<span class="user"><%= link_to micropost.user.name, micropost.user %></span>
<span class="content"><%= micropost.content %></span>
<span class="timestamp">
Posted <%= time_ago_in_words(micropost.created_at) %> ago.
</span>
</li>
ここではtime_ago_in_wordsというヘルパーメソッドを使っている。
これはメソッド名の表すとおりだが、「3分前に投稿」といった文字列を出力する。
具体的な効果について後で説明する。
また、ここでは各マイクロポストに対してCSSのidを割り振っている。
<li id="micropost-<%= micropost.id %>">
これは一般的に良いとされる慣習で、例えば将来、JavaScriptを使って各マイクロポストを操作したくなったときなどに役立つ。
次は、一度にすべてのマイクロポストが表示されてしまう潜在的問題に対処する。
以前も、ページネーションを使ったが、今回も同じ方法でこの問題を解決する。
前回同様、will_paginateメソッドを使うと次のようになる。
<%= will_paginate @microposts %>
以前のユーザー一覧画面のコードと比較すると、少し違っている。
以前は次のように単純なコードだった。
<%= will_paginate %>
実は、上のコードは引数なしで動作していた。
これはwill_paginateが、Usersコントローラのコンテキストにおいて、@usersインスタンス変数が存在していることを前提としているため。
このインスタンス変数は、前述したようにActiveRecord::Relationクラスのインスタンス。
今回の場合はUsersコントローラのコンテキストからマイクロポストをページネーションしたいため(つまりコンテキストが異なるため)、明示的に@microposts変数をwill_paginateに渡す必要がある。
したがって、そのようなインスタンス変数をUsersコントローラのshowアクションで定義しなければならない。
class UsersController < ApplicationController
.
.
.
def show
@user = User.find(params[:id])
@microposts = @user.microposts.paginate(page: params[:page])
end
.
.
.
end
paginateメソッドは、マイクロポストの関連付けを経由してmicropostテーブルに到達し、必要なマイクロポストのページを引き出してくれる。
最後の課題はマイクロポストの投稿数を表示することだが、これはcountメソッドを使うことで解決できる。
user.microposts.count
paginateと同様に、関連付けをとおしてcountメソッドを呼び出すことができる。
大事なことは、countメソッドではデータベース上のマイクロポストを全部読みだしてから結果の配列に対してlengthを呼ぶ、といった無駄な処理はしていないという点。
そんなことをしたら、マイクロポストの数が増加するにつれて効率が低下してしまう。
そうではなく、(データベース内での計算は高度に最適化されているので)データベースに代わりに計算してもらい、特定のuser_idに紐付いたマイクロポストの数をデータベースに問い合わせている。(それでもcountメソッドがアプリケーションのボトルネックになるようなことがあれば、さらに高速なcounter cacheを使うこともできる。)
これですべての要素が揃ったので、プロフィール画面にマイクロポストを表示させてみる。
(このとき、if @user.microposts.any? を使って、ユーザーのマイクロポストが1つもない場合には空のリストを表示させていない。)
<% provide(:title, @user.name) %>
<div class="row">
<aside class="col-md-4">
<section class="user_info">
<h1>
<%= gravatar_for @user %>
<%= @user.name %>
</h1>
</section>
</aside>
<div class="col-md-8">
<% if @user.microposts.any? %>
<h3>Microposts (<%= @user.microposts.count %>)</h3>
<ol class="microposts">
<%= render @microposts %>
</ol>
<%= will_paginate @microposts %>
<% end %>
</div>
</div>
マイクロポストのサンプル
サンプルデータ生成タスクにマイクロポストも追加する。
すべてのユーザーにマイクロポストを追加しようとすると時間が掛かり過ぎるので、takeメソッドを使って最初の6人だけに追加する。
User.order(:created_at).take(6)
(このとき、orderメソッドを経由することで、明示的に最初の(IDが小さい順に)6人を呼び出すようにしている。)
この6人については、1ページの表示限界数(30)を越えさせるために、それぞれ50個分のマイクロポストを追加するようにしている。
また、各投稿内容についてだが、Faker gemにLorem.sentenceという便利なメソッドがあるので、これを使う。
変更した結果は以下のとおり。
(ループの順序に違和感があるかもしれないが、これはステータスフィード(いわゆるタイムライン)を実装するときに役立つ。というのも、ユーザー毎に50個分のマイクロポストをまとめて作成してしまうと、ステータスフィードに表示される投稿がすべて同じユーザーになってしまい、視覚的な見栄えが悪くなるから。)
.
.
.
users = User.order(:created_at).take(6)
50.times do
content = Faker::Lorem.sentence(5)
users.each { |user| user.microposts.create!(content: content) }
end
ここで、いつものように開発環境用のデータベースで再度サンプルデータを生成する。
rails db:migrate:reset
rails db:seed
生成し終わったら、Railsサーバーを一度落として、起動し直す。
.
.
.
/* microposts */
.microposts {
list-style: none;
padding: 0;
li {
padding: 10px 0;
border-top: 1px solid #e8e8e8;
}
.user {
margin-top: 5em;
padding-top: 0;
}
.content {
display: block;
margin-left: 60px;
img {
display: block;
padding: 5px 0;
}
}
.timestamp {
color: $gray-light;
display: block;
margin-left: 60px;
}
.gravatar {
float: left;
margin-right: 10px;
margin-top: 5px;
}
}
aside {
textarea {
height: 100px;
margin-bottom: 5px;
}
}
span.picture {
margin-top: 10px;
input {
border: 0;
}
}
各マイクロポストの表示には、3つのどの場合にも、それが作成されてからの時間("1分前に投稿" など) が表示されている。
これはtime_ago_in_wordsメソッドによるもの。
数分待ってからページを再度読み込むと、このテキストは自動的に新しい時間に基づいて更新される。
プロフィール画面のマイクロポストをテストする
アカウントを有効化したばかりのユーザーはプロフィール画面にリダイレクトされるので、そのプロフィール画面が正しく描画されていることは、単体テストを通して確認済み。
ここでは、プロフィール画面で表示されるマイクロポストに対して、統合テストを書いていく。
まずは、プロフィール画面用の統合テストを生成してみる。
rails generate integration_test users_profile
プロフィール画面におけるマイクロポストをテストするためには、ユーザーに紐付いたマイクロポストのテスト用データが必要になる。
Railsの慣習に従って、関連付けされたテストデータをfixtureファイルに追加すると、次のようになる。
orange:
content: "I just ate an orange!"
created_at: <%= 10.minutes.ago %>
user: michael
userにmichaelという値を渡すと、Railsはfixtureファイル内の対応するユーザーを探し出して、(もし見つかれば)マイクロポストに関連付けてくれる。
michael:
name: Michael Example
email: michael@example.com
.
.
.
また、マイクロポストのページネーションをテストするためには、マイクロポスト用のfixtureにいくつかテストデータを追加する必要があるが、これは以前ユーザーを追加したときと同様に、埋め込みRubyを使うと簡単。
<% 30.times do |n| %>
micropost_<%= n %>:
content: <%= Faker::Lorem.sentence(5) %>
created_at: <%= 42.days.ago %>
user: michael
<% end %>
これらのコードを1つにまとめると、マイクロポスト用のfixtureファイルは以下のようになる。
orange:
content: "I just ate an orange!"
created_at: <%= 10.minutes.ago %>
user: michael
tau_manifesto:
content: "Check out the @tauday site by @mhartl: http://tauday.com"
created_at: <%= 3.years.ago %>
user: michael
cat_video:
content: "Sad cats are sad: http://youtu.be/PKffm2uI4dk"
created_at: <%= 2.hours.ago %>
user: michael
most_recent:
content: "Writing a short test"
created_at: <%= Time.zone.now %>
user: michael
<% 30.times do |n| %>
micropost_<%= n %>:
content: <%= Faker::Lorem.sentence(5) %>
created_at: <%= 42.days.ago %>
user: michael
<% end %>
テストデータの準備は完了したので、これからテストを書いていくが、今回のテストはやや単純。
今回のテストでは、プロフィール画面にアクセスした後に、ページタイトルとユーザー名、Gravatar、マイクロポストの投稿数、そしてページ分割されたマイクロポスト、といった順でテストしていく。(Applicationヘルパーを読み込んだことでfull_titleヘルパーが利用できている。)
require 'test_helper'
class UsersProfileTest < ActionDispatch::IntegrationTest
include ApplicationHelper
def setup
@user = users(:michael)
end
test "profile display" do
get user_path(@user)
assert_template 'users/show'
assert_select 'title', full_title(@user.name)
assert_select 'h1', text: @user.name
assert_select 'h1>img.gravatar'
assert_match @user.microposts.count.to_s, response.body
assert_select 'div.pagination'
@user.microposts.paginate(page: 1).each do |micropost|
assert_match micropost.content, response.body
end
end
end
ここでは、マイクロポストの投稿数をチェックするために、response.bodyを使っている。
名前を見ると誤解されがちだが、response.bodyにはそのページの完全なHTMLが含まれている(HTMLのbodyタグだけではない)。
したがって、そのページのどこかしらにマイクロポストの投稿数が存在するのであれば、次のように探し出してマッチできるはず。
assert_match @user.microposts.count.to_s, response.body
これはassert_selectよりもずっと抽象的なメソッド。
特に、assert_selectではどのHTMLタグを探すのか伝える必要があるが、assert_matchメソッドではその必要がない点が違う。
また、assert_selectの引数では、ネストした文法を使っている。
assert_select 'h1>img.gravatar'
このように書くことで、h1タグ(トップレベルの見出し)の内側にある、gravatarクラス付きのimgタグがあるかどうかをチェックできる。
マイクロポストを操作する
データモデリングとマイクロポスト表示テンプレートの両方が完成したので、次はWeb経由でそれらを作成するためのインターフェイスに取りかかる。
従来のRails開発の慣習と異なる箇所が1つある。
Micropostsリソースへのインターフェイスは、主にプロフィールページとHomeページのコントローラを経由して実行されるので、Micropostsコントローラにはnewやeditのようなアクションは不要ということになる。
つまり、createとdestroyがあれば十分。
したがって、Micropostsのリソースは以下のようになる。
その結果、以下のコードは、RESTfulなルーティングのサブセットになる。
もちろん、シンプルになったということは完成度がさらに高まったということの証しであり、退化したわけではない。
Rails.application.routes.draw do
root 'static_pages#home'
get '/help', to: 'static_pages#help'
get '/about', to: 'static_pages#about'
get '/contact', to: 'static_pages#contact'
get '/signup', to: 'users#new'
get '/login', to: 'sessions#new'
post '/login', to: 'sessions#create'
delete '/logout', to: 'sessions#destroy'
resources :users
resources :account_activations, only: [:edit]
resources :password_resets, only: [:new, :create, :edit, :update]
resources :microposts, only: [:create, :destroy]
end
HTTPリクエスト | URL | アクション | 名前付きルート |
---|---|---|---|
POST | /microposts | create | microposts_path |
DELETE | /microposts/1 | destroy | micropost_path(micropost) |
Micropostsリソースが提供するRESTfulルート |
マイクロポストのアクセス制御
Micropostsリソースの開発では、Micropostsコントローラ内のアクセス制御から始めることにする。
。関連付けられたユーザーを通してマイクロポストにアクセスするので、createアクションやdestroyアクションを利用するユーザーは、ログイン済みでなければならない。
ログイン済みかどうかを確かめるテストでは、Usersコントローラ用のテストがそのまま役に立つ。
つまり、正しいリクエストを各アクションに向けて発行し、マイクロポストの数が変化していないかどうか、また、リダイレクトされるかどうかを確かめればよい。
require 'test_helper'
class MicropostsControllerTest < ActionDispatch::IntegrationTest
def setup
@micropost = microposts(:orange)
end
test "should redirect create when not logged in" do
assert_no_difference 'Micropost.count' do
post microposts_path, params: { micropost: { content: "Lorem ipsum" } }
end
assert_redirected_to login_url
end
test "should redirect destroy when not logged in" do
assert_no_difference 'Micropost.count' do
delete micropost_path(@micropost)
end
assert_redirected_to login_url
end
end
このテストにパスするコードを書くためには、少しアプリケーション側のコードをリファクタリングしておく必要がある。
以前、beforeフィルターのlogged_in_userメソッドを使って、ログインを要求したときはUsersコントローラ内にこのメソッドがあったので、beforeフィルターで指定していたが、このメソッドはMicropostsコントローラでも必要。
そこで、各コントローラが継承するApplicationコントローラに、このメソッドを移す。
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を削除しておく。
class UsersController < ApplicationController
before_action :logged_in_user, only: [:index, :edit, :update, :destroy]
.
.
.
private
def user_params
params.require(:user).permit(:name, :email, :password,
:password_confirmation)
end
# beforeフィルター
# 正しいユーザーかどうかを確認
def correct_user
@user = User.find(params[:id])
redirect_to(root_url) unless current_user?(@user)
end
# 管理者かどうかを確認
def admin_user
redirect_to(root_url) unless current_user.admin?
end
end
上のコードによって、Micropostsコントローラからもlogged_in_userメソッドを呼び出せるようになった。
これにより、createアクションやdestroyアクションに対するアクセス制限が、beforeフィルターで簡単に実装できるようになる。
class MicropostsController < ApplicationController
before_action :logged_in_user, only: [:create, :destroy]
def create
end
def destroy
end
end
マイクロポストを作成する
以前、HTTP POSTリクエストをUsersコントローラのcreateアクションに発行するHTMLフォームを作成することで、ユーザーのサインアップを実装した。
マイクロポスト作成の実装もこれと似ている。
主な違いは、別の micropost/new ページを使う代わりに、ホーム画面(つまりルートパス)にフォームを置くという点。
次に、マイクロポストのcreateアクションを作り始める。
このアクションも、ユーザー用アクションと似ている。
違いは、新しいマイクロポストをbuildするためにUser/Micropost関連付けを使っている点。micropost_paramsでStrong Parametersを使っていることにより、マイクロポストのcontent属性だけがWeb経由で変更可能になっている。
class MicropostsController < ApplicationController
before_action :logged_in_user, only: [:create, :destroy]
def create
@micropost = current_user.microposts.build(micropost_params)
if @micropost.save
flash[:success] = "Micropost created!"
redirect_to root_url
else
render 'static_pages/home'
end
end
def destroy
end
private
def micropost_params
params.require(:micropost).permit(:content)
end
end
マイクロポスト作成フォームを構築するために、サイト訪問者がログインしているかどうかに応じて異なるHTMLを提供するコードを使う。
<% if logged_in? %>
<div class="row">
<aside class="col-md-4">
<section class="user_info">
<%= render 'shared/user_info' %>
</section>
<section class="micropost_form">
<%= render 'shared/micropost_form' %>
</section>
</aside>
</div>
<% else %>
<div class="center jumbotron">
<h1>Welcome to the Sample App</h1>
<h2>
This is the home page for the
<a href="https://railstutorial.jp/">Ruby on Rails Tutorial</a>
sample application.
</h2>
<%= link_to "Sign up now!", signup_path, class: "btn btn-lg btn-primary" %>
</div>
<%= link_to image_tag("rails.png", alt: "Rails logo"),
'http://rubyonrails.org/' %>
<% end %>
上のコードを動かすためには、いくつかのパーシャルを作る必要がある。
まずはHomeページの新しいサイドバーから作る。
<%= link_to gravatar_for(current_user, size: 50), current_user %>
<h1><%= current_user.name %></h1>
<span><%= link_to "view my profile", current_user %></span>
<span><%= pluralize(current_user.microposts.count, "micropost") %></span>
プロフィールサイドバーのときと同様、上のユーザー情報にも、そのユーザーが投稿したマイクロポストの総数が表示されている。
ただし少し表示に違いがある。
プロフィールサイドバーでは、 “Microposts” をラベルとし、「Microposts (1)」と表示することは問題なかったが、今回のように “1 microposts” と表示してしまうと英語の文法上誤りになってしまう。
そこで、以前紹介したpluralizeメソッドを使って “1 micropost” や “2 microposts” と表示するように調整している。
次はマイクロポスト作成フォームを定義する。
これはユーザー登録フォームに似ている。
<%= form_for(@micropost) do |f| %>
<%= render 'shared/error_messages', object: f.object %>
<div class="field">
<%= f.text_area :content, placeholder: "Compose new micropost..." %>
</div>
<%= f.submit "Post", class: "btn btn-primary" %>
<% end %>
上のフォームが動くようにするためには、2箇所の変更が必要。
1つは、(以前と同様)関連付けを使って次のように@micropostを定義すること。
class StaticPagesController < ApplicationController
def home
@micropost = current_user.microposts.build if logged_in?
end
def help
end
def about
end
def contact
end
end
もちろん、current_userメソッドはユーザーがログインしているときしか使えない。
したがって、@micropost変数もログインしているときのみ定義されるようになる。
フォームを動かすためのもう1つの変更は、エラーメッセージのパーシャルを再定義すること。
でなければ、次のコードが動かない。
<%= render 'shared/error_messages', object: f.object %>
以前は、エラーメッセージパーシャルが@user変数を直接参照していた。
今回は代わりに@micropost変数を使う必要がある。
これらのケースをまとめると、フォーム変数fをf.objectとすることによって、関連付けられたオブジェクトにアクセスすることができる。
したがって、
form_for(@user) do |f|
上のようにf.objectが@userとなる場合と、
form_for(@micropost) do |f|
上のようにf.objectが@micropostになる場合などがある。
パーシャルにオブジェクトを渡すために、値がオブジェクトで、キーがパーシャルでの変数名と同じハッシュを利用する。
これで、app/views/shared/_micropost_form.html.erbの2行目のコードが完成する。
言い換えると、object: f.objectはerror_messagesパーシャルの中でobjectという変数名を作成してくれるので、この変数を使ってエラーメッセージを更新すればよいということ。
<% if object.errors.any? %>
<div id="error_explanation">
<div class="alert alert-danger">
The form contains <%= pluralize(object.errors.count, "error") %>.
</div>
<ul>
<% object.errors.full_messages.each do |msg| %>
<li><%= msg %></li>
<% end %>
</ul>
</div>
<% end %>
この時点でテストを走らせてみても、テストがredのままになっている。
これはerror_messagesパーシャルが他の場所でも使われていたため、ユーザー登録、パスワード再設定、そしてユーザー編集のそれぞれのビューを更新する必要があったから。
<% provide(:title, 'Sign up') %>
<h1>Sign up</h1>
<div class="row">
<div class="col-md-6 col-md-offset-3">
<%= form_for(@user) do |f| %>
<%= render 'shared/error_messages', object: f.object %>
<%= f.label :name %>
<%= f.text_field :name, class: 'form-control' %>
<%= f.label :email %>
<%= f.email_field :email, class: 'form-control' %>
<%= f.label :password %>
<%= f.password_field :password, class: 'form-control' %>
<%= f.label :password_confirmation, "Confirmation" %>
<%= f.password_field :password_confirmation, class: 'form-control' %>
<%= f.submit "Create my account", class: "btn btn-primary" %>
<% end %>
</div>
</div>
<% provide(:title, "Edit user") %>
<h1>Update your profile</h1>
<div class="row">
<div class="col-md-6 col-md-offset-3">
<%= form_for(@user) do |f| %>
<%= render 'shared/error_messages', object: f.object %>
<%= f.label :name %>
<%= f.text_field :name, class: 'form-control' %>
<%= f.label :email %>
<%= f.email_field :email, class: 'form-control' %>
<%= f.label :password %>
<%= f.password_field :password, class: 'form-control' %>
<%= f.label :password_confirmation, "Confirmation" %>
<%= f.password_field :password_confirmation, class: 'form-control' %>
<%= f.submit "Save changes", class: "btn btn-primary" %>
<% end %>
<div class="gravatar_edit">
<%= gravatar_for @user %>
<a href="http://gravatar.com/emails">change</a>
</div>
</div>
</div>
<% provide(:title, 'Reset password') %>
<h1>Password reset</h1>
<div class="row">
<div class="col-md-6 col-md-offset-3">
<%= form_for(@user, url: password_reset_path(params[:id])) do |f| %>
<%= render 'shared/error_messages', object: f.object %>
<%= hidden_field_tag :email, @user.email %>
<%= f.label :password %>
<%= f.password_field :password, class: 'form-control' %>
<%= f.label :password_confirmation, "Confirmation" %>
<%= f.password_field :password_confirmation, class: 'form-control' %>
<%= f.submit "Update password", class: "btn btn-primary" %>
<% end %>
</div>
</div>
フィードの原型
マイクロポスト投稿フォームが動くようになったが、今の段階では投稿した内容をすぐに見ることができない。
というのも、Homeページにまだマイクロポストを表示する部分が実装されていないから。
フォームが正しく動作しているかどうかを確認したい場合、正しいエントリーを投稿した後、プロフィールページに移動してポストを表示すればよいのだが、これはかなり面倒な作業になる。
ユーザー自身のポストを含むマイクロポストのフィードがないと不便。
すべてのユーザーがフィードを持つので、feedメソッドはUserモデルで作るのが自然。
フィードの原型では、まずは現在ログインしているユーザーのマイクロポストをすべて取得する。
なお、今後完全なフィードを実装するため、今回はwhereメソッドでこれを実現する。
class User < ApplicationRecord
.
.
.
# 試作feedの定義
# 完全な実装は次章の「ユーザーをフォローする」を参照
def feed
Micropost.where("user_id = ?", id)
end
private
.
.
.
end
次のコードで使われている疑問符は、セキュリティ上重要な役割を果たしている。
Micropost.where("user_id = ?", id)
上の疑問符があることで、SQLクエリに代入する前にidがエスケープされるため、SQLインジェクション(SQL Injection)と呼ばれる深刻なセキュリティホールを避けることができる。
この場合のid属性は単なる整数(すなわちself.idはユーザーのid)であるため危険はないが、SQL文に変数を代入する場合は常にエスケープする習慣を身につける必要がある。
上のコードは本質的に次のコードと同等であるが、次のコードを使わずにあえて上のコードを使ったのは、今後必要となる完全なステータスフィードで応用が効くため。
def feed
microposts
end
サンプルアプリケーションでフィードを使うために、現在のユーザーのページ分割されたフィードに@feed_itemsインスタンス変数を追加し、次にフィード用のパーシャルをHomeページに追加する。
このとき、ユーザーがログインしているかどうかを調べる後置if文が変化している。
@micropost = current_user.microposts.build if logged_in?
上のコードが、次のような前置if文に変わっている。
if logged_in?
@micropost = current_user.microposts.build
@feed_items = current_user.feed.paginate(page: params[:page])
end
(1行のときは後置if文、2行以上のときは前置if文を使うのがRubyの慣習。)
class StaticPagesController < ApplicationController
def home
if logged_in?
@micropost = current_user.microposts.build
@feed_items = current_user.feed.paginate(page: params[:page])
end
end
def help
end
def about
end
def contact
end
end
<% if @feed_items.any? %>
<ol class="microposts">
<%= render @feed_items %>
</ol>
<%= will_paginate @feed_items %>
<% end %>
ステータスフィードのパーシャルは、Micropostのパーシャルとは異なっている。
<%= render @feed_items %>
このとき、@feed_itemsの各要素がMicropostクラスを持っていたため、RailsはMicropostのパーシャルを呼び出すことができた。
このように、Railsは対応する名前のパーシャルを、渡されたリソースのディレクトリ内から探しにいくことができる。
app/views/microposts/_micropost.html.erb
後は、いつものようにフィードパーシャルを表示すればHomeページにフィードを追加できる。
<% if logged_in? %>
<div class="row">
<aside class="col-md-4">
<section class="user_info">
<%= render 'shared/user_info' %>
</section>
<section class="micropost_form">
<%= render 'shared/micropost_form' %>
</section>
</aside>
<div class="col-md-8">
<h3>Micropost Feed</h3>
<%= render 'shared/feed' %>
</div>
</div>
<% else %>
.
.
.
<% end %>
現時点では、新しいマイクロポストの作成は期待どおりに動作している。
ただしささいなことではあるが、マイクロポストの投稿が失敗すると、 Homeページは@feed_itemsインスタンス変数を期待しているため、現状では壊れてしまう。
最も簡単な解決方法は、空の配列を渡しておくことだが、この場合はページ分割されたフィードを返してもうまく動かない。
class MicropostsController < ApplicationController
before_action :logged_in_user, only: [:create, :destroy]
def create
@micropost = current_user.microposts.build(micropost_params)
if @micropost.save
flash[:success] = "Micropost created!"
redirect_to root_url
else
@feed_items = []
render 'static_pages/home'
end
end
def destroy
end
private
def micropost_params
params.require(:micropost).permit(:content)
end
end
マイクロポストを削除する
最後の機能として、マイクロポストリソースにポストを削除する機能を追加する。
これはユーザー削除と同様に、"delete" リンクで実現する。
ユーザーの削除は管理者ユーザーのみが行えるように制限されていたのに対し、今回は自分が投稿したマイクロポストに対してのみ削除リンクが動作するようにする。
最初のステップとして、マイクロポストのパーシャルに削除リンクを追加する。
<li id="micropost-<%= micropost.id %>">
<%= link_to gravatar_for(micropost.user, size: 50), micropost.user %>
<span class="user"><%= link_to micropost.user.name, micropost.user %></span>
<span class="content"><%= micropost.content %></span>
<span class="timestamp">
Posted <%= time_ago_in_words(micropost.created_at) %> ago.
<% if current_user?(micropost.user) %>
<%= link_to "delete", micropost, method: :delete,
data: { confirm: "You sure?" } %>
<% end %>
</span>
</li>
次に、Micropostsコントローラのdestroyアクションを定義する。
これも、ユーザーにおける実装とだいたい同じ。
大きな違いは、admin_userフィルターで@user変数を使うのではなく、関連付けを使ってマイクロポストを見つけるようにしている点。
これにより、あるユーザーが他のユーザーのマイクロポストを削除しようとすると、自動的に失敗するようになる。
具体的には、correct_userフィルター内でfindメソッドを呼び出すことで、現在のユーザーが削除対象のマイクロポストを保有しているかどうかを確認する。
class MicropostsController < ApplicationController
before_action :logged_in_user, only: [:create, :destroy]
before_action :correct_user, only: :destroy
.
.
.
def destroy
@micropost.destroy
flash[:success] = "Micropost deleted"
redirect_to request.referrer || root_url
end
private
def micropost_params
params.require(:micropost).permit(:content)
end
def correct_user
@micropost = current_user.microposts.find_by(id: params[:id])
redirect_to root_url if @micropost.nil?
end
end
このとき、destroyメソッドではリダイレクトを使っている。
request.referrer || root_url
ここではrequest.referrerというメソッドを使っている。
このメソッドはフレンドリーフォワーディングのrequest.url変数と似ていて、一つ前のURLを返す(今回の場合、Homeページになる)。
このため、マイクロポストがHomeページから削除された場合でもプロフィールページから削除された場合でも、request.referrerを使うことでDELETEリクエストが発行されたページに戻すことができるので、非常に便利。
ちなみに、元に戻すURLが見つからなかった場合でも(例えばテストではnilが返ってくることもある)、||演算子でroot_urlをデフォルトに設定しているため、問題ない。
フィード画面のマイクロポストをテストする
これまでのコードで、Micropostモデルとそのインターフェースが完成した。
残っている箇所は、Micropostsコントローラの認可をチェックする短いテストと、それらをまとめる統合テストを書くこと。
まずはマイクロポスト用のfixtureに、別々のユーザーに紐付けられたマイクロポストを追加していく。
.
.
.
ants:
content: "Oh, is that what you want? Because that's how you get ants!"
created_at: <%= 2.years.ago %>
user: archer
zone:
content: "Danger zone!"
created_at: <%= 3.days.ago %>
user: archer
tone:
content: "I'm sorry. Your words made sense, but your sarcastic tone did not."
created_at: <%= 10.minutes.ago %>
user: lana
van:
content: "Dude, this van's, like, rolling probable cause."
created_at: <%= 4.hours.ago %>
user: lana
次に、自分以外のユーザーのマイクロポストは削除をしようとすると、適切にリダイレクトされることをテストで確認する。
require 'test_helper'
class MicropostsControllerTest < ActionDispatch::IntegrationTest
def setup
@micropost = microposts(:orange)
end
test "should redirect create when not logged in" do
assert_no_difference 'Micropost.count' do
post microposts_path, params: { micropost: { content: "Lorem ipsum" } }
end
assert_redirected_to login_url
end
test "should redirect destroy when not logged in" do
assert_no_difference 'Micropost.count' do
delete micropost_path(@micropost)
end
assert_redirected_to login_url
end
test "should redirect destroy for wrong micropost" do
log_in_as(users(:michael))
micropost = microposts(:ants)
assert_no_difference 'Micropost.count' do
delete micropost_path(micropost)
end
assert_redirected_to root_url
end
end
最後に、統合テストを書く。
今回の統合テストでは、ログイン、マイクロポストのページ分割の確認、無効なマイクロポストを投稿、有効なマイクロポストを投稿、マイクロポストの削除、そして他のユーザーのマイクロポストには[delete]リンクが表示されないことを確認、といった順でテストしていく。
rails generate integration_test microposts_interface
先ほどの順で書いた統合テストは、以下のようになる。
require 'test_helper'
class MicropostsInterfaceTest < ActionDispatch::IntegrationTest
def setup
@user = users(:michael)
end
test "micropost interface" do
log_in_as(@user)
get root_path
assert_select 'div.pagination'
# 無効な送信
assert_no_difference 'Micropost.count' do
post microposts_path, params: { micropost: { content: "" } }
end
assert_select 'div#error_explanation'
# 有効な送信
content = "This micropost really ties the room together"
assert_difference 'Micropost.count', 1 do
post microposts_path, params: { micropost: { content: content } }
end
assert_redirected_to root_url
follow_redirect!
assert_match content, response.body
# 投稿を削除する
assert_select 'a', text: 'delete'
first_micropost = @user.microposts.paginate(page: 1).first
assert_difference 'Micropost.count', -1 do
delete micropost_path(first_micropost)
end
# 違うユーザーのプロフィールにアクセス (削除リンクがないことを確認)
get user_path(users(:archer))
assert_select 'a', text: 'delete', count: 0
end
end
マイクロポストの画像投稿
ここまででマイクロポストに関する基本的な操作はすべて実装できた。
ここでは、応用編として画像付きマイクロポストを投稿できるようにしてみる。
手順としては、まずは開発環境用のβ版を実装し、その後、いくつかの改善をとおして本番環境用の完成版を実装する。
画像アップロード機能を追加するためには、2つの視覚的な要素が必要。
1つは画像をアップロードするためのフォーム、もう1つは投稿された画像そのもの。
基本的な画像アップロード
投稿した画像を扱ったり、その画像をMicropostモデルと関連付けするために、今回はCarrierWaveという画像アップローダーを使う。
まずはcarrierwave gemをGemfileに追加する。
このとき、mini_magick gemとfog gemsも含めている。
これらのgemは画像をリサイズしたり、本番環境で画像をアップロードするために使う。
source 'https://rubygems.org'
gem 'rails', '5.1.4'
gem 'bcrypt', '3.1.11'
gem 'faker', '1.7.3'
gem 'carrierwave', '1.2.2'
gem 'mini_magick', '4.7.0'
gem 'will_paginate', '3.1.5'
gem 'bootstrap-will_paginate', '1.0.0'
.
.
.
group :production do
gem 'pg', '0.20.0'
gem 'fog', '1.42'
end
.
.
.
bundle install
CarrierWaveを導入すると、Railsのジェネレーターで画像アップローダーが生成できるようになる。
早速、次のコマンドを実行してみる(画像のことをimageとすると一般的過ぎるので、今回はpictureと呼ぶことにする)。
rails generate uploader Picture
CarrierWaveでアップロードされた画像は、Active Recordモデルの属性と関連付けされているべき。
関連付けされる属性には画像のファイル名が格納されるため、String型にしておく。
必要となるpicture属性をMicropostモデルに追加するために、マイグレーションファイルを生成し、開発環境のデータベースに適用する。
rails generate migration add_picture_to_microposts picture:string
rails db:migrate
CarrierWaveに画像と関連付けたモデルを伝えるためには、mount_uploaderというメソッドを使う。
このメソッドは、引数に属性名のシンボルと生成されたアップローダーのクラス名を取る。
mount_uploader :picture, PictureUploader
(picture_uploader.rbというファイルでPictureUploaderクラスが定義されている。後で修正しするが、今はデフォルトのままで問題ない。)
Micropostモデルにアップローダーを追加した結果を次に示す。
class Micropost < ApplicationRecord
belongs_to :user
default_scope -> { order(created_at: :desc) }
mount_uploader :picture, PictureUploader
validates :user_id, presence: true
validates :content, presence: true, length: { maximum: 140 }
end
システムによっては、ここで一旦Railsサーバーを再起動させる必要がある。(ただし、Guardを使っている場合は、再起動させるだけではうまく動かないかもしれない。その場合はターミナルから一旦抜けて、新しいターミナルでGuardを再実行してみる。)
Homeページにアップローダーを追加するためには、マイクロポストのフォームにfile_fieldタグを含める必要がある。
<%= form_for(@micropost) do |f| %>
<%= render 'shared/error_messages', object: f.object %>
<div class="field">
<%= f.text_area :content, placeholder: "Compose new micropost..." %>
</div>
<%= f.submit "Post", class: "btn btn-primary" %>
<span class="picture">
<%= f.file_field :picture %>
</span>
<% end %>
最後に、Webから更新できる許可リストにpicture属性を追加する。
class MicropostsController < ApplicationController
before_action :logged_in_user, only: [:create, :destroy]
before_action :correct_user, only: :destroy
.
.
.
private
def micropost_params
params.require(:micropost).permit(:content, :picture)
end
def correct_user
@micropost = current_user.microposts.find_by(id: params[:id])
redirect_to root_url if @micropost.nil?
end
end
一度画像がアップロードされれば、Micropostパーシャルのimage_tagヘルパーでその画像を描画できるようになる。
また、画像の無い(テキストのみの)マイクロポストでは画像を表示させないようにするために、picture?という論理値を返すメソッドを使っている。
このメソッドは、画像用の属性名に応じて、CarrierWaveが自動的に生成してくれるメソッド。
<li id="micropost-<%= micropost.id %>">
<%= link_to gravatar_for(micropost.user, size: 50), micropost.user %>
<span class="user"><%= link_to micropost.user.name, micropost.user %></span>
<span class="content">
<%= micropost.content %>
<%= image_tag micropost.picture.url if micropost.picture? %>
</span>
<span class="timestamp">
Posted <%= time_ago_in_words(micropost.created_at) %> ago.
<% if current_user?(micropost.user) %>
<%= link_to "delete", micropost, method: :delete,
data: { confirm: "You sure?" } %>
<% end %>
</span>
</li>
画像の検証
上のアップローダーも悪くはないが、いくつかの目立つ欠点がある。
例えば、アップロードされた画像に対する制限がないため、もしユーザーが巨大なファイルを上げたり、無効なファイルを上げると問題が発生してしまう。
この欠点を直すために、画像サイズやフォーマットに対するバリデーションを実装し、サーバー用とクライアント(ブラウザ)用の両方に追加する。
最初のバリデーションでは、有効な画像の種類を制限していくが、これはCarrierWaveのアップローダーの中に既にヒントがある。
生成されたアップローダーの中にコメントアウトされたコードがあるが、ここのコメントアウトを取り消すことで、画像のファイル名から有効な拡張子(PNG/GIF/JPEGなど)を検証することができる。
class PictureUploader < CarrierWave::Uploader::Base
storage :file
# アップロードファイルの保存先ディレクトリは上書き可能
# 下記はデフォルトの保存先
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
2つ目のバリデーションでは、画像のサイズを制御する。
これはMicropostモデルに書き足していく。
先ほどのバリデーションとは異なり、ファイルサイズに対するバリデーションはRailsの既存のオプション(presenceやlengthなど)にはない。
したがって、今回は手動でpicture_sizeという独自のバリデーションを定義する。
また、独自のバリデーションを定義するために、今まで使っていたvalidatesメソッドではなく、validateメソッドを使っている。
class Micropost < ApplicationRecord
belongs_to :user
default_scope -> { order(created_at: :desc) }
mount_uploader :picture, PictureUploader
validates :user_id, presence: true
validates :content, presence: true, length: { maximum: 140 }
validate :picture_size
private
# アップロードされた画像のサイズをバリデーションする
def picture_size
if picture.size > 5.megabytes
errors.add(:picture, "should be less than 5MB")
end
end
end
このvalidateメソッドでは、引数にシンボル(:picture_size)を取り、そのシンボル名に対応したメソッドを呼び出す。
また、呼び出されたpicture_sizeメソッドでは、5MBを上限とし、それを超えた場合はカスタマイズしたエラーメッセージをerrorsコレクションに追加している。
定義した画像のバリデーションをビューに組み込むために、クライアント側に2つの処理を追加する。
まずはフォーマットのバリデーションを反映するためには、file_fieldタグにacceptパラメータを付与して使う。
<%= f.file_field :picture, accept: 'image/jpeg,image/gif,image/png' %>
このときacceptパラメータでは、許可したファイル形式を、MIMEタイプで指定するようにする。
次に、大きすぎるファイルサイズに対して警告を出すために、ちょっとしたJavaScript(正確にはjQuery)を書き加える。
こうすることで、長すぎるアップロード時間を防いだり、サーバーへの負荷を抑えたりすることに繋がる。
$('#micropost_picture').bind('change', function() {
var size_in_megabytes = this.files[0].size/1024/1024;
if (size_in_megabytes > 5) {
alert('Maximum file size is 5MB. Please choose a smaller file.');
}
});
jQueryはについて詳細な解説はしないが、上のコードでは(ハッシュマーク#から分かるように)CSS idのmicropost_pictureを含んだ要素を見つけ出し、この要素を監視している。
そしてこのidを持った要素とは、マイクロポストのフォームを指す(なお、ブラウザ上で画面を右クリックし、インスペクターで要素を調べることで確認できる)。
つまり、このCSS idを持つ要素が変化したとき、このjQueryの関数が動き出す。
そして、もしファイルサイズが大きすぎた場合、alertメソッドで警告を出すといった仕組み。
これらの追加的なチェック機能をまとめると、以下のようになる。
<%= form_for(@micropost) do |f| %>
<%= render 'shared/error_messages', object: f.object %>
<div class="field">
<%= f.text_area :content, placeholder: "Compose new micropost..." %>
</div>
<%= f.submit "Post", class: "btn btn-primary" %>
<span class="picture">
<%= f.file_field :picture, accept: 'image/jpeg,image/gif,image/png' %>
</span>
<% end %>
<script type="text/javascript">
$('#micropost_picture').bind('change', function() {
var size_in_megabytes = this.files[0].size/1024/1024;
if (size_in_megabytes > 5) {
alert('Maximum file size is 5MB. Please choose a smaller file.');
}
});
</script>
上のようなコードでは大きすぎるファイルのアップロードを完全には阻止できない。
例えば、ユーザーはアラートを無視してアップロードを強行する、といったことが可能。
今回は「上のようなコードでは実装はまだ不完全である」という点だけ覚えておけば十分。
また、仮に送信フォームを使った投稿をうまく制限できても、ブラウザのインスペクタ機能でJavaScriptをいじったり、curlなどを使って直接POSTリクエストを送信する場合には対応しきれない。
こういった場合にも対応できるようにするため、サーバー側のバリデーションが重要。
画像のリサイズ
ファイルサイズに対するバリデーションはうまくいったが、画像サイズ(縦横の長さ)に対する制限はないので、大きすぎる画像サイズがアップロードされるとレイアウトが壊れてしまう。
とはいえ、ユーザーに手元で画像サイズを変更させるのは不便。
なので、画像を表示させる前にサイズを変更する(リサイズする)ようにしてみる。
画像をリサイズするためには、画像を操作するプログラムが必要になる。
今回はImageMagickというプログラムを使うので、これを開発環境にインストールする(本番環境がHerokuであれば、既に本番環境でImageMagickが使えるようになっている)。
クラウドIDEでは、次のコマンドでこのプログラムをインストールできる。
sudo yum install -y ImageMagick
(もしローカル環境で開発している場合、それぞれの環境に応じてImagiMagickをインストールする手順が異なる。例えばMacの場合であれば、Homebrewを導入し、brew install imagemagickコマンドを使ってインストールする。)
次に、MiniMagickというImageMagickとRubyを繋ぐgemを使って、画像をリサイズしてみる。MiniMagickのドキュメント(英語)を見ると様々な方法でリサイズできることがわかるが、今回はresize_to_limit: [400, 400]という方法を使う。
これは、縦横どちらかが400pxを超えていた場合、適切なサイズに縮小するオプション(ただし小さい画像であっても拡大はしない)。
ちなみにCarrierWaveのMiniMagickの項目を見ると、 小さすぎる画像を引き延ばすこともできるようだが、今回は使わない。
したがって、最終的なコードは以下のようになる。
これにより、大きな画像サイズでも適切にリサイズされるようになる。
class PictureUploader < CarrierWave::Uploader::Base
include CarrierWave::MiniMagick
process resize_to_limit: [400, 400]
storage :file
# アップロードファイルの保存先ディレクトリは上書き可能
# 下記はデフォルトの保存先
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
本番環境での画像アップロード
実装した画像アップローダーは、開発環境で動かす分には問題ないが、本番環境には適していない。
これはstorage :fileという行によって、ローカルのファイルシステムに画像を保存するようになっているから。
本番環境では、ファイルシステムではなくクラウドストレージサービスに画像を保存するようにしてみる。
本番環境でクラウドストレージに保存するためには、fog gemを使うと簡単。
class PictureUploader < CarrierWave::Uploader::Base
include CarrierWave::MiniMagick
process resize_to_limit: [400, 400]
if Rails.env.production?
storage :fog
else
storage :file
end
# アップロードファイルの保存先ディレクトリは上書き可能
# 下記はデフォルトの保存先
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
ここではproduction?という論理値を返すメソッドを使ってる。
このメソッドは以前も紹介したが、これを使うと環境毎に保存先を切り替えることができる。
if Rails.env.production?
storage :fog
else
storage :file
end
世の中には多くのクラウドストレージサービスがあるが、今回は有名で信頼性も高いアマゾンの「Simple Storage Service (S3)25」を使う。
セットアップの手順は次のとおり(詳しい手順はここを参照)。
- (AWSアカウントを作成していない場合) Amazon Web Servicesアカウントにサインアップする
- AWS Identity and Access Management (IAM)でユーザーを作成し、AccessキーとSecretキーをメモする
- AWS ConsoleからS3 bucketを作成し (bucketの名前はなんでも大丈夫)、2.で作成したユーザーに対してRead権限とWrite権限を付与する
S3アカウントの作成と設定が終わったら、CarrierWaveの設定ファイルを次のように修正する。
fogでリージョンを指定する場合は :region => ENV[’S3_REGION’] といったパラメータを渡し、heroku config:set S3_REGION="リージョン名" といったコマンドを実行することで設定できる。
なお、東京のリージョン名は "ap-northeast-1"。
if Rails.env.production?
CarrierWave.configure do |config|
config.fog_credentials = {
# Amazon S3用の設定
:provider => 'AWS',
:region => ENV['S3_REGION'], # 例: 'ap-northeast-1'
:aws_access_key_id => ENV['S3_ACCESS_KEY'],
:aws_secret_access_key => ENV['S3_SECRET_KEY']
}
config.fog_directory = ENV['S3_BUCKET']
end
end
本番環境のメール設定と同様に、ここではHerokuの環境変数 ENV を使って、機密情報が漏洩しないようにしている。
以前は、SendGridのアドオンがこれらの環境変数を自動的に設定してくれたが、今回は手動で設定する必要がある。
heroku config:setコマンドを使って、次のようにHeroku上の環境変数を設定する。
heroku config:set S3_ACCESS_KEY="ココに先ほどメモしたAccessキーを入力"
heroku config:set S3_SECRET_KEY="同様に、Secretキーを入力"
heroku config:set S3_BUCKET="Bucketの名前を入力"
heroku config:set S3_REGION="Regionの名前を入力"
設定が無事に終わったら、これまでの変更をコミットしたりデプロイする準備が整った。
ただし、その前に.gitignoreファイルを更新しおく。
これにより、画像を保存するディレクトリがGitへの保存対象から除かれるので、アプリケーションと関係の無い画像ファイルなどが無視できるようになる。
.
.
.
# アップロードされたテスト画像を無視する
/public/uploads
それでは、これまでの変更をトピックブランチにコミットし、masterブランチにマージしていく。
rails test
git add -A
git commit -m "Add user microposts"
git checkout master
git merge user-microposts
git push
次に、Herokuへのデプロイ、データベースのリセット、サンプルデータの生成を順に実行していく。
git push heroku
heroku pg:reset DATABASE
heroku run rails db:migrate
heroku run rails db:seed