PHP超低記憶體遍歷目錄檔案和讀取超大檔案

小明發表於2019-05-01

這不是一篇教程,這是一篇筆記,所以我不會很系統地論述原理和實現,只簡單說明和舉例。

前言

我寫這篇筆記的原因是現在網路上關於 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 實現效率比較高。

相關文章