Edited at

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を利用します。


Gemfile

# Dotenv - 頭の方に書く

gem 'dotenv-rails', groups: [:development, :test]


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'] %>


.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を使用するので、インストールを行っておきます。


Gemfile

# Backend job

gem 'sidekiq'


production.rb

config.active_job.queue_adapter = :sidekiq


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

$ bundle install

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

$ rails g model User name_first:string name_last:string


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

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

$ rails g model CsvUpload imported_at:datetime

$ rails db:migrate


models/csv_upload.rb

class CsvUpload < ApplicationRecord

has_one_attached :csv_file
end


3. アップロード処理

$ rails g controller CsvUploads


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



views/csv_uploads/new.html.erb

<%= form_for @csv_upload do |f| %>

<%= f.file_field :csv_file %>
<% end %>


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

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

$ rails g job ImportCsvFile

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

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


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 Storegeの標準機能として、今後実装されるようです。Active Strageで検索を行うと、Edgeのドキュメントがヒットし、そこにはDownloading Filesという項目がありますが、Rails 5.2では未実装のようでした。


Possible improvements

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


ファイルアップロード時のバリデーション

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


ファイルエンコード形式をUTF-8にも対応

import_csv_file_job.rb 内でSJISのみをエンコード形式として想定していますが、これはCharlock HolmesなどのGemを使用して、自動的に判別することができます。


インポート成功、失敗時に通知

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

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

失敗の通知は、例えば、failure_countCsvUploadに作り、失敗した回数が一定回数に達したときに、通知を行うようにします。ImportCsvFileJob#perform内に、rescue clauseを作り、その中でfailure_countをインクリメントするような処理が考えられます。Sidekiqは、リトライ機能を標準で搭載しています


Conclusion

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


References