他の方の記事を参考しましたが動かなかったので、公式を読みつつ調べながら書きました。WebSocketの挙動も含め、メモしておきます。
デザインも拘りましたが、MDB使っているので参考までに。

Action Cable の概要 – Railsガイド

完成したページ

ログイン中:みんなへと自分へが表示され、両方へ送信もできる

未ログイン:みんなへのみ表示され、受信したら追加される

自動生成

channelとメッセージを保存するmodelをrails generate自動生成します。

% rails g channel message
Running via Spring preloader in process 93386
      invoke  rspec
      create    spec/channels/message_channel_spec.rb
      create  app/channels/message_channel.rb
   identical  app/javascript/channels/index.js
    conflict  app/javascript/channels/consumer.js
Overwrite /Users/kitabora/workspace/rails-app-origin_message/app/javascript/channels/consumer.js? (enter "h" for help) [Ynaqdhm] y
       force  app/javascript/channels/consumer.js
      create  app/javascript/channels/message_channel.js
% rails g model message channel:string body:text user:references deleted_at:datetime
Running via Spring preloader in process 99718
      invoke  active_record
      create    db/migrate/20220129055301_create_messages.rb
      create    app/models/message.rb
      invoke    rspec
      create      spec/models/message_spec.rb
      invoke      factory_bot
      create        spec/factories/messages.rb

migrateとmodel修正

db/migrate/20220129055301_create_messages.rb

class CreateMessages < ActiveRecord::Migration[6.1]
  def change
    create_table :messages do |t|
-      t.string :channel
+      t.string :channel, null: false, comment: 'チャネル'
-      t.text :body
+      t.text   :body,    null: false, comment: '本文'
-      t.references :user, null: false, foreign_key: true
+      t.references :user, null: false, type: :bigint, foreign_key: false, comment: 'ユーザーID'
-      t.datetime :deleted_at
+      t.datetime :deleted_at, comment: '削除日時'
      t.timestamps
    end
+    add_index :messages, [:created_at, :id], name: 'index_messages1'
+    add_index :messages, :deleted_at,        name: 'index_messages2'
  end
end

userが削除されても残せるようにforeign_keyをfalseに。

config/locales/ja.yml

ja:
  activerecord:
    models:
+      message: 'メッセージ'
    attributes:
+      message:
+        channel: 'チャネル'
+        body: '本文'
+        user: 'ユーザー'
+        deleted_at: '削除日時'
+        created_at: '作成日時'
+        updated_at: '更新日時'
  enums:
+    message:
+      channel:
+        all_channel: 'みんなへ'
+        user_channel: '自分へ'

今回は誰でも購読できるall_channelと自分だけのuser_channelを作ります。

app/models/message.rb

class Message < ApplicationRecord
  belongs_to :user

+  default_scope { where(deleted_at: nil).order(created_at: :desc, id: :desc) }
+
+  def channel_i18n
+    if channel.start_with?('user_channel')
+      I18n.t('enums.message.channel.user_channel')
+    else
+      I18n.t("enums.message.channel.#{channel}")
+    end
+  end
end

user_channelは、後ろにユーザーのcode(idでも良い)を入れて自分だけにしか通知されないようにするので、enumで定義せず、名称を返すメソットを作ります。
default_scopeは好き嫌いあると思いますが、便利なので入れます。

フロント表示

今回はトップページに表示させます。

app/controllers/top_controller.rb

  def index
+    channels = ['all_channel']
+    if user_signed_in?
+      channels += ["user_channel_#{current_user.code}"]
+      cookies.encrypted[:cable] = current_user.code # Tips: Action Cable用
+    end
+    @messages = Message.where(channel: channels).limit(5).reverse
  end

初期表示5件の最新のメッセージを古い順に返却。ここはWebSocketで取得した方が良さそうなので、後日対応。
認証も公式サイトに習ってCookieで渡すのでセット。セッションにはアクセス出来ないとの事。
Cookieで渡すのも微妙(ドメイン違ったら動かなそう。SPAだともっと嫌)なので、後日検討して対応します。

app/views/top/index.html.erb の最後に追加

<div class="row">
  <div class="col">
    <%= render 'message' %>
  </div>
</div>

app/views/top/_message.html.erb を作成

<%= javascript_pack_tag 'message' %>

<div class="card card-body mb-2">
  <h5 class="card-title mb-3">新着メッセージ</h5>
  <div id="messages_area" class="card-text mb-3">
