29
21

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

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

Last updated at Posted at 2017-07-13

背景

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

調査

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

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は何をしているか

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

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

FileBodyとは何か

      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」の項にこうありました。(適宜省略入れてます)

      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

!!!

!!!
        ensure
          body.close  if body.respond_to? :close

eachが完了した後、bodyがcloseメソッドを持っていたらcloseを呼ぶ」ってか!

絶対これですね!

試す

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だけ消しても一時ディレクトリは残ってしまうとか、書き方が冗長だとかいろいろあるので、少し調整してこんな感じで一旦完了としました。

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

FileBodyActionController::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

おわり

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

29
21
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
29
21

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?