首頁  >  文章  >  後端開發  >  聊聊怎麼利用PHP讀取大檔案(教學分享)

聊聊怎麼利用PHP讀取大檔案(教學分享)

青灯夜游
青灯夜游轉載
2022-09-22 20:09:265341瀏覽

PHP如何讀取大檔案?以下這篇文章跟大家介紹一下利用PHP讀取大檔案的方法,希望對大家有幫助!

聊聊怎麼利用PHP讀取大檔案(教學分享)

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

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

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

本教學的程式碼可以在這裡找到 GitHub

衡量成功

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

我們可以專注於兩個指標。首先是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(
        "https://github.com/assertchris/uploads/raw/master/rick.jpg"
    )
);

// ...or write this straight to stdout, if we don&#39;t need the memory info

require "memory.php";

想象一下应用程度执行到该步骤。这次我们不是要从本地文件系统中获取图像,而是从 CDN 获取。我们用 file_get_contents 代替更优雅的处理方式(例如Guzzle),它们的实际效果是一样的。

内存使用情况为 581KB,现在,我们如何尝试进行流传输呢?

// from piping-files-4.php

$handle1 = fopen(
    "https://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&#39;t need the memory info

stream_copy_to_stream($handle1, $handle2);

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

require "memory.php";

内存使用比刚才略少(400 KB),但是结果是相同的。如果我们不需要内存信息,也可以打印至标准输出。PHP 提供了一种简单的方法来执行此操作:

$handle1 = fopen(
    "https://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://memoryphp://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"
);

关于流,在 Understanding Streams in PHPUsing PHP Streams Effectively 文章中已经进行了广泛的讨论,如果你想要换个角度思考,可以查看以上这两篇文章。

自定义流

fopenfile_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("https://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 这样的函数时,这方面的错误将全部从应用程序中消失,这不失为一件好事。

英文原文地址:https://www.sitepoint.com/performant-reading-big-files-php/

译文地址:https://learnku.com/php/t/39751

推荐学习:《PHP视频教程

以上是聊聊怎麼利用PHP讀取大檔案(教學分享)的詳細內容。更多資訊請關注PHP中文網其他相關文章!

陳述:
本文轉載於:learnku.com。如有侵權,請聯絡admin@php.cn刪除