Низкоуровневый API кэша в Django

В предыдущей статье мы рассмотрели обзор кэширования в Django и погрузились в то, как кэшировать представление Django, а также в использование различных бэкендов кэша. В этой статье мы более подробно рассмотрим низкоуровневый API кэширования в Django.

Статьи о кэшировании в Django:

  1. Кэширование в Django
  2. Низкоуровневый API кэша в Django (эта статья)

Цели

К концу этой статьи вы должны уметь:

  1. Установите Redis в качестве бэкенда кэша Django
  2. Используйте низкоуровневый API кэша Django для кэширования модели
  3. Инвалидация кэша с помощью сигналов базы данных Django
  4. Упростите аннулирование кэша с помощью Django Lifecycle
  5. Взаимодействие с низкоуровневым API кэша

Django Low-Level Cache

Кэширование в Django может быть реализовано на разных уровнях (или частях сайта). Вы можете кэшировать весь сайт или определенные части с различными уровнями детализации (перечислены в порядке убывания детализации):

Подробнее о различных уровнях кэширования в Django читайте в статье Кэширование в Django.

Если кэш Django для каждого сайта или просмотра недостаточно гранулирован для нужд вашего приложения, то вы можете использовать API кэша низкого уровня для управления кэшированием на уровне объектов.

Вы можете захотеть использовать низкоуровневый API кэша, если вам нужно кэшировать различные:

  1. Объекты модели, изменяющиеся с разной периодичностью
  2. Данные зарегистрированных пользователей отделены друг от друга
  3. Внешние ресурсы с большой вычислительной нагрузкой
  4. Внешние вызовы API

Итак, низкоуровневый кэш Django хорош, когда вам нужна большая детализация и контроль над кэшем. В нем можно хранить любые объекты, которые можно безопасно мариновать. Чтобы использовать низкоуровневый кэш, вы можете воспользоваться либо встроенным django.core.cache.caches, либо, если вы хотите использовать кэш по умолчанию, определенный в файле settings.py, через django.core.cache.cache.

Настройка проекта

Склонируйте базовый проект из репозитория django-low-level-cache на GitHub:

$ git clone -b base https://github.com/testdrivenio/django-low-level-cache
$ cd django-low-level-cache

Создайте (и активируйте) виртуальную среду и установите требования:

$ python3.9 -m venv venv
$ source venv/bin/activate
(venv)$ pip install -r requirements.txt

Примените миграции Django, загрузите некоторые данные о товарах в базу данных и запустите сервер:

(venv)$ python manage.py migrate
(venv)$ python manage.py seed_db
(venv)$ python manage.py runserver

Перейдите по адресу http://127.0.0.1:8000 в вашем браузере, чтобы проверить, что все работает так, как ожидалось.

uncached product page

Бэкэнд кэша

Мы будем использовать Redis для бэкенда кэша.

Скачайте и установите Redis.

Если вы работаете на Mac, мы рекомендуем установить Redis с помощью Homebrew:

$ brew install redis

После установки в новом окне терминала запустите сервер Redis и убедитесь, что он работает на порту по умолчанию, 6379. Номер порта будет важен, когда мы будем указывать Django, как взаимодействовать с Redis.

$ redis-server

Для того чтобы Django использовал Redis в качестве бэкенда кэша, необходима зависимость django-redis. Она уже установлена, поэтому вам просто нужно добавить пользовательский бэкенд в файл settings.py:

CACHES = {
    'default': {
        'BACKEND': 'django_redis.cache.RedisCache',
        'LOCATION': 'redis://127.0.0.1:6379/1',
        'OPTIONS': {
            'CLIENT_CLASS': 'django_redis.client.DefaultClient',
        }
    }
}

Теперь, когда вы снова запустите сервер, Redis будет использоваться в качестве бэкэнда кэша:

(venv)$ python manage.py runserver

Обратитесь к коду. Представление HomePageView в products/views.py просто перечисляет все товары в базе данных:

class HomePageView(View):
    template_name = 'products/home.html'

    def get(self, request):
        product_objects = Product.objects.all()

        context = {
            'products': product_objects
        }

        return render(request, self.template_name, context)

Добавим поддержку низкоуровневого API кэша в объекты продукта.

Сначала добавьте импорт в начало файла products/views.py:

from django.core.cache import cache

Затем добавьте код для кэширования товаров в представление:

class HomePageView(View):
    template_name = 'products/home.html'

    def get(self, request):
        product_objects = cache.get('product_objects')      # NEW

        if product_objects is None:                         # NEW
            product_objects = Product.objects.all()
            cache.set('product_objects', product_objects)   # NEW

        context = {
            'products': product_objects
        }

        return render(request, self.template_name, context)

