虎の穴開発室ブログ

虎の穴ラボ株式会社所属のエンジニアが書く技術ブログです

MENU

Hotwire を使ってみよう ~ 簡単なチャット 検索画面 更新通知 を 作る ~

皆さんこんにちは、最近は ToraLab.fm のパーソナリティの おっくん(おくたに)です。

Ruby on Rails の作者 DHH が 2020 年 12 月 23 日に「Hotwire」に言及した後、 2020 年末から 2021 年始にかけて Rails 界隈の話題の一翼を担っていたと思います。 LT 会でも Hotwire について言及している場面を見かけることがありました。

今回は、Hotwire gem を利用し、 デモでも紹介されるような簡単なチャットと、検索画面そして更新通知を実装してみたいと思います。

最終的には、こんなものを作成します。

f:id:toranoana-lab:20210225094547g:plain

Hotwire って?

HOTWIRE HTML OVER THE WIRE に紹介されている紹介文には、以下のように記されています。

hotwire.dev

Hotwire is an alternative approach to building modern web applications without using much JavaScript by sending HTML instead of JSON over the wire.

Hotwire は、JavaScript を多用せずに JSON の代わりに HTML をネットワークで送信し、モダンなアプリケーションを構築するための代替のアプローチです。

伝統的なシングルページアプリケーション開発と比較し 4 つの利点を紹介されていました。

  • 最初のロードが高速になる
  • サーバーでのレンダリングを維持できる
  • シンプルにできる
  • あらゆる言語でのより生産的な開発体験

これらについては、「Hotwire 振り返り」で使ってみた感想を踏まえて別途触れていきます。

Hotwire は、Turbo と Stimulus から構成されています。 (あとStradaというものがあるんですが、こちらは現在「COMING SOON!」となっており、2021年内には公開されるようです。)

さらに、Turbo は以下の3つの機能に分けることができます。

  • Turbo Drive
    従来のTurbo links のことだそうです。 こちらについて DHH が言及しているやり取りがあります。

  • Turbo Frames
    指定した部分だけをレスポンスのHTMLから抽出し、ページの任意の部分をHTMLの書き換える機能。

  • Turbo Streams
    WebSocket、SSE、その他の転送方法で、ページの任意の部分を書き換える機能。

Stimulus は、「控えめな」JavaScriptフレームワークです。 フレームワークとして、ドキュメントの構造を変えるなどのHTMLのレンダリングは関与せず、HTMLを魅力的にするために設計されています。

ここからは Hotwire(Turbo Frames, Turbo Streams, Stimulus) を使ったアプリケーションをつくっていきます。

実行環境

  • macOS 10.15.17
  • redis 5.0.7
    後述する Turbo Streams にて Action Cable を使用するにあたり必要です。
  • ruby 3.0.0

実装

この実装では、下敷きとして hotwire が公開しているデモアプリを参考にします。

github.com

今回作成するアプリの概要は、以下の通りです。

  • 簡単な 1 部屋 1 画面だけのチャットアプリ
  • 書き込みを検索できる機能
  • 書き込みの検索結果を表示している間に新しい投稿があった時は、新着通知を表示する機能

アプリの環境設定

任意のディレクトリで以下を進めます。

bundle init
bundle set --local path vendor/bundle

# Gemfile の gem "rails" のコメントアウトを外す

bundle install
bundle exec rails new . --skip-javascript

Gemfile に、以下の編集を行います。

[Gemfile]

# redis がコメントアウトされているのでコメントアウトを外す
gem 'redis', '~> 4.0'

# hotwire-rails と stimulus-railsを書き足す
gem 'hotwire-rails'
gem 'stimulus-rails'

# お好みでCSSのライブラリなど
gem "bulma-rails", "~> 0.9.1"

hotwire-rails と stimulus-railsをインストールします。

bundle exec rails turbo:install
bundle exec rails stimulus:install

action-cable の設定を行う、config/cable.ymlを設定します。

[config/cable.yml]

development:
  adapter: redis
  url: redis://localhost:6379/cable

bundle exec rails rails s でいつもの Rails の初期画面を確認します。

