Ruby on Railsチュートリアル第6版 追加第2章

Ruby on Railsチュートリアル第6版が終わり、そこで作ったサンプルアプリケーションの修正や機能追加をやってます。

追加第2章では、返信機能を追加します。

追加第2章 返信機能

追加第2章 返信機能

Ruby on Railsチュートリアル第6版ではTwitterライクなアプリケーションを作りました。そのアプリケーションをベースにして、機能を追加しながらRailsの学習を進めます。

返信機能はRailsチュートリアルにも書かれてるものです。Railsチュートリアルにあるヒントを参考にしながら実装します。完成したのが上の画像です。

どこまでTwitterっぽくするかは悩みどころですが、ある程度のところまでできたらいいなという思いから、機能要件を決めました。機能要件という言葉の使い方があってるかいまいちわかりませんが。

返信機能の要件と実装案

最初に決めた機能要件は次の通りです。

返信機能の要件
  1. @user_nameをマイクロポストに入れることで返信することができる(後ろにスペースが必要)
  2. 返信は返信したユーザーと宛先のユーザーのフィードに表示される
  3. 各マイクロポストに返信用のリンクを表示する
  4. マイクロポストに対する返信の場合は、どのマイクロポストに対する返信かを確認することができる
  5. マイクロポスト内の@user_nameをクリックするとそのユーザーのプロフィールページに飛ぶ

これらの機能を実現するために、何をどのように実装すればいいかを事前に書き出しました。網羅できてないはずですし、実装場所を間違ってるものもあるはずなので、途中で追加・修正はあると思います。

どのように書けばいいかわからなかったので、追加する場所単位でリストアップしてあります。

返信機能の実装案
usersテーブル
  • 一意のuser_nameカラムを追加
ユーザー登録フォーム
  • user_nameフォーム追加
ユーザー情報の表示
  • nameに続けて「(@user_name)」を表示
  • これはマイクロポストに表示されてるnameも含む
Micropostモデル
  • including_repliesスコープ追加(マイクロポストの検索条件)
    ※Railsチュートリアルに書いてある
micropostsテーブル
  • in_reply_to_micropostカラム追加(micropostのidを保存)
  • in_reply_to_userカラム追加(user_nameを保存)
    ※この2つはRailsチュートリアルに書いてあるものを参考に変更
Micropostsコントローラ
  • createアクションでin_reply_to系カラムへの保存(空欄OK)
  • 正規表現を使って@からスペースまでを取得
  • マイクロポスト内の@user_nameに@userへのリンクを張る

feed
  • 返信リンク追加
  • 自分宛てマイクロポストを表示(including_repliesスコープで実現?)

ざっとこんな感じです。ここにまとめながら移動させた項目とかもあります。

データベースの変更

まずは簡単にできて、他の項目の前提になってるところから始めることにしました。データベースの変更です。

データベースに関しては、micropostsテーブルとusersテーブルにカラムを追加する必要があります。そのとき、usersテーブルのuser_nameには一意性を持たせなければいけません。

ということで、rails generateでマイグレーションファイルを2つ作りました。micropostsテーブル用とusersテーブル用です。

class AddReplyToMicroposts < ActiveRecord::Migration[6.0]
  def change
    add_column :microposts, :in_reply_to_user, :string
    add_column :microposts, :in_reply_to_micropost, :integer
  end
end
class AddNameToUsers < ActiveRecord::Migration[6.0]
  def change
    add_column :users, :user_name, :string
    add_index :users, :user_name, unique: true
  end
end

user_nameをデータベースレベルで一意になるようにしましたが、Railsアプリケーション側でも一意になるようにします。ついでに存在性と長さのバリデーションも追加します。

validates :user_name, presence: true,
                        length: { maximum: 20 }

(app/models/user.rb)

長さの制限をどうしようかと思いましたが、あまり長くてもマイクロポストに影響しそうなので20文字にしました。

ユーザー登録フォームの変更と、@user_nameの表示

データベースに保存できるようにしたので、次はユーザー登録フォームを変更します。これは簡単で、

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

(app/views/users/_form.html.erb)

