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