メール送信のジョブを永続化する(〜 Active Job基礎 〜)

Active Job

Active Jobとは

Active Jobとは、非同期処理の機能を提供するライブラリ。

非同期処理とは

あるタスクを実行している間に別のタスクも実行すること。

Active Jobのメリット

・重い処理を非同期として切り出し、ユーザーを待たせないようにできる
・日時を指定して処理を実行できる

ジョブとは

プログラムの処理のこと。

キューイングとは

送られてきた一つのジョブを一時的に一つのキューとして保管, 管理すること。client(Rails)は古いキューからジョブを実行していく。

アダプターとは

アダプターとは、ActiveJobとバックエンドを繋ぐ役割をするもの。ここでいうバックエンドとはジョブを保存しておく場所のこと。バックエンドには二種類あり、一つ目はredis(データベース)やmemcached(キャッシュサーバ)などの永続性のあるもの、もう一つは永続化はできないRailsのプロセスそのものがある。(「ActiveRecordとDBを直接繋がずにmysql2とかsqlite3とかpgとかってアダプターを介して繋いでるのと同じようなイメージですね」(引用))

Railsデフォルトのアダプターはasyncというアダプターで、これはバックエンドとしてRailsのプロセスを使っているためジョブの永続化ができない(rails serverが止まるとジョブが破棄される)。なのでアダプターとしてSidekiq、バックエンドとしてRedisを使っていく。

ジョブを実行してみる

$ rails g job sample

app/job/配下に 〇〇_job.rb が作成される。(今回は sample_job.rb )。application_job.rbapp/job/配下に元からある。

ジョブが実行されるタイミングで perform メソッドが呼び出されることから、performメソッドにジョブで実行したい処理を記述する。

class SampleJob < ApplicationJob
  queue_as :default

  def perform(*args)
    p "Hello"
  end
end


ジョブをキューに入れる

rails c

SampleJob.perform_later
#=> Enqueud SampleJob (job ID: 略) to Async(default)

perform_laterメソッドはバックエンドキューにジョブを追加し、非同期実行する。

※ 引数も指定できる(perform メソッドの仮引数 *args で受け取れる)


ジョブの実行タイミングを指定することもできる。1分後に実行するジョブを追加したい場合は、setメソッドwait引数で待ち時間を指定する。

SampleJob.set(wait: 1.minute).perform_later
#=> 


ここまではRailsがデフォルトで用意しているasyncアダプターを使ったが、上述したようにasyncアダプターの場合、全てのジョブがメモリ内で管理されているため Rails が停止すると実行前のジョブは全て消える。そのためproduction環境では向いてない。

Active Jobではバックエンドと繋ぐアダプターとしてSidekiq, Resque, DelayeJob が用意されており、これらを使えばRailsが停止した場合のジョブの消滅を防止できる。

今回はSidekiqを使う。

Gemfileにgem 'sidekiq'と書き、$ bundle installを実行する。

RailsとSidekiqを連携させるため、 config/application.rbまたはconfig/environments/以下の環境別設定ファイルにアダプターを設定する。

config.active_job.queue_adapter = :sidekiq

またSidekiqを実行するためにRedis環境がバックグラウンド(ジョブ管理)に必要なのでRedisをインストールし起動させておく。

これで、Sidekiqキューにジョブを追加するための準備完了。

rails cでジョブをキューに追加

SampleJob.perform_later
#=> 

※ この状態では、ジョブはキューに追加はされるが、実行はまだされない。

$ bundle exec sidekiqでSidekiqが起動し、キューに追加したジョブが実行される。

Sidekiqにはキューの状態を確認するWebUIが用意されているので、以下をroutes.rbに追加してhttp://localhost:3000/sidekiqにアクセスすればキューの状態が確認できる。

routes.rb

 require 'sidekiq/web'

 Rails.application.routes.draw do
   if Rails.env.development?
     mount Sidekiq::Web, at: '/sidekiq'
   end
 end

メールのジョブを永続化する

前提

メール機能が実装してある

実装

mailers/user_mailer.rb

class UserMailer < ApplicationMailer
  def comment_post
    @user_from = params[:user_from]
    @user_to = params[:user_to]
    @comment = params[:comment]
    mail(to: @user_to.email, subject: "#{@user_from.username}があなたの投稿にコメントしました")
  end
end
comments_controller.rb

class CommentsController < ApplicationController
  def create
    @comment = current_user.comments.build(comment_params)
    UserMailer.with(user_from: current_user, user_to: @comment.post.user, comment: @comment).comment_post.deliver_later if @comment.save
  end
end

上記はpostにコメントが投稿された際にpost投稿ユーザーにメールが送信されるプログラム(記事参照)

ActionMailer::MessageDelivery#deliver_later を使うと、Active Job が自動的にジョブを登録してくれて、非同期でのメール送信が行われる。

deliver_nowメソッドとdeliver_laterメソッドの違い
deliver_nowメソッドが同期的にその場でメールを送信するメソッドなのに対して、deliver_laterメソッドはActive Jobを利用して非同期でメール送信が行われる。もっとも、上述したようにActive Jobの初期設定ではRailsプロセスのスレッドプールを使ってキューが用意される。この状態ではRailsプロセスを再起動するとキュー内のジョブは破棄される。そのため、メールを確実に送りたい場合はジョブを永続化する必要がある。

