#近況報告
エンジニア転職成功しました。YouTubeもはじめました。
著者略歴
著者:YUUKI
ポートフォリオサイト:Pooks
現在:RailsTutorial2周目
#第13章 ユーザーのマイクロポスト 難易度 ★★★★★ 8時間
挫折しないRailsチュートリアルの進め方を先にお読みください↓↓
これまでに
- ユーザー
- セッション
- アカウント有効化
- パスワードリセット
という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モデルの構造
マイクロポストの投稿に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
のコードも追加されている。
この部分に関しては後に詳解する。
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
カラムは今後の実装を進めていく上で必要なカラムとなるので追加しておく。
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_index
でuser_id
とcreated_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
の存在性のバリデーションに対するテスト
このステップで追加する。
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ではないかどうか)をテストしている。
このテストをパスさせる為に、存在性のバリデーションを追加する。
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モデルのときと同じようなバリデーションを追加していく。
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用バリデーションと全く同じ。
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モデルにいくつかのテストを追加する。
出典:図 13.2: MicropostとそのUserは belongs_to (1対1) の関係性がある
出典:図 13.3: UserとそのMicropostは has_many (1対多) の関係性がある
belongs_to/has_many
関連付けを使うことで、下記の表のようなメソッドを使えるようになる。
出典:表 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
と追加する必要がある。
ここは自動的に生成されないので、手動で追加する。
class Micropost < ApplicationRecord
belongs_to :user
validates :user_id, presence: true
validates :content, presence: true, length: { maximum: 140 }
end
class User < ApplicationRecord
# 関連付け
has_many :microposts
正しく関連付けできたら、setupメソッドを修正して、慣習的に正しくマイクロポストを作成してみる。
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)と同じであるか検証するテストを書く。
test "order should be most recent first" do
assert_equal microposts(:most_recent), Micropost.first
end
:most_recent
エア、マイクロポスト用のfixtureファイルからサンプルデータを読み出しているので、次のfixtureファイルも必要になる。
orange:
content: "I just ate an orange!"
created_at: <%= 10.minutes.ago %>
tau_manifesto:
content: "Check out the @tauday site by @mhartl: http://tauday.com"
created_at: <%= 3.years.ago %>
cat_video:
content: "Sad cats are sad: http://youtu.be/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モデルを更新する。
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
を渡す。
class User < ApplicationRecord
# 関連付け
has_many :microposts, dependent: :destroy
これでユーザーが削除された時に、そのユーザーに紐付いたマイクロポストも一緒に削除されるようになる。
管理者がシステムからユーザーを削除した時、マイクロポストだけがDBに取り残される問題を防ぐ。
次に上記の振る舞いが正しく動くかどうか、テストを使って検証する。
このテストでは
①idを紐づけるためのユーザーを作成する
②そのユーザーに紐付いたマイクロポストを作成
③ユーザーを削除
④マイクロポストの数が1つ減っているかどうか
この順でテストする。
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
の場合はどうなっているか?
>> Micropost.first
Micropost Load (0.1ms) SELECT "microposts".* FROM "microposts" ORDER BY "microposts"."id" ASC LIMIT ? [["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">
Micropost Load (0.2ms) SELECT "microposts".* FROM "microposts" ORDER BY "microposts"."id" DESC LIMIT ? [["LIMIT", 1]]
=> #<Micropost id: 4, content: "Lorem ipsum", user_id: 1, created_at: "2019-01-30 11:45:01", updated_at: "2019-01-30 11:45:01">
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テンプレートを作成する。
次に、サンプルデータ生成タスクにマイクロポストのサンプルを追加して、画面にサンプルデータが表示されるようにする。
出典:図 13.4: マイクロポストが表示されたプロフィールページのモックアップ
###13.2.1 マイクロポストの描画
ユーザーのプロフィールの画面(show.html.erb)で
- そのユーザーのマイクロポストを表示させたり、
- これまでに投稿した総数も表示させたり
する。
この実装は10章ユーザーを表示する部分と似ている。
演習で既にマイクロポストをいくつか作成しているので、DBをリセットし、サンプルデーターを再生成する。
$ rails g controller Microposts
今回の目的は、ユーザーごとに全てのマイクロポストを描画できるようにすること。
indexビューでは
<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タグを使う
これは、マイクロポストが特定の順序(降順)に依存しているため。
次に、対応するパーシャルを書く。
<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アクションで定義しなければならない。
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に問い合わせている。
コレで全ての要素が揃ったので、プロフィール画面にマイクロポストを表示させてみる。
<% 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つもない場合には空のリストを表示させていない点にも注目。
ここで、改良した新しいプロフィール画面をブラウザで見てみる。
マイクロポストがないため、if文の処理がfalseとなっている。
####演習
1:今回ヘルパーメソッドとして使った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オブジェクトのクラスは何か?
>> 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 gem
にLorem.sentence
という便利なメソッドがあるので、これを使う。
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.do
のuser.each
を後に書いてるのには理由があって、というのもユーザー毎に50個分のマイクロポストを作成してしまうと、ステータスフィードに表示される投稿が全て同じユーザーになってしまい、視覚的な見栄えが悪くなってしまう。
ここで、いつものように開発環境用のDBで再度サンプルデータを生成する。
$ rails db:migrate:reset
$ rails db:seed
生成し終わったら、一度Railsサーバーを落として、再起動。
ここで、users_controller.rb
のshowアクション
のredirect_to行を削除。
def show
@user = User.find(params[:id]) # paramsで:idパラメータを受け取る(/users/1にアクセスしたら1を受け取る)
@microposts = @user.microposts.paginate(page: params[:page]) # pageを受け取ってmicropostsに渡して表示する。
end
マイクロポスト固有のスタイルが与えられていないので、CSSを適用させる。
/* 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;
}
}
どちらのページでも下部にページネーションリンクを表示している。
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ファイル書き出す。
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アクションを元に戻す。
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
- マイクロポストの投稿数
- ページ分割されたマイクロポスト
といった順でテストしていく。
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のテストが正しいか確かめるため、コメントアウトして失敗するか確認。
<!--<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リソースは以下のように出来上がる。
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コントローラ用のテストがそのまま役に立つ。
つまり、正しいリクエストを各アクションに向けて発行し、マイクロポストの数が変化していないかどうか、また、リダイレクトされるかどうかを確かめればよい。
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フィルター
を使えるようにする。
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
を削除する。
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
アクションにログインしていないと処理を実行できないアクセス制限を簡単に掛けるようになった。
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)にフォームを置くという点。
出典:図 13.10: マイクロポスト作成フォームのあるホーム画面のモックアップ
最後にホーム画面を実装したときは、[sign up now!]ボタンが中央にあった。
要は、ユーザーがログインしている場合のみ、マイクロポスト作成フォームを表示させるようにする。
(ホーム画面の表示を、ログイン状態と日ログイン状態で変わるよう実装する。)
その前に、まずはマイクロポストのcreateアクションを作り始める。
このアクションもユーザー用アクションと似ていて、違う点は
新しいマイクロポストをbulidするためにUser/Micropost関連付けを使っている点。
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を提供するコードを使う。
<% 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から書いていく。
<%= 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 micropost
や2 microposts
というように、
ユーザーの投稿数に合わせてスペルを追従させたいので、pluralize
メソッドを使って調整している。
次はマイクロポスト作成フォームを定義する。
これはユーザー登録フォームに似ている。
<%= 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
上記コードをコントローラに埋め込む。
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.object
はerror_messages
パーシャルの中で
objectという変数名を作成してくれるので、この変数を使ってエラーメッセージを更新すればよいということ。
簡単にいうと、エラーメッセージ用の変数をobject変数に統一し、renderの読み出し時にobjectがエラーであれば(送信出来なければ)、全てをobject: f.object
でエラーを表示させる。
<% 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
と更新する必要がある。
<!--ボタンと画像を表示-->
<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作成-->
<% 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.erb
とusers/edit.html.erb
ではformパーシャルを使っていたので、
_form.html.erb
とpassword_resets/edit.html.erb
の2ファイルを修正するだけでOK。
これでテストはパスする。
54 tests, 281 assertions, 0 failures, 0 errors, 0 skips
これで全てのHTMLが適切に表示されるようになったので、実際にフォームを見て確認する。
###演習
1:Homeページをリファクタリングして、if-else
文の分岐のそれぞれに対してパーシャルを作ってみる。
<% if logged_in? %>
<%= render 'static_pages/if' %>
<% else %>
<%= render 'static_pages/else' %>
<% end %>
<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>
<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に載せた方が便利なので、これを実装する。
出典:図 13.13: 試作フィードがあるHomeページのモックアップ
全てのユーザーがフィードを持つので、feedメソッドはUserモデルで作るのが自然。
なお、次章で完全なフィードを実装するため、where
メソッドを使う。
まずは現在ログインしているユーザーのマイクロポストをすべて取得してくる。
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の慣習
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
<% 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パーシャルを使ったので、こちらに組み込む。
<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
ただ、現時点ではマイクロポストの投稿が失敗すると、Homeページは@feed_items
インスタンス変数を期待しているため、エラーとなる。
これを簡単に解決するため、@feed_items
に空の配列を代入する。
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
リンクで実現する。
ユーザーの削除は管理者ユーザーのみが行えるよう制限されていたのに対し、
今回は自分が投稿したマイクロポストに対してのみ削除リンクが動作するようにする。
出典:図 13.16: マイクロポストの削除リンクと試作フィードのモックアップ
最初のステップとして、マイクロポストのパーシャルに削除リンクを追加する。
<li id="micropost-<%= micropost.id %>">
<%= link_to gravatar_for(micropost.user, size: 50), micropost.user %>
<span class="user"><%= link_to micropost.user.name, micropost.user %></span>
<span class="content"><%= micropost.content %></span>
<span class="timestamp">
Posted <%= time_ago_in_words(micropost.created_at) %> ago.
<% if current_user?(micropost.user) %>
<%= link_to "delete", micropost, method: :delete,
data: { confirm: "You sure?" } %>
<% end %>
</span>
</li>
次に、Micropostsコントローラのdestroyアクションを定義する。
これもユーザーにおける実装とだいたい同じで、
大きな違いは、admin_user
フィルターで@user変数を使うのではなく、
関連付けを使ってマイクロポストを見つけようとしている点。
これにより、あるユーザーが他のユーザーのマイクロポストを削除しようとすると、自動的に失敗するようになる。
具体的には、correct_user
フィルター内でfindメソッドを呼び出すことで、
現在のユーザーが削除対象のマイクロポストを保有しているかどうか確認する。
class MicropostsController < ApplicationController
before_action :logged_in_user, only: [:create, :destroy] # ログインユーザーのみ作成したり削除できるよう適用
before_action :correct_user, only: :destroy
def 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番目のマイクロポストを削除してみると、エラーが出ずに削除できているのが分かる。
####演習
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)
と置き換えてもうまく動くことをブラウザを使って確認する。
def destroy
@micropost.destroy
flash[:success] = "Micropost deleted"
redirect_back(fallback_location: root_url)
end
確認済み。
###13.3.5 フィード画面のマイクロポストをテストする
13.3.4のコードで、Micropostモデルとそのインターフェースが完成した。
残っている箇所は
- Micropostsコントローラの許可をチェックするテスト
- それらをまとめる統合テスト
まずはマイクロポスト用のfixtureに、別々のユーザーに紐付けられたマイクロポストを追加していく。
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
次に、自分以外のユーザーのマイクロポストを削除しようとすると、
適切にリダイレクトされることをテストで確認する。
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
実際の統合テストを書く。
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
が正しく表示されているかどうかもテストする。
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つの要素を満たしたモックアップはこちら
出典:図 13.18: 画像付きマイクロポストを投稿したときのモックアップ
Upload Imageボタンで画像付きマイクロポストを投稿できるようにする。
###13.4.1 基本的な画像アップロード
投稿しt画像を扱ったり、Micropostモデルと関連付けするために、今回は
CarrierWave
という画像アップローダーを使う。
まずはcarrierwave gem
をGemfileに追加する。
併せてmini_magick gem
とfog 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
型にしておく。
出典:図 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モデルにアップローダーを追加した結果がこちら
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
タグを含める必要がある。
<%= 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
メソッドは以下のようになる。
private
def micropost_params
params.require(:micropost).permit(:content, :picture) # pictureとcontentをmicropostハッシュの許可された属性のリストに追加する
end
マイクロポスト用のパーシャルにimage_tagヘルパーで画像を埋め込む。
<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が自動的に生成してくれるメソッド。
####演習
1:画像付きのマイクロポストを投稿してみる。大きすぎる画像が表示されればOK.
確認済み。
2:画像アップローダーをテストしてみる。
テストの準備として、まずはサンプル画像をfixtureディレクトリに追加する。
$ cp app/assets/images/rails.png test/fixtures/
実際のテストでは、Homeページにあるファイルアップロードと、
投稿に成功した時に画像が表示されているかどうかをチェックする。
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)を検証することができる。
# アップロード可能な拡張子のリスト
def extension_whitelist
%w(jpg jpeg gif png)
end
2つ目のバリデーションでは、画像のサイズを制御する。
これはMicropost
モデルに書き足していく。
先ほどのバリデーションと異なり、
ファイルサイズに対するバリデーションはRailsの既存のオプション(presenceやlengthなど)には無い。
したがって、手動でpicture_size
というバリデーションを定義する。
このバリデーションではvalidates
ではなくvalidate
とsを抜かしたメソッドを使用する。
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メソッドで警告を出すといった仕組み。
これらの追加的なチェック機能を以下に書き出す。
<%= 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以上の画像ファイルを送信しようとするとどうなるか?
2:無効な拡張子のファイルを送信しようとするとどうなるか?
###13.4.3 画像のリサイズ
ファイルサイズに対するバリデーションはうまくいったので、
画像サイズに対する制限を掛ける。
大きすぎる画像サイズがアプローどされるとレイアウトが壊れるので、
画像を表示させる前にサイズを変更するリサイズを行うようにする。
画像をリサイズするために、UnageMagick
というプログラムを開発環境にインストールする。
本番環境がHerokuであれば、既に本番環境でImageMagickが使えるようになっている。
クラウドIDEでは次のコマンドであればインストールできる。
$ sudo yum install -y ImageMagick
次にMiniMagick
というImageMagickとRubyをつなぐgem
を使って、画像をリサイズしてみる。
実際にリサイズする方法は様々あるが、今回はresize_to_limit: [400, 400]
という方法を使う。
これは縦横どちらかが400pxを超えていた場合、適切なサイズに縮小するオプション。
(ちなみに小さすぎる画像を引き伸ばすこともできる)
class PictureUploader < CarrierWave::Uploader::Base
include CarrierWave::MiniMagick
process resize_to_limit: [400, 400]
####演習
1:解像度の高い画像をアップロードし、リサイズされているかどうか確認してみる。
画像が長方形だった場合、リサイズはうまく行われているか?
2:テストのエラーを取り除いてみる。
エラーがあるとのことだがエラーなし。
57 tests, 300 assertions, 0 failures, 0 errors, 0 skips
とりあえず、指示通りテスト時はCarrierWaveに画像のリサイズをさせないようにしてみる。
if Rails.env.test?
CarrierWave.configure do |config|
config.enable_processing = false
end
end
###13.4.4 本番環境での画像アップロード
先ほど実装した画像アップローダーは、開発環境で動かす分には問題ないが、
本番環境には適していない。
というのも、picture_uploader.rb
にてstorage :file
という行によって、
ローカルのファイルシステムに画像を保存するようになっているから
本番環境では、ファイルシステムではなくクラウドストレージサービスに画像を保存するようにしてみる。
そのためには、fog gem
を使うと簡単。
class PictureUploader < CarrierWave::Uploader::Base
include CarrierWave::MiniMagick
process resize_to_limit: [400, 400]
if Rails.env.production?
storage :fog
else
storage :file
end
# storage :file
上記の条件式ではproduction?
という論理値を返すメソッドを使っている。
このメソッドを使うと、環境毎に保存先を切り替えることができる。
クラウドストレージサービスはSimple Storage Servide(S3)を使う。
セットアップ方法は以下。
1:AWSの「IAM」でユーザーを追加する。
2:グループの作成で、「AmazonS3FullAccess」のみ洗濯して作成。
3:任意のキーと値を入力してユーザーを作成する。
(CSVを保存しておく)
4:S3を開いてパケットを作成する
5:徹底で読み込み権限と書き込み権限を付与する。
設定を終えたら、carrier_wave
ファイルを作成し、以下の設定を行う。
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の仕様が変わったため、以下のコードを追記する必要がある模様。
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:本番環境で解像度の高い画像をアップロードし、適切にリサイズされているか確認。
長方形の画像であっても、リサイズされているか?
#単語集
- references型
外部キーを追加する時に使う。
モデル生成時、引数に〇〇:references
と渡すだけで一発で外部キーを追加できる。
- 複合インデックス
1つのデータの値に対してインデックスが二種類ある状態のこと。
複数の並び替え条件でインデックスを作る。
- 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
インスタンスが生成された直後に実行されるメソッド。