Rails 5.2で、CSVファイルをバックグラウンド処理でインポート

Rails 5.2は、Active Storageを標準でサポートしています。これとActive Jobを組み合わせると、CSVファイルのインポートを手軽に実装できます。

## Motivation
CSVファイルをインポートする際、それが小さなファイルであれば、フロント側で処理を行うのがシンプルです。しかしそれが、大きなファイルや、複雑な処理を伴う場合、一旦はサーバー側にファイルを保存し、バックグラウンドでそれを処理する場合があります。

ファイルの保存に、Active Storageを使用すれば、保存先はAWS S3でも、ファイルシステムでも対応が可能です。Herokuではファイルシステムに保存されたファイルの永続化が保証されない（＝サーバーに保存したファイルは、ある日突然消えることがある）ので、AWS S3などにファイルを保存することになります。

特に日本語圏のエンジニアを悩ませるのが、Microsoft ExcelがUTF-8ファイルを扱えず、SJISからのエンコード変換が必要になる点ですが、それにも対処を行います。

## Approach

1. **（Web）アップロードされたファイルの保存** - アップロードされたファイルを受け取った際、サーバーはActive Storageを利用して、それをストレージに保存します。
2. **（Active Job）ファイルをダウンロード** -
作成するJobは、はじめにファイルをActive Storageからダウンロードし、一時的に `/tmp` 以下にファイルを保存します。
3. **（Active Job）ファイルのエンコード形式を変換** - `SJIS`で保存されたファイルを、`UTF-8`に変換します。
4. **（Active Job）DBに保存後、一時ファイルを削除**

## Procedure

### 1. 設定と、必要なGemのインストール

