【個人開発】未経験から独学でキャットフード検索サービスを作ってみた 【Ruby on Rails × Next.js】
はじめに
この記事をご覧下さりありがとうございます。
未経験から独学でwebエンジニアを目指しているくま(くま@web系エンジニア転職 (@kumakuma_1106) / X)と申します。
この度、全ての猫を飼われているおうちの方に使って欲しいキャットフード検索サービス「ねこまんま」を作成しました。
本記事で、サービス概要や使用技術等を紹介させていただきます。
サービス概要
ここからは、サービスを作成した背景やサービスの詳細についてご紹介します。
サービス作成の背景
現在私は3匹の猫を飼っています。
猫には毎日ドライキャットフードをあげているのですが、そのキャットフードについて悩むことがとにかく多いです。
というのも、キャットフードはとにかく種類が多い上に、何を基準に選べばいいのかが分かりにくいものです。
だからといって、ネットで情報を集めようと思っても、大体どこのサイトでも似たようなフードばかりが紹介されています。どうやらブランディング力のあるフードばかり紹介されているようです。
そのため結局、ある程度の知識は飼い主自身が持った上で、自分が正しいと思う情報を取捨選択しキャットフードを選ぶ必要があります。
しかし、キャットフードはとにかく種類が多く、国内外合わせると膨大でとても簡単に比較は出来ません。
私はよくペットショップなどでキャットフードを比べてみるのですが、数種類のキャットフードについて原材料や成分を見比べてみるだけでもかなり苦労していました。。。
キャットフードをまとめているようなサイトはいくつもありますし、楽天市場やamazonといったショッピングモールにも多数のキャットフードはあります。
しかしキャットフード検索に特化している訳ではないため、絞り込むための要素が限定的であるという問題があります。
また、キャットフードのを判断する上で必要なコミュニティも不足しています。
これだと、ある程度どういったキャットフードを選ぶべきかが分かっても、最適な選択が難しい可能性があります。
それであれば自分で作ってしまおうと作成したのが、コミュニティ機能も付いたキャットフード検索サービスである「ねこまんま」です。
ターゲット、解決したい課題
このサービスのターゲットはもちろん猫を飼われている全ての方々です。
各ご家庭によってキャットフードの選定基準は、原料や成分にこだわりたい人、特定のメーカーがお気に入りな人、リーズナブルさを求める人、全てを加味したい人、様々だと思います。
「ねこまんま」は、こういった色んなニーズを解決できると考えています。
機能一覧
1. キャットフード閲覧&検索機能
メイン機能になります。国内外700種以上のキャットフードが閲覧出来ます。
様々な情報をデータベース化しているため、成分量で絞り込んだり除外したい原材料をキーワードとして指定するといったことが可能です。
この例だと、タンパク質30%以上、糖質15%以下、原材料に"穀類"というキーワードを含まないキャットフードのみを抽出しています。
2. ウォッチリスト機能
キャットフードを検索し気になったものがあれば、ウォッチリストに追加することが出来ます。
ウォッチリストでは、各情報をテーブル形式で一括比較することが可能なので、同じようなキャットフードの微妙な違いも一目で分かるようになります。
3. お気に入り、レビュー機能
個人的にリストとして残しておきたいようなお気に入りキャットフードは、お気に入り登録することが可能です。
また、ユーザー目線でしか評価できないような、食いつきの良さ、フードの香り、といった主観的な項目についてはレビュー機能で評価が可能です。
4. 相談所機能
キャットフードについて、気になることや困ったことはユーザー同士で相談し合える相談所機能を設けています。
5. 認証関係、マイページ
認証はトークンベースの認証を設けています。また、メールアドレス認証機能やパスワードリセット機能、ゲストログイン機能を実装しています。
マイページではプロフィールの編集や、お気に入りリストの確認が可能です。
6. レスポンシブ対応
レスポンシブデザインを実装しているため、モバイルユーザーの方でもデスクトップユーザーの方でも同じように利用できます。
こだわった点
- 楽天APIを利用してキャットフードの画像や周辺項目を用意することで、視覚的にも分かりやすいサイトとなりました。
- キャットフード検索のロジックを工夫し、柔軟かつ効率的に絞り込み検索が出来るようにしました。
- SSG/SSR/CSRを要所要所で使い分け、高速なページ遷移と良質なユーザー体験を心がけました。
使用技術、選定理由
ここからは使用技術及び、技術選定の理由を列挙します。
技術選定で意識したこと
- 開発するサービスに必要、相性がいいこと
- とりあえずよく使用されていたり、モダンであるからという理由だけではなく、なぜこのサービスに必要なのかという部分が無いと、今後の技術選定において価値が無いと考えました。
- ある程度モダンでシェアが高いこと
- モダンな技術である方が現場で即戦力となれる可能性が高まることや、最新の技術をキャッチアップしていく能力を身に着けることが出来ると考えました。
- また、ある程度シェアのある技術であることは、上記と同様に現場で使用されている可能性が高いことや、情報もそれだけ多いため、エラー対応のハードルが比較的低くなることが考えられます。
- 学習コスト
- 未経験からwebエンジニアへ転向することを考えると、学習すべき内容は非常い多いです。
- そのため、学習コストとそれが実際にサービスにどの程度必要かを考慮しました。
バックエンド
- Ruby:3.2.2
- Ruby on Rails(APIモード):7.0.4.3
- RSpec
- Rubocop
- PostgreSQL:15.3
- 主要gem
フロントエンド
- TypeScript:5.0
- Next.js (React):13
- Tailwind CSS、Preline UI
- ESLint
- Prettier
- 主要package
- axios (HTTPリクエスト用)
- nookies (cookies用)
- react-hook-form (送信フォーム用)
- moment (日時フォーマット用)
インフラ
- バックエンド:Render.com(AvtiveStorageにはS3使用)
- フロントエンド:Vercel
開発環境
とりあえず手持ちのノートPCで開発を進め、なんだかんだで最後まで頑張ってもらいましたが、メモリ8GBは全然スペック足りてませんでした。。。
ER図
インフラ構成図
当初の画面遷移図(Figma)
今後の展望
インフラ関連が知識的にも技術的にも不足しているため、CI/CDパイプライン構築、AWSなどのIaaSに挑戦、IaC化など、サービスを運用しながら学習、アップデートしていきたいと考えています。
また、フロントエンド側のテスティングフレームワークとしてJestを導入していますが、学習コストが高く感じ現段階では触れていません。少しずつ学習を進めていきたいと思います。
おわりに
最後まで読んで下さりありがとうございました。
未経験から完全独学で何とか作りたいサービスを作り切ることが出来ました。
ポートフォリオを作成する中で、やはり作りたいものを自分の手で作ることの出来るwebエンジニアという仕事を必ずしたいという意欲が増しました。
学習やポートフォリオ作成では、あまり辛いという感覚は無く楽しくやりがいを感じることが多かったです。
ネット記事でもあまり見つけることが出来なかったような問題にも直面したため、技術ブログという形で公開していこうと思います。
Ruby on Railsチュートリアル第14章
Railsチュートリアルの第14章を進めていきます。最終章です🐻
第14章(ユーザーをフォローする)
リレーションシップモデル
ユーザーのテーブル、フォロー(following)のテーブル、フォロワー(followers)のテーブルをそれぞれ作成しようとすると、重複した行が多く取り扱いが非常にやっかいとなるため、お互いの関係を考慮したリレーションシップモデルが有効。
リレーションシップという1つのモデルを用いて、フォローしている人から見てもフォロワーから見ても矛盾の無いように抽象化する。
カラム名 | データ型 |
---|---|
id | integer |
follower_id | integer |
followed_id | integer |
created_at | datetime |
updated_at | datetime |
マイグレーションファイルでは、それぞれのインデックスと一意性を担保した複合キーインデックスの3種。これによって、follower_id
とfollowed_id
の組み合わせは重複しないようになり、各要素に対するクエリも高速化される。
class CreateRelationships < ActiveRecord::Migration[7.0] def change create_table :relationships do |t| t.integer :follower_id t.integer :followed_id t.timestamps end add_index :relationships, :follower_id add_index :relationships, :followed_id add_index :relationships, [:follower_id, :followed_id], unique: true end end
User/Relationshipの関連付け
13章のマイクロポストにおけるUser/Micropostモデルの関連付けではhas_many :microposts
としたが、これに対応するMicropostモデルを使用するからこれでよかった。
ただ今回も同じようにhas_many :active_relationships
としてしまうと、期待するモデルでは無いため、モデル名を明示的に渡す必要がある。
更に、マイクロポストモデルにおいてbelongs_to :user
とするのは、Userモデルに属しておりそれを繋げる外部キーとしてuser_idを生成するという機能であった。
Railsは自動的にアンダースコア化したクラス名を元にclass_id
という外部キーを生成(Userクラスであればuser_id、FooBarクラスであればfoo_bar_id)する。
belongs_to :follower
のように書くと外部キーはfollower_idでいいが、関連付けるモデルがfollowerモデルとおかしなことになるため、関連付けるモデルを明示的に示す必要がある。。
クラス名と外部キーを指定したhas_many(削除依存もおまけ)
has_many :active_relationships, class_name: "Relationship", foreign_key: "follower_id", dependent: :destroy
期待するクラス名を指定したbelongs_to
belongs_to :follower, class_name: "User" belongs_to :followed, class_name: "User"
has_many through
has_many throughではthroughオプションで指定した関係を経由した関連付けが形成される。
[app/models/user.rb] has_many :active_relationships, class_name: "Relationship", foreign_key: "follower_id", dependent: :destroy has_many :following, through: :active_relationships, source: :followed
ここでは、has_many :active_relationships
で命名したactive_relationshipsレコードを経由して、followedコレクションを取得できる。ただ、followedの複数形followedsは不適なので、source: :followed
とした上でfollowing
でオーバーライドしている。
passive_relationshipsも併せたユーザーモデル。
[app/models/user.rb] has_many :microposts, dependent: :destroy has_many :active_relationships, class_name: "Relationship", foreign_key: "follower_id", dependent: :destroy has_many :passive_relationships, class_name: "Relationship", foreign_key: "followed_id", dependent: :destroy has_many :following, through: :active_relationships, source: :followed has_many :followers, through: :passive_relationships, source: :follower
[Follow]のWebインターフェイス
memberメソッドを使用したルーティング
member
メソッドは、ユーザーidを含むURLを扱う。この場合だと、/users/1/following
と/users/1/followers
となる。(collection
メソッドというのもあり、これはidを指定しないすべてのリスト)
resources :users do member do get :following, :followers end end
ルーティングに対応するアクションも必要となる。ここで、通常ビューはコントローラのアクションに対応する名前で呼び出す(following.html.erbなど)が、アクション内でrenderで明示的に呼び出すビューを指定しておくことも可能。
[app/controllers/users_controller.rb] before_action :logged_in_user, only: [:index, :edit, :update, :destroy, :following, :followers] . . . def following @title = "Following" @user = User.find(params[:id]) @users = @user.following.paginate(page: params[:page]) render 'show_follow', status: :unprocessable_entity end def followers @title = "Followers" @user = User.find(params[:id]) @users = @user.followers.paginate(page: params[:page]) render 'show_follow', status: :unprocessable_entity end
follow/unfollow用のパーシャル
follow/unfollowの実際の動作としては
followは新しいリレーションシップを作成する ⇒ POSTリクエストによってリレーションシップをcreateする。
unfollowは既存のリレーションシップを探索して削除する ⇒ DELETEリクエストによってリレーションシップをdestroyする。
つまり、ルーティングが必要。followとunfollowに対応するルーティングのみをresourcesで作成しておく。
resources :relationships, only: [:create, :destroy]
ボタン用のパーシャルでは、follow
とunfollow
のパーシャルに作業を振り分けている。
[app/views/users/_follow_form.html.erb] <% unless current_user?(@user) %> <div id="follow_form"> <% if current_user.following?(@user) %> <%= render 'unfollow' %> <% else %> <%= render 'follow' %> <% end %> </div> <% end %>
followパーシャルではfollowed_idをコントローラに送信しないといけないため、hidden_field_tag
で隠し属性としてデータを埋め込んでいる。
[app/views/users/_follow.html.erb] <%= form_with(model: current_user.active_relationships.build) do |f| %> <div><%= hidden_field_tag :followed_id, @user.id %></div> <%= f.submit "Follow", class: "btn btn-primary" %> <% end %>
follow/unfollowページの統合テスト
ここのテストは、フォロー/フォロワーの統計情報があるかassert_match
で調べており、またフォロー/フォロワーそれぞれについてそのユーザーへのパスがあるかをassert_select "a[href=?]"
で調べている。
重要な部分としてassert_not @user.following.empty?
の記述。これは後述するassert_select "a[href=?]"
が「空虚な真」で無いことを保証している。∵@user.following
がemptyで誰もフォローしていないならば、そのあとのブロックにおけるテストが実行されないままスルーされ、実際は何も検証できていないのにテストがパスしてしまう。
[test/integration/following_test.rb] require "test_helper" class Following < ActionDispatch::IntegrationTest def setup @user = users(:michael) log_in_as(@user) end end class FollowPagesTest < Following test "following page" do get following_user_path(@user) assert_response :unprocessable_entity assert_not @user.following.empty? assert_match @user.following.count.to_s, response.body @user.following.each do |user| assert_select "a[href=?]", user_path(user) end end test "followers page" do get followers_user_path(@user) assert_response :unprocessable_entity assert_not @user.followers.empty? assert_match @user.followers.count.to_s, response.body @user.followers.each do |user| assert_select "a[href=?]", user_path(user) end end end
Followボタン
フォローはリレーションシップの作成(create)、フォロー解除はリレーションシップの削除(destroy)となる。すこしここの動作の理解は難しかったです。
create
アクションではUser.find(params[:followed_id])
フォローするユーザーを探しているが、ここのfollowed_id]
は_follow.html.erb
で隠しフィールドとして配置したhidden_field_tag :followed_id, @user.id
によってサポートされている。これによって、followボタンを押した時の動作としてそのユーザーのidをfollowed_idとして受け取れる。
destroy
アクションは、Relationshipモデルの特定のidにおけるfollowedを示す。ここでのfollowed
はbelongs_toでfollowedを紐づけているため、指定したRelationshipモデルの中のfollowed_idを有するユーザーを返す動作。
更に、current_userを主体とすることで、ログインしていないユーザーが直接この動作にアクセスしようとした場合にエラーを発生させることが出来る。
[app/controllers/relationships_controller.rb] class RelationshipsController < ApplicationController before_action :logged_in_user def create user = User.find(params[:followed_id]) current_user.follow(user) redirect_to user end def destroy user = Relationship.find(params[:id]).followed current_user.unfollow(user) redirect_to user, status: :see_other end end
respond_toメソッド
通常のリクエストとTurboによるリクエストを統一的に扱い、どちらかを実行するようにrespond_toで制御できる。if-else的な処理。
respond_to do |format| format.html { redirect_to user, status: :see_other } format.turbo_stream end
ステータスフィード
フィードの実装要件としては、フォローしているユーザーとユーザー自身のマイクロポストのみを含めて、フォローしていないユーザーのものは含めない。
ステータスフィードを返す記述
現在のユーザーに対応するユーザーのマイクロポストを抽出する場合は、Micropost.where("user_id = ?", id)
のみでokだった。
今回の実装に使用する記述は少し複雑。
Micropost.where("user_id IN (?) OR user_id = ?", following_ids, id)
中身としては、①Micropost.where("user_id IN (?), following_ids)
と、②Micropost.where("user_id = ?", id)
の2つに分けられる。
①の部分
following_ids
はhas_many :following
関連付けを行うとActive Recordによって自動生成されるメソッドであり、following.map(&:id)
を意味しており、user.followingにある各要素のidを呼び出し、フォローしているユーザーのidを配列として利用可能となる。
SQLのIN
句は、複数の値に合致するデータを抽出する動作なので、following_ids
で得られた配列に対しIN
句を利用することで、フォローしているユーザーのマイクロポストのデータを取得可能。
②の部分
これは元々のuser自身のマイクロポストを全て返す動作。
これによって、①と②の組み合わせで①フォローしているユーザーと②ユーザー自身のマイクロポストが取得できる。
また、?よりも同じ変数を複数の場所に挿入する場合はハッシュ形式でも便利。
Micropost.where("user_id IN (:following_ids) OR user_id = :user_id", following_ids: following_ids, user_id: id)
サブセレクト
フィードの件数が膨大になるとスケール(処理量の調節)されないためる問題をSQLのサブセレクトで解決可能。
ここは以下の質問を参考にしました。サブセレクトを活用することでデータベースへの問い合わせがより少ない手法で記述することが可能といったところですかね。
N+1クエリ問題、eagerloading
Micropost.where("user_id IN (:following_ids) OR user_id = :user_id",following_ids: following_ids, user_id: id)
の記述だと、マイクロポストのみをクエリしているからマイクロポストの取り出し自体はクエリ1件で済むが、その結果を元にユーザーや添付画像をクエリする必要があるため、追加でN~2N件のクエリが必要となる。これがN+1クエリ問題。
1件のクエリの中にその他の必要なデータを取り出すクエリも含める手法がeager loading。これによって、トータル1件のクエリのみで済むようになる。
Micropost.where("user_id IN (#{following_ids}) OR user_id = :user_id", user_id: id) .includes(:user, image_attachment: :blob)
第14章ー演習ー
14.1.1
①図 14.7のid=1のユーザーに対してuser.following.map(&:id)を実行すると、結果はどのようになるでしょうか? 想像してみてください。(ヒント: 4.3.2で紹介したmap(&:method_name)のパターンを思い出してください。例えばuser.following.map(&:id)の場合、idの配列を返します。)
(解答) ["2", "7", "8", "10"]の配列が返される。
②図 14.7を参考にして、id=2のユーザーに対してuser.followingを実行すると、結果はどのようになるでしょうか? また、同じユーザーに対してuser.following.map(&:id)を実行すると、結果はどのようになるでしょうか? 想像してみてください。
(解答)id=1のユーザーの情報が返される。["1"]の配列が返される。
14.1.2
①コンソールを開き、表 14.1のcreateメソッドを使ってActiveRelationshipを作ってみましょう。データベース上に2人以上のユーザーを用意し、最初のユーザーが2人目のユーザーをフォローしている状態を作ってみてください。
(解答)
irb(main):002:0> user = User.first User Load (2.7ms) SELECT "users".* FROM "users" ORDER BY "users"."id" ASC LIMIT ? [["LIMIT", 1]] => #<User:0x00007f7de5289f00 ... irb(main):003:0> other_user = User.second User Load (0.6ms) SELECT "users".* FROM "users" ORDER BY "users"."id" ASC LIMIT ? OFFSET ? [["LIMIT", 1], ["OFFSET", 1]] => #<User:0x00007f7de6c51e88 ... irb(main):004:0> user.active_relationships.create(followed_id: other_user.id) TRANSACTION (0.1ms) begin transaction User Load (0.4ms) SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ? [["id", 1], ["LIMIT", 1]] User Load (0.2ms) SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ? [["id", 2], ["LIMIT", 1]] Relationship Create (8.6ms) INSERT INTO "relationships" ("follower_id", "followed_id", "created_at", "updated_at") VALUES (?, ?, ?, ?) [["follower_id", 1], ["followed_id", 2], ["created_at", "2023-04-21 14:59:00.445063"], ["updated_at", "2023-04-21 14:59:00.445063"]] TRANSACTION (13.2ms) commit transaction => #<Relationship:0x00007f7de512cec8 id: 1, follower_id: 1, followed_id: 2, created_at: Fri, 21 Apr 2023 14:59:00.445063000 UTC +00:00, updated_at: Fri, 21 Apr 2023 14:59:00.445063000 UTC +00:00>
②先ほどの演習を終えたら、active_relationship.followedの値とactive_relationship.followerの値を確認し、それぞれの値が正しいことを確認してみましょう。
(解答)正しい
irb(main):009:0> user.active_relationships Relationship Load (0.4ms) SELECT "relationships".* FROM "relationships" WHERE "relationships"."follower_id" = ? [["follower_id", 1]] => [#<Relationship:0x00007f7de512cec8 id: 1, follower_id: 1, followed_id: 2, created_at: Fri, 21 Apr 2023 14:59:00.445063000 UTC +00:00, updated_at: Fri, 21 Apr 2023 14:59:00.445063000 UTC +00:00>]
14.1.3
①リスト 14.5のバリデーションをコメントアウトしても、テストが成功したままになっていることを確認してみましょう。(以前のRailsのバージョンでは、このバリデーションが必須でしたが、Rails 5以降は必須ではなくなりました。ここでは念のためこのバリデーションを省略していませんが、このバリデーションが省略されているのを見かけるかもしれないので、覚えておくと良いでしょう。)
(解答) 飛ばします。
14.1.4
①コンソールを開き、リスト 14.9のコードを順々に実行してみましょう。
(解答)①②合わせて解答
②先ほどの演習の各コマンド実行時の結果を見返してみて、実際にはどんなSQLが出力されたのか確認してみましょう。
(解答)
irb(main):001:0> user = User.second User Load (2.2ms) SELECT "users".* FROM "users" ORDER BY "users"."id" ASC LIMIT ? OFFSET ? [["LIMIT", 1], ["OFFSET", 1]] => #<User:0x00007f445f438f00 ... irb(main):002:0> other_user = User.third User Load (0.4ms) SELECT "users".* FROM "users" ORDER BY "users"."id" ASC LIMIT ? OFFSET ? [["LIMIT", 1], ["OFFSET", 2]] => #<User:0x00007f445f32ad98 ... irb(main):003:0> user.following?(other_user) User Exists? (1.2ms) SELECT 1 AS one FROM "users" INNER JOIN "relationships" ON "users"."id" = "relationships"."followed_id" WHERE "relationships"."follower_id" = ? AND "users"."id" = ? LIMIT ? [["follower_id", 2], ["id", 3], ["LIMIT", 1]] => false irb(main):004:0> user.follow(other_user) TRANSACTION (0.6ms) begin transaction User Load (0.2ms) SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ? [["id", 2], ["LIMIT", 1]] Relationship Create (53.3ms) INSERT INTO "relationships" ("follower_id", "followed_id", "created_at", "updated_at") VALUES (?, ?, ?, ?) [["follower_id", 2], ["followed_id", 3], ["created_at", "2023-04-22 02:03:25.103881"], ["updated_at", "2023-04-22 02:03:25.103881"]] TRANSACTION (14.5ms) commit transaction User Load (0.5ms) SELECT "users".* FROM "users" INNER JOIN "relationships" ON "users"."id" = "relationships"."followed_id" WHERE "relationships"."follower_id" = ? [["follower_id", 2]] => [#<User:0x00007f445f32ad98 id: 3, name: "Walker Lindgren", email: "example-2@railstutorial.org", created_at: Thu, 20 Apr 2023 11:37:02.392632000 UTC +00:00, updated_at: Thu, 20 Apr 2023 11:37:02.392632000 UTC +00:00, password_digest: "[FILTERED]", remember_digest: nil, admin: false, activation_digest: "$2a$12$UAinDn/X6ZmPb/RsLTPQpuGyKsXOrSy4rO6IHCk/ddD06WF2ohmgq", activated: true, activated_at: Thu, 20 Apr 2023 11:37:02.138438000 UTC +00:00, reset_digest: nil, reset_sent_at: nil>] irb(main):005:0> user.following?(other_user) => true irb(main):006:0> user.unfollow(other_user) TRANSACTION (4.0ms) begin transaction Relationship Delete All (12.5ms) DELETE FROM "relationships" WHERE "relationships"."follower_id" = ? AND "relationships"."followed_id" = ? [["follower_id", 2], ["followed_id", 3]] TRANSACTION (13.4ms) commit transaction => [#<User:0x00007f445f32ad98 id: 3, name: "Walker Lindgren", email: "example-2@railstutorial.org", created_at: Thu, 20 Apr 2023 11:37:02.392632000 UTC +00:00, updated_at: Thu, 20 Apr 2023 11:37:02.392632000 UTC +00:00, password_digest: "[FILTERED]", remember_digest: nil, admin: false, activation_digest: "$2a$12$UAinDn/X6ZmPb/RsLTPQpuGyKsXOrSy4rO6IHCk/ddD06WF2ohmgq", activated: true, activated_at: Thu, 20 Apr 2023 11:37:02.138438000 UTC +00:00, reset_digest: nil, reset_sent_at: nil>] irb(main):007:0> user.following?(other_user) => false irb(main):008:0> user.follow(user) => nil irb(main):009:0> user.following?(user) => false
14.1.5
①コンソールを開き、何人かのユーザーが最初のユーザーをフォローしている状況を作ってみてください。最初のユーザーをuserとすると、user.followers.map(&:id)の値はどのようになっているでしょうか?
(解答)
irb(main):004:0> user_2.follow(user) TRANSACTION (0.1ms) begin transaction User Load (0.3ms) SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ? [["id", 2], ["LIMIT", 1]] Relationship Create (23.9ms) INSERT INTO "relationships" ("follower_id", "followed_id", "created_at", "updated_at") VALUES (?, ?, ?, ?) [["follower_id", 2], ["followed_id", 1], ["created_at", "2023-04-22 02:13:50.728684"], ["updated_at", "2023-04-22 02:13:50.728684"]] TRANSACTION (23.9ms) commit transaction User Load (0.4ms) SELECT "users".* FROM "users" INNER JOIN "relationships" ON "users"."id" = "relationships"."followed_id" WHERE "relationships"."follower_id" = ? [["follower_id", 2]] => [#<User:0x00007f9046bedd30 id: 1, name: "Example User", email: "example@railstutorial.org", created_at: Thu, 20 Apr 2023 11:37:00.512405000 UTC +00:00, updated_at: Thu, 20 Apr 2023 13:05:51.230122000 UTC +00:00, password_digest: "[FILTERED]", remember_digest: "$2a$12$kGwCwjO5pzAkENniN.5WM.JYJySA36du6FdeLif1aUu.CfZ7D2zgy", admin: true, activated: true, activated_at: Thu, 20 Apr 2023 11:37:00.235450000 UTC +00:00, reset_digest: nil, reset_sent_at: nil>] irb(main):005:0> user_3.follow(user) TRANSACTION (0.2ms) begin transaction User Load (0.3ms) SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ? [["id", 3], ["LIMIT", 1]] Relationship Create (2.0ms) INSERT INTO "relationships" ("follower_id", "followed_id", "created_at", "updated_at") VALUES (?, ?, ?, ?) [["follower_id", 3], ["followed_id", 1], ["created_at", "2023-04-22 02:14:01.600044"], ["updated_at", "2023-04-22 02:14:01.600044"]] TRANSACTION (14.1ms) commit transaction User Load (0.4ms) SELECT "users".* FROM "users" INNER JOIN "relationships" ON "users"."id" = "relationships"."followed_id" WHERE "relationships"."follower_id" = ? [["follower_id", 3]] => [#<User:0x00007f9046bedd30 id: 1, name: "Example User", email: "example@railstutorial.org", created_at: Thu, 20 Apr 2023 11:37:00.512405000 UTC +00:00, updated_at: Thu, 20 Apr 2023 13:05:51.230122000 UTC +00:00, password_digest: "[FILTERED]", remember_digest: "$2a$12$kGwCwjO5pzAkENniN.5WM.JYJySA36du6FdeLif1aUu.CfZ7D2zgy", admin: true, activation_digest: "$2a$12$YzmHowrQGG3J3BEaIPkU8uTQaelLF5edsPAHId6kYykM1fPSO8EQW", activated: true, activated_at: Thu, 20 Apr 2023 11:37:00.235450000 UTC +00:00, reset_digest: nil, reset_sent_at: nil>] irb(main):006:0> user.followers.map(&:id) User Load (0.5ms) SELECT "users".* FROM "users" INNER JOIN "relationships" ON "users"."id" = "relationships"."follower_id" WHERE "relationships"."followed_id" = ? [["followed_id", 1]] => [2, 3]
②上の演習が終わったら、user.followers.countの実行結果が、先ほどフォローさせたユーザー数と一致していることを確認してみましょう。
(解答) 一致している。
irb(main):008:0> user.followers.count User Count (0.5ms) SELECT COUNT(*) FROM "users" INNER JOIN "relationships" ON "users"."id" = "relationships"."follower_id" WHERE "relationships"."followed_id" = ? [["followed_id", 1]] => 2
③user.followers.countを実行した結果、出力されるSQL文はどのような内容になっているでしょうか? また、user.followers.to_a.countの実行結果と違っている箇所はありますか?(ヒント: もしuserに100万人のフォロワーがいた場合、どのような違いがあるでしょうか? 考えてみてください。)
(解答) SQL文は②に記載。"users" INNER JOIN "relationships" ON "users"."id" = "relationships"."follower_id"
の部分はusersテーブルのidとrelationshipsテーブルのfollower_idが共通しており、その部分の情報からwhereメソッドでfollower_idの行のみを
抽出してcountを返すため高速。user.followers.to_a.countは全フォロワーを取得しそれを配列に変換して、そのカウントを取るから莫大なデータ量になると当然遅くなるはず。
14.2.1
①コンソールを開いて、User.first.followers.countの結果がリスト 14.14で期待される結果と一致していることを確認してみましょう。
(解答)
irb(main):001:0> User.first.followers.count User Load (1.1ms) SELECT "users".* FROM "users" ORDER BY "users"."id" ASC LIMIT ? [["LIMIT", 1]] User Count (1.0ms) SELECT COUNT(*) FROM "users" INNER JOIN "relationships" ON "users"."id" = "relationships"."follower_id" WHERE "relationships"."followed_id" = ? [["followed_id", 1]] => 38
②上の演習と同様に、User.first.following.countの結果も一致していることを確認してみましょう。
(解答)
irb(main):002:0> User.first.following.count User Load (0.4ms) SELECT "users".* FROM "users" ORDER BY "users"."id" ASC LIMIT ? [["LIMIT", 1]] User Count (2.1ms) SELECT COUNT(*) FROM "users" INNER JOIN "relationships" ON "users"."id" = "relationships"."followed_id" WHERE "relationships"."follower_id" = ? [["follower_id", 1]] => 49
14.2.2
①ブラウザから/users/2にアクセスし、フォローボタンが表示されていることを確認してみましょう。同様に、/users/5では[Unfollow]ボタンが表示されているはずです。さて、/users/1にアクセスすると、どのような結果が表示されるでしょうか?
(解答)それぞれフォローボタン、アンフォローボタンが表示される。自分自身には何も表示されない。
②ブラウザからHomeページとプロフィールページを表示してみて、統計情報が正しく表示されているか確認してみましょう。
(解答) どちらも正しく表示されている。
③Homeページに表示されている統計情報に対してテストを書いてみましょう。同様にして、プロフィールページにもテストを追加してみましょう。(ヒント: リスト 13.29で示したテストに追加してみてください。)
(解答)assert_match
でfollowing.count.to_s
とfollowers.count.to_s
があることを確認。
Homeページのレイアウトテスト
[test/integration/site_layout_test.rb] test "root path layout links with logged in user" do log_in_as(@user) get root_path assert_template 'static_pages/home' assert_select "a[href=?]", root_path, count: 2 assert_select "a[href=?]", help_path assert_select "a[href=?]", about_path assert_select "a[href=?]", contact_path assert_select "a[href=?]", users_path assert_select "a[href=?]", user_path(@user) assert_select "a[href=?]", edit_user_path(@user) assert_select "a[href=?]", logout_path assert_match @user.following.count.to_s, response.body assert_match @user.followers.count.to_s, response.body 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', count: 1 @user.microposts.paginate(page: 1).each do |micropost| assert_match micropost.content, response.body end assert_match @user.following.count.to_s, response.body assert_match @user.followers.count.to_s, response.body end
14.2.3
①ブラウザで/users/1/followersと/users/1/followingを開き、それぞれが適切に表示されていることを確認してみましょう。サイドバーにある画像のリンクが正常に機能していることも確認してみましょう。
(解答) 画像リンクも含めて正しく動作している。
②リスト 14.29のassert_selectのテストが正しく動作することを、関連するアプリケーションのコードをコメントアウトして確認してみましょう。
(解答) この演習は少し手間取りました。実際のfollwingなどのページを見ると分かりやすいのですが、assert_select "a[href=?]", user_path(user)
にマッチする部分は、gravatarで表示される画像部分(link_to gravatar
)とユーザー一覧が表示される部分( render @users
)と2つあるので、該当する箇所を2箇所コメントアウトする必要があります。
<% provide(:title, @title) %> <div class="row"> <aside class="col-md-4"> <section class="user_info"> <%= gravatar_for @user %> <h1><%= @user.name %></h1> <span><%= link_to "view my profile", @user %></span> <span><strong>Microposts:</strong> <%= @user.microposts.count %></span> </section> <section class="stats"> <%= render 'shared/stats' %> <% if @users.any? %> <div class="user_avatars"> <% @users.each do |user| %> <%#= link_to gravatar_for(user, size: 30), user %> <% end %> </div> <% end %> </section> </aside> <div class="col-md-8"> <h3><%= @title %></h3> <% if @users.any? %> <ul class="users follow"> <%#= render @users %> </ul> <%= will_paginate %> <% end %> </div> </div>
これでREDになる。
FAIL FollowPagesTest#test_followers_page (8.32s) Expected at least 1 element matching "a[href="/users/409608538"]", found 0.. Expected 0 to be >= 1. test/integration/following_test.rb:29:in `block (2 levels) in <class:FollowPagesTest>' test/integration/following_test.rb:28:in `block in <class:FollowPagesTest>' FAIL FollowPagesTest#test_following_page (8.48s) Expected at least 1 element matching "a[href="/users/409608538"]", found 0.. Expected 0 to be >= 1. test/integration/following_test.rb:19:in `block (2 levels) in <class:FollowPagesTest>' test/integration/following_test.rb:18:in `block in <class:FollowPagesTest>'
14.2.4
①ブラウザ上から/users/2を開き、[Follow]と[Unfollow]を実行してみましょう。うまく機能しているでしょうか?
(解答)機能している。
②先ほどの演習を終えたら、Railsサーバーのログを見てみましょう。フォロー/フォロー解除が実行されると、それぞれどのテンプレートが描画されているでしょうか?
(解答)まず、どちらも共通でshow.html.erbでその中のfollow_formパーシャルのif-else分岐がおこなわれる。フォローすると、if current_user.following?(@user)
がtrueなので、render 'unfollow'
。フォロー解除すると、if current_user.following?(@user)
がelseなので、render 'follow'
となる。
14.2.5
①フォロー機能やフォロー解除機能を「標準の方法」または「Turboによる方法」で実現する方法の違いは、Turboは標準の方法よりもサーバーリクエストが1回多いという点しかありません。このため、Turboが確実に動作しているかどうかを判定するのが難しくなることがあります。リスト 14.35およびリスト 14.36にあるフォロワー数の後ろに一時的に「Turbo利用中」という文字を追加し、次に[Follow]ボタンや[Unfollow]ボタンをクリックして、Turboのテンプレートが確実にレンダリングすることを確認してみてください。確認が終わったら元に戻しておきましょう。
(解答) 確かにTurbo利用中と表示された。
14.3.1
①マイクロポストのidが数字の小さい順に並び、数字が大きいほど新しいと仮定すると、図 14.22のデータセットでuser.feed.map(&:id)を実行すると、どのような結果が表示されるでしょうか? 考えてみてください。(ヒント: 13.1.4の実装で使ったdefault_scopeを思い出してください。)
(解答) default_scope -> { order(created_at: :desc) }
としているため、マイクロポストの作成日時が新しい順(この例だとマイクロポストのidが大きい方から)に並ぶ。
14.3.2
①リスト 14.41において、現在のユーザー自身の投稿を含めないようにするにはどうすれば良いでしょうか? また、そのような変更を加えると、リスト 14.39のどのテストが失敗するでしょうか?
(解答) Micropost.where("user_id IN (?)", following_ids)
のみを記述。ユーザー自身の投稿が入らないため以下の部分がRED。
michael.microposts.each do |post_self| assert michael.feed.include?(post_self) end
②リスト 14.41において、フォローしているユーザーの投稿を含めないようにするにはどうすれば良いでしょうか? また、そのような変更を加えると、リスト 14.39のどのテストが失敗するでしょうか?
(解答)Micropost.where("user_id = ?", id)
のみを記述。フォローしているユーザーの投稿が入らないため以下の部分がRED。
lana.microposts.each do |post_following|
assert michael.feed.include?(post_following)
③リスト 14.41において、フォローしていないユーザーの投稿を含めるためにはどうすれば良いでしょうか? また、そのような変更を加えると、リスト 14.39のどのテストが失敗するでしょうか?(ヒント: 自分自身とフォローしているユーザー、そしてそれ以外という集合は、いったいどういった集合を表すのか考えてみてください。)
(解答)Micropost.all
でマイクロポストの全ての集合となる。フォローしていないユーザーも入るため以下の部分がRED。
archer.microposts.each do |post_unfollowed|
assert_not michael.feed.include?(post_unfollowed)
14.3.3
①Homeページで表示される1ページ目のフィードに対して、統合テストを書いてみましょう。リスト 14.49はそのテンプレートです。
(解答)
test "feed on Home page" do get root_path @user.feed.paginate(page: 1).each do |micropost| assert_match CGI.escapeHTML(micropost.content), response.body end end
②リスト 14.49のコードでは、期待されるHTMLをCGI.escapeHTMLメソッドでエスケープしています(このメソッドは11.2.3で扱ったCGI.escapeと密接に関連します)。このコードでHTMLをエスケープする必要がある理由を考えてみてください。(ヒント: 試しにエスケープ処理を削除して、得られるHTMLのソースコードと一致しないマイクロポストのコンテンツがないかどうか、注意深く調べてみてください。また、ターミナルの検索機能Cmd-FもしくはCtrl-Fで「sorry」を検索すると、原因の究明に役立つでしょう。)
(解答)CGIエスケープしないとI'm sorry
のようなエスケープされていない文字列が引っかかっる。
③リスト 14.44のコードは、実はRailsのleft_outer_joinsメソッドを使うと、いわゆるLEFT OUTER JOINで直接表現できます。リスト 14.50のコード23 を適用してテストを実行し、このコードが返すフィードがテストでパスすることを確かめてみましょう。 残念ながら、テストがパスするにもかかわらず、実際のフィードにはユーザー自身のマイクロポストがいくつも重複表示されている(図 14.25)24 ので、次はリスト 14.51のテストを使ってこのエラーをキャッチしてください。このテストで使っているdistinctは、コレクション内の要素を重複抜きで返します。エラーをキャッチできたら、クエリにdistinctメソッドを追加したコード(リスト 14.52)に置き換えるとテストが green になることを確かめてください。次は、生成されたSQLを直接調べて、DISTINCTという語がクエリ自身に含まれていることを確認してください。これは、DISTINCTを指定した要素がアプリケーションのメモリ上ではなく、データベース上で効率よくSELECTされていることを示しています。(ヒント: SQLを直接調べるには、RailsコンソールでUser.first.feedを実行します。)
(解答)SELECT DISTINCTというSQLが含まれていた。
irb(main):002:0> User.first.feed.paginate(page: 1) User Load (0.3ms) SELECT "users".* FROM "users" ORDER BY "users"."id" ASC LIMIT ? [["LIMIT", 1]] Micropost Load (2.5ms) SELECT DISTINCT "microposts".* FROM "microposts" LEFT OUTER JOIN "users" ON "users"."id" = "microposts"."user_id" LEFT OUTER JOIN "relationships" ON "relationships"."followed_id" = "users"."id" LEFT OUTER JOIN "users" "followers_users" ON "followers_users"."id" = "relationships"."follower_id" WHERE (relationships.follower_id = 1 or microposts.user_id = 1) ORDER BY "microposts"."created_at" DESC LIMIT ? OFFSET ? [["LIMIT", 30], ["OFFSET", 0]]
第14章ーまとめー
- has_manyでは指定した名称からモデルとの関連付けをおこなう(:micropostであればMicropostモデル)。belongs_toでは自動的に外部キーを生成する(:userであればuser_id)。これらが実際に紐づけたいものが異なる場合は、明示的に指定する必要がある。
- has_many throughで中間のモデルを経由した関連付けが可能となる。
- ユーザーのフォロー/フォローの解除はリレーションシップ(関係性)の作成/削除に該当する。
- ルーティングはmemberメソッドやcollectionメソッドでネストさせることが出来る。
- サブセレクトを活用することでSQLクエリの高速化を実現できる。
- eager loadingによって1件のSQLクエリに複数のクエリを含めることによって、N+1クエリ問題を回避できる。
ちゃんと理解を深めた完走を達成出来ました。このブログでは、特に理解が難しかったところは紐解いておくようにしたので、今後同じようなロジックの部分で止まったときも自分なりに分かるように書いてあるリファレンスとして活用出来ると思います。
第14章、Ruby on Railsチュートリアルはこれにて終わり🐻
Ruby on Railsチュートリアル第13章
第13章(ユーザーのマイクロポスト)
Micropostモデル
マイグレーション
マイクロポストを生成する際はrails generate model Micropost content:text user:references
として、user:references
を引数に含める。これによって、マイグレーションファイルにt.references :user, null: false, foreign_key: true
という行が追加される。これは、foreign_key: true
によって外部キー制約が課され、マイクロポストがusersテーブルのidを参照し、自動的にuser_id
というカラムを作成する。
さらに、add_index :microposts, [:user_id, :created_at]
というインデックスカラム(ここでは両方のキーを同時に持つ複合キーインデックス)を付与することで、user_idに関連付けられたマイクロポストを順序だてて取り出せる。
カラム名 | データ型 |
---|---|
id | integer |
content | text |
user_id | integer |
created_at | datetime |
updated_at | datetime |
User/Micropost関連付け
Userはhas_many
で0以上の複数のマイクロポストを持っており、Micropostはbelongs_to
で必ず1人のユーザーに属している。そのため、マイクロポストに対するアクションは、Micropost.create
ではなくuser.microposts.create
のように、ユーザーと関連付けを経由したマイクロポストが作成される。
@micropost = Micropost.new(content: "Lorem ipsum", user_id: @user.id)
は、@micropost = @user.microposts.build(content: "Lorem ipsum")
と記述するのが正しい。
newとbuildについて
newとbuildはどちらもオブジェクトを生成するメソッドで、buildはnewのエイリアスとなっているようで、機能としては全く同一(もともとはこうでは無かったようです)。
慣習的に今回のように、モデルに関連付けられたオブジェクトを生成する場合に使用するらしい。
デフォルトスコープ
マイクロポストの読み出しは順序に対する保証は何もないが、これを順序立てて表示するのがdefault scopeというテクニック。
この機能はアプリケーション側の実装が間違っていてもテストが成功するという問題に陥りやすい部分。
default_scope -> { order(created_at: :desc) }
という記述であれば、created_at
の降順(desc)で並び替えが可能。デフォルトスコープのデフォルトは昇順。
fixture
デフォルトスコープをテストするためのfixtureでは、以下のようにERB形式でcreated_at
を更新できる。また、user:michaelと指定すると、ユーザーfixtureのmichaelのマイクロポストであることを指示可能。
orange: content: "I just ate an orange!" created_at: <%= 10.minutes.ago %> user: michael
関連付けられたオブジェクトの削除
has_many :microposts
をhas_many :microposts, dependent: :destroy
と指定すると、ユーザー削除された際にマイクロポストも削除されるようになる。
マイクロポストの表示
10章のユーザー一覧を表示したり、ページネーションしたりした部分と酷似している。
マイクロポスト一覧を表示するshowアクションは、ユーザーとマイクロポストとの関連付けを経由してマイクロポストの情報をpaginateしている。
[app/controllers/users_controller.rb] def show @user = User.find(params[:id]) @microposts = @user.microposts.paginate(page: params[:page]) end
マイクロポストの操作
マイクロポストリソースに対するコントローラのアクションは、createとdestroyがあれば十分。resources :microposts, only: [:create, :destroy]
マイクロポストはnew画面を作成しない代わりに、ルートパス(home)にマイクロポスト投稿のフォームを配置する。
createアクション
ユーザーのcreateアクション@user = User.new(user_params)
と似ており、@micropost = current_user.microposts.build(micropost_params)
でオブジェクトを生成している。ここでは、現在のユーザーを返すcurrent_userに紐づけて、関連付けをおこなう生成メソッドなのでnewではなくbuildとしている。更に、micropost_params
でcontent属性のみを許可するStrong Parametersとしている。
[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', status: :unprocessable_entity end end def destroy end private def micropost_params params.require(:micropost).permit(:content) end end
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> <% else %> ・ ・ ・ <% end %>
マイクロポストパーシャル
'shared/micropost_form’に該当するパーシャルは以下の記述。
[app/views/shared/_micropost_form.html.erb] <%= form_with(model: @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 %>
form_withに渡す@micropost
第7章のsignup(new)ページでは、form_withに渡す変数@userの情報@user = User.new
をnewアクションで定義して渡していた。
今回のform_with(model: @micropost)
においても、homeページであるためhomeアクションに@micropostを定義してあげる必要がある。
[app/controllers/static_pages_controller.rb] def home @micropost = current_user.microposts.build if logged_in? end
エラーメッセージのパーシャル
エラーメッセージのパーシャルは、変数@userのエラーを参照することを前提として作成していたが、このままだと@miropostに対するエラーを表示できない。
ユーザーのnewビューにおけるパーシャルの呼出し(変更前)
[app/views/users/new.html.erb] <%= form_with(model: @user) do |f| %> <%= render 'shared/error_messages' %>
エラーメッセージのパーシャル(変更後)
[app/views/shared/_error_messages.html.erb] <% if @user.errors.any? %> <div id="error_explanation"> <div class="alert alert-danger"> The form contains <%= pluralize(@user.errors.count, "error") %>. </div> <ul> <% @user.errors.full_messages.each do |msg| %> <li><%= msg %></li> <% end %> </ul> </div> <% end %>
この場合は、<%= render 'shared/error_messages', object: f.object %>
とするとobject: f.object
のおかげでerror_messages
パーシャルの中に変数objectを作成出来る。これであれば、どのような変数であっても対応できる。
ユーザーのnewビューにおけるパーシャルの呼出し(変更前)
[app/views/users/new.html.erb] <%= form_with(model: @user) do |f| %> <%= render 'shared/error_messages', object: f.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 %>
フィード
フィード(タイムライン)はすべてのUserが持つものだからUserモデルで作成。
def feed Micropost.where("user_id = ?", id) end
ここで、「?」はプレースホルダと呼ばれ、効果としてはSQLクエリに代入する前にidがエスケープされ、SQLインジェクションを回避できる。SQL文に変数を代入する際は常にエスケープする。
作成したフィードメソッドは、homeアクションに@feed_items = current_user.feed.paginate(page: params[:page])
として追加する。
フィードのパーシャル
フィードのパーシャルにおいて、render @feed_items
が少し難しい。記述の中身は以下の通り
[app/views/shared/_feed.html.erb] <% if @feed_items.any? %> <ol class="microposts"> <%= render @feed_items %> </ol> <%= will_paginate @feed_items %> <% end %>
通常renderメソッドは、render 'shared/error_massages'
のようにパーシャルを指定する。今回のような下記方は10章などでrender @user
のような書き方も出てきたが、この場合はUserクラスの変数@userを指定しているため自動的に_user.html.erb
というパーシャルを自動的に探すため分かりやすい。
今回のrender @feed_items
は_feed_items.html.erb
というパーシャルを探すのではなくマイクロポストのパーシャル_micropost.html.erb
を呼び出している。これは、@feed_itemsの属しているクラスがMicropostクラスであるため、そのクラスに該当するパーシャルを該当するディレクトリ「app/views/microposts/_micropost.html.erb」から探す仕組みらしい。
つまりは、指定している変数そのものの名前に由来するパーシャルというよりは、変数が属しているクラスに由来する名前のパーシャルを探すという動作。
ここで疑問なのが、<%= render @feed_items %>
の動作がマイクロポストのパーシャルを呼び出す動作であれば、<%= render 'microposts/micropost' %>
と直でそのパーシャルを指定した方が読み手も分かりやすいのではないかと思う。
chatGPTさんに聞いてみたところ、「<%= render 'microposts/micropost' %>という形式でも、同じパーシャルを呼び出すことができます。ただし、この場合、@feed_itemsがMicropostの配列であることを明示的に示す必要があります。具体的には、<%= render partial: 'microposts/micropost', collection: @feed_items %>と書く必要があります。ただし、この方法は少し冗長であるため、通常は<%= render @feed_items %>という形式が好まれます。」との回答。
つまり、render @feed_items
という記述はrender 'microposts/micropost'
を指定すると同時に@feed_itemsそのものがMicropostの配列であることを示していることになる。初学者には、冗長でも<%= render partial: 'microposts/micropost', collection: @feed_items %>
とした方が一目で理解は出来そう。。。
マイクロポストの削除
検索して削除する。
検索はユーザーの場合はUser.find(params[:id])
の形であったがマイクロポストは@micropost = current_user.microposts.find_by(id: params[:id])
で関連付けを経由してfind_byメソッドで探している。これによって、別のユーザーが他人のマイクロポストを削除しようとしてもnilが返される。(ユーザー削除は、beforeアクションでadmin_userであることを指定していたから問題ない?)
削除は検索で定義した@micropostを@micropost.destroyで削除する。
リファラー
削除された後の以降ページは特定のページでは無く元のページに戻る方が適切なので、リファラー(referrer)の示す参照ページに飛ぶのが好ましい。フレンドリーフォワーディング機能のrequest.original_url
は、”現在”のリクエストのURLを返すため、ログインしていないユーザーがログインページへリダイレクトされ、ログインした後に保存したURLへ飛ぶ機能。リファラーのrequest.referrer
は、現在のリクエストを行う”直前”にユーザーがいたページのURLを返すため、少し異なる。
def destroy @micropost.destroy flash[:success] = "Micropost deleted" if request.referrer.nil? redirect_to root_url, status: :see_other else redirect_to request.referrer, status: :see_other end end
マイクロポストの画像投稿
Active Strage
メディアのアップロードやアップロードした画像の成形等にも使用可能。 railsguides.jp
- has_one_attechedオプションでは、Active Recordオブジェクト(マイクロポストなど)1件につき1件のファイル(画像など)が添付可能なオプション。has_many_attachedオプションは複数添付可能。
- Active Strage APIのattachメソッドでは、アップロードされた画像を@micropostオブジェクトにアタッチ(添付)出来る。この場合、params.requireにimage属性も許可しておくようにする。
- Active Storageにフォーマット機能やバリデーション機能を付与するには、
active_storage_validations
というgemを追加することで対応可能。これによって、メディアタイプやファイルサイズにバリデーションを付けることが出来る。 - 画像サイズ(縦幅や横幅)に対するバリデーションはImageMagickというプログラムを用いることで可能。
第13章ー演習ー
13.1.1
①RailsコンソールでMicropost.newを実行し、インスタンスを変数micropostに代入してください。その後、user_idに最初のユーザーのidを、contentに "Lorem ipsum" をそれぞれ代入してみてください。この時点では、 micropostオブジェクトのマジックカラムであるcreated_atとupdated_atには何が入っているでしょうか?
(解答)
irb(main):004:0> micropost => #<Micropost:0x00007fe3ce19bae0 id: nil, content: "Lorem ipsum", user_id: 1, created_at: nil, updated_at: nil>
②先ほど作ったオブジェクトを使って、micropost.userを実行してみましょう。どのような結果が返ってくるでしょうか? また、micropost.user.nameを実行した場合の結果はどうなるでしょうか?
(解答)
irb(main):005:0> micropost.user User Load (0.3ms) SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ? [["id", 1], ["LIMIT", 1]] => #<User:0x00007fe3cd995b70 id: 1, name: "Example User", email: "example@railstutorial.org", created_at: Wed, 19 Apr 2023 09:56:53.723792000 UTC +00:00, updated_at: Thu, 20 Apr 2023 07:11:18.310405000 UTC +00:00, password_digest: "[FILTERED]", remember_digest: "$2a$12$RdhZlokywEJZl9Zrg8yPCeskk4HnuQCOSdyhEHYh7WgApSdCOQkJm", admin: true, activation_digest: nil, activated: true, activated_at: Wed, 19 Apr 2023 09:56:53.455530000 UTC +00:00, reset_digest: "$2a$12$n.ovhYFMzpgkloM0sSDWSO2UYtCqR9Dy9gfKQf/a7YNA/cij7gkk6", reset_sent_at: Thu, 20 Apr 2023 06:38:46.390306000 UTC +00:00> irb(main):006:0> micropost.user.name => "Example User"
③先ほど作ったmicropostオブジェクトをデータベースに保存してみましょう。この時点でもう一度マジックカラムの内容を調べてみましょう。今度はどのような値が入っているでしょうか?
(解答)
irb(main):007:0> micropost.save TRANSACTION (0.5ms) begin transaction Micropost Create (0.8ms) INSERT INTO "microposts" ("content", "user_id", "created_at", "updated_at") VALUES (?, ?, ?, ?) [["content", "Lorem ipsum"], ["user_id", 1], ["created_at", "2023-04-20 09:15:48.400796"], ["updated_at", "2023-04-20 09:15:48.400796"]] TRANSACTION (17.7ms) commit transaction => true irb(main):009:0> micropost => #<Micropost:0x00007fe3ce19bae0 id: 1, content: "Lorem ipsum", user_id: 1, created_at: Thu, 20 Apr 2023 09:15:48.400796000 UTC +00:00, updated_at: Thu, 20 Apr 2023 09:15:48.400796000 UTC +00:00>
13.1.2
①Railsコンソールを開き、user_idとcontentが空になっているmicropostオブジェクトを作ってみてください。このオブジェクトに対してvalid?を実行すると、失敗することを確認してみましょう。また、生成されたエラーメッセージにはどんな内容が書かれているでしょうか?
(解答)
irb(main):001:0> micropost = Micropost.new => #<Micropost:0x00007f43fa5df788 id: nil, content: nil, user_id: nil, created_at: nil, updated_at: nil> irb(main):002:0> micropost.valid? => false irb(main):004:0> micropost.errors.messages => {:user=>["must exist"], :user_id=>["can't be blank"], :content=>["can't be blank"]}
②コンソールを開き、今度はuser_idが空でcontentが141文字以上のmicropostオブジェクトを作ってみてください。このオブジェクトに対してvalid?を実行すると、失敗することを確認してみましょう。また、生成されたエラーメッセージにはどんな内容が書かれているでしょうか?
(解答)
irb(main):004:0> micropost.errors.messages => {:user=>["must exist"], :user_id=>["can't be blank"], :content=>["can't be blank"]} irb(main):005:0> micropost.content = 'a' *150 => "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa... irb(main):006:0> micropost.valid? => false irb(main):007:0> micropost.errors.messages => {:user=>["must exist"], :user_id=>["can't be blank"], :content=>["is too long (maximum is 140 characters)"]}
13.1.3
①データベースにいる最初のユーザーを変数userに代入してください。そのuserオブジェクトを使ってmicropost = user.microposts.create(content: "Lorem ipsum")を実行すると、どのような結果が得られるでしょうか?
(解答)user_idも自動的に付与されてuserモデルに関連付けられたマイクロポストが生成された。
irb(main):002:0> micropost = user.microposts.create(content: "Lorem ipsum") TRANSACTION (0.1ms) begin transaction Micropost Create (2.7ms) INSERT INTO "microposts" ("content", "user_id", "created_at", "updated_at") VALUES (?, ?, ?, ?) [["content", "Lorem ipsum"], ["user_id", 1], ["created_at", "2023-04-20 09:52:23.940675"], ["updated_at", "2023-04-20 09:52:23.940675"]] TRANSACTION (14.6ms) commit transaction => #<Micropost:0x00007f460ed27bd8 ...
②先ほどの演習課題で、データベース上に新しいマイクロポストが追加されたはずです。user.microposts.find(micropost.id)を実行して、本当に追加されたのかを確かめてみましょう。また、先ほど実行したmicropost.idの部分をmicropostに変更すると、結果はどうなるでしょうか?
(解答)
irb(main):005:0> user.microposts.find(micropost.id) Micropost Load (0.3ms) SELECT "microposts".* FROM "microposts" WHERE "microposts"."user_id" = ? AND "microposts"."id" = ? LIMIT ? [["user_id", 1], ["id", 2], ["LIMIT", 1]] => #<Micropost:0x00007f460d72e520 id: 2, content: "Lorem ipsum", user_id: 1, created_at: Thu, 20 Apr 2023 09:52:23.940675000 UTC +00:00, updated_at: Thu, 20 Apr 2023 09:52:23.940675000 UTC +00:00> irb(main):006:0> user.microposts.find(micropost) /usr/local/bundle/ruby/3.1.0/gems/activerecord-7.0.4/lib/active_record/relation/finder_methods.rb:466:in `find_one': You are passing an instance of ActiveRecord::Base to `find`. Please pass the id of the object by calling `.id`. (ArgumentError)
③user == micropost.userを実行した結果はどうなるでしょうか? また、user.microposts.first == micropost を実行した結果はどうなるでしょうか? それぞれ確認してみてください。
(解答) 前の演習でid1のマイクロポストは作成していたため、id2のマイクロポストならtrueになる。
irb(main):008:0> user.microposts.first == micropost Micropost Load (10.0ms) SELECT "microposts".* FROM "microposts" WHERE "microposts"."user_id" = ? ORDER BY "microposts"."id" ASC LIMIT ? [["user_id", 1], ["LIMIT", 1]] => false irb(main):009:0> user.microposts.second == micropost Micropost Load (0.5ms) SELECT "microposts".* FROM "microposts" WHERE "microposts"."user_id" = ? ORDER BY "microposts"."id" ASC LIMIT ? OFFSET ? [["user_id", 1], ["LIMIT", 1], ["OFFSET", 1]] => true
13.1.4
①Micropost.first.created_atの実行結果と、Micropost.last.created_atの実行結果を比べてみましょう。
(解答)
irb(main):010:0> Micropost.first.created_at Micropost Load (37.1ms) SELECT "microposts".* FROM "microposts" ORDER BY "microposts"."id" ASC LIMIT ? [["LIMIT", 1]] => Thu, 20 Apr 2023 09:15:48.400796000 UTC +00:00 irb(main):011:0> Micropost.last.created_at Micropost Load (1.0ms) SELECT "microposts".* FROM "microposts" ORDER BY "microposts"."id" DESC LIMIT ? [["LIMIT", 1]] => Thu, 20 Apr 2023 09:52:23.940675000 UTC +00:00
②Micropost.firstを実行したときに発行されるSQL文はどうなっているでしょうか? 同様にして、Micropost.lastの場合はどうなっているでしょうか?(ヒント: それぞれをコンソール上で実行したときに表示される文字列が、SQL文になります。)
(解答)Micropost.firstでSELECT "microposts".* FROM "microposts" ORDER BY "microposts"."id" ASC LIMIT ?
。Micropost.lastでSELECT "microposts".* FROM "microposts" ORDER BY "microposts"."id" DESC LIMIT ?
。ASC LIMITとDESC LIMIT。
③データベース上の最初のユーザーを変数userに代入してください。そのuserオブジェクトが最初に投稿したマイクロポストのidはいくつでしょうか? 次に、destroyメソッドを使ってそのuserオブジェクトを削除してみてください。削除すると、そのuserに紐付いていたマイクロポストも削除されていることをMicropost.findで確認してみましょう。
(解答)
マイクロポストのidは1。
irb(main):002:0> user.microposts.first Micropost Load (2.5ms) SELECT "microposts".* FROM "microposts" WHERE "microposts"."user_id" = ? ORDER BY "microposts"."id" ASC LIMIT ? [["user_id", 1], ["LIMIT", 1]] => #<Micropost:0x00007f3886971830 id: 1, content: "Lorem ipsum", user_id: 1, created_at: Thu, 20 Apr 2023 09:15:48.400796000 UTC +00:00, updated_at: Thu, 20 Apr 2023 09:15:48.400796000 UTC +00:00>
ユーザーを削除したらマイクロポストも削除された。
irb(main):005:0> Micropost.find(1) Micropost Load (0.3ms) SELECT "microposts".* FROM "microposts" WHERE "microposts"."id" = ? LIMIT ? [["id", 1], ["LIMIT", 1]] /usr/local/bundle/ruby/3.1.0/gems/activerecord-7.0.4/lib/active_record/core.rb:284:in `find': Couldn't find Micropost with 'id'=1 (ActiveRecord::RecordNotFound)
13.2.1
①7.3.3で軽く説明したように、今回ヘルパーメソッドとして使ったtime_ago_in_wordsメソッドは、Railsコンソールのhelperオブジェクトから呼び出すことができます。このhelperオブジェクトのtime_ago_in_wordsメソッドを使って、3.weeks.agoや6.months.agoを実行してみましょう。
(解答)
irb(main):001:0> helper.time_ago_in_words(3.weeks.ago) => "21 days" irb(main):002:0> helper.time_ago_in_words(6.months.ago) => "6 months"
②helper.time_ago_in_words(1.year.ago)と実行すると、どういった結果が返ってくるでしょうか?
(解答)
irb(main):003:0> helper.time_ago_in_words(1.year.ago) => "about 1 year"
③micropostsオブジェクトのクラスは何でしょうか? (ヒント: リスト 13.24内のコードを参考に、まずはpaginate(page: nil)でオブジェクトを取得し、その後classメソッドを呼び出してみましょう。)
(解答)
irb(main):006:0> microposts = user.microposts.paginate(page: nil) Micropost Load (2.5ms) SELECT "microposts".* FROM "microposts" WHERE "microposts"."user_id" = ? LIMIT ? OFFSET ? [["user_id", 100], ["LIMIT", 30], ["OFFSET", 0]] => [] irb(main):007:0> microposts.class => Micropost::ActiveRecord_AssociationRelation
13.2.2
①(1..10).to_a.take(6)というコードの実行結果を推測できますか? 推測した値が合っているかどうか、実際にコンソールを使って確認してみましょう。
(解答)1..10の配列に対して6番目まで取り出すから、1~6の配列が出てくる。
irb(main):001:0> (1..10).to_a.take(6) => [1, 2, 3, 4, 5, 6]
②上の演習にあったto_aメソッドの部分は本当に必要でしょうか? 確かめてみてください。
(解答) なくてもいける。takeは実行結果の配列を返すみたい。
irb(main):002:0> (1..10).take(6) => [1, 2, 3, 4, 5, 6]
irb(main):004:0> (1..10).take(6).class => Array irb(main):005:0> (1..10).class => Range
③Fakerはlorem ipsum以外にも、非常に多種多様の事例に対応しています。Fakerのドキュメント(英語)を眺めながら画面に出力する方法を学び、実際に架空の大学名やHipster IpsumやChuck Norris facts(参考: チャック・ノリスの真実)を画面に出力してみましょう。
(解答) 飛ばします。
13.2.3
①リスト 13.29にある2つの'h1'のテストが正しいか確かめるため、該当するアプリケーション側のコードをコメントアウトしてみましょう。テストが green から red に変わることを確認してみてください。
(解答) コメントアウトするとテストRED。
<h1> <%#= gravatar_for @user %> <%= @user.name %> </h1>
②リスト 13.29にあるテストを変更して、will_paginateが1回だけ表示されていることをテストしてみましょう。(ヒント: 表 5.2を参考にしてください。)
(解答) countを追加。
assert_select 'div.pagination', count: 1
13.3.1
①なぜUsersコントローラ内にあるlogged_in_userフィルターを残したままにするとマズイのでしょうか? 考えてみてください。
(解答) applicationコントローラにもUsersコントローラにも配置するのはコードが重複しており、DRY原則に反するため。
13.3.2
①Homeページをリファクタリングして、if-else文の分岐のそれぞれに対してパーシャルを作ってみましょう。
(解答)
homeのビュー
[app/views/static_pages/home.html.erb] <% if logged_in? %> <%= render 'static_pages/home_logged_in' %> <% else %> <%= render 'static_pages/home_not_logged_in' %> <% end %>
ログイン用パーシャル
[app/views/static_pages/_home_logged_in.html.erb] <div class="row"> <aside class="col-md-4"> <section class="user_info"> <%= render 'shared/user_info' %> </section> <section class="micropost_form"> <%= render 'shared/micropost_form' %> </section> </aside> </div>
非ログイン用パーシャル
[app/views/static_pages/_home_not_logged_in.html.erb] <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: "200"), "https://rubyonrails.org/" %>
②マイクロポストを投稿した直後に、ブラウザの更新ボタンを押すとエラーが表示されます。なぜエラーが表示されるのでしょうか?その原因を考えてみましょう。
(解答) 発生しなかったけど、POSTリクエストが重複して行われるから?
③もし上記の現象に対応するとしたら、どんな対応方法があるでしょうか?その対応方法を考えてみましょう。(ヒント: さまざまな対応方法がありますが、対応方法によっては今後の実装に支障が出ることがあります。ここでは対応方法のアイデア出しに留めておきましょう。)
(解答) 送信したら別のページへリダイレクト。ボタンの一時無効化など?
13.3.3
①新しく実装したマイクロポストの投稿フォームを使って、実際にマイクロポストを投稿してみましょう。Railsサーバーのログ内にあるINSERT文では、どういった内容をデータベースに送っているでしょうか? 確認してみてください。
(解答)
Micropost Create (8.5ms) INSERT INTO "microposts" ("content", "user_id", "created_at", "updated_at") VALUES (?, ?, ?, ?) [["content", "aaaaa"], ["user_id", 2], ["created_at", "2023-04-21 04:27:35.025993"], ["updated_at", "2023-04-21 04:27:35.025993"]]
②コンソールを開き、user変数にデータベース上の最初のユーザーを代入してみましょう。その後、Micropost.where("user_id = ?", user.id)とuser.microposts、そしてuser.feedをそれぞれ実行してみて、実行結果がすべて同じであることを確認してみてください。(ヒント: ==で比較すると結果が同じかどうか簡単に判別できます。)
(解答)
irb(main):009:0> Micropost.where("user_id = ?", user.id) == user.microposts Micropost Load (0.3ms) SELECT "microposts".* FROM "microposts" WHERE "microposts"."user_id" = ? ORDER BY "microposts"."created_at" DESC [["user_id", 2]] Micropost Load (0.5ms) SELECT "microposts".* FROM "microposts" WHERE (user_id = 2) ORDER BY "microposts"."created_at" DESC => true irb(main):010:0> Micropost.where("user_id = ?", user.id) == user.feed => true
13.3.4
①マイクロポストを作成し、その後、作成したマイクロポストを削除してみましょう。次に、Railsサーバーのログを開いて、DELETE文の内容を確認してみてください。
(解答)
DELETE FROM "microposts" WHERE "microposts"."id" = ? [["id", 304]]
②リスト 13.56の2つのリダイレクトを、redirect_back_or_to(root_url, status: :see_other)と1行で置き換えてもうまく動くことを、ブラウザを使って確認してみましょう。これは、参照元URLがnilである場合は指定のURLにリダイレクトします。
(解答)うまくいく。
13.3.5
①リスト 13.59で示した4つのテスト項目が正しく動いているかをテスト項目ごとに確認してみましょう。具体的には、対応するアプリケーション側のコードをコメントアウトし、テストが red になることを確認し、元に戻すと green になることを確認してみましょう。
(解答) テストは6つで全て正しく動作している。
②サイドバーにあるマイクロポストの合計投稿数のテストを追加してみましょう。このとき、単数形のmicropostと複数形のmicropostsが正しく表示されているかどうかもテストしてください。(ヒント: リスト 13.61を参考にしてみてください。)
(解答)
class MicropostSidebarTest < MicropostsInterface test "should display the right micropost count" do get root_path assert_match "#{@user.microposts.count} microposts", response.body end test "should user proper pluralization for zero microposts" do log_in_as(users(:malory)) get root_path assert_match "0 microposts", response.body end test "should user proper pluralization for one micropost" do log_in_as(users(:lana)) get root_path assert_match "1 micropost", response.body end end
13.4.1
①画像付きのマイクロポストを投稿してみましょう。画像が大きすぎますか?(次の13.4.3で画像サイズの問題を修正します)。
(解答) 飛ばします。
②リスト 13.67に示すテンプレートを参考に、13.4で実装した画像アップローダーをテストしてください。テストの準備として、まずはターミナルでリスト 13.66を入力し、サンプル画像をfixtureディレクトリに追加してください。継続を表すバックスラッシュ記号\は入力が必要ですが、シェルで自動的に追加される2行目冒頭の >記号は入力しないようご注意ください。リスト 13.67で追加したアサーションでは、Homeページにあるファイルアップロードと、投稿に成功した時に画像が表示されているかどうかをチェックしています。なお、テスト内にあるfixture_file_uploadというメソッドは、fixtureで定義されたファイルをアップロードする特別なメソッドです22 。(ヒント: image属性が有効かどうかを確かめるときは、11.3.3で紹介したassignsメソッドを使ってください。このメソッドを使うと、投稿に成功した後にcreateアクション内のマイクロポストにアクセスするようになります。)
(解答)
[test/integration/microposts_interface_test.rb] class ImageUploadTest < MicropostsInterface test "should have a file input field for images" do get root_path assert_select 'input[type=file]' end test "should be able to attach an image" do cont = "This micropost really ties the room together." img = fixture_file_upload('kitten.jpg', 'image/jpeg') post microposts_path, params: { micropost: { content: cont, image: img } } assert assigns(:micropost).image.attached? end end
13.4.2
①5MB以上の画像ファイルを送信しようとした場合、どうなりますか?
②無効な拡張子のファイルを送信しようとした場合、どうなりますか?
飛ばします。
13.4.3
①サイズの大きい画像をアップロードし、リサイズされているかどうか確認してみましょう。画像が長方形の場合も正しくリサイズされていますか?
飛ばします。
第13章ーまとめー
- 外部キーでデータベースレベルでの制約を付けることによって、子テーブルが親テーブルの特定のデータを参照するようになる。
- has_manyとbelongs_toで関連付けたモデルを形成できる。
- デフォルトスコープ機能を使用することで、データの順序を指定可能になる。
object: f.object
のようにパーシャルの呼出しに対して変数を指定して渡すことが可能で、パーシャルの変数を動的に変えることが出来る。- SQL文に変数を代入する場合は、「?」でエスケープすることで、SQLインジェクションへの対策になる。
- あるクラスに属する変数(@userのような)をrenderメソッドに渡すと、renderはその変数が属しているクラス(@userであればUserクラス)に該当するパーシャル(_user.html.erb)を持ってくる。更に@userがUserクラスのインスタンスであることを明示的に示している。
- あるモデルに関連付けられたオブジェクトの作成、削除、取り出しなどでは、常に関連付けを通すことで第三者の削除を防止する等のセキュアな操作となる。
- Active Strageを用いて画像のアップロードやバリデーションなどがおこなえる。
第13章終わり🐻
Ruby on Railsチュートリアル第12章
第12章(パスワードの再設定)
PasswordResetsリソース
基本的にはアカウント有効化の流れと同じ。
同様な部分
パスワード再設定用のトークンを含めたメールを送信し、そのリンクをクリックするとデータベース内のパスワード再設定ダイジェストとトークンを比較し認証に成功したら、パスワードが変更可能となる。
異なる部分
パスワード再設定用のフォーム(ビュー)やモデル内のパスワードを変更するためのフォームが必要。
パスワード再設定用のリンクにはなるべく短時間の有効期限を設ける必要がある。
パスワード再設定を加えたモデル
カラム名 | データ型 |
---|---|
id | integer |
name | string |
string | |
created_at | datetime |
updated_at | datetime |
password_digest | string |
remember_digest | string |
admin | boolean |
activation_digest | string |
activated | boolean |
activated_at | datetime |
reset_digest | string |
reset_sent_at | datetime |
パスワード再設定のcreateアクション
ユーザーが再設定用のフォームからPOSTした際の動作。
- postされたメールアドレス(params[:password_reset][:email])を元にユーザーを検索。
- そのユーザーが存在すれば、再設定用ダイジェストを作成。
- リセット用のメールを送信。
- フラッシュメッセージを表示し、rootへリダイレクト。
パスワード再設定のメール送信
再設定メールようのメーラーとテンプレートはほとんどアカウントの有効化と同一。
メーラーの単体テストの内容もアカウント有効化と同じ。
パスワードを再設定
隠しフィールド
editアクションに対するメールアドレス入りのリンクがあるため、editアクションではユーザー検索のためのkeyとしてメールアドレスを使用出来る。しかし、updateアクションでも必要なためどこかに保持しておく必要がある。
隠しフィールドとしてビューの中に埋め込むことでメールアドレスの情報も保持される。
<%= hidden_field_tag :email, @user.email %>
f.hidden_fieldとhidden_field_tagについて
この2つの使い分けがいまいち分かりにくかったので、以下のサイトを参考にしました。
f.hidden_field
は、form_for
やform_with
で使用され、モデルのインスタンスに対する属性と値を隠しフィールドとして渡している。モデルにネストしているためパラメータはparams[:user][:email]のようにネストしたハッシュになる。
対して、hidden_field_tag
はモデルに対して渡している訳ではなく、単にkeyとvalueの形で各フィールドに値を埋め込んでいる。モデルにネストしていないため、パラメータはparams[:email]のようになる。
updateアクション
パスワードのupdateではいくつか考慮する事項がある。以下はその事項と対策。
- パスワード再設定の有効期限
⇒
before_action :check_expiration, only: [:edit, :update]
で有効期限を確認するcheck_expiration
メソッドを定義し、有効期限をチェック。 - 無効なパスワードは失敗させる ⇒ 失敗時の動作としてeditビューの再描画とエラーメッセージを表示させる。
- 新規パスワードと確認欄が空文字列でないか
⇒ モデルに対してallow_nilオプションを付けているため、パスワードと確認フィールド両方が空文字の場合に素通りするため、
errors.add
でエラーメッセージを追加出来る。 - 新規パスワードが問題無ければ更新 ⇒ 成功時のパスワードを更新し、ログイン+メッセージ表示+リダイレクト処理。更新可能なパラメータはStrong Parametersで指定。
[app/controllers/password_resets_controller.rb] def update if params[:user][:password].empty? # (3)への対応 @user.errors.add(:password, :blank) render 'edit', status: :unprocessable_entity elsif @user.update(user_params) # (4)への対応 @user.forget # セッションハイジャックの対応として、記憶トークンを無効化する。 reset_session log_in @user @user.update_attribute(:reset_digest, nil) #再設定後にリセットダイジェストを悪用されないようにnilにしておく。 flash[:success] = "Password has been reset." redirect_to @user else render 'edit', status: :unprocessable_entity # (2)への対応 end end private def user_params params.require(:user).permit(:password, :password_confirmation) end
更に、演習より、 update後は@user.forget
をすることで、記憶トークンを削除しておき、セッションハイジャックから保護しておく。また、update後にリセットダイジェストをnilへ更新しておくことで、例えば公共のパソコン等でパスワードを変更し終えた後に第三者が更にそのリセットダイジェストを使用してパスワードを更新するような事態を防ぐことが出来る。
第12章ー演習ー
12.1.1
①この時点で、テストスイートが green になっていることを確認してみましょう。
(解答) greenになっている。
②表 12.1の名前付きルーティングでは、pathではなくurlを使っているのはなぜでしょうか?理由を考えてみましょう。(ヒント: アカウント有効化の演習11.1.1.1と同じ理由です。)
(解答) railsアプリケーションの外部からアクセスするため、相対パスでは無く絶対パスで示す必要がある。
12.1.2
①リスト 12.4のform_withメソッドで、@password_resetではなく:password_resetを使っている理由を考えてみましょう。
(解答) セッションと同様に、password_resetモデルが無いため@password_resetというインスタンス変数は存在しない。そのためscope: :password_resetとリソースを明示的に渡す必要がある。
12.1.3
①試しに有効なメールアドレスをフォームで送信してみましょう(図 12.6)。どんなエラーメッセージが表示されますか?
(解答)以下のエラーが発生。
ArgumentError in PasswordResetsController#create
wrong number of arguments (given 1, expected 0)
end def password_reset @greeting = "Hi" mail to: "to@example.org"
②上の演習課題で送信した結果はエラーと表示されますが、該当するuserオブジェクトにはreset_digestとreset_sent_atが存在することをコンソールで確認してみましょう。それぞれの値はどのようになっていますか?
(解答) 生成されている。
UPDATE "users" SET "updated_at" = ?, "reset_digest" = ? WHERE "users"."id" = ? [["updated_at", "2023-04-20 04:09:10.810101"], ["reset_digest", "$2a$12$.9wbuYMbGDKnQ5HBOF/TneJ89rYsEl/Cgfrmow5Tj5W9765acgNwC"], ["id", 1]] UPDATE "users" SET "updated_at" = ?, "reset_sent_at" = ? WHERE "users"."id" = ? [["updated_at", "2023-04-20 04:09:10.829845"], ["reset_sent_at", "2023-04-20 04:09:10.829189"], ["id", 1]] ↳ app/models/user.rb:65:in `create_reset_digest'
12.2.1
①送信メールをブラウザでプレビューしてみましょう。「Date」フィールドにはどんな情報が表示されていますか?
(解答) 現在の日時
②パスワード再設定フォームで有効なメールアドレスを送信してみましょう。また、Railsサーバーのログを見て、生成された送信メールの内容を確認してみてください。
(解答) ちゃんとパスワードリセットトークン付きのリンクが入ったメールが送信されている。
<!DOCTYPE html> <html> <head> <meta http-equiv="Content-Type" content="text/html; charset=utf-8" /> <style> /* Email styles need to be inline */ </style> </head> <body> <h1>Password reset</h1> <p>To reset your password click the link below:</p> <a href="http://localhost:3000/password_resets/LkGibqRrg1irrOK-_4Mzgw/edit?email=example%40railstutorial.org">Reset password</a> <p>This link will expire in two hours.</p> <p> If you did not request your password to be reset, please ignore this email and your password will stay as it is. </p> </body> </html>
③Railsコンソールを開いて、上の演習課題でパスワードを再設定したUserオブジェクトを探してください。オブジェクトを見つけたら、そのオブジェクトが持つreset_digestとreset_sent_atの値を確認してみましょう。
(解答)
irb(main):002:0> user.reset_digest => "$2a$12$V3IiQHXoGECfW32djzItYuvSclU87pwi8U3CjxxNdOB8gwWHPTYC." irb(main):003:0> user.reset_sent_at => Thu, 20 Apr 2023 05:45:21.365272000 UTC +00:00
12.2.2
①メーラーのテストだけを実行してみてください。このテストは green になりますか?
(解答)green。
②リスト 12.12にある2つ目のCGI.escapeメソッドを削除すると、テストが red になることを確認してみましょう。
(解答)エスケープを外すとredになる。
12.3.1
①演習12.2.1.1で示した手順に従って、Railsサーバーのログから送信メールを探し出し、そこに記されているパスワード再設定用リンクを見つけてください。そのリンクをブラウザで表示すると、図 12.11のようになることを確かめてみましょう。
(解答) ちゃんと表示された。
②上で表示したページから、実際に新しいパスワードを送信してみましょう。どのような結果になりましたか?
(解答) 更新されない。
12.3.2
①演習12.2.1.1でRailsサーバーのログから取得できるリンクをブラウザで表示し、passwordフィールドとconfirmationフィールドの文字列をわざと間違えて送信してみましょう。どんなエラーメッセージが表示されますか?
(解答)Password confirmation doesn't match Password
②Railsコンソールを開いて、パスワード再設定を送信したユーザーオブジェクトを見つけてください。見つかったら、そのオブジェクトのpassword_digestの値を取得してみましょう。次に、パスワード再設定フォームから有効なパスワードを入力し、送信してみましょう(図 12.13)。パスワードの再設定が成功したら、再度password_digestの値を取得し、先ほど取得した値と変わっていることを確認してみましょう。(ヒント: 新しい値はuser.reloadを実行してから取得する必要があります。)
(解答)確かに変わっている。
変更前
irb(main):002:0> user.password_digest => "$2a$12$kmhU7VMMV.L73dSn7G723eYos1Szqs/2Bgy2eLIK5RJ8ryTU3Aiam"
変更後
irb(main):003:0> user.reload.password_digest User Load (44.7ms) SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ? [["id", 1], ["LIMIT", 1]] => "$2a$12$zntqk4ZU1p3Rez.2884l3.Llp2d4GWeFZkiK1c.SaNUEoL6s9xXvO"
③演習9.3.2.1では、セッションハイジャック(9.1.1)から保護するためのセッショントークンを実装しました。想定されるシナリオの1つとして「セッションを盗まれたことに気づいたユーザーが即座にパスワードをリセットする」という状況が考えられます。特に、ハイジャックされたセッションをこの操作で自動的に失効させることができたら素晴らしいでしょう。この機能を実現するのに必要なコードをリスト 12.18の(コードを書き込む)の部分に書いてください。(ヒント: リスト 9.37で、記憶ダイジェストをセッショントークンとして再利用したこと、Userモデルにはリスト 9.11で示した記憶トークンを削除するメソッドが既にあることを思い出しましょう。)
(解答)
[app/controllers/password_resets_controller.rb] def update if params[:user][:password].empty? # (3)への対応 @user.errors.add(:password, :blank) render 'edit', status: :unprocessable_entity elsif @user.update(user_params) # (4)への対応 @user.forget # セッションハイジャックの対応として、記憶トークンを無効化する。 reset_session log_in @user flash[:success] = "Password has been reset." redirect_to @user else render 'edit', status: :unprocessable_entity # (2)への対応 end end
12.3.3
①リスト 12.6にあるcreate_reset_digestメソッドはupdate_attributeを2回呼び出していますが、これは各行で1回ずつデータベースへ問い合わせしていることになります。リスト 12.21に記したテンプレートを使って、update_attributeの呼び出しを1回のupdate_columns呼び出しにまとめてみましょう。これでデータベースへの問い合わせが1回で済むようになります。また、変更後にテストを実行し、 green になることも確認してください。ちなみにリスト 12.21にあるコードには、前章の演習(リスト 11.40)の解答も含まれています。(注: update_columnsはバリデーションが実行されない上、update_attributeと異なりモデルのコールバックも行われないため、本チュートリアル以外で使用する際は注意が必要です。)
(解答)
def create_reset_digest self.reset_token = User.new_token update_columns(reset_digest: User.digest(reset_token), reset_sent_at: Time.zone.now) end
②リスト 12.22のテンプレートを埋めて、期限切れのパスワード再設定で発生する分岐(リスト 12.16)を統合テストでカバーしてみましょう。リスト 12.22 のコードにあるresponse.bodyは、そのページのHTML本文をすべて返すメソッドです。期限切れをテストする方法はいくつかありますが、リスト 12.22でオススメした手法を使えば、レスポンスの本文に「expired」という語があるかどうかでチェックできます(なお、大文字と小文字は区別されません)。
(解答)
test "should include the word 'expired' on the password-reset page" do follow_redirect! assert_match /expired/i, response.body end
③2時間経過するとパスワードを再設定できないようにする機能はセキュリティ的に好ましい手法ですが、公共の場所に設置されているコンピュータや、複数のユーザーが共有するコンピュータが使われる可能性も考慮すれば、より安全性の高いセキュリティ対策にするのが望ましいでしょう。公共の場所で共有されるコンピュータの多くは誰でもログインできるので、あるユーザーがそのコンピュータから離席するときに正しくログアウトしていたとしても、別のユーザーが2時間以内にそのコンピューターにログインし、ブラウザの「戻る」ボタンを数回押してパスワード再設定フォームを見つけたら、パスワード更新に成功してしまう可能性があり、しかもそのままログインされてしまいます。この問題を解決するために、リスト 12.23のコードを追加し、パスワードの再設定に成功したらダイジェストをnilになるように変更してみましょう6 。
リスト 12.19に1行追加し、上の演習課題に対するテストを書いてみましょう。(ヒント: リスト 9.26のassert_nilメソッドとリスト 11.34のuser.reloadメソッドを組み合わせて、reset_digest属性を直接テストしてみましょう。)
(解答)assert_nil @reset_user.reload.reset_digest
を追加。updateアクションの@user.update_attribute(:reset_digest, nil)
をコメントアウトするとテストREDとなるため、正しく機能している。
test "update with valid password and confirmation" do patch password_reset_path(@reset_user.reset_token), params: { email: @reset_user.email, user: { password: "foobaz", password_confirmation: "foobaz" } } assert is_logged_in? assert_not flash.empty? assert_redirected_to @reset_user assert_nil @reset_user.reload.reset_digest end
12.4
①production環境でユーザー登録を試してみましょう。ユーザー登録時に入力したメールアドレスにメールが届きましたか?
②メールを受信できたら、実際にメール内のリンクをクリックしてアカウントを有効化してみましょう。また、Render上のログを表示して、有効化に関するログを詳しく調べてみてください。
③アカウントを有効化できたら、今度はパスワードの再設定を試してみましょう。正常にパスワードを再設定できましたか?
飛ばします。
第12章ーまとめー
- パスワード再設定はアカウント有効化と同様でリソース化できる。
- パスワード再設定のリンクには短時間(2時間程度)の有効期限を設ける。
f.hidden_field
はモデルと紐づけた隠しフィールドとして機能し、hidden_field_tag
はモデルと紐づいていない隠しフィールドとして機能する。errors_add
で引数のエラーが発生した場合のエラーメッセージの動作を追加出来る。- パスワードの再設定に成功(update)した場合のアクションには、セッションハイジャックを防止するために記憶トークンを削除しておくことや、第三者が有効期限の切れていないリセットダイジェストから再度パスワード変更するような状況を防ぐためにリセットダイジェストを削除しておくことも重要。
第12章終わり🐻
Ruby on Railsチュートリアル第11章
第11章(アカウントの有効化)
AccountActivationsリソース
- セッションと同様に一連の操作をリソースとしてモデル化。
- アカウントの有効化では、有効化リンクをメールに含めてユーザーに送信する。メールをユーザーがクリックしてページを表示(GET)するという特性上、有効化情報を更新する(PATCH)がupdateアクションではなくeditアクションを使用する。
- Railsにはhas_secure_tokenというメソッドがあるが、これはハッシュ化されていないトークンをデータベースへ保存してしまう。そのた、第三者がデータベースからトークンを盗み有効化する可能性を否定できない。
- 記憶トークンと同様に、仮想的な属性を使用して、ハッシュ化したトークンをデータベースへ保存する実装の方がより堅牢となる。
AccountActivationsデータモデル
ユーザーのモデルには、ハッシュ化したトークン(activation_digest
)、有効化されているか真偽値を返すactivated
、有効化された日時を返すactivated_at
が追加される。
カラム名 | データ型 |
---|---|
id | integer |
name | string |
string | |
created_at | datetime |
updated_at | datetime |
password_digest | string |
remember_digest | string |
admin | boolean |
activation_digest | string |
activated | boolean |
activated_at | datetime |
AccountActivationsのbeforeアクション
有効化トークンやダイジェストはユーザーオブジェクトが作成される前に生成されておく必要がある。(アカウント有効化→作成の手順)
before_createでトークンやダイジェストを生成すればok。
[app/models/user.rb] def create_activation_digest self.activation_token = User.new_token self.activation_digest = User.digest(activation_token) end
記憶トークンのbeforeメソッドと酷似しているが、記憶ダイジェストは既にモデルで紐づいて生成されているものを更新(update)するのに対して、アクティベーショントークンはまだ無い要素を生成している。
[app/models/user.rb] def remember self.remember_token = User.new_token update_attribute(:remember_digest, User.digest(remember_token)) remember_digest end
アカウント有効化のメール送信
メーラー
- メーラーはコントローラと同じように生成可能。
rails generate mailer UserMailer account_activation password_reset
- 生成したメーラーごとにテキストメール用とHTML用の2種類のビューテンプレートが生成。
メーラーのテスト
- fixtureデータをuser変数に代入し、有効化トークンを追加。
- アカウント有効化メールをmailへ代入。
- assert_equalでメールの題名、toとfromのメールアドレスが一致してるか確認。
- assert_matchで文字列を正規表現でテスト。
[test/mailers/user_mailer_test.rb] test "account_activation" do user = users(:michael) user.activation_token = User.new_token mail = UserMailer.account_activation(user) assert_equal "Account activation", mail.subject assert_equal [user.email], mail.to assert_equal ["user@realdomain.com"], mail.from assert_match user.name, mail.body.encoded assert_match user.activation_token, mail.body.encoded assert_match CGI.escape(user.email), mail.body.encoded end
assert_matchについて
assert_match
は、第1引数に正規表現を取って、第2引数に文字列を取り、第1引数の正規表現の値が第2引数に含まれているかどうかを検証。
チュートリアル内の例だと、
- 文字列
foobar
の中にfoo
は含まれるためtrue。 - 文字列
foobar
の中にbaz
は含まれないためfalse。 - 正規表現
/\w+/
は、「wがすべての半角英数字とアンダースコア文字列で+がそれを1個以上繰り返す」で、foobar
の中には半角英数字が含まれるためtrue。 $#!*+@
の中には半角英数字もアンダースコアも含まれないため、true。
assert_match 'foo', 'foobar' # true assert_match 'baz', 'foobar' # false assert_match /\w+/, 'foobar' # true assert_match /\w+/, '$#!*+@' # false
アカウント有効化
メタプログラミング、sendメソッド
プログラムでプログラムを作成することを「メタプログラミング」と呼ぶ。
sendメソッドは、渡されたオブジェクトへメッセージを送ることでメソッドを動的に決定可能。
user.activation_digest
、user.send(:activation_digest)
、user.send("activation_digest")
は全て等価。更に、activationのシンボル(:activation)を適当な変数(attribute)に代入すると、user.send("#{attribute}_digest")
のようにも記述可能。
つまり、sendメソッドに渡すメッセージの中身を都度必要なものを利用することで、欲しいメソッドを動的に変えられる。
def authenticated?(remember_token) return false if remember_digest.nil? BCrypt::Password.new(remember_digest).is_password?(remember_token) end
このauthenticated?メソッドは、remember_digestとremember_tokenを一般化して、digestの中身はメタプログラミングで動的に変えられるようになっている。user.authenticated?(:remember, remember_token)
の形で呼び出すとこれまで通りの動作となる。
def authenticated?(attribute, token) digest = self.send("#{attribute}_digest") return false if digest.nil? BCrypt::Password.new(digest).is_password?(token) end
editアクション
有効化リンクのメールは、edit_account_activation_url(@user.activation_token, email: @user.email)
の名前付きルーティングによって、https://www.example.com/account_activations/#activation_token#/edit
といった形のURLが生成される。一方で、edit_user_url(user)
の名前付きルーティングではhttps://www.example.com/users/1/edit
のようなURLが生成される。つまり、idの部分がactivation_tokenに該当するため、この情報はparams[:id]
で持ってくることができる。
editアクションの中身の!user.activated? && user.authenticated?(:activation, params[:id])
の部分は、userが既に有効化されていないことを確認した上で、認証している。認証するとログイン状態となるため、既に使用した有効化リンクを盗み出して不正ログインするのを防ぐために重要。
update時にパスワードが無い場合はバリデーションエラーで失敗するため、update_attributeで更新をおこなう。
[app/controllers/account_activations_controller.rb] def edit user = User.find_by(email: params[:email]) if user && !user.activated? && user.authenticated?(:activation, params[:id]) user.update_attribute(:activated, true) user.update_attribute(:activated_at, Time.zone.now) log_in user flash[:success] = "Account activated!" redirect_to user else flash[:danger] = "Invalid activation link" redirect_to root_url end end
ユーザー有効化のテスト
メール配信が噛むテストにおいては、setupメソッドにActionMailer::Base.deliveries.clear
を入れておくことでテスト前に配列を空にしておくことが出来る。
有効化する際のメール配信はassert_equal 1, ActionMailer::Base.deliveries.size
とすることで、配信されたメールが1件であることを確認可能。
第11章ー演習ー
11.1.1
①現時点でテストスイートを実行すると green になることを確認してみましょう。
(解答) greenになる。
②表 11.2の名前付きルーティングにpathではなくurlを使っている理由を考えてみましょう。(ヒント: 私たちはこれから名前付きルーティングをメールで使います。)
(解答) 通常の相対パスはrailsのアプリケーション内であれば正しく認識出来るが、アプリケーション外からのアクセスとなるため、完全なURLを渡せる絶対パスとする必要がある。
11.1.2
①本節の変更を加えた後も、テストスイートが引き続き green になることを確認してみましょう。
(解答) greenのまま。
②コンソールからUserクラスのインスタンスを生成し、そのオブジェクトからcreate_activation_digestメソッドを呼び出そうとすると、PrivateメソッドなのでNoMethodErrorが発生することを確認してみましょう。また、そのUserオブジェクト内のダイジェストの値も確認してみましょう。
(解答) (NoMethodError) が発生。digestはnil。
irb(main):003:0> user.activation_digest => nil
③リスト 6.35で、メールアドレスの小文字化にはemail.downcase!という代入不要なメソッドもあることを学びました。このメソッドを使って、リスト 11.3のdowncase_emailメソッドを改良してみてください。また、うまく変更できれば、テストスイートは成功したままになっていることも確認してみてください。
(解答) 変更しても成功したまま。
def downcase_email email.downcase! end
11.2.1
①Railsコンソールを開き、CGIモジュールのescapeメソッド(リスト 11.15)でメールアドレスの文字列をエスケープできることを確認してみましょう。このメソッドで"Don't panic!"をエスケープすると、どんな結果になりますか?
(解答)
irb(main):001:0> CGI.escape("Don't panic!") => "Don%27t+panic%21"
11.2.2
①Railsのプレビュー機能を使って、ブラウザから先ほどのメールを表示してみてください。「Date」の欄にはどんな内容が表示されているでしょうか?
(解答) 今日の日付と時刻が表示される。
11.2.3
①この時点で、テストスイートが green になっていることを確認してみましょう。
(解答) greenになっている。
②リスト 11.20で使ったCGI.escapeの部分を削除すると、テストが red に変わることを確認してみましょう。
(解答) エスケープするとgreenで外すとredになる。
11.2.4
①新しいユーザーを登録したとき、リダイレクト先が適切なURLに変わったことを確認してみましょう。その後、Railsサーバーのログから送信メールの内容を確認してみてください。有効化トークンの値はどうなっていますか?
(解答)飛ばします。
②コンソールを開き、データベース上にユーザーが作成されたことを確認してみましょう。また、このユーザーはデータベース上にはいますが、有効化のステータスがfalseのままになっていることを確認してください。
(解答)飛ばします。
11.3.1
①コンソール内で新しいユーザーを作成してみてください。新しいユーザーの記憶トークンと有効化トークンはどのような値になっているでしょうか? また、各トークンに対応するダイジェストの値はどうなっているでしょうか?
(解答)
irb(main):002:0> user.remember_token => nil irb(main):003:0> user.remember_digest => nil irb(main):004:0> user.activation_token => "enSoDrbXKKWMfQCx-vE1Nw" irb(main):005:0> user.activation_digest => "$2a$12$yspioAUh6D/oCsR3mJ3zmukcQe0REGJO1YS.j7Zyc0qhYl/Zo0v2i"
②リスト 11.26で抽象化したauthenticated?メソッドを使って、先ほどの各トークン/ダイジェストの組み合わせで認証が成功することを確認してみましょう。
(解答) 最初は引数を:activation, :activation_token
と記載してしまっていたためfalseとなったが、文脈に合うように引数を(:activation, user.activation_token)
に変えたらtrueとなった。
irb(main):006:0> user.authenticated?(:activation, :activation_token) => false irb(main):007:0> user.authenticated?(:activation, user.activation_token) => true
11.3.2
①11.2.4で生成したメールに含まれているURLをRailsコンソールで調べてみてください。URLのどの部分に有効化トークンが含まれているでしょうか?
(解答) 飛ばします。
②先ほど見つけたURLをブラウザに貼り付けて、そのユーザーの認証に成功し、有効化できることを確認してみましょう。また、有効化ステータスがtrueになっていることをコンソールから確認してみてください。
(解答) 飛ばします。
11.3.3
①リスト 11.36にあるactivateメソッドはupdate_attributeを2回呼び出していますが、これは各行で1回ずつデータベースへ問い合わせしていることになります。リスト 11.40に記したテンプレートを使って、update_attributeの呼び出しを1回のupdate_columns呼び出しにまとめてみましょう。これでデータベースへの問い合わせが1回で済むようになります。変更後にテストを実行し、 green になることも確認してください。(注: update_columnsはバリデーションが実行されない上、update_attributeと異なりモデルのコールバックも行われないため、本チュートリアル以外で使用する際は注意が必要です。)
(解答)
def activate update_columns(activated: true, activated_at: Time.zone.now) end
②現在は、/usersのユーザーindexページを開くとすべてのユーザーが表示され、/users/:idのようにIDを指定すると個別のユーザーを表示できます。しかし考えてみれば、有効でないユーザーは表示する意味がありません。そこで、リスト 11.41のテンプレートを使って、この動作を変更してみましょう10 。なお、このテンプレートで使っているActive Recordのwhereメソッドについては、13.3.3でもう少し詳しく説明します。
(解答)
def index @users = User.where(activated: true).paginate(page: params[:page]) end def show @user = User.find(params[:id]) redirect_to root_url and return unless @user.activated? end
③ここまでの演習課題で変更したコードをテストするために、/usersと/users/:idの両方に対する統合テストを作成してみましょう。/usersテストのテンプレートはリスト 11.42に示されています(本節の手法をリスト 10.63に適用した結果も示されています)。users/:idについては、rails generate integration_test user_showを実行してから、リスト 11.43に示すように無効なfixtureユーザを追加してください。対応するテストのテンプレートはリスト 11.44に示されています。
(解答)
/usersの統合テスト
[test/integration/users_index_test.rb] test "should display only activated users" do # ページにいる最初のユーザーを無効化する。 # 無効なユーザーを作成するだけでは、 # Railsで最初のページに表示される保証がないので不十分 User.paginate(page: 1).first.toggle!(:activated) # /usersを再度取得して、無効化済みのユーザーが表示されていないことを確かめる get users_path # 表示されているすべてのユーザーが有効化済みであることを確かめる assigns(:users).each do |user| assert user.activated? end end
/users/:idの統合テスト
[test/integration/user_show_test.rb] require "test_helper" class UsersShowTest < ActionDispatch::IntegrationTest def setup @inactive_user = users(:inactive) @activated_user = users(:archer) end test "should redirect when user not activated" do get user_path(@inactive_user) assert_response :redirect assert_redirected_to root_url end test "should display user when activated" do get user_path(@activated_user) assert_response :success assert_template 'users/show' end end
11.4
①実際に本番環境でユーザー登録をしてみましょう。ユーザー登録時に入力したメールアドレスにメールは届きましたか?
②メールを受信できたら、実際にメールをクリックしてアカウントを有効化してみましょう。また、Render上のログを調べてみて、有効化に関するログがどうなっているのか調べてみてください。
飛ばします。
第11章ーまとめー
- Railsには
has_secure_token
というメソッドがあるが、これはハッシュ化していない生のトークンをデータベースへ保存するため、手動でダイジェスト化する実装とする方が安全。 - メーラーはコントローラと同様に生成可能でメーラーに対して2つのビュー(テキスト形式、HTML形式)が生成される。
assert_match
では第1引数の正規表現が第2引数に含まれているかどうかを検証する。- メタプログラミングとして
send
メソッドは渡すメッセージによってメソッドを動的に書き換えられる。 - 有効化メールには有効化トークンを含め、
params[:id]
の形で取得できる。 - 有効化の際には既に有効化されたアカウントでは無いことを確認することで、有効化リンクの乗っ取りなどを防ぐ。
第11章終わり🐻
Ruby on Railsチュートリアル第10章
第10章(ユーザーの更新・表示・削除)
ユーザーを更新
RESTアクションのeditアクション、updateアクションを実装する。
editアクション
- showアクションと一緒で、変数@userにparams[:id]で取り出したidで探したuserを入れる。
- editアクションのviewは新規ユーザー登録のものとほぼ同一。
- form_withメソッドは新規ユーザー登録の
POST
アクションと、ユーザー編集のPATCH
アクションと同一。Railsはデータベースの既存のユーザーかどうかをnew_record?
理論値メソッドで区別し、新規データならPOSTアクション、既存データならPATCHアクションとしている。 - editの名前付きルーティングは
edit_user_path(current_user)
で、ログインしているuserを返すcurrent_userメソッドと組み合わせることで表現可能。
target="_barank"の問題
target="_barank"
はリンクをクリックした際に新しいタブで開く機能。
リンク先のページがオリジナルなページにアクセス出来る危険性があり、例えばフィッシングサイトのような偽のコンテンツへアクセスする可能性がある(ターゲットブランキング攻撃)。対策として、aタグのrel(relationship)属性に、"noopener"と設定する。
updateアクション
初期のcreateの実装と同様の形態。updateもcreateのときと同様に、意図しない属性値を送り込まれないように、Strong Parametersのuser_params
を使用する。
編集に失敗した場合、既にパーシャルでerror_messageを表示するようにしてあるため、AcrtiveRecordの用意したエラーメッセージが表示される。
[app/controllers/users_controller.rb] def update @user = User.find(params[:id]) if @user.update(user_params) # 更新に成功した場合を扱う else render 'edit', status: :unprocessable_entity end end
編集に対するテスト
編集失敗時のテスト
ユーザー情報の更新は名前付きルーティングuser_path(user)
でHTTPリクエストのPATCH
メソッドでおこなう。
[test/integration/users_edit_test.rb] test "unsuccessful edit" do get edit_user_path(@user) assert_template 'users/edit' patch user_path(@user), params: { user: { name: "", email: "foo@invalid", password: "foo", password_confirmation: "bar" } } assert_template 'users/edit' assert_select 'div.alert', "The form contains 4 errors." end
編集成功時のテスト
編集成功動作はTDDでおこなう。重要な部分は、@user.reload
でデータベースの最新情報を再読み込みした後の、ユーザー情報がpatch
で送信したものと一致していることを確認する動作。
[test/integration/users_edit_test.rb] test "successful edit" do get edit_user_path(@user) assert_template 'users/edit' name = "Foo Bar" email = "foo@bar.com" patch user_path(@user), params: { user: { name: name, email: email, password: "", password_confirmation: "" } } assert_not flash.empty? assert_redirected_to @user @user.reload assert_equal name, @user.name assert_equal email, @user.email end end
updateアクションに成功時の動作(フラッシュメッセージの表示、users_path(@user)へリダイレクト)を追加する。
allow_nil: true
オプションについて
パスワードのバリデーションにパスワードフィールドが空だった場合の例外処理を加えるallow_nil
オプションがある。これを追加することで、editテストのバリデーションエラーを回避出来る。
これとは別にhas_secure_password
自体に、オブジェクト生成時のパスワードの存在性の検証があるため、空のパスワードが有効化される訳ではない。
このオプションの追加によって、ユーザー作成時に空のパスワードであった場合に重複するエラーが発生する問題は解消される。
認可
現在の状態だと、editやupdate機能が誰でも使用可能なため、認可されたユーザーのみが操作可能とする必要がある。
beforeフィルター
- 何らかの処理を実行する前に特定のメソッドを走らせる仕組み。
- beforeフィルターはコントローラ内のすべてのアクションに定義されるため、特定のアクションに限定する場合はハッシュ形式の
:only
オプションを渡す。 before_action :logged_in_user, only: [:edit, :update]
のように記述すると、editアクションとupdateアクションのみの直前にloged_in_user
メソッドを走らせる。
正しいユーザーを要求する動作
editアクションやupdateアクションはログインしているかつ、自分の情報に対するアクションであるようにする必要がある。
fixtureに2人目のユーザー(other_user)を追加し、other_userがuserの情報を操作しようとした場合にリダイレクトするように実装。ログインはしているため、リダイレクト先はroot_url。
correct_userメソッドをbeforeアクションに加え、editやupdate前に確認させる。
[app/controllers/users_controller.rb] # 正しいユーザーかどうか確認 def correct_user @user = User.find(params[:id]) redirect_to(root_url, status: :see_other) unless @user == current_user end
@user == current_user
の部分は、理論値を返すcurrent_user?(@user)
の形で実装するために、current_user?ヘルパーメソッドを追加する。
user&&userとすることで、存在性の検証(nilだった場合をキャッチ)も兼ねている。
[app/helpers/sessions_helper.rb] def current_user?(user) user && user == current_user end
フレンドリーフォワーディング
ログインが必要なユーザーがログイン後にroot_urlではなく、元いたページに再び飛ばされる方が親切。
フレンドリーフォワーディング機能は、保存されたURLがある場合はそこにリダイレクトし、ない場合はデフォルトのURLへ飛ぶように設定。
リクエストがあったURLをsessionハッシュに格納(GETリクエストの場合のみ)し、ユーザー作成のcerateアクション実行時にこれを参照するようにすればよい。
[app/controllers/sessions_controller.rb] def create user = User.find_by(email: params[:session][:email].downcase) if user && user.authenticate(params[:session][:password]) forwarding_url = session[:forwarding_url] reset_session params[:session][:remember_me] == '1' ? remember(user) : forget(user) log_in user redirect_to forwarding_url || user
reset_session
をforwarding_url
にセッションURLを格納してから実行することで、転送先URLをセッションから削除している。これをしないと、セッションが閉じるまでこのページにアクセスするため。
すべてのユーザーを表示
indexアクションの実装
indexページはログイン済のユーザーのみ表示し、そうでないユーザーへはログインを促す。
サンプルユーザーの追加
gemのFaker
を使用すると、一括でサンプルユーザーを生成可能。
下のコードの場合だと、メインユーザーを一人作成し、99人の追加ユーザーを作成する。
[db/seeds.rb] User.create!(name: "Example User", email: "example@railstutorial.org", password: "foobar", password_confirmation: "foobar") 99.times do |n| name = Faker::Name.name email = "example-#{n+1}@railstutorial.org" password = "password" User.create!(name: name, email: email, password: password, password_confirmation: password) end
ページネーション
- 大量のユーザーが存在している場合だと、1ページに大量のユーザーが表示されるため、ユーザー体験ととしてはよくない。
- gemの
will_pagenate
と、bootstrap-will_paginate
を追加し、Bootstrapの機能で実現可能。 - paginate機能はpaginateメソッドで得た結果が必要なため、indexアクションにpaginateメソッドを追加する必要がある。
- pagiateメソッドはkeyが:pageでvalueがページ番号のハッシュを引数として受け取る。
- paginationのテストは、paginationクラスを持つdivタグを調べることと、1ページ目にuser.nameのテキストを持ったそのユーザーへのリンク(aタグ)があることを調べる。
パーシャルのリファクタリング
ビューのindexページは、liタグ部分をrender呼出しに変えることでリファクタリングが可能。
[app/views/users/index.html.erb] <% @users.each do |user| %> <li> <%= gravatar_for user, size: 50 %> <%= link_to user.name, user %> </li> <% end %>
[app/views/users/index.html.erb] <% @users.each do |user| %> <%= render user %> <% end %>
ここのrenderは普通'layouts/header'
のようにファイル名の文字列を指定するが、ここは単にuser
としている。これは、@users.each do |user|
で指定しているUserクラスのuserオブジェクトに対して実行しており、これでRailsは_users
パーシャルを探すことが出来る。
しかも更にこの部分はリファクタリングが可能で、do...end
ブロックごとrender @users
に置き換えると、@usersにはユーザー一覧が格納されているため、RailsはUserオブジェクトのリストとして列挙してくれるらしい。
<ul class="users"> <%= render @users %> </ul>
ユーザーの削除
adminユーザー
特有の権限を持つ管理ユーザー(adminユーザー)は、データモデルにboolean(真偽値)型として追加する。adminのデフォルト値はnilであるが、マイグレーションファイルにdefault: false
を明示的に渡しておく方がよい。
このadmin属性は必ず悪意あるユーザーが変更できないように、Strong parametersのpermitで"許可されていない"ことを確認しておく。
カラム名 | データ型 |
---|---|
id | integer |
name | string |
string | |
created_at | datetime |
updated_at | datetime |
password_digest | string |
remember_digest | string |
admin | boolean |
destroyアクション
destroy
アクションは、①current_userがadminかつ、②表示しているユーザーが自分自身ではない場合にのみ表示させる。
if current_user.admin? && !current_user?(user)
がこの①、②に該当。②の !current_user?(user)
が_user.html.erb
パーシャル内にあるため少し分かりにくいが、ここの引数の(user)はeachでリスト化されるユーザーであり、リスト化されたユーザーがcurrent_userである場合はそれはadminユーザー自身であるためdeleteは出来ず、それ以外のユーザーはdelete出来るようにするため !current_user?(user)
となる。
[app/views/users/_user.html.erb] <% if current_user.admin? && !current_user?(user) %> | <%= link_to "delete", user, data: { "turbo-method": :delete, turbo_confirm: "You sure?" } %> <% end %>
コントローラでdestoyアクションを作成するとユーザー削除が可能となる。destoyアクションはログイン済みである必要があるため、beforeフィルタに加えておく。
更に念押しで、adminユーザーだけがdestroy出来るようにadminであるかどうかを確認する動作もbeforeフィルタで加えておくことが重要。
[app/controllers/users_controller.rb] def admin_user redirect_to(root_url, status: :see_other) unless current_user.admin? end
ユーザー削除のテスト
ログインユーザーでなかったり、adminユーザーでないユーザーに対するdestroy
アクションのテストは、assert_not_difference
でユーザー数が変化していないことを確認すればok。
adminユーザーの削除した場合のdestroy
アクションとレイアウトに対するテストは少し複雑。
- @adminユーザーでログイン。
- indexテンプレートやページネーションリンクがあることを確認。
- 1ページ目に表示されるユーザーを変数first_page_of_usersに格納。
- それらのユーザーに対して
delete
リンクがあることを確認。 - ユーザーを一人削除して、ユーザー数が正しく変化(-1)されていることを確認。
ここで、4のdeleteリンクの確認では、unless user == @admin
で分岐させて、adminユーザーにはリンクが無く、それ以外のユーザーにのみdeleteリンクがあることを見ている。
[test/integration/users_index_test.rb] def setup @admin = users(:michael) @non_admin = users(:archer) end test "index as admin including pagination and delete links" do log_in_as(@admin) get users_path assert_template 'users/index' assert_select 'div.pagination' first_page_of_users = User.paginate(page: 1) first_page_of_users.each do |user| assert_select 'a[href=?]', user_path(user), text: user.name unless user == @admin assert_select 'a[href=?]', user_path(user), text: 'delete' end end assert_difference 'User.count', -1 do delete user_path(@non_admin) assert_response :see_other assert_redirected_to users_url end end
第10章ー演習ー
10.1.1
①target="_blank"で新しいページを開くと、古いブラウザでセキュリティ上の小さな問題が生じます。それは、リンク先のサイトがHTMLドキュメントのwindowオブジェクトを扱えてしまう、という点です。具体的には、フィッシング(Phising)サイトのような、悪意のあるコンテンツを導入されてしまう可能性があります。Gravatarのような著名なサイトではこのような事態はめったに起きないと思いますが、念のため、このセキュリティ上のリスクも排除しておきましょう。対処方法は、リンク用のaタグのrel(relationship)属性に、"noopener"と設定するだけです。リスト 10.2で使ったGravatarの編集ページへのリンクでこの設定を行ってください。
(解答)
<li><%= link_to "Settings", edit_user_path(current_user), rel: "noopener" %></li>
②リスト 10.5のパーシャルを使って、new.html.erbビュー(リスト 10.6)とedit.html.erbビュー(リスト 10.7)をリファクタリングし、コードの重複を解消してください。(ヒント: 3.4.3で使ったprovideメソッドを使うと、重複を取り除けます3 。)
(解答) 解消された。
10.1.2
①編集フォームから有効でないユーザー名やメールアドレス、パスワードを使って送信した場合、編集に失敗することを確認してみましょう。
(解答)飛ばします。
10.1.3
①リスト 10.9のテストに1行追加して、エラーメッセージが正しい個数で表示されているかテストしてみましょう。(ヒント: 表 5.2で紹介したassert_selectを使ってalertクラスのdivタグを探しだし、「The form contains 4 errors.」というテキストを精査してみましょう。)
(解答)
[test/integration/users_edit_test.rb] test "unsuccessful edit" do get edit_user_path(@user) assert_template 'users/edit' patch user_path(@user), params: { user: { name: "", email: "foo@invalid", password: "foo", password_confirmation: "bar" } } assert_template 'users/edit' assert_select 'div.alert', "The form contains 4 errors." end
10.1.4
①実際に編集が成功するかどうか、有効な情報を送信して確かめてみましょう。
(解答) 飛ばします。
②Gravatarと紐付いていない適当なメールアドレス(foobar@example.comなど)に変更すると、プロフィール画像はどのように表示されるでしょうか? 実際に編集フォームでメールアドレスを変更して確認してみましょう。
(解答) 飛ばします。
10.2.1
①デフォルトのbeforeフィルターは、すべてのアクションに対して制限を加えます。今回のケースだと、ログインページやユーザー登録ページにも制限の範囲が及んでしまい、結果としてテストも失敗するはずです。リスト 10.15のonly:オプションをコメントアウトしてみて、テストスイートがそのエラーを検知できるかどうか(テストが失敗するかどうか)確かめてみましょう。
(解答) コメントアウトしたらテストREDとなった。
10.2.2
①editアクションとupdateアクションを両方とも保護する必要がある理由は何でしょうか?考えてみてください。
(解答) updateアクションは当然第三者からの更新を防ぐためで、editアクションはGETリクエストのため更新はされないが、そもそも第三者がアクセス可能となるページとはすべきでないため。
②上記のアクションのうち、どちらがブラウザで簡単にテストできますか?
(解答) editアクション。別のユーザーでアドレスバーにeditのパスを入力するだけだから。
10.2.3
①フレンドリーフォワーディングで、渡されたURLに転送されるのが初回のみであることを、テストを書いて確認してみましょう。次回以降のログインでは、転送先のURLをデフォルトのプロフィール画面に戻しておく必要があります。(ヒント: リスト 10.30のsession[:forwarding_url]が正しい値かどうか確認するテストを追加してみましょう。)
(解答) session[:forwarding_url]がreset_sessionによってnilになっていればよい。
[test/integration/users_edit_test.rb] test "successful edit with friendly forwarding" do get edit_user_path(@user) log_in_as(@user) assert_redirected_to edit_user_url(@user) assert session[:forwarding_url].nil?
②7.1.3で紹介したdebuggerメソッドをSessionsコントローラのnewアクションに置いてみましょう。その後、ログアウトして/users/1/editにアクセスしてみてください。デバッガーによって処理が途中で止まるはずです。ここでコンソール画面に移動し、session[:forwarding_url]の値が正しいかどうか確認してみましょう。また、newアクションにアクセスしたときのrequest.get?の値も確認してみましょう。なお、デバッガーを使っている最中に、ターミナルが不意に動かなくなったり挙動がおかしくなったりすることもあります。そのようなときは、熟練開発者の精神を思い出しながら落ち着いて対処しましょう(コラム 1.2)。
(解答) newにdebuggerを挿入して動かすと重くて操作出来ないので飛ばします。。。
10.3.1
①レイアウトにあるすべてのリンクに対して統合テストを書いてみましょう。ログイン済みユーザーとそうでないユーザーのそれぞれに対して、正しい振る舞いを考えてください。(ヒント: log_in_asヘルパーを使ってリスト 5.31にテストを追加してみましょう。)
(解答)
[test/integration/site_layout_test.rb] def setup @user = users(:michael) end #ログイン済ユーザーのindexレイアウトのテスト test 'index path layput links with logged in user' do log_in_as(@user) get users_path assert_template 'users/index' assert_select "a[href=?]", root_path, count: 2 assert_select "a[href=?]", help_path assert_select "a[href=?]", about_path assert_select "a[href=?]", contact_path assert_select "a[href=?]", edit_user_path(@user) assert_select "a[href=?]", logout_path end #ログインしていないユーザーのindexレイアウトのテスト test 'index path layput links with not logged in user' do get users_path follow_redirect! assert_template 'sessions/new' assert_select "a[href=?]", root_path, count: 2 assert_select "a[href=?]", help_path assert_select "a[href=?]", about_path assert_select "a[href=?]", contact_path assert_select "a[href=?]", login_path end
10.3.2
①試しに他のユーザーの編集ページにアクセスしてみて、10.2.2で実装した通りにリダイレクトされるかどうかを確かめてみましょう。
(解答) リダイレクトされた。
10.3.3
①Railsコンソールを開き、pageオプションにnilをセットして実行すると、1ページ目のユーザーが取得できることを確認してみましょう。
(解答) 1ページ目のユーザー30人が取得できた。
User.paginate(page: nil) TRANSACTION (0.1ms) begin transaction User Load (4.2ms) SELECT "users".* FROM "users" LIMIT ? OFFSET ? [["LIMIT", 30], ["OFFSET", 0]]
②先ほどの演習課題で取得したpaginationオブジェクトは、どのクラスでしょうか? また、User.allのクラスとどこが違うかを比較してみてください。
(解答) 同じ
irb(main):002:0> User.paginate(page: nil).class => User::ActiveRecord_Relation irb(main):003:0> User.all.class => User::ActiveRecord_Relation
10.3.4
①試しにリスト 10.46にあるページネーションのリンク(will_paginateの部分)を2つともコメントアウトしてみて、リスト 10.49のテストが red に変わるかどうか確かめてみましょう。
(解答) 2つともコメントアウトするとテストREDとなった。
②先ほどは2つともコメントアウトしましたが、1つだけコメントアウトした場合、テストが green のままであることを確認してみましょう。will_paginateのリンクが2つとも存在していることをテストしたい場合は、どのようなテストを追加すれば良いでしょうか?(ヒント: 表 5.2を参考にして、個数をカウントするテストを追加してみましょう。)
(解答) assert_select 'div.pagination', count: 2
とする。
10.3.5
①リスト 10.53にあるrenderの行をコメントアウトし、テストの結果が red に変わることを確認してみましょう。
(解答) REDになる。
10.4.1
①Web経由でadmin属性を変更できないことを確認してみましょう。具体的には、リスト 10.57に示したように、PATCHを直接ユーザーのURL(/users/:id)に送信するテストを作成してみてください。テストの振る舞いが正しいことを確信できるように、最初にadminをuser_paramsメソッド内の許可されたパラメータ一覧に追加しておきましょう。最初のテストの結果は red になるはずです。最後の行では、更新済みのユーザー情報をデータベースから読み込めることを確認します(6.1.5)。
(解答) patchリクエストでadmin:trueを送信し、データベースから最新の情報を読み込み(reload)したときにadmin属性が付与されていなければよい。permitにadmin属性を入れるとREDになり、外すとGREENとなった。
[test/controllers/users_controller_test.rb] test "should not allow the admin attribute to be edited via the web" do log_in_as(@other_user) assert_not @other_user.admin? patch user_path(@other_user), params: { user: { password: "password", password_confirmation: "password", admin: true } } assert_not @other_user.reload.admin? end
10.4.2
①管理者ユーザーとしてログインし、試しにサンプルユーザを2〜3人削除してみましょう。ユーザーを削除すると、Railsサーバーのログにはどのような情報が表示されるでしょうか?
(解答) DELETE FROM "users" WHERE "users"."id" = ? "id", 16 とログに表示される。
10.4.3
①試しにリスト 10.60にある管理者ユーザーのbeforeフィルターをコメントアウトしてみて、テストの結果が red に変わることを確認してみましょう。
(解答)REDになる。
第10章ーまとめー
- リンクを新しいウィンドウで開くtarget="brank"昨日はフィッシングサイトなどにリンクされる可能性を排除するために、rel属性に"noopener"を追加する。
- パスワードのバリデーションには
allow_nil: true
を追加しても、has_secure_password自体の存在性検証機能があるため問題無い。 - editアクションやupdateアクションはログインしているユーザーかつ、自分自身の情報の操作である場合に認可する。createアクションと同様に意図しない情報を更新させないように、Strong parametersを適用させる。
- フレンドリーフォワーディング機能によって、ログイン前の元いたページへ遷移させることが可能。
- 複数ユーザーが存在する場合はページネーション機能を用いて、特定の人数ごとにページを区切る。
- destoryアクションはadmin属性にのみ認可し、admin属性はStrong parameterで認可していないことを必ず確認する。
- ユーザー削除のテストではassert_differenceでユーザー数が削除前後で減少していることを検証すればよい。
第10章終わり🐻
Ruby on Railsチュートリアル第9章
第9章(高度なログイン機構)
Remember me機能
記憶トークン
- 記憶トークンを使用した永続化セッションの実装は、
has_secure_password
と似た構成。 - 記憶トークンはハッシュ値に変換した記憶ダイジェストとしてデータベースに保管し、照合もハッシュ値で比較する。
- 記憶トークンの生成はランダムな文字列であれば、何でもよいが、Rubyライブラリの
SecureRandom
モジュールのurlsafe_base64
は22文字のランダムな文字列を生成するためちょうどよい。
has_secure_password
で自動で生成されるpassword
属性と同じように、remember_token
属性をattr_accesor
を用いて自分で作成する。
remember_token
メソッドはローカル変数ではなくUserクラス自身(self)のメソッドとして作成する。
update_attribute
で記憶ダイジェストを更新する。(記憶ダイジェストは8章のdigestメソッドを使用して作成)
[app/models/user.rb] class User < ApplicationRecord attr_accessor :remember_token . . . # 永続セッションのためにユーザーをデータベースに記憶する def remember self.remember_token = User.new_token update_attribute(:remember_digest, User.digest(remember_token)) end end
cookiesメソッド
sessionメソッドと同じようにハッシュとして扱う。sessionメソッドと違う点で気を付ける必要があるのが、sessionメソッドは取り込んだidを自動的に暗号化して一時cookiesに保管するが、cookiesメソッドはIDを生のテキストとしてcookiesに保管してしまう。
そのため、cookies.encrypted[:user_id] = user.id
として暗号化cookiesの形で使用する。更に、期限を20年とするpermanentと組み合わせてcookies.permanent.encrypted[:user_id] = user.id
とする。
トークンと記憶ダイジェストの比較について
ここの中身の以下の記述がだいぶ難しかったのでまとめます。
BCrypt::Password.new(remember_digest) == remember_token
チュートリアル内では以下のように書かれていました。
このコードをじっくり調べてみると、実に奇妙なつくりになっています。bcryptで暗号化されたパスワードを、トークンと直接比較しています。ということは、==で比較する際にダイジェストを復号化しているのでしょうか?しかし、bcryptのハッシュは復号化できないはずなので、復号化しているはずはありません。そこでbcrypt gemの新しいソースコードでセキュアなパスワードの処理部分を詳しく調べてみると、なんと、比較に使う==演算子が再定義されています。つまり、==による比較が次のコードと同等になります。
BCrypt::Password.new(remember_digest).is_password?(remember_token)
実際の比較では、==の代わりにis_password?という論理値メソッドが使われています。これで少し見えてきました。今から書くアプリケーションコードでもこれと同じ方法を使うことにしましょう。
そもそもここは一回読んだ段階で難易度が高く飛ばしていたのですが、改めて読んでもやはり分かりません。
渡されたトークンがユーザーの記憶ダイジェストと一致??といった感じです。
BCrypt::Password.new(remember_digest)
の部分
第8章ではBCrypt::Password.create(string, cost: cost)
でパスワードが生成されるとの内容がありました。ただ、Password.newの動作については説明が無かったのでコンソールで確かめます。
まずUser.firstの情報を変数userに入れて、rememberメソッドを実行し、remember_token
とremember_digest
を作ります。
irb(main):001:0> user = User.first TRANSACTION (0.1ms) begin transaction User Load (0.7ms) SELECT "users".* FROM "users" ORDER BY "users"."id" ASC LIMIT ? [["LIMIT", 1]] => #<User:0x00007fd25b9d8f48 ... irb(main):002:0> user.remember TRANSACTION (0.2ms) SAVEPOINT active_record_1 User Update (0.5ms) UPDATE "users" SET "updated_at" = ?, "remember_digest" = ? WHERE "users"."id" = ? [["updated_at", "2023-04-16 14:47:05.086543"], ["remember_digest", "$2a$12$xB74UACYS3Uue/1NRsIfSuxTT9IDqi3x5Ntp627iYu1BchcM45bRW"], ["id", 1]] TRANSACTION (0.2ms) RELEASE SAVEPOINT active_record_1 => true
ここにPassword.newを試してみます。
irb(main):004:0> BCrypt::Password.new(user.remember_digest) => "$2a$12$DcUMen5dUMWVykb9slC9QeDjr5Gz8ZLJtm7M1RDi7N9pWHCSBslpK" irb(main):005:0> user.remember_digest => "$2a$12$DcUMen5dUMWVykb9slC9QeDjr5Gz8ZLJtm7M1RDi7N9pWHCSBslpK"
すると、user.remember_digest
の中身がそのまま出てきました。これが私的には混乱しました。
irb(main):006:0> BCrypt::Password.new(user.remember_digest).class => BCrypt::Password irb(main):007:0> user.remember_digest.class => String
文字列としては全く同じでもクラスのみが違っていました。remember_digestがstringクラスであったことを失念していました。。。
なので、BCrypt::Password.new
の機能は引数の文字列をBCryptパスワード化することとなります。
.is_password?(remember_token)の部分
ここはチュートリアル内で==演算子では無いと明言してくれていました。
irb(main):003:0> BCrypt::Password.new(user.remember_digest).is_password?(user.remember_token) => true
is_password?
は引数内の文字列をハッシュ化したものと等価(==)であるかを検査してくれるようです。
なので、 BCrypt::Password.new(remember_digest).is_password?(remember_token)
は、文字列の記憶ダイジェストをBCryptパスワード化したものと、記憶トークンをハッシュ化したものが等価であることを確認していることになると思ってます。
remember(user)メソッドとcurrent_userの実装
remember(user)メソッド
- rememberメソッドで記憶ダイジェストを生成。
- 暗号化されたidの永続化クッキーを作成。
- 暗号化されたリメンバートークンのクッキーを作成。
[app/helpers/sessions_helper.rb] # 永続セッションのためにユーザーをデータベースに記憶する def remember(user) user.remember cookies.permanent.encrypted[:user_id] = user.id cookies.permanent[:remember_token] = user.remember_token end
current_userメソッド
現在のcurrent_user
メソッドは@current_user ||= User.find_by(id: session[:user_id])
で、一時セッションのidのみなので、永続セッションにも対応可能とする。
if session[:user_id] @current_user ||= User.find_by(id: session[:user_id]) elsif cookies.encrypted[:user_id] user = User.find_by(id: cookies.encrypted[:user_id]) if user && user.authenticated?(cookies[:remember_token]) log_in user @current_user = user end end
短縮すると以下のようにも記述可能。確かにsessionとcookiesを1回ずつしか使用していないが上の方が分かりやすい。。。
意味としては、(user_id = session[:user_id])で、session[:user_id]をローカル変数user_idに代入しそれが存在すれば、という事らしい。
if (user_id = session[:user_id]) @current_user ||= User.find_by(id: user_id) elsif (user_id = cookies.encrypted[:user_id]) user = User.find_by(id: user_id) if user && user.authenticated?(cookies[:remember_token]) log_in user @current_user = user end end
ユーザーを忘れる
記憶ダイジェストをnilで更新するメソッドを作成する。
[app/models/user.rb] # ユーザーのログイン情報を破棄する def forget update_attribute(:remember_digest, nil) end
log_out
ヘルパーメソッドの中にforgetヘルパーメソッドを入れ込む。
forgetヘルパーメソッドはforgetしてから、cookiesを削除。
[app/helpers/sessions_helper.rb] # 永続的セッションを破棄する def forget(user) user.forget cookies.delete(:user_id) cookies.delete(:remember_token) end # 現在のユーザーをログアウトする def log_out forget(current_user) reset_session @current_user = nil end end
2つの小さなバグ
ここでは細かいところなので要点のみ押さえておきます。
①同一のサイトを複数タブで開いた場合に、両方のタブでログアウトしようとした場合に、current_user
がnilとなり、log_outメソッドがエラーとなる。
(解決策)logged_in?メソッドがtrueの場合のみlog_outメソッドを呼び出す。
②複数種のブラウザからログインし、片方のブラウザでログアウトした場合、ログアウトした側でremember_digest
が削除されているのに、ログアウトしていない側でremember_digest
の比較を行った際にエラー。
(解決策)記憶ダイジェストがnilの場合に、authenticated?がfalseを返すようにする。
Remember meチェックボックス
cssなどの部分は全て飛ばします。 ##### 三項演算子 if-elseのような分岐構文を1行で示す手法。
if boolean? var = foo else var = bar end
このような記述は
var = boolean? ? foo : bar
の1行に置き換えられる。boolean? ? foo : bar
の部分が三項演算子。理論値? ifの値:elseの値の形。
チェックボックスのアクション
オンのときにユーザーを記憶し、オフの場合には記憶しない。 if-else分で記述
if params[:session][:remember_me] == '1' remember(user) else forget(user) end
これを三項演算子で記述する。
params[:session][:remember_me] == '1' ? remember(user) : forget(user)
これだけで完了。
Remember meのテスト
テストユーザーのログイン
8章においては、sessionハッシュをpostすることでログインしていたが、都度記述しなくていいようにヘルパーメソッドを単体テスト用と統合テスト用で定義しておく。ActiveSupport::TestCase
クラス、統合テストはActionDispatch::IntegrationTest
クラスで定義。
[test/test_helper.rb] class ActiveSupport::TestCase # テストユーザーとしてログインする def log_in_as(user) session[:user_id] = user.id end end class ActionDispatch::IntegrationTest # テストユーザーとしてログインする def log_in_as(user, password: 'password', remember_me: '1') post login_path, params: { session: { email: user.email, password: password, remember_me: remember_me } } end end
後はこれを利用してテストを記述。ユーザーのremember_tokenはコントローラの方だと属性として含まれるが、テストの方だと仮想のため属性として含まれていないため、cookiesの値と一致するかは通常比較出来ないため、ここではcookiesが空かどうかのみを見る。
remember_me
チェックボックスをオフ"0"にした状態でテストした場合、remember_tokenは空文字でもnilとなるため、empty?ではなくbrank?を使用する。
[test/integration/users_login_test.rb] class RememberingTest < UsersLogin test "login with remembering" do log_in_as(@user, remember_me: '1') assert_not cookies[:remember_token].blank? end test "login without remembering" do # Cookieを保存してログイン log_in_as(@user, remember_me: '1') # Cookieが削除されていることを検証してからログイン log_in_as(@user, remember_me: '0') assert cookies[:remember_token].blank? end end
assignsメソッド
テストからコントローラ内の仮想の属性にアクセスしたい場合は、assigns
メソッドを利用する。
コントローラ内で定義したインスタンス変数に対応するシンボルをこのメソッドに渡すことでアクセス可能となる。
Rails5以降ではどうやら非推奨らしい。
current_userメソッドのテスト、テスト漏れの確認
テストを書き忘れた可能性のあるコードブロック内にわざと例外処理を仕込む。→テストGREENのままならテスト漏れしている、というテスト漏れの確認テクニックがある。
current_user
メソッドのテストは条件分岐ifのif (user_id = session[:user_id])
の部分が関与するため、これがtrueになるlog_in_asヘルパーは使用不可。ヘルパーの単体テストとする。
少しこのテストは追うのに時間がかかったので流れを書いておきます。
セッションがnilの場合にcurrent_userが@userとして設定されるか。
- @userにテストfixtureが追加されremember(@user)を実行する。この時点で、永続化cookiesに保存される。
- session情報は無いため、current_userのif文
if (user_id = session[:user_id])
はnilになる。 - elsif分の
elsif (user_id = cookies.encrypted[:user_id])
はrememberしたからtrue。 - 次のif文
if user && user.authenticated?(cookies[:remember_token])
もtrue。 - @current_userへ@userが代入されるため、@current_user == @userとなる。
remember_digestが不一致の場合にcurrent_userが設定されないか。
- 1~3までは同じ。
- if文
if user && user.authenticated?(cookies[:remember_token])
の部分で、update_attribute(:remember_digest)でremember_digestを変更しているため、ここがfalse。 - current_userはnilになる。
[test/helpers/sessions_helper_test.rb] require "test_helper" class SessionsHelperTest < ActionView::TestCase #@userを定義し、rememberメソッドを適用。 def setup @user = users(:michael) remember(@user) end #セッションがnilの場合にcurrent_userが@userとして設定されるか。 test "current_user returns right user when session is nil" do assert_equal @user, current_user assert is_logged_in? end #remember_digestが不一致の場合にcurrent_userが設定されないか。 test "current_user returns nil when remember digest is wrong" do @user.update_attribute(:remember_digest, User.digest(User.new_token)) assert_nil current_user end end
セッションリプレイ攻撃の脆弱性対策
演習9.3.2の内容。
cookiesや一時セッションはセッションハイジャック(乗っ取り)攻撃を受ける可能性がある。一時セッションはブラウザを閉じれば消えるが、対策は必要。
現状だと、セッションハッシュを第三者が奪ったらそのまま@current_userとなるため、最終的にはセッションハッシュがユーザーのセッショントークンと等しい場合のみ@current_userを設定するようにする。
現状のcurrent_user
def current_user if (user_id = session[:user_id]) @current_user ||= User.find_by(id: user_id)
① remember_digest
は一意の値であるため、これをsession_token
として定義。
remember_digestが存在しない場合は、rememberメソッドを呼び出す。ここでrememberメソッドの返り値をremember_digestに変えておく。
[app/models/user.rb] def remember self.remember_token = User.new_token update_attribute(:remember_digest, User.digest(remember_token)) remember_digest end def session_token remember_digest || remember end
②セッションヘルパーのlog_in
メソッドでセッショントークンを設定する。
[app/helpers/sessions_helper.rb]
session[:session_token] = user.session_token
③セッションハッシュとユーザーのセッショントークンが等しい場合に@current_userを設定する。
[app/helpers/sessions_helper.rb] def current_user if (user_id = session[:user_id]) user = User.find_by(id: user_id) if user && session[:session_token] == user.session_token @current_user = user end
第9章ー演習ー
9.1.1
①コンソールを開き、データベースにある最初のユーザーを変数userに設定してください。その後、そのuserオブジェクトからrememberメソッドがうまく動くかどうか確認してみましょう。また、remember_tokenとremember_digestの違いも確認してみてください。
(解答)rememberメソッドはうまく動く。remember_tokenは確かに22文字の文字列であって、remember_digestはまた異なる値。
irb(main):006:0> user.remember_token.length => 22
②リスト 9.3では、明示的にUserクラスを呼び出すことで、新しいトークンやダイジェスト用のクラスメソッドを定義しました。実際、User.new_tokenやUser.digestを使って呼び出せるようになったので、おそらく最も明確なクラスメソッドの定義方法であると言えるでしょう。しかし実は、より「Ruby的に正しい」クラスメソッドの定義方法が2通りあります。1つはややわかりにくく、もう1つは非常に混乱するでしょう。テストスイートを実行して、ややわかりにくいリスト 9.4の実装でも、非常に混乱しやすいリスト 9.5の実装でも、いずれも正しく動くことを確認してみてください。(ヒント: selfは、通常の文脈ではUser「モデル」、つまりユーザーオブジェクトのインスタンスを指しますが、リスト 9.4やリスト 9.5の文脈では、selfはUser「クラス」を指すことにご注意ください。わかりにくさの原因の一部はこの点にあります。)
(解答) 確認なので飛ばします。
9.1.2
①ブラウザのcookieを調べ、ログイン後のブラウザではremember_tokenと暗号化されたuser_idがあることを確認してみましょう。
(解答) どちらも確認できた。
②コンソールを開き、リスト 9.6のauthenticated?メソッドがうまく動くかどうか確かめてみましょう。
(解答)
irb(main):004:0> user.authenticated?(user.remember_token) => true
9.1.3
①ログアウトした後に、ブラウザの対応するcookiesが削除されていることを確認してみましょう。
(解答)削除されていた。
9.1.4
①リスト 9.17で修正した行をコメントアウトし、2つのログイン済みのタブによるバグを実際に確かめてみましょう。まず片方のタブでログアウトし、その後、もう1つのタブで再度ログアウトを試してみてください。
(解答)飛ばします。
②リスト 9.20で修正した行をコメントアウトし、2つのログイン済みのブラウザによるバグを実際に確かめてみましょう。まず片方のブラウザでログアウトし、もう一方のブラウザを再起動してサンプルアプリケーションにアクセスしてみてください。
(解答)飛ばします。
③上のコードでコメントアウトした部分を元に戻し、テストスイートが red から green になることを確認しましょう。
(解答)飛ばします。
9.2
①ブラウザでcookies情報を調べ、[remember me]をチェックしたときに意図した結果になっているかどうかを確認してみましょう。
(解答) セッションを閉じてもログイン状態が保持された。
②コンソールを開き、三項演算子を使った実例を考えてみてください(コラム 9.2)。
(解答)
irb(main):002:0> s = 'string' => "string" irb(main):003:0> s.empty? ? 'empty' : 'not_empty' => "not_empty"
9.3.1
①リスト 9.26の統合テストでは、仮想のremember_token属性にアクセスできないと説明しましたが、実は、assignsという特殊なテストメソッドを使うとアクセスできるようになります。コントローラで定義したインスタンス変数にテストの内部からアクセスするには、テスト内部でassignsメソッドを使います。このメソッドにはインスタンス変数に対応するシンボルを渡します。例えばcreateアクションで@userというインスタンス変数が定義されていれば、テスト内部ではassigns(:user)と書くことでインスタンス変数にアクセスできます。本チュートリアルのアプリケーションの場合、Sessionsコントローラのcreateアクションでは、userをインスタンス変数ではない通常のローカル変数として定義しましたが、これをインスタンス変数に変えてしまえば、cookiesにユーザーの記憶トークンが正しく含まれているかどうかをテストできるようになります。このアイデアに従ってリスト 9.29とリスト 9.30の不足分を埋め、[remember me]チェックボックスのテストを改良してみてください。ヒントとして?や(コードを書き込む)を目印に置いてあります。なお、この演習問題で行う変更はこの後のリスト 10.33に影響するのでご注意ください。
(解答) コントローラの変数を@userにして、テスト内ではassert_equalでcookiesの記憶トークンと@user自身の記憶トークンが一致することを確認。
[app/controllers/sessions_controller.rb] def create @user = User.find_by(email: params[:session][:email].downcase) if @user&.authenticate(params[:session][:password]) reset_session # ログインの直前に必ずこれを書くこと params[:session][:remember_me] == '1' ? remember(@user) : forget(@user) log_in @user redirect_to @user else flash.now[:danger] = 'Invalid email/password combination' render 'new', status: :unprocessable_entity end end
[test/integration/users_login_test.rb] test "login with remembering" do log_in_as(@user, remember_me: '1') assert_equal cookies[:remember_token], assigns(:user).remember_token end
9.3.2
①リスト 9.35にあるauthenticated?の式を削除すると、リスト 9.33の2つ目のテストで失敗すること、つまりこのテストが正しい対象をテストしていることを確認してみましょう。
(解答) 期待通りの結果になる。
②9.1.1で説明したように、私たちのアプリケーションのセッション内にあるuser_idは、このままだとセッションリプレイ攻撃に対して脆弱です。リスト 9.37のコードを用いて、リスト 9.38のコードの足りない部分「(コードを書き込む)」を埋めることで、コードを改善してください。また、以下のヒントも参考にしてください。 sessionハッシュがユーザーのセッショントークンと等しい場合のみ、@current_userを設定します。ただし、remember_digestは既に一意の値になっており、各ユーザーと紐付けられているので、単にこれをセッショントークンとして再利用し、ユーザーがまだ存在しない場合は生成することにします。このとき、rememberメソッドに少し手を加えて、update_attributeの結果を返すのではなく、リスト 9.37のようにダイジェストを返すようにする必要があります。
bcryptダイジェストの設計はセキュアであり、セッションの値は常に暗号化されるので、記憶ダイジェストを2つの目的に利用しても安全です。この実装では、ハイジャックされたセッションを無効にするために必要なのは、user.forgetを呼び出すことだけです。この後の演習(12.3.2.1)では、この手法を適用することで、ユーザーのパスワードをリセットするときに既存のセッションをすべて失効させられるようにする予定です。
最後に、これはセキュリティに関連する重要なコードなので、本チュートリアルの他の解答と異なり、以後のコードすべて、およびサンプルアプリにも必ず反映されています。
(解答)
def current_user if (user_id = session[:user_id]) user = User.find_by(id: user_id) if user && session[:session_token] == user.session_token @current_user = user end
第9章ーまとめー
has_secure_password
と同様に、記憶トークンもハッシュ化した記憶ダイジェストとして扱う。BCrypt::Password.create
は、引数の文字列からハッシュ化したパスワードを生成する。BCrypt::Password.new
は、引数の文字列をBCryptパスワード化する。- ログイン状態は一時セッション、もしくは永続化cookiesによって決まる。
- ログアウトは記憶ダイジェストを削除し、セッションとcookies情報を削除することで可能。
- 一時セッションのセッションリプレイ攻撃の対策として、セッションハッシュがユーザーのセッショントークンと等しい場合のみ@current_userを設定する
この章は難しい要素が多いです。。。
第9章終わり🐻