LoginSignup
1
0

More than 1 year has passed since last update.

Railsチュートリアル(第6版) 第13章 アカウントの有効化

Posted at

第13章

短いメッセージを投稿できるようにするためのリソース「マイクロポスト」機能を追加していく。

手順として
・Micropostデータモデル作成
・Userモデルとhas_manyおよびbelong_toメソッドで関連付け
・結果を処理し表示するための必要なフォームを作る

Micropostモデル

今回のマイクロポストモデルはテストされて、デフォルトの順序を持つ。また、親であるユーザーが破棄されたら自動的に破棄されるものになる。

トピックブランチを作成しておく。

$ git checkout -b user-microposts

基本的なモデル

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

image.png
参照:railsチュートリアル

String型Text型
・String型は、255文字まで格納できる
・Text型は、それ以上。また、Text用のテキストエリアを使うので、より自然な投稿フォームになる。将来のことを考えるとこっち。さらに、パフォーマンスでは差は出ないとのこと。

Micropostモデルを生成する

$ rails generate model Micropost content:text user:references

これでApplicationRecordを継承したモデルが作られた。

app/models/micropost.rb
class Micropost < ApplicationRecord
   belongs_to :user
end

user:referencesという引数を含めていたため、belongs_toというコードが追加されている。
このreferences型を利用している点が、Userモデルとの最大の違いになる。
これを利用すると、自動的にインデックスと外部キー参照付きのuser_idカラムが追加されて、UserとMicropostを関連付ける下準備をしてくれる。

Userモデルと同様にcreated_atupdated_atというカラムが追加されてる。

インデックスが付与されたMicropostのマイグレーション

db/migrate/[timestamp]_create_microposts.rb
class CreateMicroposts < ActiveRecord::Migration[6.0]
  def change
    create_table :microposts do |t|
      t.text :content
      t.references :user, foreign_key: true

      t.timestamps
    end
    add_index :microposts, [:user_id, :created_at]
  end
end

上のコードでは、user_idcreated_atカラムにインデックスが付与されている。これで、user_idに関連付けられたすべてのマイクロポストを作成時刻の逆順で取り出しやすくなる。
また、user_idcreated_atの両方を1つの配列に含めている。これで、Active Recordは両方のキーを同時に扱う複合キーインデックス(Multiple Key Index)を作成する。

マイグレートする
$ rails db:migrate

Micropostのバリデーション

Micropostモデル単体を動くようにする。

Micropostの初期テストは手順
setupでfixtureのサンプルユーザーと紐だ付けた新しいマイクロポストを作成
・次に、作成したマイクロポストが有効かどうかのチェック
・最後に、あらゆるマイクロポストはユーザーのidを持つべきなので、user_idの存在性のバリデーションに対するテストを追加

test/models/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

1つ目のテストは、現実に即しているかどうかをテスト(reality check)
2つ目のテストは、user_idが存在しているかどうか(nilではないか)

user_idに対する存在性のバリデーション

app/models/micropost.rb
class Micropost < ApplicationRecord
  belongs_to :user
  validates :user_id, presence: true
end

次にcontent属性に対するバリデーションの追加。

test/models/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

content属性も存在性と、140文字より長くならないという制限を加える。

マイクロポストにバリデーションを追記する。

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

User/Micropostの関連付け

個々のモデル間で関連付けをしておく。
今回は、それぞれのマイクロポストは一人のユーザーと関連付けられ、それぞれのユーザーは潜在的に複数のマイクロポストと関連付けられている。

MicropostとそのUserはbelongs_toの関係性
image.png
参照:railsチュートリアル

UserとそのMicropostはhas_manyの関係性
image.png
参照:railsチュートリアル

このような関係を行うことで

メソッド  用途
micropost.user  Micropostに紐付いたUserオブジェクトを返す 
user.microposts  Userのマイクロポストの集合をかえす 
user.microposts.create(arg) userに紐付いたマイクロポストを作成する 
user.microposts.create!(arg) userに紐付いたマイクロポストを作成する(失敗時に例外を発生)
user.microposts.build(arg) userに紐付いた新しいMicropostオブジェクトを返す
user.microposts.find_by(id: 1) userに紐付いていて、idが1であるマイクロポストを検索する

上のメソッドが使えるようになる。

注意点として

Micropost.create
Micropost.create!
Micropost.new

ではなく

user.microposts.create
user.microposts.create!
user.microposts.build

となっている。
紐づいているユーザーを通してマイクロポストを作成することができる。新規のマイクロポストがこの方法で作成される場合、user_idは自動的に正しい値に設定される。

最初の書き方↓

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

書き換えると↓

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

buildメソッドはオブジェクトを返すがデータベースには反映されない。
一度関連付けを定義すれば、@micropost変数のuser_idには、関連するユーザーidが自動的に設定される。

