Turbo Streamのプログレス
24 Dec 2023プログレスバーについての投稿は何度かしてきたけれども、これまで私が実装してきたのはロングポーリングか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でもできなくはないと思うのだけれども、ここまですんなりとは終わらなかったと思う。