61日目
今日の学習
Ruby on Rails
Railsチュートリアル 第13章
この章では、Twitterでいうところのツイートのような、ユーザーが短いメッセージを投稿できるようにするためのリソース「マイクロポスト」を追加していく。
Micropostモデル
まずは、Micropostモデルを作成する。このモデルの構成内容は以下の通り。
- id:integer
- content:text
- user_id:integer
- created_at:datetime
- updated_at
このうち、content
とuser_id
以外は作成時に自動でついてくる(マジックカラム)ため、二つの属性だけを追加する。
マイクロポストを格納するためにcontent
カラムを用意する。データ型はtext
にしておくことで、ある程度の量のテキストを格納する。
string
型も255文字の格納が可能(ツイートが140文字だと思うと、これでもけっこうな量な気がする)だが、Railsチュートリアルがtext
を選ぶ利点は次の通り。
- text用のテキストエリアを使うため、自然な投稿フォームになる
- 言語に応じて投稿の長さを調節できる
- text型を使っても本番環境でパフォーマンス差がない
モデルを生成する際には、references
型を使い、User
モデルと関連づける。これで、自動生成されたMicropost
モデルの中に、belongs_to :user
の一文が追加される。
rails g model Micropost content:text user:references
references
型を使うことにより、自動的にインデックスと外部キー参照付きのuser_id
カラムが追加される。
さらにインデックスを加える。
add_index :microposts, [:user_id, :created_at]
こうすることで、user_id
に関連づけられた全てのマイクロポストを作成時刻の逆順で取り出しやすくなる。
また、user_id
とcreated_at
の両方を一つの配列に含めることで、Active Recordは両方のキーを同時に扱う複合キーインデックス(Multiple Key Index)を作成する。
バリデーション
user_id
が存在しているかどうかを確認する。また、マイクロポスト(content)の投稿は140文字までという制限もつける。
app/models/micropost.rb
validates :user_id, presence: true validates :content, presence: true, length: { maximum: 140 }
User/Micropostの関連付
Webアプリケーション用のデータモデルを構築する際は、個々のモデル間での関連付けをしっかりと考えておく必要がある。
今回の場合は、一人一人のユーザーは複数のマイクロポストと関連付られることになる=UserとMicropostはhas_many
(1対多)の関係性がある。
belongs_to
とhas_many
で関連付を行うと、Railsでは以下のようなメソッドを使えるようになる。
# マイクロポストを生成する Micropost.create # 特定のユーザーがマイクロポストを作成する user.microposts.create
このメソッドを使うことで、紐づいているユーザーを通してマイクロポストを作成することかできる。
Railsチュートリアルでは、他に以下のような関連メソッドが紹介されている。
# userに紐づいていて、idが1のマイクロポストを検索 user.microposts.find_by(id: 1) # 変数micropostのidのマイクロポストを検索 user.microposts.find(micropost.id) # Micropostに紐づいたUserオブジェクトを返す micropost.user
belongs_to :user
はすでに自動的に生成されているが、Userモデルの方ではhas_many :microposts
を手動で追加する必要がある。
関連付けの改良
ユーザーのマイクロポストを特定の順序で取得できるようにしたり、マイクロポストをユーザーに依存させ、ユーザーが削除されたらマイクロポストも自動的に削除されるような改良を行う。
default scope
マイクロポストが作成時間の逆順...最も新しいマイクロポストを最初に表示するようにする。
これを実装するために、default scope
という手法を用いる。
この機能のテストは、アプリケーション側の実装が間違っているにも関わらずテストが成功してしまうケースがある。そのため、Railsチュートリアルではテスト駆動開発で進めることにしている。
データベース上の最初のマイクロポストが、fixture内のマイクロポスト(most_recent
)と同じであるかどうかを検証するテストを最初に書く。
test/models//micropost_test.rb
test "order should be most recent first" do assert_equal microposts(:most_recent), Micropost.first end
次に、マイクロポスト用のfixtureファイルをRailsチュートリアルに沿って作成する。
created_at
は本来Railsによって自動更新されるが、fixtureファイルの中では任意の更新が可能となっている。そのため、created_at: <%= Time.zone.now %>
やcreated_at: <%= 10.hours.ago %>
とすることで意図的に投稿日時をずらすことができる。
# Railsチュートリアルの一例 orange: content: "I just ate an orange!" created_at: <%= 10.minutes.ago %> # user.ymlに存在するユーザーを指定する user: michael
準備ができたので、Railsのdefault_scope
メソッドを利用する。
このメソッドは、データベースから要素を取得した時のデフォルトの順序を指定できる。
特定の順序にしたい場合は、default_scope
の引数にorder
を与える。
デフォルトの順序は昇順(ascending)になっている。
# 昇順 order(:created_at) # 降順にしたい場合 order('created_at: :desc)
今回は降順に並べるため、Micropostモデルにラムダ式を利用して順序づける。
default_scope -> { order(created_at: :desc) }
Dependent: destroy
サイト管理者はユーザーを破棄する権限を持っている。ユーザーが破棄された場合は、そのユーザーのマイクロポストも同時に破棄されるように設定する。
has_many
メソッドにdependent: :destroy
オプションを渡すことで可能になる。
has_many :microposts, dependent: :destroy
これが正常に機能しているかどうかを確認するテストも書いた。
test/models/user_test.rb
test "associated microposts should be destroyed" do @user.save @user.microposts.create!(content: "Lorem ipsum") # @userを削除することで投稿数が減っているか確認 assert_difference 'Micropost.count', -1 do @user.destroy end end
マイクロポストを表示する
Railsチュートリアルでは、ユーザーのshow
ページで直接マイクロポストを表示させることを目的とする。
最初にMicropostのコントローラとビューをコマンドで作成し、マイクロポストの描画はパーシャルを使うことにする。ひとまず、show
ページに記載することをまとめる。
<ol class="micropost"> <%= render @microposts %> </ol>
マイクロポストは降順という特定の順序に依存しているため、順序なしリストのul
タグではなく、順序付きリストのol
タグを利用している。
これを、マイクロポスト一覧を表示させたいユーザーのshow
ページに配置する。
if @user.microposts.any?
を使って、ユーザーのマイクロポストがひとつもない場合は空のリストを表示させないようにする。
また、ユーザー一覧のときと同様に、ページネーションも設定する。
今回はユーザーのshow
アクションを利用して配置しているため、`will_paginateだけではなく、そこに
@microposts`を渡す必要がある。
また、@microposts
をshow
アクションで定義する必要がある。
def show @microposts = @user.microposts.paginate(page: params[:page])
<div class="col-md-8> <% if @user.microposts.any? %> <h3>Microposts (<%= @user.microposts.count %>)</h3> <ol class="microposts"> <%= render @microposts %> </ol> <%= will_paginate @microposts %> <% end %> </div>
続いて、マイクロポストのパーシャルを作成する。ユーザーのindex
ページで作成したパーシャルと同じようにする。
app/views/microposts/_micropost.html.erb
<li id="micropost-<%= micropost.id %"> <%= link_to gravatar_for(micropost.user, size: 50), micropost.user %> <span class="user"><%= link_to micropost.user.name, micropost.user %></span> <span class="content"><%= micropost.content %></span> <span class="timestamp"> Posted <%= time_ago_in_words(micropost.created_at) %> ago. </span> </li>
タイムスタンプの部分に、time_ago_in_words
というヘルパーメソッドを使用している。
投稿されたものが、今の時間からおよそどれくらい前に投稿されたのかをtime_ago_in_words
の引数の時点から自動的に表示してくれるようだ。
マイクロポストのサンプル
今の状態だとマイクロポストのデータがなく、確認もしづらいので投稿のサンプルを作成する。
作成された最初のユーザー6人に、50個分のマイクロポストを追加するようにする。Faker gemのLomen.sentence
メソッドを使うと、投稿内容を自動で生成してくれるのでそれを利用する。
db/seeds.rb
# 作成された順で6人ユーザーを抽出 users = User.order(:created_at).take(6) # 50件のマイクロポストを人数分作成 50.times do content = Faker::Lomen.sentence(word_count: 5) users.each { |user| user.microposts.create!(content: content) } end
これでサンプルデータを作成し、表示することができるようになった。
今のままではCSSが適応されておらず見栄えが悪いため、RailsチュートリアルにあるCSSを設定して見た目を整えた。
また、プロフィール画面のマイクロポストのテストも行った。Railsチュートリアル通りに行っただけなので割愛。
マイクロポストを操作する
まだWebアプリケーション側からマイクロポストを作成することができないため、Web経由で作成できるようにインターフェイスを作成していく。最終的には、ユーザーがマイクロポストをWeb経由で削除できるようにする。
Rails開発の慣習と異なる点が1つあり、Micropostsリソースへのインターフェイスは主にプロフィールページとHomeページのコントローラを経由して実行されるため、Micropostsコントローラにはnew
やedit
のようなアクションは不要となる。
マイクロポストを作成・削除するためのcreate
とdestroy
があれば十分なようだ。そのため、リソースは以下のような形になる。
resources :microposts, only: [:create, :destroy]
マイクロポストのアクセス制御
Micropostsリソースの開発で最初に取り掛かるのは、Micropostsコントローラ内のアクセス制御から。
create
やdestroy
アクションを利用するユーザーはログイン済みである必要がある。
ログインしていないユーザーが投稿や削除しようとした場合ログインページに遷移されるかどうかのテストを作成する。
test/controllers/microposts_controller_test.rb
# fixtureファイルにあるデータを読み込む def setup @micropost = microposts(:orange) end test "should redirect create when not logged in" do # Micropostの数に変化がないかを調べる 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
現時点では上のテストはパスできない。logged_in_user
によってログイン制限をかけていたが、上のテストをパスするために、Micropostsコントローラでもユーザーのログインを要求する必要がある。
UsersコントローラでもMicropostsコントローラでもログインの有無を確かめたいので、各コントローラが継承するApplicationコントローラにlogged_in_user
メソッドを移し、Usersコントローラ内のlogged_in_user
は削除しておく。
これでMicropostsコントローラからもlogged_in_user
メソッドが利用できるようになった。create
アクションやdestroy
アクションに対してbeforeフィルターをかけることで、ログインを必須にできる。
マイクロポストの作成
マイクロポストは、micropost/new
ページを使う代わりに、ルートパスに投稿フォームを配置する。
Railsチュートリアル中、最後にホーム画面を実装したときはSign up now!
というボタンを中央に配置していた。
マイクロポスト作成フォームは、ログインしている特定のユーザーのコンテキストでのみ機能させるようにする。
そのため、ユーザーのログイン状態に応じてホーム画面の表示を変更することを目標とする。
まずはマイクロポストのcreate
アクションの作成に取り掛かる。
build
メソッドを利用してマイクロポストを作成する。自分は今までこのメソッドを利用したことがなく、new
やcreate
との違いが分からなかったので検索して調べてみる。
【Rails】モデルの関連付けで用いられるbuildメソッドまとめ|TechTechMedia
どうやら、モデルの関連付(今回の場合はUsersとMicropost)をしている場合はbuild
を使う慣習があるようだ。
@user = User.new @micropost = @user.microposts.build
今回もStrong Parametersを使用して、content
属性だけが変更可能となるように設定する。そのため、build
の引数にmicropost_params
を渡す。
def create @micropost = current_user.microposts.build(micropost_params) if @micropost.save flash[:success] = "Micropost created!" redirect_to root_url else render "static_pages/home" end end private def micropost_params params.require(:micropost).permit(:content) end
create
アクションが作成できたら、Homeページ(ルートパス)にログイン時のみ表示されるような設定を施してマイクロポストの投稿フォームを追加する。
その際、新しいパーシャルを二つ用意している。
- サイドバーで表示するユーザー情報のパーシャル
ここでは、投稿数を「1 micropost」や「2 microposts」のように表すため、以下のようにpluralize
メソッドを使って文法の誤りが起こらないようにしている。
pluralize(current_user.microposts.count, "micropost")
- マイクロポスト投稿フォームのパーシャル
<%= form_with(model: @micropost, local: true) do |f| %> <%= render 'shared/error_messages', object: f.object %> <%= f.text_area :content, placeholder: "Compose new micropost..." %> <%= f.submit "Post" %> <% end %>
このフォームでは、@micropost
という変数を使用している。これを使えるようにするためには、このパーシャルを配置するhome
アクションにマイクロポストのインスタンス変数を追加しなければならない。
app/controllers/static_pages_controller.rb
def home @micropost = current_user.microposts.build if logged_in? end
また、フォーム内でエラーメッセージを表示するパーシャルを利用しているが、今のままでは@user
変数を直接参照しているため、Userオブジェクト以外に対応できない状態になっている。micropostの場合は@micropost
変数を参照する必要がある。
Userオブジェクト以外でも動作するようにerrpr_messages
パーシャルを更新する。
app/views/shared/_error_messages.html.erb
<% if object.errors.any? %> The form contains <%= pluralize(object.errors.count, "error") %> <% object.errors.full_messages.each do |msg| %> <%= msg %> <% end %>
このパーシャル内でobject
変数を作成し、以下の投稿フォームパーシャルのエラーメッセージ表示に対応する。
<%= render 'shared/error_messages', object: f.object %>
error_messages
パーシャルはこれまでに色々な場所に配置してきたので、それらをすべて上のものに置き換える必要がある。
ここまでの実装で、ログインしているユーザーにはHomeページにマイクロポストフォームが表示されるようになった。
フィードの原型
マイクロポスト投稿フォームから、マイクロポストを投稿できるようになった。しかし、今の段階ではHome画面にマイクロポストを表示させる実装をしていないため、投稿内容をすぐに見られない。
ユーザー自身のポストを含むマイクロポストのフィードがないと不便だ(フィードとはさまざまなデータが一覧になっていることを指すらしい)。
全てのユーザーにフィードを持たせたいため、feed
メソッドをUserモデルで作成する。
最初の段階では、試作的に現在ログインしているユーザーのマイクロポストを全て取得できるようにする。
app/models/user.rb
def feed Micropost.where("user_id = ?", id) end
ここで使われている?
は、セキュリティ上で重要な役割を果たす。
?
がある結果、SQLクエリに代入する前にid
がエスケープされ、SQLインジェクションという深刻なセキュリティホールを避けられるらしい。
製作者が想定していないSQLの使用を未然に防ぐため、SQL文に変数を代入する場合は、常にエスケープすること。
よく分からなかったので検索してみたら、分かりやすい記事を見つけた。
このfeed
メソッドは、以下と同じ意味を持つ(user
には適当なユーザーの情報が入っているとする)。
- user.feed
- Micropost.where("user_id = ?", user.id)
- user.microposts
アプリケーションにフィード機能を導入するために、ログインユーザーのフィード用インスタンス変数@feed_item
をhome
アクションに追加する。
def home if logged_in? @micropost = current_user.microposts.build @feed_items = current_user.feed.paginate(page: params[:page]) end
@micropost
の1行分しかなかった時は、Railsの慣習に従い後置if文にしていたが、@feed_items
を加えて2行になったため、2行以上の時は前置if文を使用する。
次に、Home
ページに使用するフィード用のパーシャルを用意する。
<% if @feed_items.any? %> <ol> <%= render @feed_items %> </ol> <% end %>
@feed_items
の各要素がMicropost
クラスを持っているため、RailsはMicropost
のパーシャルを呼び出す。
render @feed_items
→呼び出し app/views/microposts/_micropost.html.erb
用意したパーシャルをhome.html.erb
に記述して、フィードを追加する。
余談だが、RailsチュートリアルではHome
ページにステータスフィードを追加するとして、home.html.erb
にそのまま記述しているが、本来この部分は演習でパーシャルに作り替えているため、書いてある通りに記述するとおかしなことになる。
マイクロポストの投稿が失敗すると、Home
ページは@feed_items
インスタンス変数を期待しているのに受け取れなくなる。Microposts
コントローラのcreate
アクションで、送信が失敗した場合のフィード変数を渡しておくことで解決できる。
def create ... if @micropost.save ... else # ここで変数を渡しておく @feed_items = current_user.feed.paginate(page: params[:page])
また、自分の場合は問題なかったのだが、Home
ページでのページネーションが正常に動作するように、will_paginateに
controller、
action`パラメータを渡すようにする。
<%= will_paginate @feed_items, params: { controller: :static_pages, action: :home } %>
マイクロポストの削除
ユーザーの削除と同様に、delete
リンクで削除できるようにする。
ユーザーの削除は管理者ユーザーのみが行えたことに対して、マイクロポストの場合は自分が投稿したものに対してのみ削除リンクが動作するようにする。
はじめに、マイクロポストのパーシャルに削除リンクを追加する。
# 投稿者とログインしている人が同じ場合は削除リンクを表示させる <% if current_user?(micropost.user) %> <%= link_to "delete", micropost, method: :delete, data: { confirm: "You sure?" } %> <% end %>
次は、Microposts
コントローラのdestroy
アクションを定義する。
関連付を使ってマイクロポストを見つけるようにすることで、投稿者以外のユーザーがマイクロポストを削除しようとしても自動的に失敗する。
current_user
フィルター内でfind
メソッドを呼び出すことで、現在のユーザー(current_user)が削除対象のマイクロポストを保有しているかどうかを確認する。
before_action :correct_user, only: :destroy def destroy @micropost.destroy flash[:success] = "Micropost deleted" redirect_to request.referrer || root_url end private def correct_user @micropost = current_user.microposts.find_by(id: params[:id]) redirect_to root_url if @micropost.nil? end
削除成功時のリダイレクトに、request.referrer
というメソッドが使われている。
このメソッドは、フレンドリーフォワーディングのrequest.url
変数と似ていて、ひとつ前のURLを返してくれる。
このメソッドを使うことで、DELETEリクエストが発行されたページに戻すことができる。
もとに戻すURLが見つからなかった場合のことを考え、||
演算子でroot_url
をデフォルトに設定している。
また、Rails 5から導入されたメソッドを以下のように利用しても同じ動きになるようだ。
redirect_back(fallback_location: root_url)
自分以外のユーザーのマイクロポストを削除しようとすると、リダイレクトされるかを確認するテストを行った。
マイクロポストのUIに対する統合テストでエラー
ActionView::Template::Error: ActionView::Template::Error: Missing partial microposts/_logged_in_home, application/_logged_in_home with {:locale=>[:en], :formats=>[:html], :variants=>[], :handlers=>[:raw, :erb, :html, :builder, :ruby, :jbuilder]}.
パーシャルがないよと言われている。また、microposts.controller.rb
のcreate
アクションに問題がある。
def create @micropost = current_user.microposts.build(micropost_params) if @micropost.save flash[:success] = "Micropost created!" redirect_to root_url else @feed_items = current_user.feed.paginate(page: params[:page]) # ↓この部分 render "static_pages/home" # ↑この部分 end end
検索してみたところ、同じような人が解決方法を残してくださっていた。
[Rails]パーシャルをパーシャル名のみで呼び出そうとしてハマったところ - Qiita
views/からの相対パスを指定すると、期待通りのパーシャル(logged_in)を呼び出すことができ、エラーが出なくなりました。
自分もこれと同じ方法で解決することができた。深謝。
パーシャルを使う場合は気をつける必要がある。
学習メモ
今日のやらかし
fxtureで余分なインデントを入れてエラー
fixtureを使用して生成したマイクロポストの表示テストを行っているとき、コードはおかしくないのにエラーが出てしまう。
原因は、どうやらマイクロポストを生成するコードの先頭に余分なインデントが含まれていたからのようだった。
上のmost_recent
と比べて、マイクロポスト生成用のコードにはインデントがある。
yml
ファイルは空白やインデントに厳格だと聞いたことがある。次から気を配るようにする。