PHP 怎麼快速讀取大檔案

語言: CN / TW / HK

作為 PHP 開發人員,我們不需要擔心記憶體管理。 PHP 引擎在我們背後進行了出色的清理工作,短暫執行上下文的 web server 模型意味著即使是最草率的程式碼也沒有持久的影響。

在極少數情況下,我們可能需要走出舒適的界限 — 例如,當我們嘗試在可以建立的最小 VPS 上為大型專案執行 Composer 時,或者需要在同樣小的伺服器上讀取大檔案時。

這是我們將在本教程中討論的一個問題。

衡量成功

唯一能確認我們對程式碼所做改進是否有效的方式是:衡量一個糟糕的情況,然後對比我們已經應用改進後的衡量情況。換言之,除非我們知道 “解決方案” 能幫我們到什麼程度 (如果有的話),否則我們並不知道它是否是一個解決方案。

我們可以關注兩個指標。首先是 CPU 使用率。我們要處理的過程執行得有多快或多慢?其次是記憶體使用率。指令碼執行要佔用多少記憶體?這些通常是成反比的 — 這意味著我們能夠以 CPU 使用率為代價減少記憶體的使用率,反之亦可。

在一個非同步處理模型 (例如多程序或多執行緒 PHP 應用程式) 中,CPU 和記憶體使用率都是重要的考量。在傳統 PHP 架構中,任一達到伺服器所限時這些通常都會成為一個麻煩。

測量 PHP 內部的 CPU 使用率是難以實現的。如果你確實關注這一塊,可用考慮在 Ubuntu 或 macOS 中使用類似於 top 的命令。對於 Windows,則可用考慮使用 Linux 子系統,這樣你就能夠在 Ubuntu 中使用 top 命令了。

在本教程中,我們將測量記憶體使用情況。我們將看一下 “傳統” 指令碼會使用多少記憶體。我們也會實現一些優化策略並對它們進行度量。最後,我希望你能做一個合理的選擇。

以下是我們用於檢視記憶體使用量的方法:

// formatBytes 方法取材於 php.net 文件

memory_get_peak_usage();

function formatBytes($bytes, $precision = 2) {
    $units = array("b", "kb", "mb", "gb", "tb");

    $bytes = max($bytes, 0);
    $pow = floor(($bytes ? log($bytes) : 0) / log(1024));
    $pow = min($pow, count($units) - 1);

    $bytes /= (1 << (10 * $pow));

    return round($bytes, $precision) . " " . $units[$pow];
}

我們將在指令碼的結尾處使用這些方法,以便於我們瞭解哪個指令碼一次使用了最多的記憶體。

我們有什麼選擇?

我們有許多方法來有效地讀取檔案。有以下兩種場景會使用到他們。我們可能希望同時讀取和處理所有資料,對處理後的資料進行輸出或者執行其他操作。 我們還可能希望對資料流進行轉換而不需要訪問到這些資料。

想象以下,對於第一種情況,如果我們希望讀取檔案並且把每 10,000 行的資料交給單獨的佇列進行處理。我們則需要至少把 10,000 行的資料載入到記憶體中,然後把它們交給佇列管理器(無論使用哪種)。

對於第二種情況,假設我們想要壓縮一個 API 響應的內容,這個 API 響應特別大。雖然這裡我們不關心它的內容是什麼,但是我們需要確保它被以一種壓縮格式備份起來。

這兩種情況,我們都需要讀取大檔案。不同的是,第一種情況我們需要知道資料是什麼,而第二種情況我們不關心資料是什麼。接下來,讓我們來深入討論一下這兩種做法.

逐行讀取檔案

PHP 處理檔案的函式很多,讓我們將其中一些函式結合起來實現一個簡單的檔案閱讀器

// from memory.php

function formatBytes($bytes, $precision = 2) {
    $units = array("b", "kb", "mb", "gb", "tb");

    $bytes = max($bytes, 0);
    $pow = floor(($bytes ? log($bytes) : 0) / log(1024));
    $pow = min($pow, count($units) - 1);

    $bytes /= (1 << (10 * $pow));

    return round($bytes, $precision) . " " . $units[$pow];
}

print formatBytes(memory_get_peak_usage());

 

