CSVダウンロード機能を非同期化する
こんにちは。READYFORプロダクトエンジニアの斉藤です。
主にRuby on Railsのバックエンド開発をしています。
こちらはREADYFORアドベントカレンダー2023 21日目の記事です。
概要
この記事では、ここ数ヶ月行なっていたパフォーマンス改善のひとつとして開発していた実行者管理画面における一覧画面のCSVダウンロード機能を非同期化した内容をご紹介します。
READYFORでは、今年の6月に実行者向け管理画面のリニューアルをリリースしました。
しかしながら、今年はたくさんの支援を集める実行者さんが増えてきたこともあり(とても嬉しいことではありますが)、パフォーマンス面の問題が顕在化してしまいました。
(リリースしてまだ半年も経っていないのですが(涙)
その解決策として非同期化を行なったのでその手法をご紹介します。
実行者管理画面では、プロジェクトの支援者管理、支援申し込み管理、リターン管理などの機能を提供しています。
ここでは、その中で支援者一覧画面を例にして説明します。
今回解決する課題
実行者管理画面におけるパフォーマンス問題の主な原因として、DBの構造が古いままであることでした。
しかしながら、READYFORは2011年にサービス開始しており10年以上続いているのですが、10年以上使われてきた状態から再設計して改善するには多くの時間が必要でした。
そこで、以下のフェーズに分けることにしました。
- すべての実行者が必要な機能を利用できる状態にする
- 実用に耐えうる状態にする
- ストレスを感じず利用できる状態にする
今回はこの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のエンジニアブログです。技術情報を中心に様々なテーマで発信していきます。 ( Zenn: zenn.dev/p/readyfor_blog / Hatena: tech.readyfor.jp/ )
Discussion