Railsのcsvダウンロードで直面する数々の問題を解決したらgemができた 〜csb gemの紹介〜


はじめに: csvダウンロードの処理をちゃんと書くのって意外と面倒じゃないですか?

例えば「rails csv ダウンロード」で検索すると以下のようなサンプルコードがよく出てきます。

posts_controller.rb
class PostsController < ApplicationController
  def index
    @posts = Post.all
  end
end
index.csv.ruby
require 'csv'

CSV.generate do |csv|
  column_names = %w[投稿日 カテゴリ タイトル 本文]
  csv << column_names
  @posts.each do |post|
    column_values = [
      l(post.created_at.to_date),
      post.category.name,
      post.title,
      post.content,
    ]
    csv << column_values
  end
end

しかしこのサンプルコードでは次のような問題が解決出来ていません。

  • Excelで開くと文字化けする
  • レコード件数が大量にあった場合にメモリエラーやタイムアウトエラーが発生する可能性がある
  • 列名と値の定義が離れているために、カラム数が増えてくると可読性が悪く保守性が低くなりがち
  • CSV出力の条件が複雑化した場合にテストが書きにくい(system testで頑張るしかない)

この問題を解決するために、csbというgemを作りました。
このgemは弊社ソニックガーデンの複数のプロジェクトで1年以上、本番利用されてきた実績があります。

今回の記事ではこのgemの概要と使い方を紹介します。

何が出来るの?

  • BOM付きUTF-8で出力することで、Excelでも文字化けせずに開ける
  • 数十万件以上といった大量データの場合でも、ストリーミングダウンロードにすることでメモリエラーやタイムアウトエラーといったよく起こりがちなトラブルを防げる
  • 可読性高くメンテナブルにエクスポート用の処理を書ける
  • エクスポート用の処理を切り出しやすくなりテスタビリティが上がる

使い方

インストール

Gemfile
gem 'csb'
$ bundle install

基本

基本的には以下のようにコントローラでレコードをロードして、ビューにcsvの定義を書くといった流れになります。
これだけで自動的にストリーミングダウンロードとなります。

ダウンロード元のビュー

index.html.haml
= link_to 'CSVダウンロード', posts_path(format: :csv)

CSV出力用コントローラ

posts_controller.rb
def index
  @posts = Post.preload(:category)
end

CSV出力用ビュー

index.csv.csb
# csv.itemsにレコードを入れます(each可能であればActiveRecord::Relation以外のオブジェクトを入れることも出来ます)
csv.items = @posts

# 以下で各カラムを定義しています
csv.cols.add('投稿日') { |post| l(post.created_at.to_date) } # ブロックの場合引数にはレコードが渡ってきます
csv.cols.add('カテゴリー') { |post| post.category.name }
csv.cols.add('タイトル', :title) # 単純なメソッド呼び出しだけの場合はシンボルで書けます
csv.cols.add('本文', :content)

応用

大量データの場合

ただし大量データの場合、上記の書き方だと全レコードのロードが完了してからストリーミング開始となってしまうため、メモリエラーやタイムアウトエラーが発生しやすくなりストリーミングのメリットを活かせません。
大量データの場合は以下のようにcsv.items = @posts.find_eachと書いて、csv.itemsEnumeratorを渡すことでストリーミングのメリットを最大限に活かすことが可能となります。

index.csv.csb
csv.items = @posts.find_each
csv.cols.add('投稿日') { |post| l(post.created_at.to_date) }
csv.cols.add('カテゴリー') { |post| post.category.name }
csv.cols.add('タイトル', :title)
csv.cols.add('本文', :content)

大量データかつDecoratorと組み合わせる場合

大量データかつ、CSV出力用にデータを加工したいけど繰り返し同じ処理を書きたくないといったケースでは、専用のDecoratorクラスを用意することで以下のように書けます。(draperの例)

index.csv.csb
csv.items = @posts.find_each.lazy.map(&:decorate)
csv.cols.add('投稿日') { |post| l(post.created_at.to_date) }
csv.cols.add('カテゴリー') { |post| post.category.name }
csv.cols.add('タイトル', :decorated_title)
csv.cols.add('本文', :content)

特殊な出力

また外部システムとのcsv連携で時々発生する、以下のようなケースにも対応しています。

  • 固定文字列を出力したい
  • 中身は空でいいけど列は必須
  • 同名のカラムが複数必要
index.csv.csb
csv.items = @posts
csv.cols.add('固定文字列', 'Dummy') # 第二引数をシンボルではなく文字列にするとそのまま文字列として出力されます。
csv.cols.add('空文字列') # 第二引数を省略すると空文字となります。`csv.cols.add('空文字列', '')` と書くのと同じ。
csv.cols.add('複数カラム', :col1) # 同名カラムを複数定義することも可能です。(定義順に出力されます)
csv.cols.add('複数カラム', :col2)

