ActionCable + Reactでチャットをつくってテストをして、と一通りやってみたのでメモった。
まだ比較的に新しい機能なのでぐぐりんぐが効果を発揮せず、結果として、ちゃんとドキュメントやコードを読むことによる学びや、床板を踏みぬくなどがあったのでまとめます。
ActionCable
の機能紹介といったものではなく、こうしたかったからこの機能を使った、使えなかった、こうしたら具合が良かった、という備忘録となっています。
書きながらコードも増えていったので長いです。
どのようなチャット
マーマーチャットを覚えてる人はいますか?というか、まだあったりするんですかね。
ふきだし位置を自由に決められるチャットで、16年ぐらい前、チャットにはまっていた時期に利用していたのを思い出しながら、似た感じのをつくりました。
たしかJavaアプレットで動いていて、あれってWebSocketじゃないのかなーとフンワリ思い出したのがつながりみたいなものです。
バージョン
- Rails 5.0.0.rc1
よって今後参考にならなくなる可能性は大いにあります。
ソース
exampleなどではCoffeeScriptを使用していましたが、ES2015で実装しています。 (Railsで完結しようとbrowserify-railsを使用しました)
ActionCable関係のコードの場所
app/assets/javascripts/models/chat-cable.js - JavaScript側 app/assets/javascripts/contexts/chat-context.js - Reactの一番上
おおまかな理解
Rails側
Connection
実際につなぐ一本のWebSocketに関する実装をします。接続するしないをキメます。接続ごとにインスタンスがつくられます。
Channel
実際に繋がれた一本のConnection
上で行われるやりとりを実装します。接続ごとにインスタンスがつくられます。
JavaScript側
Consumer
Connection
に対応します。
Subscription
Channel
に対応します。
テストについて
テストはテストで大いに足をひっかけたので、別ページにまとめました。
ActionCableのConnectionとChannelを単体テストする。 - Qiita CapybaraでActionCableの機能テストをする。 - Qiita SimpleCovでカバレッジをとれるはずなのにとれないファイルがある場合の処置。 - Qiita
注意点
現段階では、特にActionCable
を完全体で動かす機能テストにおいては、各テストの開始終了にフックして行われるデータベースのロールバックなどがうまく動かないことがよくあります。
before :each
などを使用して対処する必要があるかもしれません。
テスト中にびっくりする話 (Rails)
Redisをpubsubサーバーとして使っている場合、デフォルトではRails.env.test?
とRails.env.development?
で同じサーバー、同じsubscribe
用の文字列を使っています。
機能テスト中に、同時にdevelopmentをブラウザで開いて操作していると、テスト側のメッセージが届きまくります(逆ももちろん)。
チャット実装について
Connection
の設定 (Rails)
ここだけActionCable
自体の話です。
今回のチャットでは、Cookie
を共有するブラウザで複同時にアクセスした場合、同じ人間として扱います。ActionCable
はidentified_by
を用いて、その特定のためのプロパティを設定できます。
module ApplicationCable
class Connection < ActionCable::Connection::Base
identified_by :current_user
def connect
self.current_user = find_user_or_reject # Userをさがして返す。いなければ切断
end
# 略
end
end
これにより、同一のデータをもとにidentifyされたConnection
は同じinternal_channel
文字列を持つようになります。
Makes it possible for the RemoteConnection to disconnect a specific connection.
という既述がソースコードにあるとおり、internal_channel
は直接参照して何かをするためのものではありませんが、内部に手をつっこみたくなったらこれが使えるかもしれません。
サーバーへの接続成否を通知したい (JS -> Rails -> JS)
connect() {
this.cable = window.ActionCable.createConsumer();
this.cable.subscriptions.create('SessionChannel', this.sessionChannelCallback);
}
JavaScript側からの接続はActionCable.createConsumer
した時ではなく、はじめてthis.cable.subscriptions.create
した時に起こります。
createConsumer
は単に接続先の設定をし、後の接続を一元管理するためのインスタンスを作成します。
SessionChannel
でSubscription
に接続拒否を即座に通知するための腕力
接続拒否に用いられるreject_unauthorized_connection
はSubscription
になにも通知しません。
Subscription
は内部での定期的な接続確認処理でdisconnected
が発生しているのを見て気づくしかありません。これでは初回のセッションが残留してるか否かの確認に、毎回数秒待つことになります。
それはいやなので無理やり通知します。
def transmit_rejection
# SessionChannelのメッセージとして送信
transmit(
identifier: {channel: SessionChannel.to_s}.to_json,
type: ActionCable::INTERNAL[:message_types][:rejection]
)
end
道からはずれた使いかたなので、今後動かなくなる可能性があります。
Consumer
で明示的に切断しないと問い合わせし続ける
JavaScript的には延々とdisconnected
を発生するだけなのですが、その裏では毎回Connection
に問いあわせを続けています。あまりよくありません。
this.consumer.disconnect();
rejected
が発生した時点で、JavaScript側からもSessionChannel
用のSubscription
から明示的に切断しました。
接続成功後
接続成功時にはconnected
が発生するので、それを以って各種準備を開始します。
その他は普通の実装なので、exampleなどと特に変わったポイントはありません。
サーバーから特定のConnection
を切断する。 (Rails)
複数のConnection
がすでに確立されている場合、Connection
のidentifyに使われているcurrent_user
の大本を単純に削除しただけでは接続を断てません。
それどころかユーザーデータが消えて接続だけが残る、気まずい状況になります。
ActionCable.server
にはidentifyに使用されているデータを利用して、それによりidentifyされている自分以外のConnection
をRemoteConnection
クラスのインスタンスとして得るメソッドと、切断するメソッドがあります。これで一気に切断します。
今回は自分関連のConnection
がターゲットなのでcurrent_user
を用いますが、User.find(n)
で得られるような他のユーザーでももちろん使えます。Ban機能などが必要になればこれが使えますね。
# RemoteConnectionsを得る
ActionCable.server.where({current_user: current_user})
# 全て切断する
ActionCable.server.disconnect({current_user: current_user})
ところでこれはConnection
が持つwebsocket
インスタンスのclose
メソッドに直接作用するので、Connection
のclose
メソッドをオーバーライドしても処理を被せられません。
close
後の処理をどうにかするメソッドはありましたが、close
前のものはありませんでした。よって少しのタイムラグがあります。
手を突っこむヒント
ActionCable.server.disconnect
は単なるbroadcast
です。
server.broadcast internal_channel, type: 'disconnect'
実際全部切断する際にどうしたか
同じcurrent_user
でidentifyされているConnection
をひけるUserConnections
というクラスを作成し、そこから全てのConnection
をひとつずつclose
できるようにしました。
切断時の通知もこれで変なハックなしに行えるようになりました。
データの受信と配信 (JS -> Rails -> JS)
チャットということで、各種情報を投げていくことになります。
投げられるのは単純なHash
ですが、実際のアプリケーションでは、Subscription
から届いたデータを丸流しするのはほぼないと思います。
JavaScript側(クライアント側)から送信される不正なデータの抑制はJavaScript側ではできないので、JavaScript側であれこれ脳みそを絞るよりも、サーバー側でちゃんとやる方がよさそうです。
今回、送信配信されるメッセージには140文字の制限にくわえ、座標情報があります。よって文字数と座標をバリデーションしなければなりません。
バリデーションとHash
にデータを整形する、以下のようなクラスを作成しました。
HTMLに関するサニタイズをしていませんが、クライアントで描画行うReact
にその機能があるのでパスしています。もし仮にそのまま描画するようなスクリプトやライブラリを使用しているのなら、もちろん行った方が良いでしょう。
チャットメンバーのデータに関してはさらに単純ですが、同じくクラスを作成しました。
データの組み立ては、Channel
内でやるよりも、外に追いだしたほうが良いのではと思いました。
Reactとの連携 (JS)
これは特に問題にはなりませんでした。
ActionCable
周りを引き受けるクラスを用意し、それにReact
側からコールバックを登録します。今回はChatCable
というクラスを作成し、new
時にコールバックを登録しました。
例えばこのようなコールバックを登録しておきます。
{
messageReceived: (data) => {
this.setState({message: data});
},
memberReceived: (data) => {
this.setState({member: data});
}
}
ChatCable
は各種Subscription
のコールバック内などから適切なコールバックを呼ぶという寸法です。
お互いにお互いのコードがあまりめりこまず、具合が良かったです。
小ネタ
JS側のログを見よう (JS)
ActionCable.startDebugging();
でJavaScriptの細かいログが観察できます。
ES2015 (JS)
subscriptions.create
では各Channel
用のSubscription
を作成しますが、作成時に各種イベントに対してのコールバックを登録します。
this.cable.subscriptions.create('SessionChannel', {
connected: function() {
this.perform('hello'); // Channelのメソッドを叩く
},
disconnected: function() {
this.clear();
},
clear() {
// clear処理
}
});
これは内部で
extend = function(object, properties) {
var key, value;
if (properties != null) {
for (key in properties) {
value = properties[key];
object[key] = value;
}
}
return object;
};
このようなメソッドを使ってSubscription
インスタンスに合成されます。
なので、this
とか見えるしコールバックをクラスのインスタンスとして作成されるようにしたら綺麗に書けるのでは?とやってしまうと、メソッドが合成されず動かないので注意しましょう。
あとthis
の問題から
connected: () => {
this.perform('hello'); // Channelのメソッドを叩く
}
こうしてしまうと動きません、かなしいですね。
内部に手を突っこむ (Rails)
WebSocketなどでやりとりするデータは大体JSONで、JSONの中にさらにJSONがあったりするので、それに気をつけます。
{
string: {
message: 'hello.'
}.to_json
}.to_json
内部Channel
をつくる (Rails)
# だめ
DisconnectionChannel.new(connection, identifier)
Connection
内で単純にこうすると、Channel
としてある程度動きますが、subscribed
やunsubscribed
といったフックや、Connection
が終わった時のChannel
の後片付けのリストから漏れてしまいます。
そうなるといつまでも残ったままになり、よくありません。
そこで、あくまでConsumer
から接続要請がありましたよという体で作成します。
send_async :dispatch_websocket_message, {
command: 'subscribe',
identifier: {channel: DisconnectionChannel.to_s}.to_json,
}.to_json
これでsubscribed
とunsubscribed
がちゃんと呼ばれるようになりました。
ただ、send_async
は非同期にコマンドを実行するために公開されているメソッドですが、dispatch_websocket_message
周りは公開されているものではないので、やめといた方が良さそうです。
傍受してdisconnect
前に何かする
これはUserConnections
を作成する前に、切断する前に何か通知するために使っていた手段です。
退室してActionCable.server.disconnect
を発行する前に退室するよみたいなのを連絡します。
ActionCable.server.broadcast internal_channel, {disconnection: true}
class DisconnectionChannel < ApplicationCable::Channel
def subscribed
stream_from connection.me # internal_channelを返すメソッド
end
def transmit(data, *rest)
connection.tell_closing if !!data['disconnection']
end
end
stream_from
したままだと垂れ流しチャンネルになってしまうので、transmit
で傍受しつつ、抑えておきます。
バグかしら? (Rails)
追記 同コードのプルリクエストがマージされたので、これはもうありません。
Channel
のunsubscribed
でActionCable.server.broadcast
を行っていると、ごくまれに二度とそのstreamではActionCable.server.broadcast
が成功しなくなるという現象があります。
(該当Channel
インスタンス数が1 -> 0 -> 1の時に起こり、再び0になるまでなおりません)
色々p
してたどりついて、とりあえずこのモンキーパッチで現象は起こらなくなりました。問い合わせ中です。
module ActionCable
module SubscriptionAdapter
class SubscriberMap
def broadcast(channel, message)
list = @sync.synchronize do
return if !@subscribers.key?(channel)
@subscribers[channel].dup
end
list.each do |subscriber|
invoke_callback(subscriber, message)
end
end
end
end
end
これはこれです
@subscribers = Hash.new { |h,k| h[k] = [] } # => {}
@subscribers['channel'].dup # => []
@subscribers # => {"channel"=>[]}
おわり
といったように、色々と手を突っ込んだりしてみました。
自分がこうしたほうがいいな、と思ったように実装しようとすると、exampleなどからは見えてこなかった部分が見えたり、見えない部分をさがしたりして、勉強になるなと思います。
これを書いてる途中にも、これはどうなの?みたいな感じで色々やってみてなるほど〜というのがあったのでよかったです。