UserモデルとMicropostモデルを紐づける。
Micropostは、belongs_to :user
Userモデルは、has_many :micropostsと追加する

app/models/user.rb
class User < ApplicationRecord
  has_many :microposts
  .
  .
  .
end

setupメソッドを修正し、正しいテストにする。

test/models/micropost_test.rb
  def setup
    @user = users(:michael)
    @micropost = @user.microposts.build(content: "Lorem ipsum")
  end

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

UserとMicropostを関連付けを改良する。
ユーザーのマイクロポストを特定の順序で取得できるようにしたり、マイクロポストをユーザーに依存させ、ユーザーの削除と同時にマイクロポストも自動的に削除されるようにする。

デフォルトスコープ
user.micropostメソッドはデフォルト状態では読み出し順序に何も保証がない。
ブログやTwitterの慣習に従い、作成時間の逆順、最も新しいマイクロポストを最初に表示する。
これを実装するには、default scopeを使う。

このテストは、一見成功しているように思えるが、「アプリケーション側の実装が本当は間違っているのにテストが成功してしまう」トラップがある。
なので、テスト駆動開発で進める。

テスト内容
データベース上のマイクロポストが、fixture内のマイクロポスト(most_recet)と同じであるか検証する。

test/models/micropost_test.rb
require 'test_helper'

class MicropostTest < ActiveSupport::TestCase
  .
  .
  .
  test "order should be most recent first" do
    assert_equal microposts(:most_recent), Micropost.first
  end
end

fixtureでコメント、作成日、ユーザーへの関連付けを行う。

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

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

cat_video:
  content: "Sad cats are sad: https://youtu.be/PKffm2uI4dk"
  created_at: <%= 2.hours.ago %>
  user: michael

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

fixtureファイル内では日付を更新可能となっている。

続いて、Railsのdefault_scopeメソッドを使う。
このメソッドは、データベースから要素を取得した時のデフォルトの順序を指定するメソッドになる。
特定の順序にしたい場合、default_scopeの引数にorderを与える。
例えば、created_atカラムの順にしたい場合は以下の通り。

order(:created_at)

デフォルトの順序が昇順の為、小さい値から大きい値にソートされる。つまり最も古い投稿が最初に来る。
順序を逆にするには、生のSQLを引数に与える。

order('created_at DESC')

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

なんとRails4.0以降は、Rubyの文法でも書けるようになった。

order(created_at: :desc)

default_scopeでマイクロポストを順序付ける

app/models/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

上のコードでは、ラムダ式という文法を使っている。
これは、Procやlambda(無名関数)と呼ばれるオブジェクトを作成する文法。
->というラムダ式は、ブロックを引数に取り、Procオブジェクトを返す。
このオブジェクトは、callメソッドが呼ばれたとき、ブロック内の処理を評価する。

Dependent: destroy
マイクロポストに第二の要素を追加する。
サイト管理者はユーザーを破棄する権限を持つため、ユーザーが破棄されたらユーザーのマイクロポストも同時に破棄されるべき。

has_manyメソッドにオプションを渡してあげればOK

app/models/user.rb
class User < ApplicationRecord
  has_many :microposts, dependent: :destroy
  .
  .
  .
end

dependent: :destroyというオプションを使うことで、ユーザーが削除されたら、そのユーザーに紐づいたマイクロポストも一緒に削除される。
持ち主の存在しないマイクロポストがデータベースに取り残される問題を防ぐ。

Userモデルを検証する。

テスト内容
・ユーザーを作成する
・そのユーザーに紐づいたマイクロポストを作成する
・その後、ユーザーを削除してみて、マイクロポストが1つ減っているかどうか確認する

test/models/user_test.rb
require 'test_helper'

class UserTest < ActiveSupport::TestCase

  def setup
    @user = User.new(name: "Example User", email: "user@example.com",
                     password: "foobar", password_confirmation: "foobar")
  end
  .
  .
  .
  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
end

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

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

マイクロポストの描画

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

Micropostのコントローラーとビューを作成するために、コントローラを生成する。

$ rails generate controller Microposts

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

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

ulタグではなく、順序付きリストのolタグを使っている。
マイクロポストが特定の順序に依存しているため。

対応するパーシャのコード↓

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というヘルパーメソッドを使っている。これは、「〇分前に投稿」といった文字列を出力するもの。

<li id="micropost-<%= micropost.id %>">
これは、各マイクロポストにCSSのidを割り振っている。将来的に、各マイクロポストを操作したくなった時に役立つ。

ページ分割するため、will_paginateメソッドを使う。
<%= will_paginate @microposts %>

第10章のユーザー一覧画面のコードでは、<%= will_paginate %>という単純なコードだった。

