日別アーカイブ: 2021年12月11日

Rails-tutorialのまとめ(14.3 ステータスフィード)

その14.25から続く

14.3 ステータスフィード

最後の難関、ステータスフィードの実装に取りかかりましょう。
本書の中でも最も高度なものです。完全なステータスフィードは、
13章で扱ったプロトフィードをベースにします。

現在のユーザーにフォローされているユーザーのマイクロポストの配列を作成し、現在のユーザー自身のマイクロポストと合わせて表示します。
このセクションを通して、複雑さを増したフィードの実装に進んでいきます。
これを実現するためには、RailsとRubyの高度な機能の他に、
SQLプログラミングの技術も必要です。

14.3.1 動機と計画

フィードに必要な3つの条件を満たすことです。具体的には、

1) フォローしているユーザーのマイクロポストがフィードに含まれていること
2) 自分自身のマイクロポストもフィードに含まれていること。
3) フォローしていないユーザーのマイクロポストがフィードに含まれていないこと

の3つです。

まずはMichaelがLanaをフォローしていて、
Archerをフォローしていないという状況を作ってみましょう。
この状況のMichaelのフィードでは、Lanaと自分自身の投稿が見えていて、Archerの投稿は見えないことになります

先ほどの3つの条件をアサーションに変換して、
Userモデルにfeedメソッドがあることに注意しながら、
更新したUserモデルに対するテストを書いてみましょう。
結果を↓に示します。

ステータスフィードのテスト
test/models/user_test.rb

演習

マイクロポストのidが正しく並んでいると仮定して
(すなわち若いidの投稿ほど古くなる前提で)、データセットで
user.feed.map(&:id)を実行すると、
どのような結果が表示されるでしょうか? 考えてみてください。
たdefault_scopeを思い出してください。

user.feed.map($:id)
=>[1,2,7,8,10]

このように、引数として受け取った自分のidと、
フォローしているidが組み合わさって表示される。

14.3.2 フィードを初めて実装する

早速フィードの実装に着手してみましょう。最終的なフィードの実装はやや込み入っているため、細かい部品を1つずつ確かめながら導入していきます。

このフィードで必要なクエリについて考えましょう。
ここで必要なのは、micropostsテーブルから、
あるユーザー (つまり自分自身) がフォローしているユーザーに対応するidを持つマイクロポストをすべて選択 (select) することです。
このクエリを模式的に書くと次のようになります。

SELECT * FROM microposts
WHERE user_id IN (<list of ids>) OR user_id = <user id>

SQLがINというキーワードをサポートしていることを前提にしています

このキーワードを使うことで、
idの集合の内包 (set inclusion) に対してテストを行えます。

Active Recordのwhereメソッドを使っていることを思い出してください。このときに選択すべき対象はシンプルで、
現在のユーザーに対応するユーザーidを持つマイクロポストを選択すればよかったのでした。

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

今回必要になる選択は、上よりも少し複雑で、例えば次のような形になります。

Micropost.where("user_id IN (?) OR user_id = ?", following_ids, id)

フォローされているユーザーに対応するidの配列が必要であることが
わかってきました。これを行う方法の1つは、Rubyのmapメソッドを使うことです。
このメソッドはすべての「列挙可能 (enumerable)」なオブジェクト
(配列やハッシュなど、要素の集合で構成されるあらゆるオブジェクト)で使えます。なお、このメソッドは前にもも出てきました。
mapメソッドを使って配列を文字列に変換すると、次のようになります。

rails console
>> [1, 2, 3, 4].map { |i| i.to_s }
=> ["1", "2", "3", "4"]

& と、メソッドに対応するシンボルを使った短縮表記が使えます。
この短縮表記であれば、変数iを使わずに済みます。

>> [1, 2, 3, 4].map(&:to_s)
=> ["1", "2", "3", "4"]

>> [1, 2, 3, 4].map(&:to_s).join(', ')
=> "1, 2, 3, 4"

上のコードを使えば、user.followingにある各要素のidを呼び出し、
フォローしているユーザーのidを配列として扱うことができます。

>> User.first.following.map(&:id)
=> [3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18,
19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33,
34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51]