AWSの設定項目などは、環境変数に書き込みます。ローカルの開発環境では、[Dotenv](https://github.com/bkeepers/dotenv)を利用します。

```ruby:Gemfile
# Dotenv - 頭の方に書く
gem 'dotenv-rails', groups: [:development, :test]
```

```yml:storage.yml
amazon:
  service: S3
  access_key_id: <%= ENV['AWS_ACCESS_KEY_ID'] %>
  secret_access_key: <%= ENV['AWS_SECRET_ACCESS_KEY'] %>
  region: <%= ENV['AWS_S3_REGION'] %>
  bucket: <%= ENV['AWS_S3_BUCKET'] %>
```

```text:.env
AWS_ACCESS_KEY_ID='' #AWSアクセスキー
AWS_SECRET_ACCESS_KEY='' #AWSシークレットキー
AWS_S3_REGION='us-east-1' #AWS S3リージョン
AWS_S3_BUCKET='' #AWS S3バケット名
```

バックエンド処理は、Active Job経由で、Sidekiqを使用するので、インストールを行っておきます。

```ruby:Gemfile
# Backend job
gem 'sidekiq'
```

```ruby:production.rb
config.active_job.queue_adapter = :sidekiq
```

Gemをインストールします。

```bash
$ bundle install
```

サンプルでCSVファイル内のデータを挿入する、`User`モデルを作っておきます。

```bash
$ rails g model User name_first:string name_last:string
```

### 2. ファイルがアップロードされるごとに、レコードを保存するテーブルを作成

ファイルのアップロードごとに、DBに対応するレコードを作ります。

```bash
$ rails g model CsvUpload imported_at:datetime
$ rails db:migrate
```

```ruby:models/csv_upload.rb
class CsvUpload < ApplicationRecord
  has_one_attached :csv_file
end
```

### 3. アップロード処理

```bash
$ rails g controller CsvUploads
```

```ruby:controllers/csv_uploads_controller.rb
def new
  @csv_upload = CsvUpload.new
end

def create
  csv_upload = CsvUpload.new(csv_upload_params)

  if csv_upload.save
    ImportCsvFileJob.perform_later(csv_upload.id) #後述
    redirect_to new_csv_upload_path, notice: 'アップロードに成功しました'
  else
    redirect_to new_csv_upload_path, alert: 'アップロードに失敗しました'
  end
end

private

def csv_upload_params
  params.require(:csv_upload).permit(:csv_file)
end
```

```erb:views/csv_uploads/new.html.erb
<%= form_for @csv_upload do |f| %>
  <%= f.file_field :csv_file %>
<% end %>
```


### 4. インポートするJobの作成

まず、Generatorで`Job`を作ります。

```bash
$ rails g job ImportCsvFile
```

ここが一番の山場です。Active Storageに保存したファイルを、ローカルに読み込み、そのデータを挿入していきます。挿入完了後、`CsvUpload`の`imported_at`を更新してインポートが成功したことを保存します。ファイルの途中まではインポートが成功し、一部のユーザのみインポートが完了している、などの状態が発生すると非常に面倒なので、一連の動作は`ActiveRecord::Base.transaction`で囲い、トランザクションをかけています。

Excelのファイルフォーマットは、正確には`SJIS`ではなく`CP932`なので、`CP932`から`UTF-8`にエンコーディングの変更を行っています。

```ruby:jobs/import_csv_file_job.rb
require 'csv'

class ImportCsvFileJob < ApplicationJob
  queue_as :default

  def perform(csv_upload_id)
    csv_upload = CsvUpload.find(csv_upload_id)
    tmp_file_path = Rails.root.join('tmp', 'csv_file.csv')

    # 一時ファイル書き込み
    file_content = csv_upload.csv_file.download
    File.open(tmp_file_path, 'wb') do |file|
      file.write(file_content)
    end

    # ユーザテーブルにデータ挿入
    ActiveRecord::Base.transaction do
      CSV.foreach(tmp_file_path, encoding: 'CP932:UTF-8') do |line|
        User.create!(name_first: line[0], name_last: line[1])
        csv_upload.update!(imported_at: Time.zone.now)
      end
    end

  # 例外発生時を含め、一時ファイルは必ず削除する
  ensure
    File.delete(tmp_file_path) if File.exist?(tmp_file_path)
  end
end
```

ファイルをローカルにダウンロードする部分は、Active Storageの標準機能として、**今後実装されるようです**。Active Strageで検索を行うと、Edgeのドキュメントがヒットし、そこには[Downloading Files](https://edgeguides.rubyonrails.org/active_storage_overview.html#downloading-files)という項目がありますが、Rails 5.2では未実装のようでした。

## Possible improvements
サンプルでは触れませんでしたが、このような点は改善できます。

### ファイルアップロード時のバリデーション
フォーマットをしっかりとバリデーションするには多くの時間がかかりますが、少なくともCSV形式であることなどは、バリデーションを行うことができます。

### ファイルエンコード形式をUTF-8にも対応
`import_csv_file_job.rb` 内で`SJIS`のみをエンコード形式として想定していますが、これは[Charlock Holmes](https://github.com/brianmario/charlock_holmes)などのGemを使用して、自動的に判別することができます。

### インポート成功、失敗時に通知
本サンプルのフローでは、ユーザがCSVファイルをアップロードしたタイミングでは、インポートの成功、失敗はわかりません。そのため成功、失敗時にメールを送るような処理が考えられるでしょう。

成功の通知は、ユーザテーブルにデータ挿入するトランザクションが完了した後に行うのが良いでしょう。

失敗の通知は、例えば、`failure_count`を`CsvUpload`に作り、失敗した回数が一定回数に達したときに、通知を行うようにします。`ImportCsvFileJob#perform`内に、`rescue` clauseを作り、その中で`failure_count`をインクリメントするような処理が考えられます。Sidekiqは、[リトライ機能を標準で搭載しています](https://github.com/mperham/sidekiq/wiki/Error-Handling#automatic-job-retry)。

## Conclusion

本記事では、実際にサンプルを作りながら、Active Storage経由でCSVファイルをアップロード・ダウンロードし、エンコード形式を変えながら、バックエンド処理でデータのインポートを行う方法を解説しました。このサンプルは、できるだけ"The Rails Way"に沿う方法で実装を行いましたが、ここを起点として様々な拡張が可能です。

## References

* Active Storage Overview - https://edgeguides.rubyonrails.org/active_storage_overview.html
* Active Storage on Heroku - https://devcenter.heroku.com/articles/active-storage-on-heroku
* 本当は怖くないCP932 - https://qiita.com/kasei-san/items/cfb993786153231e5413