はしばみあきら blog

プログラミングアウトプットするブログ。202010スタート

【Rails】ActionCableでチャットアプリを作る

LINEを作る過程でActionCableでかなり悩まされたので、アウトプットかつおさらいということで書いていきます

今回は簡単なチャットアプリケーションを作ろうと思います

初めてActionCableを触る!くらいの人にもなるべく分かるようにしていきたいと思います

ActionCableってなんぞや?

めちゃくちゃ詳しく調べ上げた訳でない上に言葉にするのが難しいですが、ざっくり言うと「ユーザー側とサーバー側が常に通信状態だよ」って感じです

ブラウザのページは基本的にリクエストを送りサーバー側と通信しデーターを取得します

そのリクエストによってページが切り替わり、サーバーのデーターによって表示が変わります

f:id:hashibamiakira:20201111145224p:plain

この状態のままだと更新をかけるまではページは最新のものになりません

ActionCableはほぼ常にサーバーの情報を取得します

サーバーにデータ送り、保存された後そのデータを、ActionCableを通して接続されているユーザーのviewに送り返します

ここで関わってくるのは

  • view (html)
  • javascript (coffeeがデフォルト)
  • ActionCable (railsの機能)
    になります

自分は文字で書くと頭に入らないので図で表してみます

f:id:hashibamiakira:20201111153512p:plain

JSに渡ったデータがActionCableに送られ、保存するなどの処理を行った後にJSにデータを返します
返されたデータはJSによってviewを書き換える処理を行います

この一連の流れを作り上げていきます

では作っていきましょう

環境

開発環境 - Virtualbox - Vagrant

railsのバージョンが5と6では書き方が変わるようです
今回5で行っています

ER図

f:id:hashibamiakira:20201111154144p:plain

今回はとりあえずActionCableの導入とチャットができるようになるところまでは作ります
後々フォロフォロワー機能、チャットルーム機能を作り、特定の相手にチャットが届くようにしていこうと思います

アプリを作っていく

$ rails new chat-app

chat-appという名前で作っていきます

ユーザー認証を作りたいので、gem 'devise' を導入します

gem 'devise'
$ bundle install
$ rails g devise:install
$ rails g devise User name:string
$ rails db:migrate
$ rails g devise:views users
$ rails g devise:controllers users

ルート画面を新規登録画面にします

  devise_for :users, controllers:{
    sessions: 'users/sessions',
    registrations: 'users/registrations'
  }

  devise_scope :user do
    root :to => 'users/registrations#new', as: :unauthenticated_root
  end
<h2>Sign up</h2>

<%= form_for(resource, as: resource_name, url: registration_path(resource_name)) do |f| %>
  <%= render "users/shared/error_messages", resource: resource %>

  <div class="field">
    <%= f.label :name %><br />
    <%= f.text_field :name %>
  </div>

  <div class="field">
    <%= f.label :email %><br />
    <%= f.email_field :email %>
  </div>

  <div class="field">
    <%= f.label :password %>
    <% if @minimum_password_length %>
    <em>(<%= @minimum_password_length %> characters minimum)</em>
    <% end %><br />
    <%= f.password_field :password %>
  </div>

  <div class="field">
    <%= f.label :password_confirmation %><br />
    <%= f.password_field :password_confirmation %>
  </div>

  <div class="actions">
    <%= f.submit "Sign up" %>
  </div>
<% end %>

<%= render "users/shared/links" %>
<h2>Log in</h2>

<%= form_for(resource, as: resource_name, url: session_path(resource_name)) do |f| %>
  <div class="field">
    <%= f.label :email %><br />
    <%= f.email_field :email %>
  </div>

  <div class="field">
    <%= f.label :password %><br />
    <%= f.password_field :password %>
  </div>

  <div class="actions">
    <%= f.submit "Log in" %>
  </div>
<% end %>

<%= render "users/shared/links" %>
  before_action :configure_permitted_parameters, if: :devise_controller?

  private

  def configure_permitted_parameters
    devise_parameter_sanitizer.permit(:sign_up,keys:[:name])
  end

ここまででユーザー認証の機能はできました

次はER図にのっとりモデルとコントローラーを追加します

モデル・コントローラーの作成

実際にチャットを行うルームモデル
メッセージを持つメッセージモデル
どのユーザーがどのルームに登録してあるかを持つエントリーモデル

この3つを作ります

最終的にはLINEのように個人個人とチャットできるようにしますが、一旦全ての人が一つのチャットルームでチャットできるようにします

$ rails g model Room name:string
$ rails g model Entry user:references room:references
$ rails g model Message message:text user:references room:references
$ rails db:migrate

外部キーにはreferencesを使用しています

