Главная/Статьи/Laravel: обёртка для кэширования API-запросов

Laravel: обёртка для кэширования API-запросов

Создаём универсальный класс-обёртку для кэширования результатов API с учётом пользователя. Магический метод __call и автоматическое кэширование.

ДМ
Дмитрий Мещеряков
📅 25 сентября 2024 г.📖 6 мин чтения

Обёртка над API-клиентом, кэширующая ответы через __call, — приём эффектный: подключается одной строкой и не требует трогать существующий код. За эту элегантность приходится платить, и цену стоит понимать заранее — особенно в части, где кэш привязан к пользователю.

Задача

Нужно кэшировать результаты методов класса API с учётом пользователя. Решение — создать обёртку для существующего класса API, которая будет:

  • Перехватывать вызовы методов
  • Проверять наличие кэша для данного пользователя и метода
  • Возвращать закешированный результат, если он есть
  • Сохранять новые результаты в кэш

Реализация класса CachedApi

php
<?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() в момент формирования ключа.

Использование

Базовый пример

php
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

php
$cachedApi = new CachedApi($api);
$cachedApi->setCacheTtl(3600); // 1 час

$data = $cachedApi->getLongRunningData();

Очистка кэша после изменений

php
// После создания заказа очищаем кэш списка заказов
$api->createOrder($orderData);
$cachedApi->clearCache('getOrders');

// Или с конкретными аргументами
$cachedApi->clearCache('getProducts', ['category' => 'electronics']);

Расширенная версия с исключениями

php
<?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

php
// 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:

php
class OrderController extends Controller
{
    public function __construct(
        protected CachedApi $api
    ) {}

    public function index()
    {
        return $this->api->getOrders();
    }
}

Тегированный кэш

Если используете Redis, можно добавить теги для более гибкой очистки:

php
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.