他の方の記事を参考しましたが動かなかったので、公式を読みつつ調べながら書きました。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件のコメントがあります。