ER図に沿ってモデル同士の関連付けをしていきます

  has_many :messages
  has_many :entries
  has_many :rooms, through: :entries
  has_many :messages
  has_many :entries
  has_many :users, through: :entries
  belongs_to :user
  belongs_to :room
  belongs_to :user
  belongs_to :room

throughを使うことで、中間モデルを介してその先のモデルにアクセスできます
コントローラーに関しては、ルームのindexとshow、ユーザのshowを作ります

$ rails g controller Room index show
$ rails g controller User show

ルームとユーザーのコントローラーができたのでルーティングを設定します

  resources :rooms, only: [:index, :show]
  resources :users, only: [:show]

viewも作ってくれるので、サインアップ後、ログイン後はルームのindexに飛ぶようにします

  def after_sign_in_path_for(resource)
    rooms_path
  end

  def after_sign_up_path_for(resource)
    rooms_path
  end

  def after_sign_out_path_for(resource)
    unauthenticated_root_path
  end

サインアップ後にルームインデックスへ遷移したでしょうか

次はviewを書き換えていきます

viewを作成していく

room.indexのviewを作成していきます

ログアウトボタンを作ります
また、自身の名前を表示しておきます
とりあえずチャットルームの概念は置いておいて、このページでチャットができるようにしていきます

<% if user_signed_in? %>
  <%= link_to "ログアウト", destroy_user_session_path, method: :delete %>
  <p>
    YourName: <%= current_user.name %>
  </p>
<% end %>

<h2>Chat Room</h2>

<div class="message">
  <%= render partial: 'rooms/messages', locals: { messages: @messages} %>
</div>

メッセージ一覧を表示させる部分は、部分テンプレートを使います
そのため、views/_messages.html.erbを作ります
@meesagesには今までのメッセージを全て持たせます

<div class="message">
  <% messages.each do |message| %>
    <p><%= message.message %></p>
  <% end %>
</div>
  def index
    @messages = Message.all
  end

これでメッセージを表示させれるようになりました

次に投稿フォームを作ります

通常、フォームと言えばform_forやform_withが常ですが、ここではhtmlのinput要素を使って作ります

...追記

<div id="add-message"></div>

<!-- 投稿フォーム -->
<input type="text" data-behavior="room_speaker">

input部分の data-behavior は、厳密には違うようですが、クラス名やID名のようなものだと考えてください

ここまででユーザー登録、メッセージの表示と投稿までをできるようになりました
ただ、今のままだとinputはただ文字を書けるだけの箱にすぎません
保存などはActionCableを使用して行います

ではいよいよActionCableを導入します

ActionCableを導入

まず最初にchannelを作成します

$ rails g channel room speak

以下のように、channelとchannelのcoffeeScriptが作成されます

[vagrant@localhost chat-app]$ rails g channel room speak
Running via Spring preloader in process 7636
      create  app/channels/room_channel.rb
   identical  app/assets/javascripts/cable.js
      create  app/assets/javascripts/channels/room.coffee

データはJSを通ってchannelに送られます

channelは送られてきたデータを処理するコントローラやモデルのようなものです

まずcoffeeの方から作り上げていきます

jQueryの方が書きやすいかと思うので、jQueryの書き方を使います

そのため、railsjQueryを使えるようにgemを導入します

gem 'jquery-rails'
$ bundle install
//= require rails-ujs
//= require activestorage
//= require jquery
//= require_tree .

turbolinksのコメントアウトがありますが、色々と不便なこともあるので消しました

下準備はこれで完了です

assets/javascripts/channels/room.coffeeに記述します

App.room = App.cable.subscriptions.create "RoomChannel",
  connected: ->
    # 通信が確立された時の処理

  disconnected: ->
    # 通信が切断された時の処理

  received: (data) ->
    # データが送信されてきた時の処理

  speak: (message) ->
    # channelのspeakアクションにmessageパラメータを渡す
    @perform 'speak', message: message

# チャットを送る
$(document).on 'keypress', '[data-behavior~=room_speaker]', (event) ->
  # return(Enter)が押された時
  if event.keyCode is 13
    #channel speakへ、event.target.valueを引数に
    App.room.speak event.target.value
    # inputの中身を空に
    event.target.value = ''
    alert()
    event.preventDefault()

この時点で、inputにフォーカスしてエンターを押してみましょう

f:id:hashibamiakira:20201112115709p:plain

このようにアラートが表示されれば、javascriptがしっかりと動いています

ではchannelの方にいきましょう

