元々はFormオブジェクトでインポートするだけだったんですが、ファイルサイズが大きすぎてHerokuのタイムアウトを食らうケースが現れたのでバックグラウンドに回しました。
その際、ファイルActiveJobに渡す方法を考えなければなりません。ファイルのオブジェクトの形では当然渡せないし、かと言ってローカルにファイルを置いてそのPathを渡す形にしたとしても、dynoが複数ある場合には参照できなくなってしまいます。
正攻法はどこか外部のストレージに一時的に配置すること、となります。今回対象だったアプリケーションではファイルのアップロード先としてS3を利用していましたので、ファイルインポート機能としてもS3を利用するのが良さそうです。
Formオブジェクト→モデルへ
もともとFormオブジェクトとしてControllerとViewだけで利用していたクラスをやめて、ActiveRecordにしてDBに保存させることにしました。
変更前のコード
概要程度に書くとこんな感じ。
# Formオブジェクト
class Importer
include ActiveModel::Model
attr_accessor :file
validates :file, presence: true
def import
ActiveRecord::Base.transaction do
open(file.path, 'r:cp932:utf-8', undef: :replace) do |f|
# インポート処理
end
end
end
end
# Controller
def create
@importer = Importer.new(file: params[:importer][:file])
if @importer.valid?
@importer.import
redirect_to root_path
else
render :new
end
end
変更後のコード
class Importer < ApplicationRecord
validates :file, presence: true
# carrier_waveでS3へ
mount_uploader :file, ImportFileUploader
def import
transaction do
# 実はこれでは動かない(後述)
open(file.path, 'r:cp932:utf-8', undef: :replace) do |f|
# インポート処理
end
end
end
end
# Controller
def create
@importer = Importer.new(file: params[:importer][:file])
if @importer.save
@importer.delay.import # delayed_jobで処理
redirect_to root_path, notice: '受け付けたよ'
else
render :new
end
end
バックグラウンド処理としてはdelayed_jobを使っている想定です。Jobを作ってperform_later
でも良いですね。
インポート処理でハマる
さっきさらっと流したのですが、open(file.path, ...)
のところを変えないと動きません。そもそもfile
はS3上のものを指す想定なので、file.path
だけわかってもファイルを特定できません。そのためS3から読み出すようなコードに変える必要があります。
例えばopen
は受け取る引数がローカルのパスなのかURLなのかで内部の挙動が異なります。URLの場合はopenに渡しているundef: :replace
のオプションを受け付けられないため、やはり実装を変えざるをえませんでした。
一方で、CarrierWaveのよくある実装だと、ローカルではファイルとしてアップロードさせ、本番環境ではS3を使うみたなパターンが頻出かと思います。
ローカルに置いてあるファイルとURL指定とではopen
への渡し方が異なるため、development
かそうでないかでファイルの読み方を変える必要がでてきました。
(もちろん開発時からS3を使えるようにするのも手ですが、利便性のためできれば残しておきたかったのです)
また厄介なことにインポート用のファイルの文字コードがcp932であるため、何も考えずにopenしてreadしようとするとエンコーディング周りのエラーとなります。
頑張ってreadしようとして少々ハマり過ぎたため、URLにあるファイルを一時的にTempfileとして保存し、ローカルファイルとすることでローカルと本番との差異をできるだけ減らすという作戦にでました。
def file_path
if Rails.env.production?
@temp_file = Tempfile.new
@temp_file.binmode
open(file.url) do |file|
@temp_file.write(file.read)
end
@temp_file.rewind
@temp_file.path
else
file.path
end
end
def import
transaction do
# 実はこれでは動かない(後述)
open(file_path, 'r:cp932:utf-8', undef: :replace) do |f|
# インポート処理
end
end
end
import
の部分はファイルパスのみの変更とし、パスを得るところに分岐を入れました。open(url)
は一定サイズを超えるとStringIO
が戻ってくるのですが、この際Tempfileにしたほうがコードがシンプルになるかなと考えTempfileを利用しています。
こちらが大変参考になりました。
後処理
インポート処理の最後にTempfileの削除と自身の削除を実施します。インポート履歴を残したい場合は別ですが、一時的に使うのであれば削除しちゃっても問題ないかなと思っています。
def import
transaction do
open(...)
end
ensure
@tempfile.close if @tempfile
destroy
end
おわり
重たいインポート処理をバックグラウンドにするときに一例として紹介しました。
今回Tempfileを用いていますが、きれいに処理できるならTempfileを使わないで、readしたものをそのまま使ったほうがよさそう。