このコードを追加するだけです。nameとemailの間に入れておきました。「name」と「user name」は表現がわかりにくいなと思いつつ、そこは今後の検討課題としました。

@user_nameの表示に関しては、「name (@user_name)」となるように書き換えました。

書き換えたのは、

  • app/views/shared/_user_info.html.erb
  • app/views/users/show.html.erb
  • app/views/users/_user.html.erb
  • app/views/microposts/_micropost.html.erb

です。もしかしたら抜けてるかもしれませんが。「_user_info.html.erb」だけコードを書いておきます。

<%= current_user.name %> (@<%= current_user.user_name %>)

式展開で書いてもよかったかなと思ってます。

これでユーザー登録でuser_nameを入れられるようになったはずなので、実際に試してみました。

が、user_nameカラムがnilでした。

考えてみたら、ユーザー登録の際に受け取れるパラメータを制限してたんですよね。そこにuser_nameを追加して完成です。

def user_params
  params.require(:user).permit(:name,
                 :user_name,
                 :email,
                 :password,
                               :password_confirmation)
end

さらにサンプルユーザーにuser_nameを追加できるようにseeds.rbを変更しました。

1人目のExample Userは普通にuser_nameを追加するだけですが、2目以降はuser_nameが重複しないようにする必要があります。単純に番号を振ろうかと思いましたが、短すぎてわかりにくいので、「番号+name」という形にしました。

このままだと20文字というバリデーションに引っかかるので、nameは最初の3文字だけを使うことにしました。

user_name = "#{n+1}#{name[0,3]}"

それがこのコードです。これで「db:migrate:reset」後に「db:seed」を実行してサンプルユーザーを入れました。

Userモデルのテスト、インテグレーションテスト(user_name)

UserモデルとUsersコントローラに関しては先にテストを書いとけばよかったなと思いつつ、後からテストを書きました。

Userモデルのテスト(user_name)

まずUserモデルから。テストはまだちゃんと理解できてないので、とりあえずユーザーに関係しそうなテストファイルを開いて確認してみたところ、Userモデルでもテストが必要なことに気づきました。まだまだ修行が足りないようです。

def setup
  @user = User.new(name: "Example User",
              user_name: "example",
                  email: "user@example.com",
               password: "foobar",
         password_confirmation: "foobar")
end

test "user name should be preset" do
  @user.user_name = "   "
  assert_not @user.valid?
end

test "user name should not be too long" do
  @user.user_name = "a" * 21
  assert_not @user.valid?
end

(test/models/user_test.rb)

追加したテストは2つです。setupでuser_nameを追加したので、そこも書いておきました。1つ目はuser_nameが空欄、2つ目は21文字以上という条件で、バリデーションに引っかかることを確認してます。

UserモデルのバリデーションをコメントアウトするとREDになり、コメントアウトを解除するとGREENになるので、期待通りの実装ができたことが確認できました。

インテグレーションテスト(user_name)

次はインテグレーションテストを変更します。

その前に、users.ymlを書き換える必要があります。テスト用のユーザーにuser_nameがないとテストできないですからね。「user_name: hogehoge」みたいな感じでそれぞれ違うuser_nameを指定しました。繰り返し処理で作るユーザーは、

user_name: <%= "user#{n}" %>

という感じで「user+数字」になるようにしました。

まずはユーザー登録と編集に関するインテグレーションテスト。基本的な流れは変わらず、user_nameを追加するだけです。注意点としては、編集時のエラーメッセージにあるエラー数を4から5に変えることくらいですね。

次に、ユーザー一覧とユーザーのプロフィールページのインテグレーションテストです。

ユーザー一覧はuser.nameでプロフィールページにリンクされてるか確認してるので、それにuser.user_nameを実際の表示に合わせて付け加えました。

assert_select 'a[href=?]', user_path(user),
              text: "#{user.name} (@#{user.user_name})"

こんな感じです。プロフィールページのテストも同じような感じで直しました。

テストしてるところをコメントアウト・削除するとRED、元に戻すとGREENになったので、成功だと思います。

返信フォームとマイクロポストページ

返信リンクをクリックしたら返信フォームが表示されるというような動きにしようと考えました。

