プログラミング備忘録

プログラミングの学習状況をメモしています

61日目

今日の学習

Ruby on Rails

Railsチュートリアル 第13章

railstutorial.jp

この章では、Twitterでいうところのツイートのような、ユーザーが短いメッセージを投稿できるようにするためのリソース「マイクロポスト」を追加していく。

Micropostモデル

まずは、Micropostモデルを作成する。このモデルの構成内容は以下の通り。

このうち、contentuser_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_idcreated_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_tohas_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

準備ができたので、Railsdefault_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`を渡す必要がある。

また、@micropostsshowアクションで定義する必要がある。

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コントローラにはneweditのようなアクションは不要となる。

マイクロポストを作成・削除するためのcreatedestroyがあれば十分なようだ。そのため、リソースは以下のような形になる。

resources :microposts, only: [:create, :destroy]
マイクロポストのアクセス制御

Micropostsリソースの開発で最初に取り掛かるのは、Micropostsコントローラ内のアクセス制御から。

createdestroyアクションを利用するユーザーはログイン済みである必要がある。

ログインしていないユーザーが投稿や削除しようとした場合ログインページに遷移されるかどうかのテストを作成する。

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メソッドを利用してマイクロポストを作成する。自分は今までこのメソッドを利用したことがなく、newcreateとの違いが分からなかったので検索して調べてみる。

【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文に変数を代入する場合は、常にエスケープすること

よく分からなかったので検索してみたら、分かりやすい記事を見つけた。

blog.senseshare.jp

このfeedメソッドは、以下と同じ意味を持つ(userには適当なユーザーの情報が入っているとする)。

  • user.feed
  • Micropost.where("user_id = ?", user.id)
  • user.microposts

アプリケーションにフィード機能を導入するために、ログインユーザーのフィード用インスタンス変数@feed_itemhomeアクションに追加する。

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クラスを持っているため、RailsMicropostのパーシャルを呼び出す。

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にcontrolleraction`パラメータを渡すようにする。

<%= 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.rbcreateアクションに問題がある。

  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を使用して生成したマイクロポストの表示テストを行っているとき、コードはおかしくないのにエラーが出てしまう。

原因は、どうやらマイクロポストを生成するコードの先頭に余分なインデントが含まれていたからのようだった。

f:id:hasegawa_note:20210706100245p:plain

上のmost_recentと比べて、マイクロポスト生成用のコードにはインデントがある。

ymlファイルは空白やインデントに厳格だと聞いたことがある。次から気を配るようにする。