Rails-tutorialのまとめ(14.3 ステータスフィード)

その14.25から続く

14.3 ステータスフィード

最後の難関、ステータスフィードの実装に取りかかりましょう。
本書の中でも最も高度なものです。完全なステータスフィードは、
13章で扱ったプロトフィードをベースにします。

現在のユーザーにフォローされているユーザーのマイクロポストの配列を作成し、現在のユーザー自身のマイクロポストと合わせて表示します。
このセクションを通して、複雑さを増したフィードの実装に進んでいきます。
これを実現するためには、RailsとRubyの高度な機能の他に、
SQLプログラミングの技術も必要です。

14.3.1 動機と計画

フィードに必要な3つの条件を満たすことです。具体的には、

1) フォローしているユーザーのマイクロポストがフィードに含まれていること
2) 自分自身のマイクロポストもフィードに含まれていること。
3) フォローしていないユーザーのマイクロポストがフィードに含まれていないこと

の3つです。

まずはMichaelがLanaをフォローしていて、
Archerをフォローしていないという状況を作ってみましょう。
この状況のMichaelのフィードでは、Lanaと自分自身の投稿が見えていて、Archerの投稿は見えないことになります

先ほどの3つの条件をアサーションに変換して、
Userモデルにfeedメソッドがあることに注意しながら、
更新したUserモデルに対するテストを書いてみましょう。
結果を↓に示します。

ステータスフィードのテスト
test/models/user_test.rb

演習

マイクロポストのidが正しく並んでいると仮定して
(すなわち若いidの投稿ほど古くなる前提で)、データセットで
user.feed.map(&:id)を実行すると、
どのような結果が表示されるでしょうか? 考えてみてください。
たdefault_scopeを思い出してください。

user.feed.map($:id)
=>[1,2,7,8,10]

このように、引数として受け取った自分のidと、
フォローしているidが組み合わさって表示される。

14.3.2 フィードを初めて実装する

早速フィードの実装に着手してみましょう。最終的なフィードの実装はやや込み入っているため、細かい部品を1つずつ確かめながら導入していきます。

このフィードで必要なクエリについて考えましょう。
ここで必要なのは、micropostsテーブルから、
あるユーザー (つまり自分自身) がフォローしているユーザーに対応するidを持つマイクロポストをすべて選択 (select) することです。
このクエリを模式的に書くと次のようになります。

SELECT * FROM microposts
WHERE user_id IN (<list of ids>) OR user_id = <user id>

SQLがINというキーワードをサポートしていることを前提にしています

このキーワードを使うことで、
idの集合の内包 (set inclusion) に対してテストを行えます。

Active Recordのwhereメソッドを使っていることを思い出してください。このときに選択すべき対象はシンプルで、
現在のユーザーに対応するユーザーidを持つマイクロポストを選択すればよかったのでした。

Micropost.where("user_id = ?", id)

今回必要になる選択は、上よりも少し複雑で、例えば次のような形になります。

Micropost.where("user_id IN (?) OR user_id = ?", following_ids, id)

フォローされているユーザーに対応するidの配列が必要であることが
わかってきました。これを行う方法の1つは、Rubyのmapメソッドを使うことです。
このメソッドはすべての「列挙可能 (enumerable)」なオブジェクト
(配列やハッシュなど、要素の集合で構成されるあらゆるオブジェクト)で使えます。なお、このメソッドは前にもも出てきました。
mapメソッドを使って配列を文字列に変換すると、次のようになります。

rails console
>> [1, 2, 3, 4].map { |i| i.to_s }
=> ["1", "2", "3", "4"]

& と、メソッドに対応するシンボルを使った短縮表記が使えます。
この短縮表記であれば、変数iを使わずに済みます。

>> [1, 2, 3, 4].map(&:to_s)
=> ["1", "2", "3", "4"]

>> [1, 2, 3, 4].map(&:to_s).join(', ')
=> "1, 2, 3, 4"

上のコードを使えば、user.followingにある各要素のidを呼び出し、
フォローしているユーザーのidを配列として扱うことができます。

>> User.first.following.map(&:id)
=> [3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18,
19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33,
34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51]

実際、この手法は実に便利なので、Active Recordでは次のようなメソッドも用意されています。

>> User.first.following_ids
=> [3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18,
19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33,
34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48,
49, 50, 51]

following_idsメソッドは、has_many :followingの関連付けをしたときにActive Recordが自動生成したものです。
これにより、user.followingコレクションに対応するidを得るためには、関連付けの名前の末尾に_idsを付け足すだけで済みます。
結果として、フォローしているユーザーidの文字列は、
次のようにして取得することができます。

>> User.first.following_ids.join(', ')
=> "3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18,
19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33,
34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48,
49, 50, 51"

実際にSQL文字列に挿入するときは、このように記述する必要はありません。
実は、?を内挿すると自動的にこの辺りの面倒を見てくれます。
さらに、データベースに依存する一部の非互換性まで解消してくれます。
つまり、ここではfollowing_idsメソッドをそのまま使えばよいだけなのです。結果、最初に想像していたとおり、

Micropost.where("user_id IN (?) OR user_id = ?", following_ids, id)
とりあえず動くフィードの実装 green
app/models/user.rb
class User < ApplicationRecord
  .
  .
  .
  # パスワード再設定の期限が切れている場合はtrueを返す
  def password_reset_expired?
    reset_sent_at < 2.hours.ago
  end

  # ユーザーのステータスフィードを返す
  def feed
    Micropost.where("user_id IN (?) OR user_id = ?", following_ids, id)
  end

  # ユーザーをフォローする
  def follow(other_user)
    following << other_user
  end
  .
  .
  .
end
 green
rails test

いくつかのアプリケーションにおいては、この初期実装だけで目的が達成され、十分に思えるかもしれません。しかしにはまだ足りないものがあります。
それが何なのか、次に進む前に考えてみてください。
(フォローしているユーザーが5,000人もいたらどうなるでしょうか?)。

演習

1:現在のユーザー自身の投稿を含めないようにするにはどうすれば良いでしょうか? また、そのような変更を加えると、どのテストが失敗するでしょうか?

OR user_id = 1で、自分自身のユーザーidを渡している点に注目。
(user_id IN (3,..,51) OR user_id = 1)

2:フォローしているユーザーの投稿を含めないようにするにはどうすれば良いでしょうか? また、そのような変更を加えると、どのテストが失敗するでしょうか?

def feed
  Micropost.where("user_id = ?", following_ids, id)
end
ActiveRecord::PreparedStatementInvalid: wrong number of bind variables (2 for 1) in: user_id = ?
test/models/user_test.rb:15:in `block (2 levels) in <class:UserTest>'
test/models/user_test.rb:14:in `block in <class:UserTest>'

3:フォローしていないユーザーの投稿を含めるためには
どうすれば良いでしょうか?
また、そのような変更を加えると、どのテストが失敗するでしょうか?
自分自身とフォローしているユーザー、そしてそれ以外という集合は、
いったいどういった集合を表すのか考えてみてください。

# ユーザーのステータスフィードを返す
def feed
  Micropost.all
end

Expected true to be nil or false
test/models/user_test.rb:23:in `block (2 levels) in <class:UserTest>'
test/models/user_test.rb:22:in `block in <class:UserTest>'
# フォローしていないユーザーの投稿を確認
archer.microposts.each do |post_unfollowed|
  assert_not michael.feed.include?(post_unfollowed)
end

フォローしていないものが含まれているのはダメです

14.3.3 サブセレクト

つまり、フォローしているユーザーが5,000人程度になるとWebサービス全体が遅くなる可能性があります。ステータスフィードを改善していきましょう。

following_idsでフォローしているすべてのユーザーをデータベースに
問い合わせし、さらに、フォローしているユーザーの完全な配列を作るために再度データベースに問い合わせしている点です。
SQLのサブセレクト (subselect) を使うと解決できます。

フィードをリファクタリングすることから始めましょう。
whereメソッド内の変数に、キーと値のペアを使う green
app/models/user.rb

class User < ApplicationRecord
  .
  .
  .
  # ユーザーのステータスフィードを返す
  def feed
    Micropost.where("user_id IN (:following_ids) OR user_id = :user_id",
     following_ids: following_ids, user_id: id)
  end
  .
  .
  .
end

これまでのコード

Micropost.where("user_id IN (?) OR user_id = ?", following_ids, id)

を次のように置き換えました。

Micropost.where("user_id IN (:following_ids) OR user_id = :user_id",
    following_ids: following_ids, user_id: id)

前者の疑問符を使った文法も便利ですが、同じ変数を複数の場所に挿入したい場合は、後者の置き換え後の文法を使う方がより便利です。

これからSQLクエリにもう1つのuser_idを追加します。特に、次のRubyコードは、

following_ids

このようなSQLに置き換えることができます。

following_ids = "SELECT followed_id FROM relationships
WHERE follower_id = :user_id"

このコードをSQLのサブセレクトとして使います。つまり、「ユーザー1がフォローしているユーザーすべてを選択する」というSQLを既存のSQLに内包させる形になり、結果としてSQLは次のようになります。

SELECT * FROM microposts
WHERE user_id IN (SELECT followed_id FROM relationships
                  WHERE follower_id = 1)
