49
39

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

Hotwireでリアルタイムなチャットを作る

Last updated at Posted at 2020-12-25

はじめに

12/23にRuby on Railsの生みの親:DHH氏がHotwireという新しいgemについて言及され、それが話題になっているようだったので試してみた記事です。

本記事ではチュートリアルとして進めつつHotwireのデモと同じアプリを作っていきます。

Hotwire デモ

また、本記事ではHotwireを試してみて気づいたことなども紹介しつつ、Hotwireの魅力を伝えられればと思います。

完成品

hotwire-sample.gif

ソースコードはこちらから

環境

以下は、僕が実際に作ったときの環境です

OS: Windows10(WSL2 + Ubuntu18.04)
Ruby: ruby 2.7.1p83 (2020-03-31 revision a0c7c23c9c) [x86_64-linux]
Rails: Rails 6.1.0
Bundler: Bundler version 2.1.4

またHotwireWebsocketを使っているのでRedisも使用しています。

チュートリアル

rails new

何はともあれ、まずはrails newからはじめましょう。

rails new hotwire-sample --skip-webpack-install

--skip-webpack-installを追加しているのは、HotwireWebpackerを使わないからですね。
それと、Webpackerがインストールされているとturbo_frame_tag周りの挙動がおかしくなるので、それを回避するためでもあります(原因とかは調べてますが、まだわからない……)

ちなみに、僕は動画の通り進めていたつもりがturbo_frame_tag周りの挙動でかなり頭を悩ますことになりました……。

rails newが終了後、cd hotwire-sampleを実行してディレクトリを移動します。

cd hotwire-sample

Webpacker関連のファイルの削除

--skip-webpack-installをつけてrails newしたんですが、Webpacker周りのファイル(Gemfileの記述とか)が残っているのでそれを削除していきます。

まずはapp/javascriptsディレクトリを削除します。削除方法はrmコマンドでも、マウスで削除でも構いません。

次に、Gemfileに残っている以下のWebpackerの部分を削除します。

# Transpile app-like JavaScript. Read more: https://github.com/rails/webpacker	
gem 'webpacker', '~> 5.0'

削除後、bundle installを実行するのを忘れないように気をつけてください。

bundle install

最後に、WebpackerでビルドしたJavaScriptを読み込んでいるタグを削除します。
場所はapp/views/layouts/application.html.erb内の<head>タグ内にありますね。

<%= javascript_pack_tag 'application', 'data-turbolinks-track': 'reload' %>

ここまでの段階でWebpacker周りの設定はきれいに削除できました!

Hotwireのインストール

ようやくHotwireのインストールです。

Gemfileに以下のコードを追加します。

gem 'hotwire-rails'

次に、bundle installを実行します。

bundle install

あとは、Hotwireをインストールしてくれるhotwire:installを実行すればOKです!

rails hotwire:install

これでHotwireのインストールは完了です!

roomとmessageを作成

デモではroommessageを使ってチャットルームを作っていましたので、同じようにそれぞれ作っていきます。

rails g scaffold room name:string
rails g model message room:references content:text

rails g scaffold room name:stringroomを作成し、rails g model message room:references content:textmessageを作成してます。

次にrails db:migrateでマイグレーションを実行します。

rails db:migrate

マイグレーション後、ルーティングを修正します。

config/routes.rb
Rails.application.routes.draw do
-  resources :rooms
+  resources :rooms
+    resources :messages
+  end
  # For details on the DSL available within this file, see https://guides.rubyonrails.org/routing.html
end

次に、app/models/room.rbmessageモデルへのリレーションを追加します。

app/modesl/room.rb
class Room < ApplicationRecord
+    has_many :messages
end

まだmessage用のコントローラーを作っていないので、それも作ります。
app/controllers/messages_controller.rbを以下のように作成します。

class MessagesController < ApplicationController
  before_action :set_room, only: %i[ new create ]

  def new
    @message = @room.messages.new
  end

  def create
    @message = @room.messages.create!(message_params)

    redirect_to @room
  end

  private
    def set_room
      @room = Room.find(params[:room_id])
    end

    def message_params
      params.require(:message).permit(:content)
    end
end

コントローラーはこれで出来たので、次はmessage用のパーシャルとビューを作成します!
app/views/messages/new.html.erbapp/views/messages/_message.html.erbをそれぞれ以下のように作ります!

app/views/messages/new.html.erb
<h1>New Message</h1>

<%= form_with(model: [ @message.room, @message ]) do |form| %>
    <div class="field">
        <%= form.text_field :content %>
        <%= form.submit "Send" %>
    </div>