// from reading-files-line-by-line-1.php
function readTheFile($path) {
    $lines = [];
    $handle = fopen($path, "r");

    while(!feof($handle)) {
        $lines[] = trim(fgets($handle));
    }

    fclose($handle);
    return $lines;
}

readTheFile("shakespeare.txt");

require "memory.php";

我們正在閱讀一個包括莎士比亞全部著作的文字檔案。該檔案大小大約為 5.5 MB。記憶體使用峰值為 12.8 MB。現在,讓我們使用生成器來讀取每一行:

// from reading-files-line-by-line-2.php

function readTheFile($path) {
    $handle = fopen($path, "r");

    while(!feof($handle)) {
        yield trim(fgets($handle));
    }

    fclose($handle);
}

readTheFile("shakespeare.txt");

require "memory.php";

檔案大小相同,但是記憶體使用峰值為 393 KB。這個資料意義大不大,因為我們需要加入對檔案資料的處理。例如,當出現兩個空白行時,將文件拆分為多個塊:

// from reading-files-line-by-line-3.php

$iterator = readTheFile("shakespeare.txt");

$buffer = "";

foreach ($iterator as $iteration) {
    preg_match("/\n{3}/", $buffer, $matches);

    if (count($matches)) {
        print ".";
        $buffer = "";
    } else {
        $buffer .= $iteration . PHP_EOL;
    }
}

require "memory.php";

有人猜測這次使用多少記憶體嗎?即使我們將文字文件分為 126 個塊,我們仍然只使用 459 KB 的記憶體。鑑於生成器的性質,我們將使用的最大記憶體是在迭代中需要儲存最大文字塊的記憶體。在這種情況下,最大的塊是 101985 個字元。

生成器還有其他用途,但顯然它可以很好的讀取大型檔案。如果我們需要處理資料,生成器可能是最好的方法。

檔案之間的管道

在不需要處理資料的情況下,我們可以將檔案資料從一個檔案傳遞到另一個檔案。這通常稱為管道 (大概是因為除了兩端之外,我們看不到管道內的任何東西,當然,只要它是不透明的)。我們可以通過流 (stream) 來實現,首先,我們編寫一個指令碼實現一個檔案到另一個檔案的傳輸,以便我們可以測量記憶體使用情況:

// from piping-files-1.php

file_put_contents(
    "piping-files-1.txt", file_get_contents("shakespeare.txt")
);

require "memory.php";

結果並沒有讓人感到意外。該指令碼比其複製的文字檔案使用更多的記憶體來執行。這是因為指令碼必須在記憶體中讀取整個檔案直到將其寫入另外一個檔案。對於小的檔案而言,這種操作是 OK 的。但是將其用於大檔案時,就不是那麼回事了。

 

讓我們嘗試從一個檔案流式傳輸 (或管道傳輸) 到另一個檔案:

// from piping-files-2.php

$handle1 = fopen("shakespeare.txt", "r");
$handle2 = fopen("piping-files-2.txt", "w");

stream_copy_to_stream($handle1, $handle2);

fclose($handle1);
fclose($handle2);

require "memory.php";

這段程式碼有點奇怪。我們開啟兩個檔案的控制代碼,第一個處於讀取模式,第二個處於寫入模式。然後,我們從第一個複製到第二個。我們通過再次關閉兩個檔案來完成。當你知道記憶體使用為 393 KB 時,可能會感到驚訝。這個數字看起來很熟悉,這不就是利用生成器儲存逐行讀取內容時所使用的記憶體嗎。這是因為 fgets 的第二個引數定義了每行要讀取的位元組數 (預設為 -1 或到達新行之前的長度)。stream_copy_to_stream 的第三個引數是相同的(預設值完全相同)。stream_copy_to_stream 一次從一個流讀取一行,並將其寫入另一流。由於我們不需要處理該值,因此它會跳過生成器產生值的部分

單單傳輸文字還不夠實用,所以考慮下其他例子。假設我們想從 CDN 輸出影象,可以用以下程式碼來描述

// from piping-files-3.php

file_put_contents(
    "piping-files-3.jpeg", file_get_contents(
        "http://github.com/assertchris/uploads/raw/master/rick.jpg"
    )
);

// ...or write this straight to stdout, if we don't need the memory info

require "memory.php";

想象一下應用程度執行到該步驟。這次我們不是要從本地檔案系統中獲取影象,而是從 CDN 獲取。我們用 file_get_contents 代替更優雅的處理方式 (例如 Guzzle),它們的實際效果是一樣的。

