Nette Caching

Кеш ускоряет ваше приложение, сохраняя данные, получение которых однажды обошлось дорого, и позволяя быстрее обращаться к ним в дальнейшем. Мы разберём:

  • как использовать кеш
  • как сменить хранилище
  • как правильно сбрасывать кеш

Использовать кеш в Nette очень просто, и при этом он покрывает продуманные потребности кеширования. Он рассчитан на производительность и стопроцентную надёжность. В нём есть адаптеры для самых распространённых хранилищ. Он поддерживает сброс по тегам, истечение по времени, защиту от cache stampede и не только.

Установка

Скачайте и установите пакет с помощью Composer:

composer require nette/caching

Основы использования

Основной элемент для работы с кешем – объект Nette\Caching\Cache. Мы создаём его экземпляр, передавая в конструктор объект хранилища. Этот объект хранилища представляет физическое место, где будут храниться данные (база данных, Memcached, файлы на диске и т. д.). Обычно вы получаете объект хранилища через внедрение зависимостей, запросив тип Nette\Caching\Storage. Самое важное вы узнаете в разделе Хранилища.

В версии 3.0 у интерфейса ещё была приставка I, так что имя было Nette\Caching\IStorage. Кроме того, константы класса Cache записывались прописными буквами, например Cache::EXPIRE вместо Cache::Expire.

В следующих примерах будем считать, что у нас есть псевдоним Cache и экземпляр хранилища в переменной $storage.

use Nette\Caching\Cache;

$storage = /* ... */; // экземпляр Nette\Caching\Storage

Кеш – это по сути хранилище ключ-значение, то есть мы читаем и пишем данные по ключам, как в ассоциативном массиве. Приложения состоят из нескольких независимых частей. Если бы все части использовали одно хранилище (представьте себе один каталог на диске), рано или поздно возникло бы столкновение ключей. Nette Framework решает это разделением пространства хранилища на пространства имён (по сути на подкаталоги). Каждая часть приложения тогда работает в собственном пространстве имён с уникальным именем, и никаких столкновений возникнуть не может.

Имя пространства имён укажите вторым аргументом конструктора класса Cache:

$cache = new Cache($storage, 'Full Html Pages');

При необходимости от существующего экземпляра можно вывести новый кеш, ограниченный подпространством имён, методом derive():

$subCache = $cache->derive('Images');

Теперь мы можем использовать объект $cache для чтения из кеша и записи в него. Обеим задачам служит метод load(). Первый аргумент – ключ, а второй – PHP-callback, который вызывается, если ключ в кеше не найден. Callback порождает значение, возвращает его, а метод load() его кеширует:

$value = $cache->load($key, function () use ($key) {
	$computedValue = /* ... */; // дорогое вычисление
	return $computedValue;
});

Если второй параметр опущен ($value = $cache->load($key)), load() возвращает null, когда элемента в кеше нет.

Прекрасно, что кешировать можно любые сериализуемые структуры, а не только строки. То же относится и к ключам.

Чтобы удалить элемент из кеша, используйте метод remove():

$cache->remove($key);

Сохранить элемент в кеш можно и методом $cache->save($key, $data, ?array $dependencies = null). Однако подход с load(), показанный выше, обычно предпочтительнее.

Мемоизация

Мемоизация состоит в кешировании результата вызова функции или метода, так что при следующем вызове с теми же аргументами вместо повторного вычисления возвращается закешированный результат.

Методы и функции можно вызывать мемоизированно с помощью call(callable $callback, ...$args):

$result = $cache->call('gethostbyaddr', $ip);

Функция gethostbyaddr() тем самым вызывается только один раз для каждого уникального аргумента $ip. Последующие вызовы с тем же $ip вернут закешированное значение.

Можно создать и мемоизированную обёртку вокруг метода или функции, которую затем вызывать:

function factorial($num)
{
	return /* ... */;
}

$memoizedFactorial = $cache->wrap('factorial');

$result = $memoizedFactorial(5); // в первый раз вычисляет
$result = $memoizedFactorial(5); // во второй раз возвращает из кеша

Истечение и сброс

При использовании кеширования нужно решить вопрос о том, когда ранее сохранённые данные становятся недействительными. Nette Framework предоставляет механизмы для ограничения срока действия данных или их явного удаления (на языке фреймворка это называется “инвалидацией”).

