Прошлая статья по данной теме была чисто теоретическая. Теперь есть готовый пакет. И данная статья - инструкция к нему.
Базовый функционал
Самое очевидное применение группировки вызовов - решение проблемы N+1 запросов. Данная проблема возникает когда фреймворк доступа к данным выполняет N дополнительных SQL-запросов для получения тех же данных, которые можно получить при выполнении одного запроса.
К примеру для получения данных имеются вызовы следующих функций, каждая из которых выполняет один SQL-запрос. При применении пакета 6 вызовов функций группируются в две группы по типу функции вызова. И в каждую группу попадают все аргументы вызова.

В результате вместо шести SQL-запросов будет выполнено всего два.
Код реализации алгоритма с картинки:
// Функция для получения асинхронных данных и их возврата в синхронный код
// Анонимная функция должно возвращать обещание,
// результат которого и будет возвращен из функции run
$articles = BatchManager::run(function (): BatchPromise {
// Функция all выполняет массив обещаний
// и возвращает массив значений этих обещаний
return BatchManager::all([
getArticle(1),
getArticle(2),
getArticle(3)
]);
});
// Получить информацию о статье
function getArticle(int $id): BatchPromise
{
// Создаем и возвращаем обещание
return BatchManager::create(
// Функция для обработки сгруппированных данных
function (BatchGroup $group) {
// Получить список уникальных значений аргумента с номером 0
$ids = $group->arg(0);
// Выбрать из БД информацию о статьях
$articles = sql('SELECT * FROM `articles` WHERE `id` IN (' . implode(',', $ids) . ')');
// Создадим обещания получения информации о пользователях
$promises = [];
foreach ($articles as $article) {
$promises[] = getUser($article['author_id']);
}
BatchManager::all($promises)->then(function (array $users) use ($articles, $group) {
// Сгруппировать пользователей по идентификатору
$mUsers = [];
foreach ($users as $user) {
$mUsers[$user['id']] = $user;
}
// Проставить информацию об авторе
foreach ($articles as $article) {
$article['author'] = $mUsers[$article['author_id']];
}
// Сгруппировать статьи по идентификатору
$mArticles = [];
foreach ($articles as $article) {
$mArticles[$article['id']] = $article;
}
// Вернуть информацию о статьях
$group->setResult(function (int $id) use ($mArticles) {
return $mArticles[$id] ?? null;
});
});
},
$id // Функция имеет только один аргумент (его номер = 0)
);
}
// Получить информацию о пользователе
function getUser(int $id): BatchPromise
{
// Создаем и возвращаем обещание
return BatchManager::create(
// Функция для обработки сгруппированных данных
function (BatchGroup $group) {
// Получить список уникальных значений аргумента с номером 0
$ids = $group->arg(0);
// Выбрать из БД информацию о пользователях
$users = sql('SELECT * FROM `users` WHERE `id` IN (' . implode(',', $ids) . ')');
// Сгруппировать пользователей по идентификатору
$mUsers = [];
foreach ($users as $user) {
$mUsers[$user['id']] = $user;
}
// Вернуть информацию о пользователе
$group->setResult(function (int $id) use ($mUsers) {
return $mUsers[$id] ?? null;
});
},
$id // Функция имеет только один аргумент (его номер = 0)
);
}
Функция BatchManager::all
получает на вход список объектов BatchPromise, возвращаемых групповыми функциями и выполняется когда выполнятся ВСЕ обещания из переданного массива.
Цепочки вызовов
Как и все обещания в данном случае поддерживаются цепочки вызовов.
$articles = BatchManager::run(function (): BatchPromise {
return getArticle(1)
->then(function($article) {
// Функция получает значение, его можно изменить
$article['genderName'] = $article['gender']=='M' ? 'мужчина' : 'женщина';
// и передать дальше по цепочке
return $article;
})
->then(function($article) {
// Функция получает значение, его можно изменить
if( empty($article['avatar']) {
$article['avatar'] = '/avatar/default.png';
}
// и передать дальше по цепочке
return $article;
});
});
Обработка ошибок
Каждая функция (обещание) может генерировать ошибку. В отличии от обещаний в том же JavaScript я в своем пакете пошёл другим путем. Вместо указания отдельной функции для получения ошибки необходимо возвращать эту ошибку с помощью вызова BatchError:create()
. В качестве примера перепишем функцию getUser
.
// Получить информацию о пользователе
function getUser(int $id): BatchPromise
{
// Создаем и возвращаем обещание
return BatchManager::create(
// Функция для обработки сгруппированных данных
function (BatchGroup $group) {
// Получить список уникальных значений аргументов с номером 0
$ids = $group->arg(0);
// Выбрать из БД информацию о пользователях
$users = sql('SELECT * FROM `users` WHERE `id` IN (' . implode(',', $ids) . ')');
// Сгруппировать пользователей по идентификатору
$mUsers = [];
foreach ($users as $user) {
$mUsers[$user['id']] = $user;
}
// Вернуть информацию о пользователе
$group->setResult(function (int $id) use ($mUsers) {
// Возвращаем ошибку если по идентификатору ничего не выбралось
return $mUsers[$id] ?? BatchError::create("Пользователь {$id} не найден",$id);
});
},
$id
);
}
При создании ошибки необходимо указать текст + (опционально) код ошибки. Также класс работы с ошибками содержит статические методы для удобной обработки ошибок.
// Работа с ошибками
class BatchError
{
// Создать ошибку
static public function create(string $message, ?int $code = null): static;
// Создать ошибку отсутствия значения
static public function createUndefined(): static;
// Значение является ошибкой?
static public function has(mixed $value): bool;
// Все значения массив являются ошибками?
static public function hasErrors(array $values): bool;
// Отфильтровать массив значений удалив: true - ошибки/ false - не ошибки
static public function filter(array $values, bool $removeError = true): array;
// Заполнить ошибки значениями
static public function fill(array $values, mixed $value): array;
}
Приоритизация
В нашем случае группы будут выполняться последовательно. Т.е. в каждый момент времени в списке групп будет только одна группа. В более сложной ситуации групп может быть несколько. И в этом случае важно в какой последовательности они будут выполняться.
Возьмем пример с картинки. Предположим у нас в очереди сразу две группы: getArticle и getUser. В зависимости от последовательности выполнения групп у нас будет выполнено разное количество SQL-запросов. При такой последовательности getArticle, getUser
2 запроса: getArticle, getUser. При такой - getUser, getArticle, getUser
3 запроса: getUser, getArticle, getUser.
В первом случае после выполнения группы getArticle будут добавлены вызовы в группу getUser, а потом она выполнится. Во втором случае выполняется группа getUser, а после выполнения группы getArticle будет создана ещё одна группа getUser.
В функционале предусмотрен способ задания приоритизации с помощью расширенного создания обещания:
// Получить информацию о пользователе
function getUser(int $id): BatchPromise
{
// Создаем и возвращаем обещание
return BatchManager::createEx(
// Функция указания расширенных настроек
function (BatchGroupConfig $config) {
// Указываем пониженный приоритет (чтобы эта группа выполнялась последней)
$config->setPriority(BatchManager::PRIORITY_LOW);
},
// Функция для обработки сгруппированных данных
function (BatchGroup $group) {
// ...
},
$id
);
}
Теперь в независимости от того в какой очередности группы находятся в списке для выполнения, группа с пониженным приоритетом будет выполнена последней.
Для более рациональной работы необходимо для групп, которые не создают других групп, указывать пониженный приоритет. Чтобы они выполнялись в последнюю очередь.
Кэширование
Дополнительная полезная возможность использования функций группировок вызовов - это кэширование результата работы. Для этого Все функции разделяются на несколько видов:
Функция без КЭШирования
Это функция по-умолчанию. Значение функции не КЭШируется.
Функция вида LifeTime - кэширование на время
Самый простой вариант кэширования.
class ExampleCacheLifeTime
{
// Функция вида LifeTime - кэширование на время
static public function fnLifeTime(int $x): BatchPromise
{
return BatchManager::createEx(function (BatchGroupConfig $groupConfig) {
// Установить тип функции = LifeTime, установить время жизни = 5 минут
$groupConfig->setCacheLifetime(5 * 60);
}, function (BatchGroup $group) {
// Функция получения результата для каждого набора аргументов
$group->setResult(function (int $minValue) {
// Вернуть случайное число от $minValue до 1000
// и кэшировать это значение на 5 минут
return random_int(min($minValue, 1000), 1000);
});
}, $x);
}
}
Функции вида Get/Put - кэширование до изменения значения
Тип функции Get устанавливается в параметрах расширенного вызова + необходимо указать функции вида Put, от которых эта функция зависит. Если зависимостей нет, то кэширование выполняется навсегда. Если зависимости указаны, то значение кэшируется до изменения текущих значений.
// Пример функции Get и Put
class ExampleCacheGetPut
{
// Хранение значений
static protected array $data = [];
// Функция вида Get
static public function fnGet(int $x): BatchPromise
{
return BatchManager::createEx(function (BatchGroupConfig $groupConfig) {
// Установить тип функции = Get
$groupConfig->setCacheGet();
}, function (BatchGroup $group) {
// Функция получения результата для каждого набора аргументов
$group->setResult(function (int $x) {
// Читать текущее значение
$ret = self::$data[$x] ?? 0;
// Установить зависимость от функции вида Put
self::fnPut($x, $ret);
// Вернуть результат
return $ret;
});
}, $x);
}
// Функция вида Put
public function fnPut(int $x, int $value): BatchPromise
{
return BatchManager::createEx(function (BatchGroupConfig $groupConfig) {
// Установить тип функции = Put
// Указать список индексов ключевых аргументов
$groupConfig->setCachePut(0);
}, function (BatchGroup $group) {
// Функция получения результата для каждого набора аргументов
$group->setResult(function (int $x, int $value): void {
// Установить значение
self::$data[$x] = $value;
});
}, $x, $value);
}
}
Для работы кэширования необходимо установить объекты КЭШирования.
// Установить глобальные интерфейсы КЭШа
BatchConfig::setICache(CacheItemPoolInterface|callable|null $cacheGet, CacheItemPoolInterface|callable|null $cachePut = null): void;
В качестве параметров можно указать либо объект интерфейса КЭШа CacheItemPoolInterface либо замыкание которое такой объект возвращает.
Имеется два КЭШа:
Для сохранения значений функций вида Get (хранит значения результатов работы функций Get).
Для сохранения значений функций вида Put (хранит значения зависимостей функций вида Put). Если указать только аргумент $cacheGet, то аргумент $cachePut будет автоматически установлен в то же значение.
Как работает кэширование вида Get/Put?

Описание шагов:
(1) КЭШ Put и Get пустые.
(2) Вызываем функцию fnPut(2,7)
в результате в КЭШ-е Put сохраняется значение 7
с ключом fnPut,2
.
(3) Вызываем функцию fnGet(2)
. Функция проверяет значение в КЕШе Get, не находит его и вызывает групповую функцию fnGet в аргументом 2, получает результат 14 и сохраняет в КЕШ полученный результат + зависимые значения.
(3а) Вызываем функцию fnGet(2)
. Функция проверяет значение в КЕШе Get по ключу fnGet,2
, находит его и получает значение 14. Также считывается список зависимостей и проверяется что все зависимости соответствуют значениям в КЭШе Put. В данном случае они соответствуют и возвращается значение 14. Т.е. значение берется из КЭШа и групповая функция не вызывается.
(4) Вызываем функцию fnPut(2,9)
в результате в КЭШ-е Put сохраняется значение 9
с ключом fnPut,2
.
(5) Вызываем функцию fnGet(2)
. Функция проверяет значение в КЕШе Get по ключу fnGet,2
, находит его и получает значение 14. Также считывается список зависимостей и проверяется что все зависимости соответствуют значениям в КЭШе Put. В данном случае они НЕ соответствуют (7!=9) и поэтому вызывается групповая функция которая возвращает значение 18. После сохраняет в КЕШ Get полученный результат + зависимые значения.
Режим работы с КЭШем
Групповая функция с кэшированием хороша, но иногда необходимо чтобы такая функция игнорировала значения в КЭШе и выполняла прямое чтение значения через групповую функцию. И такая возможность есть. Каждая функция возвращает объект BatchPromise содержащий метод setCacheMode, который позволяет установить следующий режим работы:
BatchPromise::MODE_CACHE_OFF - отключить КЭШирование. Т.е. в независимости от типа функции она будет вызвана так как обычная функция. И результат её работы не будет сохранен в КЭШе.
BatchPromise::MODE_CACHE_DIRECT - отключить КЭШирование, но сохранять изменения в КЭШ. Т.е. в независимости от типа функции она будет вызвана так как обычная функция. И результат её работы будет сохранен в КЭШе.
BatchPromise::MODE_CACHE_ON - включить КЭШирование (значение по умолчанию). Т.е. функция работает в режиме КЭШирования - читает и сохраняет значения в КЭШ.