元々はFormオブジェクトでインポートするだけだったんですが、ファイルサイズが大きすぎてHerokuのタイムアウトを食らうケースが現れたのでバックグラウンドに回しました。
その際、ファイルActiveJobに渡す方法を考えなければなりません。ファイルのオブジェクトの形では当然渡せないし、かと言ってローカルにファイルを置いてそのPathを渡す形にしたとしても、dynoが複数ある場合には参照できなくなってしまいます。
正攻法はどこか外部のストレージに一時的に配置すること、となります。今回対象だったアプリケーションではファイルのアップロード先としてS3を利用していましたので、ファイルインポート機能としてもS3を利用するのが良さそうです。
Formオブジェクト→モデルへ
もともとFormオブジェクトとしてControllerとViewだけで利用していたクラスをやめて、ActiveRecordにしてDBに保存させることにしました。
変更前のコード
概要程度に書くとこんな感じ。
1# Formオブジェクト
2class Importer
3 include ActiveModel::Model
4 attr_accessor :file
5
6 validates :file, presence: true
7
8 def import
9 ActiveRecord::Base.transaction do
10 open(file.path, 'r:cp932:utf-8', undef: :replace) do |f|
11 # インポート処理
12 end
13 end
14 end
15end
16
17# Controller
18def create
19 @importer = Importer.new(file: params[:importer][:file])
20 if @importer.valid?
21 @importer.import
22 redirect_to root_path
23 else
24 render :new
25 end
26end
変更後のコード
1class Importer < ApplicationRecord
2 validates :file, presence: true
3
4 # carrier_waveでS3へ
5 mount_uploader :file, ImportFileUploader
6
7 def import
8 transaction do
9 # 実はこれでは動かない(後述)
10 open(file.path, 'r:cp932:utf-8', undef: :replace) do |f|
11 # インポート処理
12 end
13 end
14 end
15end
16
17# Controller
18def create
19 @importer = Importer.new(file: params[:importer][:file])
20 if @importer.save
21 @importer.delay.import # delayed_jobで処理
22 redirect_to root_path, notice: '受け付けたよ'
23 else
24 render :new
25 end
26end
バックグラウンド処理としては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として保存し、ローカルファイルとすることでローカルと本番との差異をできるだけ減らすという作戦にでました。
1def file_path
2 if Rails.env.production?
3 @temp_file = Tempfile.new
4 @temp_file.binmode
5 open(file.url) do |file|
6 @temp_file.write(file.read)
7 end
8 @temp_file.rewind
9 @temp_file.path
10 else
11 file.path
12 end
13end
14
15def import
16 transaction do
17 # 実はこれでは動かない(後述)
18 open(file_path, 'r:cp932:utf-8', undef: :replace) do |f|
19 # インポート処理
20 end
21 end
22end
importの部分はファイルパスのみの変更とし、パスを得るところに分岐を入れました。open(url)は一定サイズを超えるとStringIOが戻ってくるのですが、この際Tempfileにしたほうがコードがシンプルになるかなと考えTempfileを利用しています。
こちらが大変参考になりました。
後処理
インポート処理の最後にTempfileの削除と自身の削除を実施します。インポート履歴を残したい場合は別ですが、一時的に使うのであれば削除しちゃっても問題ないかなと思っています。
1def import
2 transaction do
3 open(...)
4 end
5ensure
6 @tempfile.close if @tempfile
7 destroy
8end
おわり
重たいインポート処理をバックグラウンドにするときに一例として紹介しました。
今回Tempfileを用いていますが、きれいに処理できるならTempfileを使わないで、readしたものをそのまま使ったほうがよさそう。
