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

個人開発のWebアプリ**「[まちかどルート](https://machiroute.herokuapp.com/)」**をv5.0正式版にむけての開発中、テスト版であるv5.0rc3へ実装したときのメモです。

![v5_0rc3_pc.png](https://qiita-image-store.s3.amazonaws.com/0/335872/239412be-ce43-f77f-0e44-a1dca29feb23.png)

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


# データベースの準備

```
$ rails g migration AddOnlineToUsers online:boolean
$ rails g migration AddOnline_atToUsers online_at:datetime
```
のあと、たとえば上記1行目のマイグレーションファイルを

```ruby
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`も当然更新されてしまうわけで、`online`と`updated_at`をペア条件にしてユーザー情報を抽出したり並べ替えすると不都合が出ました。ゆえに、ログイン/ログアウトの状態変化の日時を保存するカラムとして`online_at`を追加しました。

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


# Action Cableの主要ファイル

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

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

```ruby:/app/channels/appearance_channel.rb
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`カラムには日時を記録します。

```ruby:/app/channels/application_cable/connection.rb
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.rb`で`current_user.id`を参照しているのですが、Action Cableでは動作しませんでした。`current_user`はヘルパーメソッド化してあったのでどこからでも参照できるかと思っていましたが、Action Cableではそうもいかないみたいでした。

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

具体的には

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

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

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

```ruby:/app/jobs/appearance_broadcast_job.rb
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関連ファイル

```ruby:/app/assets/javascripts/channels/appearance.js
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`部分にわたされます。

```ruby:/app/controllers/posts_controller.rb
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`カラムが記録されているユーザーのみが抽出され、それを日時の新しい順に並べ替えています。

```erb:/app/views/posts/index.html.erb
<span id="users-list" class="logbox">
    <%= render partial: 'online_users', collection: @online_users, as: :member %>
</span>
```
```erb:/app/views/posts/_online_users.html.erb
<% 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について、ネットでしばしば見かける定番の記述が

```ruby
after_create_commit { AppearanceBroadcastJob.perform_later self }
```
という1行だけだと思います。

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

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

```ruby:/app/models/user.rb
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というのが使えるようになったりするとか。
これからも新しい学びを楽しんでいきたいです。