Здесь мы сначала проверили, есть ли объект кэша с именем product_objects в нашем стандартном кэше:

  • Если да, то мы просто возвращаем его в шаблон, не выполняя запрос к базе данных.
  • Если он не найден в нашем кэше, мы запрашиваем базу данных и добавляем результат в кэш с ключом product_objects.

При работающем сервере перейдите по адресу http://127.0.0.1:8000 в браузере. Нажмите на "Cache" в правом меню Django Debug Toolbar. Вы должны увидеть что-то похожее на:

Django Debug Toolbar

Было два вызова кэша:

  1. Первый вызов попытался получить объект кэша с именем product_objects, что привело к пропуску кэша, поскольку объект не существует.
  2. Второй вызов установил объект кэша, используя то же имя, с результатом кверисета всех товаров.

Также был один SQL-запрос. В целом загрузка страницы заняла около 313 миллисекунд.

Обновите страницу в браузере:

Django Debug Toolbar

На этот раз вы должны увидеть попадание в кэш, которое получает объект кэша с именем product_objects. Также не было никаких SQL-запросов, а загрузка страницы заняла около 234 миллисекунд.

Попробуйте добавить новый продукт, обновить существующий продукт и удалить продукт. Вы не увидите изменений по адресу http://127.0.0.1:8000 до тех пор, пока вручную не аннулируете кэш, нажав кнопку "Аннулировать кэш".

Инвалидирование кэша

Далее рассмотрим, как автоматически аннулировать кэш. В предыдущей статье мы рассмотрели, как аннулировать кэш по истечении определенного периода времени (TTL). В этой статье мы рассмотрим, как аннулировать кэш, когда что-то в модели меняется - например, когда продукт добавляется в таблицу продуктов или когда существующий продукт обновляется или удаляется.

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

Для этой задачи мы могли бы использовать базу данных signals:

Django включает в себя "диспетчер сигналов", который помогает разделенным приложениям получать уведомления о действиях, происходящих в других частях фреймворка. В двух словах, сигналы позволяют определенным отправителям уведомлять набор получателей о том, что произошло какое-то действие. Они особенно полезны, когда многие части кода могут быть заинтересованы в одних и тех же событиях.

Сохраняет и удаляет

Чтобы настроить сигналы для обработки недействительности кэша, начните с обновления products/apps.py следующим образом:

from django.apps import AppConfig


class ProductsConfig(AppConfig):
    name = 'products'

    def ready(self):                # NEW
        import products.signals     # NEW

Далее создайте файл под названием signals.py в каталоге "products":

from django.core.cache import cache
from django.db.models.signals import post_delete, post_save
from django.dispatch import receiver

from .models import Product


@receiver(post_delete, sender=Product, dispatch_uid='post_deleted')
def object_post_delete_handler(sender, **kwargs):
     cache.delete('product_objects')


@receiver(post_save, sender=Product, dispatch_uid='posts_updated')
def object_post_save_handler(sender, **kwargs):
    cache.delete('product_objects')

Здесь мы использовали декоратор receiver из django.dispatch для декорирования двух функций, которые вызываются при добавлении или удалении товара соответственно. Давайте посмотрим на аргументы:

  1. Первым аргументом является событие сигнала, к которому нужно привязать декорированную функцию, либо save, либо delete.
  2. Мы также указали отправителя, модель Product, от которой будут поступать сигналы.
  3. Наконец, мы передали строку в качестве dispatch_uid, чтобы предотвратить дублирование сигналов.

Таким образом, когда в модели Product происходит сохранение или удаление, вызывается метод delete на объекте кэша для удаления содержимого product_objects кэша.

Чтобы увидеть это в действии, запустите или перезапустите сервер и перейдите по адресу http://127.0.0.1:8000 в браузере. Откройте вкладку "Кэш" на панели инструментов отладки Django. Вы должны увидеть один пропуск кэша. Обновите страницу, и у вас не должно быть пропусков кэша и одно попадание в кэш. Закройте страницу панели отладки. Затем нажмите кнопку "Новый продукт", чтобы добавить новый продукт. После нажатия кнопки "Сохранить" вы должны быть перенаправлены обратно на главную страницу. На этот раз вы должны увидеть один промах кэша, что указывает на то, что сигнал сработал. Кроме того, ваш новый продукт должен быть виден в верхней части списка продуктов.

Обновления

Что насчет обновления?

Сигнал post_save срабатывает, если вы обновляете элемент следующим образом:

product = Product.objects.get(id=1)
product.title = 'A new title'
product.save()

Однако post_save не сработает, если вы выполните update на модели через QuerySet:

Product.objects.filter(id=1).update(title='A new title')

Обратите внимание на ProductUpdateView:

class ProductUpdateView(UpdateView):
    model = Product
    fields = ['title', 'price']
    template_name = 'products/product_update.html'

    # we overrode the post method for testing purposes
    def post(self, request, *args, **kwargs):
        self.object = self.get_object()
        Product.objects.filter(id=self.object.id).update(
            title=request.POST.get('title'),
            price=request.POST.get('price')
        )
        return HttpResponseRedirect(reverse_lazy('home'))

Итак, чтобы вызвать post_save, переопределим метод queryset update(). Начнем с создания пользовательского QuerySet и пользовательского Manager. В верхней части products/models.py добавьте следующие строки:

from django.core.cache import cache             # NEW
from django.db import models
from django.db.models import QuerySet, Manager  # NEW
from django.utils import timezone               # NEW

Далее добавим следующий код в products/models.py прямо над классом Product:

class CustomQuerySet(QuerySet):
    def update(self, **kwargs):
        cache.delete('product_objects')
        super(CustomQuerySet, self).update(updated=timezone.now(), **kwargs)


class CustomManager(Manager):
    def get_queryset(self):
        return CustomQuerySet(self.model, using=self._db)

Здесь мы создали пользовательский Manager, который имеет одно задание: возвращать наш пользовательский QuerySet. В нашем пользовательском QuerySet мы переопределили метод update(), чтобы сначала удалить ключ кэша, а затем выполнить QuerySet обновление, как обычно.

Для того чтобы это использовалось нашим кодом, вам также нужно обновить Product следующим образом:

class Product(models.Model):
    title = models.CharField(max_length=200, blank=False)
    price = models.CharField(max_length=20, blank=False)
    created = models.DateTimeField(auto_now_add=True)
    updated = models.DateTimeField(auto_now=True)

    objects = CustomManager()           # NEW

    class Meta:
        ordering = ['-created']

Полный файл:

from django.core.cache import cache
from django.db import models
from django.db.models import QuerySet, Manager
from django.utils import timezone


class CustomQuerySet(QuerySet):
    def update(self, **kwargs):
        cache.delete('product_objects')
        super(CustomQuerySet, self).update(updated=timezone.now(), **kwargs)


class CustomManager(Manager):
    def get_queryset(self):
        return CustomQuerySet(self.model, using=self._db)


class Product(models.Model):
    title = models.CharField(max_length=200, blank=False)
    price = models.CharField(max_length=20, blank=False)
    created = models.DateTimeField(auto_now_add=True)
    updated = models.DateTimeField(auto_now=True)

    objects = CustomManager()

    class Meta:
        ordering = ['-created']

Протестируйте это.

Использование жизненного цикла Django

Вместо того чтобы использовать сигналы базы данных, вы можете использовать сторонний пакет Django Lifecycle, который помогает сделать аннулирование кэша более простым и читаемым:

Этот проект предоставляет декоратор @hook, а также базовую модель и миксин для добавления крючков жизненного цикла в ваши модели Django. Встроенный в Django подход к предоставлению крючков жизненного цикла - это Signals. Однако моя команда часто сталкивается с тем, что сигналы вносят ненужную непрямолинейность и противоречат подходу Django "толстые модели".

Чтобы перейти на использование Django Lifecycle, убейте сервер, а затем обновите products/app.py следующим образом:

from django.apps import AppConfig


class ProductsConfig(AppConfig):
    name = 'products'

Далее добавьте Django Lifecycle в requirements.txt:

Django==3.1.13
django-debug-toolbar==3.2.1
django-lifecycle==0.9.1         # NEW
django-redis==5.0.0
redis==3.5.3

Установите новые требования:

(venv)$ pip install -r requirements.txt

Чтобы использовать крючки Lifecycle, обновите products/models.py следующим образом:

from django.core.cache import cache
from django.db import models
from django.db.models import QuerySet, Manager
from django_lifecycle import LifecycleModel, hook, AFTER_DELETE, AFTER_SAVE   # NEW
from django.utils import timezone


class CustomQuerySet(QuerySet):
    def update(self, **kwargs):
        cache.delete('product_objects')
        super(CustomQuerySet, self).update(updated=timezone.now(), **kwargs)


class CustomManager(Manager):
    def get_queryset(self):
        return CustomQuerySet(self.model, using=self._db)


