为了账号安全,请及时绑定邮箱和手机立即绑定

如何用PHP读取大文件(不宕掉你的服务器)

标签:
PHP

作为PHP开发人员,我们并不经常担心内存管理。 PHP引擎在我们之后做了很好的清理工作,并且短期执行上下文的Web服务器模型意味着即使是最潦草的代码也没有持久的影响。

衡量成功

确保我们对代码进行任何改进的唯一方法是测量不良情况,然后在修改程序后将该测量结果与另一个进行比较。换句话说,除非我们知道“解决方案”对我们有多大帮助(如果有的话),否则我们不知道它是否真的是一个解决方案。

我们可以关注两个度量标准。首先是CPU使用率。我们想要处理的过程有多快或多慢?第二个是内存使用情况。脚本需要执行多少内存?这些通常是成反比的 - 这意味着我们可以以CPU使用为代价来卸载内存使用量,反之亦然。

在异步执行模型中(如使用多进程或多线程PHP应用程序),CPU和内存使用量是重要的考虑因素。在传统的PHP体系结构中,当任何一个达到服务器的限制时,这些通常都会成为问题。

衡量PHP内部的CPU使用情况是不切实际的。不过可以大概的使用top来查看系统CPU的情况。对于Windows,请考虑使用Linux子系统,以便您可以在容器中使用top。

我们常常用来查看使用多少内存的方法是:

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响应的内容。我们不在乎它如果编写,但我们需要确保它以压缩形式备份。
在这两种情况下,我们都需要阅读大文件。首先,我们需要知道数据是什么。 第二,我们不关心数据是什么。

逐行读取文件

有许多用于处理文件的功能。让我们将几个结合到一个自然的文件阅读器中:

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());
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.5MB,峰值内存使用量为12.8MB。现在,让我们使用一个yield来读取每一行:

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

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

    fclose($handle);
}

readTheFile("shakespeare.txt");

require "memory.php";

文本文件的大小相同,但峰值内存使用量为393KB。但是这并不意味着什么,除非我们对我们正在阅读的数据做些什么。当我们看到两个空行时,也许我们可以将文档分成块。像这样的东西:

$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";

即使我们将文本文档拆分为1,216个块,我们仍然只使用459KB的内存。鉴于yield的性质,我们将使用的最多内存是我们需要在迭代中存储最大文本块的内存。在这种情况下,最大的块是101,985个字符。
yield有其他用途,但是这对于大文件的高性能读取来说是非常好的。如果我们需要处理数据,yield可能是最好的方法。

文件之间的管道

在我们不需要操作数据的情况下,我们可以将文件数据从一个文件传递到另一个文件。这通常被称为管道。我们可以通过使用流方法来实现这一点。我们首先编写一个脚本以从一个文件传输到另一个文件,以便我们可以测量内存使用情况:

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

require "memory.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";

这段代码有点奇怪。我们打开两个文件的句柄,第一个处于读取模式,第二个处于写入模式。然后我们从第一个复制到第二个。我们通过再次关闭这两个文件完成。你可能会惊讶地发现你使用的内存是393KB。
这似乎很熟悉。这不是yield在读取每行时用于存储的内容吗?这是因为fgets的第二个参数指定要读取每行的字节数(默认为-1或直到到达新行)。
stream_copy_to_stream的第三个参数是完全相同的参数(具有完全相同的默认值)。 stream_copy_to_stream正在从一个流读取,一次一行,并将其写入其他流。它跳过了yield产生值的部分,因为我们不需要使用该值。
不过管道对我们没有用,所以我们来看看其他可能的例子。假设我们想从我们的CDN输出图像,作为一种重定向的程序路由。我们可以用类似下面的代码来说明它:

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。现在,我们如何尝试流式传输呢?

$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";

内存使用量略少(在400KB),但结果是一样的。如果我们不需要内存信息,我们也可以打印到标准输出。事实上,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://input 只读取,我们可以访问原始请求主体
  • php://output 只写入,我们可以访问原始请求主体
  • php://memory和 php://temp 可读写 是我们可以暂时存储数据的地方。不同之处在于,一旦php:// temp足够大,php就会将数据存储在文件系统中,而php://memory将一直保存在内存中直到耗尽。

    Filters 过滤器

$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.75MB。我们可以做得更好,使用过滤器:

$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。
为了解压缩数据,我们可以通过另一个zlib过滤器压缩文件:

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

自定义流

fopen和file_get_contents有它们自己的一组默认选项,但这些都是完全可定制的。为了定义它们,我们需要创建一个新的流上下文:

$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);

在这个例子中,我们试图向API发出一个POST请求。 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);

突出显示名称需要匹配新筛选器类的filtername属性。也可以在php://filter/highligh-names/resource=story.txt字符串中使用自定义过滤器。定义过滤器比定义协议容易得多。其中一个原因是协议需要处理目录操作,而过滤器只需处理每个数据块。
如果您有这种想法,我强烈建议尝试创建自定义协议和过滤器。如果可以将过滤器应用于stream_copy_to_stream操作,那么即使在使用大容量文件时,您的应用程序也会在内存旁边使用。想象一下,编写调整图像过滤器或加密应用程序过滤器。

总结

虽然这不是我们经常遇到的问题,但在处理大文件时很容易搞砸。在异步应用程序中,当我们不注意内存使用情况时,将整个服务器关闭很容易。

原文地址: https://www.sitepoint.com/performant-reading-big-files-php/
翻译:含笑666

点击查看更多内容
1人点赞

若觉得本文不错,就分享一下吧!

评论

作者其他优质文章

正在加载中
PHP开发工程师
手记
粉丝
1.6万
获赞与收藏
1807

关注作者,订阅最新文章

阅读免费教程

感谢您的支持,我会继续努力的~
扫码打赏,你说多少就多少
赞赏金额会直接到老师账户
支付方式
打开微信扫一扫,即可进行扫码打赏哦
今天注册有机会得

100积分直接送

付费专栏免费学

大额优惠券免费领

立即参与 放弃机会
意见反馈 帮助中心 APP下载
官方微信

举报

0/150
提交
取消