LoginSignup
22
18

More than 3 years have passed since last update.

Ruby on Rails チュートリアル 第13章 メモ&演習 モデルの関連付け マイクロポストの表示 画像投稿機能の実装

Last updated at Posted at 2019-02-03

近況報告

エンジニア転職成功しました。YouTubeもはじめました。

前回の続き

著者略歴
著者:YUUKI
ポートフォリオサイト:Pooks
現在:RailsTutorial2周目

第13章 ユーザーのマイクロポスト 難易度 ★★★★★ 8時間

挫折しないRailsチュートリアルの進め方を先にお読みください↓↓

Railsチュートリアルで挫折しない3つのポイント

これまでに

- ユーザー
- セッション
- アカウント有効化
- パスワードリセット

という4つのリソースについて見てきた。

このうち、ユーザーというリソースだけが、Active RecordによってDB上のテーブルと紐づいている。

サンプルアプリのコア部分に関しての準備が整ったので、今回は
ユーザーが短いメッセージを投稿できるようにするマイクロポスト
を追加していく。

第2章ではScaffoldを使って簡易的なマイクロポスト投稿フォームに触れたが、
この章では

  • Micropostデータモデルを作成
  • Userモデルとhas_manyおよびbelongs_toメソッドを使って関連付けを行う
  • 結果を処理し表示するために必要なフォームとその部品
  • 画像のアップロード機能

を作成する。

13.1 Micropostモデル

まずはMicropostリソースの本質的な部分であるMicropostモデルを作成するところから始める。

2章で作成したモデルと同様に、この新しいMicropostモデルも
データ検証Userモデルの関連付けを含んでいる。

以前のモデルとは違い、今回のマイクロポストモデルは完全にテストされ、デフォルトの順序を待ち、また親であるユーザーが破棄された場合には自動的に破棄されるようにする。

Gitをバージョン管理に使っている場合は、いつものようにトピックブランチを作成しておく。

$ git checkout -b user-microposts

13.1.1 基本的なモデル

Micropostsモデルは、

  • マイクロポストの内容を保存するcontent属性
  • 特定のユーザーとマイクロポストを関連付けるuser_id属性

の2つの属性だけを持つ。

実行した結果のMicropostsモデルの構造

image.png

出典:図 13.1: Micropostデータモデル

マイクロポストの投稿にString型ではなく、Text型を使っている点に注目。

違いを簡単に説明すると

  • String型

255文字以内の文字列を格納するデータ型

  • Text型

制限なしの可変長文字列を格納できるデータ型

Text型の方が汎用性に富んでおり、Webサービスを国際化する時にも言語に応じて投稿の長さを調節することもできる。

さらに、Text型を使っていても本番環境でパフォーマンスの差は出ない。

これらのメリットから、今回はText型を採用する。

6章でUserモデルを生成した時と同様、g modelコマンドでMicropostモデルを生成する。

$ rails g model Microposts content:text user:references

このコマンドで6章の時と同様、ApplicationRecordを継承したモデルが作られる。

ただし、今回はuser:referencesという引数を含めているので、
ユーザーと1対1の関係であることを表すbelongs_toのコードも追加されている。

この部分に関しては後に詳解する。

microposts.rb
class Micropost < ApplicationRecord
  belongs_to :user
end

6章でDBにusersテーブルを作るマイグレーションを生成した時と同様に、
このgコマンドはmicropostsテーブルを作成するためのマイグレーションファイルを生成する。

Userモデルとの最大の違いはreferences型を利用している点。

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

Userモデルの時と同じで、Micropostモデルのマイグレーションファイルでもt.timestampsという行(マジックカラム)が自動的に生成されている。

これにより、created_at(作成日時)とupdate_at(更新日時)というカラムが追加される。

なお、created_atカラムは今後の実装を進めていく上で必要なカラムとなるので追加しておく。

[timestamp]_create_microposts.rb
class CreateMicroposts < ActiveRecord::Migration[5.1]
  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 ]                            # user_idとcreate_atカラムにインデックス(要素番号)を付与
  end
end

add_indexuser_idcreated_atカラムを指定することで、インデックスを付与することができる。

こうすると、
User_idに関連づけられた全てのマイクロポストを作成時刻の逆順で取り出しやすくなる。

add_index :microposts, [ :user_id, :created_at ] 

また、両方を1つの配列に含めている点にも注目。

こうすることで、Active Recordは、
両方のキーを同時に扱う複合キーインデックス(Multiple key Index)を作成する。

あとはマイグレーションを使ってDBを更新する。

$ rails db:migrate

演習

1:RailsコンソールでMicropost.newを実行し、インスタンスを変数micropostに代入。
その後、user_idに最初のユーザーidを、contentに"Lorem ipsum"をそれぞれ代入。

この時点で、micropostオブジェクトのマジックカラムには何が入っているか?

>> micropost = Micropost.new
=> #<Micropost id: nil, content: nil, user_id: nil, created_at: nil, updated_at: nil>
>> micropost.user_id = User.first.id
  User Load (0.2ms)  SELECT  "users".* FROM "users" ORDER BY "users"."id" ASC LIMIT ?  [["LIMIT", 1]]
=> 1
>> micropost.content = "Lorem ipsum"
=> "Lorem ipsum"
>> micropost.created_at
=> nil
>> micropost.updated_at
=> nil

2:先ほど作ったオブジェクトを使ってmicropost.user実行結果と、micropost.user.nameを実行した場合の結果をみる

>> micropost.user
  User Load (0.2ms)  SELECT  "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?  [["id", 1], ["LIMIT", 1]]
=> #<User id: 1, name: "YUUKI", email: "〇〇@gmail.com", created_at: "2019-01-27 11:45:56", updated_at: "2019-01-28 10:06:18", password_digest: "$2a$10$RtF8nSd22GLENhTmlBTkPeaqyPEs9E8v7f3UeR6UecG...", remember_digest: nil, admin: false, ¥: nil, activation_digest: "$2a$10$f3kgl8ZWwRuixqBNDohcb.qkPwxw1Pq2gocBSz116gM...", activated: true, activated_at: "2019-01-27 11:46:05", reset_digest: "$2a$10$cs997A4KK0w9hg0OOW3WA.6cv82z0gw06pZOzIX3x/p...", reset_sent_at: "2019-01-28 10:05:13">
>> micropost.user.name
=> "YUUKI"

micropostにidを付与することで、userとnameがきちんと紐づいている。

3:先ほど作ったmicropostオブジェクトをDBに保存して、もう一度マジックカラムの内容を調べてみる。

>> micropost.save
   (0.1ms)  begin transaction
  SQL (1.7ms)  INSERT INTO "microposts" ("content", "user_id", "created_at", "updated_at") VALUES (?, ?, ?, ?)  [["content", "Lorem ipsum"], ["user_id", 1], ["created_at", "2019-01-29 07:36:23.181786"], ["updated_at", "2019-01-29 07:36:23.181786"]]
   (7.9ms)  commit transaction
=> true
>> micropost.created_at
=> Tue, 29 Jan 2019 07:36:23 UTC +00:00
>> micropost.updated_at
=> Tue, 29 Jan 2019 07:36:23 UTC +00:00

13.1.2 Micropostのバリデーション

基本的なモデルを作成したので、次に要求される制限を実現するためのバリデーションを追加する。

Micropostモデルを作成した時に、マイクロポストは投稿したユーザーのid(user_id)を持たせるようにした。

これを使って、慣習的に正しくActive Recordの関連付けを実装していく。

まずは、Micropostモデル単体をテスト駆動開発で動くようにする。

Micropostの初期テストはUserモデルの初期テストと似ている。

そのステップは

  • setupのステップで、fixtureのサンプルユーザーと紐付けた新しいマイクロポストを作成
  • 作成したマイクロポストが有効かどうかチェック
  • あらゆるマイクロポストはユーザーのidを持っているべきなので、user_idの存在性のバリデーションに対するテスト

このステップで追加する。

micropost_test.rb
require 'test_helper'

class MicropostTest < ActiveSupport::TestCase
  def setup
    @user = users(:michael)
    @micropost = Micropost.new(content: "Lorem ipsum", user_id: @user.id)
  end

  test "should be valid" do
    assert @micropost.valid?
  end

  test "user id should be present" do
    @micropost.user_id = nil
    assert_not @micropost.valid?
  end
end

setupメソッドの中の、マイクロポストを作成するコードは動くが、慣習的には正しくない。

元々あるUserモデルのテストと同じで、
1つ目のテストでは、正常な状態かどうかをテストしている。

2つ目のテストでは、user_idが存在しているかどうか(nilではないかどうか)をテストしている。

このテストをパスさせる為に、存在性のバリデーションを追加する。

micropost.rb
class Micropost < ActiveRecord::Base
  belongs_to :user
  validates :user_id, presence: true