<% if @messages.blank? %>
    <article id="messages_notfound">
      メッセージはありません。
    </article>
<% end %>
<% @messages.each do |message| %>
    <article class="mb-1">
<% if message.channel == 'all_channel' %>
      <span class="badge rounded-pill bg-success"><%= message.channel_i18n %></span>
<% else %>
      <span class="badge rounded-pill bg-dark"><%= message.channel_i18n %></span>
<% end %>
      <%= message.body %> (<%= l(message.created_at) %>)
<% if message.user.present? %>
      <%= image_tag message.user.image_url(:mini), class: 'rounded-circle' %><%= message.user.name %>
<% end %>
    </article>
<% end %>
  </div>
  <div class="card-footer pb-0 px-0">
<% unless user_signed_in? %>
    ※ログインするとメッセージを送信できるようになります。
<% else %>
    <div id="connecting_message">
      <div class="spinner-border" role="status">
        <span class="visually-hidden"></span>
      </div>
      接続中...
    </div>
    <%= form_with(local: true, html: { id: 'message_form', style: 'display: none', onsubmit: 'return false' }) do |form| %>
      <div class="d-flex mt-1">
        <%= form.select :message_channel, t('enums.message.channel').invert, { include_blank: false, selected: 'all_channel' } %>
        <div class="form-outline" style="width: 55%">
          <%= form.text_field :message_body, class: 'form-control' %>
          <%= form.label 'メッセージを入力', class: 'form-label' %>
        </div>
        <%= form.button '送信', type: 'button', id: 'send_message_btn', class: 'btn btn-primary', disabled: true %>
      </div>
    <% end %>
<% end %>
  </div>
</div>

bodyにjavascript_pack_tagを入れちゃいましたが、遷移して戻ると2重・3重に受信されてしまうので、後にheadに移しました。
Turbolinksのイベントでdisconnectしても変な挙動になった気がします。

onbeforeunloadでdisconnectを後に入れていますが、多くのブラウザでは無くても切断されそう。

  // ページ遷移 or 閉じる -> 切断 // Tips: 通常、ブラウザ側でも切断される
  $(window).on('beforeunload', function() {
    console.log('==== onbeforeunload');
    consumer.disconnect();
  });

フロントjs

app/javascript/channels/message_channel.js の中身を削除

- import consumer from "./consumer"
-
- consumer.subscriptions.create("MessageChannel", {
-   connected() {
-     // Called when the subscription is ready for use on the server
-   },
-
-   disconnected() {
-     // Called when the subscription has been terminated by the server
-   },
-
-   received(data) {
-     // Called when there's incoming data on the websocket for this channel
-   }
- });

ここでcreateしてしまうと、WebSocketが不要なページでも接続してしまう為、下記でcreateするようにして、必要なviewからのみ読み込む。

app/javascript/packs/message.js

import consumer from "channels/consumer"

const messageChannel = consumer.subscriptions.create("MessageChannel", {
  initialized() {
    console.log('==== initialized');
  },

  // 接続成功 or 再接続 -> 表示切り替え
  connected() {
    console.log('==== connected');

    $('#connecting_message').css('display', 'none');
    $('#message_form').css('display', 'block');
  },

  // 接続失敗 or 通信断 or 離脱 -> 表示切り替え
  disconnected() {
    console.log('==== disconnected');

    $('#message_form').css('display', 'none');
    $('#connecting_message').css('display', 'block');
  },

  rejected() {
    console.log('==== rejected');
  },

  // メッセージ受信 -> 表示
  received(data) {
    console.log('==== received')
    console.dir(data);

    $('#messages_notfound').remove();
    let html = '<article class="mb-1">';
    if (data['channel'] === 'all_channel') {
      html += '<span class="badge rounded-pill bg-success">' + data['channel_i18n'] + '</span> '
    } else {
      html += '<span class="badge rounded-pill bg-dark">' + data['channel_i18n'] + '</span> '
    }
    html += $('<p />').text(data['body']).html() + ' (' + data['created_at_i18n'] + ') '
          + '<img class="rounded-circle" src="' + data['user']['image_url']['mini'] + '">' + data['user']['name']
          + '</article>';
    $('#messages_area').append(html);
  },

  // メッセージ送信
  sendMessage(data) {
    console.log('==== sendMessage');
    console.dir(data);

    return this.perform('send_message', data);
  }
});

