Обёртка над API-клиентом, кэширующая ответы через __call, — приём эффектный: подключается одной строкой и не требует трогать существующий код. За эту элегантность приходится платить, и цену стоит понимать заранее — особенно в части, где кэш привязан к пользователю.
Задача
Нужно кэшировать результаты методов класса API с учётом пользователя. Решение — создать обёртку для существующего класса API, которая будет:
- Перехватывать вызовы методов
- Проверять наличие кэша для данного пользователя и метода
- Возвращать закешированный результат, если он есть
- Сохранять новые результаты в кэш
Реализация класса CachedApi
<?php
namespace App\Services;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Auth;
class CachedApi
{
protected $apiInstance;
protected $user;
protected int $cacheTtl = 600; // 10 минут
public function __construct($apiInstance)
{
$this->apiInstance = $apiInstance;
// ВНИМАНИЕ: пользователь тут фиксируется в момент создания объекта.
// См. предупреждение про singleton ниже — в контейнере это
// приводит к тому, что все запросы работают с кэшем первого юзера
$this->user = Auth::user();
}
/**
* Магический метод для перехвата вызовов
*/
public function __call($method, $arguments)
{
$cacheKey = $this->getCacheKey($method, $arguments);
// remember вместо has + get: одно обращение к хранилищу вместо двух
// и корректная работа с ответами, равными null
return Cache::remember(
$cacheKey,
now()->addSeconds($this->cacheTtl),
fn() => $this->apiInstance->{$method}(...$arguments)
);
}
/**
* Формирование уникального ключа кэша
*/
protected function getCacheKey(string $method, array $arguments): string
{
$userId = $this->user?->id ?? 'guest';
// Сериализуем аргументы для уникальности
$argumentsKey = md5(serialize($arguments));
return "api_cache:user_{$userId}:method_{$method}:args_{$argumentsKey}";
}
/**
* Установка времени жизни кэша
*/
public function setCacheTtl(int $seconds): self
{
$this->cacheTtl = $seconds;
return $this;
}
/**
* Очистка кэша для метода
*/
public function clearCache(string $method, array $arguments = []): void
{
$cacheKey = $this->getCacheKey($method, $arguments);
Cache::forget($cacheKey);
}
/**
* Очистка всего кэша пользователя
*/
public function clearUserCache(): void
{
$userId = $this->user?->id ?? 'guest';
$pattern = "api_cache:user_{$userId}:*";
// Для Redis
if (Cache::getStore() instanceof \Illuminate\Cache\RedisStore) {
$keys = Cache::getStore()->getRedis()->keys($pattern);
foreach ($keys as $key) {
Cache::forget($key);
}
}
}
}Связка has() + get() — не только лишний запрос, но и ошибка. Cache::has() возвращает false для значения null. Если API законно вернул null (товар не найден, список пуст), такой ответ никогда не будет считаться закэшированным, и каждый запрос будет уходить наружу — то есть кэш перестанет работать ровно в том сценарии, где внешний сервис отвечает медленнее всего. Cache::remember() этой проблемы лишён.
Плюс между has() и get() кэш может истечь, и get() вернёт null вместо данных.
Самая опасная деталь этой конструкции — пользователь, зафиксированный в конструкторе. Если зарегистрировать обёртку в контейнере как singleton (а раздел про Service Container ниже к этому подталкивает), объект создастся один раз. В обычном PHP-FPM это ещё безобидно — процесс живёт один запрос. Но в Octane, Swoole или RoadRunner приложение остаётся в памяти между запросами: $this->user останется от первого пользователя, и все последующие получат его кэш, то есть чужие данные.
Правильно: либо регистрировать через bind (новый объект на каждый запрос), либо не хранить пользователя в свойстве, а брать Auth::id() в момент формирования ключа.
Использование
Базовый пример
use App\Services\CachedApi;
use App\Services\ExternalApi;
// Создаём экземпляр оригинального API-класса
$api = new ExternalApi();
// Оборачиваем его кэширующим классом
$cachedApi = new CachedApi($api);
// Вызываем методы как обычно
$orders = $cachedApi->getOrders();
$products = $cachedApi->getProducts(['category' => 'electronics']);С настройкой TTL
$cachedApi = new CachedApi($api);
$cachedApi->setCacheTtl(3600); // 1 час
$data = $cachedApi->getLongRunningData();Очистка кэша после изменений
// После создания заказа очищаем кэш списка заказов
$api->createOrder($orderData);
$cachedApi->clearCache('getOrders');
// Или с конкретными аргументами
$cachedApi->clearCache('getProducts', ['category' => 'electronics']);Расширенная версия с исключениями
<?php
namespace App\Services;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Auth;
class CachedApi
{
protected $apiInstance;
protected $user;
protected int $cacheTtl = 600;
/**
* Методы, которые не нужно кэшировать
*/
protected array $excludedMethods = [
'create',
'update',
'delete',
'store',
'save',
];
public function __construct($apiInstance)
{
$this->apiInstance = $apiInstance;
// ВНИМАНИЕ: пользователь тут фиксируется в момент создания объекта.
// См. предупреждение про singleton ниже — в контейнере это
// приводит к тому, что все запросы работают с кэшем первого юзера
$this->user = Auth::user();
}
public function __call($method, $arguments)
{
// Проверяем, нужно ли кэшировать этот метод
if ($this->shouldSkipCache($method)) {
return call_user_func_array(
[$this->apiInstance, $method],
$arguments
);
}
$cacheKey = $this->getCacheKey($method, $arguments);
return Cache::remember($cacheKey, $this->cacheTtl, function () use ($method, $arguments) {
return call_user_func_array(
[$this->apiInstance, $method],
$arguments
);
});
}
/**
* Проверка, нужно ли пропустить кэширование
*/
protected function shouldSkipCache(string $method): bool
{
foreach ($this->excludedMethods as $excluded) {
if (str_starts_with(strtolower($method), $excluded)) {
return true;
}
}
return false;
}
/**
* Добавление метода в исключения
*/
public function excludeMethod(string $method): self
{
$this->excludedMethods[] = $method;
return $this;
}
protected function getCacheKey(string $method, array $arguments): string
{
$userId = $this->user?->id ?? 'guest';
$argumentsKey = md5(serialize($arguments));
return "api_cache:user_{$userId}:method_{$method}:args_{$argumentsKey}";
}
}Регистрация в Service Container
// app/Providers/AppServiceProvider.php
use App\Services\CachedApi;
use App\Services\ExternalApi;
public function register(): void
{
$this->app->singleton(CachedApi::class, function ($app) {
return new CachedApi(
$app->make(ExternalApi::class)
);
});
}Использование через DI:
class OrderController extends Controller
{
public function __construct(
protected CachedApi $api
) {}
public function index()
{
return $this->api->getOrders();
}
}Тегированный кэш
Если используете Redis, можно добавить теги для более гибкой очистки:
protected function getCachedResult(string $method, array $arguments)
{
$cacheKey = $this->getCacheKey($method, $arguments);
$userId = $this->user?->id ?? 'guest';
return Cache::tags(['api', "user:{$userId}"])
->remember($cacheKey, $this->cacheTtl, function () use ($method, $arguments) {
return call_user_func_array(
[$this->apiInstance, $method],
$arguments
);
});
}
public function clearUserCache(): void
{
$userId = $this->user?->id ?? 'guest';
Cache::tags(["user:{$userId}"])->flush();
}
public function clearAllApiCache(): void
{
Cache::tags(['api'])->flush();
}Ограничения подхода
Магический __call даёт прозрачность, и она же скрывает проблемы. Что стоит знать до внедрения.
Кэшируется всё подряд. Обёртка не знает, какой метод читает данные, а какой их меняет. Список исключений — единственная защита, и он по своей природе чёрный: новый метод в API-клиенте автоматически становится кэшируемым, включая deleteOrder(). Белый список кэшируемых методов надёжнее, хотя и требует поддержки.
IDE и статический анализ перестают помогать. Через __call не работает автодополнение, а PHPStan не проверит существование метода и типы аргументов — опечатка в имени метода превратится из ошибки времени компиляции в ошибку времени выполнения. Отчасти лечится аннотацией @method в докблоке класса.
Ключ строится из serialize($arguments). Для скаляров и массивов это работает. Для объектов результат зависит от всех их свойств, включая незначимые, — и два логически одинаковых запроса дадут разные ключи, то есть кэш не попадёт ни разу. Если в аргументах бывают объекты, приводите их к скалярному представлению явно.
Нет защиты от одновременных промахов. Когда кэш истекает на популярном методе, все параллельные запросы уйдут во внешний API одновременно. Для внешнего сервиса с ограничением частоты это способ получить блокировку в самый нагруженный момент. Лечится блокировкой на время генерации (Cache::lock()).
Когда стоит выбрать явное кэширование вместо обёртки. Если методов немного, Cache::remember() прямо в сервисном классе даёт то же самое: явно видно, что кэшируется и с каким ключом, работает автодополнение, не нужны списки исключений. Обёртка через __call оправдана, когда методов десятки и они однородны — например, тонкий клиент чужого API, который вы не хотите переписывать.
Комментарии
Система комментариев скоро будет подключена. А пока вы можете написать мне в Telegram или на email.