実際、この手法は実に便利なので、Active Recordでは次のようなメソッドも用意されています。

>> User.first.following_ids
=> [3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18,
19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33,
34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48,
49, 50, 51]

following_idsメソッドは、has_many :followingの関連付けをしたときにActive Recordが自動生成したものです。
これにより、user.followingコレクションに対応するidを得るためには、関連付けの名前の末尾に_idsを付け足すだけで済みます。
結果として、フォローしているユーザーidの文字列は、
次のようにして取得することができます。

>> User.first.following_ids.join(', ')
=> "3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18,
19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33,
34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48,
49, 50, 51"

実際にSQL文字列に挿入するときは、このように記述する必要はありません。
実は、?を内挿すると自動的にこの辺りの面倒を見てくれます。
さらに、データベースに依存する一部の非互換性まで解消してくれます。
つまり、ここではfollowing_idsメソッドをそのまま使えばよいだけなのです。結果、最初に想像していたとおり、

Micropost.where("user_id IN (?) OR user_id = ?", following_ids, id)
とりあえず動くフィードの実装 green
app/models/user.rb
class User < ApplicationRecord
  .
  .
  .
  # パスワード再設定の期限が切れている場合はtrueを返す
  def password_reset_expired?
    reset_sent_at < 2.hours.ago
  end

  # ユーザーのステータスフィードを返す
  def feed
    Micropost.where("user_id IN (?) OR user_id = ?", following_ids, id)
  end

  # ユーザーをフォローする
  def follow(other_user)
    following << other_user
  end
  .
  .
  .
end
 green
rails test

いくつかのアプリケーションにおいては、この初期実装だけで目的が達成され、十分に思えるかもしれません。しかしにはまだ足りないものがあります。
それが何なのか、次に進む前に考えてみてください。
(フォローしているユーザーが5,000人もいたらどうなるでしょうか?)。

演習

1:現在のユーザー自身の投稿を含めないようにするにはどうすれば良いでしょうか? また、そのような変更を加えると、どのテストが失敗するでしょうか?

OR user_id = 1で、自分自身のユーザーidを渡している点に注目。
(user_id IN (3,..,51) OR user_id = 1)

2:フォローしているユーザーの投稿を含めないようにするにはどうすれば良いでしょうか? また、そのような変更を加えると、どのテストが失敗するでしょうか?

def feed
  Micropost.where("user_id = ?", following_ids, id)
end
ActiveRecord::PreparedStatementInvalid: wrong number of bind variables (2 for 1) in: user_id = ?
test/models/user_test.rb:15:in `block (2 levels) in <class:UserTest>'
test/models/user_test.rb:14:in `block in <class:UserTest>'

3:フォローしていないユーザーの投稿を含めるためには
どうすれば良いでしょうか?
また、そのような変更を加えると、どのテストが失敗するでしょうか?
自分自身とフォローしているユーザー、そしてそれ以外という集合は、
いったいどういった集合を表すのか考えてみてください。

# ユーザーのステータスフィードを返す
def feed
  Micropost.all
end

Expected true to be nil or false
test/models/user_test.rb:23:in `block (2 levels) in <class:UserTest>'
test/models/user_test.rb:22:in `block in <class:UserTest>'
# フォローしていないユーザーの投稿を確認
archer.microposts.each do |post_unfollowed|
  assert_not michael.feed.include?(post_unfollowed)
end

フォローしていないものが含まれているのはダメです

14.3.3 サブセレクト

つまり、フォローしているユーザーが5,000人程度になるとWebサービス全体が遅くなる可能性があります。ステータスフィードを改善していきましょう。

following_idsでフォローしているすべてのユーザーをデータベースに
問い合わせし、さらに、フォローしているユーザーの完全な配列を作るために再度データベースに問い合わせしている点です。
SQLのサブセレクト (subselect) を使うと解決できます。

フィードをリファクタリングすることから始めましょう。
whereメソッド内の変数に、キーと値のペアを使う green
app/models/user.rb

class User < ApplicationRecord
  .
  .
  .
  # ユーザーのステータスフィードを返す
  def feed
    Micropost.where("user_id IN (:following_ids) OR user_id = :user_id",
     following_ids: following_ids, user_id: id)
  end
  .
  .
  .
