早速だが、コードを見てほしい。ネットワークデータを一旦ローカルの一時ファイルに受け取り、後でゆっくり読み書きしよう、という意図のコードだ。呼び出し元はファイルハンドルを受けとり、シークを含めた読み書きができる、というか、したい。
def create_tempfile(*)
network_source = ... # 省略
Tempfile.new("example").binmode.tap do |file|
IO.copy_stream(network_source, file)
file.rewind
end
end
ところが、このコードには問題がある。普段何も問題なく動くのに、時折このメソッドから受け取るファイルに対して操作をしようとしたら IOError
が発生することがある。しかもこのエラーは環境や時間、扱うデータによって発生したりしなかったりするし、スタックトレースはなにやら所謂ライブラリ類の奥深く。そもそも問題の原因がここであることを突き止めるにも一苦労があったわけだが、えーっと、現場の苦労話はさておきだ、とにかく「問題の原因はこのコード」、「発生している例外が持つメッセージは “closed stream”」である。
どう直せば解決するか、分かっただろうか?
以下のようにすれば解決する。
def create_tempfile(*)
network_source = ... # 省略
Tempfile.new("example").tap do |file|
file.binmode
IO.copy_stream(network_source, file)
file.rewind
end
end
…ん? 何が変わったのだろう?
例外のメッセージは “closed stream” 、即ちファイルハンドルが既に閉じられていることを示している。では一体だれが閉じたのか。
原因のコードが突き止められていない間に関しては疑う余地はどこにでもあった。どこかの Gem の使い方を間違っている? うっかり close
してしまうパターンのコードがあるのか? とはいえそこは解明済。犯人は Tempfile
それ自身である。
Tempfile
はその性質上「一時的なもの」であり、作成したファイルがいつまでも残るのは好ましいことではない。なのでそうならないよう、 new
の時点で自己を閉じるコールバックを ObjectSpace
のファイナライザに追加する。こうしておくことで、オブジェクトが利用されなくなったら、GC に連れて行かれるときにファイルハンドルも道連れにできるというわけだ。
なるほど、なるほど。つまりファイナライザが呼び出されてファイルハンドルが回収されてしまったのだね?
いや、ちょっと待ってくれ。問題のメソッドはファイルオブジェクトを呼び出し元に返しているし、呼び出し元はそのオブジェクトを通してファイルにアクセスをしている。だから一時ファイルに対する参照は生きているはずだ。なぜファイナライザが呼び出されているのだ!
Tempfile#binmode
は何を返すのか?解答に示した通り、問題は binmode
である。binmode
は実際には Tempfile
のメソッドではなく、その親クラスの IO
が持つメソッドである。公式のドキュメントには以下のように記述されている。
binmode -> self
ストリームをバイナリモードにします。
MSDOS などバイナリモードの存在 する OS でのみ有効です。
そうでない場合このメソッドは何もしません。
参考: http://docs.ruby-lang.org/ja/2.1.0/method/IO/i/binmode.html
実のところこのコードを動かしていたのは Linux 環境なので、ドキュメントの通りであればこれは「何もしない」。単に扱うファイルがテキストではなかったので、「念のため」呼び出されているだけのメソッドなのだが、いったい何が起きているというのだろう。
pry(main)> a = Tempfile.new("example")
=> #<File:/tmp/example20150603-24767-1s14lqk>
pry(main)> b = a.binmode
=> #<File:/tmp/example20150603-24767-1s14lqk>
pry(main)> a == b
=> true
おかしなところはなにもないではないか。
pry(main)> a.class
=> Tempfile
pry(main)> b.class
=> File
…あっ!
そう、 Tempfile#binmode
が返すのは self
ではない のだ!
当初のコードは binmode
の結果に対して tap
を行っているので、メソッド全体の返値は binmode
の結果である。これは実は Tempfile
オブジェクトではなく、その内部の File
オブジェクトであった。従って、 Tempfile
オブジェクト自体はこのメソッドが終了した時点でどこからも参照されなくなり、「ガーベッジ」として回収を待つ状態となる。そして先に説明した通り、GC によって回収される際、 Tempfile
オブジェクトは責任もって自らが作成したファイルを閉じ、削除する。
呼び出し元は Tempfile
によって作成された File
オブジェクトを通してファイルにアクセスを行っていた。このオブジェクトそのものはひとまずは正当なものであり、アクセスに支障はない。ところが、 GC が該当の Tempfile
オブジェクトを回収した 後 に操作しようとすると、既に閉じられているためエラーとなる。
なるほど。エラーが発生したりしなかったりしていたのは、 GC が我々のあずかり知らぬタイミングで動作するせいだった、というわけだ。
今回の話は binmode
が返す値を勘違いしていたのが原因だったので、前述の変更で問題は解決するが、可能なら RAII パターン、即ち open
メソッドにブロックを渡す形式を用い、寿命を明示するのが良い。
def fetch(*)
network_source = ... # 省略
Tempfile.open("example") do |file|
file.binmode
IO.copy_stream(network_source, file)
file.rewind
yield file
end
end
# 呼び出し元
fetch(...) do |file|
... # 一時ファイルを使った何か
end
また、 Tempfile
クラス設計という観点であれば、そもそも内部情報を外に出すべきではない。ドキュメントの通り self
すなわち Tempfile
オブジェクトを返却するか、あるいは敢えて nil
を返すべきだ。特に後者の場合、 binmode!
のように感嘆符を付けると、見た目もオブジェクトの状態を変更するメソッドに見えて良さそうである。
早く binmode
なんか必要ない世界になればいいのに。
…失礼。Tempfile#binmode
が返す値には罠がある。取り扱いには注意しよう。