本当はAjaxとかでページ移動せずに返信フォームを表示したいなと思いましたが、フロントエンドの技術はまだ持ち合わせてないので、マイクロポストページ(showアクション)で代用することにしました。

返信フォームとマイクロポストページの実装

まずは、ルーティングを変更して、Micropostsコントローラにshowアクションを追加します。show.html.erbも作って、マイクロポストを1つ表示するようにします。

<% provide(:title, "#{@micropost.user.name}'s micropost") %>

<div class="row">
  <aside class="col-md-4">
    <%= render 'users/follow_form' %>
    <section class="user_info">
      <h1>
        <%= gravatar_for @micropost.user %>
        <%= @micropost.user.name %> (@<%= @micropost.user.user_name %>)
      </h1>
    </section>
    <section class="stats">
      <%= render 'shared/stats' %>
    </section>
  </aside>
  <div class="col-md-8">
    <h3><%= @micropost.user.name %> Micropost</h3>
    <ol class="microposts">
      <%= render @micropost %>
    </ol>
    <% if logged_in? %>
      <section class="micropost_form">
        <%= render 'shared/micropost_form' %>
      </section>
    <% end %>
  </div>
</div>

(app/views/microposts/show.html.erb)

フォローボタンの位置は違いますが、users/show.html.erbと共通部分が多いため、リファクタリングの対象になるのかもしれません。でも、微妙な違いがあって、パーシャル化の方法が思いつかなかったのでそのままにしました。

Micropostsコントローラのshowアクションはこんな感じです。

def show
  @micropost = Micropost.find(params[:id])
  @reply = Micropost.new
  @reply.content = "@#{@micropost.user.user_name} "
  @user = @micropost.user
end

なんかスマートじゃないなという気はしますが、返信先のマイクロポストを@micropostに入れ、返信を@replyに入れました。で、「@user_name」が入ってると返信になると決めたので、最初から「@user_name」を入れてあります(後ろに半角スペースも)。

@userに関しては、フォローボタンのパーシャルが動かすために入れてます。これを入れないと、@userがcurrent_userになってしまいます。

どこで@userにcurrent_userが代入されるかというと、「_stats.html.erb」です。このパーシャルの1行目には、

@user ||= current_user

というコードがあり、@userがnilだったらcurrent_userを入れるようになってます。debuggerで確認してやっと見つけました。

マイクロポストページでは、左側にそのマイクロポストを投稿したユーザーの情報を表示するようにしてます。なので、@userにそのマイクロポストの投稿者を入れておく必要があります。

まとめると、フォローボタンを表示することと、そのマイクロポストを投稿したユーザーの情報を表示することを目的として、showアクションに@userのコードを入れたということです。最初はそこまでわかってませんでしたが。

次にマイクロポストの投稿フォームですが、ログインしてるときだけ表示するようにしました。フォームのパーシャル側にも変更を加えてます。

返信のマイクロポストは@replyに入ってるため、そのままではパーシャルで利用できません。変更を加えないと、@micropost(返信先のマイクロポスト)を更新するリクエスト(PATCHリクエスト)を送ってしまい、ルーティングエラーになります。

これを解決するために、@replyに何か入ってるとき(nilではないとき)に@replyを@micropostに代入するようにしました。

<% @micropost = @reply unless @reply.nil? %>

具体的にはこのコードを追加しました。実際に動かしてみると、想定通りに動きます。普通の投稿もOKです。

返信フォームとマイクロポストページのテスト

返信フォームとマイクロポストページのテストは、2つの視点で書きました。統合テストです。

  1. ログインしてない場合は返信フォームが表示されない
  2. ログインした状態でプロフィールページから返信リンクをクリックして返信フォームを表示する

これを書きながら1番目の視点のテストにいくつか追加しました。それはマイクロポストページにマイクロポストなどが表示されてるか確認するものです。2番目では入れてたので、コピペです。

1番目の視点では、ログインしてない状態でマイクロポスト、ユーザーの名前(name)、user_nameが表示されるか、返信フォームが表示されないかを確認します。

2番目の視点では、次のようなストーリーを用意しました。