Срок действия данных задаётся в момент сохранения, обычно третьим параметром метода save(), например:

$cache->save($key, $value, [
	$cache::Expire => '20 minutes',
]);

Как вариант, его можно задать через параметр $dependencies, передаваемый по ссылке в callback метода load(), например:

$value = $cache->load($key, function (&$dependencies) {
	$dependencies[Cache::Expire] = '20 minutes';
	return /* ... */;
});

Либо через 3-й параметр самого метода load(), например:

$value = $cache->load($key, function () {
	return /* ... */;
}, [Cache::Expire => '20 minutes']);

В следующих примерах будем исходить из второго варианта, использующего переменную $dependencies внутри callback'а.

Истечение

Простейший вид истечения – ограничение по времени. Так данные кешируются со сроком действия 20 минут:

// принимает и количество секунд, и метку времени UNIX
$dependencies[Cache::Expire] = '20 minutes';

Если вы хотите, чтобы срок действия продлевался при каждом чтении (скользящее истечение), добиться этого можно так, но учтите, что это увеличивает накладные расходы кеша:

$dependencies[Cache::Sliding] = true;

Полезная возможность – дать данным истечь при изменении определённого файла или одного из нескольких файлов. Это удобно, например, при кешировании данных, полученных обработкой этих файлов. Используйте абсолютные пути.

$dependencies[Cache::Files] = '/path/to/data.yaml';
// либо
$dependencies[Cache::Files] = ['/path/to/data1.yaml', '/path/to/data2.yaml'];

Мы можем сделать так, чтобы элемент кеша истёк, когда истечёт другой конкретный элемент (или один из нескольких). Это удобно, когда мы кешируем, например, целую HTML-страницу и её фрагменты под разными ключами. Когда фрагмент меняется, вся страница должна быть сброшена. Если фрагменты сохранены под ключами вроде frag1 и frag2, используйте:

$dependencies[Cache::Items] = ['frag1', 'frag2'];

Истечением можно управлять и с помощью собственных функций или статических методов. Они вызываются при каждом чтении, чтобы определить, действителен ли элемент ещё. Например, мы можем сделать так, чтобы элемент истекал всякий раз при смене версии PHP. Создайте функцию, которая сравнивает текущую версию с параметром, и при сохранении добавьте в зависимости массив вида [имя функции, ...аргументы]:

function checkPhpVersion($ver): bool
{
	return $ver === PHP_VERSION_ID;
}

$dependencies[Cache::Callbacks] = [
	['checkPhpVersion', PHP_VERSION_ID] // истечёт, когда checkPhpVersion(...) === false
];

Естественно, все эти условия можно сочетать. Элемент кеша истекает, если перестало выполняться хотя бы одно из них.

$dependencies[Cache::Expire] = '20 minutes';
$dependencies[Cache::Files] = '/path/to/data.yaml';

Сброс по тегам

Теги дают очень удобный механизм сброса. Каждому сохранённому в кеш элементу мы можем присвоить список тегов (произвольных строк). Например, допустим, у нас есть HTML-страница, показывающая статью и комментарии к ней, которую мы хотим закешировать. При сохранении мы указываем соответствующие теги:

$dependencies[Cache::Tags] = ["article/$articleId", "comments/$articleId"];

Теперь перейдём в административную часть. Здесь у нас есть форма редактирования статей. Вместе с сохранением статьи в базу данных мы вызываем метод clean(), чтобы удалить закешированные элементы по их тегу:

$cache->clean([
	$cache::Tags => ["article/$articleId"],
]);

Точно так же при добавлении нового комментария (или его редактировании) мы должны не забыть сбросить соответствующий тег:

$cache->clean([
	$cache::Tags => ["comments/$articleId"],
]);

Чего мы добились? Наш HTML-кеш теперь будет сбрасываться (удаляться) всякий раз, когда меняется связанная статья или комментарии к ней. При редактировании статьи с ID = 10 сбрасывается тег article/10, и закешированная HTML-страница, несущая этот тег, удаляется. То же самое происходит при добавлении нового комментария под соответствующей статьёй.

Теги требуют журнала.

Сброс по приоритету

Отдельным элементам кеша можно присвоить приоритеты. Это позволяет управляемо удалять их, например когда кеш превышает определённый предел размера:

$dependencies[Cache::Priority] = 50;

