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

## 概要

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

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

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

### 注意

これから解説する内容は、あくまでサンプルのものになります。
参考にされる際は、自己責任でご対応のほどよろしくお願いいたします。


### 課題

sidekiqとActiveStorageはそんなに難しくないんですが、問題はAjaxで常にファイルができたか確認している処理をどうやって実装するのか。
ググったところ[ruby on rails - How to perform a Sidekiq callback when a group of workers are complete - Stack Overflow](https://stackoverflow.com/questions/25205339/how-to-perform-a-sidekiq-callback-when-a-group-of-workers-are-complete)を見つけて、公式Wikiの[Batches · mperham/sidekiq Wiki](https://github.com/mperham/sidekiq/wiki/Batches)でまとめられている方法を使うことになりそう。


## 環境

- ruby 2.6.6
- rails 6.1.3

今回、ajaxを使う場面があるため、[Rails6でのjQuery導入方法 - Qiita](https://qiita.com/tatsuhiko-nakayama/items/b2f0c77e794ca8c9bd74)を参考に設定を済ませておく必要があります。
また、参考記事は次の内容となります。
sidekiqとActiveStorageの初歩的な解説や設定は全て割愛するので前もって把握してから参考にしてくださいmm

### sidekiq

- [Railsで非同期処理を行える「Sidekiq」 - Qiita](https://qiita.com/yumiyon/items/6835d90e621e73268021)
- [hawksnowlog: Sidekiq 超入門](https://hawksnowlog.blogspot.com/2018/04/getting-started-sidekiq.html)
- [Sidekiqの要点まとめと動かし方 | つかびーの技術日記](http://tech-blog.tsukaby.com/archives/1519)
- [【Rails, Sidekiq】WeightによるJobの優先度付け - Qiita](https://qiita.com/Ushinji/items/c7d82ddc5bedb15cefb4)
- [Sidekiqのジョブを削除する [Rails] - ノンカフェインであなたにやさしい](https://akinov.hatenablog.com/entry/2018/05/20/134326)
- [SidekiqでRailsに非同期処理を実装するチュートリアル - Qiita](https://qiita.com/tachibanayu24/items/c97afa1a66217cdd064e)
- [[Rails5.2]ActiveStorageの仕組み（図あり）と使ってみてわかったこと - Qiita](https://qiita.com/eightfoursix/items/a47ce1bd945582f5d808)
- [【Rails】Sidekiqを使用してみた。 - とーますメモ](https://thoames.hatenadiary.jp/entry/2018/03/23/020616)


### ActiveStorage

- [Active Storage の概要 - Railsガイド](https://railsguides.jp/active_storage_overview.html)
- [【Rails】ActiveStorageを使ってお手軽にファイルアップロードを試す - ひよっこエンジニアの雑多な日記](https://kimuraysp.hatenablog.com/entry/2018/04/21/002011)
- [【Rails 5.2】 Active Storageの使い方 - Qiita](https://qiita.com/hmmrjn/items/7cc5e5348755c517458a)
- [ActiveStorageの挙動を調べる | Simple is Beautiful.](https://blog.kozakana.net/2018/03/try_active_storage/)
- [ActiveStorageにアップロードしたファイルのダウンロード - haayaaa’s diary](https://haayaaa.hatenablog.com/entry/2019/02/22/225426)

## 実装

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

### ダミーデータ準備

[MySQLで簡単にランダムなテストデータを作成する方法 - Qiita](https://qiita.com/tayasu/items/c5ddfc481d6b7cd8866d)を参考にデータを作ります。
※ridgepoleを使っているので参考記事を元によしなにお願いします。

```yml
# -*- 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:空レコード作り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:ダミーデータ作る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))));
```

<img width="500" alt="スクリーンショット 2021-11-12 13.53.16.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/82601/e6677d43-4181-9652-de10-a19d61008368.png">

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

```ruby: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

```

```ruby:app/models/items.rb
class Items < ApplicationRecord
end

```

```ruby: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 %>
```

<img width="500" alt="スクリーンショット 2021-11-12 14.38.49.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/82601/c6d70057-d3fd-8262-757d-188699a4c58f.png">



### sidekiq

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

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

#### Worker

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

```ruby: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生成処理に関して参考にした記事は次の内容となります。

- [【簡単3ステップ】RailsでCSV出力する方法 - Qiita](https://qiita.com/ngron/items/60915083cb2bc3622c79)
- [BOM付きUTF-8のCSVを作成する（Excel文字化け対策） - Qiita](https://qiita.com/necojackarc/items/5e865a4aa039fdd3f2c0)
- [Ruby on RailsでCSV一覧出力する３つの方法 - TIS ENGINEER NOTE](https://tisnote.com/ruby-on-rails-csv/)
- [CSV#force_quotes? (Ruby 3.0.0 リファレンスマニュアル)](https://docs.ruby-lang.org/ja/latest/method/CSV/i/force_quotes=3f.html)

### Controller

クライアント側の実装は次のようになります。
`current_user`は、deviceを使用しているため、[[Rails] devise の使い方（導入からログアウトまで） ｜ もふもふ技術部](https://tech.mof-mof.co.jp/blog/devise-introduction/)を参考に`users/sign_up`の実装まで済ませています。

```ruby: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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/82601/e0029fc8-ea32-0479-5a6d-96b4aa31d4ac.png)



### Model

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

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

### View

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

```html: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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/82601/f3ef0eee-0a4a-31e4-e79c-208af448dd2d.gif)


## まとめ

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