PHP: メモリに配慮しつつアトミックなファイル書き出しを行う

CSVエクスポートなどでPHPでファイルを書き出すとき、ファイルへの書き込みをアトミックにしたいことがある。

ここでいうアトミックとは次のような特徴があること:

* 書き込み途中のファイルが他のプログラムから見えないこと(もしくは、見えても一時ファイルであることが自明なこと)

書き込む内容がメモリに乗っかる程度であれば、`file_put_contents`で済むが、そうでない場合は次のようなアプローチが選択肢のひとつになるだろう。

* 一時的に書き込むファイルを作る (output.csv.tmpなど)
* 書き込みはその一時ファイルに行う
* 書き込みが終わったら、ファイル名を正式なファイル名に変更する (output.csvなど)

## アトミックな書き出しインターフェイス

アトミックな書き出しのアプローチは他にも考えられるので、インターフェイスを作っておく。

```php
interface AtomicWriter
{
    /**
     * @param iterable|string[] $contents 書き込む内容
     * @throws RuntimeException 書き込みに問題があったとき例外が発生する
     */
    public function writeAll(iterable $contents): void;
}
```

iterable型で書き込み内容を受け付けるようにしているのは、データが大量にあってもメモリを枯渇させないための工夫だ。

クライアントコードはこんな感じになる。

```php
function getData(): iterable {
    yield 'data1'; // 実際はDBなどから取ってきた1レコードにあたる
    yield 'data2';
    yield 'data3';
}
/** @var AtomicWriter $atomicWriter **/
$atomicWriter->writeAll(getData());
```

## 一時ファイルを使ったAtomicWriterの実装

冒頭で説明した一時ファイルを用いたアトミックな書き出しを実装したクラスが次になる。実装だけにファイル操作とエラー処理をゴリゴリ書く必要がある。

```php
final class TemporaryFileAtomicWriter implements AtomicWriter
{
    /**
     * @var string
     */
    private $temporaryFilename;

    /**
     * @var string
     */
    private $finalizedFilename;

    public function __construct(
        string $fileName,
        ?string $temporaryFilename = null
    ) {
        $this->finalizedFilename = $fileName;
        $this->temporaryFilename = $temporaryFilename ?? 'php://temp';
    }

    public function writeAll(iterable $contents): void
    {
        if ($this->temporaryFileExists()) {
            throw new \RuntimeException('temporary resource already exists');
        }
        if ($this->finalizedFileExists()) {
            throw new \RuntimeException('finalized resource already exists');
        }

        $temporaryResource = $this->createTemporaryFile();

        foreach ($contents as $content) {
            if (\fwrite($temporaryResource, $content) === false) {
                throw new \RuntimeException('failed to write');
            }
        }

        if ($this->finalizedFileExists()) {
            throw new \RuntimeException('finalized resource already exists');
        }

        $this->finalize($temporaryResource);

        if (!$this->removeTemporaryFile()) {
            throw new \RuntimeException('failed to delete temporary file');
        }

        \assert(!\file_exists($this->temporaryFilename));
    }

    private function temporaryFileExists(): bool
    {
        return \file_exists($this->temporaryFilename);
    }

    private function finalizedFileExists(): bool
    {
        return \file_exists($this->finalizedFilename);
    }

    /**
     * @return resource
     */
    private function createTemporaryFile()
    {
        $fp = \fopen($this->temporaryFilename, 'x+b');
        if ($fp === false) {
            throw new \RuntimeException('Unable to create temporary resource');
        }
        return $fp;
    }

    /**
     * @param resource $temporaryFile
     */
    private function finalize($temporaryFile): void
    {
        \assert(is_resource($temporaryFile));
        if (!\rewind($temporaryFile)) {
            throw new \RuntimeException('failed to rewind temporary file');
        }
        if ($this->temporaryFileIsLocalFile() && $this->finalizedFileIsLocalFile()) {
            if (!\rename($this->temporaryFilename, $this->finalizedFilename)) {
                throw new \RuntimeException('failed to move temporary file to finalized file');
            }
        } elseif (\stream_copy_to_stream($temporaryFile, \fopen($this->finalizedFilename, 'xb')) === false) {
            throw new \RuntimeException(
                'failed to copy contents to finalized file from temporary file'
            );
        }
    }

    private function temporaryFileIsLocalFile(): bool
    {
        return \is_file($this->temporaryFilename);
    }

    private function finalizedFileIsLocalFile(): bool
    {
        return (\parse_url($this->finalizedFilename, \PHP_URL_SCHEME) ?? 'file') === 'file';
    }

    private function removeTemporaryFile(): bool
    {
        return !\is_file($this->temporaryFilename)
            || \unlink($this->temporaryFilename);
    }
}
```

クライアントコードはこんな感じになる。

```php
function getData(): iterable {
    yield "data1\n";
    echo "yield data1\n";
    yield "data2\n";
    echo "yield data2\n";

    sleep(3); // データが膨大だと最後のデータNが出力されるまで数秒かかるので、3秒スリープしてその状況を再現する

    yield "dataN\n";
    echo "yield dataN\n";
}

$atomicFileWriter = new TemporaryFileAtomicWriter('php://stdout');
$atomicFileWriter->writeAll(getData());
```

実行結果:

![2019-02-12 20.32.48 2.gif](https://qiita-image-store.s3.amazonaws.com/0/889/ecd78976-14e2-0374-a60a-1720fb1fb1ea.gif)

すべてのイテレーションが終わってから標準出力にデータが書き出されるのが分かる。

この例ではアトミックな書き出しをわかりやすくするために標準出力を書き出し先にしたが、通常のファイルを指定することはもちろんできる。

```php
$atomicFileWriter = new TemporaryFileAtomicWriter('out');
```

また一時ファイルを指定するすることもできる。デフォルトは`php://temp`。

```php
$atomicFileWriter = new TemporaryFileAtomicWriter('out', 'out.tmp');
```