学習記録

アウトプット用に作りました

フォロー機能を実装する

ポートフォリオで使用するために一度試しに実装してみたいと思います。事前にユーザー登録、ログインはsorceryで実装済みです。

参考にしたサイト Railsでユーザーフォロー機能を実装する(Ajax使うよ)① - Qiita

テーブル設計

ユーザーフォロー機能の実装のために用意したテーブル設計は下記のようになります。本当に分かりづらいのですが、RelationshipsテーブルがUsersテーブル(フォローするユーザー)とUsersテーブル(フォローされるユーザー)の中間テーブルとしての役割を担っています。

f:id:kimura34:20210601225801p:plain

Relationshipモデルを作成する

まずはRealtionshipモデルを作成します。

$ bundle exec rails g model Relationship
Running via Spring preloader in process 1766
      invoke  active_record
      create    db/migrate/20210531014628_create_relationships.rb
      create    app/models/relationship.rb

外部キーのuser_idカラムとfollower_idカラムを作成します。ここでuser_idはフォローする人、follower_idはフォローされる人を指します。

follower_idの参照先のテーブルはUsersテーブルにしてあげたいので、foreign_key: {to_table: :users}としてあげてます。この設定をしないと存在しないfollowerテーブルを参照しようとします。この設定をすることで、followerを探すときはUsersテーブルのfollower_idをみるようになります。

class CreateRelationships < ActiveRecord::Migration[6.1]
  def change
    create_table :relationships do |t|
      t.references :user
      t.references :follower, foreign_key: { to_table: :users }

      t.timestamps

      t.index [:user_id, :follower_id], unique: true
    end
  end
end

外部キー制約をつけたので、インデックスは自動で付与されるので、add_index :relationships, :follower_idという記載は不要です。

t.index [:user_id, :follower_id], unique: trueこの記載は、unique制約で同じ人を2回フォローできないようにするために必要です。

マイグレーションファイルの修正が終わったので、テーブルを作成します。

$ rails db:migrate
== 20210531014628 CreateRelationships: migrating ==============================
-- create_table(:relationships)
   -> 0.1209s
== 20210531014628 CreateRelationships: migrated (0.1210s) =====================

次からはフォローする、フォローされるの2つの状況を1つずつ分けて理解していきたいと思います。まず最初はユーザーが他の人をフォローするという状況を説明していきます。

フォローする

Userモデルのアソシエーションの記述は下記のようになります。

class User < ApplicationRecord
  has_many :relationships, dependent: :destroy
  has_many :followings, through: :relationships, source: :follower
end

一行目は、UserはたくさんのRelationshipsを持っています。userが消去されたら結びつくrelationshipのデータを消去するという意味です。

二行目のfollowingsは「フォローしている人を取得するため」のアソシエーションの別名としてつけました。followingモデルは存在しないので、参照するモデルとしてRelationshipを指定しています。この設定をすることで、user.followingsと打つだけで、userが中間テーブルrelationshipsを取得し、 relationshipfollower_idからフォローしている人を取得しています。source: :followerというオプションをつけて関連先のモデル名としてfollowerを参照するようにします。しかしfollowerテーブルは作っていないので、どうなるのかというと、、次のRelationshipモデルのアソシエーションで定義しています。

Relationshipモデルのアソシエーションの記載は下記のようになります。

class Relationship < ApplicationRecord
  belongs_to :follower, class_name: 'User'
end

class_name: 'User'と補足設定することで、Followerクラスという存在しないクラスを参照することを防ぎ、Userクラスであることを明示しています。

実際にユーザーを作成してフォローすることができているか確認します。まず最初にユーザーデータを3つ作成しました。

irb(main):002:0> user = User.new(email: 'sayo@example.com', password: 'password', password_confirmation: 'password')
=> #<User id: nil, email: "sayo@example.com", crypted_password: nil, salt: nil, created_at: nil, updated_at: nil>
irb(main):003:0> user.save
  TRANSACTION (0.2ms)  BEGIN
  User Exists? (0.3ms)  SELECT 1 AS one FROM `users` WHERE `users`.`email` = 'sayo@example.com' LIMIT 1
  User Create (87.0ms)  INSERT INTO `users` (`email`, `crypted_password`, `salt`, `created_at`, `updated_at`) VALUES ('sayo@example.com', '$2a$10$tXrj8J450XST3ON.PlaGdetehohD1BQj92d0SctEZ6wfO5BfLOJ2u', 'hrxhPytqWsU4N__UzSZQ', '2021-05-31 02:00:17.356071', '2021-05-31 02:00:17.356071')
  TRANSACTION (2.1ms)  COMMIT