実装

続けて、モデル、コントローラー、view テンプレートを実装します。

モデル - テーブル

今回作成するのはシンプルな掲示板だけなので、テーブルは以下の一つだけとします。

カラム名 データ型
id BIGINT
name VARCHAR
coment VARCHAR
created_at DATETIME
created_at DATETIME

モデルは以下の通りです。

[app/models/post.rb]

class Post < ApplicationRecord
  after_create_commit do
    broadcast_prepend_to "post-list", target: "post-list-el", partial: "posts/post"
    broadcast_replace_to "post-info", target: "post-info-el", partial: "posts/info"
  end  
end

新たに Post が作成されたとき、Turbo Streams の機能で、2 種類の内容をブロードキャストします。

  • 検索をしていない画面への、投稿の追加表示用
    アクション: prepend (子要素の先頭に追加) 対象チャンネル: post-list
    書き換えターゲット: post-list-el
    使用テンプレート: posts/post

  • 検索をしている画面への、新着通知の表示用
    アクション: replace (要素を差し替える) 対象チャンネル: post-info
    書き換えターゲット: post-info-el
    使用テンプレート: posts/info

前者の場合、post-listをリッスンしているクライアント向けに、posts/_post.html.erbを使用しレンダリングされたHTMLを配信し、id="post-list"のHTMLエレメントの先頭に追記するという設定になります。

アクションには、他にappend update remove があります。

コントローラー

コントローラーは以下の通りです。

[app/controllers/posts_controller.rb]

class PostsController < ApplicationController
  def index
    @search_keyword = search_params[:keyword]

    @posts =
      if @search_keyword.nil?
        Post.all.order(created_at: :desc)
      else
        Post.where(['comment LIKE ?', "%#{@search_keyword}%"]).order(created_at: :desc)
      end
  end

  def create
    @post = Post.new(post_params)
    @post.save

    respond_to do |format|
      format.turbo_stream do
        render turbo_stream: turbo_stream.prepend("post", partial: "posts/dummy")
      end 
    end
  end

  private
  def search_params
    params.permit(:keyword)
  end

  def post_params
    params.require(:post).permit(:name,:comment)
  end
end

ポイントになるのは、以下のレスポンスです。

respond_to do |format|
  format.turbo_stream do
    render turbo_stream: turbo_stream.prepend("post", partial: "posts/dummy")
  end 
end

Turbo Stream でのレスポンス返却を行っています。 ただし、実際には先に示したモデルの実装の通り、after_create_commitでTurbo Streamの機能で変更内容を反映させるので、ここではダミーとなるパーシャルを返して適当なレスポンスを返すだけにとどめています。

ルーティング

ルーティングは、非常にシンプルです。 Hotwire を使うからということを特別意識する必要はありません。

[ config/routes.rb]

Rails.application.routes.draw do
  resources :posts, only: [:index, :create]
end

view テンプレート

今回 view テンプレートは、以下の4種類を用意します。

  • app/views/posts/index.html.erb
    ページ本体を構成するテンプレート
  • app/views/posts/_post.html.erb
    1つの投稿を構成するパーシャルテンプレート
  • app/views/posts/_info.html.erb
    新着通知を構成するパーシャルテンプレート
  • app/views/posts/_dummy.html.erb
    PostsController#createのレスポンスとなるダミーのレスポンスを返すためのパーシャルテンプレート

それぞれの詳細は以下の通りです。

[app/views/posts/index.html.erb]

<section class="section">
  <h1 class="title">サンプルアプリ</h1>
  <p>現在時刻<%= DateTime.now %></p>

  <%= form_with url: posts_path, method: :get do |f| %>
    <div class="field has-addons">
      <div class="control">
        <%= f.text_field :keyword, value: @search_keyword, class: "input", placeholder: "検索キーワード"%>
      </div>
      <div class="control">
        <%= f.submit "検索", class: "button is-info" %>
      </div>
      <div class="control">
        <%= link_to "リセット", posts_path, class: "button is-link" %>
      </div>

    </div>
  <% end %>