記憶體使用情況為 581KB,現在,我們如何嘗試進行流傳輸呢?

// from piping-files-4.php

$handle1 = fopen(
    "http://github.com/assertchris/uploads/raw/master/rick.jpg", "r"
);

$handle2 = fopen(
    "piping-files-4.jpeg", "w"
);

// ...or write this straight to stdout, if we don't need the memory info

stream_copy_to_stream($handle1, $handle2);

fclose($handle1);
fclose($handle2);

require "memory.php";

記憶體使用比剛才略少 (400 KB),但是結果是相同的。如果我們不需要記憶體資訊,也可以列印至標準輸出。PHP 提供了一種簡單的方法來執行此操作:

$handle1 = fopen(
    "http://github.com/assertchris/uploads/raw/master/rick.jpg", "r"
);

$handle2 = fopen(
    "php://stdout", "w"
);

stream_copy_to_stream($handle1, $handle2);

fclose($handle1);
fclose($handle2);

// require "memory.php";

其他流

還存在一些流可以通過管道來讀寫。

  • php://stdin 只讀
  • php://stderr 只寫,與 php://stdout 相似
  • php://input 只讀,使我們可以訪問原始請求內容
  • php://output 只寫,可讓我們寫入輸出緩衝區
  • php://memory  php://temp (可讀寫) 是臨時儲存資料的地方。區別在於資料足夠大時 php:/// temp 就會將資料儲存在檔案系統中,而 php:/// memory 將繼續儲存在記憶體中直到耗盡。

過濾器

我們可以對流使用另一個技巧,稱為過濾器。它介於兩者之間,對資料進行了適當的控制使其不暴露給外接。假設我們要壓縮 shakespeare.txt 檔案。我們可以使用 Zip 擴充套件

// from filters-1.php

$zip = new ZipArchive();
$filename = "filters-1.zip";

$zip->open($filename, ZipArchive::CREATE);
$zip->addFromString("shakespeare.txt", file_get_contents("shakespeare.txt"));
$zip->close();

require "memory.php";

這段程式碼雖然整潔,但是總共使用了大概 10.75 MB 的記憶體。我們可以使用過濾器來進行優化

// from filters-2.php

$handle1 = fopen(
    "php://filter/zlib.deflate/resource=shakespeare.txt", "r"
);

$handle2 = fopen(
    "filters-2.deflated", "w"
);

stream_copy_to_stream($handle1, $handle2);

fclose($handle1);
fclose($handle2);

require "memory.php";

在這裡,我們可以看到 php:///filter/zlib.deflate 過濾器,該過濾器讀取和壓縮資源的內容。然後我們可以將該壓縮資料通過管道傳輸到另一個檔案中。這僅使用了 896KB 記憶體。

雖然格式不同,或者說使用 zip 壓縮檔案有其他諸多好處。但是,你不得不考慮:如果選擇其他格式你可以節省 12 倍的記憶體,你會不會心動?

要對資料進行解壓,只需要通過另外一個 zlib 過濾器:

// from filters-2.php

file_get_contents(
    "php://filter/zlib.inflate/resource=filters-2.deflated"
);

自定義流

fopen  file_get_contents 具有它們自己的預設選項集,但是它們是完全可定製的。要定義它們,我們需要建立一個新的流上下文

// from creating-contexts-1.php

$data = join("&", [
    "twitter=assertchris",
]);

$headers = join("\r\n", [
    "Content-type: application/x-www-form-urlencoded",
    "Content-length: " . strlen($data),
]);

$options = [
    "http" => [
        "method" => "POST",
        "header"=> $headers,
        "content" => $data,
    ],
];

$context = stream_content_create($options);

$handle = fopen("http://example.com/register", "r", false, $context);
$response = stream_get_contents($handle);

fclose($handle);

本例中,我們嘗試傳送一個 POST 請求給 API。API 端點是安全的,不過我們仍然使用了 http 上下文屬性(可用於 http 或者 https)。我們設定了一些頭部,並打開了 API 的檔案控制代碼。我們可以將控制代碼以只讀方式開啟,上下文負責編寫。

自定義的內容很多,如果你想了解更多資訊,可檢視對應 文件

