[Rails] ファイルダウンロード後に一時ファイルを削除したかった

# 背景

ダウンロードボタンを押したタイミングで生成された<strong>一時ファイル</strong>をダウンロードする処理があるのですが、
ダウンロード完了後、どのタイミングで消せばいいのかわからなかったので調べてみました。

# 調査

例えばこんなコードです。

```rb:Controller
  def dl
    respond_to do |format|
      format.zip {
        dir = Dir.mktmpdir(nil, "#{Rails.root}/tmp/")
        path = @item.download_from_tmp(dir) #ファイルを生成して一時ディレクトリに保存
        stat = File::stat(path)
        send_file( path, type: 'application/zip', disposition: "attachment", filename: "#{@item.name}.zip", length: stat.size )
        
        #ここに書くとDLが始まる前に削除されてしまって何もDLできない
        # FileUtils.rm_rf dir #TODO
      }
    end
  end
```

ぐぐっても、「`begin~ensure`でやってみろ」とか、「`send_file`ではなく`send_data`にしてしまえ」とか、「いっそ定期的にファイル削除するgem使え」とかしか出てこなかったのですが、

* `begin~ensure`はやってみましたが結果は同じでした。DLが始まる前にファイルが削除されちゃいます。
* `send_data`にすると、十数MBのファイルになったりするとそれを全てメモリに読み出しちゃうので嫌だなーと思いましたまる
* 削除用のgemと、Linuxが勝手に巡回削除してくれるディレクトリ（`/tmp/`等）は、解決しなかった場合の最終手段ということにしました。
* ちなみに、`after_action`もやってみましたがやはり同じでした。ダウンロード前にファイルがなくなっちゃいます。

## そもそも`send_file`は何をしているか

https://github.com/rails/rails/blob/4-2-stable/actionpack/lib/action_controller/metal/data_streaming.rb#L75

```rb
      def send_file(path, options = {}) #:doc:
        ... #省略
        self.response_body = FileBody.new(path)
      end
```

`response_body`に`FileBody`をセットしているだけ。
まだレスポンスの出力はされていない。

## `FileBody`とは何か

https://github.com/rails/rails/blob/4-2-stable/actionpack/lib/action_controller/metal/data_streaming.rb#L81-L96

```rb
      class FileBody #:nodoc:
        attr_reader :to_path

        def initialize(path)
          @to_path = path
        end

        # Stream the file's contents if Rack::Sendfile isn't present.
        def each
          File.open(to_path, 'rb') do |file|
            while chunk = file.read(16384)
              yield chunk
            end
          end
        end
      end
```

`each`だけを持ったクラス・・・
`16384Byte`毎にreadして出力してる感じなんですかね。

こうなっていれば当然、`send_file`の後や`after_action`でファイル削除処理してもダメですよね。
`each`が呼ばれてyieldし終わった後に削除しなければ

## では、`each`はいつ呼ばれるのか

貴重な調査をしてくれてる方がいらっしゃいました。
とても参考になりました。ありがとうございました :bow: 

> Railsノート - ActionController::Request の生成過程を Webサーバーまでさかのぼる
> http://d.hatena.ne.jp/h1mesuke/20100205/p1

かなり下の方、まとめの一歩手前「Rack」の項にこうありました。（適宜省略入れてます）

```rb
      def process(request, response)
          ... #省略

        begin
          ... #省略
          response.send_header

          body.each { |part|
            response.write part
            response.socket.flush
          }
        ensure
          body.close  if body.respond_to? :close
        end
```

## ！！！

```rb:！！！
        ensure
          body.close  if body.respond_to? :close
```

「<strong>eachが完了した後、bodyがcloseメソッドを持っていたらcloseを呼ぶ</strong>」ってか！

絶対これですね！

## 試す

```rb:Controller
  def dl
    respond_to do |format|
      format.zip {
        dir = Dir.mktmpdir(nil, "#{Rails.root}/tmp/")
        path = @item.download_from_tmp(dir) #ファイルを生成して一時ディレクトリに保存
        stat = File::stat(path)
        send_file( path, type: 'application/zip', disposition: "attachment", filename: "#{@item.name}.zip", length: stat.size )
        
        file_body = Class.new do #FileBodyをコピぺ
          attr_reader :to_path

          def initialize(path)
            @to_path = path
          end

          # Stream the file's contents if Rack::Sendfile isn't present.
          def each
            File.open(to_path, 'rb') do |file|
              while chunk = file.read(16384)
                yield chunk
              end
            end
          end

          # closeを追加
          def close
            FileUtils.rm_rf @to_path
          end
        end
        self.response_body = file_body.new(path) #response_bodyを上書き
      }
    end
  end
```

あっさり成功！
ダウンロードも完璧！終わった後にファイルも残らない！

# 実装

ただ、今回は、「一時ディレクトリを作ってその中に一時ファイルを作成」という形だったので、`@to_path`だけ消しても一時ディレクトリは残ってしまうとか、書き方が冗長だとかいろいろあるので、少し調整してこんな感じで一旦完了としました。

```rb:Controller
  def dl
    respond_to do |format|
      format.zip {
          ... #省略
          send_file(...) #省略

          self.response_body = Class.new(ActionController::DataStreaming::FileBody) do #TODO: only Rails 4.x
            attr_reader :to_dir

            def initialize(path,dir)
              @to_path = path
              @to_dir  = dir
            end

            def close
              FileUtils.rm_rf @to_dir
            end
          end.new(path,dir)
      }
    end
  end
```

* `ActionController::DataStreaming::TempFileBody` を作ろうかとも思いましたが、ファイルの後処理って使う場面ごとに違うのかなと思って、今回はコントローラーに直書きで様子を見ます。

# 注意

## Rails5

`FileBody`が`ActionController::DataStreaming`にあるのはRails4.xまでのようです。

Rails5からは`send_file`ごと`response`に移っているようなので、Rais5で利用するときはこの辺注意が必要かもです。

> send_file
https://github.com/rails/rails/blob/a34ee0c41f8081b6c4869fd0546f75713c09805c/actionpack/lib/action_dispatch/http/response.rb#L345-L348
> FileBody
https://github.com/rails/rails/blob/5-0-stable/actionpack/lib/action_dispatch/http/response.rb#L323-L342

# おわり

ほんとにこんな方法しかないのかな・・とは思います。