=> true
irb(main):006:0>  user = User.new(email: 'eri@example.com', password: 'password', password_confirmation: 'password')
=> #<User id: nil, email: "eri@example.com", crypted_password: nil, salt: nil, created_at: nil, updated_at: nil>
irb(main):007:0> user.save
  TRANSACTION (2.4ms)  BEGIN
  User Exists? (0.8ms)  SELECT 1 AS one FROM `users` WHERE `users`.`email` = 'eri@example.com' LIMIT 1
  User Create (41.4ms)  INSERT INTO `users` (`email`, `crypted_password`, `salt`, `created_at`, `updated_at`) VALUES ('eri@example.com', '$2a$10$tZCz10GG/8h.8TJKdaOyjO2P2XxsQwGh9jtrh4yj6RApLHO1J1Exa', 'MdzTjVF715m3aVxN39tN', '2021-05-31 02:01:46.998133', '2021-05-31 02:01:46.998133')
  TRANSACTION (0.8ms)  COMMIT
=> true
irb(main):008:0>  user = User.new(email: 'yuka@example.com', password: 'password', password_confirmation: 'password')
=> #<User id: nil, email: "yuka@example.com", crypted_password: nil, salt: nil, created_at: nil, updated_at: nil>
irb(main):009:0> user.save
  TRANSACTION (0.2ms)  BEGIN
  User Exists? (0.5ms)  SELECT 1 AS one FROM `users` WHERE `users`.`email` = 'yuka@example.com' LIMIT 1
  User Create (40.3ms)  INSERT INTO `users` (`email`, `crypted_password`, `salt`, `created_at`, `updated_at`) VALUES ('yuka@example.com', '$2a$10$dJsYPcoFqBl3Q95JUxJNR.cQu20uwxiBrjkUsdIqBhr2EV85PhDTy', 'tbwU-CfEuFXmxJXKNyc5', '2021-05-31 02:02:11.111278', '2021-05-31 02:02:11.111278')
  TRANSACTION (0.4ms)  COMMIT
=> true

次に作成したユーザー(id: 3)が(id: 2)のユーザーをフォローします。

irb(main):010:0> user.relationships.create!(follower_id: 2)
  TRANSACTION (0.2ms)  BEGIN
  User Load (0.4ms)  SELECT `users`.* FROM `users` WHERE `users`.`id` = 2 LIMIT 1
  Relationship Create (65.1ms)  INSERT INTO `relationships` (`user_id`, `follower_id`, `created_at`, `updated_at`) VALUES (3, 2, '2021-05-31 02:02:21.616116', '2021-05-31 02:02:21.616116')
  TRANSACTION (41.8ms)  COMMIT
=> #<Relationship id: 1, user_id: 3, follower_id: 2, created_at: "2021-05-31 02:02:21.616116000 +0000", updated_at: "2021-05-31 02:02:21.616116000 +0000">
irb(main):011:0> user.followings
  User Load (40.6ms)  SELECT `users`.* FROM `users` INNER JOIN `relationships` ON `users`.`id` = `relationships`.`follower_id` WHERE `relationships`.`user_id` = 3 /* loading for inspect */ LIMIT 11
=> #<ActiveRecord::Associations::CollectionProxy [#<User id: 2, email: "eri@example.com", crypted_password: "$2a$10$tZCz10GG/8h.8TJKdaOyjO2P2XxsQwGh9jtrh4yj6RA...", salt: "MdzTjVF715m3aVxN39tN", created_at: "2021-05-31 02:01:46.998133000 +0000", updated_at: "2021-05-31 02:01:46.998133000 +0000">]>

これでフォローするというアソシエーションが機能していることがわかりました。

フォローされている

次は、あるユーザーがたくさんの人にフォローされているという関係を作成します。

Userモデルのアソシエーションの記述は下記のようになります。

class User < ApplicationRecord
  has_many :passive_relationships, class_name: 'Relationship', foreign_key: 'follower_id', dependent: :destroy
  has_many :followers, through: :passive_relationships, source: :user
end

一行目は、has_many :relationshipsでいいかな?と思ったのですが、ユーザーをフォローするという関係性でrelationshipモデルを参照してしまっていてuser.relationshipsのように使うことができなくなってしまうので、別名としてpassive_relationshipsと名付けました。class_name: 'Relationship'Relationshipモデルを参照するように指定しています。foreign_key: 'follower_id'relaitonshipsテーブルにアクセスする時、follower_idを元にアクセスしてくださいということを意味しています。

二行目は、Userはたくさんのfollowerspassive_relationshipsを通じて持っています。passive_relationshipsは、直前の定義によりRelationshipクラスを参照するようになっています。