ログインした状態で自分のプロフィールページに行き、返信リンクがあるか確認(自分への返信も可能という設計)。

その後、他のユーザーのプロフィールページに行き、返信リンクがあるか確認し、返信リンクをクリック(マイクロポストページを表示)。マイクロポストページにマクロポスト、ユーザーの名前(name)、user_name、返信フォーム(「@user_name 」入り)があるかを確認。

おそらく、これに続いて、返信するというストーリーが追加されることになると思います。

@user_nameにリンクを貼る

返信ができるようになったので、@user_nameにそのユーザーのプロフィールページへのリンクを貼るようにします。具体的には@user_nameにaタグを追加することになります。

その準備として、マイクロポストから@user_nameを抽出して、user_nameをin_reply_to_userカラムに保存します。このとき、@user_nameの後ろにスペースがあることを条件としました。

@user_nameを抽出する

1つのマイクロポストに複数の@user_nameがあっても機能するようにしました。Micropostsコントローラのcreateアクションに処理を追加します。

if @micropost.content.include?("@")
  replied_users = []
  split_microposts = @micropost.content.split(" ")
  split_microposts.each do |split_micropost|
    split_micropost = split_micropost.scan(/@(.*)/)
    replied_users << split_micropost if User.find_by(user_name: split_micropost)
  end
  @micropost.in_reply_to_user = replied_users.join(",")
  @micropost.in_reply_to_micropost = params[:micropost_id]
end

もっとシンプルに書く方法がありそうだなと思いつつ、今のスキルではこれが限界でした。

まず、投稿されたマイクロポストに「@」が含まれてるときだけ、@user_nameの抽出を行うようにしました。抽出されたuser_nameはreplied_users配列に入れます。

@user_nameの後ろにスペースがあるという条件なので、マイクロポストをスペースで分割します。分割されたマイクロポストの配列から要素を1つずつ取り出し、@から後ろを取り出します。

取り出した文字列が登録ユーザーのuser_nameに存在してる場合、replied_users配列に入れます。

replied_users配列に入ってる要素を「,」区切りでマイクロポストのin_reply_to_user属性に入れます。

ここを実装するのにものすごく苦労しました。Railsコンソールのリロードを忘れていて、何をやっても結果が変わらないという無駄なことを何度も繰り返してしまったのが1番の原因です。

ついでに、宛先のマイクロポストのidもin_reply_to_micropost属性に入れてあります。そのために、「_micropost_form.html.erb」にhiddenフィールドを追加しました。

<%= hidden_field_tag :micropost_id, params[:id] %>

マイクロポストへの返信はそのマイクロポストのshowページで行うことにしたので、params[:id]にはマイクロポストのidが入ってます。それをmicropost_idとして投げ、Micropostsコントローラ側でparams[:micropost_id]として受け取ってます。

@user_nameをaタグ付きに変換する

返信のマイクロポストの表示で、宛先(@user_name)からそのユーザーのプロフィールページに飛べるようにします。Micropostsコントローラのshowアクションから実装しました。

replied_user_list = @micropost.in_reply_to_user
  if replied_user_list
    replied_users = replied_user_list.split(",")
    replied_users.each do |replied_user|
      if user = User.find_by(user_name: replied_user)
        @micropost.content = @micropost.content.gsub("@#{replied_user}",
        "<a href=\"#{user_path(user)}\">@#{user.user_name} </a>")
      end
    end
  end

マイクロポストのin_reply_to_userをreplied_user_listに入れ、そこに何か入ってたときだけ処理を実行します。

user_nameは「,」区切りになってるので、「,」で分割してreplied_users配列に入れます。その要素を1つずつ取り出し、user_nameが一致するユーザーを検索します。ユーザーが存在する場合、@user_nameをaタグ付きに置き換えます。

このままだとRailsが自動的にエスケープ処理をして、aタグがそのまま表示されてしまいます。リンクされるように、エスケープ処理を止めました。

<%= micropost.content.html_safe %>

「_micropost.html.erb」にあるマイクロポストを表示するところで、html_safeメソッドを使ってます。これをするとエスケープ処理が止まります。ということは、セキュリティ的に問題が出てくることが予想されます。