実は、上のコードは引数なしで動作していた。will_paginateがUsersコントーラのコンテキストにおいて、@userインスタンス変数が存在していることを前提としてたから。このインスタンス変数は、ActiveRecord::Relationクラスのインスタンス。
今回の場合は、Usersコントローラのコンテキストからマイクロポストをページネーションしたいため(コンテキストが異なる)、明示的に@microposts変数をwill_paginateに渡す必要がある。
従って、そのようなインスタンス変数をUsersコントローラのshowアクションで定義しなければならない。

@micropostsインスタンス変数をshowアクションに追加する。

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

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

最後に、マイクロポストの投稿数を表示するが、これはcountメソッドでOK

user.microposts.count

関連付けを通して、countメソッドを呼び出している。
なんとcountメソッドでは、データベース上のマイクロポストを全部読みだしてから結果の配列にlengthを呼ぶような、無駄な処理はしていない。
(そりゃぁそうでなければね)

データーベースに代わりに計算してもらい、特定のuser_idに紐づいたマイクロポストの数をデータベースに問い合わせている。countメソッドよりもさらに高速なcounter cacheも使うことが可能。

プロフィール画面にマイクロポストを表示させる。
if @user.microposts.any?を使って、ユーザーのマイクロポストが1つもない場合には空のリストを表示させないようにしている。

app/views/users/show.html.erb
<% provide(:title, @user.name) %>
<div class="row">
  <aside class="col-md-4">
    <section class="user_info">
      <h1>
        <%= gravatar_for @user %>
        <%= @user.name %>
      </h1>
    </section>
  </aside>
  <div class="col-md-8">
    <% if @user.microposts.any? %>
      <h3>Microposts (<%= @user.microposts.count %>)</h3>
      <ol class="microposts">
        <%= render @microposts %>
      </ol>
      <%= will_paginate @microposts %>
    <% end %>
  </div>
</div>

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

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

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

orderメソッドを経由することで、最初の6人を明示的に呼び出すようにしている。

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

db/seeds.rb
# ユーザーの一部を対象にマイクロポストを生成する
users = User.order(:created_at).take(6)
50.times do
  content = Faker::Lorem.sentence(word_count: 5)
  users.each { |user| user.microposts.create!(content: content) }
end

開発環境のデータベースで生成

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

image.png
参照:railsチュートリアル

CSSを整えて、、

app/assets/stylesheets/custom.scss
/* microposts */

.microposts {
  list-style: none;
  padding: 0;
  li {
    padding: 10px 0;
    border-top: 1px solid #e8e8e8;
  }
  .user {
    margin-top: 5em;
    padding-top: 0;
  }
  .content {
    display: block;
    margin-left: 60px;
    img {
      display: block;
      padding: 5px 0;
    }
  }
  .timestamp {
    color: $gray-light;
    display: block;
    margin-left: 60px;
  }
  .gravatar {
    float: left;
    margin-right: 10px;
    margin-top: 5px;
  }
}

aside {
  textarea {
    height: 100px;
    margin-bottom: 5px;
  }
}

span.image {
  margin-top: 10px;
  input {
    border: 0;
  }
}

image.png

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

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

$ rails generate integration_test users_profile

マイクロポスト用のfixtureにいくつかデータを追加

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

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

cat_video:
  content: "Sad cats are sad: https://youtu.be/PKffm2uI4dk"
  created_at: <%= 2.hours.ago %>
  user: michael

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

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

テスト内容
プロフィール画面にアクセス後
・ページタイトルとユーザー名
・Gravatar
・マイクロポストの投稿数
・ページ分割されたマイクロポスト
という順番でテストする。

test/integration/users_profile_test.rb
require 'test_helper'

class UsersProfileTest < ActionDispatch::IntegrationTest
  include ApplicationHelper

  def setup
    @user = users(:michael)
  end

  test "profile display" do
    get user_path(@user)
    assert_template 'users/show'
    assert_select 'title', full_title(@user.name)
    assert_select 'h1', text: @user.name
    assert_select 'h1>img.gravatar'
    assert_match @user.microposts.count.to_s, response.body
    assert_select 'div.pagination'
    @user.microposts.paginate(page: 1).each do |micropost|
      assert_match micropost.content, response.body
    end
  end
end

第12章と同様に、response.bodyを使っている。これには、そのページの完全なHTMLが含まれている。
従って、そのページのどこかしらにマイクロポストの投稿数があれば、以下のように探してマッチできる。

assert_match @user.microposts.count.to_s, response.body

上のはassert_selectよりも抽象的なメソッド。
特にassert_selectではどのHTMLタグを探すか指定しなければならない。
assert_matchメソッドはその必要がない。

さらに、assert_selectの引数ではネストした文法を使う。

assert_select 'h1>img.gravatar'

h1タグの内側にあるgrabatarクラス月のimgタグがあるかどうかをチェックしてる。

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

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