OR user_id = 1

集合のロジックを (Railsではなく) データベース内に保存するので、
より効率的にデータを取得することができます。

もっと効率的なフィードを実装する準備ができました。
(ここに記述されているコードは生のSQLを表す文字列であり、
following_idsという文字列はエスケープされているのではなく、
見やすさのために式展開しているだけだという点に注意してください。)

フィードの最終的な実装 green
app/models/user.rb
class User < ApplicationRecord
  .
  .
  .
  # ユーザーのステータスフィードを返す
  def feed
    following_ids = "SELECT followed_id FROM relationships
                     WHERE follower_id = :user_id"
    Micropost.where("user_id IN (#{following_ids})
                     OR user_id = :user_id", user_id: id)
  end
  .
  .
  .
end

RailsとRubyとSQLのコードが複雑に絡み合っていて厄介ですが、
ちゃんと動作します。

green
rails test

大規模なWebサービスでは、バックグラウンド処理を使ってフィードを非同期で生成するなどのさらなる改善が必要でしょう。

ステータスフィードの実装は完了です。

rails test
git add -A
git commit -m "Add user following"
git checkout master
git merge following-users

コードをリポジトリにpushして、本番環境にデプロイしてみましょう。

git push
git push heroku
heroku pg:reset DATABASE
heroku run rails db:migrate
heroku run rails db:seed

演習

1:Homeページで表示される1ページ目のフィードに対して、
統合テストを書いてみましょう。

 test "feed on Home page" do
   get root_path
   @user.feed.paginate(page: 1).each do |micropost|
    assert_match CGI.escapeHTML(micropost.content), response.body
  end 
 end

2:期待されるHTMLをCGI.escapeHTMLメソッドでエスケープしています(このメソッドはCGI.escapeと同じ用途です)。
このコードでは、なぜHTMLをエスケープさせる必要があったのでしょうか?考えてみてください。
ヒント: 試しにエスケープ処理を外して、
得られるHTMLの内容を注意深く調べてください。
マイクロポストの内容が何かおかしいはずです。また、
ターミナルの検索機能 (Cmd-FもしくはCtrl-F) を使って
「sorry」を探すと原因の究明に役立つはずです。

contentをエスケープしている為、CGI.esapeHTMLを加える必要がある。

ステータスフィードが追加され、Ruby on Railsチュートリアルの
サンプルアプリケーションがとうとう完成しました。
このサンプルアプリケーションには、
Railsの主要な機能 (モデル、ビュー、コントローラ、テンプレート、パーシャル、beforeフィルター、バリデーション、コールバック、has_many/belongs_to/has_many through関連付け、セキュリティ、テスティング、デプロイ)が多数含まれています。

14.4.1 サンプルアプリケーションの機能を拡張する

Railsアプリケーションに何らかの機能を実装していて困ったときは、RailsガイドやRails APIをチェックしてみてください。
いずれも1,000ページを超える大型の公式ドキュメントなので、
今自分がやろうとしていることに関連するトピックがあるかもしれません。

できるだけ念入りにGoogleで検索し、自分が調べようとしているトピックに言及しているブログやチュートリアルがないかどうか、よく探すことです。
Webアプリケーションの開発には常に困難がつきまといます。
他人の経験と失敗から学ぶことも重要です。

14.4.1 サンプルアプリケーションの機能を拡張する

Twitterには、マイクロポスト入力中に@記号に続けてユーザーのログイン名を入力するとそのユーザーに返信できる機能があります。
このポストは、宛先のユーザーのフィードと、
自分をフォローしているユーザーにのみ表示されます。
この返信機能の簡単なバージョンを実装してみましょう。具体的には、@replyは受信者のフィードと送信者のフィードにのみ表示されるようにします。
これを実装するには、micropostsテーブルのin_reply_toカラムと、
追加のincluding_repliesスコープをMicropostモデルに追加する
必要があると思います。スコープの詳細については、
RailsガイドのActive Record クエリインターフェイスを参照してください。

このサンプルアプリケーションではユーザー名が重なり得るので、
ユーザー名を一意に表す方法も考えなければならないでしょう。
1つの方法は、idと名前を組み合わせて@1-michael-hartlのようにすることです

もう1つの方法は、ユーザー登録の項目に一意のユーザー名を追加し、@replyで使えるようにすることです。

メッセージ機能

Twitterでは、ダイレクトメッセージを行える機能がサポートされています。この機能をサンプルアプリケーションに実装してみましょう
(ヒント: Messageモデルと、新規マイクロポストにマッチする正規表現が必要になるでしょう)。

フォロワーの通知

ユーザーに新しくフォロワーが増えたときにメールで通知する機能を
実装してみましょう。続いて、メールでの通知機能をオプションとして選択可能にし、不要な場合は通知をオフにできるようにしてみましょう。
メール周りで分からないことがあったら、
RailsガイドのAction Mailerの基礎にヒントがないか調べてみましょう。

RSSフィード

ユーザーごとのマイクロポストをRSSフィードする機能を実装してください。次にステータスフィードをRSSフィードする機能も実装し、
余裕があればフィードに認証スキームも追加してアクセスを制限してみてください。

REST API

多くのWebサイトはAPI (Application Programmer Interface)を
公開しており、第三者のアプリケーションからリソースのget/post/put/deleteが行えるようになっています。
サンプルアプリケーションにもこのようなREST APIを実装してください。解決のヒントは、respond_toブロックをコントローラーの多くのアクションに追加することです。
このブロックはXMLをリクエストされたときに応答します。
セキュリティには十分注意してください。
認可されたユーザーにのみAPIアクセスを許可する必要があります。

検索機能

現在のサンプルアプリケーションには、ユーザーの一覧ページを端から探す、もしくは他のユーザーのフィードを表示する以外に他のユーザーを検索する手段がありません。
この点を強化するために、検索機能を実装してください。
続いて、マイクロポストを検索する機能も追加してください
(ヒント: まずは自分自身で検索機能に関する情報を探してみましょう。難しければ、@budougumi0617 さんの簡単な検索フォームの実装例を参考にしてください)。

他の拡張機能

上記の他にも、「いいね機能」「シェア機能」「minitestの代わりにRSpecで書き直す」「erbの代わりにHamlで書き直す」
「エラーメッセージをI18nで日本語化する」「オートコンプリート機能」といったアイデアがありそうです。

14.4.3 本章のまとめ

1:has_many :throughを使うと、複雑なデータ関係をモデリングできる

2:has_manyメソッドには、クラス名や外部キーなど、いくつものオプションを渡すことができる

3:適切なクラス名と外部キーと一緒に
has_many/has_many :throughを使うことで、
能動的関係 (フォローする) や受動的関係 (フォローされる)がモデリングできた

4:ルーティングは、ネストさせて使うことができる

5:whereメソッドを使うと、
柔軟で強力なデータベースへの問い合わせが作成できる

6:Railsは (必要に応じて) 低級なSQLクエリを呼び出すことができる

7:本書で学んだすべてを駆使することで、フォローしているユーザーのマイクロポスト一覧をステータスフィードに表示させることができた

has_many through
多対多の関係性を定義する関連付けメソッド。

source
has_manyに対してパラメータを与えるオプション。
sourceオブションで与えた値は配列の元を表しているので、実際の配列のインデックスは変わらない。

collection
コレクションルーティングを追加するメソッド。
idを指定せずに全てのメンバーを表示したりできる

Rails-tutorialのまとめ14.25(FollowingとFollowersページ)

その14.2から続く

14.2.3 [Following] と [Followers] ページ

フォローしているユーザーを表示するページと、フォロワーを表示するページは、
いずれもプロフィールページとユーザー一覧ページを合わせたような作りになるという点で似ています。
どちらにもフォローの統計情報などのユーザー情報を
表示するサイドバーと、ユーザーのリストがあります。
さらに、サイドバーには小さめのユーザープロフィール画像のリンクを格子状に並べて表示する予定です。

フォローしているユーザーのリンクとフォロワーのリンクを
動くようにすることでTwitterに倣って、
どちらのページでもユーザーのログインを要求するようにします。

フォローしているユーザーのリンクとフォロワーのリンクを動くようにすることです。 Twitterに倣って、どちらのページでもユーザーのログインを要求するようにします。そこで前回のアクセス制御と同様に、まずはテストから書いていきましょう。

フォロー/フォロワーページの認可をテストする red
test/controllers/users_controller_test.rb

require 'test_helper'

class UsersControllerTest < ActionDispatch::IntegrationTest

 def setup
  @user = users(:michael)
  @other_user = users(:archer)
 end
.
.
.
 test "should redirect following when not logged in" do
  get following_user_path(@user)
  assert_redirected_to login_url
 end

 test "should redirect followers when not logged in" do
  get followers_user_path(@user)
  assert_redirected_to login_url
 end
end

この実装には1つだけトリッキーな部分があります。
それはUsersコントローラに2つの新しいアクションを追加する必要があるということです。

定義した2つのルーティングにもとづいており、これらはそれぞれfollowingおよびfollowersと呼ぶ必要があります。
それぞれのアクションでは、タイトルを設定し、ユーザーを検索し、@user.followingまたは@user.followersからデータを取り出し、
ページネーションを行なって、ページを出力する必要があります。

followingアクションとfollowersアクション red
app/controllers/users_controller.rb

class UsersController < ApplicationController
  before_action :logged_in_user, only: [:index, :edit, :update, :destroy,
                                        :following, :followers]
  .
  .
  .
  def following
    @title = "Following"
    @user  = User.find(params[:id])
    @users = @user.following.paginate(page: params[:page])
    render 'show_follow'
  end

  def followers
    @title = "Followers"
    @user  = User.find(params[:id])
    @users = @user.followers.paginate(page: params[:page])
    render 'show_follow'
  end

  private
  .
  .
  .
end

Railsは慣習に従って、アクションに対応するビューを暗黙的に呼び出します。
例えば、showアクションの最後でshow.html.erbを呼び出す、といった具合です。

renderを明示的に呼び出し、show_followという同じビューを出力しています。つまり、作成が必要なビューはこれ1つです。
renderで呼び出しているビューが同じである理由は、
ERbはどちらの場合でもほぼ同じであり、で両方の場合をカバーできるためです。

↓フォローしているユーザーとフォロワーの両方を表示するshow_followビュー green
app/views/users/show_follow.html.erb

<% provide(:title, @title) %>
<div class="row">
 <aside class="col-md-4">
  <section class="user_info">
   <%= gravatar_for @user %>
   <h1><%= @user.name %></h1>
   <span><%= link_to "view my profile", @user %></span>
   <span><b>Microposts:</b> <%= @user.microposts.count %></span>
  </section>
 <section class="stats">
  <%= render 'shared/stats' %>
   <% if @users.any? %>
    <div class="user_avatars">
     <% @users.each do |user| %>
      <%= link_to gravatar_for(user, size: 30), user %>
   <% end %>
    </div>
   <% end %>
  </section>
 </aside>
<div class="col-md-8">
 <h3><%= @title %></h3>
  <% if @users.any? %>
   <ul class="users follow">
    <%= render @users %>
   </ul>
  <%= will_paginate %>
 <% end %>
 </div>
</div>

 

続きを読む

Rails-tutorialのまとめ14.2(FollowのWebインターフェイス)

その14から続く

14.2 FollowのWebインターフェイス

フォロー/フォロー解除の基本的なインターフェイスを実装します。また、
フォローしているユーザーと、フォロワーにそれぞれ表示用のページを作成します。
ユーザーのステータスフィードを追加して、サンプルアプリケーションを完成させます。

14.2.1 フォローのサンプルデータ

サンプルデータを自動作成するrails db:seedを使って、
データベースにサンプルデータを登録できるとやはり便利です。
先にサンプルデータを自動作成できるようにしておけば、
Webページの見た目のデザインから先にとりかかることができ、
バックエンド機能の実装を後に回すことができます。

最初のユーザーにユーザー3からユーザー51までをフォローさせ、
それから逆にユーザー4からユーザー41に最初のユーザーをフォローさせます。

ソースを見るとわかるように、このような設定を自由に行うことができます。こうしてリレーションシップを作成しておけば、
アプリケーションのインターフェイスを開発するには十分です。

サンプルデータにfollowing/followerの関係性を追加する
db/seeds.rb

# ユーザー
User.create!(name:  "Example User",
             email: "example@railstutorial.org",
             password:              "foobar",
             password_confirmation: "foobar",
             admin:     true,
             activated: true,
             activated_at: Time.zone.now)

99.times do |n|
  name  = Faker::Name.name
  email = "example-#{n+1}@railstutorial.org"
  password = "password"
  User.create!(name:  name,
               email: email,
               password:              password,
               password_confirmation: password,
               activated: true,
               activated_at: Time.zone.now)
end

# マイクロポスト
users = User.order(:created_at).take(6)
50.times do
  content = Faker::Lorem.sentence(5)
  users.each { |user| user.microposts.create!(content: content) }
end

# リレーションシップ
users = User.all
user  = users.first
following = users[2..50]
followers = users[3..40]
following.each { |followed| user.follow(followed) }
followers.each { |follower| follower.follow(user) }
rails db:migrate:reset
rails db:seed

演習

1:コンソールを開き、User.first.followers.countの結果が
期待している結果と合致していることを確認してみましょう。

User.first.followers.count
User Load (0.2ms) SELECT "users".* FROM "users" ORDER BY "users"."id" ASC LIMIT ? [["LIMIT", 1]]
(0.4ms) SELECT COUNT(*) FROM "users" INNER JOIN "relationships" ON "users"."id" = "relationships"."follower_id" WHERE "relationships"."followed_id" = ? [["followed_id", 1]]
=> 38

2:先ほどの演習と同様に、User.first.following.countの結果も合致していることを確認してみましょう。

User.first.following.count
User Load (0.2ms) SELECT "users".* FROM "users" ORDER BY "users"."id" ASC LIMIT ? [["LIMIT", 1]]
(0.2ms) SELECT COUNT(*) FROM "users" INNER JOIN "relationships" ON "users"."id" = "relationships"."followed_id" WHERE "relationships"."follower_id" = ? [["follower_id", 1]]
=> 49

14.2.2 統計とFollowフォーム

これでサンプルユーザーに、フォローしているユーザーとフォロワーができました。プロフィールページとHomeページを更新して、
これを反映しましょう。
最初に、プロフィールページとHomeページに、
フォローしているユーザーとフォロワーの統計情報を表示するための
パーシャルを作成します。次に、フォロー用とフォロー解除用のフォームを作成します。
それから、フォローしているユーザーの一覧 (“following”)と
フォロワーの一覧 (“followers”) を表示する専用のページを作成します。

resourcesブロックの内側で:memberメソッドを使っています。
これは初登場のメソッドですが、まずはどんな動作をするのか推測してみてください

Usersコントローラにfollowingアクションとfollowersアクションを追加する
config/routes.rb

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 do
    member do
      get :following, :followers
    end
  end
  resources :account_activations, only: [:edit]
  resources :password_resets,     only: [:new, :create, :edit, :update]
  resources :microposts,          only: [:create, :destroy]
end

どちらもデータを表示するページなので、
適切なHTTPメソッドはGETリクエストになります。
したがって、getメソッドを使って適切なレスポンスを返すようにします。

続きを読む

Rails-tutorialのまとめ(第14章 ユーザーをフォロー 主に演習)

13章から続く

第14章ユーザーをフォローする

他のユーザーをフォロー(およびフォロー解除)できるソーシャルな仕組みの追加と、
フォローしているユーザーの投稿をステータスフィードに表示する機能を追加します。

ここで学んだデータモデルは、今後自分用のWebアプリケーションを開発するときに必ず役に立ちます。

14.1 Relationshipモデル

ユーザーをフォローする機能を実装する第一歩は、データモデルを構成することです。ただし、これは見た目ほど単純ではありません。

素朴に考えれば、has_many (1対多) の関連付けを用いて「1人のユーザーが複数のユーザーをhas_manyとしてフォローし、1人のユーザーに複数のフォロワーがいることをhas_manyで表す」といった方法でも実装できそうです。しかし後ほど説明しますが、この方法ではたちまち壁に突き当たってしまいます。これを解決するためのhas_many throughについてもこの後で説明します。

14.1.1 データモデルの問題 (および解決策)

あるユーザーをフォローしているすべてのユーザーの集合はfollowersとなりuser.followersはそれらのユーザーの配列を表すことになります。

Twitterの慣習にならい、本チュートリアルではfollowingという
呼称を採用します (例: “50 following, 75 followers”)。

したがって、あるユーザーがフォローしているすべてのユーザーの集合はcalvin.followingとなります。

followingテーブルと has_many関連付けを使って、
フォローしているユーザーのモデリングができます。
user.followingはユーザーの集合でなければならないため、

followingテーブルのそれぞれの行は、followed_idで識別可能なユーザーでなければなりません (これはfollower_idの関連付けについても同様です)。

さらに、それぞれの行はユーザーなので、
これらのユーザーに名前やパスワードなどの属性も追加する必要があるでしょう。

2つの疑問が生じます。

1. あるユーザーが別のユーザーをフォローするとき、何が作成される?

2. あるユーザーが別のユーザーをフォロー解除するとき、何が削除される?。

1人のユーザーは1対多の関係を持つことができ、
さらにユーザーはリレーションシップを経由して多くのfollowing
(またはfollowers) と関係を持つことができるということです。

Facebookのような友好関係 (Friendships) では本質的に
左右対称のデータモデルが成り立ちますが、
Twitterのようなフォロー関係では左右非対称の性質があります。
すなわち、CalvinはHobbesをフォローしていても、
HobbesはCalvinをフォローしていないといった関係性が成り立つのです。

左右非対称な関係性を見分けるために、
それぞれを能動的関係 (Active Relationship)と
受動的関係 (Passive Relationship)と呼ぶことにします

CalvinがHobbesをフォローしているが、
HobbesはCalvinをフォローしていない場合では、
CalvinはHobbesに対して「能動的関係」を持っていることになります。

つまりアイドルとファンの関係のようなものでCalvinはアイドルなわけよ

逆に、HobbesはCalvinに対して「受動的関係」を持っていることになります。

つまりアイドルとファンの関係のようなものでHobbesはファンなわけよ
フォローしているユーザーを生成するために、能動的関係に焦点を当てていきます
フォローしているユーザーはfollowed_idがあれば識別することができるので、

先ほどのfollowingテーブルをactive_relationshipsテーブル
と見立ててみましょう。

ただしユーザー情報は無駄なので、ユーザーid以外の情報は削除します。そして、followed_idを通して、
usersテーブルのフォローされているユーザーを見つけるようにします。

テーブル名にはこの「関係」を表す「relationships」を使いましょう。モデル名はRailsの慣習にならって、Relationshipとします。

続きを読む

Rails-tutorial(13.5画像の検証)

その13.4から続く

13.4.2 画像の検証

アップローダーも悪くはありませんが、いくつかの目立つ欠点があります。例えば、アップロードされた画像に対する制限がないため、
もしユーザーが巨大なファイルを上げたり、無効なファイルを上げると問題が発生してしまいます。

この欠点を直すために、画像サイズやフォーマットに対するバリデーションを実装し、サーバー用とクライアント (ブラウザ)用の両方に追加しましょう。

画像のファイル名から有効な拡張子 (PNG/GIF/JPEGなど) を検証する
画像フォーマットのバリデーション
app/uploaders/picture_uploader.rb

class PictureUploader < CarrierWave::Uploader::Base
  storage :file

  # アップロードファイルの保存先ディレクトリは上書き可能
  # 下記はデフォルトの保存先
  def store_dir
    "uploads/#{model.class.to_s.underscore}/#{mounted_as}/#{model.id}"
  end

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

2つ目のバリデーションでは、画像のサイズを制御します。
これはMicropostモデルに書き足していきます。

今回は手動でpicture_sizeという独自のバリデーションを定義します。

今まで使っていたvalidatesメソッドではなく、
validateメソッドを使っている点に注目してください。

 画像に対するバリデーションを追加する
app/models/micropost.rb
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

validateメソッドでは、引数にシンボル (:picture_size) を取り、
そのシンボル名に対応したメソッドを呼び出します。
また、呼び出されたpicture_sizeメソッドでは、5MBを上限とし、
それを超えた場合はカスタマイズした
エラーメッセージをerrorsコレクションに追加しています。

定義した画像のバリデーションをビューに組み込むために、
クライアント側に2つの処理を追加しましょう。

まずはフォーマットのバリデーションを反映するためには、
file_fieldタグにacceptパラメータを付与して使います。

<%= f.file_field :picture, accept: 'image/jpeg,image/gif,image/png' %>

このときacceptパラメータでは、リスト 13.64で許可したファイル形式を、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.');
}
});

