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件のコメントがあります。