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(未接続)、その他(エラー)とか解るのでいい感じ!
こちらでフロント向けのトークン作成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件のコメントがあります。