<% end %>

<%= link_to 'Back', @message.room %>
app/views/messages/_message.html.erb
<p id="<%= dom_id message %>">
  <%= message.created_at.to_s(:short) %>: <%= message.content %>
</p> 

最後に、app/views/rooms/show.html.erbに以下のようにmessageを表示する箇所とmessageを新しく作る画面へのリンクを追加します。

app/views/rooms/show.html.erb
<p id="notice"><%= notice %></p>

<p>
  <strong>Name:</strong>
  <%= @room.name %>
</p>

<%= link_to 'Edit', edit_room_path(@room) %> |
<%= link_to 'Back', rooms_path %>

+ <div id="messages">
+   <%= render @room.messages %>
+ </div>
+ 
+ <%= link_to "New Message", new_room_message_path(@room) %>

とりあえずここまででチャットができるようになりました!

TurboでSPA化

ここからはHotwireを導入したことで使えるようになるTurboを使っていきます!

ですが、その前にどの部分でTurboが使われているか分かりやすくなるようにCSSを少しいじります。

app/assets/stylesheets/application.cssに以下のようにCSSを追加します。

app/assets/stylesheets/application.css
/*
 * This is a manifest file that'll be compiled into application.css, which will include all the files
 * listed below.
 *
 * Any CSS and SCSS file within this directory, lib/assets/stylesheets, or any plugin's
 * vendor/assets/stylesheets directory can be referenced here using a relative path.
 *
 * You're free to add application-wide styles to this file and they'll appear at the bottom of the
 * compiled file so the styles you add here take precedence over styles defined in any other CSS/SCSS
 * files in this directory. Styles in this file should be added after the last require_* statement.
 * It is generally better to create a new file per style scope.
 *
 *= require_tree .
 *= require_self
 */

+ turbo-frame {
+    display: block;
+    border: 1px solid blue;
+ }

以下の部分を追加していますね。

 turbo-frame {
    display: block;
    border: 1px solid blue;
 }

これでTurboを使っている箇所は青枠で表示されるので分かりやすくなります!

それではTurboを使っていきます!
まずはapp/views/rooms/show.html.erbを以下のように変更します。

app/views/rooms/show.html.erb
<p id="notice"><%= notice %></p>

- <p>
-   <strong>Name:</strong>
-   <%= @room.name %>
- </p>
- 
- <%= link_to 'Edit', edit_room_path(@room) %> |
- <%= link_to 'Back', rooms_path %>
+ <%= turbo_frame_tag "room" do %>
+   <p>
+     <strong>Name:</strong>
+     <%= @room.name %>
+   </p>
+ 
+   <%= link_to 'Edit', edit_room_path(@room) %> |
+   <%= link_to 'Back', rooms_path %>
+ <% end %>

<div id="messages">
  <%= render @room.messages %>
</div>

<%= link_to "New Message", new_room_message_path(@room) %>

<%= turbo_frame_tag "room" do %>から<% end %>までがTurboが適用される範囲です!
間違ってこの範囲にmessage関連の表示とかを含めると意図しない挙動になるので注意しましょう。

同じようにapp/views/rooms/edit.html.erbにもturbo_frame_tagを追加します。

<h1>Editing Room</h1>

+ <%= turbo_frame_tag "room" do %>
+     <%= render 'form', room: @room %>
+ <% end %>

<%= link_to 'Show', @room %> |
<%= link_to 'Back', rooms_path %>

ここまでの段階で、app/views/rooms/show.html.erbEditをクリックするとその部分だけが編集用のフォームに切り替わります。

このようにturbo_frame_tagで括っている範囲内の表示をリンクを経由して差し込むことができるようです。
また引数に渡している文字列はそのturbo-frameidなどになっているようです。

ただし、一点注意が必要で、app/views/rooms/show.html.erbBackをクリックすると一覧へともどることが出来ません。
これはturbo_frame_tagで括られている箇所がapp/views/rooms/index.html.erb内にないためだと思われます。

そこで、"data-turbo-frame": "_top"link_toに追加します。これはTurboを使った画面遷移を超えてリンク先に移動できるようにするためのようです。

app/views/rooms/show.html.erb
<p id="notice"><%= notice %></p>

<%= turbo_frame_tag "room" do %>
  <p>
    <strong>Name:</strong>
    <%= @room.name %>
  </p>

  <%= link_to 'Edit', edit_room_path(@room) %> |
-  <%= link_to 'Back', rooms_path %>
+  <%= link_to 'Back', rooms_path, "data-turbo-frame": "_top" %>
<% end %>