</section>

<!-- Turbo Frame の機能を使い部分的な書き換えをする場合には、<turbo-frame>タグで囲ってあげる --> 
<turbo-frame id="posts">
  <section class="section">
    
    <!-- Turbo Stream の機能を使うには、turbo_stream_from でリッスンするチャンネルを指定する -->
    <% if @search_keyword.present? %>
      <%= turbo_stream_from "post-info" %>
    <% else %>
      <%= turbo_stream_from "post-list" %>
    <% end %>

    <% if !@search_keyword.present? %>
      <%= form_with model: Post, url: posts_path, method: :post, data: { controller: "posts", action: "turbo:submit-end->posts#reset" } do |f| %>
        <div class="field is-horizontal">
          <div class="field-label is-normal">
            <label class="label">ハンドルネーム</label>
          </div>
          <div class="field-body">
            <div class="field">
              <div class="control">
                <%= f.text_field :name, class: "input" %>
              </div>
            </div>
          </div>
        </div>

        <div class="field is-horizontal">
          <div class="field-label is-normal">
            <label class="label">コメント</label>
          </div>
          <div class="field-body">
            <div class="field">
              <div class="control">
                <%= f.text_field :comment, class: "input", data: { "posts-target": "comment"} %>
              </div>
            </div>
          </div>
        </div>

        <div class="field is-horizontal">
          <div class="field-label">
            <!-- Left empty for spacing -->
          </div>
          <div class="field-body">
            <div class="field">
              <div class="control">
                <%= f.submit "書き込み", class: "button is-primary" %>
              </div>
            </div>
          </div>
        </div>

      <% end %>
    <% end %>

  </section>
  <section class="section">

    <div id="post-info-el">
    </div>
  
    <% if @posts.present? %>
      <% if @search_keyword.present? %><%= @search_keyword %>」の検索結果
      <% end %>

      <div id="post-list-el">
        <% @posts.each do |post|%>
          <%= render partial: "post", :locals => { post: post }  %>
        <% end %>
      </div>
    <% else %>
      <% if @search_keyword.present? %><%= @search_keyword %>」を含む投稿はありません。
      <% else %>
        投稿はありません。
      <% end %>
    <% end %>
    
  </section>
</turbo-frame>

後述するStimulusによる、機能拡張が行われています。

[app/views/posts/_post.html.erb]

<div>
  <div class="card">
    <header class="card-header">
      <p><%= post.name %> さんの投降 - <%= post.created_at.strftime("%H:%M %S") %> </p>
    </header>
    <div class="card-content">
      <div class="content">
        <p><%= post.comment %></p>
      </div>
    </div>
  </div>
  <hr/>
</div>

[app/views/posts/_info.html.erb]

<div id="post-info-el" class="notification is-primary">
  <%= link_to "新着投稿があります(#{post.created_at.strftime("%H:%M:%S")})", posts_path %>
</div>

[app/views/posts/_dummy.html.erb]

<% # Return handled by cable %>

CSSライブラリとして、Bulmaを使用しています。 こちらについては公式のドキュメントを参照ください。

bulma.io

Stimulus コントローラー

今回のアプリケーションでは、データ作成をPOSTしたときにフォーム部分は、要素の書き換えが行われないようになっています。こちらを、Stimulusを用いて拡張し送信した時にフォームの中でコメント部分のみリセットするようにします。

[app/assets/javascripts/controllers/posts_controller.js]

import { Controller } from "stimulus"

export default class extends Controller {
  static targets = ["comment"]

  reset(){
    this.commentTarget.value = ""
  }
}

Stimulus のコントローラーは、app/views/posts/index.html.erbで呼び出しが行われています。 以下の部分です。

<%= form_with model: Post, url: posts_path, method: :post, data: { controller: "posts", action: "turbo:submit-end->posts#reset" } do |f| %>

変換された HTML は次のようなります。

<form data-controller="posts" data-action="turbo:submit-end->posts#reset" action="/posts" accept-charset="UTF-8" method="post">

data-controller="hoge"で使用するコントローラを、data-action="fuga"でトリガーと実行するアクションそのものを指定できます。

