Laravel 的LazyCollection类是一个强大的工具,可以让你用很少的内存处理大量的数据。它是最近添加到框架中的(在 Laravel 6 中引入),并且还不是很出名。
为了让人们更熟悉惰性集合的强大功能,我在第一届Laravel 全球聚会上发表了演讲:
深入研究 Lazy Collections。我们将了解它们是什么、它们在底层是如何工作的,以及如何使用它们来大幅减少应用程序的内存占用。这篇文章是该演讲的书面版本。在 30 分钟的演讲中,您只能塞进这么多内容,因此这篇文章还有大量额外的信息,这些信息并没有成为演讲的内容。
我们将从查看常规集合开始,以及它们为何不真正适合大量数据。然后我们将看到惰性集合如何帮助我们解决这个问题。
定期收藏
自远古以来,该类一直是 Laravel 的主要Collection内容。正如文档所说,常规集合包装了一个原生的 PHP 数组,提供了一个流畅、方便的 API 来与底层数组进行交互。
要创建一个常规集合,您可以将一个值数组传递给它。为了玩玩,让我们从一个简单的数字数组开始:
use Illuminate\Support\Collection;
new Collection([1, 2, 3, 4, 5]);
事实上,Laravel 集合有一个方便的times方法,它可以很方便地创建一个包含一系列数字的集合:
Collection::times(100); // [1, 2, 3, ... 98, 99, 100]
一旦我们有了一个集合实例,我们就可以开始将方法链接到它上面:
Collection::times(100) // [1, 2, 3, ... 98, 99, 100]
->map(fn ($number) => $number * 2) // [2, 4, 6, ... 196, 198, 200]
->filter(fn ($number) => $number % 20 == 0); // [20, 40, 60, ... 160, 180, 200]
虽然这个简化的示例在现实生活中并不是很有用,但它显示了有关常规集合的一个重要事实:所有值都保存在内存中,并且每个方法调用都会创建一个新的内存中值数组(包装在一个新实例中)Collection
。
内存不足
当我们有一个相对较短的列表时,将所有值保存在内存中是可以的,但是随着我们处理的数据量开始增长,我们将很快耗尽内存。
为了说明这一点,让我们尝试创建一个具有十亿个值的集合类:
Collection::times(1000 * 1000 * 1000);
如果您尝试在您的计算机上运行它,您很可能会收到内存不足的错误消息:
允许的内存大小为 3154116608 字节耗尽(尝试分配 34359738376 字节)
原因很简单:该times
方法创建一个集合,将其所有值存储在内存中。尝试为十亿个数字分配内存显然会超出可用内存量。
此外,即使我们只想处理集合的一小部分(例如,取前 1,000 个偶数):
Collection::times(1000 * 1000 * 1000)
->filter(fn ($number) => $number % 2 == 0)
->take(1000);
...它仍然会爆炸,因为每一步都会在内存中构建一个完整的集合。当我们调用该times
方法时,它无法知道我们要过滤它的值;这只会发生在下一步中。
切换到惰性集合
如果我们使用惰性集合尝试上面的代码:
use Illuminate\Support\LazyCollection;
$collection = LazyCollection::times(1000 * 1000 * 1000)
->filter(fn ($number) => $number % 2 == 0)
->take(1000);
...我们不会用完内存,因为这些值都还没有生成(稍后会详细介绍)。事实上,这个代码片段几乎不使用内存!
这怎么可能?通过 s 的力量Generator。
要完全理解惰性集合,我们首先需要对 PHP 生成器有一个扎实的理解:
PHP 中的生成器函数
“生成器函数”是 PHP 5.5 中引入的一个强大的结构。尝试阅读关于它的PHP 文档可能会让人望而生畏,所以让我们一步一步地分解它,在小的、短小的课程中学习它。
生成器函数可以返回多个值
PHP 中的常规函数只能返回一个值。返回第一个值后,所有后续return
语句都将被忽略。
function run() {
return 1;
return 2;
}
dump(run());
这只会转储1
. 在第一条return
语句之后,函数终止,函数中不再执行任何代码。
要从函数返回多个值,我们可以使用yield
关键字而不是return
.
function run() {
yield 1;
yield 2;
}
dump(run());
但是等一下!如果你真的运行这段代码,你可能会对它的输出感到惊讶,它会是这样的:
Generator {
executing: {...}
closed: false
}
一个Generator
对象?我们从未创建过这个对象,也没有从我们的run
函数中返回它。那么它是从哪里来的呢?
第 1 课:这就是“生成器函数”。
yield
函数中关键字的存在本身就告诉 PHP 这不是一个普通函数,而是一个生成器函数。生成器函数的处理方式与常规函数完全不同。
我们可以通过dump
在我们的生成器函数中添加 a 来看到这一点:
function run() {
dump('Did we get here?');
yield 1;
yield 2;
}
run();
这根本不会转储任何东西。为什么?
第 2 课:调用生成器函数甚至不执行函数体内的任何代码。相反,我们得到一个Generator对象,这是一种机制,通过它我们可以逐步执行函数的代码,并在每个
yield
语句处暂停。
使用current
andnext
获取生成器函数的值
要开始单步执行生成器函数中的代码,请使用 的Generator
方法current
。这实际上将开始执行代码,直到第一条yield
语句,并返回被编辑的值yield
。
function run() {
yield 1;
yield 2;
}
$generator = run();
$firstValue = $generator->current();
dump($firstValue);
运行上面的代码将为我们提供,这是从生成器函数中编辑的1
第一个值。yield
第 3 课:使用
Generator
的current
方法“启动”生成器函数。函数中的代码会执行到第一条yield
语句,并返回它的值。
与常规函数不同,生成器函数在返回第一个值后不会终止。该函数仍然存在,等待我们从Generator
.
这一次,不是current
立即转储值,而是让我们首先将生成器移动到下一条yield
语句,并且只记录第二个值:
function run() {
yield 1;
yield 2;
}
$generator = run();
$firstValue = $generator->current();
$generator->next();
$secondValue = $generator->current();
dump($secondValue);
这将转储2
,这是第二条语句返回的值yield
。
第 4 课:使用
Generator
的next
方法将生成器函数推进到下一条yield
语句。
yield
在循环中使用
到目前为止,我们已经处理了yield
生成器函数中的多个硬编码语句。但是语句的真正威力yield
只有在我们开始在循环中使用它时才能实现:
function generate_numbers()
{
$number = 1;
while (true) {
yield $number;
$number++;
}
}
$generator = generate_numbers();
dump($generator->current()); // Dumps: 1
$generator->next();
dump($generator->current()); // Dumps: 2
$generator->next();
dump($generator->current()); // Dumps: 3
等等,什么?无限循环???
是的!由于循环的每次迭代中的代码不会自行执行,因此这个循环实际上并不是无限的。它只会产生与我们从中提取的值一样多的值(通过使用current
和 的组合next
)。
第 5 课:
yield
在无限循环中使用 from 实际上不会导致无限循环。由于生成器函数的执行在 every 之后暂停yield
,因此它只会运行与稍后请求的值数量一样多的循环迭代。
foreach
与Generator
s一起使用
不停地打电话current
,next
很快就会很累。因此,PHP 无需手动执行此操作,而是支持将 aGenerator
直接传递给foreach
!
让我们尝试从我们的生成器中转储前 20 个数字:
$generator = generate_numbers();
foreach ($generator as $number) {
dump($number);
if ($number == 20) break;
}
注意到break
那里了吗?由于我们在生成器函数中有一个“无限循环”,我们必须注意只从中提取有限数量的值。否则这foreach
将导致实际的无限循环。
第 6 课:用于
foreach
轻松枚举生成器中的所有值。如果您的生成器函数中存在无限循环,请注意foreach
在某个时刻停止您的循环。
组合生成器函数
现在,我们不必break
手动添加一个,而是创建一个名为take
. 我们将传递给它 2 个参数:一个生成器和一个“限制”:
function take($generator, $limit)
{
foreach ($generator as $index => $value) {
if ($index == $limit) break;
yield $value;
}
}
注意:
take
为了保持示例简单,上面的帮助程序故意非常幼稚。如需更强大的版本,请阅读源代码(您应该能够在本文末尾理解)。
现在我们有了这个take
助手,我们可以将两个生成器函数组合在一起(组合只是一个奇特的词,意思是:通过将一个函数的结果传递给另一个函数来一起使用两个函数)。我们会将数字生成器传递给take
生成器函数,并在我们的循环中使用它foreach
:
$allNumbersGenerator = generate_numbers();
$twentyNumbersGenerator = take($allNumbersGenerator, 20);
foreach ($twentyNumbersGenerator as $number) {
dump($number);
}
我们也可以直接内联执行此操作,而无需中间变量(为清楚起见,如上所示):
foreach (take(generate_numbers(), 20) as $number) {
dump($number);
}
第 7 课:您可以创建辅助生成器函数。他们采用一个生成器,并根据第一个生成器中的值返回一个不同的生成器。这让你可以将整个操作链组合在一起,这是驱动 Laravel 惰性集合的真正秘诀!
现在我们已经对原生 PHP 中的生成器和生成器函数有了深入的了解,让我们回到惰性集合。
惰性集合包装一个生成器函数
与包装原生 PHP 数组的常规(急切)集合不同,惰性集合包装原生 PHP 生成器函数。
让我们围绕我们的数字生成器函数包装一个惰性集合:
use Illuminate\Support\LazyCollection;
$collection = LazyCollection::make(function () {
$number = 1;
while (true) {
yield $number++;
}
});
我们现在有一个“保存”所有数字序列的集合。我将“持有”放在引号中,因为正如我们所见,这些数字尚未生成。
作为一个集合意味着我们可以访问 Laravel 提供给我们的无数方法。事实上,eagerCollection
类和LazyCollection
class 都实现了相同的Enumerable接口,为两个集合类提供了相同的 API 集。
使用我们所有数字的惰性集合,让我们使用集合的take
方法只取其中的前 10 个:
$firstTenNumbers = $collection->take(10);
啊...这比我们上面自己的函数组合要好得多!
我们链接到惰性集合的每个方法都会返回一个新LazyCollection
实例,它在内部包装了一个新的生成器函数。
为了带来这个完整的循环,让我们从这篇文章的开头重新审视我们的例子(略有扭曲):
$collection = LazyCollection::times(INF)
->filter(fn ($number) => $number % 2 == 0)
->take(1000);
我们从一个“包含”无限数量项目的集合开始,然后将它们过滤为偶数,然后取前 1,000 个值。
正如我们现在已经知道的那样,尚未生成任何一个值。这就是这段代码几乎不使用内存的原因。这些数字只会在我们开始枚举它们时生成(使用foreach
, 或集合的each
方法):
LazyCollection::times(INF)
->filter(fn ($number) => $number % 2 == 0)
->take(1000)
->each(fn ($number) => dump($number));
现在,也只有现在,才会生成这些数字!
练习:总共会生成多少个数字?不要只是继续阅读。在你继续之前想一想。最后,上面这段代码中的第一个生成器会生成多少个数字?
答案是两千。上面的代码片段将生成总共两千个值。为什么?想想这个
filter
函数是如何工作的:它从原始生成器中提取值,丢弃任何没有通过过滤器的值,然后只产生通过过滤器的值。所以为了得到 1,000 个偶数,它必须丢弃 1,000 个奇数!
至此,您有望了解惰性集合在幕后是如何工作的(要了解更多信息,请深入了解源代码)。接下来,让我们远离使用简单的数字,看看我们如何在真实场景中使用惰性集合。
使用惰性集合流式传输文件下载
惰性集合最有用的用例之一是将流式数据导出到下载文件。流式导出给我们带来很多好处:
- 我们不必将所有源记录都保存在内存中。
- 我们不必在内存中构建整个导出文件。
- 用户不必等到整个文件在服务器上建立起来。下载可以立即开始!
让我们看看我们如何使用惰性集合将 CSV 文件流式传输到浏览器,使用versatileleague/csv包。
首先,让我们创建一个包含一百万个虚拟登录日志的惰性集合:
$logins = LazyCollection::times(1000000, fn () => [
'user_id' => 24,
'name' => 'Houdini',
'logged_in_at' => now()->toIsoString(),
]);
现在我们有了这个巨大的数据集(实际上还没有生成),让我们看看如何将它作为 CSV 文件直接流式传输到浏览器,而不必在内存中将其构建为巨大的CSV 字符串。
Laravel 中的流式下载
首先,因为我们使用的是 Laravel,所以我们需要弄清楚流式下载在 Laravel 中是如何工作的。Laravel 期望所有路由都返回一个响应(可以是一个View
,可以转换为 JSON 的东西,或者一个实际的Response
对象)。路由处理程序不应该直接向客户端输出任何东西(通过使用echo
等)。
对于流式下载,使用,StreamedResponse它使用回调在框架准备好时流式传输响应。在回调中,我们可以直接向客户端输出内容:
Route::get('streamed-download', function () {
return response()->streamDownload(function () {
// From within here we can "echo"
// or write to the PHP output stream
}, 'the-filename.txt');
});
写入 PHP 输出缓冲区
接下来,让我们看看如何使用 的类league/csv
将CSV 文件流式传输到浏览器。期望将写入 CSV 记录的文件或流。我们将使用本机流,顾名思义,它让我们可以直接写入输出缓冲区。从文档:WriterWriter
php://output
php://output
echo
。
把它们放在一起
把它们放在一起,我们最终得到这个:
use Illuminate\Support\LazyCollection;
use League\Csv\Writer;
Route::get('streamed-download', function () {
$logins = LazyCollection::times(1000 * 1000, fn () => [
'user_id' => 24,
'name' => 'Houdini',
'logged_in_at' => now()->toIsoString(),
]);
return response()->streamDownload(function () use ($logins) {
$csvWriter = Writer::createFromFileObject(
new SplFileObject('php://output', 'w+')
);
$csvWriter->insertOne(['User ID', 'Name', 'Login Time']);
$csvWriter->insertAll($logins);
}, 'logins.csv');
});
回顾一下:
- 我们创建了一个惰性集合,其中包含一百万条登录日志虚拟记录。
- 该路由返回一个
StreamedResponse
, 并带有一个回调,框架在为流做好准备后将调用该回调。 - 在回调中,我们
Writer
使用php://output
缓冲区创建一个 CSV,以便我们可以将 CSV 记录直接写入流式下载文件。 insertOne
在 CSV 文件中添加一行,作为列名的标题行。- 惰性集合直接传递给
insertAll
,它在内部用于foreach循环遍历生成器中的所有记录。
在任何时候,我们都不会将所有记录都保存在内存中;不是原始原始数据,也不是生成的 CSV。每条记录一条一条生成,并立即写入用户浏览器的流文件中!
我们学习了如何以惰性方式写入数据。现在我们将学习如何惰性读取数据。
使用 Lazy Collection 懒惰地读取文件
惰性集合非常有用的另一个领域是惰性读取文件。即:逐行读取文件,在每一行进入时对其进行处理,从不将整个文件加载到内存中。
NDJSON 日志文件
将文件作为流读取的一个很好的例子涉及使用NDJSON格式(也称为换行分隔的 JSON):
NDJSON 是一种方便的格式,用于存储或流式传输可以一次处理一条记录的结构化数据。这是日志文件的一种很好的格式。
以下是将最近登录存储为 NDJSON 的方式:
{ "user_id": 2, "name": "Alice", "timestamp": "2020-07-29T22:51:30.352869Z" }
{ "user_id": 1, "name": "Jinfeng", "timestamp": "2020-07-29T22:54:05.280122Z" }
{ "user_id": 2, "name": "Alice", "timestamp": "2020-07-29T22:54:16.565840Z" }
请注意,这不是数组,每行末尾也没有逗号。每一行都是一个自包含的 JSON 对象,可以自行解析和处理。
懒惰地将文件写入磁盘
要解决这个问题,我们需要一个实际的 NDJSON 文件。让我们绕道而行,看看如何使用惰性集合创建这样一个包含一堆虚假数据的文件:
LazyCollection::times(10 * 1000)
->flatMap(fn () => [
['user_id' => 1, 'name' => 'Jinfeng'],
['user_id' => 2, 'name' => 'Alice'],
])
->map(fn ($user, $index) => array_merge($user, [
'timestamp' => now()->addSeconds($index)->toIsoString(),
]))
->map(fn ($entry) => json_encode($entry))
->each(fn ($json) => Storage::append('logins.ndjson', $json));
这将创建一个文件并storage/app/logins.ndjson
添加 20,000 条虚假登录记录,所有这些都不会在内存中保存超过一行!
逐行读取文件
现在我们有了一个日志文件,让我们尝试一下,看看如何在不将整个文件加载到内存的情况下对该文件运行一些统计信息。
要创建一次读取文件一行的惰性集合,我们可以使用本机 PHPfopen和函数,如原始惰性集合 PR 的第 4 个示例fgets所示:
$logins = LazyCollection::make(function () {
$handle = fopen(storage_path('app/logins.ndjson'), 'r');
while (($line = fgets($handle)) !== false) {
yield $line;
}
});
我们现在有一个$logins
惰性集合,其中集合中的每个项目都是一个独立的 JSON 字符串。
注意:Laravel 8.0引入了一个新
File::lines($path)
方法,它返回LazyCollection
带有文件行的 a - 就像我们上面的代码一样。在下一个示例中,我们将File::lines()
直接使用该方法。
让我们看看 Alice 登录了多少次:
$loginCountForAlice = $logins
->map(fn ($json) => json_decode($json))
->filter() // In case we have empty lines
->where('name', 'Alice')
->count();
我们现在有了 Alice 登录的总次数(100,000 次),而内存中只保留了一个日志条目!
阅读和流式传输文件
对于我们的最后一个实际示例,我们将使用单个惰性集合将读取和流式传输文件下载结合起来。
结合我们到目前为止所学的一切,我们可以解析日志文件并将其作为 CSV 文件下载流式传输给用户:
use File;
use Illuminate\Support\LazyCollection;
use League\Csv\Writer;
Route::get('read-and-stream', function () {
$logins = File::lines(storage_path('app/logins.ndjson'))
->map(fn ($json) => json_decode($json, true))
->filter();
return response()->streamDownload(function () use ($logins) {
$csvWriter = Writer::createFromFileObject(
new SplFileObject('php://output', 'w+')
);
$csvWriter->insertOne(['User ID', 'Name', 'Login Time']);
$csvWriter->insertAll($logins);
}, 'logins.csv');
});
这将一次一行地读取源文件,解析 JSON,将其转换为 CSV 记录,并将其流式传输给用户。所有这一切,同时几乎不使用内存!
将常规集合转换为惰性集合
在我们开始之前,让我们快速了解如何将常规集合转换为惰性集合,以及我们为什么要这样做。
假设我们正在处理无法流式传输的庞大数据集,例如单个 API 调用的结果:
use Illuminate\Support\Collection;
function get_all_customers_from_quickbooks() : Collection
{
// Run some code that gets all QuickBooks customers,
// and return it as a regular, eager collection...
}
该功能的实际实现并不重要。我们只关心它会返回一个无法流式传输给我们的巨大的急切集合,因此我们必须将其全部保存在内存中。
然而,惰性集合仍然可以派上用场。为了解如何实现,让我们统计一下法国有多少余额超过 100 欧元的客户:
$count = get_all_customers_from_quickbooks()
->where('country', 'FR')
->where('balance', '>', '100')
->count();
简单吧?但让我们看看幕后发生的事情:每次调用 时where
,我们实际上是在创建一个全新的Collection
,这意味着我们在内存中创建另一个数组来保存所有过滤后的值。
我们可以做得更好。
即使原始值都保存在内存中,后续过滤器也不必将它们的值存储在内存中。我们可以使用lazy
常规Collection
类上的方法将其转换为LazyCollection
:
$count = get_all_customers_from_quickbooks()
->lazy()
->where('country', 'FR')
->where('balance', '>', 100)
->count();
虽然原始值仍将全部保留在内存中,但在我们过滤其结果时不会分配额外的内存。很简约