Использование 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, я просто оставлю ссылку на его пост.

Он быстро объясняет что такое генераторы и (в деталях) как мы можем получить преимущества из-за того, что они могут быть прерваны и отправлять/принимать данные для реализации сопрограмм и даже многозадачности.

Подводя итог

Генераторы...

  • ... упрощенные итераторы;
  • ... могут возвращать неограниченные объемы данных без дополнительного потребления памяти;
  • ... могут быть агрегированы с помощью делегирования генераторов;
  • ... могут быть использованы для реализации многозадачности;
  • ... просто прикольные!

Оригинал статьи