システム側で挿入したいaタグだけエスケープ処理しないようにしたかったんですが、その方法は見つかりませんでした。今回は勉強のための機能実装なのでとりあえずこのままにして、オリジナルアプリケーションを作るときに同じ処理をする場合はセキュリティ問題を解決した実装をします。

@user_nameのリンクをテストする

本当は最初にテストを書いた方がよかったんだろうなと思いつつ、最後になってしまいました。ただ、コメントアウトとかでテストで調べたいところを調べられてることは確認しました。

どこにテストを書けばいいのか悩みましたが、返信フォームとかの続きに書きました。

@user_name入りのマイクロポストを投稿して、その中に@user_nameがあるかチェック、showページではリンクが貼れてるかをチェックという感じです。

テスト用のマイクロポストには存在するユーザーのuser_nameを4人分と、存在しないuser_nameを使いました。

存在するユーザーのuser_nameはマイクロポストの先頭に1人、マイクロポストの途中に2人(文字列@user_nameと@user_name文字列)、マイクロポストの最後に1人としました。

1人目はいらないかなと思いますが、念のため入れました。途中の2人は@user_nameの前に文字列があっても反応するか、@user_nameの後ろに続けて文字列があったら反応しないかというのを確認してます。最後は後ろにスペースがなくても文章の最後なら反応するかのチェックです。

ちょっと過剰な気もしますが、念には念を入れて、という感じです。

存在しないユーザーに関しては、存在しないユーザーの場合はリンクを貼らないということを確認するために入れました。

テストがGREENになったので完成です。

プロフィールページのフィードでもリンクされるようにしたかったんですが、やり方がわからず、とりあえず放置することにしました。

自分宛てのマイクロポストをフィードに表示する

返信を実装できたので、自分宛てのマイクロポストをフィードに表示されるようにします。今度はテストから書きます。

自分宛てのマイクロポストが表示されてるかのテスト

このテストもどこに書けばいいかわからず、悩みました。マイクロポストの投稿関係はUserモデルのテストにいろいろあります。でも、フィードの中に自分宛てのマイクロポストがあるかを確認するテストをどう書けばいいかわかりませんでした。

そこで、ずっと使ってる返信に関する統合テストに追加することにしました。@user_nameの投稿の確認までできてるので、その状態から宛先になってるユーザーでログインし直して、root_urlに行って、その投稿があるかをチェックします。

「.content」を忘れて完成後もREDが出続けて苦労しましたが、返信マイクロポストが含まれてるかを確認するテストにしました。assert_matchですね。

自分宛てのマイクロポストを表示する

テストができたので、それがGREENになるようにコードを書いていきます。Railsチュートリアルにはスコープの追加とありましたが、うまくスコープが使えませんでした。

仕方ないので、力業のような感じで、@feed_itemsのところにSQL文を追加しました。

@feed_items = current_user.feed.or(Micropost.where("in_reply_to_user LIKE ?",
               "%#{current_user.user_name}%")).paginate(page: params[:page])

これもすごく試行錯誤しました。feedメソッドに繋げる形でコードを追加してますが、feedメソッドで取り出したマイクロポストに加えて自分宛てのマイクロポストを取り出すようにするためにどうすればいいかわからなかったからです。

結局「or」で繋げればいいということがわかり、in_reply_to_userカラムにuser_nameが含まれてるときに、そのマイクロポストを取り出すようにしました。LIKEを書けばいいかも調べて何とか実装できました。

Rails - LIKE句を使った文字のあいまい検索(特定の文字を含む語句を曖昧検索したい場合) - Qiita
特定の文字を含む語句を特定のカラムに持ったレコードを検索したい時。 仕事でRailsで文字列検索をする必要があったので、忘備録的にシェアします。 LIKE句の基本(完全一致検索) まず文字検索をしたい時はLIKE句を使いま...

最初に書いたテストで入れ忘れてた「.content」を追加して、テストはGREENになりました。

リファクタリング

最後にリファクタリングもやってみます。候補はMicropostsコントローラのcreateアクションにある@user_nameの抽出です。これをメソッド化できれば、コードが読みやすくなるはずです。