Relationshipモデルのアソシエーションの記載は下記のようになります。

class Relationship < ApplicationRecord
    belongs_to :user
end

コンソールでid:3のユーザーがid:2のユーザーをフォローするというデータは作ったので、id:2のユーザーは、id:3のユーザーにフォローされているはずです。

irb(main):001:0> user2 = User.second
  User Load (1.9ms)  SELECT `users`.* FROM `users` ORDER BY `users`.`id` ASC LIMIT 1 OFFSET 1
=> #<User id: 2, email: "eri@example.com", crypted_password: "$2a$10$tZCz10GG/8h.8TJKdaOyjO2P2XxsQwGh9jtrh4yj6RA...", salt: "MdzTjVF715m3aVxN39tN", created_at: "2021-05-31 02:01:46.998133000 +0000", updated_at: "2021-05-31 02:01:46.998133000 +0000">
irb(main):002:0> user2.followers
  User Load (42.8ms)  SELECT `users`.* FROM `users` INNER JOIN `relationships` ON `users`.`id` = `relationships`.`user_id` WHERE `relationships`.`follower_id` = 2 /* loading for inspect */ LIMIT 11
=> #<ActiveRecord::Associations::CollectionProxy [#<User id: 3, email: "yuka@example.com", crypted_password: "$2a$10$dJsYPcoFqBl3Q95JUxJNR.cQu20uwxiBrjkUsdIqBhr...", salt: "tbwU-CfEuFXmxJXKNyc5", created_at: "2021-05-31 02:02:11.111278000 +0000", updated_at: "2021-05-31 02:02:11.111278000 +0000">]>

最終的なUserモデルとRelationshipモデルのアソシエーションを下記にまとめておきます。

class User < ApplicationRecord
  has_many :relationships, dependent: :destroy
  has_many :followings, through: :relationships, source: :follower
  has_many :passive_relationships, class_name: 'Relationship', foreign_key: 'follower_id', dependent: :destroy
  has_many :followers, through: :passive_relationships, source: :user
end
class Relationship < ApplicationRecord
  belongs_to :user
  belongs_to :follower, class_name: 'User'
end

relationshipsコントローラを作成する

次にフォロー機能を定義するrelationshipsコントローラを作成します。

$ rails g controller relationships
Running via Spring preloader in process 2378
      create  app/controllers/relationships_controller.rb
      invoke  erb
      create    app/views/relationships
      invoke  helper
      create    app/helpers/relationships_helper.rb
      invoke  assets
      invoke    scss
      create      app/assets/stylesheets/relationships.scss

作成したコントローラを実装していきます。

class RelationshipsController < ApplicationController
  def create
    @other_user = User.find(params[:follower])
    current_user.follow(@other_user)
    redirect_back fallback_location: root_path
  end

  def destroy
    @user = current_user.relationships.find(params[:id]).follower
    current_user.unfollow(params[:id])
    redirect_back fallback_location: root_path
  end
end

createアクションはフォローするという処理、destroyアクションはフォローを解除するという処理です。ここで出てきたfollowunfollowメソッドはモデルに定義します。

Userモデルにフォローする、フォローしているか確認する、フォロー解除するというメソッドをUserモデルに作成しました。

def follow(other_user)
  return if self == other_user
  relationships.find_or_create_by!(follower: other_user)
end

def following?(user)
  followings.include?(user)
end

def unfollow(relathinoship_id)
  relationships.find(relathinoship_id).destroy!
end

return if self == other_userで、フォローしようとしているother_userが自分自身ではないかを検証しています。

ルーティングとビュー

フォロー機能のルーティングとユーザー一覧機能のルーティングの追加もしておきます。

Rails.application.routes.draw do
    resources :users, only: [:new, :create, :index]
    resources :relationships, only: [:create, :destroy]
end

ルーティングを追加したのでusersコントローラにindexアクションも追加しました。

class UsersController < ApplicationController
  # ユーザー一覧
  def index
    @users = User.all
  end
end

ユーザー一覧のビュー(index.html.erb)も準備します。

$ touch app/views/users/index.html.erb

フォローボタン、解除ボタンをそれぞれパーシャルに切り出して、管理しやすいようにしておきたいので、それぞれのファイルを作成します。

$ touch app/views/relationships/_follow_button.html.erb
$ touch app/views/relationships/_unfollow_button.html.erb
relationships/_follow_button.html.erb

<%= button_to 'フォロー', relationships_path(follower: user), method: :post %>
relationships/_unfollow_button.html.erb

<%= button_to 'フォロー解除', relationship_path(current_user.relationships.find_by(follower: user)), method: :delete %>

上記で作ったテンプレートを埋め込んでユーザー一覧画面を作成します。

