元々は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を利用しています。

こちらが大変参考になりました。

Qiita
# Ruby open-uri の open の戻り値 ## Ruby open-uri ライブラリの open メソッド open メソッドは引数に URL を取り、通常呼び出し or ブロック呼び出しのいずれかのスタイルを取り...

後処理

インポート処理の最後にTempfileの削除と自身の削除を実施します。インポート履歴を残したい場合は別ですが、一時的に使うのであれば削除しちゃっても問題ないかなと思っています。

def import
  transaction do
    open(...)
  end
ensure
  @tempfile.close if @tempfile
  destroy
end

おわり

重たいインポート処理をバックグラウンドにするときに一例として紹介しました。

今回Tempfileを用いていますが、きれいに処理できるならTempfileを使わないで、readしたものをそのまま使ったほうがよさそう。