Использование php-генераторов
Несмотря на то, что php-генераторы доступны с php 5.5.0, они все еще почти не используются. Более того, большинство разработчиков, которых я знаю, понимают, как работают генераторы, но не видят, когда они могут быть полезны в реальной жизни.
Да, генераторы определенно смотрятся хорошо, но знаете... Я не понимаю, где они могут быть полезными для меня, разве что для расчета последовательности Фибоначчи.
И они не ошибаются, ведь даже примеры в php-документации слишком упрощены. Они только объясняют, как эффективно реализовать range или итерировать по строкам файла.
Но даже с этих простых пример мы можем понять ключевые преимущества использования генераторов: они просто упрощают итераторы.
Генераторы позволяют вам написать код, который использует foreach для итерации множества данных без необходимости выделения памяти под массив.
Держа этот факт в памяти, я попытаюсь объяснить, почему генераторы насколько здорово помогли мне решить задачи над которыми я работал в компании.
Сначала немного контекста
Я работаю в TEA. В основном, мы разрабатываем экосистему для электронных книжек. Это покрывает весь путь от получения файлов нужного формата от издателей до размещения их на e-commerce сайте и предоставления конечному потребителю возможности читать онлайн (используя браузер, написанный @johanpoirier) или с электронной книги.
Для возможности продавать книги и отображения релевантной информации потребителям, нам нужно много метаданных продуктов (заголовок, формат, цена, издатели, автор(ы), ...).
В большинстве примеров кода дальше я буду называть эти метаданные $ebooks.
Поехали!
Итерация по крупному множеству данных
Для первого примера использования, давайте предположим, что у меня есть большая коллекция книг и я хочу отфильтровать те, которые можно читать у веб-просмотрщике.
По традиции я должен был бы написать что-то вроде:
<?php
private function getEbooksEligibleToWebReader($ebooks)
{
$rule = 'format = "EPUB" AND protection != "Adobe DRM"';
$filteredEbooks = [];
foreach ($ebooks as $ebook) {
if ($this->rulerz->satisfies($ebook, $rule)) {
$filteredEbooks[] = $ebook;
}
}
return $filteredEbooks;
}
Проблему легко увидеть: чем больше книг, тем больше нужно памяти для $filteredEbooks.
Одно из решений - создать итератор, который бы итерировал $ebooks и возвращал подходящие. Но для этого нам нужно было бы создать новый класс, кроме того, итераторы реализируются немного утомительно... К счастью, с php 5.5.0 мы можем использовать генераторы!
<?php
private function getEbooksEligibleToWebReader($ebooks)
{
$rule = 'format = "EPUB" AND protection != "Adobe DRM"';
foreach ($ebooks as $ebook) {
if ($this->rulerz->satisfies($ebook, $rule)) {
yield $ebook;
}
}
}
Да, рефакторинг метода getEbooksEligibleToWebReader для использования генератора очень прост: заменяем передачу значений в переменную $filteredEbooks конструкцией yield.
Предположив, что $ebooks не массив книг, а итератор, или генератор (даже лучше!), потребление памяти теперь будет константой, не важно, сколько книг нужно вернуть, и мы уверены, что книги будут искаться только когда реально понадобятся.
Бонус: RulerZ внутри использует генераторы, так что мы можем переписать метод и остаться с той же оптимизацией по выделению памяти.
<?php
private function getEbooksEligibleToWebReader($ebooks)
{
$rule = 'format = "EPUB" AND protection != "Adobe DRM"';
return $this->rulerz->filter($ebooks, $rule);
}
Агрегация нескольких источников данных
Теперь рассмотрим момент получения $ebooks. Я вам не сказал, но они по факту приходят с разных источников: реляционной БД и Elasticsearch.
Мы можем написать простой метод, агрегирующий эти два источники:
<?php
private function getEbooks()
{
$ebooks = [];
// fetch from the DB
$stmt = $this->db->prepare("SELECT * FROM ebook_catalog");
$stmt->execute();
$stmt->setFetchMode(\PDO::FETCH_ASSOC);
foreach ($stmt as $data) {
$ebooks[] = $this->hydrateEbook($data);
}
// and from Elasticsearch (findAll uses ES scan/scroll)
$cursor = $this->esClient->findAll();
foreach ($cursor as $data) {
$ebooks[] = $this->hydrateEbook($data);
}
return $ebooks;
}
Но еще раз, количество потребляемой памяти при использовании подобного подхода очень зависит от количества книг, хранимых в базе данных и Elasticsearch.
Мы можем начать использовать генераторы и возвратить результат:
<?php
private function getEbooks()
{
// fetch from the DB
$stmt = $this->db->prepare("SELECT * FROM ebook_catalog");
$stmt->execute();
$stmt->setFetchMode(\PDO::FETCH_ASSOC);
foreach ($stmt as $data) {
yield $this->hydrateEbook($data);
}
// and from Elasticsearch (findAll uses ES scan/scroll)
$cursor = $this->esClient->findAll();
foreach ($cursor as $data) {
yield $this->hydrateEbook($data);
}
}
Так, конечно, лучше, но у нас все равно есть проблема: наш метод getBooks выполняет слишком много работы! Мы должны разделить две ответственности (считывание данных с БД и вызов Elasticsearch) в два метода:
<?php
private function getEbooks()
{
yield from $this->getEbooksFromDatabase();
yield from $this->getEbooksFromEs();
}
private function getEbooksFromDatabase()
{
$stmt = $this->db->prepare("SELECT * FROM ebook_catalog");
$stmt->execute();
$stmt->setFetchMode(\PDO::FETCH_ASSOC);
foreach ($stmt as $data) {
yield $this->hydrateEbook($data);
}
}
private function getEbooksFromEs()
{
// and from Elasticsearch (findAll uses ES scan/scroll)
$cursor = $this->esClient->findAll();
foreach ($cursor as $data) {
yield $this->hydrateEbook($data);
}
}
Вы могли заметить использование yield from оператора (доступен с php 7.0), который позволяет делегировать использование генераторов. Это идеально, к примеру, для агрегации нескольких источников данных, которые используют генераторы.
yield from оператор работает с любым Traversable объектом, так что массивы и итераторы также могут быть использованы с этим оператором.
Используя такую конструкцию, мы можем агрегировать несколько источников данных пару строками кода:
<?php
private function getEbooks()
{
yield new Ebook(…);
yield from [new Ebook(…), new Ebook(…)];
yield from new ArrayIterator([new Ebook(…), new Ebook(…)]);
yield from $this->getEbooksFromCSV();
yield from $this->getEbooksFromDatabase();
}
Сложная ленивая (по требованию) гидрация записей БД
Другой вариант использования генераторов - реализация ленивой гидрации, которая может обрабатывать связи.
Мне нужно было импортировать сотни тысяч заказов с давней БД в нашу систему, каждый заказ содержал несколько пунктов.
Наличие заказов и "пунктов заказов" было предпосылкой к тому, что мы должны сделать. Я написал метод, который возвращает сгидрированые заказы и при этом не становится слишком медленным или прожорливым.
Идея слегка наивная: сджойнить заказы с пунктами, и сгруппировать заказы и пункты заказов в цикле.
<?php
public function loadOrdersWithItems()
{
$oracleQuery = <<<SQL
SELECT o.*, item.*
FROM order_history o
INNER JOIN ORDER_ITEM item ON item.order_id = o.id
ORDER BY order.id
SQL;
if (($stmt = oci_parse($oracleDb, $oracleQuery)) === false) {
throw new \RuntimeException('Prepare fail in ');
}
if (oci_execute($stmt) === false) {
throw new \RuntimeException('Execute fail in ');
}
$currentOrderId = null;
$currentOrder = null;
while (($row = oci_fetch_assoc($stmt)) !== false) {
// did we move to the next order?
if ($row['ID'] !== $currentOrderId) {
if ($currentOrderId !== null) {
yield $currentOrder;
}
$currentOrderId = $row['ID'];
$currentOrder = $row;
$currentOrder['lines'] = [];
}
$currentOrder['lines'][] = $row;
}
yield $currentOrder;
}
Используя генераторы, мне удалось реализовать метод, который получает заказы с БД и присоединяет соответствующие пункты заказа. Все это потребляет стабильное количество памяти. Генератор избавил от надобности держать все заказы и их пункты: текущий заказ - это все, что мне нужно, чтобы сагрегировать все данные.
Имитация асинхронных задач
Последнее, но тем не менее важное: генераторы так же могут быть использованы для имитации асинхронных задач. Пока я писал эту заметку, я наткнулся на пост @nikita_ppv на такую же тематику, и так как он первый реализовал генераторы в php, я просто оставлю ссылку на его пост.
Он быстро объясняет что такое генераторы и (в деталях) как мы можем получить преимущества из-за того, что они могут быть прерваны и отправлять/принимать данные для реализации сопрограмм и даже многозадачности.
Подводя итог
Генераторы...
- ... упрощенные итераторы;
- ... могут возвращать неограниченные объемы данных без дополнительного потребления памяти;
- ... могут быть агрегированы с помощью делегирования генераторов;
- ... могут быть использованы для реализации многозадачности;
- ... просто прикольные!