Stimulus ハンドブックには、様々な使い方が記載されています。

stimulus.hotwire.dev

Hotwireと関連させずとも使うことができるものなので、興味があればぜひ試してみてください。

動作確認

作成したアプリケーションを二つのタブで開き、操作した様子が冒頭でも示した以下の動画です。

f:id:toranoana-lab:20210225094547g:plain

検索をしていない場合には、書き込みのためのフォームが表示され、投稿すると別のタブで内容が追加されます。
検索画面では、他のタブで書き込みがあると、「新着投稿があります(HH:MM:ss)」の形式で通知が現れます。
変更内容を画面の状態に従い、整理して表示することができています。

また、書き込みが行われた時フォームの最上部に出した現在時刻が書き換わらずページ全体での再読み込みがされていないこともお判りいただけるかと思います。

Hotwire 振り返り

冒頭の Hotwire の紹介で以下の 4 つの特徴を取り上げました。

  • 最初のロードが高速になる
  • サーバーでのレンダリングを維持できる
  • シンプルにできる
  • あらゆる言語でのより生産的な開発体験

この中で、「サーバーでのレンダリングを維持できる」と「シンプルにできる」ついてはアプリケーションを作る中で体験できるものかと思います。 特に、シングルページアプリケーションをバックエンドのAPIまで作った経験があると、体感しやすいかと思います。

続いて「最初のロードが高速になる」は、厳密な比較は難しいので断定的な言及は控えますが、アプリの規模が大きくなるほどシングルページアプリケーションの方が遅くなるだろうことはある程度予想できそうです。 昨今だと、フレームワーク側でSSRしてくれるものも多いので、一概にこの主張が正しいとは思いません。 また、バンドルするファイルを分割するなどでも対応することができるかと思います。

「あらゆる言語でのより生産的な開発体験」の「生産的な開発体験」ついては、通信に必要なWebSocket関連の実装などはすべてHotwireが用意してくれています。
新たに覚える必要がある事柄も、あまり多くは無く学習コストはかなり低いように思います。
先に示したように、動的なフロントエンドを持つアプリの開発を行う中で、要求されるJavaScriptの実装はほぼなく、
stimulus でのフォームの拡張程度で済みました。
Ruby と JavaScript でのコンテキストスイッチがほとんどないという点では、「生産的な開発体験」といえるかもしれません。(これは好みであったり、Ruby と JavaScript の切り替えをコンテキストスイッチと思うかなど意見がありそうです。)

「あらゆる言語でのより生産的な開発体験」の「あらゆる言語」の部分については、今回体験できる内容ではありません。
お気づきの方もいらっしゃるかと思いますが、「HTMLページをレンダリングしレスポンスする」と「パーシャルをレンダリングして転送する」仕組みがあれば Hotwire (特にTurbo Frame と Turbo Strems) の構成を取ることが考えられているようです。

既に様々な言語でのHotwireの実装の試みている方がいらっしゃるので、いくつか紹介します。

Python

github.com

JavaScript

github.com

Go

github.com

PHP

github.com

まとめ

Hotwire を 使った簡単なチャットと、検索画面と通知を作成しました。
Hotwire で示されたアプリケーションの構造がメインストリームとなるかはこれからにかかっているのだと思います。
特に Ruby のトレンドに閉じてしまわないことが、必要なのことなのだと思います。
代表的といえるような導入事例がいくつか挙がってくると、風向きが変わってくるかもしれませんね。

P.S.

3/19(金)19:30~ 【オンライン】3/19 とらのあなエンジニア&マーケター採用説明会【地方勤務可能!!】 yumenosora.connpass.com

採用情報
募集職種
yumenosora.co.jp

カジュアル面談も随時開催中です
お申し込みはこちら!
yumenosora.connpass.com

ToraLab.fmスタートしました!
メンバーによるPodcastを配信中!是非スキマ時間に聞いて頂けると嬉しいです。

anchor.fm

Twitterもフォローしてくださいね!
ツイッターでも随時情報発信をしています
twitter.com