class RoomChannel < ApplicationCable::Channel
  def subscribed
    # 接続
    stream_from "room_channel"
  end

  def unsubscribed
    # 切断
  end

  # coffeeから送られて来たmessage(event.target.value)を受け取る
  # inputタグの文字がメッセージカラムに保存される
  def speak(message)
    Message.create(message: message['message'])
  end
end

coffeeからspeakにデータが送られます
そのデータを基にmessageを保存します

しかしメッセージにはFKがあります

f:id:hashibamiakira:20201112120740p:plain

今はroom.indexで全て表示するのでroom_idはいいとしても、user_idは保存したいところです

ここで、htmlのinputにdataとしてユーザーidを持たせ、js側に渡し、パラメータとしてchannelまで通します

実際に作ってみましょう

<!-- 投稿フォーム -->
<input type="text" data-behavior="room_speaker" data-user=<%= current_user.id %>>

data-〇〇は値をjs側に送ることができます
この場合だと、inputのdata['user']とすることで、自分のユーザーidを使うことができます

次にcoffeeです

App.room = App.cable.subscriptions.create "RoomChannel",
  connected: ->
    # 通信が確立された時の処理

  disconnected: ->
    # 通信が切断された時の処理

  received: (data) ->
    # データが送信されてきた時の処理

  speak: (message, user) ->
    # channelのspeakアクションにmessageパラメータを渡す
    @perform 'speak', {message: message, user: user}

# チャットを送る
$(document).on 'keypress', '[data-behavior~=room_speaker]', (event) ->
  # return(Enter)が押された時
  if event.keyCode is 13
    #channel speakへ、event.target.valueを引数に
    App.room.speak event.target.value, $('[data-user]').attr('data-user')
    # inputの中身を空に
    event.target.value = ''
    event.preventDefault()

エンターキーを押された時に、speakアクションの引数を文字とユーザーidの2つを指定します

speak: の後に、messageに文字、userにユーザーidを渡すように指定します

ではchannelにいきます

  # coffeeから送られて来たmessage(event.target.value)を受け取る
  # inputタグの文字がメッセージカラムに保存される
  def speak(message)
    Message.create(message: message['message'], user_id: message['user'].to_i)
    # room_channelに紐づくjsにメッセージの内容を配信する
    ActionCable.server.broadcast 'room_channel', message: message['message']
  end

実際にターミナルで値がどのようになっているか見てみましょう

f:id:hashibamiakira:20201112123500p:plain

(message)に、{"message"=>"こんにちわ", "user"=>"1", "action"=>"speak"}と、これだけの情報が入っています
メッセージは文字列なのでそのままで大丈夫です
ユーザーidは、integerにしたいので、渡って来たデータのままだとstring状態です
.to_iメソッドで数字に変換してから保存するようにします

これでメッセージを保存するところまでできました
ActionCable.server.broadcastで、inputの文字列をjsに返します

jobと言う機能を使うと、DBに保存したデータを配信できるのですが、それは次回にします
ここでは一旦送られてきた生のデータを配信します

ではcoffee側にいきます
received: と言う値が送られて来た時の処理を記述します

  received: (data) ->
    # データが送信されてきた時の処理
    $('#add-message').append("<p>" + data["message"] + "</p>");

(data)には["message"]として、inputに入力された文字列が入っています
その文字列をpタグで囲んだ要素として、div id="add-message" に追加します

それでは、フォームに文字を入力してエンターを押してみましょう

このように内容が即座に追加されると思います

ここで問題発生

room_idを入力していないのでDBに保存されません、そのため一旦room_idがnilでも保存できるようにします

class Message < ApplicationRecord
  belongs_to :user
  belongs_to :room, optional: true
end

optional: trueは、紐づくデータがnilでも保存できるようにするよ、と言うメソッドです
railsの仕様で、FKがない場合は保存ができなくなっていたようです

これで簡単ではありますがチャット機能ができました

チャット機能の完成

ブラウザを2つ立ち上げて、実際チャットが即座に反映されるか確かめてみます

実際にデプロイ後にちゃんと動くかどうかはこれからですが、ActionCableはこんな感じで動きます!

もしかしたら抜け等あるかもしれません...ご了承をば...

次回は全体ではなく、特定のユーザーやグループでのチャット機能を作っていきます

今回参考にしたサイト・記事等

WebSocketはなぜ生まれたのか?Action Cableを使いたい
ActionCableを使って簡易チャット機能を作ろう
ActionCableを用いてリアルタイムチャットの実装
Rails 5 + ActionCableで作る!シンプルなチャットアプリ(DHH氏のデモ動画より)
【Rails6.0】ActionCableとDeviseの欲張りセットでリアルタイムチャット作成(改正版)
[Rails]ActionCableを使用してリアルタイムチャットの実装

ありがとうございます!