這篇文章是翻譯自Julien Pauli的博客文章php output buffer in deep,Julien是PHP源碼的資深開發和維護人員。這篇文章從多個方面講解了PHP中的輸出緩沖區以及怎么使用它。輸出緩沖區可能一直都是PHP開發人員的一個盲點,很多人可能只是知道這個東西,而且也知道大概怎么使用,但對于它為什么是這個樣子,以及還可能是其他什么樣子,可能并不了解,這篇文章可以解決你的所有困惑!
引言
大家都知道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%20buffer),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%20SAPI中時,CLI%20SAPI都會立即將這些數據扔到它的下一層去,一般會是標準輸出管道,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%20:%20使用ext/zlib壓縮輸出 mb_output_handler%20:%20使用ext/mbstring轉換字符編碼 ob_iconv_handler%20:%20使用ext/iconv轉換字符編碼 ob_tidyhandler%20:%20使用ext/tidy整理輸出的html文本 ob_[inflate/deflate]_handler%20:%20使用ext/http壓縮輸出 ob_etaghandler%20:%20使用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%20output%20buffers)
對于用戶輸出緩沖區,我們先通過一個示例來看看它是怎么工作的,以及你可以用它來做什么。再強調一下,如果你想使用默認PHP輸出緩沖區層的話,你不能使用CLI,因為它已禁用了這個層。下面的這個示例用的就是默認PHP輸出緩沖區,使用了PHP的內部web服務器SAPI:
/*%20launched%20via%20php%20-doutput_buffering=32%20-dimplicit_flush=1%20-S127.0.0.1:8080%20-t/var/www%20*/ echo%20str_repeat('a',%2031); sleep(3); echo%20'b'; sleep(3); echo%20'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)%20{%20static%20$a%20=%200;%20return%20$a++%20.%20'-%20'%20.%20$ctc%20.%20"n";},%2010); ob_start(function($ctc)%20{%20return%20ucfirst($ctc);%20},%203); echo%20"fo"; sleep(2); echo%20'o'; sleep(2); echo%20"barbazz"; sleep(2); echo%20"hello"; /*%200-%20FooBarbazzn%201-%20Hellon%20*/
在此我代替原作者講解下這個示例。我們假設第一個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-%20FooBarbazz'。
最后一個echo語句輸出了字符串'hello',它大于3個字符,所以會觸發緩沖區2刷新,因為此時腳本已執行完畢,所以也會立即刷新緩沖區1,最終得到的第二行輸出為'1-%20Hello'。
輸出緩沖區的內部實現
自5.4版后,整個緩沖區層都被重寫了(由Michael%20Wallner完成)。之前的代碼很垃圾,很多事情都做不了,并且有很多bug。這篇文章會給你提供更多相關信息。所以PHP%205.4才會對這部分進行重新,現在的設計更好,代碼也更整潔,添加了一些新特性,跟5.3版的不兼容問題也很少。贊一個!
其中最贊的一個特性是擴展可以聲明它自己的輸出緩沖區回調與其他擴展提供的回調沖突。在此之前,這是不可能的,之前如果要開發使用輸出緩沖區的擴展,必須先搞清楚所有其他提供了緩沖區回調的擴展可能帶來的影響。
下面是一個簡單的示例,它展示了怎樣注冊一個回調函數來將緩沖區中的字符轉換為大寫,這個示例的代碼可能不是很好,但是足以滿足我們的目的:
#ifdef%20HAVE_CONFIG_H #include%20"config.h" #endif #include%20"php.h" #include%20"php_ini.h" #include%20"main/php_output.h" #include%20"php_myext.h" static%20int%20myext_output_handler(void%20**nothing,%20php_output_context%20*output_context) { %20char%20*dup%20=%20NULL; %20dup%20=%20estrndup(output_context->in.data,%20output_context->in.used); %20php_strtoupper(dup,%20output_context->in.used); %20output_context->out.data%20=%20dup; %20output_context->out.used%20=%20output_context->in.used; %20output_context->out.free%20=%201; %20return%20SUCCESS; } PHP_RINIT_FUNCTION(myext) { %20php_output_handler%20*handler; %20handler%20=%20php_output_handler_create_internal("myext%20handler",%20sizeof("myext%20handler")%20-1,%20myext_output_handler,%20/*%20PHP_OUTPUT_HANDLER_DEFAULT_SIZE%20*/%20128,%20PHP_OUTPUT_HANDLER_STDFLAGS); %20php_output_handler_start(handler); %20return%20SUCCESS; } zend_module_entry%20myext_module_entry%20=%20{ %20STANDARD_MODULE_HEADER, %20"myext", %20NULL,%20/*%20Function%20entries%20*/ %20NULL, %20NULL,%20/*%20Module%20shutdown%20*/ %20PHP_RINIT(myext),%20/*%20Request%20init%20*/ %20NULL,%20/*%20Request%20shutdown%20*/ %20NULL,%20/*%20Module%20information%20*/ %20"0.1",%20/*%20Replace%20with%20version%20number%20for%20your%20extension%20*/ %20STANDARD_MODULE_PROPERTIES }; #ifdef%20COMPILE_DL_MYEXT ZEND_GET_MODULE(myext) #endif
陷阱
大部分陷阱都已經揭示出來了。有一些是邏輯的問題,有一些是隱藏的。邏輯方面,最明顯的是你不應該在輸出緩沖區回調函數內調用任何緩沖區相關的函數,也不要在回調函數中輸出任何東西。
相對不太明顯的是有些PHP的內部函數也使用了輸出緩沖區,它們會疊加到其他的緩沖區上,這些函數會填滿自己的緩沖區然后刷新,或者是返回里面的內容。print_r()、highlight_file()和highlight_file::handle()都是這類函數。你不應該在輸出緩沖區的回調函數中使用這些函數。這種行為會導致未定義的錯誤,或者至少得不到你期望的結果。
總結
輸出層(output%20layer)就像一個網,它會把所有從PHP”遺漏“的輸出圈起來,然后把它們保存到一個大小固定的緩沖區中。當緩沖區被填滿了的時,里面的內容會刷新(寫入)到下一層(如果有的話),或者是寫入到下面的邏輯層:SAPI緩沖區。開發人員可以控制緩沖區的數量、大小以及在每個緩沖區層可以執行的操作(清除、刷新和刪除)。這種方式非常靈活,它允許庫和框架設計者可以完全控制它們自己輸出的內容,并把它們放到一個全局的緩沖區中。對于輸出,我們需要知道任何輸出流的內容和任何HTTP消息頭,PHP都會以正確的順序發送它們。
輸出緩沖區也有一個默認緩沖區,可以通過設置3個INI配置選項來控制它,它們是為了防止出現過大量的細小的寫入操作,從而造成訪問SAPI層過于頻繁,這樣網絡消耗會很大,不利于性能。PHP的擴展也可以定義回調函數,然后在每個緩沖區上執行這個回調,這種應用已經有很多了,例如執行數據壓縮,HTTP消息頭管理以及搞很多其他的事情。