class Product(LifecycleModel):              # NEW
    title = models.CharField(max_length=200, blank=False)
    price = models.CharField(max_length=20, blank=False)
    created = models.DateTimeField(auto_now_add=True)
    updated = models.DateTimeField(auto_now=True)

    objects = CustomManager()

    class Meta:
        ordering = ['-created']

    @hook(AFTER_SAVE)                       # NEW
    @hook(AFTER_DELETE)                     # NEW
    def invalidate_cache(self):             # NEW
       cache.delete('product_objects')      # NEW

В коде выше мы:

  1. Сначала импортировали необходимые объекты из Django Lifecycle
  2. .
  3. Затем наследовал от LifecycleModel, а не от django.db.models
  4. Создан метод invalidate_cache, удаляющий ключ кэша product_object
  5. Использовали декораторы @hook для указания событий, которые мы хотим "зацепить"

Протестируйте это в своем браузере:

  1. Переход по адресу http://127.0.0.1:8000
  2. Обновление и проверка на панели инструментов отладки, что есть попадание в кэш
  3. Добавление товара и проверка того, что теперь есть пропуск кэша

Как и в случае с django signals, хуки не будут срабатывать, если обновление выполняется через QuerySet, как в ранее упомянутом примере:

Product.objects.filter(id=1).update(title="A new title")

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

Попробуйте также редактировать и удалять продукты.

Низкоуровневые методы API кэша

До сих пор мы использовали методы cache.get, cache.set и cache.delete для получения, установки и удаления (для признания недействительными) объектов в кэше. Давайте рассмотрим некоторые дополнительные методы из django.core.cache.cache.

cache.get_or_set

Получает указанный ключ, если он присутствует. Если его нет, он устанавливает ключ.

Синтаксис

cache.get_or_set(key, default, timeout=DEFAULT_TIMEOUT, version=None)

Параметр timeout используется для установки времени (в секундах), в течение которого будет действовать кэш. Если установить значение None, то значение будет кэшироваться вечно. Если его опустить, то будет использоваться таймаут, если таковой установлен в setting.py в настройках CACHES

Многие методы кэша также включают параметр version. С помощью этого параметра вы можете установить или получить доступ к разным версиям одного и того же ключа кэша.

Пример

>>> from django.core.cache import cache
>>> cache.get_or_set('my_key', 'my new value')
'my new value'

Мы могли бы использовать это в нашем представлении вместо использования операторов if:

# current implementation
product_objects = cache.get('product_objects')

if product_objects is None:
    product_objects = Product.objects.all()
    cache.set('product_objects', product_objects)


# with get_or_set
product_objects = cache.get_or_set('product_objects', product_objects)

cache.set_many

Используется для установки нескольких ключей одновременно путем передачи словаря пар ключ-значение.

Синтаксис

cache.set_many(dict, timeout)

Пример

>>> cache.set_many({'my_first_key': 1, 'my_second_key': 2, 'my_third_key': 3})

cache.get_many

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

Синтаксис

cache.get_many(keys, version=None)

Пример

>>> cache.get_many(['my_key', 'my_first_key', 'my_second_key', 'my_third_key'])
OrderedDict([('my_key', 'my new value'), ('my_first_key', 1), ('my_second_key', 2), ('my_third_key', 3)])

cache.touch

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

Синтаксис

cache.touch(key, timeout=DEFAULT_TIMEOUT, version=None)

Пример

>>> cache.set('sample', 'just a sample', timeout=120)
>>> cache.touch('sample', timeout=180)

cache.incr и cache.decr

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

В случае, если параметр дельта не указан, значение будет увеличено/уменьшено на 1.

Синтаксис

cache.incr(key, delta=1, version=None)

cache.decr(key, delta=1, version=None)

Пример

>>> cache.set('my_first_key', 1)
>>> cache.incr('my_first_key')
2
>>>
>>> cache.incr('my_first_key', 10)
12

cache.close()

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

Синтаксис

cache.close()

Пример

cache.close()

cache.clear

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

Синтаксис

cache.clear()

Пример

cache.clear()

Заключение

В этой статье мы рассмотрели низкоуровневый API кэша в Django. Мы расширили демонстрационный проект для использования низкоуровневого кэширования, а также аннулировали кэш с помощью сигналов базы данных Django и стороннего пакета Django Lifecycle hooks.

Мы также представили обзор всех доступных методов в низкоуровневом API кэша Django вместе с примерами их использования.

Вы можете найти финальный код в репо django-low-level-cache.

Статьи о кэшировании в Django:

  1. Кэширование в Django
  2. Низкоуровневый API кэша в Django (эта статья)
Вернуться на верх