end

これまでのコード

Micropost.where("user_id IN (?) OR user_id = ?", following_ids, id)

を次のように置き換えました。

Micropost.where("user_id IN (:following_ids) OR user_id = :user_id",
    following_ids: following_ids, user_id: id)

前者の疑問符を使った文法も便利ですが、同じ変数を複数の場所に挿入したい場合は、後者の置き換え後の文法を使う方がより便利です。

これからSQLクエリにもう1つのuser_idを追加します。特に、次のRubyコードは、

following_ids

このようなSQLに置き換えることができます。

following_ids = "SELECT followed_id FROM relationships
WHERE follower_id = :user_id"

このコードをSQLのサブセレクトとして使います。つまり、「ユーザー1がフォローしているユーザーすべてを選択する」というSQLを既存のSQLに内包させる形になり、結果としてSQLは次のようになります。

SELECT * FROM microposts
WHERE user_id IN (SELECT followed_id FROM relationships
                  WHERE follower_id = 1)
OR user_id = 1

集合のロジックを (Railsではなく) データベース内に保存するので、
より効率的にデータを取得することができます。

もっと効率的なフィードを実装する準備ができました。
(ここに記述されているコードは生のSQLを表す文字列であり、
following_idsという文字列はエスケープされているのではなく、
見やすさのために式展開しているだけだという点に注意してください。)