やること
・ステータスフィード実装
・ユーザーがマイクロポストをWeb経由で破棄できるようにする

従来のRails開発と子なる点が1つある。
Micropostリソースへのインターフェイスは、主にプロフィールページとHomeページのコントローラを経由し実行されるため、Micropostコント―羅にはneweditアクションは不要。

つまり、createdestroyがあればOK

Micropostsリソースは下記になる。

resources :microposts, only: [:create, :destroy]

Micropostsリソースが提供するRESTfulルート

HTTPリクエスト URL アクション 名前付きルート
POST /microposts create microposts_path
DELETE /microposts/1 destroy micropost_path(micropost)

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

Micropostsリソース開発では、コントローラ内のアクセス制御から始める。
関連付けられたユーザーを通してマイクロポストにアクセスするので、createアクションやdestroyアクションを利用するユーザーはログイン済である必要がある。

ログイン済か調べるテストは、Usersコントローラ用のテストがそのまま使える。
正しいリクエストを各アクションに発行し、マイクロポスト数が変化してないかどうか、リダイレクトされるかどうかを確認する。

test/controllers/microposts_controller_test.rb
require 'test_helper'

class MicropostsControllerTest < ActionDispatch::IntegrationTest

  def setup
    @micropost = microposts(:orange)
  end

  test "should redirect create when not logged in" do
    assert_no_difference 'Micropost.count' do
      post microposts_path, params: { micropost: { content: "Lorem ipsum" } }
    end
    assert_redirected_to login_url
  end

  test "should redirect destroy when not logged in" do
    assert_no_difference 'Micropost.count' do
      delete micropost_path(@micropost)
    end
    assert_redirected_to login_url
  end
end

ここでリファクタリングの必要性あり。
第10章では、beforeフィルタのlogged_in_userメソッドを使って、ログインを要求した。第10章では、Usersコントローラ内にこのメソッドがあったので、beforeフィルターを指定した。
なので、Micropostsコントローラでも同様に必要になる。
なので、各コントローラーが継承するApplicationコントローラに、このメソッドを移す。

app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
  include SessionsHelper

  private

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

Usersコントローラからは、logged_in_userは削除しておこう。

これでMicropostsコントローラーからもlogged_in_userメソッドを呼び出せるようになった。
これで、createアクションやdestroyアクションに対するアクセス制限が、beforeフィルターで簡単に実装できるようになった。

app/controllers/microposts_controller.rb
class MicropostsController < ApplicationController
  before_action :logged_in_user, only: [:create, :destroy]

  def create
  end

  def destroy
  end
end

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

第7章でやった、HTTP POSTリクエストをUsersコントローラのcreateアクションに発行するHTMLフォームを作成することで、ユーザーのサインアップをやった。
今回もこれと似てる。違いとして、別のmicropost/neeページを使う代わりに、ホーム画面にフォームを置くという点。

この節での目標
・ユーザーのログイン状態に応じて、ホーム画面の表示を変更する事

マイクロポストのcreateアクションを作る。

app/controllers/microposts_controller.rb
class MicropostsController < ApplicationController
  before_action :logged_in_user, only: [:create, :destroy]

  def create
    @micropost = current_user.microposts.build(micropost_params)
    if @micropost.save
      flash[:success] = "Micropost created!"
      redirect_to root_url
    else
      render 'static_pages/home'
    end
  end

  def destroy
  end

  private

    def micropost_params
      params.require(:micropost).permit(:content)
    end
end

ユーザー用アクションと似てるが、違いは新しいマイクロポストをbuildするためにUser/Micropost関連付けを使っているというところ。
micropost_paramsでStrong Parmetersを使っていることで、マイクロポストのcotent属性だけがWeb経由で変更可能になっている。

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

app/views/static_pages/home.html.erb
<% if logged_in? %>
  <div class="row">
    <aside class="col-md-4">
      <section class="user_info">
        <%= render 'shared/user_info' %>
      </section>
      <section class="micropost_form">
        <%= render 'shared/micropost_form' %>
      </section>
    </aside>
  </div>
<% else %>
  <div class="center jumbotron">
    <h1>Welcome to the Sample App</h1>

    <h2>
      This is the home page for the
      <a href="https://railstutorial.jp/">Ruby on Rails Tutorial</a>
      sample application.
    </h2>

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

  <%= link_to image_tag("rails.svg", alt: "Rails logo", width: "200px"),
              "https://rubyonrails.org/" %>
<% end %>

上のコードを動かすためパーシャルを作る必要がある。

Homeページの新しいサイドバー

app/views/shared/_user_info.html.erb
<%= link_to gravatar_for(current_user, size: 50), current_user %>
<h1><%= current_user.name %></h1>
<span><%= link_to "view my profile", current_user %></span>
<span><%= pluralize(current_user.microposts.count, "micropost") %></span>

