PHPの出力バッファリング(ob_start)を実務で使い切る

出力バッファリングとは、echo などの出力をその場で送らずにメモリへ溜めておき、あとからまとめて取得・加工・破棄できるPHPの仕組みです。

フレームワークのテンプレート機能の中身を覗くと、たいてい ob_start() が出てきます。魔法でも何でもなく、include した結果の出力を横取りして文字列にしているだけなんですね。

ただ、この「横取り」は状態を持ちます。開けたら閉じる、閉じ忘れると次の処理に影響が出る。中級者がハマるのはたいていそこです。

テンプレートを文字列にする、が第一の用途

PHPファイルをそのままテンプレートとして使い、その出力を変数に受け取りたい。include は画面に直接出してしまうので、前後をバッファで挟みます。

<?php
declare(strict_types=1);

function render(string $file, array $vars = []): string
{
    ob_start();
    extract($vars, EXTR_SKIP);
    require $file;

    $html = ob_get_clean();

    return $html === false ? '' : $html;
}

ob_get_clean() は「中身を取り出す」と「バッファを閉じる」を一度にやってくれます。バッファが無いときは false を返すので、戻り値の型を string にするなら受け止めておくのが無難です。

ちなみに extract() は嫌われがちですが、テンプレート用の限定された場所で EXTR_SKIP 付きなら実害は少ないと思います。既存変数($file や $vars 自身)を上書きされない、というのが EXTR_SKIP の意味です。

例外が飛んだとき、バッファはどうなるのか

ここが一番のハマりどころです。require したテンプレートの中で例外が投げられると、ob_get_clean() までたどり着けません。バッファは開いたまま残り、その後の出力が全部そこへ吸い込まれます。「なぜか画面が真っ白」の正体がこれだったりします。

対処は単純で、開始時点の段数を覚えておき、自分が開いた分だけ確実に閉じることです。

<?php
declare(strict_types=1);

function render(string $file, array $vars = []): string
{
    $level = ob_get_level();
    ob_start();

    try {
        extract($vars, EXTR_SKIP);
        require $file;

        $html = ob_get_clean();

        return $html === false ? '' : $html;
    } catch (\Throwable $e) {
        while (ob_get_level() > $level) {
            ob_end_clean();
        }
        throw $e;
    }
}

「自分より下の段までは触らない」のが肝です。ob_end_clean() を無条件ループで回すと、フレームワークや php.ini の output_buffering が開いたバッファまで巻き込んで壊してしまいます。ob_get_level() はスクリプト開始時点で 0 とは限りません。

なぜ finally ではなく catch なのか

finally でも書けますが、正常系ではすでに ob_get_clean() で閉じているため、finally に「残っていたら閉じる」処理を置くと条件分岐が増えます。異常系だけを catch で始末して再スローする方が、読んだときに意図が素直に伝わる気がしています。

もちろん、途中に return が複数ある関数なら finally の方が安全です。そこは形にこだわらず、確実に閉じられる方を選べばいいと思います。

コールバックで出力全体に手を入れる

ob_start() は第1引数にコールバックを取れます。バッファが吐き出されるタイミングで呼ばれ、返した文字列が実際の出力に差し替わります。ミニファイやデバッグ用のコメント付与など、出力の最終加工に使えます。

<?php
declare(strict_types=1);

$handler = function (string $buffer, int $phase): string {
    if ($phase & PHP_OUTPUT_HANDLER_FINAL) {
        return $buffer . "\n<!-- generated by app -->";
    }
    return $buffer;
};

ob_start($handler, 0, PHP_OUTPUT_HANDLER_STDFLAGS);

$phase は PHP_OUTPUT_HANDLER_* 定数のビットマスクです。START / CLEAN / FLUSH / FINAL があり、フラッシュのたびに呼ばれるので、「最後の一回だけ」やりたい処理は FINAL を見て分岐します。判定が & のビット演算なのを忘れて == で書くと、静かに動かない側に倒れます。

第3引数の PHP_OUTPUT_HANDLER_STDFLAGS は CLEANABLE・FLUSHABLE・REMOVABLE をまとめたもので、これが既定値です。ここに 0 を渡すと「消せない・流せない・外せない」バッファになり、あとから ob_end_clean() しようとしても失敗します。意図的にロックしたいとき以外は既定のままがいいです。

なお、ハンドラの中から ob_get_clean() を呼ぶのは禁止です。致命的エラーになります。加工は引数の $buffer だけで完結させる、と割り切ってください。

ヘッダーを後から送れるようになる、という副作用

出力バッファリングを有効にしていると、本文が実際に送信されるのはバッファを閉じるときまで遅れます。つまり、少し echo したあとでも header() や setcookie() が間に合う。「headers already sent」の回避策として output_buffering を有効にする運用は昔からよくあります。

ただ、個人的にはこれを常用の解決策にするのは筋が悪いと思っています。ヘッダーは出力より先に決める、という設計にしておく方が事故が減ります。バッファはあくまで保険で、設計の代わりにはなりません。

メモリの話も一応

バッファは当然メモリに載ります。数百MBのCSVを echo で吐くような処理をバッファ内でやると、そのまま数百MB積み上がります。ストリーミング的に流したいなら、ob_start() の第2引数 chunk_size を指定して、一定サイズに達したら自動でフラッシュさせる手があります。

<?php
declare(strict_types=1);

ob_start(null, 8192);

foreach ($rows as $row) {
    echo implode(',', $row), "\n";
}

ob_end_flush();

chunk_size の既定は 0 で、これは「閉じるまで全部溜める」の意味です。大量出力の経路だけ明示的に指定しておくと、メモリのピークが素直に下がります。

まとめ

出力バッファリングは、テンプレートの出力を文字列として受け取るための道具、というのが実務での本命です。難しい機能ではないのですが、状態を持つぶん「閉じ忘れ」が全部あとで効いてきます。開始時の ob_get_level() を控えておいて、自分が開いた分だけ閉じる。例外経路でもそれを保証する。この二つを守っていれば、だいたい平和に使えると思います。

よくある質問

Q. ob_get_clean() と、ob_get_contents() + ob_end_clean() を並べるのは何が違いますか?
A. 結果はほぼ同じで、ob_get_clean() は取得と破棄をまとめて行うだけです。ただしバッファが無い状態で呼んだとき、ob_get_clean() は false を返して静かに終わります。中身だけ見て閉じない場合は ob_get_contents() を使ってください。

Q. ob_start() のコールバックの中で ob_get_clean() を呼べますか?
A. 呼べません。出力ハンドラの内部から呼ぶと致命的エラーになります。加工したい内容はコールバックの引数 $buffer に全部入っているので、そこで完結させる形にします。

Q. バッファが今いくつ開いているか確認する方法は?
A. ob_get_level() が現在の段数を返します。php.ini の output_buffering やフレームワークが先に開いている場合があるので、スクリプト開始時点で 0 だと思い込まないのが安全です。

類似投稿

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です