【Rails6】Action Cableによるコメント機能の実装

Rails6で作成したポートフォリオに**Action Cable**を利用したコメント機能を実装しました。
備忘録のため、実装方法を記載します。

# 開発環境
- MacOS Catalina 10.15.7
- Ruby 2.6.5
- Ruby on Rails 6.0.3.4

# Action Cableとは
そもそもAction Cableとは、[Railsガイド](https://railsguides.jp/action_cable_overview.html)によると

> WebSocketとRailsのその他の部分をシームレスに統合するためのもの

とのこと。

**WebSocket**は通信規格の一種。
Webアプリケーションにおいて、クライアント/サーバ間のデータ通信を実現するための規格。
現時点では、**リアルタイムの双方向通信をイイ感じに実現してくれる規格**とでも覚えておきます。

# 今回実装する要件
既存アプリの概要は、記事を投稿・閲覧するアプリ。
今回は既存アプリの詳細画面上で、コメントを入力・閲覧できる機能を実装します。
コメント閲覧は誰でも可能。
コメント入力はログイン済みのユーザーのみ可能。

## 画面遷移図
赤い「コメント入力機能」の部分を実装していきます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/837961/55374f8e-9894-a955-a4dd-dcbe97754f30.png)

## ER図
既存のテーブルは、ユーザー管理を行う「users」と、投稿内容を記録していく「records」。
今回、コメント機能用に「comments」テーブルを新規作成します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/837961/1caf37b1-2462-0d00-0f18-9912b247579f.png)

# いざ実装
## 1.Commentsテーブルの作成

最初に、Commentsテーブルやモデル周りを実装していきます。
ターミナルを開き**rails g modelコマンド**でモデルやマイグレーションファイルを生成します。

```zsh:アプリのディレクトリに移動して実行
rails g model comment
```

次に、マイグレーションファイルを修正。
コメント投稿機能では、記事の内容と、コメント投稿するユーザーを紐づける必要があったため、既存の外部キーをそれぞれ設定します。

```ruby:db/migrate/yyyymmddhhmmss_create_comments.rb
class CreateComments < ActiveRecord::Migration[6.0]
  def change
    create_table :comments do |t|

      # 追記ここから
      t.references :record,   null: false, foreign_key: true
      t.references :user,     null: false, foreign_key: true
      t.text :comment_text,   null: false 
      # 追記ここまで

      t.timestamps
    end
  end
end
```

そしてmodelファイルを修正。

まずは新しく作成したcommentモデルを修正します。
recordモデルやuserモデルとは1対他の関係となるため、アソシエーションはbelongs_to(commentが属する側)で設定しました。

コメント内容が何も無いときは投稿できないように、バリデーションは**presence: true**を記述します。

```ruby:app/models/comment.rb
class Comment < ApplicationRecord
  # アソシエーション
  belongs_to :record
  belongs_to :user

  # バリデーション
  validates :comment_text,   presence: true
end
```

次に、既存モデル(recordとuser)にもアソシエーションを追記します。

```ruby:app/models/record.rb
class Record < ApplicationRecord
    # アソシエーション
    belongs_to :user
    has_many :comments  # add コメント機能
  
    # (省略)
end
```

```ruby:app/models/user.rb
class User < ApplicationRecord
    # アソシエーション
    has_many :records
    has_many :comments  # add コメント機能
  
    # (省略)
end
```

ターミナルでdb:migrateを実行し、データベースに変更内容を適用します。

```zsh:アプリのディレクトリに移動して実行
rails db:migrate
```

これでデータベースにcommentテーブルが作成されたはず。
念のため、本当にcommentテーブルが作成されたか確認します。

なおデータベースはMySQLを使用しています。
データベース名は仮でhoge_developmentとしてあります。

```zsh:ターミナル
mysql -h localhost -u root  # データベースに接続

# 以下、MySQLに接続した後、hoge_developmentのテーブル一覧を表示
mysql> show tables hoge_development;

+---------------------------------------+
| Tables_in_hoge_development            |
+---------------------------------------+
| # (省略)                               |
| comments                              |
| records                               |
| users                                 |
+---------------------------------------+

# 想定通りの構成になっているか、commentsテーブルのカラムも確認
mysql> show columns from comments from hoge_development;

+--------------+-------------+------+-----+---------+----------------+
| Field        | Type        | Null | Key | Default | Extra          |
+--------------+-------------+------+-----+---------+----------------+
| id           | bigint(20)  | NO   | PRI | NULL    | auto_increment |
| record_id    | bigint(20)  | NO   | MUL | NULL    |                |
| user_id      | bigint(20)  | NO   | MUL | NULL    |                |
| comment_text | text        | NO   |     | NULL    |                |
| created_at   | datetime(6) | NO   |     | NULL    |                |
| updated_at   | datetime(6) | NO   |     | NULL    |                |
+--------------+-------------+------+-----+---------+----------------+

# 確認できたのでMySqlからログアウト
mysql> exit
Bye

```

