Turbo Streamのプログレス

プログレスバーについての投稿は何度かしてきたけれども、これまで私が実装してきたのはロングポーリングかSSEのみであった。 Rails 7になってからTurboを使い始めるようになったので、WebSocketの実装というとちょっと違うような気がするけれども、Turbo Streamを使ってプログレスバーを実装するというのは前々から興味のあるタスクだった。

まずは前準備としてこのようなスクリプトを用意してみる:

#!/usr/bin/env ruby

$stdout.sync = true

require "json"

100.times do |n|
  puts JSON.dump({ progress: n + 1 })
  sleep rand
end

この小さなスクリプトは見たままの内容で、ランダムな感覚でJSONを出力する。 $stdout.sync = trueの行が重要で、これがないとプログラムが一気に100行分のJSONを最後に出力してしまう。 このコマンドを処理するYtDlpというクラスを用意する。

そしてVideoというモデルを用意する:

class Video < ApplicationRecord
  enum :download_status, [:idle, :downloading, :downloaded]

  validates :url, presence: true

  def download_video
    YtDlp.new(self).download
  end
end

YtDlp#downloadを実行すると次のListenerというクラスのon_dataに先程JSONで生成したprogressが渡される:

class YtDlp::Listener
  def initialize(video)
    @video = video
  end

  def on_data(data)
    progress = data.fetch("progress")
    @video.broadcast_update(
      target: "progress",
      html: "<p>#{progress}</p>"
    )
  end

  def on_end
    @video.downloaded!
  end
end

ここで注意したいのが似たようなメソッドでbroadcast_replaceがあるが、Turbo Frameごと書き換えてしまうので意図した挙動にはならない。

続いてHTML側の実装を見てみよう。 コントローラ側の実装は省略するが、@videoにVideoのモデルが入っている。

<!-- app/views/show.html.erb -->
<%= turbo_stream_from @video %>

<%= turbo_frame_tag "progress" do %>
  <p>-</p>
<% end %>

この時点ではprogressに永続性はない。 しかしこのコードを実行すると以下のようなログが得られるはずだ。

[ActiveJob] [DownloadVideoJob] [3fe38318-a0fc-4121-8900-f2e0f7452604] [ActionCable] Broadcasting to Z2lkOi8vbmlzaGlub2Zsb3dlci9WaWRlby8y: "<turbo-stream action=\"update\" target=\"progress\"><template><p>42</p></template></turbo-stream>"

Rails上でUIのデザインをするのもよいのだが、今回はBootstrapを使うことにする。 Railsもそうだけれども、Bootstrapは未だにメンテナンスされていてストレスなく使うことができるので有り難い。

もちろん先程のコードに対して書いても良いのだがコードが重複してしまう。

class Video < ApplicationRecord
  def broadcast_progress
    broadcast_update(
      target: "progress",
      partial: "videos/progress",
      locals: { video: self }
    )
  end
end

NOTE: locals: { video: self }は省略可能だが、今回は明示的に記述した。

このようにした。 そうなるとHTML側の実装も次のようになるはずだ:

<!-- app/views/show.html.erb -->
<%= turbo_stream_from @video %>

<%= turbo_frame_tag "progress" do %>
  <%= render "progress", video: @video %>
<% end %>

<!-- app/views/_progress.html.erb -->
<div class="progress"
     role="progressbar"
     data-controller="progress"
     data-progress="<%= video.progress %>"
     data-is-downloading="<%= video.downloading? %>">
  <div class="progress-bar" style="width: <%= video.progress %>%"></div>
</div>

さてこうなると当然Video#progressというメソッドが必要になる。 単純にprogressというカラムを用意してもよいのだが、ミリ秒単位でDBに対してCOMMITを行うのもなんだか気が引ける。 そこでKredisを使おうというわけ。

NOTE: Kredisに関しては以前TILのリポジトリに投稿した。

class Video < ApplicationRecord
  after_update_commit :broadcast_progress, if: :downloaded?

  kredis_integer "download_progress", default: 0, after_change: :broadcast_progress

  def progress
    download_progress.value
  end

  def progress=(value)
    download_progress.value = value.to_i
  end
end

このように実装してみた。 無事次のようなログが出力されていれば成功だ。

[ActiveJob] [DownloadVideoJob] [405b7ea6-c459-4d38-be6a-8936110c790e] [ActionCable] Broadcasting to Z2lkOi8vbmlzaGlub2Zsb3dlci9WaWRlby8xMA: "<turbo-stream action=\"update\" target=\"progress\"><template><div class=\"progress\" role=\"progressbar\" data-controller=\"progress\" data-progress=\"42\" data-is-downloading=\"true\">\n  <div class=\"progress-bar progress-bar-striped progress-bar-animated\" style=\"width: 42%\"></div>\n</div>...
[ActiveJob] [DownloadVideoJob] [405b7ea6-c459-4d38-be6a-8936110c790e] [ActionCable] Broadcasting to Z2lkOi8vbmlzaGlub2Zsb3dlci9WaWRlby8xMA: "<turbo-stream action=\"update\" target=\"progress\"><template><div class=\"progress\" role=\"progressbar\" data-controller=\"progress\" data-progress=\"100\" data-is-downloading=\"false\">\n  <div class=\"progress-bar\" style=\"width: 100%\"></div>\n</div>\n</template></turbo-stream>"

気になる点といえばプログレスバーがカクカクに動くところだろうか。 本来CSSの値が変わるとトランジションが効くはずなので、アニメーションは滑らかに動くはずなのだけれどもHTMLの要素ごとupdateしているのでHTMLを生成せずにscriptタグなどを動的に出力する必要があるのかもしれない。

NOTE: HTMLのかわりにjavascript_tagを使えばスムーズに動かせるのだが、課題も多いのでここには残さないでおく。

とはいえTurbo Streamの実装は非常に簡単であり、Ruby的である。 これがもしAction Cableの頃の実装であればVideoChannelのようなクラスとJavaScriptの実装も必要だったと思う。 WebSocketで面倒なpub/sub周りの実装はTurbo側に任せておけるので、途中でブラウザのタブを閉じたり、あるいはリロードしてもバックグラウンドの処理には影響しないのが素晴らしい。 もちろんAction Cableでもできなくはないと思うのだけれども、ここまですんなりとは終わらなかったと思う。