Реальные примеры использования генераторов в PHP

Реальные примеры использования генераторов в PHP

Несмотря на то, что генераторы доступны с версии PHP 5.5, они до сих пор используются крайне редко. На самом деле, большинство разработчиков, которых я знаю, понимают, как работают генераторы, но не видят, когда они могут быть полезны в реальных ситуациях.

"Да, генераторы, безусловно, выглядят здорово, но знаете... за исключением вычисления последовательности Фибоначчи, я не вижу, чем они могут быть мне полезны."

И в чем-то такие люди правы, ведь даже примеры в документации PHP о генераторах довольно упрощены. Они только показывают, как эффективно реализовать диапазон или итерацию по строкам файла.

Но даже из этих простых примеров мы можем понять суть генераторов: это просто упрощенные итераторы.

Генератор позволяет вам написать код, который использует foreach для итерации по набору данных без необходимости создавать массив в памяти.

Учитывая это, я попытаюсь объяснить, почему генераторы являются потрясающими, используя некоторые примеры, взятые из реальных моих приложений, над которыми я работаю.

Немного контекста

У меня был один проект, направленный на чтение электронных книг. Книга проходит весь путь от получения файлов электронных книг от издателей до представления ее на сайте и предоставления конечному покупателю возможности читать их онлайн (с помощью веб-ридера) или на электронной читалке.

Чтобы иметь возможность продавать эти электронные книги и отображать соответствующую информацию для наших клиентов, нам необходимо иметь множество метаданных для наших книг (название, формат, цена, издатель, автор(ы), ...).

В большинстве примеров кода, которые будут показаны ниже, я буду ссылаться на эти метаданные под именем электронной книги.

Итак, начнем!

Итерация больших наборов данных

Для первого примера предположим, что у меня есть большая коллекция электронных книг, и я хочу отфильтровать те, которые можно читать в веб-читалке.

Традиционно я бы написал что-то вроде этого:

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 мы можем использовать генераторы!

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 - это не массив со всеми электронными книгами, а итератор или генератор (еще лучше!), то потребление памяти будет постоянным, независимо от количества книг, и мы обязательно найдем эти книги тогда, когда они нам понадобятся.

Агрегирование данных с нескольких источников

Теперь давайте рассмотрим часть, связанную с извлечением $ebooks. Я не говорил вам, но эти электронные книги поступают из разных источников данных: реляционной базы данных Mysql и Elasticsearch.

Мы можем написать простой метод для объединения этих двух источников:

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.

Мы могли бы начать с использования генераторов для получение мерджа этих данных с различных источников:

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) на два отдельных метода:

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

Используя это ключевое слово, мы могли бы объединить несколько источников данных всего несколькими строками кода:

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();
}

Сложная гидратация данных для строк базы данных со связями

Другим примером использования генераторов, который я нашел, была реализация гидратации данных, которая могла обрабатывать связи.

Мне нужно было импортировать сотни тысяч заказов из старой базы данных в нашу систему, причем каждый заказ имел несколько связей - товаров.

Наличие, и строки товаров заказа было необходимым условием для того, что нам нужно было сделать, поэтому я должен был написать метод, который мог бы возвращать объединенные данные по заказам, не будучи слишком медленным и не занимая много памяти.

Идея довольно наивна: соединить заказ и соответствующие строки, сгруппировать заказ и строки заказа вместе в цикле.

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;
}

Используя генератор, мне удалось реализовать метод, который может получать заказы из базы данных и соединять соответствующие строки заказов. Все это при стабильном потреблении памяти. Генератор устранил необходимость отслеживать все заказы с их строками заказов: текущий заказ - это все, что мне было нужно для объединения всех данных.

Генераторы при парсинге больших объемов данных

В своей работе и личных проектах мне часто приходится писать различные парсеры. И когда дело доходит до парсинга больших объемов данных, то тут очень выручают генераторы и асинхронные запросы.

Ранее я уже писал статьи по написанию асинхронных парсеров на PHP. Но, в своих проектах, я, в основном, пользуюсь другой библиотекой для парсинга - Guzzle. Это очень крутая и многофункциональная библиотека которая позволяет выполнять параллельные, асинхронные запросы в PHP, добавлять перехватчики ответов, модификаторы запросов, middleware и многое другое. К тому же, с Laravel она идет по умолчанию.

В документации Guzzle есть отличные примеры использования генераторов с их библиотекой для параллельных запросов. Но я приведу свой личный код из персонального проекта.

У меня есть проект по сбору бесплатных прокси с многих источников и проверка их на валидность. Задача, по факту, делится на 2 этапа: сбор прокси и проверка на валидность. Сбор прокси условно быстрая операция. На один сайт, откуда мы парсим список прокси мы делаем от 1 до 5 запросов. Если таких источников будет 15-20, то даже в этом случае эти запросы можно выполнить синхронно где-то в очереди.

Проблемы начинаются, когда нам нужно проверить весь список спаршенных прокси на валидность. Потому что тут два нюанса: к-во прокси после первого этапа равно где-то от 15-20к. Проверка должна быть достаточно быстрой, чтобы в этом была целесообразность. Если мы будем в синхронном режиме ждать проверки каждого из прокси-сервера с максимальным тамймаутом в 5 секунд. То в худшем случае, 100 прокси проверится за 500 секунд. А проверка всего списка займет больше 27 часов.

Потому, нужно сформировать пулл из прокси и парралельно проверять их валидность. И как только один прокси проверен, в пулл добавляется новое значение. То есть, в один момент времени будет проверяться N-значений.

$handler = HandlerStack::create();

$iterator = function () use ($handler, $proxies, $url, $project) {
    /* @var Proxy $proxy */
    foreach($proxies as $proxy) {
        $request = new Request('GET', $project->ping_url, []);

        $startTime = microtime(true);

        yield (new Client([
            'handler' => $handler,
            RequestOptions::TIMEOUT => 5,
            RequestOptions::CONNECT_TIMEOUT => 5,
            RequestOptions::READ_TIMEOUT => 5,
            RequestOptions::PROXY => $proxy->getFullProxy(),
            RequestOptions::ALLOW_REDIRECTS => true,
            RequestOptions::HEADERS => [
                'user-agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/73.0.3683.86 Safari/537.36',
            ],
        ]))
            ->sendAsync($request)
            ->then(
                function (Response $response) use ($request, $proxy, $startTime, $project) {
                    $responseTime = microtime(true) - $startTime;

                    $validator = new RulesValidator($response->getBody()->__toString());
                    // добавляем проверки валидации ответа
                    if($validator->validate() === false) {
                        return $proxy->update([
                            'status' => Proxy::STATUS_FAULT,
                            'response_time' => $responseTime,
                            'response_status' => $response->getStatusCode(),
                            // ...
                        ]);
                    }

                    $proxy->update([
                        'status' => Proxy::STATUS_SUCCESS,
                        'response_time' => $responseTime,
                        'response_status' => $response->getStatusCode(),
                        // ...
                    ]);
                },
                function($reason) use($proxy, $startTime) {
                    // обрабатываем ошибку
                });
    }
};

// конкурентно парсить в 100 потоков
$promise = each_limit_all($iterator(), 100);

$promise->wait();


Если интересно, пишите на почту, и я обязательно напишу подробную статью как я писал проект на Laravel по многопоточной проверке прокси, проверки ответа, скорости соединения и это все на PHP.