Rails5では上記のバリデーションを追加していなくてもテストが成功する。
しかし、これは上記のmicropost_newのコードを書いた場合のみ発生する。

この部分を、慣習的に正しいコードを書くと、user_idに対する存在性のバリデーションが期待通りに動く。

以上の背景を踏まえると、今の所テストは成功する。

$ rails test:models

次に、マイクロポストのcontent属性に対するバリデーションを追加する

  • content属性が存在する
  • 140文字より長くならない

このような制限を加える。

6章でUserモデルにバリデーションを追加した時と同様に、
テスト駆動開発でMicropostモデルのバリデーションを追加していく。

基本的には、Userモデルのときと同じようなバリデーションを追加していく。

micropost_test.rb
  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

マイクロポストの長さをテストする為に、実際にコンソールで文字列の乗算を使う。

>> "a" * 10                                                                                                                                                     
=> "aaaaaaaaaa"
>> "a" * 141
=> "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"

これに対応するアプリケーション側の実装は、Userのname用バリデーションと全く同じ。

micropost.rb
class Micropost < ApplicationRecord
  belongs_to :user
  validates :user_id, presence: true
  validates :content, presence: true, length: { maximum: 140 }
end

これで、テストはパスする。

15 tests, 19 assertions, 0 failures, 0 errors, 0 skips

演習

1:Railsコンソールでuser_idとcontentが空になっているmicropostオブジェクトを作ってみる。
このオブジェクトに対してvalid?を実行すると、失敗することを確認。
生成されたエラーメッセージにはどんな内容が書かれているか?

>> micropost = Micropost.new(user_id: "",content: "")                                                                                                           
=> #<Micropost id: nil, content: "", user_id: nil, created_at: nil, updated_at: nil>
>> micropost.valid?
=> false
>> micropost.errors.full_messages
=> ["User must exist", "User can't be blank", "Content can't be blank"]
>> 

2:コンソールを開き、今度はuser_idが空でcontentが141文字以上のmicropostオブジェクトを作ってみる。
このオブジェクトに対してvalid?を実行すると、失敗することを確認。
エラーメッセージについても確認する。

>> micropost = Micropost.new(user_id: "",content: "a" * 141)
=> #<Micropost id: nil, content: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa...", user_id: nil, created_at: nil, updated_at: nil>
>> micropost.valid?
=> false
>> micropost.errors.full_messages
=> ["User must exist", "User can't be blank", "Content is too long (maximum is 140 characters)"]

13.1.3 User/Micorpostの関連付け

Webアプリケーション用のデータモデルを構築するにあたって、個々のモデル間での関連付けを十分に考えておく必要がある。

今回の場合は、

①それぞれのマイクロポストは1人のユーザーと関連付けられ、
②それぞれのユーザーは複数のマイクロポストと関連付けられる。

つまり

ユーザー(1)(user) 対 マイクロポスト(1)(micropost.user)
ユーザー(1)(user) 対 マイクロポスト(多)(user.microposts)

の関連付けとなっている。

この関連付けを実装するための一環として、Micropostモデルに対するテストを作成し、さらにUserモデルにいくつかのテストを追加する。

image.png

出典:図 13.2: MicropostとそのUserは belongs_to (1対1) の関係性がある

image.png

出典:図 13.3: UserとそのMicropostは has_many (1対多) の関係性がある

belongs_to/has_many関連付けを使うことで、下記の表のようなメソッドを使えるようになる。

スクリーンショット 2019-01-30 13.00.57.png

出典:表 13.1: user/micropost関連メソッドのまとめ

また、下記のメソッドではなく

Micropost.create
Micropost.create!
Micropost.new

このメソッドになっている点に注意。

user.microposts.create
user.microposts.create!
user.microposts.bulid

これらのメソッドを使うことで、紐付いているユーザーを通してマイクロポストを作成することができる。

新規のマイクロポストがこの方法で作成される場合、user_idは自動的に正しい値に設定される。
この方法を使うと、例えば次のような

@user = users(:michael)
# このコードは慣習的に正しくない
@micropost = Micropost.new(content: "Lorem ipsum", user_id: @user.id)

このような書き方を

@user = users(:michael)
@microposts = @user.microposts.bulid(content: "Lorem ipsum")

という書き方に変更できる。

newメソッドと同様に、bulidmメソッドはオブジェクトを返すがDBに反映されない。
一度正しい関連付けを定義してしまえば、@microposts変数のuser_idには、関連するユーザーのidが自動的に設定される。

@user.microposts.bulidのようなコードを使うためには、UserモデルとMicropostモデルをそれぞれ更新して、関連付ける必要がある。
Micropostモデルの方では、belongs_to:userというコードが必要になるが、これはマイグレーションによって自動的に生成されているはず。

一方、Userモデルの方では、has_many:micropostsと追加する必要がある。

ここは自動的に生成されないので、手動で追加する。

micropost.rb
class Micropost < ApplicationRecord
  belongs_to :user
  validates :user_id, presence: true
  validates :content, presence: true, length: { maximum: 140 }
end
user.rb
class User < ApplicationRecord
  # 関連付け
  has_many :microposts

正しく関連付けできたら、setupメソッドを修正して、慣習的に正しくマイクロポストを作成してみる。

micropost_test.rb
require 'test_helper'

class MicropostsTest < ActiveSupport::TestCase

  def seup
    @user = users(:michael)
    @micropost = @user.microposts.build(content: "Lorem ipsum")
  end

  test "should be valid" do
    @micropost.user_id = nil
    assert @micropost.valid?
  end

end

これでテストはパスする。

$ rails t

演習

1:DBにいる最初のユーザーを変数userに代入する。そのuserオブジェクトを使って
micropost = user.microposts.create(content: "Lorem ipsum")
を実行すると、どのような結果が得られるか?

>> user = User.first
  User Load (0.2ms)  SELECT  "users".* FROM "users" ORDER BY "users"."id" ASC LIMIT ?  [["LIMIT", 1]]
=> #<User id: 1, name: "YUUKI", email: "〇〇@gmail.com", created_at: "2019-01-27 11:45:56", updated_at: "2019-01-28 10:06:18", password_digest: "$2a$10$RtF8nSd22GLENhTmlBTkPeaqyPEs9E8v7f3UeR6UecG...", remember_digest: nil, admin: false, ¥: nil, activation_digest: "$2a$10$f3kgl8ZWwRuixqBNDohcb.qkPwxw1Pq2gocBSz116gM...", activated: true, activated_at: "2019-01-27 11:46:05", reset_digest: "$2a$10$cs997A4KK0w9hg0OOW3WA.6cv82z0gw06pZOzIX3x/p...", reset_sent_at: "2019-01-28 10:05:13">
>> micropost = user.microposts.create(content: "Lorem ipsum")
   (0.1ms)  begin transaction
  SQL (2.4ms)  INSERT INTO "microposts" ("content", "user_id", "created_at", "updated_at") VALUES (?, ?, ?, ?)  [["content", "Lorem ipsum"], ["user_id", 1], ["created_at", "2019-01-30 05:30:17.137853"], ["updated_at", "2019-01-30 05:30:17.137853"]]
   (8.3ms)  commit transaction
=> #<Micropost id: 2, content: "Lorem ipsum", user_id: 1, created_at: "2019-01-30 05:30:17", updated_at: "2019-01-30 05:30:17">

2:先ほどの演習問題で、DB上に新しいマイクロポストが追加された。
user.microposts.find(micropost.id)を実行して、本当に追加されたのかを確かめる。

また、先ほど実行したmicropost.idの部分をmicropostに変更すると、結果はどうなるか?

>> user.microposts.find(micropost.id)
  Micropost Load (0.2ms)  SELECT  "microposts".* FROM "microposts" WHERE "microposts"."user_id" = ? AND "microposts"."id" = ? LIMIT ?  [["user_id", 1], ["id", 2], ["LIMIT", 1]]
=> #<Micropost id: 2, content: "Lorem ipsum", user_id: 1, created_at: "2019-01-30 05:30:17", updated_at: "2019-01-30 05:30:17">

>> user.microposts.find(micropost)
ArgumentError: You are passing an instance of ActiveRecord::Base to `find`. Please pass the id of the object by calling `.id`.
        from (irb):4

3: user == micropost.userを実行した結果はどうなるか?
また、user.microposts.first == micropostを実行した結果はどうなるか?

>> user == micropost.user
=> true
>> user.microposts.first == micropost
  Micropost Load (0.2ms)  SELECT  "microposts".* FROM "microposts" WHERE "microposts"."user_id" = ? ORDER BY "microposts"."id" ASC LIMIT ?  [["user_id", 1], ["LIMIT", 1]]
=> false

13.1.4 マイクロポストを改良する

これからUserとMicropostの関連付けを改良していく

具体的には、