Чтобы удалить все элементы с приоритетом, равным 100 или меньше:

$cache->clean([
	$cache::Priority => 100,
]);

Приоритеты требуют так называемого журнала.

Очистка кеша

Параметр Cache::All очищает всё:

$cache->clean([
	$cache::All => true,
]);

Массовое чтение

Для массового чтения из кеша и записи в него служит метод bulkLoad(). Передайте ему массив ключей, и он вернёт массив соответствующих значений:

$values = $cache->bulkLoad($keys);

Метод bulkLoad() работает похоже на load() и тоже принимает вторым параметром callback. Этот callback получает ключ порождаемого элемента:

$values = $cache->bulkLoad($keys, function ($key, &$dependencies) {
	$computedValue = /* ... */; // дорогое вычисление
	return $computedValue;
});

И наоборот, чтобы записать сразу несколько элементов, используйте метод bulkSave(), который принимает массив пар ключ => значение и необязательные зависимости:

$cache->bulkSave([
	$key1 => $value1,
	$key2 => $value2,
], [Cache::Expire => '20 minutes']);

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

Чтобы использовать Nette Cache с интерфейсом PSR-16, можно воспользоваться PsrCacheAdapter. Он даёт бесшовную интеграцию между Nette Cache и любым кодом или библиотекой, ожидающей реализацию кеша, совместимую с PSR-16.

$psrCache = new Nette\Bridges\Psr\PsrCacheAdapter($storage);

Теперь вы можете использовать $psrCache как обычный кеш PSR-16:

$psrCache->set('key', 'value', 3600); // сохраняет значение на 1 час
$value = $psrCache->get('key', 'default');

Адаптер поддерживает все методы, определённые в PSR-16, включая getMultiple(), setMultiple() и deleteMultiple().

Кеширование вывода

Вывод можно очень изящно перехватить и закешировать:

if ($capture = $cache->capture($key)) {

	// echo ... выводим какие-то данные

	$capture->end(); // сохраняем вывод в кеш
}

Если вывод уже есть в кеше, метод capture() его выводит и возвращает null, так что блок условия if пропускается. Иначе он начинает буферизовать вывод и возвращает объект $capture, которым вы в конце сохраняете перехваченные данные в кеш через его метод end().

В версии 3.0 этот метод назывался $cache->start().

Кеширование в Latte

Кеширование в шаблонах Latte совсем просто. Достаточно обернуть часть шаблона, которую вы хотите закешировать, тегами {cache}...{/cache}. Кеш автоматически сбрасывается всякий раз, когда меняется исходный файл шаблона (включая любые шаблоны, подключённые внутри кешируемого блока). Теги {cache} можно вкладывать. Когда сбрасывается вложенный блок (например, по тегу), сбрасывается и родительский блок.

Внутри тега можно указать ключи, к которым будет привязана запись кеша (здесь переменная $id), задать время истечения и определить теги сброса.

{cache $id, expire: '20 minutes', tags: [tag1, tag2]}
	...
{/cache}

Все эти параметры необязательны, так что указывать ни истечение, ни теги, ни даже ключи не обязательно.

Использование кеширования можно сделать и условным через if: содержимое будет закешировано, только если условие выполнено:

{cache $id, if: !$form->isSubmitted()}
	{$form}
{/cache}

Хранилища

Хранилище – объект, представляющий физическое место, где хранятся данные. Мы можем использовать базу данных, сервер Memcached или самое доступное хранилище: файлы на диске.

Хранилище Описание
FileStorage Хранилище по умолчанию, сохраняет кеш в файлы на диске.
MemcachedStorage Для хранения использует сервер Memcached.
MemoryStorage Данные временно хранятся в памяти (теряются в конце запроса).
SQLiteStorage Данные хранятся в файле базы данных SQLite.
DevNullStorage Данные на самом деле не хранятся; полезно для тестирования.

Объект хранилища вы получаете через внедрение зависимостей, запросив тип Nette\Caching\Storage. По умолчанию Nette предоставляет объект FileStorage, который хранит данные в подкаталоге cache внутри каталога для временных файлов.

Изменить хранилище по умолчанию можно в конфигурации:

services:
	cache.storage: Nette\Caching\Storages\DevNullStorage

FileStorage

