🦓

[Rails]turbo_streamでの通知ブロードキャスト

2023/08/30に公開

はじめに

noticedgemとTurbo broadcastを使って、投稿に新規コメントがある場合、投稿のユーザーにリアルタイム通知を送る機能を作っていきます。

https://github.com/excid3/noticed

リアルタイム通知を実現するためには、アクションが実行されたときにそのイベントをBroadcast(ブロードキャスト)する必要があります。

Turboのbroadcastは、Railsのアクション結果やイベントを非同期的にクライアントに送信し、それをTurbo Streamsを使用して画面に反映させるプロセスです。

Broadcastドキュメント

環境

Rails 7.0.7
ruby 3.2.1
Redis

Broadcast

  1. 全てのユーザーに対するBroadcast:ユーザーがコメントを投稿した場合、Turbo Streamsで新しいコメントをページに追加するような処理を行い、全てのユーザーのブラウザに表示されます。
  2. 特定のユーザーに対するBroadcast:コメント、いいねなどのアクションのターゲット(target)になるユーザー(current_user)に通知をBroadcastし、受信したイベントに基づいて、Turbo Streamsがユーザーの画面をリアルタイムに更新し、新規通知を表示します。

今回では2番目のケースになります。

大まかな作業手順:
1. noticedで通知機能を作成する
2. 通知用Turbo Streamを作成する
3. 新規通知をBroadcastするためのメソッドを作成する

通知機能を作成する

noticedgemのドキュメントを参考しながら作っていきます。
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テーブル
db/schema.rb
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

モデルの関連付けを設定する

app/models/user.rb
class User < ApplicationRecord
  has_many :notifications, as: :recipient
end
app/models/notification.rb
class Notification < ApplicationRecord
  include Noticed::Model
  belongs_to :recipient, polymorphic: true
end
app/models/post.rb
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

コメント通知を設定する

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を使います。

app/models/comment.rb
class Comment < ApplicationRecord
...
  after_create_commit :notify_recipient

  private

  def notify_recipient
    CommentNotification.with(message: self).deliver_later(user)
  end
end

通知URLを設定する

config/route.rb
resources :notifications, only: %i[index]

通知コントローラーを設定する

app/controllers/notification_controller.rb
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の通知を新しいものから順に並べ替えるための並べ替えメソッドです。これにより、最新の通知が最初に表示されるようになります。

通知ビューを作成する

通知が新たに追加されたときに、通知一覧に新しい通知が一番上に追加されます。
通知数も更新されます。

通知一覧

app/views/notifications/index.turbo_stream.erb
<%= 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 %>

通知一覧ペーシャル

app/views/notifications/_notifications.html.erb
<%= 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 %>

通知パーシャル

app/views/notificaiotns/_notification.html.erb
<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: 未読の通知を取得するためのメソッドも用意されています。

通知数パーシャル

app/views/notifications_notification_count.html.erb
<%= 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が存在すれば通知機能を効かせます。

app/views/shared/_header.html.erb
<% if current_user %>
  <%= turbo_stream_from "notifications_for_user_#{current_user.id}" %>
<% end %>

Turbo streamが作成されました。

通知用Broadcastメソッドを作成する

app/models/notification.rb
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の更新操作に関連する情報を指定できます。

このメソッドを使用すると、指定したターゲット要素に対して非同期的に新しいコンテンツが追加され、ページ全体をリロードせずに特定の部分を更新することができます。

https://rubydoc.info/github/hotwired/turbo-rails/Turbo/Broadcastable

ログでは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