①ユーザーのマイクロポストを特定の順序で取得できるようにする
②マイクロポストをユーザーに依存させて、ユーザーが削除されたらマイクロポストも自動的に削除されるにする

デフォルトスコープとは?

user.micropostsメソッドはデフォルトでは読み出しの順序に対して何も保証しないが、
ブログやTwitterの慣習に従って、作成時間の逆順、
つまり最も新しいマイクロポストを最初に表示するようにする(降順)

これを実装する為に、default scopeというテクニックを使う。

この機能のテストは、アプリケーション側の実装が本当は間違っているのにテストが成功してしまう場合がある。

よって、正しいテスト駆動開発で進めていく。
具体的には、まずDB上の最初のマイクロポストが(Micropost.first)が、fixture内のマイクロポスト(most_recent)と同じであるか検証するテストを書く。

micropost_test.rb
  test "order should be most recent first" do
    assert_equal microposts(:most_recent), Micropost.first
  end

:most_recentエア、マイクロポスト用のfixtureファイルからサンプルデータを読み出しているので、次のfixtureファイルも必要になる。

microposts.yml
orange:
  content: "I just ate an orange!"
  created_at: <%= 10.minutes.ago %>

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

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

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

ここで、埋め込みRubyを使ってcreated_atカラムに明示的に値をセットしている点に注目。

このマジックカラムはRailsによって自動的に更新されるため、基本的には手動で更新することはできないが、
fixtureファイルの中ではそれが可能

また、原理的には必要はないかもしれませんが、ほとんどのシステムでは上から順に作成されるので、
fixtureファイルでも意図的に順序をいじっている。

昇順でデータが生成されるので、一番最後に生成されたmost_recent:
最も新しい投稿になるように修正する。

ただ、この振る舞いはシステムに依存していて崩れやすいので、この振る舞いに依存したテストは書くべきではない。

$ rails test test/models/micropost_rest.rb

このテストでは現在は失敗する。

テストを成功させる方法として、Railsのdefault_scopeメソッドを使ってこのテストを成功させる。
このメソッドは、DBから要素を取得したときのデフォルトの順序を指定するメソッド

特定の順序にしたい場合は、default_scopeの引数にorderを与える

例えば、created_atカラムの順にしたい場合

order(:created_at)

このように書く。
ただし、デフォルトの順序が昇順となっているので、
このままでは数の小さい値から大きい値にソートされてしまう。

順序を逆にしたい場合は、次のように生のSQLを引数に与える必要がある。

order('created_at DESC')

DESCとは、SQLの降順(descending)を指す。
したがって、これで新しい投稿から古い投稿順(降順)に並ぶ。

このように書くことも可能

order(created_at: :desc)

このコードを使ってMicropostモデルを更新する。

micropost.rb
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

これでテストはパスする。

5 tests, 5 assertions, 0 failures, 0 errors, 0 skips

ちなみに、->はラムダ式と呼ばれる文法を使っているので、呼び出し時に降順にソートされる。
(単語集参照)

Dependent: destroy

マイクロポストに第二の要素を追加してみる。
サイト管理者はユーザーを破棄する権限があるが、ユーザーが破棄された場合、
ユーザーのマイクロポストも破棄されるようにする必要がある。

この振る舞いを実現させるため、
has_manyにオブションとしてdependent: :destroyを渡す。

user.rb
class User < ApplicationRecord
  # 関連付け
  has_many :microposts, dependent: :destroy

これでユーザーが削除された時に、そのユーザーに紐付いたマイクロポストも一緒に削除されるようになる。

管理者がシステムからユーザーを削除した時、マイクロポストだけがDBに取り残される問題を防ぐ。

次に上記の振る舞いが正しく動くかどうか、テストを使って検証する。

このテストでは

①idを紐づけるためのユーザーを作成する
②そのユーザーに紐付いたマイクロポストを作成
③ユーザーを削除
④マイクロポストの数が1つ減っているかどうか

この順でテストする。

user_test.rb
  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

Userモデルが正しく動いていればテストはパスする。

12 tests, 16 assertions, 0 failures, 0 errors, 0 skips

演習

1:Micropost.first.created_atの実行結果と、Micropost.last.created_atの実行結果を比べみる。

>> Micropost.first.created_at
  Micropost Load (0.2ms)  SELECT  "microposts".* FROM "microposts" ORDER BY "microposts"."id" ASC LIMIT ?  [["LIMIT", 1]]
=> Tue, 29 Jan 2019 07:36:23 UTC +00:00
>> Micropost.last.created_at
  Micropost Load (0.2ms)  SELECT  "microposts".* FROM "microposts" ORDER BY "microposts"."id" DESC LIMIT ?  [["LIMIT", 1]]
=> Wed, 30 Jan 2019 11:45:01 UTC +00:00
>> 

最初に更新した日付と最後に更新した日付が出ている。

2:Micropost.firstを実行した時に発行されるSQL文はどうなっているか?
また、Micropost.lastの場合はどうなっているか?
 
```ruby

Micropost.first
Micropost Load (0.1ms) SELECT "microposts".* FROM "microposts" ORDER BY "microposts"."id" ASC LIMIT ? [["LIMIT", 1]]
=> #

Micropost Load (0.2ms) SELECT "microposts".* FROM "microposts" ORDER BY "microposts"."id" DESC LIMIT ? [["LIMIT", 1]]
=> #
```

firstの時にASC(昇順)で取り出して、lastの時はDESC(降順)で取り出している。

3:DB上に最初のユーザーを変数userに代入する。
そのuserオブジェクトが最初に投稿したマイクロポストのidはいくつか?
次のdestroyメソッドを使ってそのオブジェクトを削除してみる。

>> user = User.first
  User Load (0.1ms)  SELECT  "users".* FROM "users" ORDER BY "users"."id" ASC LIMIT ?  [["LIMIT", 1]]
=> #<User id: 1, name: "YUUKI", email: "yuukitetsuyanet@gmail.com", created_at: "2019-01-27 11:45:56", updated_at: "2019-01-28 10:06:18", password_digest: "$2a$10$RtF8nSd22GLENhTmlBTkPeaqyPEs9E8v7f3UeR6UecG...", remember_digest: nil, admin: false, ¥: nil, activation_digest: "$2a$10$f3kgl8ZWwRuixqBNDohcb.qkPwxw1Pq2gocBSz116gM...", activated: true, activated_at: "2019-01-27 11:46:05", reset_digest: "$2a$10$cs997A4KK0w9hg0OOW3WA.6cv82z0gw06pZOzIX3x/p...", reset_sent_at: "2019-01-28 10:05:13">
>> user.microposts.first
  Micropost Load (0.1ms)  SELECT  "microposts".* FROM "microposts" WHERE "microposts"."user_id" = ? ORDER BY "microposts"."id" ASC LIMIT ?  [["user_id", 1], ["LIMIT", 1]]
=> #<Micropost id: 1, content: "Lorem ipsum", user_id: 1, created_at: "2019-01-29 07:36:23", updated_at: "2019-01-29 07:36:23">

>> user_f = user.microposts.first
  Micropost Load (0.1ms)  SELECT  "microposts".* FROM "microposts" WHERE "microposts"."user_id" = ? ORDER BY "microposts"."id" ASC LIMIT ?  [["user_id", 1], ["LIMIT", 1]]
=> #<Micropost id: 1, content: "Lorem ipsum", user_id: 1, created_at: "2019-01-29 07:36:23", updated_at: "2019-01-29 07:36:23">
>> user_f.destroy
   (0.1ms)  begin transaction
  SQL (2.2ms)  DELETE FROM "microposts" WHERE "microposts"."id" = ?  [["id", 1]]
   (6.1ms)  commit transaction
=> #<Micropost id: 1, content: "Lorem ipsum", user_id: 1, created_at: "2019-01-29 07:36:23", updated_at: "2019-01-29 07:36:23">

>> Micropost.find(1)
  Micropost Load (0.2ms)  SELECT  "microposts".* FROM "microposts" WHERE "microposts"."id" = ? LIMIT ?  [["id", 1], ["LIMIT", 1]]
ActiveRecord::RecordNotFound: Couldn't find Micropost with 'id'=1
        from (irb):37

13.2 マイクロポストを表示する

Web経由でマイクロポストを作成する方法は現時点ではない。
しかし、マイクロポストを表示することと、テストすることならできる。

ここでは、Twitterのような独立したマイクロポストを表示する。

以下のモックアップのように、ユーザーshowページで直接マイクロポストを表示させることになる。

ユーザープロフィールにマイクロポストを表示させるため、最初はERbテンプレートを作成する。
次に、サンプルデータ生成タスクにマイクロポストのサンプルを追加して、画面にサンプルデータが表示されるようにする。

image.png

出典:図 13.4: マイクロポストが表示されたプロフィールページのモックアップ

13.2.1 マイクロポストの描画

