PHP генераторы — практический пример
Что это значит
Нередко возникает необходимость работы с большими массивами данных, например, чтение CSV-файла с данными о клиентах объёмом 3 ГБ, который необходимо сохранить в базе данных, или преобразование данных из одного формата в другой, когда общий массив данных имеет значительный размер в памяти.
Другим сценарием может быть получение данных из API и сохранение их в массиве для последующего итерационного хранения. Это лежит в основе ключевого примера, который мы сегодня рассмотрим.
Простой пример
function xrange(int $from, int $to) {
for($i = $from;$i <= $to;$i++) {
yield $i;
}
}
foreach(xrange(1, 10_000_000) as $i) {
var_dump($i);
}
В данном случае xrange
не будет возвращать сразу 10 миллионов элементов. Вместо этого на каждой итерации будет генерироваться одно число. Следовательно, расход памяти останется минимальным.
Ручная итерация
Итерацию по генератору можно выполнить и другим способом.
function domains() {
yield 'google.com';
yield 'facebook.com';
yield 'instagram.com';
}
$d = domains();
$d->current(); // возвращает 'google.com'
$d->next(); // перемещает указатель на следующий элемент и возвращает null
$d->current(); // возвращает 'facebook.com';
$d->next(); // перемещает указатель на следующий элемент
$d->current(); // возвращает 'instagram.com'
Проблема
Ранее я построил систему с компонентом позволяющим пользователям интегрировать свой магазин Shopify. Что давало нам возможность получать все его данные (включая товары, заказы и клиентов) и формировать на их основе финансовые и маркетинговые отчёты.
Процесс включал три отдельных фоновых задания для одновременного получения данных о товарах, заказах и клиентах, а затем сохранения их в нашей базе данных MySQL, как только пользователь добавлял свой магазин Shopify.
Многие наши заказчики имели тысячи таких записей. Однако код, используемый для их получения, был несовершенен.
Рассмотрим исходную реализацию и выясним, как можно её улучшить.
Получение товаров
Для запроса данных магазина используется фабрика ClientFactory
.
class ClientFactory
{
public function make(Shop $shop) {
return new Client([
'base_uri' => "https://{$shop->domain}/admin/api/2021-07/",
'headers' => [
'X-Shopify-Access-Token' => $shop->token,
'Content-Type' => 'application/json',
],
]);
}
}
Наиболее простая и понятная реализация, которая может прийти в голову, — это просто использование Guzzle для получения товаров и их непосредственного хранения.
public function handle(ClientFactory $factory): void
{
do {
$response = $this->factory->make($shop)->get('products.json', ['query' => [
'limit' => 250,
'since_id' => $lastId ?? 0,
]]);
$data = json_decode($response->getBody()->getContents(), true);
$shopifyProducts = $data['products'];
if(count($shopifyProducts) == 0) break;
$lastId = $shopifyProducts[count($shopifyProducts) - 1]['id'];
foreach ($shopifyProducts as $shopifyProduct) {
Product::create(ShopifyProductMapper::map($shopifyProduct)->toArray());
}
} while ($lastId);
}
Ограничения API
- Shopify допускает не более 250 товаров на странице.
- Пагинация достигается путём передачи идентификатора последнего товара в параметре
since_id
. Это позволяет получить следующие 250 товаров после заданного параметраsince_id
.
Давайте разобьём задачу на части
- Мы запускаем цикл do-while, поскольку нам необходимо получить все товары.
- Если количество полученных товаров равно нулю, то мы просто выходим из цикла.
- В противном случае мы устанавливаем идентификатор последнего товара в
$lastId
, который будет использоваться в качестве указателя следующей страницы. - Мы сохраняем товары в нашей модели
Product
.
Этот код не очень хорошо организован, и многие операции выполняются в одном месте. Представьте себе, как накладно будет повторять этот процесс для конечных точек Orders
и Customers
, а также для любых других данных, которые мы извлекаем из этих хранилищ.
Мы можем улучшить эту ситуацию, извлекая вызов конечной точки API в другой класс, например Repository
, и возвращая массив товаров.
ProductsAPI
class ProductsAPI
{
public function __construct(private ClientFactory $factory)
{
}
public function getAllProducts(Shop $shop): array
{
$products = [];
do {
$response = $this->factory->make($shop)->get('products.json', ['query' => [
'limit' => 250,
'since_id' => $lastId ?? 0,
]]);
$data = json_decode($response->getBody()->getContents(), true);
$shopifyProducts = $data['products'];
if (count($shopifyProducts) == 0) break;
$lastId = $shopifyProducts[count($shopifyProducts) - 1]['id'];
foreach ($shopifyProducts as $shopifyProduct) {
$products[] = ShopifyProductMapper::map($shopifyProduct);
}
} while ($lastId);
return $products;
}
}
Проблема заключается в том, что мы храним все товары в памяти до тех пор, пока не выполним задачу, после чего возвращаем их вызывающей стороне. Такой подход может привести к исключению OOM (Out of Memory) при работе с тысячами записей.
Как же более эффективно управлять пагинацией, чтобы избежать этого?
callable
Один метод, который мне не очень нравится, тем не менее, возможен, состоит в том, чтобы использовать функцию обратного вызова.
public function getAllProducts(Shop $shop, callable $callback)
{
do {
$response = $this->factory->make($shop)->get('products.json', ['query' => [
'limit' => 250,
'since_id' => $lastId ?? 0,
]]);
$data = json_decode($response->getBody()->getContents(), true);
$shopifyProducts = $data['products'];
if (count($shopifyProducts) == 0) break;
$lastId = $shopifyProducts[count($shopifyProducts) - 1]['id'];
$products = [];
foreach ($shopifyProducts as $shopifyProduct) {
$products[] = ShopifyProductMapper::map($shopifyProduct);
}
$callback($products);
} while ($lastId);
}
Как видно, метод имеет второй вызываемый параметр, который он вызывает в строке 21 для предоставления доступа к товарам, полученным по запросу в строке 18. Таким образом, в контексте класса Job
его использование может выглядеть следующим образом:
public function handle(ProductsApi $api): void
{
$shop = Shop::findOrFail($this->shopId);
$api->getAllProducts($shop, function (array $shopifyProducts) {
foreach ($shopifyProducts as $shopifyProduct) {
Product::create($shopifyProduct->toArray());
}
});
}
Мне такой подход не кажется привлекательным, поскольку на репозиторий неожиданно возлагается ответственность за выполнение функции обратного вызова. Это кажется необычным для репозитория.
Параметр LastId
Другой подход заключается в том, чтобы разрешить вызывающему репозиторию передавать последний идентификатор. Однако это, по-видимому, противоречит названию метода.
public function getAllProducts(Shop $shop, int $lastId): array
{
$response = $this->factory->make($shop)->get('products.json', ['query' => [
'limit' => 250,
'since_id' => $lastId,
]]);
$data = json_decode($response->getBody()->getContents(), true);
$shopifyProducts = $data['products'];
if (count($shopifyProducts) == 0) break;
$lastId = $shopifyProducts[count($shopifyProducts) - 1]['id'];
$products = [];
foreach ($shopifyProducts as $shopifyProduct) {
$products[] = ShopifyProductMapper::map($shopifyProduct);
}
return $products;
}
Теперь, при таком подходе, пользователь этого класса должен знать и понимать, как обрабатывать последний идентификатор. Кроме того, он должен определить, когда следует остановить процесс пагинации.
Генераторы
Мы можем упростить весь этот процесс, используя Генераторы.
Цель заключается в переборе всех товаров после их пагинации и получения.
В шаге ProductsAPI мы выполняли всю пагинацию в методе getAllProducts
, что в итоге привело к использованию значительного объёма памяти. Генераторы помогут нам создать тот же массив продуктов, но в более оптимизированном для памяти виде.
Посмотрим, как его можно развернуть.
/**
* @return Generator<Product[]>
*/
public function getAllProducts(Shop $shop): Generator
{
do {
$response = $this->factory->make($shop)->get('products.json', ['query' => [
'limit' => 250,
'since_id' => $lastId ?? 0,
]]);
$data = json_decode($response->getBody()->getContents(), true);
$shopifyProducts = $data['products'];
if (count($shopifyProducts) == 0) break;
$lastId = $shopifyProducts[count($shopifyProducts) - 1]['id'];
yield from array_map(function (array $shopifyProduct) {
return ShopifyProductMapper::map($shopifyProduct);
}, $shopifyProducts);
} while ($lastId);
}
Как видно из строки 4, тип возвращаемого метода — Generator
, а в строке 18 мы получаем данные (yield from
) от сопоставленного объекта.
Сначала рассмотрим, как это может быть использовано на практике, а затем кратко объясним процесс его выполнения.
Мы можем модифицировать код в нашем задании следующим образом:
public function handle(ProductsApi $api): void
{
$shop = Shop::findOrFail($this->shopId);
+ $shopifyProducts = $api->getAllProducts($shop);
+
- $api->getAllProducts($shop, function (array $shopifyProducts) {
foreach ($shopifyProducts as $shopifyProduct) {
Product::create($shopifyProduct->toArray());
}
- });
}
Таким образом, когда будет выполнена строка 5, ничего внутри getAllProducts
выполняться не будет.
Если выполнить операцию "die & dump" для $shopifyProducts
, то на экран выведется следующее:
Generator {#2531
this: App\Modules\Shopify\ProductsApi {#2500 …}
trace: {
./app/Modules/Shopify/ProductsApi.php:18 {
App\Modules\Shopify\ProductsApi->getAllProducts(Shop $shop): Generator
› public function getAllProducts(Shop $shop): Generator
› {
› do {
}
App\Modules\Shopify\ProductsApi->getAllProducts() {}
}
closed: false
}
Это не сразу бросается в глаза и не сразу понятно, но это то, что PHP, безусловно, может интерпретировать. Можно сказать, что это ссылка на наш метод и связанную с ним логику.
Как он вызывается
Генераторы могут вызываться различными способами:
- Внутри цикла
foreach
- Использование
->current
и->next
Итак, имеющийся у нас общий объект $shopifyProducts
оснащён некоторыми методами, которые мы можем использовать.
$shopifyProducts->current()
Вызов ->current()
вызовет первый вызов API к Shopify и вернёт первый товар в исходном массиве из 250 товаров.
Если вызвать метод current()
несколько раз, то он всегда будет возвращать один и тот же первый продукт.
Чтобы получить следующий набор из 250 товаров, необходимо вызвать ->next
251 раз. При 251 вызове будет отправлен второй запрос, и тогда current
будет указывать на 251 товар.
Затем вы продолжаете вызывать next
, пока не закончите работу с 500-м товаром, и так далее.
Конечный результат
Благодаря генераторам у нас есть простой цикл, позволяющий получать список всех товаров, клиентов и заказов.
$shop = Shop::findOrFail($this->shopId);
$shopifyProducts = $api->getAllProducts($shop);
foreach ($shopifyProducts as $shopifyProduct) {
Product::create($shopifyProduct->toArray());
}
Мы не рассмотрели некоторые аспекты, такие как обработка исключений API и оптимизация вставки в базу данных. Однако эти разделы были опущены намеренно, чтобы сделать статью более краткой. Если вы заинтересованы в дальнейшем изучении этих тем, сообщите мне об этом в комментариях.
Заключение
Вместе мы рассмотрели, что такое генератор, основные способы его использования и практический пример его применения.
Генераторы можно использовать для оптимизации таких процессов, как импорт или экспорт файлов, например CSV или файлов логов, и других сценариев, позволяющих оптимизировать использование памяти.
Если вы уже использовали генераторы, хотелось бы узнать, какую пользу они принесли вам. Поделитесь с нами своим опытом!