<div id="messages">
  <%= render @room.messages %>
</div>

<%= link_to "New Message", new_room_message_path(@room) %>

これで一覧へと戻ることができるようになりました!

最後に、Turboを使ってmessageを作成するフォームをapp/views/rooms/show.html.erbに埋め込んでみます。

まずはapp/views/messages/new.html.erbで埋め込みたいフォームの部分をturbo_frame_tagで括ります。
先ほどまでと違うのは, target: "_top"を追加しています。

app/views/messages/new.html.erb
<h1>New Message</h1>

- <%= form_with(model: [ @message.room, @message ]) do |form| %>
-     <div class="field">
-         <%= form.text_field :content %>
-         <%= form.submit "Send" %>
-     </div>
- <% end %>
+ <%= turbo_frame_tag "new_message", target: "_top" do %>
+     <%= form_with(model: [ @message.room, @message ]) do |form| %>
+         <div class="field">
+             <%= form.text_field :content %>
+             <%= form.submit "Send" %>
+         </div>
+     <% end %>
+ <% end %>

<%= link_to 'Back', @message.room %>

なぜ, target: "_top"を追加しているのかはよくわかっていないんですが、先ほどのリンクの件のようにスコープの外からでも呼び出せる的なことなんじゃないかと……。

あとはapp/views/rooms/show.html.erbのリンク部分を置き換えます。

app/views/rooms/show.html.erb
<p id="notice"><%= notice %></p>

<%= turbo_frame_tag "room" do %>
  <p>
    <strong>Name:</strong>
    <%= @room.name %>
  </p>

  <%= link_to 'Edit', edit_room_path(@room) %> |
  <%= link_to 'Back', rooms_path, "data-turbo-frame": "_top" %>

  <div id="messages">
    <%= render @room.messages %>
  </div>
<% end %>

- <%= link_to "New Message", new_room_message_path(@room) %>
+ <%= turbo_frame_tag "new_message", src: new_room_message_path(@room), target: "_top" %>

これで、app/views/rooms/show.html.erbを表示するとmessageのフォームが埋め込まれています!

リアルタイム更新用の準備

今のままだとTurbo経由でビューのHTMLがすべて受け取ったりしてmessageを追加することになり面倒です。
そこで、追加されたmessageの部分だけを差分で受け取ることができるように修正していきます。
また追加でその他の修正箇所も直していきます。

まずはapp/controllers/messages_controller.rbcreateメソッドを以下のように修正します。

app/controllers/messages_controller.rb
  def create
    @message = @room.messages.create!(message_params)
    
-    redirect_to @room
+    respond_to do |format|
+      format.turbo_stream
+      format.html { redirect_to @room }
+    end
  end

新しくformat.turbo_streamを使えるようにしています。

次にapp/views/messages/create.turbo_stream.erbを以下のように作成します。

app/views/messages/create.turbo_stream.erb
<%= turbo_stream.append "messages", @message %> 

これは対になるerbファイルを経由してid="messages"のところに差分のHTMLだけを差し込むための修正です。
新しくmessageが作成されるとformat.turbo_streamを経由してapp/views/messages/create.turbo_stream.erbにデータが渡され、最後にmessageを表示しているところに差分が差し込まれるという流れです。

次に、新しくmessageを作成するときに入力した文字などがリセットされていない問題を解消します。
その問題を解決するためにデモではStimulusというJavaScriptのフレームワークを使っています。

まずapp/assets/javascripts/controllers/reset_form_controller.jsを以下のように作成します。

app/assets/javascripts/controllers/reset_form_controller.js
import { Controller } from "stimulus"

export default class extends Controller {
    reset() {
        this.element.reset()
    }
}

これはrest()という入力された値をリセットするメソッドを作っています。

次に、作成したreset()をフォームで呼び出せるようにします。

app/views/messages/new.html.erbを以下のように修正します。

app/views/messages/new.html.erb
<h1>New Message</h1>

<%= turbo_frame_tag "new_message", target: "_top" do %>
-    <%= form_with(model: [ @message.room, @message ]) do |form| %>
+    <%= form_with(model: [ @message.room, @message ], data: { controller: "reset_form", action: "turbo:submit-end->reset_form#reset" }) do |form| %>
        <div class="field">
            <%= form.text_field :content %>
            <%= form.submit "Send" %>
        </div>
    <% end %>
<% end %>

<%= link_to 'Back', @message.room %>

Stimulusではdata{ controller: "reset_form", action: "turbo:submit-end->reset_form#reset" }のように作成したファイル名と呼び出す際のイベントと呼び出されるメソッドを指定することができます。