そこで、ジョブを永続化するためSidekiqを使う。

Gemfileに以下を書き、$ bundle installを実行。sinatraダッシュボードを利用するためのgem。

gem 'sidekiq'
gem 'sinatra'


RailsとSidekiqを連携させるため、 config/application.rbまたはconfig/environments/以下の環境別設定ファイルにアダプターを設定する

config/application.rb

module アプリ名
  class Application < Rails::Application
    config.active_job.queue_adapter = :sidekiq
  end
end


サーバー側とクライアント側の2種類の設定を追記。

config/initializers/sidekiq.rb

Sidekiq.configure_server do |config|
  config.redis = {
      url: 'redis://localhost:6379'
  }
end

Sidekiq.configure_client do |config|
  config.redis = {
      url: 'redis://localhost:6379'
  }
end

※ Redisの場所を特定するのにSidekiq.configure_serverブロックとSidekiq.configure_clientブロックの両方が必要らしい。

※ また、本番環境で使うには別途設定が必要みたい。。

Sidekiqを実行するためにRedis環境がバックグラウンド(ジョブ管理)に必要なのでインストール&起動しておく。

その後、$ bundle exec sidekiq -q default -q mailers でSidekiq を起動する。

メールを非同期で送信する際にmailers というキューが使われるため、それを起動時に -qオプションで指定しておく必要があるが、毎回オプションを指定するのは面倒。そこで、設定ファイル(config/sidekiq.yml)を用意しておき、それを起動時に読み込むということをする。

config/sidekiq.yml

:concurrency: 25
:queues:
  - default
  - mailers

※ concurrencyは、sidekiqプロセスで使用するスレッド数を表す。

設定ファイル(config/sidekiq.yml)を読み込んで起動させる。
$ bundle exec sidekiq -C config/sidekiq.yml