上のコードでは(ハッシュマーク#から分かるように)
CSS idのmicropost_pictureを含んだ要素を見つけ出し、
この要素を監視しています。

そしてこのidを持った要素とは、マイクロポストのフォームを指します(なお、ブラウザ上で画面を右クリックし、インスペクターで要素を調べることで確認できます)。

つまり、このCSS idを持つ要素が変化したとき、このjQueryの関数が動き出します。そして、もしファイルサイズが大きすぎた場合、
alertメソッドで警告を出すといった仕組みです。

これらの追加的なチェック機能をまとめると、↓のようになります。

ファイルサイズをjQueryでチェックする
app/views/shared/_micropost_form.html.erb

<%= 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リクエストを送信する場合には対応しきれません

演習

1:5MB以上の画像ファイルを送信しようとした場合、どうなりますか?
また無効な拡張子のファイルを送信しようとした場合、どうなりますか?

エラーメッセージが以下のようになる。
画像は5MB未満である必要があります
画像「xmind」ファイルのアップロードは許可されていません。
許可されているタイプ:jpg、jpeg、gif、png

13.4.3 画像のリサイズ

画像を表示させる前にサイズを変更する

画像をリサイズするためには、画像を操作するプログラムが必要になります。今回は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を超えていた場合、適切なサイズに縮小するオプションです(ただし小さい画像であっても拡大はしません)。

画像をリサイズするために画像アップローダーを修正する
app/uploaders/picture_uploader.rb

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_whitelist
    %w(jpg jpeg gif png)
  end
end

演習

1:解像度の高い画像をアップロードし、リサイズされているかどうか確認してみましょう。画像が長方形だった場合、リサイズはうまく行われているでしょうか?

うまく出来る。バリデーション実装前のはもちろんリサイズされない

2:テストを追加していた場合、この時点でテストスイートを走らせると
紛らわしいエラーメッセージが表示されることがあります。
このエラーを取り除いてみましょう。
設定ファイルを修正し、テスト時はCarrierWaveに画像のリサイズを
させないようにしてみましょう。

テスト時は画像のリサイズをさせない設定config/initializers/skip_image_resizing.rb
if Rails.env.test?
  CarrierWave.configure do |config|
    config.enable_processing = false
  end
end

13.4.4 本番環境での画像アップロード

storage :fileという行によって、
ローカルのファイルシステムに画像を保存するようになっているからです。
本番環境では、ファイルシステムではなくクラウドストレージサービスに画像を保存するようにしてみましょう。

本番環境でクラウドストレージに保存するためにはfog gemを使うと簡単です。

本番環境での画像アップロードを調整する
app/uploaders/picture_uploader.rb

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_whitelist
    %w(jpg jpeg gif png)
  end
end

世の中には多くのクラウドストレージサービスがありますが、今回は有名で信頼性も高いアマゾンの「Simple Storage Service (S3) 」を使います。

Amazon Web Servicesアカウントにサインアップする

AWS Identity and Access Management (IAM)でユーザーを
作成し、AccessキーとSecretキーをメモする

AWS ConsoleからS3 bucketを作成し(bucketの名前はなんでも大丈夫です)作成したユーザーに対してRead権限とWrite権限を付与する

fogでリージョンを指定する場合は
:region => ENV[‘S3_REGION’] といったパラメータを渡し、
heroku config:set S3_REGION=”リージョン名”
といったコマンドを実行することで設定できます。
なお、東京のリージョン名は “ap-northeast-1” です。

CarrierWaveを通してS3を使うように修正するconfig/initializers/carrier_wave.rb
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 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ファイルにアップロード用ディレクトリを追加する

# アップロードされたテスト画像を無視する
/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

最終状態のGemfile

source 'https://rubygems.org'

gem 'rails',                   '5.1.6'
gem 'bcrypt',                  '3.1.12'
gem 'faker',                   '1.7.3'
gem 'carrierwave',             '1.2.2'
gem 'mini_magick',             '4.7.0'
gem 'will_paginate',           '3.1.6'
gem 'bootstrap-will_paginate', '1.0.0'
gem 'bootstrap-sass',          '3.3.7'
gem 'puma',                    '3.9.1'
gem 'sass-rails',              '5.0.6'
gem 'uglifier',                '3.2.0'
gem 'coffee-rails',            '4.2.2'
gem 'jquery-rails',            '4.3.1'
gem 'turbolinks',              '5.0.1'
gem 'jbuilder',                '2.7.0'

group :development, :test do
  gem 'sqlite3', '1.3.13'
  gem 'byebug',  '9.0.6', platform: :mri
end

group :development do
  gem 'web-console',           '3.5.1'
  gem 'listen',                '3.1.5'
  gem 'spring',                '2.0.2'
  gem 'spring-watcher-listen', '2.0.1'
end

group :test do
  gem 'rails-controller-testing', '1.0.2'
  gem 'minitest',                 '5.10.3'
  gem 'minitest-reporters',       '1.1.14'
  gem 'guard',                    '2.14.1'
  gem 'guard-minitest',           '2.4.6'
end

group :production do
  gem 'pg',   '0.20.0'
  gem 'fog',  '1.42'
end

# Windows環境ではtzinfo-dataというgemを含める必要があります
gem 'tzinfo-data', platforms: [:mingw, :mswin, :x64_mingw, :jruby]

13.5.1 本章のまとめ

1:Active Recordモデルの力によって、マイクロポストも
(ユーザーと同じで) リソースとして扱える

2:Railsは複数のキーインデックスをサポートしている

3:Userは複数のMicropostsを持っていて (has_many)、Micropostは
1人のUserに依存している (belongs_to) といった関係性をモデル化した

4:has_manyやbelongs_toを利用することで、
関連付けを通して多くのメソッドが使えるようになった

5:user.microposts.build(…)というコードは、
引数で与えたユーザーに関連付けされたマイクロポストを返す

6:default_scopeを使うとデフォルトの順序を変更できる

7:default_scopeは引数に無名関数 (->) を取る

8:dependent: :destroyオプションを使うと、
関連付けされたオブジェクトと自分自身を同時に削除する

9:paginateメソッドやcountメソッドは、
どちらも関連付けを通して実行され、効率的にデータベースに問い合わせしている

10:fixtureは、関連付けを使ったオブジェクトの作成もサポートしている

11:パーシャルを呼び出すときに、一緒に変数を渡すことができる

12:whereメソッドを使うと、Active Recordを通して選択 (部分集合を取り出すこと) ができる

13:依存しているオブジェクトを作成/削除するときは、
常に関連付けを通すようにすることで、よりセキュアな操作が実現できる

14:CarrierWaveを使うと画像アップロードや画像リサイズができる

14章に続く

Rails-tutorial(13.4マイクロポストを削除する 主に演習)

13.3から続く

13.3.4 マイクロポストを削除する

最後の機能として、マイクロポストリソースにポストを削除する機能を追加します。
これはユーザー削除と同様に”delete” リンクで実現します。
ユーザーの削除は管理者ユーザーのみが行えるように制限されていたのに対し、
今回は自分が投稿したマイクロポストに対してのみ削除リンクが
動作するようにします。

最初のステップとして、マイクロポストのパーシャルに削除リンクを追加します。作成したコードを示します。

マイクロポストのパーシャルに削除リンクを追加する
app/views/microposts/_micropost.html.erb

<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メソッドを呼び出すことで、現在のユーザーが削除対象のマイクロポストを保有しているかどうかを確認します。作成したコードを↓に示します。

Micropostsコントローラのdestroyアクション
app/controllers/microposts_controller.rb

before_action :correct_user, only: :destroy
 def destroy
  @micropost.destroy
  flash[:success] = "Micropost deleted"
  redirect_to request.referrer || root_url
 end

private

 def correct_user
 @micropost = current_user.microposts.find_by(id: params[:id])
 redirect_to root_url if @micropost.nil?
 end

destroyメソッドではリダイレクトを使っている点に注目してください。

request.referrerというメソッドを使っています。
このメソッドはフレンドリーフォワーディングのrequest.url変数
と似ていて、一つ前のURLを返します(今回の場合、Homeページになります)

演習

1:マイクロポストを作成し、その後作成したマイクロポストを
削除してみましょう。Railsサーバーのログを見てみて、
DELETE文の内容を確認してみてください。

DELETE FROM "microposts" WHERE "microposts"."id" = ? [["id", 303]]

2:redirect_to request.referrer || root_urlの行を
redirect_back(fallback_location: root_url)と置き換えても
うまく動くことを、ブラウザを使って確認してみましょう
(このメソッドはRails 5から新たに導入されました)。

redirect_back(fallback_location: root_url)

13.3.5 フィード画面のマイクロポストをテストする

残っている箇所は、Micropostsコントローラの認可をチェックする短いテストとそれらをまとめる統合テストを書くことです。

まずはマイクロポスト用のfixtureに、
別々のユーザーに紐付けられたマイクロポストを追加していきます

別のユーザーに所属しているマイクロポストを追加する
test/fixtures/microposts.yml

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

次に、自分以外のユーザーのマイクロポストは削除をしようとすると、適切にリダイレクトされることをテストで確認します。

間違ったユーザーによるマイクロポスト削除に対してテストする green
test/controllers/microposts_controller_test.rb

require 'test_helper'

class MicropostsControllerTest < ActionDispatch::IntegrationTest

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に対する統合テスト green
test/integration/microposts_interface_test.rb

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

演習

1:4つのコメント(無効な送信など)のそれぞれに対して、
テストが正しく動いているか確認してみましょう。
具体的には、対応するアプリケーション側のコードをコメントアウトし、テストが redになることを確認し、元に戻すとgreenになることを確認してみましょう。

@micropost = @user.microposts.build(content: "")にする
Expected false to be truthy.
test/models/micropost_test.rb:11:in `block in <class:MicropostTest>'

@micropost.destroy
flash[:success] = "Micropost deleted"

"Micropost.count" didn't change by -1.
Expected: 38
Actual: 39

ifの部分を消して誰でも見れるようにする

Expected exactly 0 elements matching "a", found 2..
Expected: 0
Actual: 2

2:サイドバーにあるマイクロポストの合計投稿数をテストしてみましょう。このとき、単数形 (micropost) と複数形 (microposts) が
正しく表示されているかどうかもテストしてください。

サイドバーでマイクロポストの投稿数をテストするためのテンプレート
test/integration/microposts_interface_test.rb

require 'test_helper'

class MicropostInterfaceTest < ActionDispatch::IntegrationTest

def setup
  @user = users(:michael)
end
.
.
.
test "micropost sidebar count" do
 log_in_as(@user)
 get root_path
 assert_match "#{FILL_IN} microposts", response.body
# まだマイクロポストを投稿していないユーザー
 other_user = users(:malory)
 log_in_as(other_user)
 get root_path
 assert_match "0 microposts", response.body
 other_user.microposts.create!(content: "A micropost")
 get root_path
 assert_match FILL_IN, response.body
 end
end

@user.microposts.count
“1 micropost”

13.4 マイクロポストの画像投稿

ここまででマイクロポストに関する基本的な操作はすべて実装できました。
この節では、応用編として画像付きマイクロポストを投稿できるようにしてみます。
手順としては、まずは開発環境用のβ版を実装し、その後、
いくつかの改善をとおして本番環境用の完成版を実装します。

画像アップロード機能を追加するためには、2つの視覚的な要素が必要です。1つは画像をアップロードするためのフォーム、もう1つは投稿された画像そのものです。

13.4.1 基本的な画像アップロード

投稿した画像を扱ったり、その画像をMicropostモデルと関連付けするために、今回はCarrierWaveという画像アップローダーを使います。
まずはcarrierwave gemをGemfileに追加しましょう。

gem 'carrierwave', '1.2.2'
gem 'mini_magick', '4.7.0'
group :production do
gem 'fog', '1.42'
end
bundle install

CarrierWaveを導入すると、
Railsのジェネレーターで画像アップローダーが生成できるようになります。早速、次のコマンドを実行してみましょう。

rails generate uploader Picture

(画像のことをimageとすると一般的過ぎるので、
今回はpictureと呼ぶことにします)

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モデルにアップローダーを追加した結果を↓に示します。

Micropostモデルに画像を追加する
app/models/micropost.rb

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

Homeページにアップローダーを追加するためには、
マイクロポストのフォームにfile_fieldタグを含める必要があります

マイクロポスト投稿フォームに画像アップローダーを追加する
app/views/shared/_micropost_form.html.erb

<%= 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属性を追加しましょう。
追加すると、micropost_paramsメソッドは次のようになります。

pictureを許可された属性のリストに追加する
app/controllers/microposts_controller.rb

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が自動的に生成してくれるメソッドです。

マイクロポストの画像表示を追加する
app/views/microposts/_micropost.html.erb

<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>

演習

1:テンプレートを参考に、画像アップローダーをテストしてください。
テストの準備として、まずはサンプル画像をfixtureディレクトリに
追加してください(cp app/assets/images/rails.png test/fixtures/)。

追加したテストでは、Homeページにあるファイルアップロードと、
投稿に成功した時に画像が表示されているかどうかをチェックしています。
なお、テスト内にあるfixture_file_uploadというメソッドは、
fixtureで定義されたファイルをアップロードする特別なメソッドです。
ヒント: picture属性が有効かどうかを確かめるときは、assignsメソッドを使ってください。

このメソッドを使うと、
投稿に成功した後にcreateアクション内のマイクロポストにアクセスするようになります。

画像アップロードをテストするためのテンプレートtest/integration/microposts_interface_test.rb

require 'test_helper'

class MicropostInterfaceTest < ActionDispatch::IntegrationTest

  def setup
    @user = users(:michael)
  end

  test "micropost interface" do
    log_in_as(@user)
    get root_path
    assert_select 'div.pagination'
    assert_select 'input[type=FILL_IN]'
    # 無効な送信
    post microposts_path, params: { micropost: { content: "" } }
    assert_select 'div#error_explanation'
    # 有効な送信
    content = "This micropost really ties the room together"
    picture = fixture_file_upload('test/fixtures/rails.png', 'image/png')
    assert_difference 'Micropost.count', 1 do
      post microposts_path, params: { micropost:
                                      { content: content,
                                        picture: FILL_IN } }
    end
    assert FILL_IN.picture?
    follow_redirect!
    assert_match content, response.body
    # 投稿を削除する
    assert_select 'a', '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

答え:↑FILL_INを↓にする。

file,picture,

assigns(:micropost)

13.5に続く

Rails-tutorial(13.3マイクロポストを操作する 主に演習)

その13.2から続く

13.3 マイクロポストを操作する

次はWeb経由でそれらを作成するためのインターフェイスに
取りかかりましょう。この節では、ステータスフィード
(第14章で完成させます) の最初のヒントをお見せします。
最後に、ユーザーがマイクロポストをWeb経由で破棄できるようにします。

Micropostsリソースへのインターフェイスは、主にプロフィールページとHomeページのコントローラを経由して実行されるので、
Micropostsコントローラにはnewやeditのようなアクションは不要と
いうことになります。つまり、createとdestroyがあれば十分です。

マイクロポストリソースのルーティング
config/routes.rb

#追加
resources :microposts, only: [:create, :destroy]
HTTPリクエスト URL    アクション 名前付きルート
POST        /microposts create microposts_path
DELETE       /microposts/1 destroy micropost_path(micropost)

13.3.1 マイクロポストのアクセス制御

Micropostsリソースの開発では、Micropostsコントローラ内の
アクセス制御から始めることにしましょう。
関連付けられたユーザーを通してマイクロポストにアクセスするので、createアクションやdestroyアクションを利用するユーザーは、ログイン済みでなければなりません。

ログイン済みかどうかを確かめるテストでは、
Usersコントローラ用のテストがそのまま役に立ちます。

正しいリクエストを各アクションに向けて発行し、
マイクロポストの数が変化していないかどうか、
また、リダイレクトされるかどうかを確かめればよいのです。

Micropostsコントローラの認可テスト red
test/controllers/microposts_controller_test.rb

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コントローラに
(4.4.4)、このメソッドを移してしまいましょう。

ogged_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を削除しておきましょう

Usersコントローラ内のlogged_in_userフィルターを削除する red
app/controllers/users_controller.rb

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フィルターで簡単に実装できるようになります

Micropostsコントローラの各アクションに認可を追加する green
app/controllers/microposts_controller.rb

class MicropostsController < ApplicationController
before_action :logged_in_user, only: [:create, :destroy]

 def create
 end

 def destroy
 end
end

演習

1:なぜUsersコントローラ内にあるlogged_in_userフィルターを
残したままにするとマズイのでしょうか? 考えてみてください。

コードが重複して思わぬ動作が起きるから

13.3.2 マイクロポストを作成する

最後にホーム画面を実装したときは[Sign up now!]ボタンが中央にありましたマイクロポスト作成フォームは、
ログインしている特定のユーザーのコンテキストでのみ機能するので、
この節の一つの目標は、
ユーザーのログイン状態に応じて、ホーム画面の表示を変更することです。

次に、マイクロポストのcreateアクションを作り始めましょう。
このアクションも、ユーザー用アクションと似ています。違いは、
新しいマイクロポストをbuildするためにUser/Micropost関連付けを
使っている点です

micropost_paramsでStrong Parametersを使っていることにより、
マイクロポストのcontent属性だけがWeb経由で変更可能になっている点に
注目してください。

Micropostsコントローラのcreateアクション
app/controllers/microposts_controller.rb

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を提供するコードを使います

Homeページ (/) にマイクロポストの投稿フォームを追加するapp/views/static_pages/home.html.erb

<% 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 %>


<% end %>

コードを動かすためには、いくつかのパーシャルを作る必要があります。まずはHomeページの新しいサイドバーからです。
次のようになります。

サイドバーで表示するユーザー情報のパーシャル
app/views/shared/_user_info.html.erb

<%= 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” と表示するように調整しています。

 

次はマイクロポスト作成フォームを定義します。
これはユーザー登録フォームに似ています。
マイクロポスト投稿フォームのパーシャル

app/views/shared/_micropost_form.html.erb

<%= 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を定義することです。

homeアクションにマイクロポストのインスタンス変数を追加するapp/controllers/static_pages_controller.rb
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変数もログインしているときのみ
定義されるようになります。

リスト13.39を動かすためのもう1つの変更は、
エラーメッセージのパーシャルを再定義することです。でなければ、
次のコードが動きません。

<%= render 'shared/error_messages', object: f.object %>

form_for(@user) do |f|上のようにf.objectが@userとなる場合と、

form_for(@micropost) do |f|

上のようにf.objectが@micropostになる場合などがあります。

パーシャルにオブジェクトを渡すために、値がオブジェクトで、
キーがパーシャルでの変数名と同じハッシュを利用します。
これで、2行目のコードが完成します。言い換えると、object: f.objectはerror_messagesパーシャルの中でobjectという変数名を
作成してくれるので、この変数を使ってエラーメッセージを更新すればよいということです。

Userオブジェクト以外でも動作するようにerror_messagesパーシャルを更新する red
app/views/shared/_error_messages.html.erb

<% 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パーシャルの他の
出現場所です。
このパーシャルは他の場所でも使われていたため、
ユーザー登録、パスワード再設定、そしてユーザー編集のそれぞれのビューを更新する必要があったのです。
各ビューを更新した結果を示します。

ユーザー登録時のエラー表示を更新するapp/views/users/new.html.erb
<% 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>
リスト 13.44: ユーザー編集時のエラー表示を更新するapp/views/users/edit.html.erb
<% 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>
リスト 13.45: パスワード再設定時のエラー表示を更新するapp/views/password_resets/edit.html.erb
<% 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>

これで、すべてのテストが greenになるはずです。

rails test

演習

1:Homeページをリファクタリングして、if-else文の分岐のそれぞれに対してパーシャルを作ってみましょう。

<% if logged_in? %>
<%= render 'static_pages/if'%>
<% else %>
<%= render 'static_pages/else'%>
<% end %>

13.3.3 フィードの原型

マイクロポスト投稿フォームが動くようになりましたが、今の段階では投稿した内容をすぐに見ることができません。Homeページにまだマイクロポストを表示する部分が実装されていないからです。

すべてのユーザーがフィードを持つので、feedメソッドはUserモデルで作るのが自然です。
フィードの原型では、まずは現在ログインしているユーザーの
マイクロポストをすべて取得してきます。
なお、次章で完全なフィードを実装するため、
今回は紹介したwhereメソッドでこれを実現します。
Userモデルに変更を加えた結果を示します

マイクロポストのステータスフィードを実装するための準備
app/models/user.rb

# 試作feedの定義
# 完全な実装は次章の「ユーザーをフォローする」を参照
def feed
  Micropost.where("user_id = ?", id)
end

疑問符は、セキュリティ上重要な役割を↑果たしています。

SQLクエリに代入する前にidがエスケープされるため、SQLインジェクション(SQL Injection)と呼ばれる深刻なセキュリティホールを避けることができます。
この場合のid属性は単なる整数 (すなわちself.idはユーザーのid)
であるため危険はありませんが、
SQL文に変数を代入する場合は常にエスケープする習慣をぜひ身につけてください。

↑のコードは本質的に↓のコードと同等であることに気付くかもしれません。

def feed
  microposts
end

サンプルアプリケーションにフィード機能を導入するため、
ログインユーザーのフィード用にインスタンス変数@feed_itemsを追加し、Homeページにはフィード用のパーシャルを追加します。

homeアクションにフィードのインスタンス変数を追加する
app/controllers/static_pages_controller.rb

class StaticPagesController < ApplicationController

def home
  if logged_in?
    @micropost = current_user.microposts.build
    @feed_items = current_user.feed.paginate(page: params[:page])
  end
end

ステータスフィードのパーシャル
app/views/shared/_feed.html.erb

<% if @feed_items.any? %>
 <ol class="microposts">
  <%= render @feed_items %>
 </ol>
 <%= will_paginate @feed_items %>
<% end %>

このとき、@feed_itemsの各要素がMicropostクラスを持っていたため、RailsはMicropostのパーシャルを呼び出すことができました。
このように、Railsは対応する名前のパーシャルを、
渡されたリソースのディレクトリ内から探しにいくことができます。

app/views/microposts/_micropost.html.erb

Homeページにステータスフィードを追加する
app/views/static_pages/home.html.erb

<% 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インスタンス変数を期待しているため、現状では壊れてしまいます。

最も簡単な解決方法は、空の配列を渡しておくことです。
残念ですが、この場合はページ分割されたフィードを返してもうまく動きません。

createアクションに空の@feed_itemsインスタンス変数を追加するapp/controllers/microposts_controller.rb
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

演習

1:新しく実装したマイクロポストの投稿フォームを使って、
実際にマイクロポストを投稿してみましょう。
Railsサーバーのログ内にあるINSERT文では、
どういった内容をデータベースに送っているでしょうか? 確認してみてください。

INSERT INTO "microposts" ("content", "user_id", "created_at", "updated_at") VALUES (?, ?, ?, ?) [["content", "tet\r\n"], ["user_id", 1], ["created_at", "2020-04-10 09:48:54.428673"], ["updated_at", "2020-04-10 09:48:54.428673"]]

2:コンソールを開き、user変数にデータベース上の最初のユーザーを
代入してみましょう。
その後、Micropost.where(“user_id = ?”, user.id)とuser.microposts、そしてuser.feedをそれぞれ実行してみて、
実行結果がすべて同じであることを確認してみてください。
ヒント: ==で比較すると結果が同じかどうか簡単に判別できます。

user = User.first
Micropost.where("user_id = ?", user.id) == user.microposts
Micropost Load (1.1ms) SELECT "microposts".* FROM "microposts" WHERE "microposts"."user_id" = ? ORDER BY "microposts"."created_at" DESC [["user_id", 1]]
Micropost Load (0.3ms) SELECT "microposts".* FROM "microposts" WHERE (user_id = 1) ORDER BY "microposts"."created_at" DESC
=> true
>> user.feed == user.microposts

13.4に続く

Rails-tutorialのまとめ(13.2.1 マイクロポストの描画 主に演習)

その13から続く

13.2.1 マイクロポストの描画

ユーザーのプロフィール画面 (show.html.erb) でそのユーザーの
マイクロポストを表示させたり、これまでに投稿した総数も表示させたりしていきます。

Micropostのコントローラとビューを作成するために、コントローラを生成しましょう。なお、今回使うのはビューだけで、Micropostsコントローラは 13.3から使っていきます。

rails db:migrate:reset
rails db:seed
rails generate controller Microposts

_micropost.html.erbパーシャルを使ってマイクロポストのコレクションを
表示しようとすると、次のようになります。

<ol class=”microposts”>
<%= render @microposts %>
</ol>

順序無しリストのulタグではなく、順序付きリストのolタグを使っている点に注目してください。
これは、マイクロポストが特定の順序 (新しい→古い)
に依存しているためです。

次に、対応するパーシャルを示します。
1つのマイクロポストを表示するパーシャル
app/views/microposts/_micropost.html.erb

<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を使って
各マイクロポストを操作したくなったときなどに役立ちます。

一度にすべてのマイクロポストが表示されてしまう潜在的問題に対処します。10ではページネーションを使いましたが、今回も同じ方法でこの問題を解決します。
前回同様、will_paginateメソッドを使うと次のようになります。

<%= will_paginate @microposts %>

以前は次のように単純なコードでした。

<%= will_paginate %>

実は、上のコードは引数なしで動作していました。
これはwill_paginateが、Usersコントローラのコンテキストに
おいて、@usersインスタンス変数が存在していることを前提としているためです

今回の場合はUsersコントローラのコンテキストからマイクロポストをページネーションしたいため (つまりコンテキストが異なるため)、
明示的に@microposts変数をwill_paginateに渡す必要があります。

したがって、そのようなインスタンス変数をUsersコントローラの
showアクションで定義しなければなりません
@micropostsインスタンス変数をshowアクションに追加する

app/controllers/users_controller.rb

class UsersController < ApplicationController
  .
  .
  .
  def show
    @user = User.find(params[:id])
    @microposts = @user.microposts.paginate(page: params[:page])
  end
  .
  .
  .
end

マイクロポストの投稿数を表示することですが、これはcountメソッドを使うことで解決できます。

user.microposts.count

paginateと同様に、関連付けをとおしてcountメソッドを呼び出すことができます。
大事なことは、countメソッドではデータベース上の
マイクロポストを全部読みだしてから結果の配列に対してlengthを呼ぶ、といった無駄な処理はしていないという点です。
そんなことをしたら、
マイクロポストの数が増加するにつれて効率が低下してしまいます。
そうではなく、(データベース内での計算は高度に最適化されているので)データベースに代わりに計算してもらい、
特定のuser_idに紐付いた
マイクロポストの数をデータベースに問い合わせています。

すべての要素が揃ったので、プロフィール画面にマイクロポストを
表示させてみましょう(このとき、if @user.microposts.any?を使って、ユーザーのマイクロポストが1つもない場合には空のリストを
表示させていない点にも注目してください。)

マイクロポストをユーザーのshowページ (プロフィール画面) に追加する
app/views/users/show.html.erb

<% 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>

演習

今回ヘルパーメソッドとして使ったtime_ago_in_wordsメソッドは、
Railsコンソールのhelperオブジェクトから呼び出すことができます。
このhelperオブジェクトのtime_ago_in_wordsメソッドを使って、
3.weeks.agoや6.months.agoを実行してみましょう。

helper.time_ago_in_words(3.weeks.ago)
=> "21 days"
>> helper.time_ago_in_words(6.months.ago)
=> "6 months"

2:helper.time_ago_in_words(1.year.ago)と実行すると、どういった結果が返ってくるでしょうか?

helper.time_ago_in_words(1.year.ago)
=> "about 1 year"

3:micropostsオブジェクトのクラスは何でしょうか? ヒント: リスト 13.23内のコードにあるように、まずはpaginateメソッド (引数はpage: nil) でオブジェクトを取得し、その後classメソッドを呼び出してみましょう。

user = User.find(1)
User Load (0.2ms) SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ? [["id", 1], ["LIMIT", 1]]
=> #<User id: 1, name: "Example User", email: "example@railstutorial.org", created_at: "2020-04-10 02:03:10", updated_at: "2020-04-10 02:03:10", password_digest: "$2a$10$cwFqu4HHjAMUEhZpbM294.4JUOfjV08XlIzGu37zTSD...", remember_digest: nil, admin: true, activation_digest: "$2a$10$mRd3DEhj6awejp8QoTEbouySA73h7GJW4h5MQYUJ3ax...", activated: true, activated_at: "2020-04-10 02:03:09", reset_digest: nil, reset_sent_at: nil>
>> microposts = user.microposts.paginate(page:nil)
Micropost Load (0.2ms) SELECT "microposts".* FROM "microposts" WHERE "microposts"."user_id" = ? ORDER BY "microposts"."created_at" DESC LIMIT ? OFFSET ? [["user_id", 1], ["LIMIT", 11], ["OFFSET", 0]]
=> #<ActiveRecord::AssociationRelation []>
>> microposts.class
=> Micropost::ActiveRecord_AssociationRelation

13.2.2 マイクロポストのサンプル

すべてのユーザーにマイクロポストを追加しようとすると時間が掛かり過ぎるので、takeメソッドを使って最初の6人だけに追加します。

User.order(:created_at).take(6)

このとき、orderメソッドを経由することで、
作成されたユーザーの最初の6人を明示的に呼び出すようにしています。

この6人については、1ページの表示限界数(30)を越えさせるために、
それぞれ50個分のマイクロポストを追加するようにしています。
また、各投稿内容についてですが、Faker gem
にLorem.sentenceという便利なメソッドがあるので、これを使います。

サンプルデータにマイクロポストを追加する
db/seeds.rb

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

マイクロポスト用のCSS (本章で利用するCSSのすべて)
app/assets/stylesheets/custom.scss

.
.
/* 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;
  }
}

演習

1:(1..10).to_a.take(6)というコードの実行結果を推測できますか?
推測した値が合っているかどうか、コンソールを使って確認してみましょう。

(1..10).to_a.take(6)
=> [1, 2, 3, 4, 5, 6]

2:先ほどの演習にあったto_aメソッドの部分は本当に必要でしょうか? 確かめてみてください。

(1..10).take(6)
=> [1, 2, 3, 4, 5, 6]

3:Fakerのドキュメント (英語) を眺めながら画面に出力する方法を学び、実際に大学名や電話番号を画面に出力してみましょう。

Faker::Hipster.words
=> ["whatever", "schlitz", "kickstarter"]
>> Faker::ChuckNorris.fact
=> "Quantum cryptography does not work on Chuck Norris. When something is being observed by Chuck it stays in the same state until he's finished."

13.2.3 プロフィール画面のマイクロポストをテストする

プロフィール画面で表示されるマイクロポストに対して、
統合テストを書いていきます。まずは、プロフィール画面用の統合テストを生成してみましょう。

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ファイル内の対応するユーザーを探し出して、
(もし見つかれば) マイクロポストに関連付けてくれます。

マイクロポストのページネーションをテストするためには、
マイクロポスト用のfixtureにいくつかテストデータを追加する必要が
ありますが、これはリスト 10.47でユーザーを追加したときと同様に、埋め込みRubyを使うと簡単です。

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

ユーザーと関連付けされたマイクロポストのfixture
test/fixtures/microposts.yml

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 %>

Userプロフィール画面に対するテストgreen

test/integration/users_profile_test.rb

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にはそのページの完全な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タグがあるかどうかをチェックできます。

green
rails test

演習

1:2つの’h1’のテストが正しいか確かめるため、
該当するアプリケーション側のコードをコメントアウトしてみましょう。greenからredに変わることを確認してみてください。

Expected at least 1 element matching "h1", found 0..
Expected 0 to be >= 1.
REDになる

2:テストを変更して、will_paginateが1度のみ表示されていることを
テストしてみましょう。ヒント: 表 5.2を参考にしてください。

assert_select 'div.pagination', count:1

13.3に続く

Rails-tutorialのまとめ(第13章ユーザーのマイクロポスト 主に演習)

その12から続く

第13章ユーザーのマイクロポスト

ユーザーというリソースだけが、
Active Recordによってデータベース上のテーブルと紐付いています。
全ての準備が整った今、
ユーザーが短いメッセージを投稿できるようにするためのリソース「マイクロポスト」を追加していきます。

Userモデルとhas_manyおよびbelongs_toメソッドを使って関連付けを行い、さらに、結果を処理し表示するために必要なフォームとその部品を作成します

13.1 Micropostモデル

この新しいMicropostモデルもデータ検証とUserモデルの関連付けを含んでいます。
以前のモデルとは違って、
今回のマイクロポストモデルは完全にテストされ、
デフォルトの順序を持ち、また親であるユーザーが破棄された場合には自動的に破棄されるようにします。

13.1.1 基本的なモデル

Micropostモデルは、マイクロポストの内容を保存するcontent属性と、特定のユーザーとマイクロポストを関連付けるuser_id属性の2つの属性だけを持ちます。

マイクロポストの投稿にString型ではなくText型を使っている。
Text型の方が表現豊かなマイクロポストを実現できるから
Text型の方が将来における柔軟性に富んでいて
Text用のテキストエリアを使うため、
より自然な投稿フォームが実現できます。

rails generate model Micropost content:text user:references

ApplicationRecordを継承したモデルが作られます。
ただし、今回は生成されたモデルの中に、
ユーザーと1対1の関係であることを表す。
belongs_toのコードも追加されています。
user:referencesという引数も含めていたからです。

このgenerateコマンドはmicropostsテーブルを作成するための
マイグレーションファイルを生成します。

Userモデルとの最大の違いはreferences型を利用している点です。
これを利用すると、自動的にインデックスと外部キー参照付きのuser_idカラムが追加され、
UserとMicropostを関連付けする下準備をしてくれます。

インデックスが付与されたMicropostのマイグレーション
db/migrate/[timestamp]_create_microposts.rb

  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

続きを読む

Rails-tutorialのまとめ12.3(パスワードの再設定をテストする 主に演習)

12章から続く

12.3.3 パスワードの再設定をテストする

パスワード再設定をテストする手順は、アカウント有効化のテストと多くの共通点がありますが、
テストの冒頭部分には次のような違いがあります。
最初に「forgot password」フォームを表示して無効なメールアドレスを送信し、
次はそのフォームで有効なメールアドレスを送信します。
後者ではパスワード再設定用トークンが作成され、再設定用メールが送信されます。

続いて、メールのリンクを開いて無効な情報を送信し、

次にそのリンクから有効な情報を送信して、それぞれが期待どおりに動作することを
確認します。作成したテストを示します。
このテストはコードリーディングのよい練習台になりますよ。

パスワード再設定の統合テスト
test/integration/password_resets_test.rb

require 'test_helper'

class PasswordResetsTest < ActionDispatch::IntegrationTest

def setup
  ActionMailer::Base.deliveries.clear
@user = users(:michael)
end

test "password resets" do
 get new_password_reset_path
 assert_template 'password_resets/new'
 # メールアドレスが無効
 post password_resets_path, params: { password_reset: { email: "" } }
 assert_not flash.empty?
 assert_template 'password_resets/new'
# メールアドレスが有効
 post password_resets_path,
 params: { password_reset: { email: @user.email } }
 assert_not_equal @user.reset_digest, @user.reload.reset_digest
 assert_equal 1, ActionMailer::Base.deliveries.size
 assert_not flash.empty?
 assert_redirected_to root_url
# パスワード再設定フォームのテスト
 user = assigns(:user)
# メールアドレスが無効
 get edit_password_reset_path(user.reset_token, email: "")
 assert_redirected_to root_url
# 無効なユーザー
 user.toggle!(:activated)
 get edit_password_reset_path(user.reset_token, email: user.email)
 assert_redirected_to root_url
 user.toggle!(:activated)
# メールアドレスが有効で、トークンが無効
 get edit_password_reset_path('wrong token', email: user.email)
 assert_redirected_to root_url
# メールアドレスもトークンも有効
 get edit_password_reset_path(user.reset_token, email: user.email)
 assert_template 'password_resets/edit'
 assert_select "input[name=email][type=hidden][value=?]", user.email
# 無効なパスワードとパスワード確認
 patch password_reset_path(user.reset_token),
 params: { email: user.email,
 user: { password: "foobaz",
 password_confirmation: "barquux" } }
 assert_select 'div#error_explanation'
# パスワードが空
 patch password_reset_path(user.reset_token),
 params: { email: user.email,
 user: { password: "",
 password_confirmation: "" } }
 assert_select 'div#error_explanation'
# 有効なパスワードとパスワード確認
 patch password_reset_path(user.reset_token),
 params: { email: user.email,
 user: { password: "foobaz",
 password_confirmation: "foobaz" } }
 assert is_logged_in?
 assert_not flash.empty?
 assert_redirected_to user
 end
end

assert_select “input[name=email][type=hidden][value=?]”
, user.email

上のコードは、inputタグに正しい名前、type=”hidden”、
メールアドレスがあるかどうかを確認します。

green
rails test

演習

1:create_reset_digestメソッドはupdate_attributeを
2回呼び出していますが、これは各行で1回ずつデータベースへ問い合わせしていることになります。

テンプレートを使って、update_attributeの呼び出しを
1回のupdate_columns呼び出しにまとめてみましょう。
(これでデータベースへの問い合わせが1回で済むようになります)。
また、変更後にテストを実行し、 greenになることも確認してください。

user.rb
(前略)
  # パスワード再設定の属性を設定する
  def create_reset_digest
    self.reset_token = User.new_token
    update_columns(reset_digest:  User.digest(reset_token), reset_sent_at: Time.zone.now)
  end

2:テンプレートを埋めて、期限切れのパスワード再設定で発生する分岐を統合テストで網羅してみましょう(response.bodyは、
そのページのHTML本文をすべて返すメソッドです)。
期限切れをテストする方法はいくつかありますが、
オススメした手法を使えば、
レスポンスの本文に「expired」という語があるかどうかでチェックできます(なお、大文字と小文字は区別されません)。

assert_match /FILL_IN/i, response.body
expired
password_resets_test.rb

(前略)
  test "expired token" do
    get new_password_reset_path
    post password_resets_path,
         params: { password_reset: { email: @user.email } }

    @user = assigns(:user)
    @user.update_attribute(:reset_sent_at, 3.hours.ago)
    patch password_reset_path(@user.reset_token),
          params: { email: @user.email,
                    user: { password:              "foobar",
                            password_confirmation: "foobar" } }
    assert_response :redirect
    follow_redirect!
    #追加
    assert_match "expired", response.body   
  end

3:2時間経ったらパスワードを再設定できなくする方針は、セキュリティ的に好ましいやり方でしょう。
しかし、もっと良くする方法はまだあります。
例えば、公共の (または共有された) コンピューターでパスワード再設定が行われた場合を考えてみてください。
仮にログアウトして離席したとしても、2時間以内であれば、そのコンピューターの履歴からパスワード再設定フォームを
表示させ、パスワードを更新してしまうことができてしまいます
(しかもそのままログイン機構まで突破されてしまいます!)。
この問題を解決するために、コードを追加し、
パスワードの再設定に成功したらダイジェストをnilになるように変更してみましょ。

パスワード再設定が成功したらダイジェストをnilにする
app/controllers/password_resets_controller.rb

@user.update_attribute(:reset_digest, nil)

4:1行追加し、1つ前の演習課題に対するテストを書いてみましょう。assert_nilメソッドとuser.reloadメソッドを組み合わせて、
reset_digest属性を直接テストしてみましょう。

password_resets_test.rb
require 'test_helper'
class PasswordResetsTest < ActionDispatch::IntegrationTest
(中略)
  test "password resets" do
(中略)
  assert_redirected_to user
  assert_nil user.reload['reset_digest']
end

12.4 本番環境でのメール送信 (再掲)

もし既に前章でセットアップを終わらせていたら 演習までスキップしてしまっても大丈夫です。

演習

1:production環境でユーザー登録を試してみましょう。ユーザー登録時に入力したメールアドレスにメールは届きましたか?

動作確認するだけ

2:メールを受信できたら、実際にメールをクリックしてアカウントを有効化してみましょう。また、Heroku上のログを調べてみて、有効化に関するログがどうなっているのか調べてみてください。
ヒント: ターミナルからheroku logsコマンドを実行してみましょう。

動作確認するだけ: ターミナルからheroku logsコマンドを実行。

3:アカウントを有効化できたら、今度はパスワードの再設定を試してみましょう。正しくパスワードの再設定ができたでしょうか?

動作確認するだけ

12.6 証明: 期限切れの比較

12.3では、パスワードの期限が切れたかどうかを調べるために、次の比較を行いました。

reset_sent_at < 2.hours.ago

リスト 12.17で説明したように、この式を「少ない」と解釈すると逆の意味になってしまいますので、「早い」と解釈してみてください 

最初に、期間を2つ定義します。Δtrをパスワード再設定メールを送信してからの期間、Δteをパスワード再設定の有効な期間 (例: 2時間) と定めます。

パスワードの再設定は、メールが送信された時刻から経過した期間が、有効期間よりも長くなった場合に「期限切れ」となります。これを次のように表します。

ここで、現在時刻 (訳注: 比較を行った時刻) をtN、パスワード再設定メールの送信時刻をtr、有効期間が切れる時刻 (例: 2時間経過後) をteと表すと、次の2つの関係式を得ることができます。


式 (12.2)式 (12.3)式 (12.1)に代入すると、次の結果が得られます。





両辺に−1をかけると、次の式が得られます。

式 (12.4)をRailsのコードに置き換え、値をte=2 時間前とすると、
password_reset_expired?メソッドと同じコードになります。

def password_reset_expired?
  reset_sent_at < 2.hours.ago
end

<記号を「〜より少ない」ではなく「〜より早い時刻」と解釈すれば、「パスワードの再設定は、現在より2時間以上前の時刻に行われた」という言明と一致します。

12章のまとめ

1:パスワードの再設定は Active Recordオブジェクトではないが、
セッションやアカウント有効化の場合と同様に、リソースでモデル化できる

2:Railsは、メール送信で扱うAction Mailerのアクションとビューを生成することができる

3:Action MailerではテキストメールとHTMLメールの両方を利用できる

4:メイラーアクションで定義したインスタンス変数は、
他のアクションやビューと同様、メイラーのビューから参照できる

5:パスワードを再設定させるために、生成したトークンを使って一意のURLを作る

6:より安全なパスワード再設定のために、ハッシュ化したトークン (ダイジェスト) を使う

7:メイラーのテストと統合テストは、どちらもUserメイラーの振舞いを確認するのに有用

8:SendGridを使うとproduction環境からメールを送信できる

その13に続く