背景
ダウンロードボタンを押したタイミングで生成された一時ファイルをダウンロードする処理があるのですが、
ダウンロード完了後、どのタイミングで消せばいいのかわからなかったので調べてみました。
調査
例えばこんなコードです。
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_body
にFileBody
をセットしているだけ。
まだレスポンスの出力はされていない。
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
はいつ呼ばれるのか
貴重な調査をしてくれてる方がいらっしゃいました。
とても参考になりました。ありがとうございました
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を呼ぶ」ってか!
絶対これですね!
試す
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
だけ消しても一時ディレクトリは残ってしまうとか、書き方が冗長だとかいろいろあるので、少し調整してこんな感じで一旦完了としました。
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
おわり
ほんとにこんな方法しかないのかな・・とは思います。