ユーザー情報に、そのユーザーが投稿したマイクロポスト総数が表示される。
pluralizeメソッドを使って、"1 micropost"や"2 microposts"とするよう調整してる。

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

app/views/shared/_micropost_form.html.erb
<%= form_with(model: @micropost, local: true) 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.build

app/controllers/static_pages_controller.rb
class StaticPagesController < ApplicationController

  def home
    @micropost = current_user.microposts.build if logged_in?
  end

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

2つめは、エラーメッセージのパーシャルを再定義すること。

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

今回は@mircropost変数を使う。フォーム変数ff.objectとし、関連付けられたオブジェクトにアクセスすることができる。

form_with(model: @user, local: true) do |f|

上のようにf.object@userとなる場合と

form_with(model: @micropost, local: true) do |f|

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

パーシャルにオブジェクトを渡すため、値がオブジェクトで、キーがパーシャルでの変数名と同じハッシュを利用する。
object: f.objecterror_messagesパーシャルの中でobjectという変数名を作成してくれるので、この変数名を使ってエラーメッセージを更新すれば良い。

app/views/shared/_error_messages.html.erb
<% if object.errors.any? %>
  <div id="error_explanation">
    <div class="alert alert-danger">
      The form contains <%= pluralize(object.errors.count, "error") %>.
    </div>
    <ul>
    <% object.errors.full_messages.each do |msg| %>
      <li><%= msg %></li>
    <% end %>
    </ul>
  </div>
<% end %>

この時点でテストは失敗する。error_messagesパーシャルの他の出現場所がヒント。
このパーシャルは、他の場所で使われていたため、ユーザー登録、パスワード再設定、ユーザー編集のそれぞれのビューを更新する必要がある。

各ビューを更新した結果を下記に示す。

app/views/users/new.html.erb
<% provide(:title, 'Sign up') %>
<h1>Sign up</h1>

<div class="row">
  <div class="col-md-6 col-md-offset-3">
    <%= form_with(model: @user, local: true) do |f| %>
      <%= render 'shared/error_messages', object: f.object %>
      <%= f.label :name %>
      <%= f.text_field :name, class: 'form-control' %>

      <%= f.label :email %>
      <%= f.email_field :email, class: 'form-control' %>

      <%= f.label :password %>
      <%= f.password_field :password, class: 'form-control' %>

      <%= f.label :password_confirmation, "Confirmation" %>
      <%= f.password_field :password_confirmation, class: 'form-control' %>

      <%= f.submit "Create my account", class: "btn btn-primary" %>
    <% end %>
  </div>
</div>
app/views/users/edit.html.erb
<% provide(:title, "Edit user") %>
<h1>Update your profile</h1>

<div class="row">
  <div class="col-md-6 col-md-offset-3">
    <%= form_with(model: @user, local: true) do |f| %>
      <%= render 'shared/error_messages', object: f.object %>

      <%= f.label :name %>
      <%= f.text_field :name, class: 'form-control' %>

      <%= f.label :email %>
      <%= f.email_field :email, class: 'form-control' %>

      <%= f.label :password %>
      <%= f.password_field :password, class: 'form-control' %>

      <%= f.label :password_confirmation, "Confirmation" %>
      <%= f.password_field :password_confirmation, class: 'form-control' %>

      <%= f.submit "Save changes", class: "btn btn-primary" %>
    <% end %>

    <div class="gravatar_edit">
      <%= gravatar_for @user %>
      <a href="https://gravatar.com/emails">change</a>
    </div>
  </div>
</div>
app/views/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_with(model: @user, url: password_reset_path(params[:id]),
                    local: true) do |f| %>
      <%= render 'shared/error_messages', object: f.object %>

      <%= hidden_field_tag :email, @user.email %>

      <%= f.label :password %>
      <%= f.password_field :password, class: 'form-control' %>

      <%= f.label :password_confirmation, "Confirmation" %>
      <%= f.password_field :password_confirmation, class: 'form-control' %>

      <%= f.submit "Update password", class: "btn btn-primary" %>
    <% end %>
  </div>
</div>

フィードの原型

マイクロポスト投稿フォームが動作するようになった。
しかし、現時点では投稿した内容をすぐにみる事ができない。なぜなら、Homeページにまだマイクロポストを表示する部分が実装されないから。

フォームが正しく動作してるかチェックする場合
正しいエントリー投稿→プロフィールページに移動→ポストを表示
になるが、これは面倒だ。

なので、ユーザー自身のポストを含むマイクロポストのフィードがないと不便。

image.png
参照:railsチュートリアル

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

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

    private
    .
    .
    .
end

上のコードにある疑問符は、セキュリティ上重要だ。

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

