元々は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したものをそのまま使ったほうがよさそう。