Записывает записи кеша в файлы на диске. Хранилище Nette\Caching\Storages\FileStorage сильно оптимизировано по производительности и, что принципиально важно, обеспечивает полную атомарность операций. Что это значит? При использовании кеша не может случиться, что вы прочитаете файл, который другой поток ещё не дописал до конца, или что кто-то удалит его, пока вы читаете. Поэтому использование этого хранилища кеша совершенно безопасно.

В этом хранилище есть и важная встроенная возможность, предотвращающая чрезмерный всплеск нагрузки на процессор, когда кеш очищен или ещё “холодный” (то есть ещё не создан). Это защита от так называемого cache stampede. Он возникает, когда несколько одновременных запросов разом просят один и тот же элемент кеша (например, результат дорогого SQL-запроса). Если элемента в кеше в этот момент нет, все эти процессы могли бы начать выполнять одну и ту же дорогую операцию (тот же SQL-запрос). Это многократно увеличивает нагрузку на сервер, и может даже случиться, что ни один поток не успеет ответить в отведённое время, кеш не создастся, а приложение рухнет. К счастью, кеш Nette с этим справляется: при нескольких одновременных запросах на один и тот же элемент порождает его только первый поток. Остальные потоки ждут и затем используют результат, порождённый первым.

Пример создания FileStorage:

// хранилищем будет каталог '/path/to/temp' на диске
$storage = new Nette\Caching\Storages\FileStorage('/path/to/temp');

MemcachedStorage

Сервер Memcached – высокопроизводительная распределённая система кеширования объектов в памяти. Его адаптер в Nette – Nette\Caching\Storages\MemcachedStorage. В конфигурации укажите IP-адрес сервера и порт, если он отличается от стандартного 11211.

Требует PHP-расширения memcached.

services:
	cache.storage: Nette\Caching\Storages\MemcachedStorage('10.0.0.5')

MemoryStorage

Nette\Caching\Storages\MemoryStorage – хранилище, которое держит данные в массиве PHP. Соответственно, данные теряются в конце запроса.

SQLiteStorage

База данных SQLite вместе с адаптером Nette\Caching\Storages\SQLiteStorage даёт способ кешировать данные в одном файле на диске. В конфигурации указывается путь к этому файлу базы данных.

Требует PHP-расширений pdo и pdo_sqlite.

services:
	cache.storage: Nette\Caching\Storages\SQLiteStorage('%tempDir%/cache.db')

DevNullStorage

Особая реализация хранилища – Nette\Caching\Storages\DevNullStorage, которая на самом деле никаких данных не хранит. Поэтому она подходит для тестирования, когда вы хотите исключить влияние кеширования.

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

При использовании кеширования в своём коде есть два основных подхода. Первый – получить объект хранилища через внедрение зависимостей и затем создать объект Cache самому:

use Nette;

class ClassOne
{
	private Nette\Caching\Cache $cache;

	public function __construct(Nette\Caching\Storage $storage)
	{
		$this->cache = new Nette\Caching\Cache($storage, 'my-namespace');
	}
}

Второй вариант – запросить объект Cache напрямую:

class ClassTwo
{
	public function __construct(
		private Nette\Caching\Cache $cache,
	) {
	}
}

Объект Cache тогда нужно определить в конфигурации, например так:

services:
	- ClassTwo( Nette\Caching\Cache(namespace: 'my-namespace') )

Журнал

Сведения о тегах и приоритетах Nette хранит в так называемом журнале. По умолчанию для этого используется SQLite через файл journal.s3db, и требуются PHP-расширения pdo и pdo_sqlite.

Изменить реализацию журнала можно в конфигурации:

services:
	cache.journal: MyJournal

Сервисы DI

Эти сервисы добавляются в DI-контейнер:

Имя Тип Описание
cache.journal Nette\Caching\Storages\Journal Хранилище журнала кеша
cache.storage Nette\Caching\Storage Основное хранилище кеша

Отключение кеша

Один из способов отключить кеширование в приложении – задать в качестве хранилища DevNullStorage:

services:
	cache.storage: Nette\Caching\Storages\DevNullStorage

Эта настройка не влияет на кеширование шаблонов Latte или DI-контейнера, потому что эти библиотеки не используют сервисы nette/caching и управляют своим кешем самостоятельно. Кроме того, их кеш обычно не нужно отключать в режиме разработки.

Если вы переходите на более новую версию, посмотрите страницу обновления.

версия: 3.x