疑問符があることで、SQLクエリに代入する前にidがエスケープされるため、SQLインジェクション(SQL Injection)と呼ばれる深刻なセキュリティホールを避けられる。
この場合、id属性は単なる整数(self.idはユーザーのid)であるため危険でないが、SQL文に変数を代入する場合は常にエスケープしておこう。

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

先ほどあった下記のコード
@micropost = current_user.microposts.build if logged_in?
これが

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

前置if文に変わる。
(1行の時は後置if文、2行の時は前置if文を使うのがRubyの慣習)

homeアクションにフィードのインスタンス変数を追加する。

app/controllers/static_pages_controller.rb
class StaticPagesController < ApplicationController

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

ステータスフィードのパーシャル

app/views/shared/_feed.html.erb
<% if @feed_items.any? %>
  <ol class="microposts">
    <%= render @feed_items %>
  </ol>
  <%= will_paginate @feed_items %>
<% end %>

ステータスフィードのパーシャルは、Micropostのパーシャルとは異なる。

<%= render @feed_items %>

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

あとはいつものようにフィードパーシャルを表示すればHomeページにフィードを追加できる。
この結果はHomeページのフィードとして表示される。

app/views/static_pages/home.html.erb
<% if logged_in? %>
  <div class="row">
    <aside class="col-md-4">
      <section class="user_info">
        <%= render 'shared/user_info' %>
      </section>
      <section class="micropost_form">
        <%= render 'shared/micropost_form' %>
      </section>
    </aside>
    <div class="col-md-8">
      <h3>Micropost Feed</h3>
      <%= render 'shared/feed' %>
    </div>
  </div>

しかし、マイクロポストの投稿が失敗すると、Homeページは@feed_itemsインスタンス変数を期待しているため、現状では壊れる。
解決法として、Micropostコントローラのcreateアクションへの送信が失敗した場合に備え、必要なフィード変数をこのブランチで渡しておくこと。

app/controllers/microposts_controller.rb
  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

しかし、この時点ではわざと長いマイクロポストを投稿するとエラーが出る。
ページネーション用のリンクを表示すると、"2"のリンクと"Next"のリンクがどちらも同じ次のページを指している。
createアクションはMicropostsコントローラにあるので、このURLは「/microposts?page=2」となります。しかし、これはMicropostsの存在していないindexアクションを開こうとしている。
その結果、どちらのリンクをクリックしてもエラーが発生する。

この問題は、Homeページに対応するcontrollerパラメータとactionパラメータを明示的にwill_paginateに渡せばOK
例として、static_pagesコントローラとhomeアクションを渡すと次の通り。

app/views/shared/_feed.html.erb
<% if @feed_items.any? %>
  <ol class="microposts">
    <%= render @feed_items %>
  </ol>
  <%= will_paginate @feed_items,
                    params: { controller: :static_pages, action: :home } %>
<% end %>

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

削除機能を追加する。
これは、ユーザー削除と同じように"delete"リンクでOK。
今回は自分が投稿したマイクロポストのみ削除リンクが動作するようにする。

まずは、マイクロポストのパーシャルに削除リンクを追加する。

app/views/microposts/_micropost.html.erb
<li id="micropost-<%= micropost.id %>">
  <%= link_to gravatar_for(micropost.user, size: 50), micropost.user %>
  <span class="user"><%= link_to micropost.user.name, micropost.user %></span>
  <span class="content"><%= micropost.content %></span>
  <span class="timestamp">
    Posted <%= time_ago_in_words(micropost.created_at) %> ago.
    <% if current_user?(micropost.user) %>
      <%= link_to "delete", micropost, method: :delete,
                                       data: { confirm: "You sure?" } %>
    <% end %>
  </span>
</li>

次に、Micropostsコントローラのdestroyアクションを定義する。
ユーザーの実装とほぼ同じ。違いは、admin_userフィルターで@user変数を使うのではなく、関連付けを使いマイクロポストを見つけるようにする点。
これで、あるユーザーが他のユーザーのマイクロポストを削除しようとすると、自動的に失敗するようになる。

correct_userフィルター内でfindメソッドを呼び出すことで、現在のユーザーが削除対象のマイクロポストを保有してるかどうか確認する。

app/controllers/microposts_controller.rb
class MicropostsController < ApplicationController
  before_action :logged_in_user, only: [:create, :destroy]
  before_action :correct_user,   only: :destroy
  .
  .
  .
  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変数と似ており、一つ前のURLを返す。(今回なら、Homeページ)
なので、マイクロポストがHomeページから削除された場合でもプロフィールページから削除された場合でも、request.referrerを使うことによりDELETEリクエストが発行されたページに戻れる。
また、元に戻すURLが見つからなくても||演算子でroot_urlをデフォルトに設定してるので問題なし。

image.png
マイクロポスト作成

image.png
マイクロポスト削除

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

