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

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

- [【簡単3ステップ】RailsでCSV出力する方法](https://qiita.com/ngron/items/60915083cb2bc3622c79)
- [RailsでCSV出力を行う方法を現役エンジニアが解説【初心者向け】](https://techacademy.jp/magazine/22192)

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

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

### controller

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

```ruby: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`を指定したボタンを設置するのみです＾＾。

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

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

```ruby:/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
```
#### 解説１： bom
BOMとは、`Byte Order Mark(バイト・オーダー・マーク)`の略で、`Unicode`で記載された文書の冒頭につく短い文字列です。この文字列には、文書のエンコーディング方式等が記載されています。

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

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

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

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

- [BOM付きUTF-8で文字化けしないCSV出力](https://doruby.jp/users/ueki/entries/BOM%E4%BB%98%E3%81%8DUTF-8%E3%81%A7%E6%96%87%E5%AD%97%E5%8C%96%E3%81%91%E3%81%97%E3%81%AA%E3%81%84CSV%E5%87%BA%E5%8A%9B)
- [BOM  【 Byte Order Mark 】  バイトオーダーマーク / バイト順マーク](http://e-words.jp/w/BOM.html)

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

- [Railsはどのようにテンプレートを見つけているか](https://qiita.com/ngtk/items/cd406961cf62531a81c5)
- [railsでrenderを指定しないmethodがどのようにrender先を指定しているのか確認Try2](http://talktome.hatenablog.jp/entry/2014/03/20/020200)

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

### 解説3: Enumerator.new

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

▼参考
- [Ruby の Enumerator とたわむれる](https://moneyforward.com/engineers_blog/2020/02/04/ruby-enumerator/)

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

```ruby
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
```
これと同じでした。

```ruby
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形式に変換してくれるメソッドのようです。以下、こちらのドキュメントからの引用コードです。

- [class CSV](https://docs.ruby-lang.org/ja/latest/class/CSV.html)

```ruby
require 'csv'

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

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

```ruby:
def set_csv_request_headers(filename, charset: 'UTF-8') 
```
▼詳しくはこちら
- [Rubyのキーワード引数の使い方を現役エンジニアが解説【初心者向け】](https://techacademy.jp/magazine/19791)

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

### 解説6: self.response.headers
最後に、こちらの部分ですが、

```ruby:
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'
```
レスポンスが返ってくる時の、レスポンスのヘッダーに追加する一連のオプションを指定しています。
オプション一覧は、こちらです。

- [HTTP ヘッダー](https://developer.mozilla.org/ja/docs/Web/HTTP/Headers)

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

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

...だそうです。
(*)の部分は、[公式ドキュメント](https://developer.mozilla.org/ja/docs/Web/HTTP/Headers/Content-Disposition)にはそう書いてあるのですが、今のところ自分の環境では成功していません。ごめんなさい。。。


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

▼作成したCSV出力ボタン:sunny:
[![Image from Gyazo](https://i.gyazo.com/bbaf2106ed7cefd1b56e23092ff47901.png)](https://gyazo.com/bbaf2106ed7cefd1b56e23092ff47901)

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

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