原文地址:PHP headers already sent 原因分析
先上結論,為了避免 headers already sent 錯誤,你應該[^1]:
- 檢查 PHP 程式碼,確認 <?php 前沒有空格和空行
避免在業務程式碼中使用 echo 和 print 系函式,只在框架組織 HTTP body 輸出的時候使用,這些函式包括
- print, echo, printf, vprintf
- trigger_error, ob_flush, ob_end_flush, var_dump, print_r
- readfile, passthru, flush, imagepng, imagejpeg
原因分析
最近上線程式碼之後遇到了一個問題,在某些情況下會丟擲異常:Uncaught Exception: ErrorException: Severity: 2; Message: Cannot modify header information - headers already sent by...。而且這個異常並非總是會出現,在不瞭解原因的情況下想要在測試環境重現比較困難,以下是分析步驟。
異常產生的原因
它本質上是一個 E_WARNING,被 error_handler 截獲而丟擲異常:
<?php
function _error_handler($severity, $message, $filepath, $line)
{
// ...
if (($severity & error_reporting()) == $severity)
{
// db rollback
throw new ErrorException("Severity: $severity; Message: $message");
}
}複製程式碼
在 index.php 中我們設定 error_reporting 要報告 E_WARNING 錯誤,所以會走到這裡並丟擲異常。也就是說,我們需要找到 E_WARNING 丟擲的位置和原因。
E_WARNING 產生的原因
<p>Severity: Warning</p>
<p>Message: Cannot modify header information - headers already sent by (output started at .../application/controllers/my_script.php:xxx)</p>
<p>Filename: libraries/Session.php</p>複製程式碼
這個錯誤從字面理解,就是設定 header() 的時候發現 header 中已經有內容了,那麼,在異常資訊中, headers already sent by () 括號裡的內容就很重要了,它表明了是那一行的輸出導致了這個問題。按照定位的位置,是指令碼中的一個printf
語句;繼續看,是 Session 中的 setcookie() 方法發現這個 printf 語句已經輸出內容了。
想要解決這個問題,可以使用 sprintf 來組裝字串,使用 fwrite 等標準輸出將內容輸出到控制檯。
為什麼會出現 headers already sent
在 PHP 中,不能在header()
之前 echo 任何內容,一旦 echo,PHP 會傳送已有的 header 內容,我們做一下實驗。
在實驗之前,你需要把php.ini
中的 output_buffering 關閉或者設定一個很小的值。之後重啟 php-fpm。
[PHP]
...
output_buffering = 3
...複製程式碼
這樣設定表明輸出的 buffer 不超過 3 個字元。
然後重現一下這個 bug:
<?php
public function test()
{
echo 'asd';
header('a: b');
}複製程式碼
使用 curl 訪問一下,返回的 HTTP body 是 asd 和一個 headers already sent 錯誤資訊,curl -I http://localhost/test
一下看看 header,發現 a: b 並沒有輸出到 header 中。
echo 的內容超出了緩衝區限制的長度,便會作為 HTTP body 輸出給 WEB 伺服器。一旦 echo,PHP 輸出 header 的任務就等於結束了,那麼此時呼叫header()
就會丟擲 headers already sent 的錯誤。
修改一下程式碼:
<?php
public function test()
{
header('b: c');
echo 'asd';
header('a: b');
}複製程式碼
此時輸出的 HTTP body 內容是相同的,但是 curl -I 看到的 header 中多了 b: c,說明 echo 之前的header()
正確的輸出了內容。
setcookie 方法也會傳送 header:set-cookie: xxx
,所以一樣會引起這個問題。
在上面的例子中,我們將 output_buffering 設定為 3,如果 echo 的內容小於 3,是不會引起問題的,因為緩衝區緩衝了 echo 的內容,會在 header 輸出之後再輸出緩衝內容。在實際的應用中,可以給 output_buffering 一個稍大一些的值。
但是,不能依賴 output_buffering 的大小,應該儘量避免在業務程式碼中使用 echo 和 print 系函式。
怎樣使用 echo
echo 很方便,古董 PHP 開發還會使用 echo 除錯大法,而且我們要輸出 HTTP 內容肯定要用到 echo 或者 print,怎麼可能避免使用呢?
業務程式碼中儘量避免
我們應該避免在業務中使用,而不是禁止使用。當使用 echo 的時候,因為上述原因出現 headers already sent 錯誤,要看 output_buffering 設定的大小和 echo 內容的長度,這給 debug 帶來了很大的不確定性,測試環境很可能會漏掉這個 case。
在業務中,可能用到 echo 的原因有:1. 除錯程式碼,檢視變數;2. 命令列指令碼的輸出。對於 1,建議通過除錯工具除錯,或者使用外掛 clockwork;對於 2,可以在指令碼中通過標準輸出來輸出重要內容,並不需要使用 echo。
<?php
fwrite(STDOUT, $content);複製程式碼
如果基於某種原因一定要使用,可以將一段輸出用 ob_start 和 ob_end 包裹起來。被包裹的輸出會進入內部緩衝區,在需要的時候再 flush 出來。
<?php
// ob_start 的函式定義
bool ob_start ([ callable $output_callback = NULL [, int $chunk_size = 0 [, int $flags = PHP_OUTPUT_HANDLER_STDFLAGS ]]])複製程式碼
$chunk_size=0
的時候,只有在關閉緩衝區的時候才會輸出緩衝區的內容。[^3]
<?php
public function test()
{
ob_start(); // 開啟緩衝區
echo 'asd';
header('a: b');
ob_end_flush(); // 關閉緩衝區,將緩衝區的內容輸出到 HTTP body
}複製程式碼
一般框架的輸出都是這樣設計的,echo 會包裹在 ob_start 和 ob_end 之間。
ob_start 的問題
ob_start 不能解決 PHP 程式碼不規範導致的 headers already sent:
<?php
public function test()
{
ob_start(); // 開啟緩衝區
echo 'asd';
header('a: b');
ob_end_flush(); // 關閉緩衝區,將緩衝區的內容輸出到 HTTP body
}
// 這段程式碼也會報錯複製程式碼
使用 ob_start 需要及時的將資料輸出出去,否則可能會因為字串拼接和二進位制內容衝突:
<?php
public function test()
{
ob_start(); // 開啟緩衝區
echo 'asd';
imagepng($resource);
ob_end_flush(); // 關閉緩衝區,將緩衝區的內容輸出到 HTTP body
}
// asd 和 imagepng() 的內容混在一起,輸出的圖片不可用複製程式碼
好的實踐
綜上所述,一個良好的實踐是:
- output_buffering 關閉或者設定一個較小的數值[^2]
- 如非必要,不使用 echo 和 print 系函式
- 使用 echo 時,儘量用 ob_start 和 ob_end 包裹
- 使用 ob_start 和 ob_end 包裹時,對自己包裹的內容有清晰的認識,儘量不要跨函式使用 ob_start 和 ob_end
[^1]: 參見 stackoverflow 回答,除此之外,還有 UTF-8 BOM 等其他原因
[^2]: 參見PHP程式訪問報錯Warning: Cannot modify header information - headers already sent by 和 PHP: 執行時配置 - Manual,開啟 output_buffering 可能影響 PHP 執行效率
[^3]: 使用 ob_start 的時候不受 php.ini 中的 output_buffering 大小的影響