Micropostモデルとそのインターフェイスが完成した。
あとは、Micropostsコントローラの認可をチェックするテストと、それらをまとめる統合テストだ。

マイクロポスト用のfixtureに、別々のユーザーに紐づけられたマイクロポストを追加する。

別のユーザーに所属しているマイクロポストを追加

test/fixtures/microposts.yml
ants:
  content: "Oh, is that what you want? Because that's how you get ants!"
  created_at: <%= 2.years.ago %>
  user: archer

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

tone:
  content: "I'm sorry. Your words made sense, but your sarcastic tone did not."
  created_at: <%= 10.minutes.ago %>
  user: lana

van:
  content: "Dude, this van's, like, rolling probable cause."
  created_at: <%= 4.hours.ago %>
  user: lana

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

test/controllers/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 generate integration_test microposts_interface

マイクロポストUIに対する統合テスト

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'
    assert_select 'a[href=?]', '/?page=2'  # 正しいページネーションリンク
    # 有効な送信
    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

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

ここでは、画像付きマイクロポスト投稿の機能を実装する。

手順として、まずは開発環境のβ版を実装し、その後いくつかの改善を通して本番環境用の完成版を実装する。

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

image.png
参照:railsチュートリアル モックアップ画像

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

Railsでファイルをアップロードする簡単な方法は、Railsに組み込まれているActive Storage機能を使う事。
Active Storageで画像を簡単に扱えて、画像に関連付けるモデルも自由に指定可能となる。
また、Active Storageは汎用性が高く、平文テキスト、PDFファイルや音声等様々なバイナリファイルも扱える。

Active Storageインストールコマンド

$ rails active_storage:install

上のコマンドで、添付ファイルの保存に用いるデータモデルを作成するためのデータベースマイグレーションが1つ作られる。

次にマイグレーションを実行

$ rails db:migrate

Active Storageの中で最初に知るべきことは、has_one_attachedメソッドだ。
これは、指定のモデルとアップロードされたファイルを関連付けるのに使う。
下の場合は、imageを指定してMicropostモデルと関連付けてる。

app/models/micropost.rb
class Micropost < ApplicationRecord
  belongs_to       :user
  has_one_attached :image
  default_scope -> { order(created_at: :desc) }
  validates :user_id, presence: true
  validates :content, presence: true, length: { maximum: 140 }
end

このアプリケーションでは、「マイクロポスト1件につき画像は1件」という設計をしているが、Active Storageは他にもhas_many_attachedオプションも提供してる。文字通り、Active Recordオブジェクト1件につき複数のファイルを添付できるもの。

Homeページに画像をアップロードを追加するのに、マイクロポストフォームにfile_fieldタグを含める。

マイクロポストのcreateフォームに画像アップロードを追加

app/views/shared/_micropost_form.html.erb
<%= form_with(model: @micropost, local: true) 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="image">
    <%= f.file_field :image %>
  </span>
<% end %>

最後に、Micropostsコントローラを更新し、新たに作成したmicropostオブジェクトに画像を追加できるようにする。
Active Storage APIにはそのためのattachメソッドが提供されており、これを使う。
具体的にMicropostsコントローラのcreateアクションの中で、アップロードされた画像を@micropostオブジェクトにアタッチする。
このアップロード許可のために、micropost_paramsメソッドを更新し、:imageを許可済み属性リストに追加して、Web経由で更新できるようにする。

app/controllers/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)
    @micropost.image.attach(params[:micropost][:image])
    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

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

  private

    def micropost_params
      params.require(:micropost).permit(:content, :image)
    end

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

一度画像がアップロードされれば、micropostパーシャルのimage_tagヘルパーを用いて、関連付けられたmicropost.imageを描画できる。
また、画像の無いテキストのみのマイクロポストでは画像を表示させないようにするため、attached?という論理値を返すメソッドを使っている。

app/views/microposts/_micropost.html.erb
<li id="micropost-<%= micropost.id %>">
  <%= link_to gravatar_for(micropost.user, size: 50), micropost.user %>
  <span class="user"><%= link_to micropost.user.name, micropost.user %></span>
  <span class="content">
    <%= micropost.content %>
    <%= image_tag micropost.image if micropost.image.attached? %>
  </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>

画像の検証

画像アップロード機能を持たせたが、欠点がある。それは、アップロードされた画像に対する制限がないため、もしユーザーが巨大なファイルを上げたり、無効なファイルを上げると問題が発生する。
この欠点を直すために、画像サイズやフォーマットに対するバリデーションを実装する。

Railsチュートリアル執筆時点では、Active Storageは、こうしたフォーマット機能やバリデーションがネイティブでサポートされていないとのこと。
なので、そのような機能をgemで追加する。

gem 'active_storage_validations', '0.8.2'

いつものようにbundle installする。

このgemでは、content_typeを検査する事で画像をバリデーションできる。

