WebSocket(Action Cable)の認証方法を考えて実装する で認証は実装しましたが、結果通知方法がなく、切断されるだけ。
フロントでは、なぜ切断されたか解らず、再接続を繰り返す(無駄な処理が続く)のと、ユーザーに理由を伝えられないので、次のアクションを案内できないという問題がある。
また、切断中に送られてきたメッセージはlostするので、再接続した時に未取得分の取得処理も必要になる。
初期表示もviewに渡していましたが、フロント(Nuxt等)からだとAPIが必要になりますが、WebSocketで取得した方が効率的ですね。

通知方法を考える

1. broadcast以外の通知方法 → ユニキャストとかなさそう。
2. セッション用のチャネルを作って通知 → これしかなさそう。

チャネル名を考える

ユーザー毎でも、ブラウザ毎でもなく、タブ毎に。
何かをキーに実質、1対1のチャネルになるようなチャネル名にする。
この段階では認証通ってないので、token等は使えない。

app/channels/message_channel.rb

  def subscribed
    logger.debug("==== subscribed: #{connection.statistics}")
==== subscribed: {
	:identifier=>"", 
	:started_at=>2022-02-06 09:02:11.474513 +0900, 
	:subscriptions=>["{\"channel\":\"MessageChannel\"}"], 
	:request_id=>"c48ecc10-d96d-4c64-8229-f88106551249"
}

request_idで概ねユニークになりそう。念の為、started_atも使う。こんな感じに。
> session_channel_c48ecc10-d96d-4c64-8229-f88106551249_20220206090211

migrateとmodel修正

便利そうなので、request_idとstarted_atを追加しておく。

migrate/20220205014432_create_ws_tokens.rb

+      t.string :request_id, comment: 'リクエストID'
+      t.datetime :started_at, comment: '開始日時'

config/locales/ja.yml

ja:
  activerecord:
    attributes:
      ws_token:
+        request_id: 'リクエストID'
+        started_at: '開始日時'
% rails db:migrate:reset
% rails db:seed

サーバー側

app/channels/message_channel.rb

class MessageChannel < ApplicationCable::Channel
  # 開始
  def subscribed
    logger.debug("==== subscribed: #{connection.statistics}")
+    request_id = connection.statistics[:request_id]
+    started_at = connection.statistics[:started_at].to_datetime.strftime('%Y%m%d%H%M%S')
+    return connection.close if request_id.blank? || started_at.blank?
+
+    @session_channel = "session_channel_#{request_id}_#{started_at}"
+    stream_from @session_channel
  end

実質1対1の結果を返したり、初回やlostしたメッセージを返してあげるチャネル。

  # 終了
  def unsubscribed
    logger.debug("==== unsubscribed: #{connection.statistics}")
-    if @ws_token.present?
-      @ws_token.last_status = 'unsubscribed'
-      @ws_token.save!
-    end
+    @ws_token = update_ws_token(@ws_token, false, 'unsubscribed')
  end

リファクタ。メソッドで処理を共通化。

  # 認証・再認証
  def auth_request(data)
    logger.debug("==== auth_request: #{data}, #{connection.statistics}")

    error, ws_token = check_token(data['token'])
    if error.present?
-      if ws_token.present?
-        ws_token.last_status = error
-        ws_token.save!
-      end
+      ws_token = update_ws_token(ws_token, false, error)
      connection.close
-      # TODO: 失敗通知
+      ActionCable.server.broadcast(@session_channel, { action: 'auth_result', success: false, alert: I18n.t("alert.ws_token.#{error}"), data: data })
    else
-      ws_token.last_auth_successed_at = Time.current
-      ws_token.auth_successed_at = ws_token.last_auth_successed_at if ws_token.auth_successed_at.blank?
-      ws_token.last_status = 'success'
-      ws_token.save!
+      ws_token = update_ws_token(ws_token, true, 'success')
      add_stream_channel(ws_token.user) if @ws_token.blank?
-      # TODO: 成功通知
+      ActionCable.server.broadcast(@session_channel, { action: 'auth_result', success: true, data: data })
    end

    logger.debug("@ws_token: #{@ws_token.inspect} -> #{ws_token.inspect}")
    @ws_token = ws_token

    error.blank?
  end

リファクタと、失敗・成功通知をセッションチャネルに投げる。

+  # メッセージ取得
+  def get_messages(data)
+    logger.debug("==== get_messages: #{data}")
+    return unless auth_request(data)
+
+    messages_data = []
+    channels = ['all_channel']
+    channels.push(@user_channel) if @user_channel.present?
+    messages = Message.where(channel: channels, deleted_at: nil).limit(Settings['default_messages_limit']).reverse
+    messages.each do |message|
+      messages_data.push(send_message_data(message, message.user))
+    end
+    ActionCable.server.broadcast(@session_channel, { action: 'get_messages', success: true, messages: messages_data })
+  end

フロントからの要求でメッセージをセッションチャネルに投げる。
今回は初回取得想定で、単純に最新の一定件数を返す。

  # メッセージ送信(受信)
  def send_message(data)
    logger.debug("==== send_message: #{data}")
    return unless auth_request(data)