Excelで文字化けせずに開きたい

以下のように出力文字コードをBOM付きUTF-8に設定することで、最近のExcelであればWindowsでもMacでも文字化けせずに開けるようになります。

config/initializers/csb.rb
Csb.configure do |config|
  config.utf8_bom = true # default: false
end

ダウンロードするcsvファイル名を設定したい

古いブラウザの考慮が不要で、必ずリンク経由でのダウンロードであればaタグのdownload属性を利用するのがお手軽ですが(参考リンク)、view側でも以下のように指定可能となっています。(Rails6以降であれば日本語も問題ないはずです)

index.csv.csb
csv.items = @posts
csv.filename = "posts_#{Time.current.to_i}.csv"

# ...

非同期でcsv生成したい

ストリーミングダウンロードにすることでメモリエラーやタイムアウトエラーといったよく起こりがちなトラブルは防げますが、サーバ負荷を考慮してあえて非同期で処理したいといったケースもあるかと思います。
そのような場合も以下のようにCsb::Builderクラスを利用することで、同様のロジックでcsv生成処理を記述することが出来ます。

batch.rb
csv = Csb::Builder.new(items: posts)
csv.cols.add('投稿日') { |post| l(post.created_at.to_date) }
csv.cols.add('カテゴリー') { |post| post.category.name }
csv.cols.add('タイトル', :decorated_title)
csv.cols.add('本文', :content)
IO.write('posts.csv', csv.build) # csv.buildでcsv文字列が生成されます

テスト

複雑な条件によるcsv出力の場合は当然テストも必要となりますが、system testで条件別に検証というのは面倒ですよね。
そんな場合はcsv出力の定義だけをモデル等に書くことで、テスタビリティを上げることが出来ます。

post.rb
def self.csb_cols
  Csb::Cols.new do |cols|
    cols.add('タイトル', :title)
    cols.add('本文', :content)
    cols.add('画像') { |post| post.image&.url }
  end
end
index.csv.csb
csv.items = @posts
csv.cols = Post.csb_cols
post_spec.rb
# requireすることでcol_pairs, as_tableメソッドが追加で定義されます
require 'csb/testing'

# 行単位でのテスト
context '画像が添付されている場合' do
  let(:image) { build(:image, url: 'https://example.test/example.jpg') }
  let(:post) { build(:post, title: 'Testing', content: 'hogehoge', image: image) }

  it '画像カラムにURLが出力されること' do
    expect(Post.csb_cols.col_pairs(post)).to eq [
      ['タイトル', 'Testing'],
      ['本文', 'hogehoge'],
      ['画像', 'https://example.test/example.jpg'],
    ]
  end
end

context '画像が添付されていない場合' do
  let(:post) { build(:post, title: 'Testing', content: 'hogehoge', image: nil) }

  it '画像カラムが空となること' do
    expect(Post.csb_cols.col_pairs(post)).to eq [
      ['タイトル', 'Testing'],
      ['本文', 'hogehoge'],
      ['画像', ''],
    ]
  end
end

# csv全体でのテスト
describe '.csb_cols' do
  let(:image) { build(:image, url: 'https://example.test/example.jpg') }
  let(:items) do
    [
      build(:post, title: 'Testing1', content: 'hogehoge', image: image),
      build(:post, title: 'Testing2', content: 'fugafuga', image: nil),
    ]
  end

  it '画像有無に関係なくcsv出力されること' do
    expect(Post.csb_cols.as_table(items)).to eq [
      ['タイトル', '本文', '画像'],
      ['Testing1', 'hogehoge', 'https://example.test/example.jpg'],
      ['Testing2', 'fugafuga', nil]
    ]
  end
end

その他

オプション例

config/initializers/csb.rb
Csb.configure do |config|
  # デフォルトはfalseとなっているので注意してください。
  config.utf8_bom = true # default: false

  # 基本はデフォルトのままtrueでいいかと思いますが、大量データ配信が一切不要の場合はfalseとするほうがviewで発生するエラーに気付きやすいです。
  config.streaming = false # default: true

  # 何も設定しない場合、Bugsnag等のエラー検知ツールを入れていたとしてもストリーミング配信中のエラーは通知されないので注意が必要です。
  config.after_streaming_error = ->(error) do # default: nil
    Rails.logger.error(error)
    Bugsnag.notify(error)
  end
end

gemの名前の由来

csv builderの略です。
gemの名前とviewの拡張子を揃えようとすると短い名前の方が嬉しいので、語呂が良くて(日本語での)発音も似ているcsbとなりました。

リポジトリ

https://github.com/aki77/csb
良かったら使ってみてください。