概要
例えば何らかの一覧画面上のデータを全てExportする機能を作った際に sidekiq でその重たい処理を実行させたい場面があるかと。
sidekiq のタスクが終わってファイルが生成できた後、ユーザーがそのファイルをダウンロードできるようにするためには ActiveStorage でファイル管理すると楽ですよね。
今回は、次の処理フローをまとめていきたいと思います。
- 何らかの一覧画面上のデータを全てExportするボタンをクリックしてダウンロード画面へ遷移(UIスレッド)
- sidekiqにその一覧データをCSVなどにファイル生成する処理をタスク化(UIスレッド・非同期)
- ↑この間、ダウンロード画面上はダウンロード中のインゲージUIを表示させて裏ではAjaxでファイルが生成されたのか常に確認している(UIスレッド)
- ファイルが生成できたらActiveStorageでlocal設定は特定のパスにファイルを移動、aws設定ならS3にファイルをアップロードする(非同期)
- ファイルができたことを確認したらダウンロード中のインゲージUIを止めてファイルの詳細とダウンロードボタンを表示(UIスレッド)
- ActiveStorageで管理しているファイルをダウンロード(UIスレッド)
注意
これから解説する内容は、あくまでサンプルのものになります。
参考にされる際は、自己責任でご対応のほどよろしくお願いいたします。
課題
sidekiqとActiveStorageはそんなに難しくないんですが、問題はAjaxで常にファイルができたか確認している処理をどうやって実装するのか。
ググったところruby on rails - How to perform a Sidekiq callback when a group of workers are complete - Stack Overflowを見つけて、公式WikiのBatches · mperham/sidekiq Wikiでまとめられている方法を使うことになりそう。
環境
- ruby 2.6.6
- rails 6.1.3
今回、ajaxを使う場面があるため、Rails6でのjQuery導入方法 - Qiitaを参考に設定を済ませておく必要があります。
また、参考記事は次の内容となります。
sidekiqとActiveStorageの初歩的な解説や設定は全て割愛するので前もって把握してから参考にしてくださいmm
sidekiq
- Railsで非同期処理を行える「Sidekiq」 - Qiita
- hawksnowlog: Sidekiq 超入門
- Sidekiqの要点まとめと動かし方 | つかびーの技術日記
- 【Rails, Sidekiq】WeightによるJobの優先度付け - Qiita
- Sidekiqのジョブを削除する [Rails] - ノンカフェインであなたにやさしい
- SidekiqでRailsに非同期処理を実装するチュートリアル - Qiita
- [Rails5.2]ActiveStorageの仕組み(図あり)と使ってみてわかったこと - Qiita
- 【Rails】Sidekiqを使用してみた。 - とーますメモ
ActiveStorage
- Active Storage の概要 - Railsガイド
- 【Rails】ActiveStorageを使ってお手軽にファイルアップロードを試す - ひよっこエンジニアの雑多な日記
- 【Rails 5.2】 Active Storageの使い方 - Qiita
- ActiveStorageの挙動を調べる | Simple is Beautiful.
- ActiveStorageにアップロードしたファイルのダウンロード - haayaaa’s diary
実装
まずは、何らかの一覧画面についてサンブルを用意していきます。
ダミーデータ準備
MySQLで簡単にランダムなテストデータを作成する方法 - Qiitaを参考にデータを作ります。
※ridgepoleを使っているので参考記事を元によしなにお願いします。
# -*- mode: ruby -*-
# vi: set ft=ruby :
create_table "items", options: "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci", force: :cascade do |t|
t.text "name"
t.text "description"
t.integer "price"
t.datetime "created_at"
end
空のレコードを作成するsqlを何回か実行、その後にランダムで数値を入れてダミーデータ作りしました。(200万以上できてしまった笑汗)
INSERT INTO items () VALUES ();
INSERT INTO items (id) SELECT 0 FROM items;
INSERT INTO items (id) SELECT 0 FROM items;
INSERT INTO items (id) SELECT 0 FROM items;
INSERT INTO items (id) SELECT 0 FROM items;
UPDATE items SET
name = CONCAT('商品', id),
description = SUBSTRING(MD5(RAND()), 1, 30),
price = CEIL(RAND() * 10000),
created_at = ADDTIME(CONCAT_WS(' ','2020-01-01' + INTERVAL RAND() * 180 DAY, '00:00:00'), SEC_TO_TIME(FLOOR(0 + (RAND() * 86401))));
次に $ rails g controller items
で各ファイルを準備して調整します。
今回はサーバサイドの実装がメインなのでフロントサイドは最低限表示させるところまでしかやらないです。
# 追記
resources :items do
collection do
get 'download'
get 'check_file'
get 'export'
end
end
class ItemsController < ApplicationController
# 一覧画面
def index
@items = Items.all
end
# ダウンロード画面
def download
# TODO: あとで実装する予定
end
# ファイルチェック
def check_file
# TODO: あとで実装する予定
end
# 出力
def export
# TODO: あとで実装する予定
end
end
class Items < ApplicationRecord
end
<%= link_to '<button type="button"> Export </button>'.html_safe, export_items_path %>
<% @items.each do |item| %>
<p><%= item.id %></p>
<p><%= item.name %></p>
<p><%= item.description %></p>
<p><%= item.price %></p>
<p><%= item.created_at %></p>
<% end %>
sidekiq
まずは、環境設定を行います。
次の設定のように/storage
配下にactive_storage
フォルダーを作成しておいてください。
# 変更
#root: <%= Rails.root.join("storage") %>
root: <%= Rails.root.join("storage/active_storage") %>
Worker
次にワーカークラスの実装です。
引数にユーザーIDを受け取るようにしています。
require 'csv'
class ItemsWorker
include Sidekiq::Worker
# 失敗時のリトライはとりあえず0
sidekiq_options queue: :development, retry: 0
def perform(user_id)
# CSV生成
bom = %w(EF BB BF).map { |e| e.hex.chr }.join
csv_data = CSV.generate(bom, :force_quotes => true) do |csv|
# ヘッダー設定
csv << ['id','name','description','price','created_at']
# CSVの中身
Items.all.each do |item|
column_values = [item.id,item.name,item.description,item.price,item.created_at]
csv << column_values
end
end
# 一度ローカルにファイル生成
filename = "#{(Time.now).strftime("%Y%m%d")}_Items.csv"
filepath = "#{Rails.root.join("storage")}/#{filename}"
# CSVファイルを生成してアップロード
File.open(filepath, "wb") { |file| file.write(csv_data) }
User.find(user_id).csv.attach(io: File.open(filepath), filename: filename, content_type: "csv")
# アップロード元のファイルを削除
File.delete(filepath) if File.exist?(filepath)
end
end
CSV
上記の内容を読んでわかる通り、一度ローカルにCSVファイルを生成してからActiveStorageに処理を渡しています。
ユーザーからファイルをアップロードさせてそのファイルを元にActiveStorageに処理させるUXならこんなことをしなくて済みますが、今回は、重たいファイル生成処理をsidekiqで非同期処理させているのでこのようになります。
CSV生成処理に関して参考にした記事は次の内容となります。
- 【簡単3ステップ】RailsでCSV出力する方法 - Qiita
- BOM付きUTF-8のCSVを作成する(Excel文字化け対策) - Qiita
- Ruby on RailsでCSV一覧出力する3つの方法 - TIS ENGINEER NOTE
- CSV#force_quotes? (Ruby 3.0.0 リファレンスマニュアル)
Controller
クライアント側の実装は次のようになります。
current_user
は、deviceを使用しているため、[Rails] devise の使い方(導入からログアウトまで) | もふもふ技術部を参考にusers/sign_up
の実装まで済ませています。
class ItemsController < ApplicationController
# 一覧画面
def index
@items = Items.all
# 作成したファイルがある場合は削除する
user = User.find(current_user.id)
user.csv.purge_later if user.csv.attached?
end
# ダウンロード画面
def download
ItemsWorker.perform_async(current_user.id)
end
# ファイルチェック
def check_file
user = User.find(current_user.id)
render :json => { :result => user.csv.attached?, :file_name => user.csv.attached? ? user.csv.filename.to_s : "" }
end
# 出力
def export
user = User.find(current_user.id)
# ファイルがなかったら最初からやり直し
return redirect_to action: 'index' unless user.csv.attached?
# ダウンロード処理
csv = user.csv.download
send_data(csv, type: user.csv.content_type, filename: user.csv.filename.to_s)
end
end
NoMethodError (undefined method `gsub' for #<ActiveStorage::Filename
ActiveStorageが提供しているファイル名を取得できるfilename
をそのままsend_data
で使用すると次のようなエラーになるので文字列に変換が必要でした。
(ActiveStorageに限った話じゃなく、使う変数の値が本当に文字列型なのか把握していないとこのようなエラーでハマってしましますよねー笑)
Model
ActiveStorageで管理するCSVをUserモデルに設定します。
# 追記
# 単一設定(※サーバの負荷を最小限にするために)
has_one_attached :csv
# # 複数設定
# has_many_attached :csv
View
最後にダウンロード画面上の処理について次のように実装します。
visibility
で表示・非表示の制御しているので仮対応です。
悪意のある人ならブラウザのデベロッパーツールから強制的に表示させることができるので変更が必要になるのでよしなに対応してください。
<%= link_to '<button type="button"> Download </button><div class="download_file_name"></div>'.html_safe, export_items_path, id: "export_items", style: "visibility:hidden;" %>
<%= image_tag 'icon_loader.gif', id: "icon_loader", style: "visibility:visible;" %>
<script type="text/javascript">
$(function() {
let export_items_ele = document.getElementById('export_items');
let export_download_file_name = document.getElementsByClassName('download_file_name');
let icon_loader_ele = document.getElementById('icon_loader');
let check_file_ajax = function() {
$.ajax({
url: "<%= root_url %>items/check_file",
type: "GET"
})
.done(function (data, textStatus, jqXHR) {
console.log(data);
if(data['result']) {
clearInterval(timer);
export_items_ele.style.visibility = 'visible';
icon_loader_ele.style.visibility = 'hidden';
export_download_file_name.innerHTML = data['file_name'];
}
})
.fail(function() {
console.log("error");
});
};
let timer = setInterval(check_file_ajax, 5000);
});
</script>
結果
まとめ
has_many_attached
に変更する場合は、attached?
やpurge_later
の挙動が変わるので上記の実装のままでは使えないので注意してください。