-    channel = check_send_channel(data['channel'], @ws_token.user)
+    channel = get_send_channel(data['channel'], @ws_token.user)
    if channel.blank?
-      # TODO: 失敗通知
+      alert = I18n.t('alert.message.channel.not_subscribed')
+      ActionCable.server.broadcast(@session_channel, { action: 'send_result', success: false, alert: alert, data: data })
      return
    end

    message = Message.new(channel: channel, body: data['body'], user_id: @ws_token.user_id)
    message.save!
-    ActionCable.server.broadcast(channel, send_message_data(message, @ws_token.user)) # TODO: 非同期
+    ActionCable.server.broadcast(channel, { action: 'send_message', success: true, messages: [send_message_data(message, @ws_token.user)] }) # TODO: 非同期
-    # TODO: 成功通知
+    ActionCable.server.broadcast(@session_channel, { action: 'send_result', success: true, data: data })
  end

メッセージ送信でも、失敗・成功通知をセッションチャネルに投げる。
1対多の通知にもactionやsuccessを追加して、フロントでハンドリングし易くする。

  private

+  # トークン情報更新
+  def update_ws_token(ws_token, auth_success, status)
+    return if ws_token.blank?
+
+    if auth_success
+      ws_token.last_auth_successed_at = Time.current
+      ws_token.auth_successed_at = ws_token.last_auth_successed_at if ws_token.auth_successed_at.blank?
+    end
+    ws_token.last_status = status
+    ws_token.request_id = connection.statistics[:request_id]
+    ws_token.started_at = connection.statistics[:started_at]
+    ws_token.save!
+
+    ws_token
+  end

上で共通化で呼び出しているメソッド。

config/locales/ja.yml

ja:
  alert:
+    message:
+      channel:
+        not_subscribed: 'Channel not subscribed.'
+    ws_token:
+      blank: 'Token is empty.'
+      different: 'Token is different.'
+      notfound: 'Token not found.'
+      expired_start: 'Token start has expired.'
+      expired_created: 'Token created has expired.'

config/settings.yml

## メッセージ
# 一覧のデフォルト最大表示件数
default_messages_limit: 5

app/controllers/top_controller.rb

  # GET / トップページ
-  def index
-    channels = ['all_channel']
-    channels += ["user_channel_#{current_user.code}"] if user_signed_in?
-    @messages = Message.where(channel: channels).limit(5).reverse
-  end
+  # def index
+  # end

フロント側

app/javascript/packs/message.js

import consumer from "channels/consumer"
+ const debug = true;

ログを出すかのフラグを入れてみた。
通常、falseで良さそう。環境毎に切り替えられるとGoodかも。

const messageChannel = consumer.subscriptions.create("MessageChannel", {
  // 接続成功 or 再接続 -> 認証・表示切り替え
  connected() {
-    console.log('==== connected');
+    if (debug) { console.log('== connected'); }
-
-    messageChannel.perform('auth_request', { token: $('#message_token').val() });
+    messageChannel.perform('get_messages', { token: $('#message_token').val() });
-    $('#connecting_message').css('display', 'none');
-    $('#message_form').css('display', 'block');
  },

get_messages(auth_requestも含む)に変更。
表示戻すのも結果通知を受け取ってからに変更。

  // 接続失敗 or 通信断 or 離脱 -> 表示切り替え
  disconnected() {
-    console.log('==== disconnected');
+    if (debug) { console.log('== disconnected'); }
-
    $('#message_form').css('display', 'none');
+    $('#auth_error_message').css('display', 'none');
    $('#connecting_message').css('display', 'block');
  },
  // メッセージ受信、認証・送信結果受信 -> 表示
  received(data) {
-    console.log('==== received')
+    if (debug) { 
+      console.log('== received')
      console.dir(data);
+    }
+    if (data['alert'] != null) { console.log(data['alert']); }
+
+    if (data['action'] === 'auth_result') {
+      $('#connecting_message').css('display', 'none');
+      $('#auth_error_message').css('display', (data['success'] === true) ? 'none' : 'block');
+      $('#message_form').css('display', (data['success'] === true) ? 'block' : 'none');
+      return;
+    }
+    if (data['action'] === 'send_result') {
+      if (data['success'] === true) {
+        $('#message_body').val('');
+      } else {
+        alert(data['alert']);
+      }
+      $('#message_channel').prop("disabled", false);
+      $('#message_body').prop("disabled", false);
+      return;
+    }

認証結果(auth_result)と送信結果(send_result)が来たら、結果に応じた表示内容に切り替える。
どのチャネル当てに来たかは取れなそう。メッセージ内のactionで判断。

+    if (!['get_messages', 'send_message'].includes(data['action']) || data['success'] !== true) {
+      console.log('undefined action[' + data['action'] + '] or success[' + data['success'] + ']');
+      return;
+    }

以降でメッセージを表示するので、それ以外や失敗はここで終了。

+    $('#loading_messages').css('display', 'none');
+    if (data['messages'].length === 0) {
+      if (data['action'] === 'get_messages') {
+        $('#messages_notfound').css('display', 'block');
+        $('#messages_area').empty();
+      }
+      return;
+    }

メッセージが0件だったら、ここで終了。

-    $('#messages_notfound').remove();
+    $('#messages_notfound').css('display', 'none');
-    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>';
+    let html = '';
+    for (const message of data['messages']) {
+      const badge = (message['channel'] === 'all_channel') ? 'bg-success' : 'bg-dark';
+      html += '<article class="mb-1">'
+            + '<span class="badge rounded-pill ' + badge + '">' + message['channel_i18n'] + '</span> '
+            + $('<p />').text(message['body']).html() + ' (' + message['created_at_i18n'] + ') '
+            + '<img class="rounded-circle" src="' + message['user']['image_url']['mini'] + '">' + message['user']['name']
+            + '</article>';
+    }
+    if (data['action'] === 'get_messages') { $('#messages_area').empty(); }
    $('#messages_area').append(html);
  }
});