<p id="notice"><%= notice %></p>

<h1>Users</h1>

<table>
  <thead>
    <tr>
      <th>Email</th>
      <th colspan="3"></th>
    </tr>
  </thead>

  <tbody>
    <% @users.each do |user| %>
      <tr>
        <td><%= user.email %></td>
        <td>
        <% if logged_in? && current_user != user %>
          <div class="form-group">
            <% if current_user.following?(user) %>
              <%= render 'relationships/unfollow_button', user: user %>
            <% else %>
              <%= render 'relationships/follow_button', user: user %>
            <% end %>
          </div>
        <% end %>
        </td>
      </tr>
    <% end %>
  </tbody>
</table>

<br>

<%= link_to 'New User', new_user_path %>

ここまでの設定でログイン中のユーザーにはボタンが出ず、そのほかのユーザーにはボタンが出るようになって、また「フォロー」ボタンを押して画面をリロード後「フォロー解除」ボタンが現れるようになりました。

f:id:kimura34:20210601230139p:plain

この時の処理をメモとして残しておきます。フォローした時の状況です。

Started POST "/relationships?follower=2" for ::1 at 2021-06-01 11:32:08 +0900
Processing by RelationshipsController#create as HTML
  Parameters: {"authenticity_token"=>"[FILTERED]", "follower"=>"2"}
  User Load (12.4ms)  SELECT `users`.* FROM `users` WHERE `users`.`id` = 2 LIMIT 1
  ↳ app/controllers/relationships_controller.rb:3:in `create'
  User Load (43.7ms)  SELECT `users`.* FROM `users` WHERE `users`.`id` = 1 LIMIT 1
  ↳ app/controllers/relationships_controller.rb:4:in `create'
  Relationship Load (93.0ms)  SELECT `relationships`.* FROM `relationships` WHERE `relationships`.`user_id` = 1 AND `relationships`.`follower_id` = 2 LIMIT 1
  ↳ app/models/user.rb:22:in `follow'
  TRANSACTION (13.2ms)  BEGIN
  ↳ app/models/user.rb:22:in `follow'
  Relationship Create (90.3ms)  INSERT INTO `relationships` (`user_id`, `follower_id`, `created_at`, `updated_at`) VALUES (1, 2, '2021-06-01 02:32:09.536051', '2021-06-01 02:32:09.536051')
  ↳ app/models/user.rb:22:in `follow'
  TRANSACTION (30.1ms)  COMMIT
  ↳ app/models/user.rb:22:in `follow'
Redirected to http://localhost:3000/users
Completed 302 Found in 743ms (ActiveRecord: 325.5ms | Allocations: 5074)

Started GET "/users" for ::1 at 2021-06-01 11:32:09 +0900
Processing by UsersController#index as HTML
  Rendering layout layouts/application.html.erb
  Rendering users/index.html.erb within layouts/application
  User Load (100.0ms)  SELECT `users`.* FROM `users`
  ↳ app/views/users/index.html.erb:14
  User Load (2.7ms)  SELECT `users`.* FROM `users` WHERE `users`.`id` = 1 LIMIT 1
  ↳ app/views/users/index.html.erb:18
  User Exists? (64.6ms)  SELECT 1 AS one FROM `users` INNER JOIN `relationships` ON `users`.`id` = `relationships`.`follower_id` WHERE `relationships`.`user_id` = 1 AND `users`.`id` = 2 LIMIT 1
  ↳ app/models/user.rb:26:in `following?'
  Relationship Load (1.6ms)  SELECT `relationships`.* FROM `relationships` WHERE `relationships`.`user_id` = 1 AND `relationships`.`follower_id` = 2 LIMIT 1
  ↳ app/views/users/index.html.erb:23
  User Exists? (1.3ms)  SELECT 1 AS one FROM `users` INNER JOIN `relationships` ON `users`.`id` = `relationships`.`follower_id` WHERE `relationships`.`user_id` = 1 AND `users`.`id` = 3 LIMIT 1
  ↳ app/models/user.rb:26:in `following?'
  Relationship Load (2.0ms)  SELECT `relationships`.* FROM `relationships` WHERE `relationships`.`user_id` = 1 AND `relationships`.`follower_id` = 3 LIMIT 1
  ↳ app/views/users/index.html.erb:23
  Rendered users/index.html.erb within layouts/application (Duration: 211.0ms | Allocations: 5505)
[Webpacker] Everything's up-to-date. Nothing to do
  Rendered layout layouts/application.html.erb (Duration: 268.0ms | Allocations: 10045)
Completed 200 OK in 274ms (Views: 99.5ms | ActiveRecord: 172.1ms | Allocations: 10499)