【Rails】Action Cableで全ユーザーのログイン履歴をWebアプリにリアルタイム表示

個人開発のWebアプリまちかどルートをv5.0正式版にむけての開発中、テスト版であるv5.0rc3へ実装したときのメモです。

プログラミングに入門してちょうど7ヶ月。サーバーとの双方向通信を実現するRails 5.2の新機能Action Cableを、チャットルームのようなタイムライン表示以外にも活用できないかと考え、今回の実装に至りました。

データベースの準備

$ rails g migration AddOnlineToUsers online:boolean  
$ rails g migration AddOnline_atToUsers online_at:datetime  

のあと、たとえば上記1行目のマイグレーションファイルを

class AddOnlineToUsers < ActiveRecord::Migration[5.2]  
  def change  
    add_column :users, :online, :boolean, default: :false  
  end  
end  

というふうに編集してからマイグレーションを実行。

$ rails db:migrate  

以上でデータベースの準備が完了しました。

このonlineカラムには、ログイン/ログアウトつまりAction Cableでいうところのsubscribed(購読)/unsubscribed(購読解除)の状態変化がtrue/falseとして書き込まれます。

ちなみに、データベースのカラムに元々updated_atがあるのに、なぜわざわざonline_atも追加したかというと、それなりの理由があります。

というのもonline以外のカラムが更新されたときupdate_atも当然更新されてしまうわけで、onlineupdated_atをペア条件にしてユーザー情報を抽出したり並べ替えすると不都合が出ました。ゆえに、ログイン/ログアウトの状態変化の日時を保存するカラムとしてonline_atを追加しました。

なおmodelにもひと工夫が必要でした。それについては後述します。

Action Cableの主要ファイル

Action Cableの基本的な初期設定はここでは省き、要点だけメモとして残します。

$ rails g channel appearance  

このようにして、まずはappearanceというAction Cableのchannelを作成します。

class AppearanceChannel < ApplicationCable::Channel  
  def subscribed  
    member = User.where(id: current_user.id).first  
    return unless member  
    member.update_attributes(online: true, online_at: DateTime.now)  
    stream_from "appearance_user"  
  end  

  def unsubscribed  
    member = User.where(id: current_user.id).first  
    return unless member  
    member.update_attributes(online: false, online_at: DateTime.now)  
  end  
end  

channelには、前述したように購読と購読解除の際onlineカラムをtrue/falseで記録するように書きました。と同時にonline_atカラムには日時を記録します。

module ApplicationCable  
  class Connection < ActionCable::Connection::Base  
    identified_by :current_user  

    def connect  
      self.current_user = find_verified_user  
    end  

    private  
      def find_verified_user  
        if verified_user = User.find_by(id: cookies.encrypted[:user_id])  
          verified_user  
        else  
          reject_unauthorized_connection  
        end  
      end  
  end  
end  

ログイン認証済みのユーザーだけに限定するようにしてあります。ここがけっこう苦戦しました。

というのもひとつ前のappearance_channel.rbcurrent_user.idを参照しているのですが、Action Cableでは動作しませんでした。current_userはヘルパーメソッド化してあったのでどこからでも参照できるかと思っていましたが、Action Cableではそうもいかないみたいでした。

そこでいろいろ調べた結果、cookieを橋渡しに使う方法があるということでした。

具体的には

cookies.encrypted[:user_id] = @current_user.id  

というcookie保存用の1行を、セッションが生まれるsessions_controller.rbposts_controller.rbといったcontrollerのファイルに追記しておきます。

このcookieを橋渡しにしてAction Cableからcurrent_user.idが参照可能になる、というわけです。

class AppearanceBroadcastJob < ApplicationJob  
  queue_as :default  

  def perform(user)  
    ActionCable.server.broadcast "appearance_user", render_json(user)  
  end  

  private  
  def render_json(user)  
    ApplicationController.renderer.render(json: user)  
  end  
end  

このファイルの記述についてはお決まりのようなものみたいです。通信するユーザー情報をJSON形式でレンダーするようになっていますね。

view関連ファイル

App.appearance = App.cable.subscriptions.create({  
  channel:'AppearanceChannel'  
 }, {  
  received: function(data) {  
    var user = JSON.parse(data)  

    if (user.online === true){  
      var element = document.getElementById("users-list");  
        element.insertAdjacentHTML("afterbegin", "<li class='user-login'>" + user.display_name + "</li>");  
    };  
    if (user.online === false){  
      var element = document.getElementById("users-list");  
        element.insertAdjacentHTML("afterbegin", "<li class='user-logout'>" + user.display_name + "</li>");  
    };  
  }  
});  

viewに対してリアルタイムにログイン履歴を反映するとき使われるjsファイルです。前述のappearance_broadcast_job.rbによってログイン/ログアウトしたユーザーの情報がJSON形式としてuserに格納されていますので、そのなかのonline情報がtrueならばログイン状態を表すCSS(ここではclass='user-login')、falseならログアウト状態を表すCSSを付けてリアルタイムに下記viewのusers-list部分にわたされます。

cookies.encrypted[:user_id] = @current_user.id  
@online_users = User.where.not(online_at: nil).order(online_at: :desc)  

controllerでは前述のcookieの1行と、viewに使う@online_usersを設定しておきます。@online_usersに格納されるデータには、online_atカラムが記録されているユーザーのみが抽出され、それを日時の新しい順に並べ替えています。

<span id="users-list" class="logbox">  
    <%= render partial: 'online_users', collection: @online_users, as: :member %>  
</span>  
<% if member.online == true %>  
    <li class="user-login">  
       <%= member.display_name %>  
    </li>  
<% elsif member.online == false %>  
    <li class="user-logout">  
       <%= member.display_name %>  
    </li>  
<% end %>  

viewでは部分テンプレートを使っています。appearance.jsと連携してリアルタイムに更新されるようid="users-list"を指定してあるのがポイントです。

modelにひと工夫が必要でした

modelについて、ネットでしばしば見かける定番の記述が

after_create_commit { AppearanceBroadcastJob.perform_later self }  

という1行だけだと思います。

でもそれではonlineカラムが更新されたときだけAction Cableが通信する(Jobに対してキューをわたす)ように設定できません。

そこで下記のようにしました。

class User < ApplicationRecord  
(中略)  
  after_update_commit :watchonline_self  

  def watchonline_self  
    if saved_change_to_online?  
      AppearanceBroadcastJob.perform_later(self)  
    end  
  end  
end  

watchonline_selfというメソッドを作り、saved_change_to_online?によってonlineカラムが更新されたときだけAction CableがJobに対してキューをわたすようにしました。

あとがき

とても駆け足?でまとめました。いろいろ試行錯誤・紆余曲折を経て今回のコードに至ったので、もしかしたら抜けやミスがあるかもしれません。

とりあえずログイン履歴が実現できてよかったです。じぶんの開発したWebアプリで、みんながネトゲさながらにログイン/ログアウトしていく様子を見られるだけで楽しいものですね。

次期Rails 6ではAction Textというのが使えるようになったりするとか。
これからも新しい学びを楽しんでいきたいです。