ユーザーのプロフィールの画面(show.html.erb)で

  • そのユーザーのマイクロポストを表示させたり、
  • これまでに投稿した総数も表示させたり

する。

この実装は10章ユーザーを表示する部分と似ている。

演習で既にマイクロポストをいくつか作成しているので、DBをリセットし、サンプルデーターを再生成する。

$ rails g controller Microposts

今回の目的は、ユーザーごとに全てのマイクロポストを描画できるようにすること。

indexビューでは

index.html.erb
<ul class="users">
  <%= render @users %>
</ul>

Railsは@usersをUserオブジェクトのリストであると推測する(index.html)

indexでは、_user.html.erbパーシャルを使って自動的に@users変数内のそれぞれのユーザーを出力していた。

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

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

micropostでは、順序無しリストのulタグではなく、順序付きリストのolタグを使う

これは、マイクロポストが特定の順序(降順)に依存しているため。

次に、対応するパーシャルを書く。

_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 classs="timestamp">
    Posted <%= time_ago_in_words(micropost.created_at ) %> ago.
  </span>
</li>

ここでtime_ago_in_wordsというヘルパーメソッドを使っている。

*これは、「何分前に投稿したか」という情報を文字列で出力するメソッド。:

また、各マイクロポストに対してCSSのidを割り振っている。

<li id="micropost"-<%= micropost.id %>

これは一般的に良いとされる慣習だそう。
例えば、将来JavaScriptを使って各マイクロポストを操作したくなった時などに役立つ。

次は、一度に全てのマイクロポストが表示されてしまう潜在的問題に対処する。
10章ではページネーションを使ったが、今回も同じ方法でこの問題を解決する。

前回同様、will_paginateメソッドを使うと次のようになる。

<%= will_paginate @microposts %>

ユーザー一覧画面のコードと比較すると、少し違った。
以前は次のように単純なコードだった。

<%= will_paginate %>

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

@usersインスタンス変数は、10賞でも述べたようにActiveRecord::Relationクラスのインスタンス。

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

したがって、そのようなインスタンス変数をUsersコントローラのshowアクションで定義しなければならない。

users_controller.rb
  def show
    @user = User.find(params[:id])                                              # paramsで:idパラメータを受け取る(/users/1にアクセスしたら1を受け取る)
    @microposts = @user.microposts.paginate(page: params[:page])                # pageを受け取ってmicropostsに渡して表示する。
    redirect_to root_url and return unless @user.activated?                     # activatedがfalseならルートURLヘリダイレクト
  end

paginateメソッドはマイクロポストの関連付けを経由してmicropostテーブルに到達し、必要なマイクロポストのページを引き出してくれる。

最後にマイクロポストの投稿数を表示するため、countメソッドを使う。

user.microposts.count

paginateと同様に、関連付けを通してcountメソッドを呼び出すことができる。

countメソッドではDB上のマイクロポストを全部読みだしてから結果の配列に対してlengthを呼ぶ、
と言った無駄な処理はしていない。

↑のような処理だとマイクロポストの数が増加するにつれて効率が低下してしまう。

そうではなく、DB内での計算は高度に最適化されているので、DBに代わりに計算してもらい、特定のuser_idに紐付いたマイクロポストの数をDBに問い合わせている。

コレで全ての要素が揃ったので、プロフィール画面にマイクロポストを表示させてみる。

show.html.erb
<% provide(:title, @user.name ) %> <!-- @user.nameを:titleに格納-->
<div class="row">
    <aside class="col-md-4">
        <section class="user_info">
            <h1>
                <%= gravatar_for @user %>                                       <!-- gravatar_forというヘルパーメソッドでユーザー(user)の画像を表示、@userで表示しているビューのユーザー情報を表示 -->
                <%= @user.name %>                                               <!-- 表示しているビューのユーザーの名前(nmae)を表示-->
            </h1>
        </section>
    </aside>
    <div class="col-md-8">
        <% if @user.microposts.any? %>                                         <!-- マイクロポストが空じゃなければ(1つでもあれば)処理を行う-->
        <h3>Microposts (<%= @user.microposts.count %>)</h3>
        <ol class="microposts">
            <%= render @microposts %>                                           <!-- @micropostsページネーションを読み出し-->
        </ol>
        <%= will_paginate @microposts %>
        <% end %>
    </div>
</div>

このとき、同様にif @user.microposts.any?を使って、ユーザーのマイクロポストが1つもない場合には空のリストを表示させていない点にも注目。

ここで、改良した新しいプロフィール画面をブラウザで見てみる。

スクリーンショット 2019-01-31 18.25.24.png

マイクロポストがないため、if文の処理がfalseとなっている。

演習

1:今回ヘルパーメソッドとして使ったtime_ago_in_wordsメソッドは、Railsコンソールのhelperオブジェクトから呼び出すことができる。
このhelperオブジェクトのtime_ago_in_wordsメソッドを使って、3.weeks.ago6.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オブジェクトのクラスは何か?

>> user = User.find(1)
  User Load (0.7ms)  SELECT  "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?  [["id", 1], ["LIMIT", 1]]
=> #<User id: 1, name: "Example User", email: "example@railstutorial.org", created_at: "2019-01-30 15:05:31", updated_at: "2019-01-30 15:05:31", password_digest: "$2a$10$HCZFOhkJBAIs4/jSiVRaJuc.YW2B5vEBH42caw53axl...", remember_digest: nil, admin: true, activation_digest: "$2a$10$/aa5ShcsDBvnoPQNFk4XD./AAHe97RlhuZwXY.zDzMo...", activated: true, activated_at: "2019-01-30 15:05:31", reset_digest: nil, reset_sent_at: nil>
>> microposts = user.microposts.paginate(page: nil)
  Micropost Load (0.1ms)  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 gemLorem.sentenceという便利なメソッドがあるので、これを使う。

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

上記のコードで50.times.douser.eachを後に書いてるのには理由があって、というのもユーザー毎に50個分のマイクロポストを作成してしまうと、ステータスフィードに表示される投稿が全て同じユーザーになってしまい、視覚的な見栄えが悪くなってしまう。

ここで、いつものように開発環境用のDBで再度サンプルデータを生成する。

$ rails db:migrate:reset
$ rails db:seed

生成し終わったら、一度Railsサーバーを落として、再起動。

ここで、users_controller.rbshowアクションのredirect_to行を削除。

users_controller.rb
  def show
    @user = User.find(params[:id])                                              # paramsで:idパラメータを受け取る(/users/1にアクセスしたら1を受け取る)
    @microposts = @user.microposts.paginate(page: params[:page])                # pageを受け取ってmicropostsに渡して表示する。
  end

スクリーンショット 2019-01-31 20.30.25.png

マイクロポスト固有のスタイルが与えられていないので、CSSを適用させる。

custom.scss
/* micrposts */

.microposts {
  list-styles: 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;
  }
}

スクリーンショット 2019-01-31 20.53.16.png

スクリーンショット 2019-01-31 20.53.58.png

どちらのページでも下部にページネーションリンクを表示している。
1分前・・・などと表示されている部分は、tie_ago_in_wordsメソッドによるもの。

数分待ってからページを再度読み込むと、このテキストは自動的に新しい時間に基づいて更新される。

演習

1:(1..10).to_a.take(6)というコードの実行結果はどうなるか?

