#第13章
短いメッセージを投稿できるようにするためのリソース「マイクロポスト」機能を追加していく。
手順として
・Micropostデータモデル作成
・Userモデルとhas_many
およびbelong_to
メソッドで関連付け
・結果を処理し表示するための必要なフォームを作る
##Micropostモデル
今回のマイクロポストモデルはテストされて、デフォルトの順序を持つ。また、親であるユーザーが破棄されたら自動的に破棄されるものになる。
トピックブランチを作成しておく。
$ git checkout -b user-microposts
###基本的なモデル
Micropostモデルは、マイクロポストの内容を保存するcontent
属性と、特定のユーザーとマイクロポストを関連付けるためのuser_id
属性を持つ。
String型とText型
・String型は、255文字まで格納できる
・Text型は、それ以上。また、Text用のテキストエリアを使うので、より自然な投稿フォームになる。将来のことを考えるとこっち。さらに、パフォーマンスでは差は出ないとのこと。
Micropostモデルを生成する
$ rails generate model Micropost content:text user:references
これでApplicationRecord
を継承したモデルが作られた。
class Micropost < ApplicationRecord
belongs_to :user
end
user:references
という引数を含めていたため、belongs_to
というコードが追加されている。
このreferences
型を利用している点が、Userモデルとの最大の違いになる。
これを利用すると、自動的にインデックスと外部キー参照付きのuser_id
カラムが追加されて、UserとMicropostを関連付ける下準備をしてくれる。
Userモデルと同様にcreated_at
とupdated_at
というカラムが追加されてる。
インデックスが付与されたMicropostのマイグレーション
class CreateMicroposts < ActiveRecord::Migration[6.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
に関連付けられたすべてのマイクロポストを作成時刻の逆順で取り出しやすくなる。
また、user_id
とcreated_at
の両方を1つの配列に含めている。これで、Active Recordは両方のキーを同時に扱う**複合キーインデックス(Multiple Key Index)**を作成する。
マイグレートする
$ rails db:migrate
###Micropostのバリデーション
Micropostモデル単体を動くようにする。
Micropostの初期テストは手順
・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
1つ目のテストは、現実に即しているかどうかをテスト(reality check)
2つ目のテストは、user_id
が存在しているかどうか(nilではないか)
user_id
に対する存在性のバリデーション
class Micropost < ApplicationRecord
belongs_to :user
validates :user_id, presence: true
end
次にcontent
属性に対するバリデーションの追加。
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
content
属性も存在性と、140文字より長くならないという制限を加える。
マイクロポストにバリデーションを追記する。
class Micropost < ApplicationRecord
belongs_to :user
validates :user_id, presence: true
validates :content, presence: true, length: { maximum: 140 }
end
###User/Micropostの関連付け
個々のモデル間で関連付けをしておく。
今回は、それぞれのマイクロポストは一人のユーザーと関連付けられ、それぞれのユーザーは潜在的に複数のマイクロポストと関連付けられている。
MicropostとそのUserはbelongs_to
の関係性
参照:railsチュートリアル
UserとそのMicropostはhas_many
の関係性
参照:railsチュートリアル
このような関係を行うことで
メソッド | 用途 |
---|---|
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であるマイクロポストを検索する |
上のメソッドが使えるようになる。
注意点として
Micropost.create
Micropost.create!
Micropost.new
ではなく
user.microposts.create
user.microposts.create!
user.microposts.build
となっている。
紐づいているユーザーを通してマイクロポストを作成することができる。新規のマイクロポストがこの方法で作成される場合、user_id
は自動的に正しい値に設定される。
最初の書き方↓
@user = users(:michael)
# このコードは慣習的に正しくない
@micropost = Micropost.new(content: "Lorem ipsum", user_id: @user.id)
書き換えると↓
@user = users(:michael)
@micropost = @user.microposts.build(content: "Lorem ipsum")
build
メソッドはオブジェクトを返すがデータベースには反映されない。
一度関連付けを定義すれば、@micropost
変数のuser_id
には、関連するユーザーidが自動的に設定される。
UserモデルとMicropostモデルを紐づける。
Micropostは、belongs_to :user
Userモデルは、has_many :microposts
と追加する
class User < ApplicationRecord
has_many :microposts
.
.
.
end
setup
メソッドを修正し、正しいテストにする。
def setup
@user = users(:michael)
@micropost = @user.microposts.build(content: "Lorem ipsum")
end
###マイクロポストを改良する
UserとMicropostを関連付けを改良する。
ユーザーのマイクロポストを特定の順序で取得できるようにしたり、マイクロポストをユーザーに依存させ、ユーザーの削除と同時にマイクロポストも自動的に削除されるようにする。
デフォルトスコープ
user.micropost
メソッドはデフォルト状態では読み出し順序に何も保証がない。
ブログやTwitterの慣習に従い、作成時間の逆順、最も新しいマイクロポストを最初に表示する。
これを実装するには、default scopeを使う。
このテストは、一見成功しているように思えるが、「アプリケーション側の実装が本当は間違っているのにテストが成功してしまう」トラップがある。
なので、テスト駆動開発で進める。
テスト内容
データベース上のマイクロポストが、fixture内のマイクロポスト(most_recet
)と同じであるか検証する。
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でコメント、作成日、ユーザーへの関連付けを行う。
orange:
content: "I just ate an orange!"
created_at: <%= 10.minutes.ago %>
user: michael
tau_manifesto:
content: "Check out the @tauday site by @mhartl: https://tauday.com"
created_at: <%= 3.years.ago %>
user: michael
cat_video:
content: "Sad cats are sad: https://youtu.be/PKffm2uI4dk"
created_at: <%= 2.hours.ago %>
user: michael
most_recent:
content: "Writing a short test"
created_at: <%= Time.zone.now %>
user: michael
fixtureファイル内では日付を更新可能となっている。
続いて、Railsのdefault_scope
メソッドを使う。
このメソッドは、データベースから要素を取得した時のデフォルトの順序を指定するメソッドになる。
特定の順序にしたい場合、default_scope
の引数にorder
を与える。
例えば、created_at
カラムの順にしたい場合は以下の通り。
order(:created_at)
デフォルトの順序が昇順の為、小さい値から大きい値にソートされる。つまり最も古い投稿が最初に来る。
順序を逆にするには、生のSQLを引数に与える。
order('created_at DESC')
DESC
は、SQLの降順(descending)を指す。これで新しい投稿から古い投稿の順に並ぶ。
なんとRails4.0以降は、Rubyの文法でも書けるようになった。
order(created_at: :desc)
default_scope
でマイクロポストを順序付ける
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
上のコードでは、ラムダ式という文法を使っている。
これは、Procやlambda(無名関数)と呼ばれるオブジェクトを作成する文法。
->
というラムダ式は、ブロックを引数に取り、Procオブジェクトを返す。
このオブジェクトは、call
メソッドが呼ばれたとき、ブロック内の処理を評価する。
Dependent: destroy
マイクロポストに第二の要素を追加する。
サイト管理者はユーザーを破棄する権限を持つため、ユーザーが破棄されたらユーザーのマイクロポストも同時に破棄されるべき。
has_many
メソッドにオプションを渡してあげればOK
class User < ApplicationRecord
has_many :microposts, dependent: :destroy
.
.
.
end
dependent: :destroy
というオプションを使うことで、ユーザーが削除されたら、そのユーザーに紐づいたマイクロポストも一緒に削除される。
持ち主の存在しないマイクロポストがデータベースに取り残される問題を防ぐ。
Userモデルを検証する。
テスト内容
・ユーザーを作成する
・そのユーザーに紐づいたマイクロポストを作成する
・その後、ユーザーを削除してみて、マイクロポストが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
##マイクロポストを表示する
ユーザープロフィールにマイクロポストを表示させるため、最初に極めて神父rなERbテンプレートを作成する。
次に、サンプルデータ生成タスクにマイクロポストのサンプルを追加し、画面にサンプルデータが表示されるようにする。
###マイクロポストの描画
ユーザーのプロフィール画面(show.html.erb
)で、そのユーザーのマイクロポストを表示させたり、これまでに投稿した総数も表示させる。
Micropostのコントローラーとビューを作成するために、コントローラを生成する。
$ rails generate controller Microposts
_micropost.html.erb
パーシャルを使って、マイクrポストのコレクションを表示しようとすると以下のようになる。
<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
というヘルパーメソッドを使っている。これは、「〇分前に投稿」といった文字列を出力するもの。
<li id="micropost-<%= micropost.id %>">
これは、各マイクロポストにCSSのidを割り振っている。将来的に、各マイクロポストを操作したくなった時に役立つ。
ページ分割するため、will_paginate
メソッドを使う。
<%= will_paginate @microposts %>
第10章のユーザー一覧画面のコードでは、<%= will_paginate %>
という単純なコードだった。
実は、上のコードは引数なしで動作していた。will_paginate
がUsersコントーラのコンテキストにおいて、@user
インスタンス変数が存在していることを前提としてたから。このインスタンス変数は、ActiveRecord::Relation
クラスのインスタンス。
今回の場合は、Usersコントローラのコンテキストからマイクロポストをページネーションしたいため(コンテキストが異なる)、明示的に@microposts
変数をwill_paginate
に渡す必要がある。
従って、そのようなインスタンス変数をUsersコントローラのshow
アクションで定義しなければならない。
@microposts
インスタンス変数をshow
アクションに追加する。
class UsersController < ApplicationController
.
.
.
def show
@user = User.find(params[:id])
@microposts = @user.microposts.paginate(page: params[:page])
end
.
.
.
end
マイクロポストの関連付けを経由し、micropost
テーブルに到達して、必要なマイクロポストのページを引き出している。
最後に、マイクロポストの投稿数を表示するが、これはcount
メソッドでOK
user.microposts.count
関連付けを通して、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
メソッドを経由することで、最初の6人を明示的に呼び出すようにしている。
1ページの表示限界数(30)を越えさせるのに、それぞれ50個分のマイクロポストを追加する。
また、各投稿内容はFaker gemにLorem.sentence
というメソッドを使う。
# ユーザーの一部を対象にマイクロポストを生成する
users = User.order(:created_at).take(6)
50.times do
content = Faker::Lorem.sentence(word_count: 5)
users.each { |user| user.microposts.create!(content: content) }
end
開発環境のデータベースで生成
$ rails db:migrate:reset
$ rails db:seed
CSSを整えて、、
/* 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.image {
margin-top: 10px;
input {
border: 0;
}
}
###プロフィール画面のマイクロポストをテスト
プロフィール画面で表示されるマイクロポストに対して、統合テストを書く。
まずは、プロフィール画面用の統合テストを生成する。
$ rails generate integration_test users_profile
マイクロポスト用の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: https://tauday.com"
created_at: <%= 3.years.ago %>
user: michael
cat_video:
content: "Sad cats are sad: https://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(word_count: 5) %>
created_at: <%= 42.days.ago %>
user: michael
<% end %>
テスト内容
プロフィール画面にアクセス後
・ページタイトルとユーザー名
・Gravatar
・マイクロポストの投稿数
・ページ分割されたマイクロポスト
という順番でテストする。
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
第12章と同様に、response.body
を使っている。これには、そのページの完全なHTMLが含まれている。
従って、そのページのどこかしらにマイクロポストの投稿数があれば、以下のように探してマッチできる。
assert_match @user.microposts.count.to_s, response.body
上のはassert_select
よりも抽象的なメソッド。
特にassert_select
ではどのHTMLタグを探すか指定しなければならない。
assert_match
メソッドはその必要がない。
さらに、assert_select
の引数ではネストした文法を使う。
assert_select 'h1>img.gravatar'
h1
タグの内側にあるgrabatar
クラス月のimg
タグがあるかどうかをチェックしてる。
##マイクロポストを操作する
データモデリングとマイクロポスト表示テンプレートが完成したので、続いてWeb経由でそれらを作成するためのインターフェースに取り掛かる。
やること
・ステータスフィード実装
・ユーザーがマイクロポストをWeb経由で破棄できるようにする
従来のRails開発と子なる点が1つある。
Micropostリソースへのインターフェイスは、主にプロフィールページとHomeページのコントローラを経由し実行されるため、Micropostコント―羅にはnew
やedit
アクションは不要。
つまり、create
やdestroy
があればOK
Micropostsリソースは下記になる。
resources :microposts, only: [:create, :destroy]
Micropostsリソースが提供するRESTfulルート
HTTPリクエスト | URL | アクション | 名前付きルート |
---|---|---|---|
POST | /microposts | create | microposts_path |
DELETE | /microposts/1 | destroy | micropost_path(micropost) |
###マイクロポストのアクセス制御
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
ここでリファクタリングの必要性あり。
第10章では、beforeフィルタのlogged_in_user
メソッドを使って、ログインを要求した。第10章では、Usersコントローラ内にこのメソッドがあったので、beforeフィルターを指定した。
なので、Micropostsコントローラでも同様に必要になる。
なので、各コントローラーが継承するApplicationコントローラに、このメソッドを移す。
class ApplicationController < ActionController::Base
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
は削除しておこう。
これで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
###マイクロポストを作成する
第7章でやった、HTTP POSTリクエストをUsersコントローラのcreate
アクションに発行するHTMLフォームを作成することで、ユーザーのサインアップをやった。
今回もこれと似てる。違いとして、別のmicropost/neeページを使う代わりに、ホーム画面にフォームを置くという点。
この節での目標
・ユーザーのログイン状態に応じて、ホーム画面の表示を変更する事
マイクロポストのcreate
アクションを作る。
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
ユーザー用アクションと似てるが、違いは新しいマイクロポストをbuild
するためにUser/Micropost関連付けを使っているというところ。
micropost_params
でStrong Parmetersを使っていることで、マイクロポストのcotent
属性だけがWeb経由で変更可能になっている。
マイクロポスト作成フォーム構築のために、サイト訪問者がログインしてるかどうかに応じ、異なる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.svg", alt: "Rails logo", width: "200px"),
"https://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>
ユーザー情報に、そのユーザーが投稿したマイクロポスト総数が表示される。
pluralize
メソッドを使って、"1 micropost"や"2 microposts"とするよう調整してる。
マイクロポスト作成フォームを定義する。ユーザー登録フォームに似てる。
<%= form_with(model: @micropost, local: true) 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
と定義すること
@micropost = current_user.microposts.build
class StaticPagesController < ApplicationController
def home
@micropost = current_user.microposts.build if logged_in?
end
current_user
メソッドはユーザーがログインしている時しか使えないので、@micropost
変数もログインしているときのみ定義されるようになる。
2つめは、エラーメッセージのパーシャルを再定義すること。
<%= render 'shared/error_messages', object: f.object %>
今回は@mircropost
変数を使う。フォーム変数f
をf.object
とし、関連付けられたオブジェクトにアクセスすることができる。
form_with(model: @user, local: true) do |f|
上のようにf.object
が@user
となる場合と
form_with(model: @micropost, local: true) do |f|
f.object
が@micropost
になる場合がある。
パーシャルにオブジェクトを渡すため、値がオブジェクトで、キーがパーシャルでの変数名と同じハッシュを利用する。
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 %>
この時点でテストは失敗する。error_messages
パーシャルの他の出現場所がヒント。
このパーシャルは、他の場所で使われていたため、ユーザー登録、パスワード再設定、ユーザー編集のそれぞれのビューを更新する必要がある。
各ビューを更新した結果を下記に示す。
<% provide(:title, 'Sign up') %>
<h1>Sign up</h1>
<div class="row">
<div class="col-md-6 col-md-offset-3">
<%= form_with(model: @user, local: true) 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_with(model: @user, local: true) 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="https://gravatar.com/emails">change</a>
</div>
</div>
</div>
<% provide(:title, 'Reset password') %>
<h1>Reset password</h1>
<div class="row">
<div class="col-md-6 col-md-offset-3">
<%= form_with(model: @user, url: password_reset_path(params[:id]),
local: true) 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モデルで作るのが良い。
フィードの原型では、まずは現在ログインしているユーザーのマイクロポストをすべて取得する。
第14章で完全なフィードを実装するので、今回はwhere
メソッドでこれを実現する。
Micropost
モデルに変更を加える。
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文に変数を代入する場合は常にエスケープしておこう。
サンプルアプリケーションにフィード機能を導入するために、ログインユーザーのフィード用にインスタンス変数@feed_items
を追加、Homeページにはフィード用のパーシャルを追加する。
先ほどあった下記のコード
@micropost = current_user.microposts.build if logged_in?
これが
if logged_in?
@micropost = current_user.microposts.build
@feed_items = current_user.feed.paginate(page: params[:page])
end
前置if文に変わる。
(1行の時は後置if文、2行の時は前置if文を使うのがRubyの慣習)
homeアクションにフィードのインスタンス変数を追加する。
class StaticPagesController < ApplicationController
def home
if logged_in?
@micropost = current_user.microposts.build
@feed_items = current_user.feed.paginate(page: params[:page])
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は対応する名前のパーシャルを、渡されたリソースのディレクトリ内から探しにいくことができる。
あとはいつものようにフィードパーシャルを表示すればHomeページにフィードを追加できる。
この結果は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>
しかし、マイクロポストの投稿が失敗すると、Homeページは@feed_items
インスタンス変数を期待しているため、現状では壊れる。
解決法として、Micropostコントローラのcreate
アクションへの送信が失敗した場合に備え、必要なフィード変数をこのブランチで渡しておくこと。
def create
@micropost = current_user.microposts.build(micropost_params)
if @micropost.save
flash[:success] = "Micropost created!"
redirect_to root_url
else
@feed_items = current_user.feed.paginate(page: params[:page])
render 'static_pages/home'
end
end
しかし、この時点ではわざと長いマイクロポストを投稿するとエラーが出る。
ページネーション用のリンクを表示すると、"2"のリンクと"Next"のリンクがどちらも同じ次のページを指している。
create
アクションはMicropostsコントローラにあるので、このURLは「/microposts?page=2」となります。しかし、これはMicropostsの存在していないindexアクションを開こうとしている。
その結果、どちらのリンクをクリックしてもエラーが発生する。
この問題は、Homeページに対応するcontroller
パラメータとaction
パラメータを明示的にwill_paginate
に渡せばOK
例として、static_pages
コントローラとhome
アクションを渡すと次の通り。
<% if @feed_items.any? %>
<ol class="microposts">
<%= render @feed_items %>
</ol>
<%= will_paginate @feed_items,
params: { controller: :static_pages, action: :home } %>
<% end %>
###マイクロポストを削除する
削除機能を追加する。
これは、ユーザー削除と同じように"delete"リンクでOK。
今回は自分が投稿したマイクロポストのみ削除リンクが動作するようにする。
まずは、マイクロポストのパーシャルに削除リンクを追加する。
<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が見つからなくても||
演算子で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
続いて、自分以外のユーザーのマイクロポストは削除しようとした時、適切にリダイレクトされることを確認する。
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
最後に、統合テストを書く。
統合テストの内容
・ログイン
・マイクロポストのページ分割確認
・無効なマイクロポストを投稿
・有効なマイクロポストを投稿
・マイクロポストの削除
・他のユーザーのマイクロポストには[delete]リンクが非表示
という順番でテストする。
統合テストを生成する。
$ rails generate integration_test microposts_interface
マイクロポストUIに対する統合テスト
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'
assert_select 'a[href=?]', '/?page=2' # 正しいページネーションリンク
# 有効な送信
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つ目は、画像をアップロードするためのフォーム
・2つ目は、1つ目に投稿された画像そのもの
###基本的な画像アップロード
Railsでファイルをアップロードする簡単な方法は、Railsに組み込まれているActive Storage機能を使う事。
Active Storageで画像を簡単に扱えて、画像に関連付けるモデルも自由に指定可能となる。
また、Active Storageは汎用性が高く、平文テキスト、PDFファイルや音声等様々なバイナリファイルも扱える。
Active Storageインストールコマンド
$ rails active_storage:install
上のコマンドで、添付ファイルの保存に用いるデータモデルを作成するためのデータベースマイグレーションが1つ作られる。
次にマイグレーションを実行
$ rails db:migrate
Active Storageの中で最初に知るべきことは、has_one_attached
メソッドだ。
これは、指定のモデルとアップロードされたファイルを関連付けるのに使う。
下の場合は、image
を指定してMicropostモデルと関連付けてる。
class Micropost < ApplicationRecord
belongs_to :user
has_one_attached :image
default_scope -> { order(created_at: :desc) }
validates :user_id, presence: true
validates :content, presence: true, length: { maximum: 140 }
end
このアプリケーションでは、「マイクロポスト1件につき画像は1件」という設計をしているが、Active Storageは他にもhas_many_attached
オプションも提供してる。文字通り、Active Recordオブジェクト1件につき複数のファイルを添付できるもの。
Homeページに画像をアップロードを追加するのに、マイクロポストフォームにfile_field
タグを含める。
マイクロポストのcreateフォームに画像アップロードを追加
<%= form_with(model: @micropost, local: true) 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="image">
<%= f.file_field :image %>
</span>
<% end %>
最後に、Micropostsコントローラを更新し、新たに作成したmicropostオブジェクトに画像を追加できるようにする。
Active Storage APIにはそのためのattach
メソッドが提供されており、これを使う。
具体的にMicropostsコントローラのcreate
アクションの中で、アップロードされた画像を@micropost
オブジェクトにアタッチする。
このアップロード許可のために、micropost_params
メソッドを更新し、:image
を許可済み属性リストに追加して、Web経由で更新できるようにする。
class MicropostsController < ApplicationController
before_action :logged_in_user, only: [:create, :destroy]
before_action :correct_user, only: :destroy
def create
@micropost = current_user.microposts.build(micropost_params)
@micropost.image.attach(params[:micropost][:image])
if @micropost.save
flash[:success] = "Micropost created!"
redirect_to root_url
else
@feed_items = current_user.feed.paginate(page: params[:page])
render 'static_pages/home'
end
end
def destroy
@micropost.destroy
flash[:success] = "Micropost deleted"
redirect_to request.referrer || root_url
end
private
def micropost_params
params.require(:micropost).permit(:content, :image)
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
ヘルパーを用いて、関連付けられたmicropost.image
を描画できる。
また、画像の無いテキストのみのマイクロポストでは画像を表示させないようにするため、attached?
という論理値を返すメソッドを使っている。
<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.image if micropost.image.attached? %>
</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>
###画像の検証
画像アップロード機能を持たせたが、欠点がある。それは、アップロードされた画像に対する制限がないため、もしユーザーが巨大なファイルを上げたり、無効なファイルを上げると問題が発生する。
この欠点を直すために、画像サイズやフォーマットに対するバリデーションを実装する。
Railsチュートリアル執筆時点では、Active Storageは、こうしたフォーマット機能やバリデーションがネイティブでサポートされていないとのこと。
なので、そのような機能をgemで追加する。
gem 'active_storage_validations', '0.8.2'
いつものようにbundle install
する。
このgemでは、content_type
を検査する事で画像をバリデーションできる。
content_type: { in: %w[image/jpeg image/gif image/png],
message: "must be a valid image format" }
上記のコードは、サポートする画像フォーマットに対応する画像MIME typeをチェックしてる。
配列構文%w[]
がある。
同じく、ファイルサイズも以下のようにバリデーション可能。
size: { less_than: 5.megabytes,
message: "should be less than 5MB" }
上はtimeヘルパーの時に使った構文と同じで、画像の最大サイズを5MBに制限してる。
これらのバリデーションをまとめた結果をMicropostモデルに追加する。
class Micropost < ApplicationRecord
.
.
.
validates :image, content_type: { in: %w[image/jpeg image/gif image/png],
message: "must be a valid image format" },
size: { less_than: 5.megabytes,
message: "should be less than 5MB" }
end
先ほどのモデルに追加バリデーションを強化するために、クライアント側でも画像アップロードのサイズやフォーマットをチェックする仕組みを入れる。
まずは、JavaScript(jQuery)にて、ユーザーがアップロードしようとする画像が大きすぎたらアラートを表示するようにする。
jQueryでファイルサイズをチェックする。
<script type="text/javascript">
$("#micropost_image").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.");
$("#micropost_image").val("");
}
});
</script>
最後に、accept
パラメータをfile_field
入力タグで用いれば、有効なフォーマットでないとアップロードできない事をユーザーに伝えられる。
<%= form_with(model: @micropost, local: true) 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="image">
<%= f.file_field :image, accept: "image/jpeg,image/gif,image/png" %>
</span>
<% end %>
<%= f.file_field :image, accept: "image/jpeg,image/gif,image/png" %>
このコードは最初に有効な画像フォーマットだけを選択可能にしておき、それ以外のファイルタイプを灰色で表示するもの。
ブラウザ側に色々コードを追加したが、このコードは無効なファイルをアップロードしにくくするだけ。
その気になればcurl
等でPOSTリクエストを直接発行して、無効なファイルをアップロードできてしまう。
なので、サーバー側のバリデーションは必要なのだ。
###画像のリサイズ
ファイルサイズのバリデーションは実装した。
今度は、画像サイズ(縦横の長さ)に対する制限がない。なので、大きすぎる画像サイズがアップロードされるとレイアウトが崩壊する。
かといって、ユーザーで画像サイズを変更させるのは大変不便だ。
そのため、画像を表示させる前にサイズを変更するようにする。
画像を操作するプログラムが必要なので、ImageMagickを使う。
開発環境にインストールする。
$ sudo apt-get -y install imagemagick
続いて、画像処理のためにいくつかgemを追加する。
image_processing
gem、Ruby製ImageMagickプロセッサのmini_magick
gemが必要だ。
gem 'image_processing', '1.9.3'
gem 'mini_magick', '4.9.5'
いつものようにbundle install
する。
これで、Active Storageが提供するvariant
メソッドで変換済画像を作成できるようになる。
特にresize_to__limit
オプションを用いて、下記のように画像の幅や高さが500ピクセルを越えないように制約をかけとく。
image.variant(resize_to_limit: [500, 500])
上記のコードをdisplay_image
メソッドにおいて利便性を高めよう。
# 表示用のリサイズ済み画像を返す
def display_image
image.variant(resize_to_limit: [500, 500])
end
display_image
をmicropostパーシャルで使えるようになった。
リサイズ済のdisplay_image
を使う。
<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.display_image if micropost.image.attached? %>
</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>
上記のコードに
<%= image_tag micropost.display_image if micropost.image.attached? %>
があるが、そこにdisplay_image
メソッドを使ってる。
variant
によるリサイズは、<%= image_tag micropost.display_image if micropost.image.attached? %>
で最初に呼ばれる時にオンデマンドで実行される。以後、結果をキャッシュするので効率が良い。
###本番環境での画像アップロード
画像アップロード機能を実装したが、このままで本番環境に適さない。
本番環境では、ファイルシステムではなくクラウドストレージサービスに画像を保存するようにする。
今回はAWSのS3(Simple Storage Service)を使う。
S3を使うためgemを設定する。
gem 'aws-sdk-s3', '1.46.0', require: false
例のごとくbundle install
を実行する。
AWSの設定方法は省く。
railsチュートリアルに画像付きで細かく載っているため。
##最後に
Micropostsリソースによって、良い感じにTwitterっぽくなった。
次章は、ユーザーをお互いフォローする仕組みを作っていく。
ユーザー同士のリレーションシップモデリングを学んで、マイクロポストフィードにどんな感じで関連するか学ぶ。