Hotwire（Turbo）を試す その3: Trubo Streamsでのブロードキャスト

環境: Rails 6.1、Turbo 7.0.0-beta.5

- [Hotwire（Turbo）を試す その1: 導入、作成・更新フォーム](https://qiita.com/kazutosato/items/10a5bc04443d6b7e5bf8)
- [Hotwire（Turbo）を試す その2: Trubo Streamsでページの一部の差替・追加](https://qiita.com/kazutosato/items/74ab0a22d41cd8859fad)

の続きです。

Trubo Streamsには、HTML片を通常のHTTPレスポンスで送るほかに、Action Cableを使ってブラウザーに一斉送信（ブロードキャスト）する機能があります。

## ブロードキャストをとりあえず試す

その1とその2で作ったサンプルにこの機能を導入して、記事を作成・更新・削除するたびに記事一覧が自動更新されるようにしてみます。各メソッドの詳しい説明は後回しにします。

Action Cable経由で更新したいページには、turbo_stream_fromメソッドでストリーム名を指定します。ここでは 'top_entries' としています。

```erb:app/views/entries/index.html.erb
<%= turbo_stream_from 'top_entries' %>
```

このメソッドは`<turbo-cable-stream-source>`要素を埋め込みます。TurboのJavaScriptはこれを見てAction Cableへの接続を開始するようです。

```html
<turbo-cable-stream-source channel="Turbo::StreamsChannel" signed-stream-name="InRvcF9lbnRyaWVzIg==--0088422a300e71f502a839bd9f5940479f3fe7b18578309a3313619862ef4118"></turbo-cable-stream-source>
```

一覧中の記事が個別に更新されるように、記事一つ一つを`<turbo-frame>`で囲みます。フレーム名に文字列の代わりにモデルオブジェクトentryを指定していますが、これは 'entry_12' のように「モデル名_id」の文字列になります。entryの代わりに`dom_id(entry)`としても同じです。

[その2](https://qiita.com/kazutosato/items/74ab0a22d41cd8859fad#turbo-frame-%E3%81%AE%E4%B8%AD%E3%81%AE%E3%83%AA%E3%83%B3%E3%82%AF%E3%81%AE%E6%8C%99%E5%8B%95)で一覧を囲む`<turbo-frame>`に付けていた`target: '_top'`をここに移します。

```erb:app/views/entries/_entry.html.erb
<%= turbo_frame_tag entry, target: '_top' do %>
  <div class="entry">
    <%= link_to entry.title, entry %> |
    <%= entry.created_at.strftime('%m/%d %H:%M') %> |
    <%= link_to 'Edit', edit_entry_path(entry) %> |
    <%= link_to 'Destroy', entry, method: :delete, data: { confirm: 'Are you sure?' } %>
  </div>
<% end %>
```

モデルクラスでは、クラスメソッドbroadcasts_toを呼び出して、作成・更新・削除のコールバックで自動的にAction Cableによるブロードキャストが行われるようにします。ラムダ内の 'top_entries' は、ストリーム名です。`inserts_by: 'prepend', target: 'entries'`は作成のときだけ使われるもので、後ろではなく前に追加、追加先のフレーム名は 'entries' 、という指示です。

```ruby:app/models/entry.rb
class Entry < ApplicationRecord
  validates :title, :body, presence: true

  broadcasts_to ->(e){ 'top_entries' }, inserts_by: 'prepend', target: 'entries'
end
```

ブラウザーのウィンドウを2つ並べ、記事の新規作成・更新・削除を行うと、もう片方の表示に反映されます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/308595/5fdf476e-3849-a430-3deb-ff765296fa9d.png)

## ブロードキャストのためのメソッド

上記のサンプルでは、モデルのコールバックで一律にブロードキャストしていますが、実際のアプリケーションでは、ブロードキャストのためのメソッドを必要に応じて呼び出すことになるかと思います。

Turbo Streamsによるブロードキャストの機能は、Turbo::StreamsChannelクラスで「broadcast_アクション名_to」という名前のメソッドに実装されています。また、同名のメソッドがモデルクラスにも用意されています。

アクションには、append（後ろに追加）、prepend（前に追加）、replace（差し替え）、remove（削除）があります。

Turboのソースコードを見るのが手っ取り早いかもしれません。

- [Turbo::Streams::Broadcasts](https://github.com/hotwired/turbo-rails/blob/main/app/channels/turbo/streams/broadcasts.rb)
- [Turbo::Broadcastable](https://github.com/hotwired/turbo-rails/blob/main/app/models/concerns/turbo/broadcastable.rb)

### 追加

ある記事のHTML片を送信してリストの上に追加するには、broadcast_prepend_toメソッドを使います。第1引数はストリーム名です。targetオプションは対象になるフレーム名です。ストリーム名とフレーム名を混同しないようにしましょう。

partialとlocalsはそれぞれ、レンダリングに使う部分テンプレートと部分テンプレートで使うローカル変数です。

```ruby
Turbo::StreamsChannel.broadcast_prepend_to 'top_entries', target: 'entries', partial: "entries/entry", locals: { entry: @entry }
```

これは、次のようなHTML片をストリーム 'top_entries' に送信します。

```html
<turbo-stream action="prepend" target="entries">
  <template>
app/views/entries/_entry.html.erb のレンダリング結果
  </template>
</turbo-stream>
```

次のようにモデルのメソッドを呼び出しても同じことになります。

```ruby
@entry.broadcast_prepend_to 'top_entries', target: 'entries', partial: "entries/entry", locals: { entry: @entry }
```

モデルのメソッドの場合、オプションは省略できます。targetを省略すると、モデル名の複数形（ここでは 'entries' ）が使われます。partialとlocalsを省略すると、Railsのrenderメソッドで`render partial: 'モデル名の複数形/モデル名', locals: { モデル名: モデルオブジェクト }`としたときと同じになります。

```ruby
@entry.broadcast_prepend_to 'top_entries'
```

なお、HTML片を下に追加するときは、broadcast_prepend_toの代わりにbroadcast_append_toを使います。

### 差し替え

HTML片を送信してリスト中の項目を差し替えるには、broadcast_replace_toメソッドを使います。targetオプションにモデルオブジェクトを指定すると、「entry_123」のようなid付きのフレーム名が対象になります。

```ruby
Turbo::StreamsChannel.broadcast_replace_to 'top_entries', target: @entry, partial: "entries/entry", locals: { entry: @entry }
```

モデルにも同名のメソッドが用意されています。

```ruby
@entry.broadcast_replace_to 'top_entries', target: @entry, partial: "entries/entry", locals: { entry: @entry }
```

broadcast_prepend_toと同様にオプションを省略できます。デフォルトのtargetは、`target: @entry`となります。

```ruby
@entry.broadcast_replace_to 'top_entries'
```

### 削除

削除用のメソッドは、broadcast_remove_toです。差し替えと同じく、targetオプションにはモデルオブジェクトを指定できます。

```ruby
Turbo::StreamsChannel.broadcast_remove_to 'top_entries', target: @entry
```

モデルのメソッドを使うと次のようになります。targetオプションは省略できます。

```ruby
@entry.broadcast_remove_to 'top_entries'
```

## ストリームにモデルオブジェクトを指定した場合

turbo_stream_fromメソッドに指定するストリーム名には、文字列のほかにモデルオブジェクトを指定できます。一覧ページではなく詳細ページで自動更新するための書き方でしょうか。

```erb
<%= turbo_stream_from @entry %>
```

この場合は、ブロードキャストの際にストリーム名にモデルオブジェクトを指定できます。

```ruby
@entry.broadcast_replace_to @entry, target: @entry, partial: "entries/entry", locals: { entry: @entry }
```

「_to」を抜いたメソッドを使うと、ストリーム名はモデルオブジェクトと見なされます。オプションのtarget、partial、localsは省略可能です。

```ruby
@entry.broadcast_replace
```

ところで、ストリーム名には複数のオブジェクトを配列で指定できます。特定のユーザーにだけ送信したい、という場合に使えそうです。

```erb
<%= turbo_stream_from [current_user, @entry] %>
```

ブロードキャスト用のメソッドでもストリーム名に配列を指定します。

```ruby
@entry.broadcast_replace_to [current_user, @entry]
```

## ジョブを使った非同期送信

ブロードキャストのメソッドには、「broadcast_アクション名_later_to」という名前のものも用意されています。「later」付きのメソッドを使うと、Active JobのクラスTurbo::Streams::ActionBroadcastJobのジョブが起動して、ジョブがAction Cableを使って送信する、という流れになります。

```ruby
@entry.broadcast_prepend_later_to 'top_entries', target: 'entries'
```

「later」付きのメソッドで非同期のブロードキャストを実験するには、Active Jobを普通に設定すればOKです。アダプターのGemを加え（ここではSidekiq）、

```ruby:Gemfile
gem 'sidekiq'
```

設定でアダプター名を指定し、

```ruby:config/environments/development.rb
  config.active_job.queue_adapter = :sidekiq
```

Sidekiqを起動しておきます。

```
% bundle exec sidekiq
```

## モデルのコールバック用のメソッド

上記の「ブロードキャストをとりあえず試す」では、broadcasts_toメソッドを使いました。

```ruby
  broadcasts_to ->(e){ 'top_entries' }, inserts_by: 'prepend', target: 'entries'
```

これは、次のように指定するのと同じことです。[モデルのコールバック](https://guides.rubyonrails.org/active_record_callbacks.html#transaction-callbacks)を使ってブロードキャスト用のメソッドを呼び出します。

```ruby
  after_create_commit { broadcast_prepend_later_to 'top_entries', target: 'entries' }
  after_update_commit { broadcast_replace_later_to 'top_entries' }
  after_destroy_commit { broadcast_remove_to 'top_entries' }
```

作成と更新のときは「later」付きのジョブを使ったブロードキャストで、削除のときは「later」なしです。なぜそうなのかは分かりません。

broadcasts_toの第1引数には、belongs_toなどで関連付けたモデルオブジェクトを指定することが想定されているようです。次のようにすると、送信先のストリーム名は`send(:blog)`になります。上の例のようにストリーム名を文字列で指定したい場合は、ラムダを使うしかありません。

```ruby
  belongs_to :blog
  broadcasts_to :blog, inserts_by: 'prepend', target: 'entries'
```

これは、次のように指定するのと同じです。

```ruby
  after_create_commit { broadcast_prepend_later_to blog, target: 'entries' }
  after_update_commit { broadcast_replace_later_to blog }
  after_destroy_commit { broadcast_remove_to blog }
```

コールバック用のメソッドにはもう1つ、「_to」の付かないbroadcastsメソッドがあります。

```ruby
  broadcasts inserts_by: 'prepend'
```

これは、次のように書くのと同じになります。送信先のストリームは、モデルオブジェクト（`@entry.broadcast_replace_later_to @entry`などと同じ）となります。

```ruby
  after_create_commit { broadcast_prepend_later }
  after_update_commit { broadcast_replace_later }
  after_destroy_commit { broadcast_remove }
```

## ここまでの感想

- Action Cableを使うときに、プログラムを自分であれこれ工夫しなくてもよいので楽になりそう。
- 当面は[その1](https://qiita.com/kazutosato/items/10a5bc04443d6b7e5bf8)とその3の機能だけ使って、[その2](https://qiita.com/kazutosato/items/74ab0a22d41cd8859fad)は使わないかなあ。

おしまい