📄

CSVダウンロード機能を非同期化する

2023/12/22に公開

こんにちは。READYFORプロダクトエンジニアの斉藤です。
主にRuby on Railsのバックエンド開発をしています。

こちらはREADYFORアドベントカレンダー2023 21日目の記事です。
https://qiita.com/advent-calendar/2023/readyfor

概要

この記事では、ここ数ヶ月行なっていたパフォーマンス改善のひとつとして開発していた実行者管理画面における一覧画面のCSVダウンロード機能を非同期化した内容をご紹介します。

READYFORでは、今年の6月に実行者向け管理画面のリニューアルをリリースしました。
https://corp.readyfor.jp/news/20230606

しかしながら、今年はたくさんの支援を集める実行者さんが増えてきたこともあり(とても嬉しいことではありますが)、パフォーマンス面の問題が顕在化してしまいました。
(リリースしてまだ半年も経っていないのですが(涙)

その解決策として非同期化を行なったのでその手法をご紹介します。

実行者管理画面では、プロジェクトの支援者管理、支援申し込み管理、リターン管理などの機能を提供しています。
ここでは、その中で支援者一覧画面を例にして説明します。

今回解決する課題

実行者管理画面におけるパフォーマンス問題の主な原因として、DBの構造が古いままであることでした。
しかしながら、READYFORは2011年にサービス開始しており10年以上続いているのですが、10年以上使われてきた状態から再設計して改善するには多くの時間が必要でした。

そこで、以下のフェーズに分けることにしました。

  1. すべての実行者が必要な機能を利用できる状態にする
  2. 実用に耐えうる状態にする
  3. ストレスを感じず利用できる状態にする

今回はこの1つめの「すべての実行者が必要な機能を利用できる状態にする」ことを目的とします。
支援者一覧画面のCSVダウンロード機能でいうと、CSVをダウンロードできること。

当時は、支援者が最も多い数名の実行者の場合にダウンロードが完了する前にタイムアウトしてしまう状態となっていました。
まずは、時間がかかったとしてもダウンロードできる状態にすることをゴールとします。

非同期化を選択した背景

以下の理由により非同期化を選択しました。

  • 短い期間で対応する必要があった
  • クエリ改善では解決されなかった
  • 今後より多くの支援を集める実行者が増えることが予想される
  • 同じ仕組みで複数の管理画面の改善が出来る

API

非同期処理の場合、CSV生成とCSVダウンロードのAPIを分ける必要があります。
また、生成中の状況を知る必要があります。

以下は同期処理と非同期処理のAPIの違いです。

同期処理

  • CSVダウンロードAPI:
    • CSVを生成してダウンロードするためのAPI

非同期処理

  • CSV生成API:
    • CSVを生成するためのAPI
  • 生成状況API:
    • CSV生成の生成状況と進捗率を返すAPI
    • ステータス
      • 生成中
      • 生成済み
      • 生成失敗
  • 生成済みCSVダウンロードAPI:
    • 生成されたCSVファイルをダウンロードするAPI

シーケンス

生成完了パターン

生成失敗パターン

ER図

モデル

生成済みCSVを扱うクラス

今回は支援者情報のCSVですが、支援申し込み情報やリターン情報などのCSVも同じ仕組みで対応できるようにSTIにしています。
生成したCSVはActiveStorageでS3に保存してダウンロード時に取得します。

class AsyncGeneratedDocument < ApplicationRecord
  belongs_to :user
  has_one :async_document_generation, dependent: :destroy
end
class ContributorsCsv < AsyncGeneratedDocument
  has_one_attached :file, service: :contributors_csv
end

非同期生成状況を管理するクラス

AASMでステータス管理しています。
各イベントの発生時にステータスと発生日時を更新するだけのシンプルな作りです。
進捗率は「完了件数 / 総件数」 で計算しています。

class AsyncDocumentGeneration < ApplicationRecord
  include AASM
  belongs_to :async_generated_document
  enum status: { initial: 0, processing: 1, generated: 2, failed: 3 }, _prefix: true

  aasm column: :status do
    state :initial, default: true
    state :processing
    state :generated
    state :failed

    event :start_generation do
      transitions from: :initial, to: :processing do
        after do |time = Time.zone.now|
          self.job_started_at = time
        end
      end

      # Jobがリトライした時用
      transitions from: :processing, to: :processing
      transitions from: :failed, to: :processing
    end

    event :end_generation do
      transitions from: :processing, to: :generated do
        after do |time = Time.zone.now|
          self.job_ended_at = time
        end
      end
    end

    event :fail_generation do
      transitions to: :failed do
        after do |time = Time.zone.now|
          self.job_failed_at = time
        end
      end
    end
  end
  
  # 生成の進捗を返す
  # 生成準備ができた状態を 1/n として含めて計算する
  def progress_rate
    ((1 + generated_count) * 100).fdiv(1 + target_count).floor
  end
end

CSV生成API

CSV生成APIで実行する非同期ジョブです。

ここでやることは

  • 生成中ステータスに変更
  • CSV生成
    • 対象の支援者リストを取得
    • 総件数をtarget_countに保存
    • 順番にCSVに書き込んでいき、件数を都度generated_countに更新
  • 生成が完了したら完了ステータスに変更
  • 生成に失敗したら失敗ステータスに変更
class CreateContributorsCsvJob
  include Sidekiq::Job
  sidekiq_options queue: "contributors_csv"

  def perform(csv_generation_id, user_id, contributor_ids)
    csv_generation = AsyncDocumentGeneration.find(csv_generation_id)
    return if csv_generation.status_generated?

    csv_generation.start_generation!

    # CSVを生成するサービスクラス
    # 1000件ずつデータ生成しファイルに書き込んでいる
    # 書き込んだ件数をgenerated_countに更新している
    ContributorsCsv::GenerateFileService.new(csv_generation, user_id, contributor_ids).call

    csv_generation.end_generation! 
  rescue StandardError => e
    csv_generation.fail_generation!
    raise e
  end
end

生成状況API

ここでやることは

  • 生成状況と進捗率を返す

クライアント側ではこのレスポンスのstatusが完了になるまで一定間隔でこのAPIを呼び続けます。
statusが生成中の間はレスポンスで受け取ったprogressRateを進捗率としてスナックバーに表示しています。

class Api::V2::ContributorsCsv::GenerationsController < ApiController
  def show
    generation = current_user.contributors_csvs.last
    render json { status: generation.status, progressRate: generation.progress_rate }
  end
end

生成済みCSVダウンロードAPI

ここでやることは

  • 生成済みCSVを取得してダウンロードする

生成状況APIのレスポンスのstatusがgeneratedになったら、クライアント側でこのAPIにリクエストしCSVがダウンロードされます。

class Api::V2::ContributorsCsvsController < ApiController
  def show
    csv = current_user.contributors_csvs.with_attached_file.generated.last!

    # ダウンロード日時を記録する
    csv.downloaded

    response.headers["Access-Control-Expose-Headers"] = "Content-Disposition"
    send_data csv.file.download,
              filename: csv.generated_file_name,
              type: csv.generated_file_content_type
  end
end

以上でCSVダウンロード非同期化を実現しました。

さいごに

機能開発と比べるとパフォーマンス改善はどうしても後回しになってしまいがちだと思います。
ですが、着手が遅くなればなるほど対応するのは簡単にはいかなくなります。
日頃からしっかりと向き合い計画的に進めていく重要性を改めて感じたここ数ヶ月でした。

この記事が同じような課題を抱える方々の参考になれば幸いです。
ここまで読んでいただきありがとうございました!

READYFORテックブログ

Discussion