他の方の記事を参考しましたが動かなかったので、公式を読みつつ調べながら書きました。WebSocketの挙動も含め、メモしておきます。
デザインも拘りましたが、MDB使っているので参考までに。
完成したページ
ログイン中:みんなへと自分へが表示され、両方へ送信もできる
未ログイン:みんなへのみ表示され、受信したら追加される
自動生成
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 (日本語訳)
“Action CableでWebSocketを試す” に対して1件のコメントがあります。