想定通りにテーブル作成できたことが確認できました。
以上で、コメントを保存するためのテーブル作成は完了です。

## 2.ルーティング

次にルーティングを設定。
コメントは各投稿に紐づく形になるので、投稿機能であるrecordsコントローラにネストさせます。

recordsのshowアクションで表示されたViewに対しコメント入力欄を表示させます。
そのため、commentsにはnewアクションは作成しません。

コメント入力後に送信ボタンが押されたタイミングで、コメント投稿処理を行うようcreateアクションのみ記述します。

```ruby:config/routes.rb
Rails.application.routes.draw do
  devise_for :users
  root to: 'records#index'

  resources :records, only: [ :index, :new, :create, :show, :edit, :update ] do
    resources :comments, only: :create  # add コメント機能追加
  end
end

```

記述後、ルーティングを確認。
これで、投稿記録のrecord_idに紐づくコメントを作成するコントローラーとアクション(comments#create)を設定できました。

```zsh:ターミナル
rails routes

# 以下、実行結果
Prefix          Verb  URI Pattern                  Controller#Action
record_comments POST  /records/:record_id/comments(.:format)  comments#create
# (省略)
```

## 3.コメント表示の作成

コメントの入力・閲覧機能のViewは詳細画面(records#show)に同居させるため、コメント機能用のViewファイルは生成不要です。

コメント欄を部分テンプレート化して作成し、records#showで読み込むことにしました。
records/show.html.erbに、部分テンプレートを読み込むコードを書きます。

```erb:app/views/records/show.html.erb
  <%# コメント欄 %>
  <%= render "shared/comment" %>
```

部分テンプレート化に対応するViewファイルを作成します。
部分テンプレートをまとめている「shared」というディレクトリ内に「_comment.html.erb」というファイルを作成しました。

続いてコメント一覧表示欄と入力フォームを書いていきます。

コメント一覧の表示部分は、登録されたコメントを「コメント【コメント書いたユーザーニックネーム】コメント登録日時」のように表示できるように記述しました。

```erb:app/views/shared/_comment.html.erb
<div>
  <h3>コメント</h3>
  <div id="comments">
    <% if @comments.length != 0 %>
      <% @comments.each do |comment| %>
        <p><%= comment.comment_text %> 【<%= comment.user.nickname %>】<%= comment.created_at %></p>
      <% end %>
    <% else %>
      <p>まだコメントはありません。</p>
    <% end %>
  </div>

<div>
```

投稿内容に紐づくコメント(recordに紐づくcomment)データを取得するため、records#showに追記。

```ruby:app/controllers/records_controller.rb
  def show
    @record = Record.find(params[:id])
    @comments = @record.comments  # add コメント機能のため追記
  end
```

これでコメント一覧を表示できるようになりました。
続けて、コメント入力欄を作成します。

コメント投稿を行う処理は「comments#create」に記述するので、form_withで指定するurlは「record_comments_path(@record.id)」を指定しました。

```erb:app/views/shared/_comment.html.erb
<div>
  <h3>コメント</h3>
  <%# (コメント一覧表示部分は省略) %>

  <%# コメント入力欄 ここから #%>
  <%# ログイン済みならコメント投稿フォームを表示 %>
  <% if user_signed_in? %>
    <%= form_with model: @comment, url: record_comments_path(@record.id), method: :post, local: true  do |f| %>

      <%= f.text_field :comment_text, placeholder:"（ここにコメントを記入してください）" %>
      <%= f.submit "コメントを投稿する" %>

    <% end %>
  <% end %>

  <%# コメント入力欄 ここまで %>

<div>
```

実は上記のform_withの記述に1点誤りがあります。
それは後ほど気づいて直すことになります…

records#showにコメント入力欄に関するコードを書きます。

```ruby:app/controllers/records_controller.rb
  def show
    @record = Record.find(params[:id])
    @comments = @record.comments

    # add コメント入力欄を以下に追加。
    if user_signed_in?
      @comment = Comment.new
    end
  end
```

これで表示部分の基礎は完成。
次からコメント登録処理を作成していきます。

## 4.Commentsコントローラの作成

対応するControlerとアクションを作成していきます。

先ほどrecords#showに対応するViewを作成済みなので、Commentsコントローラーの生成時にhtml.erbやscssファイルが作成されないよう、skipオプションを使いました。

```zsh:ターミナル
rails g controller comments --skip-template-engine --skip-assets

# 以下、作成されたファイル類
Running via Spring preloader in process 6189
      create  app/controllers/comments_controller.rb
      invoke  rspec
      create    spec/requests/comments_request_spec.rb
      invoke  helper
      create    app/helpers/comments_helper.rb
      invoke    rspec
      create      spec/helpers/comments_helper_spec.rb
```

コメント入力フォームの情報だけを受け取れるよう、privateメソッドにストロングパラメータを定義していきます。

```ruby:app/controllers/comments_controller.rb
class CommentsController < ApplicationController

  def create
    @record = Record.find(params[:record_id])
    @comment = @record.comments.build(comment_params)
  end

  private
  def comment_params
    params.require(:comment).permit(:comment_text, :record_id, :user_id).merge(user_id: current_user.id)
  end
end
```

ここまでで、コメント投稿を行う基礎ができました。
次からやっと今回の本題である**Action Cable**の実装に入ります。

## 5.Action Cableの実装

Action Cable用のファイルを**rails g channel**コマンドで生成します。
今回は**comment**というチャネル名で作成しました。

```zsh:ターミナル
rails g channel comment

# 以下、実行結果
Running via Spring preloader in process 10355
      invoke  rspec
      create    spec/channels/comment_channel_spec.rb
      create  app/channels/comment_channel.rb
   identical  app/javascript/channels/index.js
   identical  app/javascript/channels/consumer.js
      create  app/javascript/channels/comment_channel.js
```

サーバからクライアントにデータを渡すために、ストリーム名を記述。

```ruby:app/channels/comment_channel.rb
class CommentChannel < ApplicationCable::Channel
  def subscribed
    stream_from "comment_channel"  # add
  end

  def unsubscribed
  end
end
```

「コメントを投稿する」ボタンを押した後、comments#createで処理が行われます。
commentsテーブルへのInsert処理が成功したとき、ActionCableで処理します。
使用するストリーム名と、受け渡すデータを記述しました。

```ruby:app/controllers/comments_controller.rb
class CommentsController < ApplicationController

  def create
    @record = Record.find(params[:record_id])
    @comment = @record.comments.build(comment_params)

    # 追記　ここから。commentsテーブルへの書き込みが成功したらActionCableを利用
    if @comment.save
      ActionCable.server.broadcast 'comment_channel', content: @comment, user: @comment.user, date: @comment.created_at
    end
    # 追記　ここまで
  end

  private
  # (省略)
end
```

クライアント側にデータが受け渡った後、コメント一覧の表示が更新されるように「received(data)」部分に追記します。

```js:app/javascript/channels/comment_channel.js
import consumer from "./consumer"

consumer.subscriptions.create("CommentChannel", {
  connected() {
  },

  disconnected() {
  },

  received(data) {
    // Called when there's incoming data on the websocket for this channel
    // (=このチャネルのWebSocketに受信データが入ってきた場合に呼び出されます)

    const html = `<p>${data.content.comment_text} 【${data.user.nickname}】 ${data.date}</p>`;
    const comments = document.getElementById('comments');
    const newComment = document.getElementById('comment_comment_text');

    // <div id="comments">欄に追記する。
    comments.insertAdjacentHTML('beforeend', html);
    // コメント入力欄を空にする。
    newComment.value='';
  }
});
```

これでひとまず完成。
あとは動作確認です。

## 6.動作確認

ローカル環境で動作確認したところ、
コメントを投稿するとリロードすることなく、内容が即時にコメント一覧に反映されるようになりました！

しかし一点、問題が発覚…

### 一度ボタンを押して投稿すると、再度ボタンが押せなくなる事象が発生…

リロードすればもちろん再度コメント投稿できるのですが、それでは意味がない。

原因は、**コメント入力欄のform_withの設定**にありました。
form_withのオプションで「local: true」としてしまったことが原因でした。

localオプションは、デフォルトではfalse。
非同期通信を行う設定をformタグに追記します。
falseで非同期通信がONとなる模様。

非同期通信させようとしていたのに、true=非同期通信OFFのような感じになってたのだろうか。
「local: false」に直したところ、リロードしなくても何度でもコメント投稿できるようになりました。

```erb:app/views/shared/_comment.html.erb
  <%# コメント入力欄 ここから #%>
  <% if user_signed_in? %>

    <%# localをtrueからfalseに修正 #%>
    <%= form_with model: @comment, url: record_comments_path(@record.id), method: :post, local: false  do |f| %>

      <%= f.text_field :comment_text, placeholder:"（ここにコメントを記入してください）" %>
      <%= f.submit "コメントを投稿する" %>

    <% end %>
  <% end %>
  <%# コメント入力欄 ここまで %>
```

最終的には、以下の動画のような感じにコメント機能を実装できました。

![4c62408497ab5dc1ca6adf11dc30ed73.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/837961/c0398581-f53d-3456-885b-d13cbdf7a418.gif)

# 最後に
本番環境でAction Cableを運用するには、config/cable.ymlへの設定など、他にも確認することがありました。
AWSやHerokuなど、デプロイする環境によっても設定は変わるようです。

プログラミングの際は、環境による違いにも注意しながらプログラミングする必要性を再認識しました。