(バックグラウンドで動かす場合は$ bundle exec sidekiq -C config/sidekiq.yml -d

管理画面のためにroutes.rbに書き込む。

require 'sidekiq/web'

Rails.application.routes.draw do
  if Rails.env.development?
    mount Sidekiq::Web, at: '/sidekiq'
  end
end

http://localhost:3000/sidekiq にアクセスすると...

gem configを使ってみた(〜 定数管理基礎 〜)

gem configでの定数設定について

configとは

configとは、定数を管理しやすくしてくれるgem(yml形式での管理)

定数管理は、環境ごとに異なる定数を使いたい場合や定数を一箇所にまとめたい場合などに必要となる。例えばconfig/initializerやapplicatiotn_controllerなど色々なところに定数を書くが、これが一つにまとまると便利。
定数管理は、主に大規模なアプリケーションの場合に必要となる。

導入

Gemfileにgem 'config'と書いて、$ bundle install

$ rails g config:installでいくつかの設定ファイルが生成される

config/initializers/config.rb:config自身の設定ファイル
config/settings/development.yml:開発環境で使う定数を定義
config/settings/production.yml:本番環境で使う定数を定義
config/settings/test.yml:テスト環境で使う定数を定義
config/settings.local.yml:すべての環境で使う定数を定義
config/settings.yml:ローカル環境で使う定数を定義

使用例

config/settings/development.yml

default_url_options:
  host: 'localhost:3000'
rails c

Settings.default_url_options
#=> #<Config::Options host: "localhost:3000">
Settings.default_url_options.host
#=> "localhost:3000"
Settings.default_url_options[:host]
#=> "localhost:3000"
Settings.default_url_options.to_h
#=> { :host => "localhost:3000" }

Railsメール送信機能(〜 ActionMailer基礎 〜)

要件

デフォルト

app/mailers/application_mailer.rb

class ApplicationMailer < ActionMailer::Base
  default from: 'from@example.com'
  layout 'mailer'
end

※ application_mailerには全メーラー共通の設定を、

mailercontrollerみたいなもの

app/views/layouts/mailer.html.erb
<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>
    <%= yield %>
  </body>
</html>

app/views/layouts/mailer.text.erb
<%= yield %>

メール送信のトリガー

今回は、コメントがされた場合にメールで通知する機能を作りたいので、commentsコントローラーのcreateアクションにメール送信のトリガーを書く。

comments_controller.rb

class CommentsController < ApplicationController
  
  def create
    @comment = current_user.comments.build(comment_params)
    UserMailer.with(user_from: current_user, user_to: @comment.post.user, comment: @comment).comment_post.deliver_later if @comment.save
  end
end

withメソッドに渡されるキーの値は、メイラーアクションでは単なるparamsになる。なので、with(user_form: current_user)params[:user_form] = current_userとなる。

withメソッドはUserMailerオブジェクトを返す??

comment_postメソッドは後で定義

comment_postメソッドActionMailer::MessageDeliveryオブジェクトを1つ返し、このオブジェクトは、そのメール自身が送信対象であることをdeliver_nowやdeliver_laterに伝えます???

メールの送信

mailer

$ rails g mailer UserMailer

app/mailers/user_mailer.rbファイルとapp/views/user_mailer/ディレクトリが作られる

user_mailer.rb

class UserMailer < ApplicationMailer
  def comment_post
    @user_from = params[:user_from]
    @user_to = params[:user_to]
    @comment = params[:comment]
    mail(to: @user_to.email, subject: "#{@user_from.username}があなたの投稿にコメントしました")
  end
end
app/mailers/application_mailer.rb

class ApplicationMailer < ActionMailer::Base
  default from: 'hogehoge@example.com'
  layout 'mailer'
end

default {キー: バリュー}はメイラーから送信するあらゆるメールで使われるデフォルト値のハッシュ。上の例の場合、:fromヘッダーにこのクラスのすべてのメッセージで使う値を1つ設定している。この値はメールごとに上書きすることもでる。

layoutでレイアウトの指定ができる。

mailメソッドは実際のメール・メッセージ。ここでは:toヘッダーと:subjectヘッダーを渡してる。

例
From(送信者): 太郎<Taro@example.com>
To(受信者): ogawa@example.com
Subject(件名): 課題の提出について。

user_mailer.rbcomment_postアクションが実行されると、views/user_mailerディレクトリのcomment_post.html.erbファイルが返される

※ UserMailerで定義したインスタンス変数はdefの壁を超えてviewで使える(コントローラーの場合と同じ)。もちろん、インスタンス変数なんかにparamsを代入せずにviewで直接paramsを使っても良い。ただ、コントローラーの場合と同様、インスタンス変数を使った方がviewのコードが煩雑にならずに済むため、かかる変数を使っている。

view

views/user_mailer/comment_post.html.erb

<h2><%= "#{@user_to.username}さん" %></h2>
<p><%= "#{@user_from.username}さんがあなたの投稿にコメントしました。" %></p>
<%= link_to "確認する", post_url(@comment.post, { anchor: "comment-#{@comment.id}" }) %>

letter_opener_webで確認

letter_opener_webとは

letter_opener_webとは、送信処理があったメールを一覧できるgem(実際に送信されているわけではない)。送信したメールの内容はrails cでも確認できるものの非常に見えにくいことから、かかる不利益を解消するためのgem。

導入

以下をGemfileに書いて$ bundle install

group :development do
  gem 'letter_opener_web'
end

メール確認用画面を、config/routes.rb に追加

routes.rb

if Rails.env.development?
    mount LetterOpenerWeb::Engine, at: '/letter_opener'
end

config/environments/development.rb に設定を追加

config/environments/development.rb

config.action_mailer.default_url_options = Settings.default_url_options.to_h
config.action_mailer.delivery_method = :letter_opener_web

config.action_mailer.default_url_optionsはアプリケーションのホスト情報をメイラー内で使うためのオプション。(参考:Railsガイド

Settings.default_url_optionsは定数。host: 'localhost:3000'を設定してある(configというgemを使用している)。

to_hでハッシュ化。

config.action_mailer.delivery_methodは配信方法を指定している。デフォルトは:smtp。(参考:Railsガイド


http://localhost:3000/letter_openerを開くと、送信処理があったメールを一覧できる。(実際に送信されているわけではない)




    
  

Rails通知機能実装②(既読機能)

注意

自分用のアウトプットとして書いてるので間違ってること平気で書いてたりします。自分の成長と理解に合わせて適宜修正していきます。

要件

既読判定を行えるようにする。通知一覧において、既読のものは薄暗い背景で、未読のものは白い背景で表示すること。

既読とするタイミングは各通知そのものをクリックした時とする。

通知の元となったリソースが削除された際には通知自体も削除する仕様とする。

ヘッダー部分の通知リストには最新の10件しか表示させないこと。

完成イメージ

Image from Gyazo

前提

通知機能が実装されていること

Ruby2.7.1, Rails5.2.6

設計

ER図

activitiesテーブルにreadカラムを追加する

ルーティング設計

PATCH   /activities/:id/read    activities#read

実装

データベース

$ rails g migration AddReadToActivities read: boolean

class AddReadToActivities < ActiveRecord::Migration[5.2]
  def change
    add_column :activities, :read, :boolean, null: false, default: false
  end
end

ルーティング

resources :activities, only: [] do
    patch :read, on: :member
end

Rails基本の7つのアクション以外で、かつオリジナルのメソッドだけがある場合only: []の書き方ができる。

※ memberルーティングが1つだけしかない場合は、ルーティングで:onオプションを指定することでブロックを省略できるため、上のコードは下記と一緒。

resources :activities, only: [] do
   member do
     patch 'read'
   end
end

コントローラー

class ActivitiesController < ApplicationController
  before_action :require_login, only: %i[read]

  def read
    activity = current_user.activities.find(params[:id])
    activity.read! if activity.unread?
    redirect_to activity.redirect_path
  end
end

※ モデルでenumを使っているのでいくつか特殊なメソッドがある。

enumの便利な確認メソッド

インスタンス.定数名?の形でenumカラムであるreadに指定した定数が入っていればtrueが返ってきて、指定した定数が入っていなければfalseが返ってくる。
例えば、ある@activityreadカラムの値がreadなら、@activity.read?がtrue、@activity.unread?がfalseとなる。

インスタンス.定数名!の形で今enumカラム(readカラム)に入ってる定数('read')を別の定数に更新することができる。
例えば、ある@activityreadカラムの値がreadなら、@activity.unread!@activityreadカラムの値がunreadに更新される。

.redirect_pathはActivityインスタンス(activity)に使いたいインスタンスメソッドなので、Activityクラスに定義する。

モデル

class Activity < ApplicationRecord

  include Rails.application.routes.url_helpers

  scope :recent, ->(count) { order(created_at: :desc).limit(count)}

  enum read: { unread: false, read: true }

  def redirect_path
    case self.action_type.to_sym
    when :commented_to_own_post
      post_path(self.subject.post, anchor: "comment-#{self.subject.id}")
    when :liked_to_own_post
      post_path(self.subject.post)
    when :followed_me
      user_path(self.subject.follower)
    end
  end
end

※ なぜこんな書き方をするのか

include Rails.application.routes.url_helpersについて。
hoge_pathのような便利なメソッド(Urlヘルパー)を使いたいが、このメソッドはmodelで定義されていないためController、Helper、Viewでしか使えない。 UrlヘルパーがRails.application.routes.url_helpersによって提供されていることから、Rails.application.routes.url_helpers.users_pathでUrlヘルパーを呼び出せるが、includeすれば〇〇_pathの形で使える。

Rails.application.routes.url_helpers.users_path
#=> "/users"

Rails.application.routes.url_helpers
#=> #<Module:0x00007fd36b0cd2f8>
# Urlヘルパーが定義されているモジュールが返ってくる
Rails.application.routes.url_helpers.class
#=> Module
module Foo
end

puts Foo.class
#=> Module
# Fooは Moduleクラスのオブジェクト

(参考:Railsの不特定ModuleやClass(Modelなど)で_pathを使う

enumについて。
enumとは、1つのカラムに対して文字の配列と定数(integerまたはboolean)を対応させ保存できる様にする為のもの

enumを使うことで、定数が入っているレコードを取り出すのが容易になったり、可読性が上がったりなどのメリット

DBに保存されるのは定数だが、モデルでは文字列で扱える

readカラムでenumを設定している。enumはオブジェクトの状態を管理するのに便利だが、boolean型の場合はenumを設定しなくてもbooleanだけで充分機能は実装できるのでenumを設定するメリット特にないっぽい。
(参考:https://pikawaka.com/rails/enum

scopeメソッドについて

スコープを設定することで、関連オブジェクトやモデルへのメソッド呼び出しとして参照される、よく使用されるクエリを指定することができます。(引用:Railsガイド

つまりモデルのscopeとは複数の検索メソッドをまとめたメソッド。どのスコープメソッドも、常にActiveRecord::Relationオブジェクトを返すから、これを使って検索クエリを組み立てられる。

scopeを使うメリット
・条件式に名前を付けられるので、直感的なコードになる
・メソッドがまとまるためもし間違っても修正箇所をscope内に限定することが出来る
・メソッドをまとめられるためコードを短くできる

order(created_at: :desc).limit()ActiveRecord::Relationを返すメソッドをチェーンしてDBからのデータ取得条件を絞ってる(検索クエリを組み立てている)

redirect_pathはActivityのインスタンスメソッド

case~whenはif文と同じ。しかし、1つのデータに対して複数の値をチェックして処理を分ける場合には、ifではなくcaseを使用した方がコードの可読性が上がる。なぜなら、if文があらゆる条件を指定できるのに対して、caseでは値の一致性という条件しか指定できないため、ifよりcaseの方がどんな条件なのか判断しやすいから。

 x = 5

case x
when 3
  puts "三郎"
when 5
  puts "五郎"
end
#=> 五郎

if x == 3
  puts "三郎"
elsif x == 5
  puts "五郎"
end
#=> 五郎

if文で可読性が下がるパターン

if x.empty?
  puts "名前あり"
else
  puts "名前なし"
end
#=> 名前あり

case x
when 3
  puts "三郎"
when 5
  puts "五郎"
else 
  puts "名前なし"
end
#=> 五郎

(参考:https://pikawaka.com/ruby/case

to_symメソッドは文字列オブジェクトをシンボルに変換するメソッド

"hello".to_sym
#=> :hello

anchorオプション通常のリンクはリンク先ページの先頭に遷移するが、アンカー名(「#」付きの文字列)を指定することで、リンク先ページのさらに指定した箇所へ遷移させることができる。

user_path(@user, anchor: "wall")
#=> "/users/1#wall"

view

既読アクションを実行する前のview(通知の一覧画面)

※ 既読アクション実行後のviewは単にpostとかuserの詳細画面

超簡易バージョン

_liked_to_own_post.html.erb(通知)
_commented_to_own_post.erb(通知)
_followed_me.erb(通知)

<%= link_to read_activity_path(activity), class: "#{'read' if activity.read?}", method: :patch do %>
~
<% end %>

結局は/activities/:id/readpatchリクエスト送るだけ。送ったらactivityインスタンスのaction_typeカラムがunreadからreadに変わりclass: "read"が追加され、CSSが適用されて背景色が変わる。

assets/stylesheets/application.scss
.read {
    background: #f1f1f1;
  }

もう少し具体的に

※ 通知の一覧画面は、「通知一覧のリンクから飛ぶページ」と「ヘッダー」の2つあり、今回は「ヘッダー」で表示する方をかく。

※ 核となる部分以外は省略してる

layouts/application.html.erb
<%= render 'shared/header' %>

shared/_header.html.erb
<ul class="navbar-nav">
 <li class="nav-item">
  <div class="dropdown dropleft">
   <a id="dropdownMenuButton" class="nav-link" href="#" data-mdb-toggle="dropdown"...>
    <%= icon 'far', 'bell', class: 'fa-lg' %>
    <%= render 'shared/unread_badge' %>
   </a>
   <div id="header-activities" class="dropdown-menu"...>
    <%= render 'shared/header_activities' %>
  </div>
 </li>
</ul>
shared/unread_badge
 <% if current_user.activities.unread.count > 0 %>
   <span class="badge badge-warning navbar-badge position-absolute" style='top: 0; right:0;'></span>
     <%= current_user.activities.unread.count %>
 <% end %>



shared/_header_activities.html.erb
 <% if current_user.activities.present? %>
   <% current_user.activities.recent(10).each do |activity| %>
     <%= render "shared/#{activity.action_type}", activity: activity %>
   <% end %>

   <% if current_user.activities.count > 10 %>
     <%= link_to 'すべてみる', mypage_activities_path, class: 'dropdown-item' %>
   <% end %>

 <% else %>
   <div class="dropdown-item">
      お知らせはありません
   </div>
 <% end %>

※ shared/unread_badgeは、current_user.activitiesのunreadの個数を計算し、0以上ならその数字を通知マークの右上に表示するようにしている

※ 通知一覧の表示と違ってインスタンス(@activities)を呼び出してviewで表示してるわけではない(表示される内容は通知一覧の場合と同じだが)

_headerで表示する場合、application.html.erbから表示することになるが、毎回コントローラーに@activitiesを書くのは面倒。なのでいつもみたいにコントローラーで@activitiesに代入したあとviewに飛ばすということをせずに、viewで直接Activityにアクセスする。

※ でもどっちの場合であってもSQLが発行されるのはeach文の実行時。

"shared/#{activity.action_type}"で、表示するhtmlを動的に変化させている。一例として_liked_to_own_post.html.erbをのせた。

shared/_liked_to_own_post.html.erb
<%= link_to read_activity_path(activity), class: "dropdown-item border-bottom #{'read' if activity.read?}", method: :patch do %>
 <object>
  <%= link_to activity.subject.user.username, user_path(activity.subject.user) %>
 </object>
  があなたの
 <object>
  <%= link_to '投稿', post_path(activity.subject.post) %>
 </object>
 にいいねしました
<% end %>

assets/stylesheets/application.scss
@import 'header';

stylesheets/_header.scss
#header-activities {
  width: 400px;
  .dropdown-item {
    max-width: initial;
    font-size: 12px;
  }

  .read {
    background: #f1f1f1;
  }
}

#{'read' if activity.read?}"について
activityインスタンスごとにhtmlのclassを動的に変化させている。activityインスタンスのreadカラムの値が'read'ならclassreadが追加されCSSが適用される。

※ link_toがクリックされるとactivityインスタンスのreadカラムの値が更新されるため('read'から'unread'になる)、背景色が白になる。

link_toがクリックされた後の流れ
/activities/:id/readPATCHリクエストが送られる⇨activityコントローラーのreadアクションが実行されactivityインスタンスのreadカラムが'read'から'unread'になる⇨redirect_toでactivityインスタンスの中身(action_typeカラム)に合わせてそれぞれのページに飛ばされる(例. /posts/5#comment-3または/posts/5または/users/2

※ readアクションで背景色は白になるが、飛ばされるページは背景色が白になったページではないから注意

ポリモーフィック研究

例①

class Hoge
  def say
    puts "太郎"
  end
end

class Fuga
  def say
    puts "次郎"
  end
end

class Boss
  def initialize(name)
    @name = name
  end

  def new_say
    @name.say
  end
end

hoge = Hoge.new
fuga = Fuga.new

Boss.new(hoge).new_say
#=> 太郎
Boss.new(fuga).new_say
#=> 次郎

例②

class Hoge
  def call
    puts "太郎"
  end
end

class Fuga
  def cry
    { aaa: "次郎" }
  end
end

class Boss
  def initialize(name)
    @name = name
  end

  def new_say
    case @name
    when Hoge
      @name.call
    when Fuga
      puts @name.cry[:aaa]
    end
  end
end

hoge = Hoge.new
fuga = Fuga.new

Boss.new(hoge).new_say
#=> 太郎
Boss.new(fuga).new_say
#=> 次郎

例①と例②の同じ点

※ 両方とも全く同じ出力結果

例①と例②の違う点

HogeとFugaでメソッド名が異なっている

HogeとFugaでメソッドの形が異なっている

例①との違いから発生する例②での問題点

※ 問題点1:new_sayメソッドを定義するにあたってBossインスタンスの引数がhogeなのかfugaなのかで条件分岐するコード(case~when)を書くことになりコードが複雑になる(例①では@name.sayを書くだけで済んだ)

※ 問題点2:new_sayメソッドを定義するにあたってFugaクラスのメソッドの形を意識しなければいけない。puts @name.cry[:aaa]と書かなければいけなくなったのはFugaクラスのcryメソッドが{aaa: "次郎"}の形だからです。(例①では@name.sayを書くだけで済んだ)

※ 問題点3:もし新しいクラスを追加する場合new_sayメソッドのcase~whenに新しく追加しなければならなくなる(例①なら@name.sayのままでいい)

ポリモーフィック

ポリモーフィック(Polymorphism)について
ポリモーフィックとは、メソッドを呼び出す際に、オブジェクトごとに振舞ってもらうことで、オブジェクトの実態を気にすることなくメソッドを呼び出せる仕組み

例えば、投稿に対していいねやコメントがされた場合に通知を送る機能を実装する場合を考える。いいねが押されて通知が発生する場面では、Activitieモデルのインスタンスが生成され、その際post_id(いいねされたpostのid)は埋まるがcomment_idnilとなる。そのため、もし@activitieからcommentに接続しようとすると、@activitie.commentはnil、@activitie.comment.bodyはエラーとなる。なのでこの場合、@activitieからメソッドを呼び出す際に、@activitieの中身(引数)がなんなのかいちいち確認するという処理を書かなければならず面倒臭い。(さっきの問題点1で指摘したのと同じようなことが起こる)

# いいねが押されて通知が発生した場合

@activitie = Activitie.new(..., comment_id: nil, post_id: 3, ...)

@activitie.comment
#=> nil
@activitie.comment.body
#=> NoMethodError: undefined method `body' for nil:NilClass

@activitie.post
#=> #<Post:0x00063ad90  id: 3, body: "テスト投稿"...>
@activitie.post.body
#=> "テスト投稿"

そこで、polymorphicを使ってこの不利益を解消する。
モデル同士をpolymorphic関連にしておけば、@activitieの中身(オブジェクトの中身)を知らなくても、求めているデータの呼び出しができる。

@activitie = Activitie.new(..., comment_id: nil, post_id: 3, ...)

@activitie.subject
#=> #<Post:0x00063ad90  id: 3, body: ...>
@activitie.subject.body
#=> "テスト投稿"

 * @activitieの中身を自動で判定してくれて、空のcommentではなくpostを取り出してくれる。

polymorphic: true
これを書くことでsubject_typeというカラム(親のモデル名を記録するカラム)とsubject_idというカラム(親モデルレコードのidを記録するカラム)がDBに自動追加される。ここで子がどの親のオブジェクトを参照すべきかを判断してるっぽい。

訂正

class Activity < ApplicationRecord
  belongs_to :subject, polymorphic: true
end

class Comment < ApplicationRecord
  has_one :activity, as: :subject, dependent: :destroy
end

class Like < ApplicationRecord
  has_one :activity, as: :subject, dependent: :destroy
end

class Relationship < ApplicationRecord
  has_one :activity, as: :subject, dependent: :destroy
end
@activity = Activity.new(post_id: 3, comment_id: nil, ...)

@activity = Activity.new(subject: Like.new(post_id: 3, ...), ...)

Rails通知機能実装①(〜 ポリモーフィック基礎 〜)

注意

自分用のアウトプットとして書いてるので間違ってることを平気で書いてたりします。自分の成長と理解に合わせて適宜修正していきます。

要件

通知機能の実装

タイミングと文言は以下の通りとする。(リンク)と書いてある箇所はリンクを付与する。

フォローされたとき
xxx(リンク)があなたをフォローしました
通知そのものに対してはxxxへのリンクを張る

自分の投稿にいいねがあったとき
xxx(リンク)があなたの投稿(リンク)にいいねしました
通知そのものに対しては投稿へのリンクを張る

自分の投稿にコメントがあったとき xxx(リンク)があなたの投稿(リンク)にコメント(リンク)しました
通知そのものに対してはコメントへのリンクを張る(厳密には投稿ページに遷移し当該コメント部分にページ内ジャンプするイメージ)

不自然ではありますが通知の元となったリソースが削除された際には通知自体も削除する仕様とします。

ポリモーフィック関連を使うこと

完成イメージ

Image from Gyazo

前提

・コメント機能(Comment), いいね機能(Like), フォロー機能(Relationship)が実装されている

・Ruby2.7.1, Rails5.2.6

設計

ER図

目標
通知機能を実装したい

いいねが押された時に「Aさんあなたの投稿いいねしました」、コメントがされた時に「Cさんあなたの投稿コメントしました」、フォローされた時に「Eさんあなたフォローしました」、などの文字を表示したい

いいね情報、コメント情報、フォロー情報を持ったテーブルを用意し、そこからcurrent_userのデータだけを取り出して各ユーザーそれぞれのページで一覧表示する。みたいなことができれば、上記の目標が実現できそう
(メモ)
Aさんの部分:@like.user(Likeのuser_idから取得できる)
あなたの投稿の部分:@like.post(Likeのpost_idから取得できる)
current_userの部分:user_id(別途カラムが必要)
(Aさんはいいねした人、current_userはいいねされた人(通知を見る人) )

そこで、activitiesテーブルを作成し、上記で書いたようなデータを持つカラムとしてlike_id, comment_id, relationship_id, user_idを設ければ良さそう。

もっとも、ポリモーフィック関連付けを使うと、ある1つのモデルが他の複数のモデルに属していることを、1つの関連付けだけで表現できる。

そこで、like_id, comment_id, relationship_idsubjectとして1つのカラムにまとめる。

こうやってポリモーフィック関連付けをすると、activity.subjectの形で、Like, Comment, Relationshipインスタンスが呼び出せるようになる。

action_typeの必要性について
viewsのとこで、いいねが押されたらいいね通知を表示するhtml, コメントが押されたらコメント通知を表示するhtm, フォローが押されたらフォロー通知を表示するhtm と、起こったアクションの種類ごとに表示するhtmlを変えたい(その方が1つのファイルに3つの表示処理を書く場合に比べてコードがスッキリするから)。

そのため、何かしらのカラムを作ってそこに3つのうちどの処理がされたのかをデータとして残しておき、viewsのとこで呼び出せるようにしたい。

そこで、action_typeカラムというものを設置して、そこにActivityインスタンスの状態が、いいね, コメント, フォロー のどの状態なのかを保存しておく。

ルーティング

GET   /mypage/activities    mypage/activities#index

実装

データベース

$ rails g model Activity subject: references user: references action_type: integer

class CreateActivities < ActiveRecord::Migration[5.2]
  def change
    create_table :activities do |t|
      t.references :subject, polymorphic: true
      t.references :user, foreign_key: true
      t.integer :action_type, null: false

      t.timestamps
    end
  end
end

ポリモーフィック(Polymorphism)について
ポリモーフィックとは、メソッドを呼び出す際に、オブジェクトごとに振舞ってもらうことで、オブジェクトの実態を気にすることなくメソッドを呼び出せる仕組み

例えば、投稿に対していいねやコメントがされた場合に通知を送る機能を実装する場合を考える。いいねが押されて通知が発生する場面では、Activityモデルのインスタンス(@activity)が生成され、その際@activityのカラムのlike_id(いいね自身のid)は埋まるがcomment_idnilとなる。そのため、もし@activityからcommentに接続しようとすると、@activity.commentはnil、@activity.comment.bodyはエラーとなる。なのでこの場合、@activityからメソッドを呼び出す際に、@activityの中身がなんなのかいちいち確認するという処理を書かなければならず面倒臭い。

# いいねが押されて通知が発生した場合

@activitiy = Activity.create(..., comment_id: nil, like_id: 3, ...)

@activity.comment
#=> nil
@activity.comment.body
#=> NoMethodError: undefined method `body' for nil:NilClass

@activity.like
#=> #<Like:0x00063ad90  id: 3, user_id: 1, post_id: 5>
@activity.like.post
#=> #<Post:0x00057ad60  id: 5, body: "テスト投稿"...>
@activity.like.post.body
#=> "テスト投稿"

そこで、polymorphicを使ってこの不利益を解消する。
モデル同士をpolymorphic関連にしておけば、@activitiyの中身(オブジェクトの中身)を知らなくても、求めているデータの呼び出しができる。

訂正
#@activity = Activity.create(..., comment_id: nil, like_id: 3, ...)
 @activity = Activity.create(subject: Like.find(3))

@activity.subject
#=> #<Like:0x00063ad90  id: 3, user_id: 1, post_id: 5>
@activitiy.subject.post
#=> #<Post:0x00054ad60  id: 5, body: "テスト投稿", ...>
@activity.subject.post.body
#=> "テスト投稿"

 * @activityの中身を自動で判定してくれて、空のcommentではなくpostを取り出してくれる。

polymorphic: true
これを書くことでsubject_typeというカラム(親のモデル名を記録するカラム)とsubject_idというカラム(親モデルレコードのidを記録するカラム)がDBに自動追加される。ここで子がどの親のオブジェクトを参照すべきかを判断してるっぽい。

(参考)
Railsのポリモーフィック関連とはなんなのか
Railsのポリモーフィック関連 初心者→中級者へのSTEP10/25

ルーティング

namespace :mypage do
    resources :activities, only: %i[index]
end

コントローラー

mypage/activities_controller.rb 

def index
   @activities = current_user.activities.order(created_at: :desc).page(params[:page]).per(10)
end

※ なんでincludes(:user)ない?N+1をチェックする!!!!!!🌟

モデル

Like, Comment, Relationship

class Like < ApplicationRecord
  has_one :activity, as: :subject, dependent: :destroy

  after_create_commit :create_activities

  private
  def create_activities
    Activity.create(subject: self, user: self.post.user, action_type: :liked_to_own_post)
  end
end

has_oneメソッドはメソッドを作るためのメソッド。has_one :activityを書くことで、.activityメソッドが使えるようになり、インスタンス.activityでActivityモデルにアクセスできるようになる。

asオプションポリモーフィック関連のやつ。

※ なんでhas_manyじゃなくてhas_one?
一つのいいねに対応した一つの通知(activity)が取り出せれば十分だから(一つのいいねに複数の通知はつかない)。

user: self.post.userは、いいねされたpostを投稿した投稿者を表す。こうすることで、いいねされたという通知を投稿者に対して送れる。

コールバックについて
コールバックとは、オブジェクトのcreate, update, destroyなどの間(ライフサイクル期間)における特定の瞬間に呼び出されるメソッドのこと。コールバックを利用することで、Active Recordオブジェクトが作成/保存/更新/削除/検証/データベースからの読み込み、などのイベント発生時に常に実行されるメソッドを作れる。

after_create_commitメソッドについて
データベースのトランザクションが完了したときにトリガされるコールバックがafter_commitafter_rollbackの2つ。after_saveと似ているが、after_commitafter_rollbackについては、データベースの変更のコミットまたはロールバックが完了して初めてトリガされる。

例えば、コメントが投稿されたらafter_saveコールバックによって通知がいく機能があるとして、コントローラーのcomment.save!でコメントを保存する場合、もしコメントがバリデーションに引っかかるとエラーが出る。エラーが出てコメントがDBに保存されていないにも関わらず、コールバックは呼び出される。そうすると、コメントは保存されていないのにコメント投稿の通知だけはいくみたいな事態になり良くない。そこで、DBへの保存が確実に完了してから呼びだされるafter_commitコールバックを使い、コメントが確実に投稿された場合にのみ通知が行くようにする。(after_save使ってもif comment.saveとコントローラーで条件分岐すればok?)

after_create_commitは、after_commit :hoge, on: [:create]エイリアス

※ 今回のケースでいうと、いいねが押されLikeインスタンスが生成されると、そのインスタンスをsubjectカラムに持つActivityインスタンスが自動生成される。

(参考)
Railsガイド
「「分かりそう」で「分からない」でも「分かった」気になれるIT用語辞典(ロールバック (rollback))

※ Comment, Relationshipも同様に書く(以下)

class Comment < ApplicationRecord
  has_one :activity, as: :subject, dependent: :destroy

  after_create_commit :create_activities

  private
  def create_activities
    Activity.create(subject: self, user: post.user, action_type: :commented_to_own_post)
  end
end
class Relationship < ApplicationRecord
  has_one :activity, as: :subject, dependent: :destroy

  after_create_commit :create_activities

  private
  def create_activities
    Activity.create(subject: self, user: followed, action_type: :followed_me)
  end
end
class Post < ApplicationRecord
   has_one :activity, as: :subject, dependent: :destroy
end

※ 何でpostにも書く?
post.activityをviewで使いたいから?

class User < ApplicationRecord
   has_many :activities, dependent: :destroy
end

Activity

class Activity < ApplicationRecord

  belongs_to :subject, polymorphic: true
  belongs_to :user

  enum action_type: { commented_to_own_post: 0, liked_to_own_post: 1, followed_me: 2 }
end

enumについて。
enumとは、1つのカラムに対して文字の配列と定数(integerまたはboolean)を対応させ保存できる様にする為のもの

enumを使うことで、定数が入っているレコードを取り出すのが容易になったり、可読性が上がったりなどのメリット。

enumを使用するのは、インスタンスの状態を管理したい場合、例えば、@userに入金前, 入金確認中, 入金完了,の状態を持たせたい場合など。

DBに保存されるのは定数だが、モデルでは文字列で扱える

例.enum status: { 入金前: 0, 入金確認中: 1, 入金完了: 2 }

user1 = User.create(status: "入金前")
user1.status
#=> "入金前"

user2 = User.create(status: "入金完了")
user2.status
#=> "入金完了"

user3 = User.create(status: "入金確認中")
user3.status
#=> "入金確認中"

(参考:https://pikawaka.com/rails/enum

view

通知一覧を表示するためのリンク

views/hoge.html.erb

<%= link_to "通知一覧", "/mypage/activities" %>

通知一覧の表示

<% if @activities.present? %>
  <% @activities.each do |activity| %>
    <%= render "#{activity.action_type}", activity: activity %>
<% else %>
    通知はありません
<% end %>

Activityインスタンスaction_typeカラムに入っている値によって表示されるhtmlを動的に変化させることができる(#{activity.action_type})。
ここで初めてaction_typeカラムをDBに入れた理由(action_typeの必要性)がわかった。

_liked_to_own_post.html.erb

 <%= link_to "#" do %>
    <object>
     <%= link_to activity.subject.user.username, 
 user_path(activity.subject.user) %>
    </object>
    があなたの
    <object>
     <%= link_to '投稿', post_path(activity.subject.post) %>
    </object>
    にいいねしました
   <div class="text-right" >
     <%= l activity.created_at, format: :short %>
   </div>
 <% end %>


_commented_to_own_post.html.erb

  <% # 同様 %>


_followed_me.html.erb

  <% # 同様 %>

※ htmlのobjectタグ必須

lメソッドは、config/application.rbやymlに設定を加えることで日時を2021/10/17 03:41:25で表示できるメソッド。format: :shortオプションで10/17 03:41と短くできる(strftime('%Y/%m/%d %H:%M:%S')メソッド)はDRYではないから非推奨)。
(参考:Railsで日時をフォーマットするときはstrftimeよりも、lメソッドを使おう

補足

それぞれの通知をクリックした時にそのpostやuserの詳細ページに飛ばしたい場合

方法1

_liked_to_own_post.html.erb
 <%= link_to post_path(activity.subject.post) do %>
 <% end %>

_commented_to_own_post.html.erb
 <%= link_to post_path(activity.subject.post, anchor: "comment-#{activity.subject.id}") do %>
 <% end %>

_followed_me.html.erb
 <%= link_to user_path(activity.subject.follower) do %>
 <% end %>

このように書いてもいいが、下記のようにhelperメソッドとしてまとめると綺麗になる

方法2

module HogesHelper

  def transition_path(activity)
    case activity.action_type.to_sym
    when :commented_to_own_post
      post_path(activity.subject.post, anchor: "comment-#{activity.subject.id}")
    when :liked_to_own_post
      post_path(activity.subject.post)
    when :followed_me
      user_path(activity.subject.follower)
    end
  end
end

※ activityインスタンスの中身(action_typeカラムの中身)によって遷移するページを変える。

※ transition_path(activity)はヘルパーメソッドなので、helperに定義する。

link_to "#" do"#"をちゃんとしたリンクに変える

<%= link_to transition_path(activity) do %>
~
<% end %>