showアクションにある@user_nameにaタグを追加する(リンクを貼る)ところもメソッド化したいと思います。

@user_name抽出のメソッド化

まずはメソッド化したコードから。

def save_reply_data(micropost)
  if micropost.content.include?("@")
    replied_users = []
    split_microposts = micropost.content.split(" ")
    split_microposts.each do |split_micropost|
      split_micropost = split_micropost.scan(/@(.*)/)
      replied_users << split_micropost if User.find_by(user_name: split_micropost)
    end
    micropost.in_reply_to_user = replied_users.join(",")
    micropost.in_reply_to_micropost = params[:micropost_id]
  end
end

メソッド名は迷いましたが、それっぽい感じで付けてみました。リファクタリング前は引数なしでしたが、引数を取るように変更してあります。その関係で@micropostからmicropostへの変更もしました。

createアクション側は、

save_reply_data(@micropost)

これをデータベースに保存する前に入れてあります。リファクタリング前にあった場所です。

テストはGREENで、実際に動かしてみても変化はなさそうでした。

@user_nameへのリンクのメソッド化

こっちも似たような感じでリファクタリングしました。

def link_user_name(micropost)
  replied_user_list = micropost.in_reply_to_user
  if replied_user_list
    replied_users = replied_user_list.split(",")
    replied_users.each do |replied_user|
      if user = User.find_by(user_name: replied_user)
        micropost.content = micropost.content.gsub("@#{replied_user}",
                    "<a href=\"#{user_path(user)}\">@#{user.user_name} </a>")
      end
    end
  end
end

showアクションは、

link_user_name(@micropost)

こういうコードに変更しました。

テストはGREENで、実際に動かしてみても問題なさそうです。

これで返信機能の追加は完了としました。

まとめ

なんとか返信機能を実装することができました。まだまだ十分とは言えませんが、ここまでが今できるところかなと思います。

不満なところはいくつかあり、納得するまでやるか、とりあえずの完成とするかで迷いましたが、とりあえずの完成としました。とりあえずだとしても完成にしないとモチベーションに影響が出そうだったのが理由の1つです。その分達成感はいまいちな感じですが。

実装を諦めたのは、

  1. フィードの@user_nameにリンクを貼る
  2. フィードのマイクロポストにマイクロポストのshowページへのリンクを貼る
  3. スコープを使って自分宛てのマイクロポストを取り出す
  4. 宛先になったユーザーが退会したときに、@user_nameへのリンクをなくす(in_reply_to_userから削除する)
  5. 返信のマイクロポストを表示したときに、宛先になったマイクロポストも表示する

2番目はaタグをブロック要素に拡大(?)すればいいんですが、そうするとその中にあるaタグと共存できないみたいでレイアウトが崩れました。HTML&CSSの領域になってしまうので諦めました。

5番目も同じで、レイアウトをどう整えるかで悩んで諦めました。in_reply_to_micropostを用意して、そこにマイクロポストのidを保存するようにしてあるので、レイアウトを作る手間を惜しまなければ実装できると思います。

1番目はshowページでリンクを貼るのに苦労した後に実装しようとして、力尽きた感じです。4番目は必須とは言えないので後回しにした感じです。追加で実装するか考えてます。

3番目はいろいろ試しましたが、やり方がわかりませんでした。SQLも関係してるところなので、機能として実装できただけで良しとしました。

機能追加という形でしたが、答えがない状態で作り上げることができてそれなりに満足してます。Railsチュートリアルというベースがあったからできたという感じもあります。

一番大きいのはHTML&CSSをほとんど変更しないで実装できたというところですね。模写コーディングとかである程度の知識は持ってるつもりですが、デザインが苦手という問題を回避できたのがよかったと思ってます。

次は何をやろうか考えましたが、返信機能で実装できなかったところに取り組むことにしました。うまくいくかわかりませんが、できる限りやってみるつもりです。

Ruby on Rails チュートリアル:実例を使って Rails を学ぼう
SNS 開発を題材にした大型チュートリアル。プロダクト開発の 0→1 を創りながら学びます。電子書籍や解説動画、質問対応、法人向けサービスも提供しています。