これでSendをクリックすると入力内容がリセットされます!

ActionCable(websocket)でリアルタイム更新

最後にActionCable(websocket)でリアルタイム更新ができるように修正していきます!

まずは、room毎にActionCable(websocket)への接続を追加します。

app/views/rooms/show.html.erbを以下のように修正していきます。

app/views/rooms/show.html.erb
<p id="notice"><%= notice %></p>

+ <%= turbo_stream_from @room %>

<%= turbo_frame_tag "room" do %>
  <p>
    <strong>Name:</strong>
    <%= @room.name %>
  </p>

  <%= link_to 'Edit', edit_room_path(@room) %> |
  <%= link_to 'Back', rooms_path, "data-turbo-frame": "_top" %>

  <div id="messages">
    <%= render @room.messages %>
  </div>
<% end %>

<%= turbo_frame_tag "new_message", src: new_room_message_path(@room), target: "_top" %>

<%= turbo_stream_from @room %>を追加しているだけで、簡単ですね。

次に、messageが作成されたときにActionCable(Websocket)へと配送する処理を追加します。
これも非常に簡単でapp/models/message.rbを以下のように修正するだけです。

app/models/message.rb
class Message < ApplicationRecord
  belongs_to :room
+  after_create_commit -> { broadcast_append_to room }
end

これでActionCable(Websocket)経由で新しいmessageが配送されます。

余談ですがafter_create_commitは新しくmessageが作成されたイベントをフックしています。このほかにもafter_destroy_commitafter_update_commitのように削除されたり、更新されたときのイベントをフックすることもできます。
なので、削除などもリアルタイムに処理することができます。

ここまでの修正でapp/views/messages/create.turbo_stream.erbでの処理は不要となっているので以下のようにコメントだけに修正します。

app/views/messages/create.turbo_stream.erb
- <%= turbo_stream.append "messages", @message %> 
+ <% # Return handled by cable %>

現状では作成されたmessageの配送はしていますが、削除などは対応していません(after_destroy_commitを追加していないので)

そこでそれらすべてをまとめて追加できるbroadcasts_toapp/models/message.rbに追加します。

app/models/message.rb
class Message < ApplicationRecord
  belongs_to :room
-  after_create_commit -> { broadcast_append_to room }
+  broadcasts_to :room
end

基本的に個別のイベントをフックしたいとかでもない限りはbroadcasts_toを使うとよさそうです。

ここまでの段階でリアルタイムにmessageをやり取りすることができます!(ただし、Redisは必要!)

最後に、roomの情報もリアルタイムに共有できるようにします!
まずはapp/models/room.rbに配送周りの処理を追加します。

app/models/room.rb
class Room < ApplicationRecord
    has_many :messages
+    broadcasts
end

追加するのはbroadcastsだけです。

あとはapp/views/rooms/_room.html.erbを以下のように作成し、それをapp/views/rooms/show.html.erb でパーシャルとして呼び出すだけです。

app/views/rooms/_room.html.erb
<p id="<%= dom_id room %>">
    <strong>Name:</strong>
    <%= room.name %>
</p> 
app/views/rooms/show.html.erb
<p id="notice"><%= notice %></p>

<%= turbo_stream_from @room %>

<%= turbo_frame_tag "room" do %>
-  <p>
-    <strong>Name:</strong>
-    <%= @room.name %>
-  </p>
+  <%= render @room %>

  <%= link_to 'Edit', edit_room_path(@room) %> |
  <%= link_to 'Back', rooms_path, "data-turbo-frame": "_top" %>

  <div id="messages">
    <%= render @room.messages %>
  </div>
<% end %>

<%= turbo_frame_tag "new_message", src: new_room_message_path(@room), target: "_top" %>

これでroomnameを編集した内容がリアルタイムで共有されます!

所感

かなり簡単かつ手早くSPA+Websocketを使ったアプリが作れるのでこれは非常に便利!
少人数のチームでサクッと作るのにかなり向いてそうですね。

あとは、JavaScriptに強い人がいないチームとかでは重宝されそうですね!

参考

ref: 速報: Basecampがリリースした「Hotwire」の概要
ref: hotwired/hotwire-rails-demo-chat
ref: hotwired/stimulus-rails
ref: hotwired/turbo-rails
ref: hotwired/hotwire-rails
ref: Hotwire
ref: Hotwire デモ
ref: Rails環境でJS , CSSをwebpackで完全に管理する
ref: rails new with --skip-webpack-install still includes the webpacker gem. #35484
ref: How to use Hotwire in Rails

49
39
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
49
39

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?