大家都知道PHP中有一個名為“輸出緩衝區”層(layer)的東西。這篇文章就是來講解它到底是個什麼東西的?PHP內部是怎麼實現它的?以及在PHP程式中怎麼使用它?這個層並不複雜,但經常會被誤解,很多PHP開發人員並沒有完成掌握它。今天我們就一起來徹底把它搞清楚吧。
我們要討論的東西是基於PHP 5.4(及以上版本),PHP中的OB層從5.4版開始就發生了很多變化,確切說是完全重寫了,有些地方可能都不相容PHP 5.3了。
什麼是輸出緩衝區?
PHP的輸出資料流包含很多位元組,通常都是程式員要PHP輸出的文本,這些文本大多是echo語句或者printf()函數輸出的。對於PHP中的輸出緩衝區,你要知道三點內容。
第一點是任何會輸出點什麼東西的函數都會用到輸出緩衝區,當然這說的是用PHP寫的程式。如果你是編寫PHP擴充,你使用的函數(C函數)可能會直接將輸出寫到SAPI緩衝區層,而不需要經過OB層。你可以在源檔案main/php_output.h中瞭解到這些C函數的API文檔,這個檔案給我們提供了很多其他的資訊,例如預設的緩衝區大小。
第二點你需要知道的是輸出緩衝區層不是唯一用於緩衝輸出的層,它實際上只是很多層中的一個。最後一點你要記住輸出緩衝區層的行為跟你使用的SAPI(web或cli)相關,不同的SAPI可能有不同的行為。我們先通過一個圖片來看看這些層的關係:
上面這張圖片展示了PHP中的三種緩衝區層的邏輯關係。上面的兩層就是我們通常所認識到的“輸出緩衝區”,最後一個是SAPI中的輸出緩衝區。這些都是PHP中的層,當輸出的位元組離開PHP進入電腦體繫結構中的更底層時,緩衝區又會不斷出現(終端緩衝區(terminal buffer),fast-cgi緩衝區,web伺服器緩衝區,OS緩衝區,TCP/IP棧緩衝區。。。)。請記住一個通用原則,除了這篇文章中討論的PHP中的情況外,一個軟體的很多部分都會先保留資訊,然後再把它們傳遞到下一部分,直到最終把這些資訊傳遞給使用者。
CLI的SAPI有點特殊,這裡重點講一下。CLI會將INI配置中的output_buffer選項強制設定為0,這表示禁用預設PHP輸出緩衝區。所以在CLI中,預設情況下你要輸出的東西會直接傳遞到SAPI層,除非你手動調用ob_()類函數。並且在CLI中,implicit_flush的值也會被設定為1。我們經常會搞不清implicit_flush的作用,原始碼已說明一切:當implicit_flush被設定為開啟(值為1),一旦有任何輸出寫入到SAPI緩衝區層,它都會立即重新整理(flush,意思是把這些資料寫入到更低層,並且緩衝區會被清空)。換句話說就是:任何時候當你寫入任何資料到CLI SAPI中時,CLI SAPI都會立即將這些資料扔到它的下一層去,一般會是標準輸出管道,write()和fflush()這兩個函數就是負責幹這個事情的。簡單,對吧!
預設PHP輸出緩衝區
如果你使用不同於CLI的SAPI,像PHP-FPM,你會用到下面三個跟緩衝區相關的INI配置選項:
output_buffering
implicit_flush
output_handler
在搞清楚這幾個選項的含義之前,有一點需要先說明下,不能在運行時使用ini_set()改這幾個選項的值。這些選項的值會在PHP程式啟動的時候,還沒有運行任何指令碼之前解析,所以也許在運行時可以使用ini_set()改變它們的值,但改變後的值並不會生效,一切都已經太遲了,因為輸出緩衝區層已經啟動並已啟用。你只能通過編輯php.ini檔案或者是在執行PHP程式的時候使用-d選項才能改變它們的值。
預設情況下,PHP發行版會在php.ini中把output_buffering設定為4096個位元組。如果你不使用任何php.ini檔案(或者也不會在啟動PHP的時候使用-d選項),它的預設值將為0,這表示禁用輸出緩衝區。如果你將它的值設定為“ON”,那麼預設的輸出緩衝區的大小將是16kb。你可能已經猜到了,在web應用環境中對輸出的內容使用緩衝區對效能有好處。預設的4k的設定是一個合適的值,這意味著你可以先寫入4096個ASCII字元,然後再跟下面的SAPI層通訊。並且在web應用環境中,通過socket一個位元組一個位元組的傳輸訊息的方式對效能並不好。更好的方式是把所有內容一次性傳輸給伺服器,或者至少是一塊一塊地傳輸。層與層之間的資料交換的次數越少,效能越好。你應該總是保持輸出緩衝區處於可用狀態,PHP會負責在請求結束後把它們中的內容傳輸給終端使用者,你不用做任何事情。
implicit_flush已在前面談論CLI的時候提到過。對於其他的SAPI,implicit_flush預設被設定為關閉(off),這是正確的設定,因為只要有新資料寫入就重新整理SAPI的做法很可能並非你所希望的。對於FastCGI協議,重新整理操作(flushing)是每次寫入後都發送一個FastCGI數組包(packet),如果發送資料包之前先把FastCGI的緩衝區寫滿會更好一些。如果你想手動重新整理SAPI的緩衝區,使用PHP的flush()函數。如果你想寫一次就重新整理一次,你可以設定INI配置中的implicit_flush選項,或者調用一次ob_implicit_flush()函數。
output_handler是一個回呼函數,它可以在緩衝區重新整理之前修改緩衝區中的內容。PHP的擴充提供了很多回呼函數(使用者也可以自己編寫回呼函數,下面會講到)。
ob_gzhandler : 使用ext/zlib壓縮輸出
mb_output_handler : 使用ext/mbstring轉換字元編碼
ob_iconv_handler : 使用ext/iconv轉換字元編碼
ob_tidyhandler : 使用ext/tidy整理輸出的HTML文本
ob_[inflate/deflate]_handler : 使用ext/http壓縮輸出
ob_etaghandler : 使用ext/http自動產生HTTP的Etag
緩衝區中的內容會傳遞給你選擇的回呼函數(只能用一個)來執行內容轉換的工作,所以如果你想擷取PHP傳輸給web伺服器以及使用者的內容,你可以使用輸出緩衝區回調。當前有一點也需要提一下,這裡說的“輸出”指的是訊息頭(headers)和訊息體(body)。HTTP的訊息頭也是OB層的一部分。
訊息頭和訊息體
當你使用一個輸出緩衝區(無論是使用者的,還是PHP的)的時候,你可能想以你希望的方式發送HTTP訊息頭和內容。你知道任何協議都必須在發送訊息體之前發送訊息頭(這也是為什麼叫做“頭”),但是如果你使用了輸出緩衝區層,那麼PHP會接管這些,而不需要你操心。實際上,任何跟訊息頭的輸出有關的PHP函數(header(),setcookie(),session_start())都使用了內部的sapi_header_op()函數,這個函數只會把內容寫入到訊息頭緩衝區中。然後當你輸出內容是,例如使用printf(),這些內容會寫入到輸出緩衝區(假設只有一個)。當這個輸出緩衝區中的內容需要被發送時,PHP會先發送訊息頭,然後發送訊息體。PHP為你搞定了所有的事情。如果你覺得不爽,想自己動手,那你就只有把輸出緩衝區禁用掉,除此之外別無他法。
使用者輸出緩衝區(user output buffers)
對於使用者輸出緩衝區,我們先通過一個樣本來看看它是怎麼工作的,以及你可以用它來做什麼。再強調一下,如果你想使用預設PHP輸出緩衝區層的話,你不能使用CLI,因為它已禁用了這個層。下面的這個樣本用的就是預設PHP輸出緩衝區,使用了PHP的內部web伺服器SAPI:
/* launched via php -doutput_buffering=32 -dimplicit_flush=1 -S127.0.0.1:8080 -t/var/www */echo str_repeat('a', 31);sleep(3);echo 'b';sleep(3);echo 'c';
在這個樣本中,啟動PHP的時候將預設輸出緩衝區的大小設定為32位元組,程式運行後會先向其中寫入31個位元組,然後進入睡眠狀態。此時螢幕是空的,什麼都不會輸出,跟預計一樣。2秒之後睡眠結束,再寫入了一個位元組,這個位元組填滿了緩衝區,它會立即重新整理自身,把裡面的資料傳遞給SAPI層的緩衝區,因為我們將implicit_flush設定為1,所以SAPI層的緩衝區也會立即重新整理到下一層。字串’aaaaaaaaaa{31個a}b’會出現在螢幕上,然後指令碼再次進入睡眠狀態。2秒之後,再輸出一個位元組,此時緩衝區中有31個空位元組,但是PHP指令碼已執行完畢,所以包含這1個位元組的緩衝區也會立即重新整理,從而會在螢幕上輸出字串’c’。
從這個樣本我們可以看到預設PHP輸出緩衝區是如何工作的。我們沒有調用任何跟緩衝區相關的函數,但這並不意味這它不存在,你要認識到它就存在當前程式的運行環境中(在非CLI模式中才有效)。
OK,現在開始討論使用者輸出緩衝區,它通過調用ob_start()建立,我們可以建立很多這種緩衝區(至到記憶體耗盡為止),這些緩衝區組成一個堆棧結構,每個建立緩衝區都會堆疊到之前的緩衝區上,每當它被填滿或者溢出,都會執行重新整理操作,然後把其中的資料傳遞給下一個緩衝區。
ob_start(function($ctc) { static $a = 0; return $a++ . '- ' . $ctc . "\n";}, 10);ob_start(function($ctc) { return ucfirst($ctc); }, 3);echo "fo";sleep(2);echo 'o';sleep(2);echo "barbazz";sleep(2);echo "hello";/* 0- FooBarbazz\n 1- Hello\n */
在此我代替原作者講解下這個樣本。我們假設第一個ob_start建立的使用者緩衝區為緩衝區1,第二個ob_start建立的為緩衝區2。按照棧的後進先出原則,任何輸出都會先存放到緩衝區2中。
緩衝區2的大小為3個位元組,所以第一個echo語句輸出的字串'fo'(2個位元組)會先存放在緩衝區2中,還差一個字元,當第二echo語句輸出的'o'後,緩衝區2滿了,所以它會重新整理(flush),在重新整理之前會先調用ob_start()的回呼函數,這個函數會將緩衝區內的字串的首字母轉換為大寫,所以輸出為'Foo'。然後它會被儲存在緩衝區1中,緩衝區1的大小為10。
第三個echo語句會輸出'barbazz',它還是會先放到緩衝區2中,這個字串有7個位元組,緩衝區2已經溢出了,所以它會立即重新整理,調用回呼函數得到的結果為'Barbazz',然後被傳遞到緩衝區1中。這個時候緩衝區1中儲存了'FooBarbazz',10個字元,緩衝區1會重新整理,同樣的先會調用ob_start()的回呼函數,緩衝區1的回呼函數會在字串前面添加行號,以及在尾部添加一個斷行符號符,所以輸出的第一行是'o- FooBarbazz'。
最後一個echo語句輸出了字串'hello',它大於3個字元,所以會觸發緩衝區2重新整理,因為此時指令碼已執行完畢,所以也會立即重新整理緩衝區1,最終得到的第二行輸出為'1- Hello'。
輸出緩衝區的內部實現
自5.4版後,整個緩衝區層都被重寫了(由Michael Wallner完成)。之前的代碼很垃圾,很多事情都做不了,並且有很多bug。這篇文章會給你提供更多相關資訊。所以PHP 5.4才會對這部分進行重新,現在的設計更好,代碼也更整潔,添加了一些新特性,跟5.3版的不相容問題也很少。贊一個!
其中最贊的一個特性是擴充可以聲明它自己的輸出緩衝區回調與其他擴充提供的回調衝突。在此之前,這是不可能的,之前如果要開發使用輸出緩衝區的擴充,必須先搞清楚所有其他提供了緩衝區回調的擴充可能帶來的影響。
下面是一個簡單的樣本,它展示了怎樣註冊一個回呼函數來將緩衝區中的字元轉換為大寫,這個樣本的代碼可能不是很好,但是足以滿足我們的目的:
#ifdef HAVE_CONFIG_H#include "config.h"#endif#include "php.h"#include "php_ini.h"#include "main/php_output.h"#include "php_myext.h"static int myext_output_handler(void **nothing, php_output_context *output_context){ char *dup = NULL; dup = estrndup(output_context->in.data, output_context->in.used); php_strtoupper(dup, output_context->in.used); output_context->out.data = dup; output_context->out.used = output_context->in.used; output_context->out.free = 1; return SUCCESS;}PHP_RINIT_FUNCTION(myext){ php_output_handler *handler; handler = php_output_handler_create_internal("myext handler", sizeof("myext handler") -1, myext_output_handler, /* PHP_OUTPUT_HANDLER_DEFAULT_SIZE */ 128, PHP_OUTPUT_HANDLER_STDFLAGS); php_output_handler_start(handler); return SUCCESS;}zend_module_entry myext_module_entry = { STANDARD_MODULE_HEADER, "myext", NULL, /* Function entries */ NULL, NULL, /* Module shutdown */ PHP_RINIT(myext), /* Request init */ NULL, /* Request shutdown */ NULL, /* Module information */ "0.1", /* Replace with version number for your extension */ STANDARD_MODULE_PROPERTIES};#ifdef COMPILE_DL_MYEXTZEND_GET_MODULE(myext)#endif
陷阱
大部分陷阱都已經揭示出來了。有一些是邏輯的問題,有一些是隱藏的。邏輯方面,最明顯的是你不應該在輸出緩衝區回呼函數內調用任何緩衝區相關的函數,也不要在回呼函數中輸出任何東西。
相對不太明顯的是有些PHP的內建函式也使用了輸出緩衝區,它們會疊加到其他的緩衝區上,這些函數會填滿自己的緩衝區然後重新整理,或者是返回裡面的內容。print_r()、highlight_file()和highlight_file::handle()都是這類函數。你不應該在輸出緩衝區的回呼函數中使用這些函數。這種行為會導致未定義的錯誤,或者至少得不到你期望的結果。
總結
輸出層(output layer)就像一個網,它會把所有從PHP”遺漏“的輸出圈起來,然後把它們儲存到一個大小固定的緩衝區中。當緩衝區被填滿了的時,裡面的內容會重新整理(寫入)到下一層(如果有的話),或者是寫入到下面的邏輯層:SAPI緩衝區。開發人員可以控制緩衝區的數量、大小以及在每個緩衝區層可以執行的操作(清除、重新整理和刪除)。這種方式非常靈活,它允許庫和架構設計者可以完全控制它們自己輸出的內容,並把它們放到一個全域的緩衝區中。對於輸出,我們需要知道任何輸出資料流的內容和任何HTTP訊息頭,PHP都會以正確的順序發送它們。
輸出緩衝區也有一個預設緩衝區,可以通過設定3個INI配置選項來控制它,它們是為了防止出現過大量的細小的寫入操作,從而造成訪問SAPI層過於頻繁,這樣網路消耗會很大,不利於效能。PHP的擴充也可以定義回呼函數,然後在每個緩衝區上執行這個回調,這種應用已經有很多了,例如執行資料壓縮,HTTP訊息頭管理以及搞很多其他的事情。