ActionCable + Reactでチャットをつくってテストをして、と一通りやってみたのでメモった。

ActionCable + Reactでチャットをつくってテストをして、と一通りやってみたのでメモった。

まだ比較的に新しい機能なのでぐぐりんぐが効果を発揮せず、結果として、ちゃんとドキュメントやコードを読むことによる学びや、床板を踏みぬくなどがあったのでまとめます。

ActionCableの機能紹介といったものではなく、こうしたかったからこの機能を使った、使えなかった、こうしたら具合が良かった、という備忘録となっています。

書きながらコードも増えていったので長いです。

どのようなチャット

マーマーチャットを覚えてる人はいますか?というか、まだあったりするんですかね。

ふきだし位置を自由に決められるチャットで、16年ぐらい前、チャットにはまっていた時期に利用していたのを思い出しながら、似た感じのをつくりました。

マーマー.png

たしかJavaアプレットで動いていて、あれってWebSocketじゃないのかなーとフンワリ思い出したのがつながりみたいなものです。

バージョン

  • Rails 5.0.0.rc1

よって今後参考にならなくなる可能性は大いにあります。

ソース

mmmpa/cable_chat

exampleなどではCoffeeScriptを使用していましたが、ES2015で実装しています。 (Railsで完結しようとbrowserify-railsを使用しました)

ActionCable関係のコードの場所

app/channels - Rails側

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を共有するブラウザで複同時にアクセスした場合、同じ人間として扱います。ActionCableidentified_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は単に接続先の設定をし、後の接続を一元管理するためのインスタンスを作成します。

SessionChannelSubscriptionに接続拒否を即座に通知するための腕力

接続拒否に用いられるreject_unauthorized_connectionSubscriptionになにも通知しません。

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されている自分以外のConnectionRemoteConnectionクラスのインスタンスとして得るメソッドと、切断するメソッドがあります。これで一気に切断します。

今回は自分関連の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メソッドに直接作用するので、Connectioncloseメソッドをオーバーライドしても処理を被せられません。

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にデータを整形する、以下のようなクラスを作成しました。

class MemberMessage
  include ActiveModel::Validations

  attr_accessor :user, :message, :x, :y

  validates! :user, :message, :x, :y,
             presence: true
  validates! :message,
             length: {in: 1..140}
  validates! :x, :y,
             numericality: true
# 略
  def render
    {
      name: user.name,
      user_key: user.key,
      key: "#{Time.now.to_i}_#{user.key}",
      message: message,
      x: x,
      y: y
    }
  end
end

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としてある程度動きますが、subscribedunsubscribedといったフックや、Connectionが終わった時のChannelの後片付けのリストから漏れてしまいます。

そうなるといつまでも残ったままになり、よくありません。

そこで、あくまでConsumerから接続要請がありましたよという体で作成します。

send_async :dispatch_websocket_message, {
  command: 'subscribe',
  identifier: {channel: DisconnectionChannel.to_s}.to_json,
}.to_json

これでsubscribedunsubscribedがちゃんと呼ばれるようになりました。

ただ、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)

追記 同コードのプルリクエストがマージされたので、これはもうありません。

ChannelunsubscribedActionCable.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などからは見えてこなかった部分が見えたり、見えない部分をさがしたりして、勉強になるなと思います。

これを書いてる途中にも、これはどうなの?みたいな感じで色々やってみてなるほど〜というのがあったのでよかったです。