0
0

More than 1 year has passed since last update.

sidekiq に特定のファイルを生成する処理とそのファイルを ActiveStorage でアップロードする処理のフローをまとめる

Last updated at Posted at 2022-10-19

概要

例えば何らかの一覧画面上のデータを全てExportする機能を作った際に sidekiq でその重たい処理を実行させたい場面があるかと。
sidekiq のタスクが終わってファイルが生成できた後、ユーザーがそのファイルをダウンロードできるようにするためには ActiveStorage でファイル管理すると楽ですよね。

今回は、次の処理フローをまとめていきたいと思います。

  1. 何らかの一覧画面上のデータを全てExportするボタンをクリックしてダウンロード画面へ遷移(UIスレッド)
  2. sidekiqにその一覧データをCSVなどにファイル生成する処理をタスク化(UIスレッド・非同期)
  3. ↑この間、ダウンロード画面上はダウンロード中のインゲージUIを表示させて裏ではAjaxでファイルが生成されたのか常に確認している(UIスレッド)
  4. ファイルが生成できたらActiveStorageでlocal設定は特定のパスにファイルを移動、aws設定ならS3にファイルをアップロードする(非同期)
  5. ファイルができたことを確認したらダウンロード中のインゲージUIを止めてファイルの詳細とダウンロードボタンを表示(UIスレッド)
  6. 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

ActiveStorage

実装

まずは、何らかの一覧画面についてサンブルを用意していきます。

ダミーデータ準備

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万以上できてしまった笑汗)

空レコード作りSQL
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;
ダミーデータ作るSQL
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))));
スクリーンショット 2021-11-12 13.53.16.png

次に $ rails g controller items で各ファイルを準備して調整します。
今回はサーバサイドの実装がメインなのでフロントサイドは最低限表示させるところまでしかやらないです。

config/routes.rb
# 追記
  resources :items do
    collection do
      get 'download'
      get 'check_file'
      get 'export'
    end
  end
ruby:app/controllers/items_controller.rb
class ItemsController < ApplicationController
  # 一覧画面
  def index
    @items = Items.all
  end

  # ダウンロード画面
  def download
    # TODO: あとで実装する予定
  end

  # ファイルチェック
  def check_file
    # TODO: あとで実装する予定
  end

  # 出力
  def export
    # TODO: あとで実装する予定
  end
end

app/models/items.rb
class Items < ApplicationRecord
end

app/views/items/index.html.erb
<%= 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 %>
スクリーンショット 2021-11-12 14.38.49.png

sidekiq

まずは、環境設定を行います。
次の設定のように/storage配下にactive_storageフォルダーを作成しておいてください。

config/storage.yml
  # 変更
  #root: <%= Rails.root.join("storage") %>
  root: <%= Rails.root.join("storage/active_storage") %>

Worker

次にワーカークラスの実装です。
引数にユーザーIDを受け取るようにしています。

app/workers/items_worker.rb
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生成処理に関して参考にした記事は次の内容となります。

Controller

クライアント側の実装は次のようになります。
current_userは、deviceを使用しているため、[Rails] devise の使い方(導入からログアウトまで) | もふもふ技術部を参考にusers/sign_upの実装まで済ませています。

app/controllers/items_controller.rb
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に限った話じゃなく、使う変数の値が本当に文字列型なのか把握していないとこのようなエラーでハマってしましますよねー笑)

スクリーンショット 2021-11-18 13.48.31.png

Model

ActiveStorageで管理するCSVをUserモデルに設定します。

app/models/user.rb
  # 追記
  # 単一設定(※サーバの負荷を最小限にするために)
  has_one_attached :csv
  # # 複数設定
  # has_many_attached :csv

View

最後にダウンロード画面上の処理について次のように実装します。
visibilityで表示・非表示の制御しているので仮対応です。
悪意のある人ならブラウザのデベロッパーツールから強制的に表示させることができるので変更が必要になるのでよしなに対応してください。

app/views/items/download.html.erb
<%= 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>

結果

2021-11-18 15.21.04.gif

まとめ

has_many_attachedに変更する場合は、attached?purge_laterの挙動が変わるので上記の実装のままでは使えないので注意してください。

 

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0