Rails: StimulusReflexとCableReadyでチャット機能を作ってみる

先週の Ruby Weekly で紹介があったYoutube動画『[Build a Twitter clone in 10 minutes with Rails, CableReady, and StimulusReflex](https://www.youtube.com/watch?v=F5hA79vKE_E)』を参考に、Rails・StimulusReflex・CableReady を使って簡単なチャット機能を実装してみました。

出来上がりはこんな感じです↓

![async_reflex_message.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/138789/e3b8d067-9336-e46b-f823-4dd07057b74d.gif)

ソースコードは [GitHub](https://github.com/kei178/stimulus_reflex_cable_ready_demo) をご覧ください。

# 環境

```
ruby -v
ruby 2.6.4p104 (2019-08-28 revision 67798) [x86_64-darwin18]
```

```
rails -v
Rails 6.0.2.2
```

# 用語の整理

## StimulusReflex

[StimulusReflex](https://docs.stimulusreflex.com/) は、Ruby on Rails 用の JavaScript フレームワークで、Single Page App (SPA) ライクにリアクティブな挙動をシンプルに実現することを目指しています。

## CableReady

[CableReady](https://cableready.stimulusreflex.com/) は、ActionCable を使った複数クラアイント間の DOM 更新処理を Ruby で書くためのライブラリです。

# 手順

Rails・StimulusReflex・CableReady を使った簡単なチャット機能の実装手順を整理していきます。

## 1. Railsアプリの作成

まず、Railsアプリを新規作成します。オプションで `--webpack=stimulus` を指定してください。

```
rails new --skip-spring --webpack=stimulus app_name
```

続いて、Redis、CableReady、StimulusReflex をインストールします。

```
bundle add redis cable_ready stimulus_reflex
bundle install
yarn add cable_ready stimulus_reflex
bundle exec rails stimulus_reflex:install 
```

## 2. チャットページの作成

StimulusReflexとCableReadyの実装を除く、単純なチャットページの部分から作成します。

スキャフォールドで `Messeage` モデルを作成します。

```
bundle exec rails g scaffold Message username body like_count:integer --no-jbuilder
```

生成されるマイグレーションファイルを編集して、デフォルト値をセットします。

```ruby
class CreateMessages < ActiveRecord::Migration[6.0]
  def change
    create_table :messages do |t|
      t.string  :username, default: 'Anonymous'
      t.string  :body
      t.integer :like_count, default: 0

      t.timestamps
    end
  end
end
```

マイグレーションを適用させます。

```
rails db:migrate
```

MessagesController のアクションを `index` `create` のみにし、ロジックを次のように修正します。

```ruby

class MessagesController < ApplicationController
  def index
    @messages = Message.all.order(created_at: :desc)
    @message = Message.new
  end

  def create
    Message.create(message_params)
    redirect_to messages_path
  end

  private

  def message_params
    params.require(:message).permit(:username, :body)
  end
end
```

今回は、SemanticUI のCSSスタイルを使うので、レイアウトテンプレートを次のように修正します。

```erb:app/views/layouts/application.html.erb
<!DOCTYPE html>
<html>
  <head>
    <title>StimulusReflexCableReadyDemo</title>
    <%= csrf_meta_tags %>
    <%= csp_meta_tag %>

    <%= stylesheet_link_tag 'application', media: 'all', 'data-turbolinks-track': 'reload' %>
    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/semantic-ui@2.4.2/dist/semantic.min.css">
    <%= javascript_pack_tag 'application', 'data-turbolinks-track': 'reload' %>
  </head>

  <body>
    <div class="ui centered grid">
      <%= yield %>
    </div>
  </body>
</html>
```

`index` のビューテンプレートを調整します。

```erb:app/views/messages/index.html.erb
<div class="fourteen wide ten wide tablet seven wide computer column">
  <div class="ui divider hidden"></div>
  <h1>Messages</h1>

  <%= render 'form' %>

  <div id="messages" class="ui comments">
    <%= render @messages %>
  </div>

  <div class="ui divider"></div>
  <div class="ui divider hidden"></div>
</div>
```
```erb:app/views/messages/_form.html.erb
<%= form_with(model: @message, class: 'ui form') do |form| %>
  <div class="field">
    <%= form.label :username, 'Your Name' %>
    <%= form.text_field :username %>
  </div>

  <div class="field">
    <%= form.label :body %>
    <%= form.text_area :body, rows: 3, placeholder: 'Say something...', id: 'message-form-body' %>
  </div>

  <div class="actions">
    <%= form.submit 'Post', class: 'ui button fluid' %>
  </div>
<% end %>
```

```erb:app/views/messages/_message.html.erb
<div class="ui divider"></div>
<div class="comment">
  <div class="content">
    <div class="author"><%= message.username %></div>
    <div class="metadata">
      <span class="date"><%= time_ago_in_words message.created_at %></span>
    </div>
    <div class="text">
      <%= message.body %>
    </div>
    <div class="actions">
      <%= link_to "Like #{message.like_count}", '#', class: 'reply' %>
    </div>
  </div>
</div>
```

`create` を非同期で呼び出した場合の処理も書きます。

```js:app/views/messages/create.js.erb
document.getElementById('message-form-body').value = ''
```

最後にルーティングを調整すれば、単純なチャットページの部分は完成です。

```ruby:config/routes.rb
Rails.application.routes.draw do
  resources :messages, only: %i[index create]
  root 'messages#index'
  # For details on the DSL available within this file, see http://guides.rubyonrails.org/routing.html
end
```

挙動を確認します。

```
rails s
```

![simple_message.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/138789/27b2d638-6224-d998-3b86-a435272b23cd.gif)


メッセージが新規作成されればOKです。ただし、リロードしない限り、別のブラウザに変更が反映されません。

## 3. CableReadyでフロードキャストさせる

ブラウザ間の変更がリアルタイムで反映されるように、CableReady を使って ActionCable の処理を書いていきます。


`ApplicationCable::Connection` を次のように修正し、 `session_id` で各ブラウザを識別できるようにします。

```ruby:app/channels/application_cable/connection.rb
module ApplicationCable
  class Connection < ActionCable::Connection::Base
    identified_by :session_id

    def connect
      self.session_id = request.session.id
    end
  end
end
```

続いて、Messageチャンネルを作成します。

```
bundle exec rails g channel Message
```

`unsubscribed` は不要なので削除し、`subscribed` で利用する `_channel.js` の名前を指定します。

```ruby:app/channels/message_channel.rb
class MessageChannel < ApplicationCable::Channel
  def subscribed
    stream_from 'message'
  end
end
```

`message_channel.js` でウェブソケットが受け取るデータを CableReady に渡します。

```js:app/javascript/channels/message_channel.js
import CableReady from 'cable_ready'
import consumer from "./consumer"

consumer.subscriptions.create("MessageChannel", {
  received(data) {
      if (data.cableReady) CableReady.perform(data.operations)
  }
});
```

MessagesController に `include CableReady::Broadcaster` を追加し、CableReady を使ってブロードキャストさせます。

```ruby:app/controllers/messages_controller.rb
class MessagesController < ApplicationController
  include CableReady::Broadcaster

  # 略

  def create
    message = Message.create(message_params)
    cable_ready['message'].insert_adjacent_html(
      selector: '#messages',
      position: 'afterbegin',
      html: render_to_string(partial: 'message', locals: { message: message })
    )
    cable_ready.broadcast
  end

  # 略
end
```

* 利用可能な DOM オペレーションの詳細は[公式ドキュメント](https://cableready.stimulusreflex.com/usage/dom-operations)を参照してください。

挙動を確認します。

```
rails s
```

![async_message.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/138789/372e722d-7810-6e67-0f81-ac18f206a8d4.gif)


メッセージが新規投稿された場合、その変更がブラウザ間でリアルタイムに反映されるようになりました。


## 4. StimulusReflex で非同期処理を書く

メッセージに対する Like アクションも、ブラウザ間でリアルタイムに反映されるように調整します。

StimulusReflex を使って MessagesReflex を生成します。

```
bundle exec rails g stimulus_reflex Messages
```

そして `like` アクションを次のように実装します。

```rb:app/reflexes/messages_reflex.rb
# frozen_string_literal: true

class MessagesReflex < ApplicationReflex
  include CableReady::Broadcaster

  def like
    message = Message.find(element.dataset[:id])
    message.increment! :like_count
    cable_ready['message'].text_content(
      selector: "#message-#{message.id}-likes",
      text: message.like_count
    )
    cable_ready.broadcast
  end
end
```

メッセージの Like のアクション部分から MessagesReflex の処理を呼び出すように修正します。

```erb:
<div class="actions">
  <%= tag.div class: 'ui button mini blue label',
              data: { reflex: 'click->MessagesReflex#like',
                      id: message.id,
                      controller: 'stimulus-reflex',
                      action: 'click->stimulus-reflex#__perform' } do %>
    Like <%= tag.span message.like_count, id: "message-#{message.id}-likes" %>
  <% end %>
</div>
```

*（補足） `data-controller` と `data-action` は、通常省略できます（`data-reflex` の値を受けてそれぞれ描画時に自動生成されるため）。今回は、別ブラウザにブロードキャストした同要素にこのデータアトリビュートが追加されなかったので、このように明記することで解決しました。もっと良いやり方があれば教えてください。*


以上で Like の部分もリアルタイムで更新されるようになるはずです。

挙動を確認します。

```
rails s
```

![like_reflex_message.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/138789/5ccfd5d0-be4a-f1fb-3585-54bbcc1c7f5c.gif)

それぞれの変更が更新されればOKです！

# References

- [Build a Twitter clone in 10 minutes with Rails, CableReady, and StimulusReflex](https://www.youtube.com/watch?v=F5hA79vKE_E)
- [StimulusReflex](https://docs.stimulusreflex.com/)
- [CableReady](https://cableready.stimulusreflex.com/)