tmpfile で削除されたファイルのストリームリソースを返していて一見ダメそうだけど実は大丈夫なメモ

tmpfile の使い方の問題でぱっと見うまく動かないように見えて、でも実はうまく動くメモ。

問題のコード

要約すると次のような処理でした。

  1. tmpfile で一時ファイルを作成
  2. stream_get_meta_data でファイル名を得る
  3. そのファイル名に ZipArchive で書き込み
  4. 同じファイル名を fopen で開いてメソッドから返す(実際には Response オブジェクトだったけど)

コードにすると次のような感じです(実際のコードをかなり簡略化しています)。

<?php
function f()
{
    // tmpfile で一時ファイルを作成
    $tmp = tmpfile();

    // stream_get_meta_data でファイル名を得る
    $filename =  stream_get_meta_data($tmp)['uri'];

    // そのファイル名に ZipArchive で書き込み
    $zip = new ZipArchive();
    $zip->open($filename, ZipArchive::CREATE);
    $zip->addFromString('a.txt', 'A');
    $zip->close();

    // 同じファイル名を fopen で開いてメソッドから返す
    return fopen($filename, 'r');
}

うまく動かない気がした理由

tmpfile はとても便利で、これで作成された一時ファイルは自動で削除されるので後始末の必要がありません。

この削除は tmpfile が返すストリームリソースのデストラクタ的なものによって行われます(オブジェクトではないのでデストラクタとは呼ばないとは思うけれども)。PHP は参照カウント式の GC によって $tmp に入っているリソースはメソッドのスコープを抜けたタイミングで直ちに破棄されます。なのでこのファイルはメソッドから抜けたときには削除されており、fopen で同名のファイルを開いてメソッドから返したとしてもそのファイルは既に削除されていて存在しません。

ので、一見ダメそうですが・・・実は大丈夫です。

うまく動く理由

ファイルシステム上にファイル名として存在するファイルは消えていたとしても、そのファイルの実体はそのファイルのディスクリプタが全部無くなるまで存在しているので、ファイルは削除されてしまっているけれども、fopen で開いたファイルの実体は残っていて読み書きできます。

<?php
file_put_contents('a.txt', 'abc');
$stream = fopen('a.txt', 'r+');
unlink('a.txt');
clearstatcache();
var_dump(file_exists('a.txt')); //=> false
fwrite($stream, 'A');
rewind($stream);
var_dump(fgets($stream)); //=> Abc

このようなコードで、削除して存在しないはずのファイルへの読み書きが出来ることがわかります。なお、このとき lsof で見てみると /path/to/a.txt (deleted) などと表示されて、もう削除されてることがわかります。

tmpfile をそのまま返す → ダメ

なお、同名のファイルを fopen しなくても tmpfile が返したストリームリソースをそのまま返せばよいのでは・・・

<?php
function f()
{
    $tmp = tmpfile();
    $filename =  ($tmp)['uri'];
    $zip = new ZipArchive();
    $zip->open($filename, ZipArchive::CREATE);
    $zip->addFromString('a.txt', 'A');
    $zip->close();
    return $tmp;
}

と思ったのですがこれはうまく動作しません。ZipArchive がファイルを上書きしたときに i-node が変わるため、元の tmpfile が返したストリームリソースとは別の実体になってしまうためです。

strace で見た感じ、ZipArchive(の中の libzip ?) はファイルを直接上書きするのではなく、サフィックスにランダムな文字列を付け足したファイル名で保存した上で rename で元のファイルを上書くようになっています。

そのため tmpfile で作成されたファイルと ZipArchive によって書き込まれたファイルは別になるので、これはうまく動きません。メソッドが返したストリームリソースの中身は空っぽです。

tmpfile の代わりに tempnam を使う

tmpfile の代わりに tempnam を使っても似たようなことができます。tmpfile のように自動では削除されないので finally で unlink する必要がありますけど。

<?php
function f()
{
    $filename = tempnam(sys_get_temp_dir(), 'php-zip');
    try {
        $zip = new ZipArchive();
        $zip->open($filename, ZipArchive::CREATE);
        $zip->addFromString('a.txt', 'A');
        $zip->close();
        return fopen($filename, 'r');
    } finally {
        unlink($filename);
    }
}

さいごに

最初のコードをぱっと見たときにはギョッとしましたが、よくよく考えてみれば問題なく、なるほどなーと思った事例でした(自分が書いたコードではない)。

なお、最後の tempnam の例は tmpfile よりも記述量は増えますが、stream_get_meta_data($tmp)['uri'] のような知らなければなんのこっちゃなコードと比べるとなにをやっているか明白です。もちろん tmpfile によってメソッドから抜けたタイミングでファイルが削除されることを理解して使う分には tmpfile でも良いと思います。