content_type: { in: %w[image/jpeg image/gif image/png],
                message: "must be a valid image format" }

上記のコードは、サポートする画像フォーマットに対応する画像MIME typeをチェックしてる。
配列構文%w[]がある。

同じく、ファイルサイズも以下のようにバリデーション可能。

size: { less_than: 5.megabytes,
        message: "should be less than 5MB" }

上はtimeヘルパーの時に使った構文と同じで、画像の最大サイズを5MBに制限してる。

これらのバリデーションをまとめた結果をMicropostモデルに追加する。

app/models/micropost.rb
class Micropost < ApplicationRecord
.
.
.
  validates :image,   content_type: { in: %w[image/jpeg image/gif image/png],
                                      message: "must be a valid image format" },
                      size:         { less_than: 5.megabytes,
                                      message: "should be less than 5MB" }
end

image.png
参照:railsチュートリアル

先ほどのモデルに追加バリデーションを強化するために、クライアント側でも画像アップロードのサイズやフォーマットをチェックする仕組みを入れる。
まずは、JavaScript(jQuery)にて、ユーザーがアップロードしようとする画像が大きすぎたらアラートを表示するようにする。

jQueryでファイルサイズをチェックする。

app/views/shared/_micropost_form.html.erb
<script type="text/javascript">
  $("#micropost_image").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.");
      $("#micropost_image").val("");
    }
  });
</script>

最後に、acceptパラメータをfile_field入力タグで用いれば、有効なフォーマットでないとアップロードできない事をユーザーに伝えられる。

app/views/shared/_micropost_form.html.erb
<%= form_with(model: @micropost, local: true) 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="image">
    <%= f.file_field :image, accept: "image/jpeg,image/gif,image/png" %> 
  </span>
<% end %>

<%= f.file_field :image, accept: "image/jpeg,image/gif,image/png" %>
このコードは最初に有効な画像フォーマットだけを選択可能にしておき、それ以外のファイルタイプを灰色で表示するもの。

ブラウザ側に色々コードを追加したが、このコードは無効なファイルをアップロードしにくくするだけ。
その気になればcurl等でPOSTリクエストを直接発行して、無効なファイルをアップロードできてしまう。
なので、サーバー側のバリデーションは必要なのだ。

画像のリサイズ

ファイルサイズのバリデーションは実装した。
今度は、画像サイズ(縦横の長さ)に対する制限がない。なので、大きすぎる画像サイズがアップロードされるとレイアウトが崩壊する。
かといって、ユーザーで画像サイズを変更させるのは大変不便だ。
そのため、画像を表示させる前にサイズを変更するようにする。

image.png
参照:railsチュートリアル

画像を操作するプログラムが必要なので、ImageMagickを使う。

開発環境にインストールする。

$ sudo apt-get -y install imagemagick

続いて、画像処理のためにいくつかgemを追加する。
image_processinggem、Ruby製ImageMagickプロセッサのmini_magickgemが必要だ。

gem 'image_processing',           '1.9.3'
gem 'mini_magick',                '4.9.5'

いつものようにbundle installする。

これで、Active Storageが提供するvariantメソッドで変換済画像を作成できるようになる。
特にresize_to__limitオプションを用いて、下記のように画像の幅や高さが500ピクセルを越えないように制約をかけとく。

image.variant(resize_to_limit: [500, 500])

上記のコードをdisplay_imageメソッドにおいて利便性を高めよう。

app/models/micropost.rb
  # 表示用のリサイズ済み画像を返す
  def display_image
    image.variant(resize_to_limit: [500, 500])
  end

display_imageをmicropostパーシャルで使えるようになった。

リサイズ済のdisplay_imageを使う。

<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.display_image if micropost.image.attached? %>
  </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>

上記のコードに
<%= image_tag micropost.display_image if micropost.image.attached? %>
があるが、そこにdisplay_imageメソッドを使ってる。

variantによるリサイズは、<%= image_tag micropost.display_image if micropost.image.attached? %>で最初に呼ばれる時にオンデマンドで実行される。以後、結果をキャッシュするので効率が良い。

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

画像アップロード機能を実装したが、このままで本番環境に適さない。
本番環境では、ファイルシステムではなくクラウドストレージサービスに画像を保存するようにする。

今回はAWSのS3(Simple Storage Service)を使う。

S3を使うためgemを設定する。

gem 'aws-sdk-s3', '1.46.0', require: false

例のごとくbundle installを実行する。

AWSの設定方法は省く。
railsチュートリアルに画像付きで細かく載っているため。

最後に

Micropostsリソースによって、良い感じにTwitterっぽくなった。
次章は、ユーザーをお互いフォローする仕組みを作っていく。
ユーザー同士のリレーションシップモデリングを学んで、マイクロポストフィードにどんな感じで関連するか学ぶ。

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