[1,2,3,4,5,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はlorem ipsum以外にも、多種多様の事例に対応している。

Fakerドキュメントを眺めながら画面に出力する方法を学び、実際に大学名や電話番号、Hipster IpsumやChuck Norris factsを画面に出力してみる。

>> Faker::Hipster.words
=> ["occupy", "organic", "venmo"]
>> Faker::ChuckNorris.fact
=> "When Chuck Norris presses Ctrl+Alt+Delete, worldwide computer restart is initiated."
>> Faker::University.name
=> "Western Durgan"
>> Faker::PhoneNumber.phone_number
=> "200.580.1438 x69164"
>> 

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

アカウントを有効化したばかりのユーザーはプロフィール画面にリダイレクトされるので、
そのプロフィール画面が正しく描画されてることは、単体テストを通して確認済み。

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

$ rails g integration_test users_profile
Running via Spring preloader in process 18157
      invoke  test_unit
      create    test/integration/users_profile_test.rb

プロフィール画面におけるマイクロポストをテストするためには、ユーザーに紐付いたマイクロポストのテスト用データが必要になる。

Railsの慣習に従って、関連付けされたテストデータをfixtureファイルに追加すると、次のようになる。

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

userにmichaelという値を渡すと、Railsはfixtureファイル内の対応するユーザーを探し出して、もし見つかればマイクロポストに関連付けてくれる。

michael:
  name: Michael Example
  email: michael@example.com

また、マイクロポストのページネーションをテストするためには、マイクロポスト用のfixtureにいくつかテストデータを追加する必要があるが、コレは埋め込みRubyを使うと簡単。

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

コレらのコードを1つにまとめて、マイクロポスト用のfixtureファイル書き出す。

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/PKffm2ul4dk"
  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 %>

これでテストデータの準備は完了。

ここで、users_controller.rbのshowアクションを元に戻す。

users_controller.rb
  def show
    @user = User.find(params[:id])                                              # paramsで:idパラメータを受け取る(/users/1にアクセスしたら1を受け取る)
    @microposts = @user.microposts.paginate(page: params[:page])                # pageを受け取ってmicropostsに渡して表示する。
    redirect_to root_url and return unless @user.activated?                     # activatedがfalseならルートURLヘリダイレクト
  end

実際のテストでは、プロフィール画面にアクセスした後に

  • ページタイトル
  • ユーザー名
  • Gravatar
  • マイクロポストの投稿数
  • ページ分割されたマイクロポスト

といった順でテストしていく。

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

Applicationヘルパーを読み込んだことでfull_titleヘルパーが利用できている点に注目。

上記のテストコードでは、マイクロポストの投稿数をチェックするために、12章の演習で使ったresponse.bodyを使っている。
コレは、そのページの完全な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タグの内側にある、garavatarクラス付きのimgタグがあるかどうかチェックしている。

これでテストはパスする

52 tests, 277 assertions, 0 failures, 0 errors, 0 skips

演習

1:上記テストのh1のテストが正しいか確かめるため、コメントアウトして失敗するか確認。

show.html.erb
            <!--<h1>-->
                <%= gravatar_for @user %>                                       <!-- gravatar_forというヘルパーメソッドでユーザー(user)の画像を表示、@userで表示しているビューのユーザー情報を表示 -->
                <%= @user.name %>                                               <!-- 表示しているビューのユーザーの名前(nmae)を表示-->
            <!--</h1>-->
 FAIL["test_profile_display", UsersProfileTest, 0.9884029359998294]
 test_profile_display#UsersProfileTest (0.99s)
        Expected at least 1 element matching "h1", found 0..
        Expected 0 to be >= 1.
        test/integration/users_profile_test.rb:14:in `block in <class:UsersProfileTest>'

  21/21: [=================================================================================================================] 100% Time: 00:00:01, Time: 00:00:01

Finished in 1.27827s
21 tests, 127 assertions, 1 failures, 0 errors, 0 skips

2:テストを変更して、will_paginateが一個のみ表示されていることをテストしてみる。

  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', count: 1
    @user.microposts.paginate(page: 1).each do |micropost|
      assert_match micropost.content, response.body
    end
  end
end

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

データモデリングとマイクロポスト表示テンプレートの両方が完成したので、
次はWeb経由でそれらを作成する為のインターフェイスに取り掛かる。

まずステータスフィードの開発に取り掛かり、最後にユーザーがマイクロポストをWeb経由で破棄できるようにする。

従来のRails開発と異なる点が一点ある。
Micropostsリソースへのインターフェイスは、主にプロフィールページとHomeページのコントローラを経由して使うので、new(新規作成)やedit(編集)のようなアクションは不要である。

つまり、create(ユーザー新規作成)、destroy(削除)アクションがあれば十分。

上記を考慮してMicropostsリソースは以下のように出来上がる。

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'
  post    '/signup',  to: 'users#create'
  get     '/login',   to: 'sessions#new'
  post    '/login',   to: 'sessions#create'
  delete  '/logout',  to: 'sessions#destroy'
  resources :users                                                              # usersリソースをRESTfullな構造にするためのコード。
  resources :account_activations, only: [:edit]                                 # editアクションのみaccount_activationsリソースを適用
  resources :password_resets,     only: [:new, :create, :edit, :update]         # password再設定用のリソースを適用
  resources :microposts,          only: [:create, :destroy]                     # micropostsリソースをcreateとdestroyアクションにのみ適用
end

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

Micropostsリソースの開発では、Micropostsコントローラ内のアクセス制御から始めることにする。

関連付けられたユーザーを通してマイクロポストにアクセスするので、createアクションやdestroyアクションを利用するユーザーは、ログイン済みでなければならない。

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

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

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コントローラに、logged_in_user(ユーザーログインの確認)メソッドを移して、
共通でbeforeフィルターを使えるようにする。

application_controller.rb
class ApplicationController < ActionController::Base
  protect_from_forgery with: :exception
  include SessionsHelper                                                        #SessionHelper(メソッドの集合体)を全コントローラに適用

  private

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

users_controller.rbから、logged_in_userを削除する。

users_controller.rb
class UsersController < ApplicationController
  before_action :logged_in_user, only: [:index, :edit, :update, :destroy]       # logged_in_userメソッドを適用

  private                                                                       # 外部から使えない(Usersコントローラ内のみ)部分

    def user_params                                                             # paramsハッシュの中を指定する。(requireで:user属性を必須、permitで各属性の値が入ってないとparamsで受け取れないよう指定)
      params.require(:user).permit(:name, :email, :password,
                                  :password_confirmation)
    end

    # beforeアクション

アクションを移したことにより、Micropostsコントローラからもlogged_in_userメソッドを呼び出せるようになった。

これにより、createアクションやdestroyアクションにログインしていないと処理を実行できないアクセス制限を簡単に掛けるようになった。

microposts_controller.rb
class MicropostsController < ApplicationController
  before_action :logged_in_user, only: [:create, :destroy]                      # ログインユーザーのみ作成したり削除できるよう適用

  def create

  end

  def destroy

  end
end

これでテストはパスする。

54 tests, 281 assertions, 0 failures, 0 errors, 0 skips

演習

1:なぜUsersコントローラ内にあるlogged_in_userフィルターは残したままにするとマズイのか?

無駄なコードが増えたり、エラーの原因になるから。

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

7章では、HTTP POSTリクエストをUsersコントローラのcreateアクションに発行するHTMLフォームを作成することで、ユーザーのサインアップを実装した。

マイクロポスト作成の実装もこれと似ていて、
主な違いは別のmicropost/newページを使う代わりに、ホーム画面(home.html.erb)にフォームを置くという点。

image.png

出典:図 13.10: マイクロポスト作成フォームのあるホーム画面のモックアップ

最後にホーム画面を実装したときは、[sign up now!]ボタンが中央にあった。

要は、ユーザーがログインしている場合のみ、マイクロポスト作成フォームを表示させるようにする。
(ホーム画面の表示を、ログイン状態と日ログイン状態で変わるよう実装する。)

その前に、まずはマイクロポストのcreateアクションを作り始める。

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

microposts_controller.rb
class MicropostsController < ApplicationController
  before_action :logged_in_user, only: [:create, :destroy]                      # ログインユーザーのみ作成したり削除できるよう適用

  def create
    @micropost = current_user.microposts.build(micropost_params)                # formで送った値をmicropost_paramsで受け取り、親モデルと関連付け(bulid)してcurrent_userに渡して代入
    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

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

次に、フォームを作成する。

マイクロポスト作成フォームを構築するために、
サイト訪問者がログインしているかどうかに応じて異なるHTMLを提供するコードを使う。

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

<div class="center jumbotron">
  <h1>YUUKIのポートフォリオサイトへようこそ</h1>

  <h2>
    このサイトは
    <a href="https://twitter.com/bitcoinjpnnet">YUUKI</a>
    のポートフォリオサイトです
  </h2>

  <%= link_to "Sign up now!", signup_path, class: "btn btn-lg btn-primary" %>
</div>

<%= link_to image_tag("yuuki.png", alt: "Rails logo"),
            'https://twitter.com/bitcoinjpnnet', target: "_blank" %>

上記コードで条件式でログインしている場合のみ、

  • ユーザー情報(shared/user_info)
  • マイクロポストフォーム(shared/micropost_form)

をパーシャルとして描画させる。

この2つのパーシャルはまだ書いていないので、まずはuser_infoから書いていく。

_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>       <!-- カウントした数によってmicropostのスペルを変更する -->

プロフィールサイドバーのときと同様、
ユーザー情報にもユーザーが投稿したマイクロポストの総数が表示されていることにも注目。

プロフィールサイドバーでは、1 micropost2 micropostsというように、
ユーザーの投稿数に合わせてスペルを追従させたいので、pluralizeメソッドを使って調整している。

次はマイクロポスト作成フォームを定義する。
これはユーザー登録フォームに似ている。

_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を定義する

@micropost = current_user.microposts.bulid

上記コードをコントローラに埋め込む。

static_pages_controller.rb
class StaticPagesController < ApplicationController

  def home
    @micropost = current_user.microposts.build if logged_in?                    # ログインしていたらcurrent_userに紐付いたマイクロポストオブジェクトを生成し代入
  end

もちろんcurrent_userメソッドはユーザーがログインしている時しか使えない。
したがって、@micropost変数もログインしているとき定義されるようになる。

さらに、もう1つの変更はエラーメッセージのパーシャルを再定義すること

でなければ

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

このコードが動かない。

7章ではエラーメッセージパーシャルが@user変数を直接参照していた。
今回は代わりに@microposts変数を使う必要がある。

これらのケースをまとめると、フォーム変数fをf.objectとすることによって、関連付けられたオブジェクトにアクセスすることができる。したがって、

form_for(@user) do |f|

上記のように、f.objectが@userを引数に取る場合と

form_for(@micropost) do |f|

f.objectが@micropostになる場合がある。

パーシャルにオブジェクトを渡すために、
値がオブジェクトで、キーがパーシャルでの変数名と同じハッシュを利用する。

これで、error_messagesパーシャルの2行目のコードが完成する。

object: f.objecterror_messagesパーシャルの中で
objectという変数名を作成してくれるので、この変数を使ってエラーメッセージを更新すればよいということ。

簡単にいうと、エラーメッセージ用の変数をobject変数に統一し、renderの読み出し時にobjectがエラーであれば(送信出来なければ)、全てをobject: f.objectでエラーを表示させる。

_error_messages.html.erb
<% if object.errors.any? %>                                                      <!-- オブジェクトのエラーメッセージが空だった(エラーを起こしていない)場合true -->
  <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| %>                          <!-- エラーメッセージの配列をブロック変数msgに繰り返し代入 -->
      <li><%= msg %></li>                                                       <!-- ブロック変数msgを表示(エラー文の値を一つ一つ表示) -->
      <% end %>
    </ul>
  </div>
<% end %>

この時点でテストは失敗する。

54 tests, 267 assertions, 0 failures, 2 errors, 0 skips

これはerror_messagesパーシャルが

  • ユーザー登録
  • パスワード設定
  • ユーザー編集

それぞれのビューで使われていたため、これらで第二引数にobject: f.objectと更新する必要がある。

_form.html.erb
    <!--ボタンと画像を表示-->

    <div class="row">
      <div class="col-md-6 col-md-offset-3">
    <!-- formの送信先を指定 -->

    <%= form_for(@user, url: yield(:url)) do |f| %>                             <!-- 、fブロックに代入-->
      <%= render 'shared/error_messages', object: f.object %>                   <!-- エラーメッセージ用のパーシャルを表示 -->

    <!-- form作成-->
password_resets/edit.html.erb
<% provide(:title, 'Reset password') %>
<h1>Reset password</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 %>

new.html.erbusers/edit.html.erbではformパーシャルを使っていたので、
_form.html.erbpassword_resets/edit.html.erbの2ファイルを修正するだけでOK。

これでテストはパスする。

54 tests, 281 assertions, 0 failures, 0 errors, 0 skips

これで全てのHTMLが適切に表示されるようになったので、実際にフォームを見て確認する。

スクリーンショット 2019-02-01 23.28.44.png

スクリーンショット 2019-02-01 23.30.15.png

演習

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

home.html.erb
<% if logged_in? %>
  <%= render 'static_pages/if' %>
<% else %>
  <%= render 'static_pages/else' %>
<% end %>
_if.html.erb
  <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.html.erb
<div class="center jumbotron">
  <h1>YUUKIのポートフォリオサイトへようこそ</h1>

  <h2>
    このサイトは
    <a href="https://twitter.com/bitcoinjpnnet">YUUKI</a>
    のポートフォリオサイトです
  </h2>

  <%= link_to "Sign up now!", signup_path, class: "btn btn-lg btn-primary" %>
</div>

<%= link_to image_tag("yuuki.png", alt: "Rails logo"),
            'https://twitter.com/bitcoinjpnnet', target: "_blank" %>

13.3.3 フィードの原型

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

実際に正しく動いているか確認するには、エントリーを投稿後にプロフィールページに移動してポストを確認すればよいが、これはかなり面倒な作業。

以下のモックアップのような、ユーザー自身のポストをHomeに載せた方が便利なので、これを実装する。

image.png

出典:図 13.13: 試作フィードがあるHomeページのモックアップ

全てのユーザーがフィードを持つので、feedメソッドはUserモデルで作るのが自然。
なお、次章で完全なフィードを実装するため、whereメソッドを使う。

まずは現在ログインしているユーザーのマイクロポストをすべて取得してくる。

user.rb
  def feed
    Micropost.where("user_id = ?", id)
  end

private

上の疑問符があることで、SQLクエリに代入する前にidがエスケープされるため、
SQLインジェクション攻撃の対象となるようなセキュリティホールを避けることができる。

この場合のid属性は単なる整数であるため危険はないが、
SQL文に変数を代入する場合は常にエスケープする習慣を身に付けておくべき。

また、本質的には

def feed
  microposts
end

このコードと同じだが(マイクロポストを表示する上では)
上のコードを使わなかったのは、14章で必要となる完全なステータスフィードで応用が効くため。

アプリケーションでフィードを使うために、以下の流れで実装する。

①現在のユーザーのページ分割されたフィードに@feed_itemsインスタンス変数を追加
②フィード用のパーシャルをHomeページに追加

Homeページに変更を加える時、ユーザーがログインしているかどうかを調べる後置if文を、前置if文に変更する。

@microopost = current_user.microposts.build if logged_in?

これを

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

1行で終わる場合は後置if文、2行以上の時は前置if文を使うのがRubyの慣習

static_pages_controller.rb
  def home
    if logged_in?
      @micropost  = current_user.microposts.build                               # current_userに紐付いたマイクロポストオブジェクトを生成し代入
      @feed_items = current_user.feed.paginate(page: params[:page])             # current_userに紐付いたポストをページネーション化して代入
    end
  end
_feed.html.erb
<% if @feed_items.any? %>
  <ol class="microposts">
    <%= render @feed_items %>
  </ol>
  <%= will_paginate @feed_items %>
<% end %>

上記がステータスフィードのパーシャル。
Micropostのパーシャルとは少し異なっている。

<%= render @feed_items %>

@feed_itemsの各要素がMicropostクラスを持っていたため、
RailsはMicropostのパーシャルを呼び出すことができる。

このように、
Railsは対応する名前のパーシャルを、渡されたリソースのディレクトリ内から探しにいくことができる。

app/views/microposts/_micropost.html.erb

結果的に上記ファイルのパーシャル(各ポスト投稿)を呼び出している。

あとは、_feedパーシャルをHomeページに組み込むことで、
フィードとして表示することができる。

先ほどifパーシャルを使ったので、こちらに組み込む。

_if.html.erb
  <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>

これでOK

スクリーンショット 2019-02-02 2.29.40.png

ただ、現時点ではマイクロポストの投稿が失敗すると、Homeページは@feed_itemsインスタンス変数を期待しているため、エラーとなる。
これを簡単に解決するため、@feed_itemsに空の配列を代入する。

microposts_controller.rb
  def create
    @micropost = current_user.microposts.build(micropost_params)                # formで送った値をmicropost_paramsで受け取り、親モデルと関連付け(bulid)してcurrent_userに渡して代入
    if @micropost.save
      flash[:success] = "Micropost created!"
      redirect_to root_url
    else
      @feed_items = []
      render 'static_pages/home'
    end
  end

演習

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

   (0.0ms)  begin transaction
  SQL (1.8ms)  INSERT INTO "microposts" ("content", "user_id", "created_at", "updated_at") VALUES (?, ?, ?, ?)  [["content", "こんにちは"], ["user_id", 1], ["created_at", "2019-02-01 18:41:52.457876"], ["updated_at", "2019-02-01 18:41:52.457876"]]
   (6.8ms)  commit transaction

2:コンソールを開き、user変数にDB上の最初のユーザーを代入。
その後、
Micropost.where("user_id = ?", user.id)
user.microposts
user.find

をそれぞれ実行してみて、実行結果が全て同じあることを確認。

>> user = User.first
>> user_where = Micropost.where("user_id = ?", user.id)
>> user_microposts = user.microposts
>> user_feed = user.feed
>> user_where == user_microposts
=> true
>> user_microposts == user_feed
=> true

全部同じ。

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

最後の機能として、マイクロポストリソースにポストを削除する機能を追加する。

これは、ユーザー削除と同様にdeleteリンクで実現する。

ユーザーの削除は管理者ユーザーのみが行えるよう制限されていたのに対し、
今回は自分が投稿したマイクロポストに対してのみ削除リンクが動作するようにする。

image.png

出典:図 13.16: マイクロポストの削除リンクと試作フィードのモックアップ

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

_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_controller.rb
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)                # formで送った値をmicropost_paramsで受け取り、親モデルと関連付け(bulid)してcurrent_userに渡して代入
    if @micropost.save
      flash[:success] = "Micropost created!"
      redirect_to root_url
    else
      @feed_items = []
      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)
    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変数と似ていて、1つ前のURLを返す。
(今回の場合はHomeページになる)

このため、マイクロポストがHomeページから削除された場合でもプロフィールページから削除された場合でも、
request.referrerを使うことでDELETEリクエストが発行されたページに戻すことができる。

元に戻すURLが見つからなかった場合でも、root_urlを||で指定しているので、エラーが出ずに/へと飛ぶ。

これらのコードにより、上から2番目のマイクロポストを削除してみると、エラーが出ずに削除できているのが分かる。

スクリーンショット 2019-02-02 14.28.01.png

演習

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

   (0.1ms)  begin transaction
  SQL (1.0ms)  DELETE FROM "microposts" WHERE "microposts"."id" = ?  [["id", 306]]
   (7.0ms)  commit transaction

2:redirect_to request.referrer || root_urlの行を
redirect_back(fallback_location: root_url)と置き換えてもうまく動くことをブラウザを使って確認する。

microposts_controller.rb
  def destroy
    @micropost.destroy
    flash[:success] = "Micropost deleted"
    redirect_back(fallback_location: root_url)
  end

確認済み。

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

13.3.4のコードで、Micropostモデルとそのインターフェースが完成した。

残っている箇所は

  • Micropostsコントローラの許可をチェックするテスト
  • それらをまとめる統合テスト

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

microposts.yml
ants:
  content: "yes yes,"
  created_at: <%= 2.years.ago %>
  user: archer

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

tone:
  content: "oh"
  created_at: <%= 10.minutes.ago %>
  user: lana

van:
  content: "Dude"
  created_at: <%= 4.hours.ago %>
  user: lana

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

microposts_controller_test.rb
  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 g integration_test microposts_interface
Running via Spring preloader in process 8155
      invoke  test_unit
      create    test/integration/microposts_interface_test.rb

実際の統合テストを書く。

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 tests, 10 assertions, 0 failures, 0 errors, 0 skips

演習

1:上記テストが正しく動いているか、それぞれ対応するコードをコメントアウトして失敗するか確認。

確認済み。

2:サイドバーにあるマイクロポストの合計投稿数をテストしてみる。

また、単数形ならmicropost複数形ならmicropostsが正しく表示されているかどうかもテストする。

microposts_interface_test.rb
  test "micropost sidebar count" do
    log_in_as(@user)
    get root_path
    assert_match "#{@user.microposts.count} 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 "1 micropost", response.body
  end

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

ここまででマイクロポストに関する基本的な操作は全て実装できた。

あとは応用として画像付きマイクロポストを投稿できるようにする。

その手順として

  • 開発環境用のβ版を実装
  • いくつかの改善をとおして本番環境用の完成版を実装

で行う。

画像アップロード機能を追加するためには、以下の視覚的な要素が必要。

  • 画像をアップロードするためのフォーム
  • 投稿された画像そのもの

この2つの要素を満たしたモックアップはこちら

image.png

出典:図 13.18: 画像付きマイクロポストを投稿したときのモックアップ

Upload Imageボタンで画像付きマイクロポストを投稿できるようにする。

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

投稿しt画像を扱ったり、Micropostモデルと関連付けするために、今回は
CarrierWaveという画像アップローダーを使う。

まずはcarrierwave gemをGemfileに追加する。

併せてmini_magick gemfog gemsも含める。

これらのgemは画像をリサイズしたり、本番環境で画像をアップロードするために使う。

gem 'faker',        '1.7.3'
gem 'carrierwave',  '1.2.2'

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

いつも通りgemをインストール

$ bundle install

Carrier Waveを導入すると、Railsのジェネレーターで画像アップローダーが生成できるようになる。
次のコマンドを実行して適用する。

rails g uploader Picture

Carrier Waveでアップロードされた画像は、Active Recordモデルの属性と関連付けされているべき。
関連付けされる属性には画像のファイル名が格納されるため、pictureのデータ型はstring型にしておく。

image.png

出典:図 13.19: picture属性を追加したマイクロポストのデータモデル

必要となるpicture属性をMicropostモデルに追加するために、
マイグレーションファイルを生成し、開発環境のDBに適用する。

$ rails g migration add_picture_to_microposts picture:string
$ rails db:migrate

CarrierWaveに画像と関連付けたモデルを伝えるためには、mut_uploaderというメソッドを使う。

このメソッドは、引数に属性名のシンボルと生成されたアップローダーのクラス名を取る。

mount_uploader :picture, PictureUploader #pictureにPictureUploaderのクラス名を渡す

picture_uploader.rbというファイルでPictureUploaderクラスが定義されている。

Micropostモデルにアップローダーを追加した結果がこちら

micropost.rb
class Micropost < ApplicationRecord
  belongs_to :user
  default_scope -> { order(created_at: :desc) }                                 # 並び順を降順に変更
  mount_uploader :picture, PictureUploader                                      # picture属性にPictureUploader(画像投稿gem)を渡す。
  validates :user_id, presence: true
  validates :content, presence: true, length: { maximum: 140 }
end

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

_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メソッドは以下のようになる。

microposts_controller.rb
  private

    def micropost_params
      params.require(:micropost).permit(:content, :picture)                     # pictureとcontentをmicropostハッシュの許可された属性のリストに追加する
    end

マイクロポスト用のパーシャルにimage_tagヘルパーで画像を埋め込む。

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

一度画像がアップロードされれば、Micropostパーシャルのimage_tagヘルパーでその画像を描画できるようになる。

また、画像の無いマイクロポストでは画像を表示させないようにするために、
picture?という論理値を返すメソッドを使っている点に注目。

このメソッドは、
画像用の属性名に応じて、CarrierWaveが自動的に生成してくれるメソッド。

スクリーンショット 2019-02-02 17.49.21.png

演習

1:画像付きのマイクロポストを投稿してみる。大きすぎる画像が表示されればOK.

確認済み。

2:画像アップローダーをテストしてみる。
テストの準備として、まずはサンプル画像をfixtureディレクトリに追加する。

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

実際のテストでは、Homeページにあるファイルアップロードと、
投稿に成功した時に画像が表示されているかどうかをチェックする。

microposts_interface_test.rb
  test "micropost interface" do
    log_in_as(@user)
    get root_path
    assert_select 'div.pagination'
    assert_select 'input[type=file]'
    # 無効な送信
    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"
    picture = fixture_file_upload('test/fixtures/rails.png', 'image/png')
    assert_difference 'Micropost.count', 1 do
      post microposts_path, params: { micropost: 
                                    { content: content, 
                                      picture: picture } }
    end
    assert assigns(:micropost).picture?
    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

13.4.2 画像の検証

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

この欠点を直すために、
画像サイズやフォーマットに対するバリデーションを実装し、
サーバー用途クライアント用の両方に追加する

最初のバリデーションでは、有効な画像の種類を制限していくが、
これはCarrierWaveのアップローダーの中に既にヒントがある。

生成されたアップローダーの中にコメントアウトされたコードがあるので、
このコメントアウトを取り消すことで、画像のファイル名から有効な拡張子(PNG/GIF/JPEG)を検証することができる。

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

2つ目のバリデーションでは、画像のサイズを制御する。

これはMicropostモデルに書き足していく。

先ほどのバリデーションと異なり、
ファイルサイズに対するバリデーションはRailsの既存のオプション(presenceやlengthなど)には無い。

したがって、手動でpicture_sizeというバリデーションを定義する。

このバリデーションではvalidatesではなくvalidateとsを抜かしたメソッドを使用する。

micropost.rb
class Micropost < ApplicationRecord
  belongs_to :user
  default_scope -> { order(created_at: :desc) }                                 # 並び順を降順に変更
  mount_uploader :picture, PictureUploader                                      # picture属性にPictureUploader(画像投稿gem)を渡す。
  validates :user_id, presence: true
  validates :content, presence: true, length: { maximum: 140 }
  validate :picture_size
end

  private

    # アップロードされた画像のサイズをバリデーションする
    def picture_size
      if picture.size > 5.megabytes
        errors.add(:picture, "should be less than 5MB")
      end
    end

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

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

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

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

このときacceptパラメータでは、許可したファイル形式を、MIMEタイプで指定するようにする。

次に、大きすぎるファイルサイズに対して警告を出すために、
ちょっとしたJavaScript(正確にはjQuery)を書き加える。

こうすることで、
長すぎるアップロード時間を防いだり、サーバーへの負荷を抑えたりすることに繋がる。

$('#micropost_picture').bind('change', function() {
 var size_in_megabytes = this.files[0].size/1024/1024;
 if (size_in_megabytes > 5) {
   alert('Maximum file size is 5MB. Please chooose a smailler file.'); 
 } 
});

上のコードではCSS id のmicropost_pictureを含んだ要素を見つけ出し、この要素を監視している。
そして、このidを持った要素とは、マイクロポストのフォームを指す。

つまり、このCSS idを持つ要素が変化した時、このjQueryの関数が動き出す。

そして、もしファイルサイズが大きすぎた場合、alertメソッドで警告を出すといった仕組み。

これらの追加的なチェック機能を以下に書き出す。

_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/git.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リクエストを送信する場合には対応しきれない。

したがって、このコードに対してmicropost.rbでバリデーションをかけることで大きすぎるファイルのアップロードを阻止できる。

演習

1:5MB以上の画像ファイルを送信しようとするとどうなるか?

スクリーンショット 2019-02-04 1.48.26.png

2:無効な拡張子のファイルを送信しようとするとどうなるか?

スクリーンショット 2019-02-04 1.56.30.png

13.4.3 画像のリサイズ

ファイルサイズに対するバリデーションはうまくいったので、
画像サイズに対する制限を掛ける。

大きすぎる画像サイズがアプローどされるとレイアウトが壊れるので、
画像を表示させる前にサイズを変更するリサイズを行うようにする。

image.png

出典:図 13.21: 恐ろしく大きなアップロード画像

画像をリサイズするために、UnageMagickというプログラムを開発環境にインストールする。

本番環境がHerokuであれば、既に本番環境でImageMagickが使えるようになっている。

クラウドIDEでは次のコマンドであればインストールできる。

$ sudo yum install -y ImageMagick

次にMiniMagickというImageMagickとRubyをつなぐgemを使って、画像をリサイズしてみる。

実際にリサイズする方法は様々あるが、今回はresize_to_limit: [400, 400]という方法を使う。

これは縦横どちらかが400pxを超えていた場合、適切なサイズに縮小するオプション。
(ちなみに小さすぎる画像を引き伸ばすこともできる)

picture_uploader.rb
class PictureUploader < CarrierWave::Uploader::Base
  include CarrierWave::MiniMagick
  process resize_to_limit: [400, 400]

スクリーンショット 2019-02-04 2.21.51.png

演習

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

スクリーンショット 2019-02-04 2.27.48.png

2:テストのエラーを取り除いてみる。

エラーがあるとのことだがエラーなし。

57 tests, 300 assertions, 0 failures, 0 errors, 0 skips

とりあえず、指示通りテスト時はCarrierWaveに画像のリサイズをさせないようにしてみる。

skip_image_resizing.rb
if Rails.env.test?
  CarrierWave.configure do |config|
    config.enable_processing = false
  end
end

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

先ほど実装した画像アップローダーは、開発環境で動かす分には問題ないが、
本番環境には適していない。

というのも、picture_uploader.rbにてstorage :fileという行によって、
ローカルのファイルシステムに画像を保存するようになっているから

本番環境では、ファイルシステムではなくクラウドストレージサービスに画像を保存するようにしてみる。

そのためには、fog gemを使うと簡単。

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

  # storage :file

上記の条件式ではproduction?という論理値を返すメソッドを使っている。

このメソッドを使うと、環境毎に保存先を切り替えることができる。

クラウドストレージサービスはSimple Storage Servide(S3)を使う。

セットアップ方法は以下。

1:AWSの「IAM」でユーザーを追加する。

スクリーンショット 2019-02-04 3.58.48.png

2:グループの作成で、「AmazonS3FullAccess」のみ洗濯して作成。

スクリーンショット 2019-02-04 3.59.41.png

3:任意のキーと値を入力してユーザーを作成する。
(CSVを保存しておく)

4:S3を開いてパケットを作成する

5:徹底で読み込み権限と書き込み権限を付与する。

設定を終えたら、carrier_waveファイルを作成し、以下の設定を行う。

config/initializers/carrier_wave.rb
if Rails.env.production?
  CarrierWave.configure do |config|
    config.fog_credentials = {
      # Amazon S3用の設定
      :provider                     => 'AWS',
      :region                       => ENV['S3_REGION'],
      :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の環境変数を設定する。

$ heroku config:set S3_ACCESS_KEY="ダウンロードしたAccessキー"
$ heroku config:set S3_SECRET_KEY="ダウンロードしたSecretキー"
$ heroku config:set S3_BUCKET_KEY="S3のBucketの名前"
$ heroku config:set S3_REGION="ap-northeast-1"

最後に、.gitignoreファイルを以下のように更新し、Gitに画像を保存するディレクトリを除かれるようにする。
(テスト用にアップした画像をGitに上げない)

# アップロードされたテスト画像を無視する
/public/uploads

あとはいつも通り変更をトピックブランチにコミットし、masterブランチにマージする。

$ rails t
$ git add -A
$ git commit -m "Add user microposts"
$ git checkout master
$ git merge user-microposts
$ git push

Herokuへのデプロイ後、DBのリセット、サンプルデータの生成を順に実行する。

$ git push heroku
$ heroku pg:reset DATABSE
$ heroku run rails db:migrate
$ heroku run rails db:seed

これで本番環境で画像をアップロードできればOK.

が、しかし、アップするとエラーになる・・・。

試行錯誤の末、こちらの記事に到達。

エラーAccess Denied 〜Rails + Carrierwave + HerokuでAWS S3に画像を保存〜

Amazon S3の仕様が変わったため、以下のコードを追記する必要がある模様。

carrier_wave.rb
if Rails.env.production?
  CarrierWave.configure do |config|
    config.fog_provider = 'fog/aws' 
    config.fog_credentials = {
      # Amazon S3用の設定
      :provider              => 'AWS',
      :region                => ENV['S3_REGION'], 
      :aws_access_key_id     => ENV['S3_ACCESS_KEY'],
      :aws_secret_access_key => ENV['S3_SECRET_KEY']
    }
    config.fog_directory     =  ENV['S3_BUCKET']
    config.fog_attributes = { cache_control: "public, max-age=#{365.days.to_i}" } 
  end

  # 日本語ファイル名の設定
  CarrierWave::SanitizedFile.sanitize_regexp = /[^[:word:]\.\-\+]/ 
end

また、S3のパケットポリシーに以下の記述を行う。

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "statement1",
            "Effect": "Allow",
            "Principal": {
                "AWS": "arn:aws:iam::[AWSIDを記入]:user/[IAMユーザー名を記入]"
            },
            "Action": "*",
            "Resource": "arn:aws:s3:::[パケット名を記入]/*"
        }
    ]
}

これで、再度heroku configの設定を行って、デプロイ。

画像がアップロードできればOK。

演習

1:本番環境で解像度の高い画像をアップロードし、適切にリサイズされているか確認。
長方形の画像であっても、リサイズされているか?

スクリーンショット 2019-02-04 7.23.52.png

第14章へ

単語集

  • references型

外部キーを追加する時に使う。

モデル生成時、引数に〇〇:referencesと渡すだけで一発で外部キーを追加できる。

  • 複合インデックス

1つのデータの値に対してインデックスが二種類ある状態のこと。
複数の並び替え条件でインデックスを作る。

参考:DBのインデックスと複合インデックス

  • bulidメソッド

親モデルに対する子モデルのインスタンスを新たに生成したい場合に使うメソッド。
外部キーに値が入った状態でインスタンスを生成できる。

ただし、newメソッドと同様、DBに保存されない。 

  • ラムダ式

Procやlambdaと呼ばれるオブジェクトを作成する文法。
->は、ブロックを引数に取り、Procオブジェクトを返す。

このオブジェクトは、callメソッドが呼ばれた時、ブロック内の処理を評価する。

使い方

>> -> { puts "foo"}
=> #<Proc:0x00000003fc5a50@(irb):6 (lambda)>
>> -> { puts "foo" }.call
foo
=> nil
  • dependent: :destroy

このオブションをhas_manyに渡すことで、関連づいた親モデルも一緒に削除されるようになる。

  • time_ago_in_words

「何分前に投稿したか」という情報を文字列で出力するメソッド。

  • any?

要素の中に1つでも真があればtrueを返す。1つもない場合はfalseを返す

  • take

引数に指定した数だけを、オブジェクトに渡すメソッド。

  • where

DBから、ある条件に当てはまるデータを全て取り出すメソッド。

  • redirect_back(fallback_location: Your_page)

直前のページを開く時に使えるメソッド。キー値にパス名を入れると、そのページにリダイレクトできる。

  • file_field

ファイル選択ボックスを表示する。

使い方

file_field(オブジェクト名, プロパティ名[, オプション])
  • picture?

画像用の属性名に応じて、CarrierWaveが自動的に生成してくれるメソッド。
micropostなどの属性名にこのメソッドを渡すことで使用できる。

  • fixture_file_upload

fixtureで定義されたファイルをアップロードする特別なメソッド

  • accept

サーバー側で受け入れ体制の整っているファイルを指定する属性。
この属性を指定することで、ファイル以外は送ることができない。

  • initiailzers

インスタンスが生成された直後に実行されるメソッド。

22
18
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
22
18