フィードの最終的な実装 green
app/models/user.rb
class User < ApplicationRecord
  .
  .
  .
  # ユーザーのステータスフィードを返す
  def feed
    following_ids = "SELECT followed_id FROM relationships
                     WHERE follower_id = :user_id"
    Micropost.where("user_id IN (#{following_ids})
                     OR user_id = :user_id", user_id: id)
  end
  .
  .
  .
end

RailsとRubyとSQLのコードが複雑に絡み合っていて厄介ですが、
ちゃんと動作します。

green
rails test

大規模なWebサービスでは、バックグラウンド処理を使ってフィードを非同期で生成するなどのさらなる改善が必要でしょう。

ステータスフィードの実装は完了です。

rails test
git add -A
git commit -m "Add user following"
git checkout master
git merge following-users

コードをリポジトリにpushして、本番環境にデプロイしてみましょう。

git push
git push heroku
heroku pg:reset DATABASE
heroku run rails db:migrate
heroku run rails db:seed

演習

1:Homeページで表示される1ページ目のフィードに対して、
統合テストを書いてみましょう。

 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

2:期待されるHTMLをCGI.escapeHTMLメソッドでエスケープしています(このメソッドはCGI.escapeと同じ用途です)。
このコードでは、なぜHTMLをエスケープさせる必要があったのでしょうか?考えてみてください。
ヒント: 試しにエスケープ処理を外して、
得られるHTMLの内容を注意深く調べてください。
マイクロポストの内容が何かおかしいはずです。また、
ターミナルの検索機能 (Cmd-FもしくはCtrl-F) を使って
「sorry」を探すと原因の究明に役立つはずです。

contentをエスケープしている為、CGI.esapeHTMLを加える必要がある。

ステータスフィードが追加され、Ruby on Railsチュートリアルの
サンプルアプリケーションがとうとう完成しました。
このサンプルアプリケーションには、
Railsの主要な機能 (モデル、ビュー、コントローラ、テンプレート、パーシャル、beforeフィルター、バリデーション、コールバック、has_many/belongs_to/has_many through関連付け、セキュリティ、テスティング、デプロイ)が多数含まれています。

14.4.1 サンプルアプリケーションの機能を拡張する

Railsアプリケーションに何らかの機能を実装していて困ったときは、RailsガイドやRails APIをチェックしてみてください。
いずれも1,000ページを超える大型の公式ドキュメントなので、
今自分がやろうとしていることに関連するトピックがあるかもしれません。

できるだけ念入りにGoogleで検索し、自分が調べようとしているトピックに言及しているブログやチュートリアルがないかどうか、よく探すことです。
Webアプリケーションの開発には常に困難がつきまといます。
他人の経験と失敗から学ぶことも重要です。

14.4.1 サンプルアプリケーションの機能を拡張する

Twitterには、マイクロポスト入力中に@記号に続けてユーザーのログイン名を入力するとそのユーザーに返信できる機能があります。
このポストは、宛先のユーザーのフィードと、
自分をフォローしているユーザーにのみ表示されます。
この返信機能の簡単なバージョンを実装してみましょう。具体的には、@replyは受信者のフィードと送信者のフィードにのみ表示されるようにします。
これを実装するには、micropostsテーブルのin_reply_toカラムと、
追加のincluding_repliesスコープをMicropostモデルに追加する
必要があると思います。スコープの詳細については、
RailsガイドのActive Record クエリインターフェイスを参照してください。

このサンプルアプリケーションではユーザー名が重なり得るので、
ユーザー名を一意に表す方法も考えなければならないでしょう。
1つの方法は、idと名前を組み合わせて@1-michael-hartlのようにすることです

もう1つの方法は、ユーザー登録の項目に一意のユーザー名を追加し、@replyで使えるようにすることです。

メッセージ機能

Twitterでは、ダイレクトメッセージを行える機能がサポートされています。この機能をサンプルアプリケーションに実装してみましょう
(ヒント: Messageモデルと、新規マイクロポストにマッチする正規表現が必要になるでしょう)。

フォロワーの通知

ユーザーに新しくフォロワーが増えたときにメールで通知する機能を
実装してみましょう。続いて、メールでの通知機能をオプションとして選択可能にし、不要な場合は通知をオフにできるようにしてみましょう。
メール周りで分からないことがあったら、
RailsガイドのAction Mailerの基礎にヒントがないか調べてみましょう。

RSSフィード

ユーザーごとのマイクロポストをRSSフィードする機能を実装してください。次にステータスフィードをRSSフィードする機能も実装し、
余裕があればフィードに認証スキームも追加してアクセスを制限してみてください。

REST API

多くのWebサイトはAPI (Application Programmer Interface)を
公開しており、第三者のアプリケーションからリソースのget/post/put/deleteが行えるようになっています。
サンプルアプリケーションにもこのようなREST APIを実装してください。解決のヒントは、respond_toブロックをコントローラーの多くのアクションに追加することです。
このブロックはXMLをリクエストされたときに応答します。
セキュリティには十分注意してください。
認可されたユーザーにのみAPIアクセスを許可する必要があります。

検索機能

現在のサンプルアプリケーションには、ユーザーの一覧ページを端から探す、もしくは他のユーザーのフィードを表示する以外に他のユーザーを検索する手段がありません。
この点を強化するために、検索機能を実装してください。
続いて、マイクロポストを検索する機能も追加してください
(ヒント: まずは自分自身で検索機能に関する情報を探してみましょう。難しければ、@budougumi0617 さんの簡単な検索フォームの実装例を参考にしてください)。

他の拡張機能

上記の他にも、「いいね機能」「シェア機能」「minitestの代わりにRSpecで書き直す」「erbの代わりにHamlで書き直す」
「エラーメッセージをI18nで日本語化する」「オートコンプリート機能」といったアイデアがありそうです。

14.4.3 本章のまとめ

1:has_many :throughを使うと、複雑なデータ関係をモデリングできる

2:has_manyメソッドには、クラス名や外部キーなど、いくつものオプションを渡すことができる

3:適切なクラス名と外部キーと一緒に
has_many/has_many :throughを使うことで、
能動的関係 (フォローする) や受動的関係 (フォローされる)がモデリングできた

4:ルーティングは、ネストさせて使うことができる

5:whereメソッドを使うと、
柔軟で強力なデータベースへの問い合わせが作成できる

6:Railsは (必要に応じて) 低級なSQLクエリを呼び出すことができる

7:本書で学んだすべてを駆使することで、フォローしているユーザーのマイクロポスト一覧をステータスフィードに表示させることができた

has_many through
多対多の関係性を定義する関連付けメソッド。

source
has_manyに対してパラメータを与えるオプション。
sourceオブションで与えた値は配列の元を表しているので、実際の配列のインデックスは変わらない。

collection
コレクションルーティングを追加するメソッド。
idを指定せずに全てのメンバーを表示したりできる