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

 

更多学习内容请访问:

腾讯T3-T4标准精品PHP架构师教程目录大全,只要你看完保证薪资上升一个台阶(持续更新)图标

 

posted @ 2020-04-14 14:47  八重樱  阅读(1206)  评论(0编辑  收藏  举报