RailsでCSVダウンロード機能を実装する


背景

RailsでCSVダウンロード機能を実装することになりました。すでに簡単で優れた事例はQiita上にたくさん上がっていて、このあたりの記事を見ればさらっと実装できたのですが、

仕事先で見つけたCSV出力機能はこれらよりもずっと複雑な作りをしていたので、「この仕組みはどうなっているんだ!」と思ったことを調べつつ、実装してみることにしました。基本的には、上記で紹介した2つ目の記事に近いです。

なお、Railsのバージョンは5.2.4.2です。

controller

controllerは上記で紹介した記事とほとんど変わらないです。htmlとcsvでformatを分けて、csvのフォーマットでリクエストがあったときに、csvを作成するメソッドが呼び出されます。

controllers/somethings_controller.rb
class SomethingsController < ApplicationController
  include SomeCsvModule # これから定義します

  def index
    @somethings = Something.all
    respond_to do |format|
      format.html
      format.csv do
        generate_csv(@somethings) # これから定義します
      end
    end
  end
end

view

ビューは至ってシンプルで、format: :csvを指定したボタンを設置するのみです^^。

views/somethings/index.html.haml
= link_to "CSVダウンロード", somethings_path(format: :csv)

module

ビューとコントローラーがこれだけシンプルな秘密は、moduleにありました。様々なcontrollerでの共通メソッドなので、controllers/concerns/内に共通のモジュールを定義しました。

/controllers/concerns/some_csv_module.rb
module SomeCsvModule
  extend ActiveSupport::Concern

  def generate_csv(somethings)
    filename = "情報一覧_#{Date.today}.csv"
    set_csv_request_headers(filename)

    bom = "\uFEFF" # 解説します(1)
    self.response_body = Enumerator.new do |csv_data| # 解説します(2・3)
      csv_data << bom

      header = %i(id 名前 内容)
      csv_data << header.to_csv # 解説します(4)

      somethings.each do |some|
        body = [
          some.id,
          some.name,
          some.content
        ]
        csv_data << body.to_csv
      end
    end
  end

  def set_csv_request_headers(filename, charset: 'UTF-8') # 解説します(5)
    # ↓解説します(6)
    self.response.headers['Content-Type'] ||= "text/csv; charset=#{charset}"
    self.response.headers['Content-Disposition'] = "attachment;filename=#{ERB::Util.url_encode(filename)}"
    self.response.headers['Content-Transfer-Encoding'] = 'binary'
  end
end

解説1: bom

BOMとは、Byte Order Mark(バイト・オーダー・マーク)の略で、Unicodeで記載された文書の冒頭につく短い文字列です。この文字列には、文書のエンコーディング方式等が記載されています。

Excelの標準の文字コードはShift-JISで、WEBの世界はUTF-8なので、WEBアプリでCSV出力したデータをExcelで開こうとすると、確実に文字化けしてしまいます。

文書の冒頭のbomでこの文書の文字コードはUTF-8であると伝えれば、文字化けを防げます。

このモジュールでは、bom = "\uFEFF"と定義し、続くcsvデータ作成処理の冒頭で、データの一番初めにbomを追加しています。

なお、この部分の解説は、こちらの2記事を参考に作成しています。

解説2: self.response_body

self.response_bodyの部分については、調べたけれども、よくわかりませんでした・・・。ごめんなさい。ただ、この辺りの記事から推測すると...

Railsがレンダリングするビューを定義する部分がself.response_bodyのようです。デフォルトではnilで、self.response_bodyに値を代入することで、レンダリングするビューを作成することができます。

解説3: Enumerator.new

Enumerator.newは、下記の記事を参考に調べた限りでは「配列的な要素を作成する」メソッドのようです。

▼参考
- Ruby の Enumerator とたわむれる

上記のコードのこの部分は

self.response_body = Enumerator.new do |csv_data|
  csv_data << bom

  header = %i(id 名前 内容)
  csv_data << header.to_csv

  somethings.each do |some|
    body = [
      # 中略
    ]
    csv_data << body.to_csv
  end
end

これと同じでした。

csv_data = []
csv_data << bom

header = %i(id 名前 内容)
csv_data << header.to_csv

somethings.each do |some|
  body = [
    # 中略
  ]
  csv_data << body.to_csv
end

self.response_body = csv_data

解説4: to_csv

to_csvは、csvライブラリのメソッドで、配列をcsv形式に変換してくれるメソッドのようです。以下、こちらのドキュメントからの引用コードです。

require 'csv'

csv_string = ["CSV", "data"].to_csv   # => "CSV,data"
csv_array  = "CSV,String".parse_csv   # => ["CSV", "String"]

解説5: キーワード引数

こちらのメソッドのcharset: 'UTF-8'の部分はキーワード引数と言って、ハッシュ のように引数にキーを指定する書き方です。

def set_csv_request_headers(filename, charset: 'UTF-8') 

▼詳しくはこちら
- Rubyのキーワード引数の使い方を現役エンジニアが解説【初心者向け】

このように指定することで、メソッド内ではcharsetのキーワードでUTF-8という値を呼び出すことができます。

解説6: self.response.headers

最後に、こちらの部分ですが、

self.response.headers['Content-Type'] ||= "text/csv; charset=#{charset}"
self.response.headers['Content-Disposition'] = "attachment;filename=#{ERB::Util.url_encode(filename)}"
self.response.headers['Content-Transfer-Encoding'] = 'binary'

レスポンスが返ってくる時の、レスポンスのヘッダーに追加する一連のオプションを指定しています。
オプション一覧は、こちらです。

それぞれ、簡単に解説すると、

  • Content-Type...コンテンツのMIMEタイプを指定する。
  • Content-Disposition...コンテンツがHTML(inline)なのか、添付ファイル(attachment)なのかを指定する。filename="ファイル名"オプションで、名前を付けて保存するウィンドウを出すことができる(*)
  • Content-Transfer-Encoding...は、リクエストの文字列が、どんな仕組みでエンコーディングされているかを記載している(詳細はこちら)

...だそうです。
(*)の部分は、公式ドキュメントにはそう書いてあるのですが、今のところ自分の環境では成功していません。ごめんなさい。。。

まとめ

...以上となります!仕組みを理解するまでに色々な学習が必要でしたが、なんとか無事CSV出力機能を実装することができました^^

▼作成したCSV出力ボタン

仕事で見かけたコードは、まだまだレスポンスヘッダーに色々なオプションがついていたり、共通化できる処理をまとめて汎用化できる形に整理したり、職人的な技が目白押しだったのですが、自分もまずはこの形を習得して、様々なオプションを使いこなせるようになろうと思います。

(まずは、これのテストが書けるようになりたいな...)
引き続き、精進あるのみです♪