メッセージ毎にHTMLを組み立てて表示。
jQueryだとこうなるよね。Vueだともっとスマートに書けるのでお勧め。

$(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 (debug) { console.log('== #send_message_btn.onclick: ' + channel + ', ' + body); }
-    if (body === '' || !['all_channel', 'user_channel'].includes(channel)) { return }
+    if (!['all_channel', 'user_channel'].includes(channel) || body === '') {
+      console.log('undefined channel[' + channel + '] or empty body[' + body + ']');
+      return;
+    }
+
+    $('#message_channel').prop("disabled", true);
-    $('#message_body').val('');
+    $('#message_body').prop("disabled", true);
    $('#send_message_btn').prop("disabled", true);
    messageChannel.perform('send_message', { channel: channel, body: body, token: $('#message_token').val() });
  });

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

app/views/top/_message.html.erb

<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">
+  <div class="card-text mb-3">
+    <div id="messages_area">
+    </div>
+    <article id="loading_messages">
+      <div class="spinner-border" role="status">
+        <span class="visually-hidden"></span>
+      </div>
+      取得中...
+    </article>
+    <article id="messages_notfound" style="display: none">
      メッセージはありません。
    </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>
+    <div id="auth_error_message" style="display: none">
+      認証に失敗しました。ページを更新してください。
+    </div>
+ <% unless user_signed_in? %>
+    <div id="message_form" style="display: none">
+      ※ログインするとメッセージを送信できるようになります。
+    </div>
+ <% else %>
    <%= 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 mx-1" 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 %>
    <%= hidden_field_tag :message_token, @message_token %>
  </div>
</div>

最後に

% rails db
> SELECT * FROM ws_tokens ORDER BY created_at;
+----------------------------------+---------+---------------------+------------------------+--------------+--------------------------------------+---------------------+----------------------------+----------------------------+
| code                             | user_id | auth_successed_at   | last_auth_successed_at | last_status  | request_id                           | started_at          | created_at                 | updated_at                 |
+----------------------------------+---------+---------------------+------------------------+--------------+--------------------------------------+---------------------+----------------------------+----------------------------+
| 79392411a3302a20d3220762f0ead53c |    NULL | NULL                | NULL                   | NULL         | NULL                                 | NULL                | 2022-02-18 10:01:32.137722 | 2022-02-18 10:01:32.137722 |
| 6264dafa2ce2ad6c98ce5d1eaebe5309 |    NULL | 2022-02-18 10:01:53 | 2022-02-18 10:01:53    | unsubscribed | 3c89ecc4-e48d-4450-9f97-6328827d6069 | 2022-02-18 10:01:53 | 2022-02-18 10:01:53.377769 | 2022-02-18 10:03:57.525786 |
| 84cb3a6b46b112ce0d8534df9fbccda5 |       1 | 2022-02-18 10:03:59 | 2022-02-18 10:03:59    | success      | 3aac754e-5c1c-421f-9b16-5498cd1ab13b | 2022-02-18 10:03:59 | 2022-02-18 10:03:59.219699 | 2022-02-18 10:03:59.389851 |
+----------------------------------+---------+---------------------+------------------------+--------------+--------------------------------------+---------------------+----------------------------+----------------------------+

DB見ると、success(今、繋がってそうな人)、unsubscribed(閉じた人)、NULL(未接続)、その他(エラー)とか解るのでいい感じ!


今回のコミットログ
https://dev.azure.com/nightonly/rails-app-origin/_git/rails-app-origin/commit/cf8afb8ce9a1c80e67a4a072e5487865aadcdd14

こちらでフロント向けのトークン作成APIや、定期的な認証チェック、再接続時に未取得メッセージのみ取得する変更を行っているので参考までに。
https://dev.azure.com/nightonly/rails-app-origin/_git/rails-app-origin/commit/f0ce6afe328a312da0e01796aabd2f0f72873c2a

同様の処理をNuxtアプリでも作ってみました。Action Cableに接続。
https://dev.azure.com/nightonly/nuxt-app-origin/_git/nuxt-app-origin/commit/acd46282fc63edfe7fa4ecbd96810bec667927ec

WebSocket(Action Cable)の認証等の結果通知方法を考えて実装する” に対して1件のコメントがあります。

コメントを残す

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