這不是一篇教程,這是一篇筆記,所以我不會很系統地論述原理和實現,只簡單說明和舉例。
前言
我寫這篇筆記的原因是現在網路上關於 PHP 遍歷目錄檔案和 PHP 讀取文字檔案的教程和示例程式碼都是極其低效的,低效就算了,有的甚至好意思說是高效,實在辣眼睛。
這篇筆記主要解決這麼幾個問題:
PHP 如何使用超低記憶體快速遍歷數以萬計的目錄檔案?
PHP 如何使用超低記憶體快速讀取幾百MB甚至是GB級檔案?
順便解決哪天我忘了可以通過搜尋引擎搜到我自己寫的筆記來看看。(因為需要 PHP 寫這兩個功能的情況真的很少,我記性不好,免得忘了又重走一遍彎路)
遍歷目錄檔案
網上關於這個方法的實現大多示例程式碼是 glob 或者 opendir + readdir 組合,在目錄檔案不多的情況下是沒問題的,但檔案一多就有問題了(這裡是指封裝成函式統一返回一個陣列的時候),過大的陣列會要求使用超大記憶體,不僅導致速度慢,而且記憶體不足的時候直接就崩潰了。
這時候正確的實現方法是使用 yield 關鍵字返回,下面是我最近使用的程式碼:
<?php
function glob2foreach($path, $include_dirs=false) {
$path = rtrim($path, '/*');
if (is_readable($path)) {
$dh = opendir($path);
while (($file = readdir($dh)) !== false) {
if (substr($file, 0, 1) == '.')
continue;
$rfile = "{$path}/{$file}";
if (is_dir($rfile)) {
$sub = glob2foreach($rfile, $include_dirs);
while ($sub->valid()) {
yield $sub->current();
$sub->next();
}
if ($include_dirs)
yield $rfile;
} else {
yield $rfile;
}
}
closedir($dh);
}
}
// 使用
$glob = glob2foreach('/var/www');
while ($glob->valid()) {
// 當前檔案
$filename = $glob->current();
// 這個就是包括路徑在內的完整檔名了
// echo $filename;
// 指向下一個,不能少
$glob->next();
}
yield 返回的是生成器物件(不瞭解的可以先去了解一下 PHP 生成器),並沒有立即生成陣列,所以目錄下檔案再多也不會出現巨無霸陣列的情況,記憶體消耗是低到可以忽略不計的幾十 kb 級別,時間消耗也幾乎只有迴圈消耗。
讀取文字檔案
讀取文字檔案的情況跟遍歷目錄檔案其實類似,網上教程基本上都是使用 file_get_contents 讀到記憶體裡或者 fopen + feof + fgetc 組合即讀即用,處理小檔案的時候沒問題,但是處理大檔案就有記憶體不足等問題了,用 file_get_contents 去讀幾百MB的檔案幾乎就是自殺。
這個問題的正確處理方法同樣和 yield 關鍵字有關,通過 yield 逐行處理,或者 SplFileObject 從指定位置讀取。
逐行讀取整個檔案:
<?php
function read_file($path) {
if ($handle = fopen($path, 'r')) {
while (! feof($handle)) {
yield trim(fgets($handle));
}
fclose($handle);
}
}
// 使用
$glob = read_file('/var/www/hello.txt');
while ($glob->valid()) {
// 當前行文字
$line = $glob->current();
// 逐行處理資料
// $line
// 指向下一個,不能少
$glob->next();
}
通過 yield 逐行讀取檔案,具體使用多少記憶體取決於每一行的資料量有多大,如果是每行只有幾百位元組的日誌檔案,即使這個檔案超過100M,佔用記憶體也只是KB級別。
但很多時候我們並不需要一次性讀完整個檔案,比如當我們想分頁讀取一個1G大小的日誌檔案的時候,可能想第一頁讀取前面1000行,第二頁讀取第1000行到2000行,這時候就不能用上面的方法了,因為那方法雖然佔用記憶體低,但是數以萬計的迴圈是需要消耗時間的。
這時候,就改用 SplFileObject 處理,SplFileObject 可以從指定行數開始讀取。下面例子是寫入陣列返回,可以根據自己業務決定要不要寫入陣列,我懶得改了。
<?php
function read_file2arr($path, $count, $offset=0) {
$arr = array();
if (! is_readable($path))
return $arr;
$fp = new SplFileObject($path, 'r');
// 定位到指定的行數開始讀
if ($offset)
$fp->seek($offset);
$i = 0;
while (! $fp->eof()) {
// 必須放在開頭
$i++;
// 只讀 $count 這麼多行
if ($i > $count)
break;
$line = $fp->current();
$line = trim($line);
$arr[] = $line;
// 指向下一個,不能少
$fp->next();
}
return $arr;
}
以上所說的都是檔案巨大但是每一行資料量都很小的情況,有時候情況不是這樣,有時候是一行資料也有上百MB,那這該怎麼處理呢?
如果是這種情況,那就要看具體業務了,SplFileObject 是可以通過 fseek 定位到字元位置(注意,跟 seek 定位到行數不一樣),然後通過 fread 讀取指定長度的字元。
也就是說通過 fseek 和 fread 是可以實現分段讀取一個超長字串的,也就是可以實現超低記憶體處理,但是具體要怎麼做還是得看具體業務要求允許你怎麼做。
複製大檔案
順便說下 PHP 複製檔案,複製小檔案用 copy 函式是沒問題的,複製大檔案的話還是用資料流好,例子如下:
<?php
function copy_file($path, $to_file) {
if (! is_readable($path))
return false;
if(! is_dir(dirname($to_file)))
@mkdir(dirname($to_file).'/', 0747, TRUE);
if (
($handle1 = fopen($path, 'r'))
&& ($handle2 = fopen($to_file, 'w'))
) {
stream_copy_to_stream($handle1, $handle2);
fclose($handle1);
fclose($handle2);
}
}
最後
我這隻說結論,沒有展示測試資料,可能難以服眾,如果你持懷疑態度想求證,可以用 memory_get_peak_usage 和 microtime 去測一下程式碼的佔用記憶體和執行時間。
補充:踩坑和修改大檔案
這篇筆記是我昨晚睡不著無聊突然想起來就隨手寫的,今天起來又看了一下,發現有一個巨坑沒提到,雖說不計劃寫成教程,但是這個巨坑必須提一下。
前面生成器物件迴圈程式碼塊裡最後都有一個 $glob->next();
程式碼,意思是指向下一項,這個至關重要,因為如果沒有了它,下一次迴圈獲取到的還是這次的結果。
舉個例子:
有個文字檔案裡面有三行文字,分別是 111111、222222、333333 ,當我們用以下程式碼讀取的時候,while
會迴圈三次,每次 $line
分別對應 111111、222222、333333 。
<?php
// 使用
$glob = read_file('/var/www/hello.txt');
while ($glob->valid()) {
// 當前行文字
$line = $glob->current();
// 逐行處理資料
// $line
// 指向下一個,不能少
$glob->next();
}
但是,如果沒有 $glob->next();
這一行,就會導致 $line
始終是讀到第一行 111111 ,會導致死迴圈或者讀取到的不是預期的資料。
看到這裡你可能會覺得這是廢話,不,不是,理論上不容易出現這個錯誤,但是在實際的程式設計中我們可能會使用 continue
跳到下次迴圈,如果你寫著寫著不記得了,在 $glob->next();
前面使用 continue
跳到下次迴圈,就會導致下次迴圈的 $line
依然是這次的值,導致異常甚至死迴圈。
要解決這個問題,除了保持編碼警惕性,也可以修改下 $glob->next();
的位置。
<?php
// 使用
$glob = read_file('/var/www/hello.txt');
while ($glob->valid()) {
// 當前項
$line = $glob->current();
// 指向下一個,不能少
$glob->next();
// 注意,這時已經指向下一項
// 再使用 $glob->current() 獲取到的就不是 $line 的值了,而是下一項的值了
// 在這後面你就可以放心使用 continue 了
// 但是別忘了讀取當前項只能通過 $line 了
// 逐行處理資料
}
這個坑我是踩過的,無意間使用 continue
導致讀取資料不對。其實出現這種錯誤導致死迴圈程式崩潰是好事,立即排查能排查出結果,最可怕的是隻讀錯資料,讓人一時半會兒察覺不到。
另外,補充一下修改大檔案的要點。
要讀大檔案往往會涉及到修改它,如果是從中摘取資料或者大幅度修改,我們可以使用 fopen + fwrite 組合配合生成器物件逐行處理資料之後逐行寫入,這樣效率也是高的,儘量避免存到變數裡再集中寫入以免佔用記憶體爆炸。
<?php
$handle = fopen('/var/www/newhello.txt', 'w');
$glob = read_file('/var/www/hello.txt');
while ($glob->valid()) {
// 當前行文字
$line = $glob->current();
// 逐行處理資料
// 將處理過的寫入新檔案
fwrite($handle, $line . "\n");
// 指向下一個,不能少
$glob->next();
}
fclose($handle);
如果是修改大檔案裡的小細節,這個我還沒做過,不過據我瞭解好像是通過 Stream Functions 的 filter 實現效率比較高。