普段、CSV出力系の機能を作ることが稀に良くあるが、割と毎回同じようなことを実装してる気もするのでいい感じに共通化できないか実装を検討したのでメモ
※ベンチマークは特に取ってない
CSV出力の実装検討メモ
考えたこと
- CSV出力時にエラーが発生した場合に、ハンドリングしやいようにカスタムエラーをraiseする
- 各行の出力ロジックは算出値を利用することもあるので外部から注入できるようにする
ActiveRecord::Relation
からよしななオブジェクトを返すin_batches
で取得してDB負荷を抑制
- アプリケーション側のメモリ負荷抑制のためにCSVを分割して出力する(スコープ外)
- 直接send dataでレスポンスとして返すようなケースもありそうなので今回はスコープ外
- よしなにBOMをつける
- エクセルは対応しなくてもいいケースもありそうなので今回はスコープ外
実装してみる
module Csv class Genarator def initialize(relation:, headers:, row_storategy:, batch_size: 1000) @relation = relation @headers = headers @row_storategy = row_storategy @batch_size = batch_size end def call! raw_rows = genarate_raw_rows(@relation, @row_storategy, @batch_size) rows = genarate_rows(@headers, raw_rows) genarate_csv(rows) rescue StandardError => error raise GenarateFailed, error end private def genarate_raw_rows(relation, strategy, batch_size) relation.in_batches(of: batch_size) .reduce([]) { |result, records| result + strategy.call(records) } end def genarate_rows(headers, raw_rows) generate_row = ->(raw_row) { CSV::Row.new(headers, raw_row) } raw_rows.map(&generate_row) end def genarate_csv(rows) CSV::Table.new(rows) end end end
補足
- row_storategyでProcオブジェクトを渡すことで行生成のロジックを外部から注入できるようにした
- 本当だったらpluckとかその辺りの最適化のロジックも良しなに組み込みたかったけど、そこは責務から除外した。この辺を矯正したいならclumnsみたいなのを引数で撮ってrelationにpluckで適用するような処理を内部に持つようにしても良いかもしれない。
- in_batches + reduceで一定件数ずつ抽出 + 処理して結果を返すようにした
- CSV::Rowを生成してCSV::Tableを返すようにした
- この辺はpureなHashとかArrayを生成してCSV.generateした方が負荷的には良いのかもしれない
利用例
headerと行の抽出ロジックを渡せば良しなに生成してくれる。
headers = %i[id title endpoint last_published_at created_at updated_at] row_storategy = ->(records) { records.pluck(*headers) } result = Csv::Genarator.new(relation: feeds, headers: headers, row_storategy: row_storategy).call! result.to_csv # CSV文字列を返す