[Rails]turbo_streamでの通知ブロードキャスト
はじめに
noticed
gemとTurbo broadcastを使って、投稿に新規コメントがある場合、投稿のユーザーにリアルタイム通知を送る機能を作っていきます。
リアルタイム通知を実現するためには、アクションが実行されたときにそのイベントをBroadcast(ブロードキャスト)する必要があります。
Turboのbroadcastは、Railsのアクション結果やイベントを非同期的にクライアントに送信し、それをTurbo Streamsを使用して画面に反映させるプロセスです。
Broadcastドキュメント
環境
Rails 7.0.7
ruby 3.2.1
Redis
Broadcast
- 全てのユーザーに対するBroadcast:ユーザーがコメントを投稿した場合、Turbo Streamsで新しいコメントをページに追加するような処理を行い、全てのユーザーのブラウザに表示されます。
- 特定のユーザーに対するBroadcast:コメント、いいねなどのアクションのターゲット(
target
)になるユーザー(current_user
)に通知をBroadcastし、受信したイベントに基づいて、Turbo Streamsがユーザーの画面をリアルタイムに更新し、新規通知を表示します。
今回では2番目のケースになります。
大まかな作業手順:
1. noticed
で通知機能を作成する
2. 通知用Turbo Streamを作成する
3. 新規通知をBroadcastするためのメソッドを作成する
通知機能を作成する
noticed
gemのドキュメントを参考しながら作っていきます。
Gemfileに入れてbundle install
を実行します。
Notificationモデルを作成する
ポリモーフィック関連付けのNotificationモデルを作成されました。
bin/rails generate noticed:model
generate model
rails generate model Notification recipient:references{polymorphic} type params:json read_at:datetime:index
invoke active_record
create db/migrate/20230829173439_create_notifications.rb
create app/models/notification.rb
insert app/models/notification.rb
insert db/migrate/20230829173439_create_notifications.rb
bin/rails db:migrate
== 20230829173439 CreateNotifications: migrating ==============================
-- create_table(:notifications)
-> 0.0022s
-- add_index(:notifications, :read_at)
-> 0.0005s
== 20230829173439 CreateNotifications: migrated (0.0028s) =====================
Notificationテーブル
create_table "notifications", force: :cascade do |t|
t.string "recipient_type", null: false
t.integer "recipient_id", null: false
t.string "type", null: false
t.json "params"
t.datetime "read_at"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["read_at"], name: "index_notifications_on_read_at"
t.index ["recipient_type", "recipient_id"], name: "index_notifications_on_recipient"
end
モデルの関連付けを設定する
class User < ApplicationRecord
has_many :notifications, as: :recipient
end
class Notification < ApplicationRecord
include Noticed::Model
belongs_to :recipient, polymorphic: true
end
class Post < ApplicationRecord
has_noticed_notifications
has_many :notifications, as: :recipient, dependent: :destroy
end
コメント通知を生成する
bin/rails generate noticed:notification CommentNotification
create app/notifications/comment_notification.rb
コメント通知を設定する
class CommentNotification < Noticed::Base
deliver_by :database
def message
parms[:message]
end
end
ここに通知機能の要件を追加します。
deliver_by :database
: 通知がデータベースを介して配信されることを指定しています。通知はデータベース内に保存され、後でユーザーに表示されます。
message
メソッド: このメソッドは、通知を返すために使用されます。params[:message]
は、通知作成時に渡された message
パラメータを取得しています。ここではコメントになります。
irb(main):057:0> notification.to_notification.params[:message]
=>
#<Comment:0x0000000109d96cc8
id: 2,
user_id: 2,
body: "comment",
created_at: Wed, 30 Aug 2023 09:42:20.890807000 UTC +00:00,
updated_at: Wed, 30 Aug 2023 09:42:20.890807000 UTC +00:00,
commentable_type: "Post",
commentable_id: 2>
コメント通知を送信する
投稿に新規コメントが作成されましたら、投稿のユーザーに通知を送ります。
先に定義したmessage
を使います。
class Comment < ApplicationRecord
...
after_create_commit :notify_recipient
private
def notify_recipient
CommentNotification.with(message: self).deliver_later(user)
end
end
通知URLを設定する
resources :notifications, only: %i[index]
通知コントローラーを設定する
class NotificationsController < ApplicationController
before_action :authenticate_user!
def index
@notifications = current_user.notifications.newest_first
respond_to do |format|
format.turbo_stream
format.html
end
end
end
newest_first
: これはnoticed
の通知を新しいものから順に並べ替えるための並べ替えメソッドです。これにより、最新の通知が最初に表示されるようになります。
通知ビューを作成する
通知が新たに追加されたときに、通知一覧に新しい通知が一番上に追加されます。
通知数も更新されます。
通知一覧
<%= turbo_stream.prepend "notifications_for_user_#{current_user.id}" do %>
<%= render 'notifications/notification', collection: @notifications, as: :notification %>
<% end %>
<%= turbo_stream.replace "notification_count" do %>
<%= partial: 'notifications/notification_count', notifications: @notifications %>
<% end %>
通知一覧ペーシャル
<%= render 'notifications/notification_count', notifications: @notifications %>
<%= turbo_frame_tag "notifications_for_user_#{current_user.id}" do %>
<% if @notifications.present? %>
<% @notifications.each do |notification| %>
<%= render 'notifications/notification', notification: notification %>
<% end %>
<% else %>
<p>通知がありません。</p>
<% end %>
<% end %>
通知パーシャル
<li>
<div>
<p class="text-gray-700">
<%= notification.to_notification.message %>
</p>
<div>
<time>
<%= time_ago_in_words(notification.created_at) %>
</time>
<span>
<%= notification.read? ? "既読" : "未読" %> |
</span>
</div>
</div>
</li>
message
は先にCommentNotification
に定義したものです。
to_notification
メソッドは、通知オブジェクトを生成するためのメソッドです。
user.notifications.read
: 特定のユーザーに関連する既読の通知を取得するためのメソッドです。
user.notifications.unread
: 未読の通知を取得するためのメソッドも用意されています。
通知数パーシャル
<%= turbo_frame_tag 'notification_count' do %>
<% if current_user&.notifications&.unread.present? %>
<span>
<%= current_user.notifications.unread.count %>
</span>
<% end %>
<% end %>
これらのパーシャルをヘッダーに読み込んで、コメントを作成し通知機能を確認します。
コメント通知:
irb(main):003:0> n = Notification.last
Notification Load (0.1ms) SELECT "notifications".* FROM "notifications" ORDER BY "notifications"."id" DESC LIMIT ? [["LIMIT", 1]]
Comment Load (0.1ms) SELECT "comments".* FROM "comments" WHERE "comments"."id" = ? LIMIT ? [["id", 17], ["LIMIT", 1]]
=>
#<Notification:0x0000000108631060
...
irb(main):004:0> n
=>
#<Notification:0x0000000108631060
id: 2,
recipient_type: "User",
recipient_id: 1,
type: "CommentNotification",
params:
{:message=>
#<Comment:0x00000001087f72a0
id: 17,
user_id: 2,
body: "comment",
created_at: Wed, 30 Aug 2023 08:20:31.220676000 UTC +00:00,
updated_at: Wed, 30 Aug 2023 08:20:31.220676000 UTC +00:00,
commentable_type: "Post",
commentable_id: 2>},
read_at: nil,
created_at: Wed, 30 Aug 2023 08:20:31.231018000 UTC +00:00,
updated_at: Wed, 30 Aug 2023 08:20:31.231018000 UTC +00:00>
# 通知オブジェクトにアクセスする
irb(main):007:0> n.to_notification
User Load (0.1ms) SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ? [["id", 1], ["LIMIT", 1]]
=>
#<CommentNotification:0x0000000107bfd328
# コメントオブジェクト
@params=
{:message=>
#<Comment:0x00000001087f72a0
id: 17,
user_id: 2,
body: "comment",
created_at: Wed, 30 Aug 2023 08:20:31.220676000 UTC +00:00,
updated_at: Wed, 30 Aug 2023 08:20:31.220676000 UTC +00:00,
commentable_type: "Post",
commentable_id: 2>},
# 受信するユーザー
@recipient=
#<User id: 1, email: "user@sample.com", created_at: "2023-08-29 13:00:54.170181000 +0000", updated_at: "2023-08-29 13:00:54.170181000 +0000">,
# 通知レコード
@record=
#<Notification:0x0000000108631060
id: 2,
recipient_type: "User",
recipient_id: 1,
type: "CommentNotification",
params:
{:message=>
#<Comment:0x00000001087f72a0
id: 17,
user_id: 2,
body: "comment",
created_at: Wed, 30 Aug 2023 08:20:31.220676000 UTC +00:00,
updated_at: Wed, 30 Aug 2023 08:20:31.220676000 UTC +00:00,
commentable_type: "Post",
commentable_id: 2>},
read_at: nil,
created_at: Wed, 30 Aug 2023 08:20:31.231018000 UTC +00:00,
updated_at: Wed, 30 Aug 2023 08:20:31.231018000 UTC +00:00>>
# コメントレコード
irb(main):011:0> n.to_notification.params
=>
{:message=>
#<Comment:0x00000001087f72a0
id: 17,
user_id: 2,
body: "comment",
created_at: Wed, 30 Aug 2023 08:20:31.220676000 UTC +00:00,
updated_at: Wed, 30 Aug 2023 08:20:31.220676000 UTC +00:00,
commentable_type: "Post",
commentable_id: 2>}
通知機能ができました。Turbo Streamも見ていきます。
redis
を立ち上げます。
通知用Turbo Streamを作成する
current_user
が存在すれば通知機能を効かせます。
<% if current_user %>
<%= turbo_stream_from "notifications_for_user_#{current_user.id}" %>
<% end %>
Turbo streamが作成されました。
通知用Broadcastメソッドを作成する
class Notification < ApplicationRecord
include Noticed::Model
belongs_to :recipient, polymorphic: true
after_create_commit :broadcast_to_recipient
def broadcast_to_recipient
broadcast_prepend_later_to(
recipient,
"notifications_for_user_#{recipient.id}",
target: "notifications_for_user_#{recipient.id}",
partial: 'notifications/notification',
locals: {
notification: self
}
)
end
end
broadcast_prepend_later_to
は、Turbo Streamsの一部として提供されるメソッドの1つで、非同期的にHTMLコンテンツを追加するために使用されます。
broadcast_append_later_to(target, content, **options)
-
target
: 更新する要素を指定します。CSSセレクタ、DOM要素のID、またはDOM要素自体を指定します。 -
content
: 更新するコンテンツを指定します。HTML文字列または生成されたHTML要素を指定します。 -
options
: オプションとして、Turbo Streamsの更新操作に関連する情報を指定できます。
このメソッドを使用すると、指定したターゲット要素に対して非同期的に新しいコンテンツが追加され、ページ全体をリロードせずに特定の部分を更新することができます。
ログではTurbo Streamの処理になっているいることを確認します。
21:34:35 web.1 | Finished "/cable" [WebSocket] for ::1 at 2023-08-30 21:34:35 +0900
21:34:35 web.1 | Turbo::StreamsChannel stopped streaming from notifications_for_user_2
21:34:35 web.1 | Started GET "/cable" for ::1 at 2023-08-30 21:34:35 +0900
21:34:35 web.1 | Started GET "/cable" [WebSocket] for ::1 at 2023-08-30 21:34:35 +0900
21:34:35 web.1 | Successfully upgraded to WebSocket (REQUEST_METHOD: GET, HTTP_CONNECTION: Upgrade, HTTP_UPGRADE: websocket)
21:34:35 web.1 | Turbo::StreamsChannel is transmitting the subscription confirmation
21:34:35 web.1 | Turbo::StreamsChannel is streaming from notifications_for_user_2
# 通知の送信
21:34:12 web.1 | [ActiveJob] [Turbo::Streams::ActionBroadcastJob] [09ba0c3e-5497-495f-bbff-91c34525dfb9] Notification Load (0.1ms) SELECT "notifications".* FROM "notifications" WHERE "notifications"."id" = ? LIMIT ? [["id", 26], ["LIMIT", 1]]
21:34:12 web.1 | [ActiveJob] [Turbo::Streams::ActionBroadcastJob] [09ba0c3e-5497-495f-bbff-91c34525dfb9] Performing Turbo::Streams::ActionBroadcastJob (Job ID: 09ba0c3e-5497-495f-bbff-91c34525dfb9) from Async(default) enqueued at 2023-08-30T12:34:12Z with arguments: "Z2lkOi8vcmFpbHM3LXRvZG8vVXNlci8y:notifications_for_user_2", {:action=>:prepend, :target=>"notifications_for_user_2", :targets=>nil, :partial=>"notifications/notification", :locals=>{:notification=>#<GlobalID:0x0000000108d61dd0 @uri=#<URI::GID gid://test-app/Notification/26>>}}
21:34:12 web.1 | [ActiveJob] [Turbo::Streams::ActionBroadcastJob] [09ba0c3e-5497-495f-bbff-91c34525dfb9] Comment Load (0.1ms) SELECT "comments".* FROM "comments" WHERE "comments"."id" = ? LIMIT ? [["id", 2], ["LIMIT", 1]]
21:34:12 web.1 | [ActiveJob] [Turbo::Streams::ActionBroadcastJob] [09ba0c3e-5497-495f-bbff-91c34525dfb9] ↳ app/views/notifications/_notification.html.erb:4
21:34:12 web.1 | [ActiveJob] [Turbo::Streams::ActionBroadcastJob] [09ba0c3e-5497-495f-bbff-91c34525dfb9] User Load (0.1ms) SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ? [["id", 2], ["LIMIT", 1]]
21:34:12 web.1 | [ActiveJob] [Turbo::Streams::ActionBroadcastJob] [09ba0c3e-5497-495f-bbff-91c34525dfb9] ↳ app/views/notifications/_notification.html.erb:4
21:34:12 web.1 | [ActiveJob] [Turbo::Streams::ActionBroadcastJob] [09ba0c3e-5497-495f-bbff-91c34525dfb9] Rendered notifications/_notification.html.erb (Duration: 2.1ms | Allocations: 1632)
21:34:12 web.1 | [ActiveJob] [Turbo::Streams::ActionBroadcastJob] [09ba0c3e-5497-495f-bbff-91c34525dfb9] [ActionCable] Broadcasting to Z2lkOi8vcmFpbHM3LXRvZG8vVXNlci8y:notifications_for_user_2: "<turbo-stream action=\"prepend\" target=\"notifications_for_user_2\"><template><turbo-frame id=\"notification_26\">\n <div>\n <p class=\"text-gray-700\">\n 新規コメントがありました\n </p>\n <div class=\"flex mt-1 text-gray-500 text-sm space-x-4\">\n <time>\n less than a minute |\n...
21:34:12 web.1 | [ActiveJob] [Turbo::Streams::ActionBroadcastJob] [09ba0c3e-5497-495f-bbff-91c34525dfb9] Performed Turbo::Streams::ActionBroadcastJob (Job ID: 09ba0c3e-5497-495f-bbff-91c34525dfb9) from Async(default) in 19.52ms
終わりに
初めてのturbo_stream
ですが難しかったです。
間違いがあればご指摘いただけますと幸いです。
Discussion