$(function(){
  // メッセージ入力 -> 送信ボタンのdisabled切り替え
  $('#message_body').on('input', function() {
    $('#send_message_btn').prop("disabled", $('#message_body').val() === '');
  });

  // 送信ボタンクリック -> メッセージ送信
  $('#send_message_btn').on('click', function() {
    let channel = $('#message_channel').val();
    let body = $('#message_body').val();
    console.log('==== #send_message_btn.onclick: ' + channel + ', ' + body);

    if (body !== '' && (channel === 'all_channel' || channel === 'user_channel')) {
      $('#send_message_btn').prop("disabled", true);
      $('#message_body').val('');
      messageChannel.sendMessage({ channel: channel, body: body });
    }
  });
});

入力にタグが含まれると、そのまま表示されちゃうので、$('<p />').text(data['body']).html()でエスケープ。

initialized → connected後、通信断が発生した場合はdisconnected → connectedで再接続される。3秒毎(デフォルト)にpingを送って疎通を確認している。

サーバー側の処理

app/channels/application_cable/connection.rb

module ApplicationCable
  class Connection < ActionCable::Connection::Base
+    identified_by :current_user
+
+    def connect
+      logger.debug('==== connect')
+
+      code = cookies.encrypted[:cable]
+      self.current_user = User.find_by(code: code) if code.present?
+      # reject_unauthorized_connection if current_user.blank? # Tips: 未ログインでも使用する為、コメントアウト
+
+      logger.debug("current_user: #{current_user}")
+    end
  end
end

controllerでセットしたCookieからユーザーを探して、current_userにセットしている。
認証不要な場合は、ここは不要。

app/channels/message_channel.rb

class MessageChannel < ApplicationCable::Channel
  def subscribed
-    # stream_from "some_channel"
+    logger.debug('==== subscribed')
+    logger.debug("current_user: #{current_user}")
+
+    stream_from 'all_channel'
+    stream_from "user_channel_#{current_user.code}" if current_user.present?
  end

  def unsubscribed
-    # Any cleanup needed when channel is unsubscribed
+    logger.debug('==== unsubscribed')
+    logger.debug("current_user: #{current_user}")
  end

+  def send_message(data)
+    logger.debug("==== send_message: #{data}")
+    logger.debug("current_user: #{current_user}")
+    return if current_user.blank?
+
+    case data['channel']
+    when 'all_channel'
+      channel = data['channel']
+    when 'user_channel'
+      channel = "#{data['channel']}_#{current_user.code}"
+    else
+      return
+    end
+
+    message = Message.new(channel: channel, body: data['body'], user_id: current_user.id)
+    message.save!
+    data['channel_i18n'] = message.channel_i18n
+    data['user'] = current_user_info
+    data['created_at'] = message.created_at
+    data['created_at_i18n'] = I18n.l(message.created_at)
+    ActionCable.server.broadcast(channel, data)
+  end
+
+  private
+
+  def current_user_info
+    {
+      code: current_user.code,
+      image_url: {
+        mini: "#{Settings['base_image_url']}#{current_user.image_url(:mini)}",
+        small: "#{Settings['base_image_url']}#{current_user.image_url(:small)}",
+        medium: "#{Settings['base_image_url']}#{current_user.image_url(:medium)}",
+        large: "#{Settings['base_image_url']}#{current_user.image_url(:large)}",
+        xlarge: "#{Settings['base_image_url']}#{current_user.image_url(:xlarge)}"
+      },
+      name: current_user.name
+    }
+  end
end

connect → subscribed → unsubscribedの流れ。
subscribedで購読したいチャネルをstream_fromでセット。
unsubscribedで解除する必要はなく、後処理が必要な場合にここに書く。

send_messageは、フロントjsの下記で呼び出している。
定義すれば呼び出せるので、追加の処理が必要な場合に使えそう。
> this.perform('send_message', data);

リクエストヘッダも見てみる

RFC 6455 - The WebSocket Protocol (日本語訳)


今回のコミット内容
https://dev.azure.com/nightonly/rails-app-origin/_git/rails-app-origin/commit/bc74774ceb3a4aa4d6054c063330c02698d6a8e8

Action CableでWebSocketを試す” に対して1件のコメントがあります。

コメントを残す

メールアドレスが公開されることはありません。 * が付いている欄は必須項目です