データのCSV形式エクスポート機能はあらゆるアプリケーションで求められる事でしょう。Controllerで直接CSVを作りそれを送るのが素朴な実装で、それで問題なく動作する場合も多いでしょう。

class Admin::UsersController < ApplicationController
  def index
    @users = User.search(conditions)

    respond_to.csv |format|
      format.html
      format.csv { send_file(generate_csv(@users)) }
    end
  end
end

ただしこの実装は潜在的に以下の問題をはらんでいます。

  • Web serverのworkerを長い時間占有する。
  • 処理時間が一定以上長くなるとタイムアウトする。

これらの問題に対処するためにはCSV処理をbackground workerに任せるのが良いでしょう。

class Admin::UserExportJob
  def perform(conditions)
    csv = generate_csv(User.search(conditions))
    bucket.create_file(csv)
  end

  private

  def bucket
    @bucket = storage.bucket("my-bucket")
  end

  def storage
    @storage ||= Google::Cloud::Storage.new(
      project_id: "my-project",
      credentials: "/path/to/keyfile.json"
    )
  end
end

一方で上記の実装にもいくつかの問題があります。

  • GCSとの通信を直書きしているのでテストが書き難い。
  • Active Storageを使っている場合にbucketの設定が重複している。

もちろんWebMockなどを使えばこれもテスト可能でしょう。1ただしレールに乗っている感がありません。Active Storageを使ってもっとレールに乗れないでしょうか。

そこで思い切って「CSVエクスポート」自体をテーブルとして表現してみましょう。

class Admin::UserExport < ApplicationRecord
  belongs_to :exported_by, class_name: "User"

  has_one_attached :csv

  store_accessor :conditions

  after_create -> { LaterJob.perform_later(self, :generate_and_notify) }

  def generate_and_notify
    csv.attach(generate_and_csv(User.search(conditions)))
    UserMailer.exported(exported_by, csv).deliver_later
  end
end

ここでの味噌はafter_createで作成後にCSVを生成するjobをenqueueする部分です。CSVの生成自体はモデルに定義されていますが、LaterJobを呼び出すことにより実際のはbackground workerによって行われます。なおafter_createではafter_create_commitを使うべきと言う意見もあります。2

LaterJobは与えられたobjectのmethodを呼び出すだけの非常に単純なjobです。

class LaterJob < ApplicationJob
  def perform(object, method) = object.public_send(method)
end

またAdmin::UserExportを作成するcontrollerを考えてみると、scaffoldで生成されたCRUDのコードと同一と言って差し支えないでしょう。

class Admin::UsersController < ApplicationController
  def create
    @export = UserExport.new(export_params)
    if @export.save
      redirect_to @export, notice: "エクスポートを開始しました"
    else
      render :new, status: :unprocessable_entity
    end
  end

  private

  def export_params
    params.
    require(:user_export).
    permit(conditions: {}).
    merge(exported_by: current_user)
end

最後にモデル部分のテストが容易に書けることを示します。Active Storageを使っているので、テスト時は自動でGCSからテスト可能なbackendに切り替えが行われます。

class Admin::UserExportTest < ActiveSupport::TestCase
  test "作成後にjobをenqueueする" do
    export = Admin::UserExport.new(...)

    assert_enqueued_with job: LaterJob, args: [export, :generate_and_notify] do
      export.save!
    end
  end

  test "csvを作成する" do
    export = Admin::UserExport.create(...)

    assert_changes -> { export.csv.attached? }, from: false, to: true do
      export.generate_and_notify
    end
  end
end

まとめると以下の目標を達成する事が出来ました。

  • HTTP requestがタイムアウトしない。
  • HTTP serverのworkerを長時間占有しない。
  • テストが容易に書ける。

RailsはActive RecordでHTTPリクエストとDBを串刺しにすることで、高い生産性を生み出しています。今回はそれを最大限利用するために、「CSVエクスポート」という動作自体をテーブルとして表現しました。これによりRailsが敷設したレールに乗ることが出来ました。

蛇足ですがこのパータンだとActive StorageのattachableとしてTempfileを渡したくなります。Railsにその旨のpull requestを送ってみました(この記事を書いている時点では取り込まれていません)。3