建立自定義協議和過濾器

在總結之前,我們先談談建立自定義協議。如果你檢視 文件,可以找到一個示例類:。

Protocol {
    public resource $context;
    public __construct ( void )
    public __destruct ( void )
    public bool dir_closedir ( void )
    public bool dir_opendir ( string $path , int $options )
    public string dir_readdir ( void )
    public bool dir_rewinddir ( void )
    public bool mkdir ( string $path , int $mode , int $options )
    public bool rename ( string $path_from , string $path_to )
    public bool rmdir ( string $path , int $options )
    public resource stream_cast ( int $cast_as )
    public void stream_close ( void )
    public bool stream_eof ( void )
    public bool stream_flush ( void )
    public bool stream_lock ( int $operation )
    public bool stream_metadata ( string $path , int $option , mixed $value )
    public bool stream_open ( string $path , string $mode , int $options ,
        string &$opened_path )
    public string stream_read ( int $count )
    public bool stream_seek ( int $offset , int $whence = SEEK_SET )
    public bool stream_set_option ( int $option , int $arg1 , int $arg2 )
    public array stream_stat ( void )
    public int stream_tell ( void )
    public bool stream_truncate ( int $new_size )
    public int stream_write ( string $data )
    public bool unlink ( string $path )
    public array url_stat ( string $path , int $flags )
}

我們並不打算實現其中一個,因為我認為它值得擁有自己的教程。有很多工作要做。但是一旦完成工作,我們就可以很容易地註冊流包裝器:

if (in_array("highlight-names", stream_get_wrappers())) {
    stream_wrapper_unregister("highlight-names");
}

stream_wrapper_register("highlight-names", "HighlightNamesProtocol");

$highlighted = file_get_contents("highlight-names://story.txt");

同樣,也可以建立自定義流過濾器。 文件 有一個示例過濾器類:

Filter {
    public $filtername;
    public $params
    public int filter ( resource $in , resource $out , int &$consumed ,
        bool $closing )
    public void onClose ( void )
    public bool onCreate ( void )
}

可被輕鬆註冊

$handle = fopen("story.txt", "w+");
stream_filter_append($handle, "highlight-names", STREAM_FILTER_READ);

highlight-names 需要與新過濾器類的 filtername 屬性匹配。還可以在 php:///filter/highligh-names/resource=story.txt 字串中使用自定義過濾器。定義過濾器比定義協議要容易得多。原因之一是協議需要處理目錄操作,而過濾器僅需要處理每個資料塊。

如果您願意,我強烈建議您嘗試建立自定義協議和過濾器。如果您可以將過濾器應用於 stream_copy_to_stream 操作,則即使處理令人討厭的大檔案,您的應用程式也將幾乎不使用任何記憶體。想象一下編寫調整大小影象過濾器或加密應用程式過濾器。

如果你願意,我強烈建議你嘗試建立自定義協議和過濾器。如果你可以將過濾器應用於 stream_copy_to_stream 操作,即使處理煩人的大檔案,你的應用程式也幾乎不使用任何記憶體。想象下編寫 resize-image 過濾器和 encrypt-for-application 過濾器吧。

總結

雖然這不是我們經常遇到的問題,但是在處理大檔案時的確很容易搞砸。在非同步應用中,如果我們不注意記憶體的使用情況,很容易導致伺服器的崩潰。

本教程希望能帶給你一些新的想法(或者更新你的對這方面的固有記憶),以便你能夠更多的考慮如何有效地讀取和寫入大檔案。當我們開始熟悉和使用流和生成器並停止使用諸如 file_get_contents 這樣的函式時,這方面的錯誤將全部從應用程式中消失,這不失為一件好事。

以上內容希望幫助到大家,很多PHPer在進階的時候總會遇到一些問題和瓶頸,業務程式碼寫多了沒有方向感,更多PHP大廠PDF面試文件,PHP進階架構視訊資料,PHP精彩好文免費獲取可以關注公眾號:PHP開源社群,或者訪問:

2021金三銀四大廠面試真題集錦,必看!

四年精華PHP技術文章整理合集——PHP框架篇

四年精華PHP技術文合集——微服務架構篇

四年精華PHP技術文合集——分散式架構篇

四年精華PHP技術文合集——高併發場景篇

四年精華PHP技術文章整理合集——資料庫篇