Дорабатываем HTTP-кеширование в Django

image
В этой заметке речь пойдет о HTTP-кэшировании (перевод) и его использовании совместно с фреймворком Django. Мало кто будет спорить с утверждением о том, что применение HTTP-кэширования — очень правильная и разумная практика разработки веб-приложений. Однако именно в этом функционале Django содержит ряд ошибок и неточностей, которые очень сильно ограничивают практическую пользу от такого подхода. Например, до сих пор актуален баг #15855, заведенный в апреле 2011 года, который может приводить к очень неприятным ошибкам в работе веб-приложения.

Middleware vs. explicit decorator


В Django есть два стандартных способа включения HTTP-кэширования: через активацию UpdateCacheMiddleware/FetchFromCacheMiddleware, либо через декорирование функции представления при помощи декоратора cache_page. У первого способа имеется один существенный недостаток — он включает HTTP-кэширование для всех без исключения представлений (view) проекта, зато второй содержит тот самый баг #15855. Если бы не этот баг, то вариант с использованием cache_page являлся бы более предпочтительным. Плюс, такой вариант хорошо согласуется с важнейшим из постулатов The Zen of Python, что «явное лучше неявного».

Причина появления #15855 кроется в механизме обработки запросов Django с применением так-называемых middleware. Схематично этот механизм представлен на рисунке ниже.

image

Декораторы для представлений на схеме располагаются вместе с самими представлениями (view function), то есть после их отрабатывания у каждого middleware есть возможность дополнительно повлиять на итоговый результат (HttpResponse). Например, так поступает SessionMiddleware, добавляя в ответ заголовок Vary со значением «Cookie» в том случае, если внутри view function было обращение к сессии (обычный кейс при работе с авторизованными пользователями). Неучитывание значений заголовка Vary при сохранении кэша может привести к тому, что пользователь приложения получит данные из кэша другого пользователя. К слову, в комментариях к описанному багу есть примеры его решения именно для случая с SessionMiddleware, но проблема также актуальна и при использовании других middleware, например, LocaleMiddleware, которая расширяет заголовок Vary значением «Accept-Language».

Исправляем баг


Для полного исправления #15855 необходимо обновлять кэш HttpResponse уже после того, как отработают все middleware. Теперь ясно, почему в случае с UpdateCacheMiddleware/FetchFromCacheMiddleware этой ошибки нет, ведь если мы поставим UpdateCacheMiddleware выше всех остальных middleware, то она выполняется последней и учитывает все заголовки ответа. Единственный не-middleware способ реализовать аналогичное решение — обрабатывать сигнал request_finished. Но в таком способе есть две проблемы, которые необходимо решить: во-первых, обработчик сигнала не получает информацию о текущем запросе/ответе, а во-вторых, сигнал посылается уже после того, как ответ был отправлен клиенту. Для обновления кэша второй пункт в общем-то несущественен (кэш мы можем обновить и после отправки ответа), но нам необходимо при этом в ответ добавить свои заголовки — Expires и Cache-Control (самое важное!), чего мы не сможем сделать, если запрос был уже обработан.

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

cache_page.py
from django.utils import decorators

from .middleware import CacheMiddleware


def cache_page(**kwargs):
    """
    используется вместо оригинального django.views.decorators.cache.cache_page
    """
    cache_timeout = kwargs.get('cache_timeout')
    cache_alias = kwargs.get('cache_alias')
    key_prefix = kwargs.get('key_prefix')
    decorator = decorators.decorator_from_middleware_with_args(CacheMiddleware)(
        cache_timeout=cache_timeout,
        cache_alias=cache_alias,
        key_prefix=key_prefix,
    )
    return decorator


middleware.py
from django.middleware import cache as cache_middleware

class CacheMiddleware(cache_middleware.CacheMiddleware):
    pass  # это будет middleware, в котором мы будем производить доработки


Для начала решим две существующие проблемы с request_finished, о которых я говорил ранее. Мы точно знаем, что в одном потоке одновременно обрабатывается только один запрос, значит текущий формируемый ответ пользователю можно сохранять, правильно, в threading.local. Делаем это в тот момент, когда управление все еще находится у декоратора для того, чтобы впоследствии использовать в обработчике request_finished. Таким образом мы можем «убить сразу двух зайцев»: добавление заголовков Expires и Cache-Control до отправки response клиенту и отложенное сохранение в кэш с учетом всех возможных изменений:
middleware.py
import threading

from django.core import signals
from django.middleware import cache as cache_middleware

response_handle = threading.local()


class CacheMiddleware(cache_middleware.CacheMiddleware):

    def __init__(self, *args, **kwargs):
        super(CacheMiddleware, self).__init__(*args, **kwargs)
        signals.request_finished.connect(update_response_cache)
    
    def process_response(self, request, response):
        response_handle.response = response
        return super(CacheMiddleware, self).process_response(request, response)


def update_response_cache(*args, **kwargs):
    """
    обработчик сигнала request_finished
    """
    response = getattr(response_handle, 'response', None)  # текущий response
    if response:
        try:
            pass  # сохранение response в кэш
        finally:
            response_handle.__dict__.clear()


Но в этом простейшем случае сохранение в кэш будет происходить дважды, причем в первый раз без учета всех значений Vary. Технически эту проблему решить можно. Кому интересно, под спойлером ниже изложено такое решение.
middleware.py
import contextlib
import threading
import time

from django.core import signals
from django.core.cache.backends.dummy import DummyCache
from django.middleware import cache as cache_middleware
from django.utils import http, cache

response_handle = threading.local()

dummy_cache = DummyCache('dummy_host', {})


@contextlib.contextmanager
def patch(obj, attr, value, default=None):
    original = getattr(obj, attr, default)
    setattr(obj, attr, value)
    yield
    setattr(obj, attr, original)


class CacheMiddleware(cache_middleware.CacheMiddleware):

    def __init__(self, *args, **kwargs):
        super(CacheMiddleware, self).__init__(*args, **kwargs)
        signals.request_finished.connect(update_response_cache)

    def process_response(self, request, response):
        if not self._should_update_cache(request, response):
            return super(CacheMiddleware, self).process_response(request, response)

        response_handle.response = response
        response_handle.request = request
        response_handle.middleware = self

        with patch(cache_middleware, 'learn_cache_key', lambda *_, **__: ''):
            # заменяем функцию расчета ключа для кэша заглушкой (просто оптимизация)

            with patch(self, 'cache', dummy_cache):
                # используем заглушку вместо драйвера кэша для того, чтобы
                # отложить сохранение response в кэш до того момента,
                # когда будут готовы все значения заголовка Vary,
                # см. https://code.djangoproject.com/ticket/15855
                    
                return super(CacheMiddleware, self).process_response(request, response)

    def update_cache(self, request, response):
        with patch(cache_middleware, 'patch_response_headers', lambda *_: None):
            # мы не хотим патчить заголовки response повторно

            super(CacheMiddleware, self).process_response(request, response)


def update_response_cache(*args, **kwargs):
    middleware = getattr(response_handle, 'middleware', None)
    request = getattr(response_handle, 'request', None)
    response = getattr(response_handle, 'response', None)
    if middleware and request and response:
        try:
            CacheMiddleware.update_cache(middleware, request, response)
        finally:
            response_handle.__dict__.clear()


Устраняем другие неточности


В начале я упомянул о том, что Django содержит несколько ошибок в механизме HTTP-кэширования, так и есть. И решенный выше баг является не единственным, хотя и самым критическим. Другой неточностью Django является то, что при чтении сохраненного запроса из кэша значение параметра max-age заголовка Cache-Control возвращается таким, каким оно было на момент сохранения response в кэш, то есть max-age может не соответствовать значению заголовка Expires из-за разницы во времени между двумя этими событиями. А так как браузеры предпочитают использовать Cache-Control вместо Expires, мы получаем еще одну ошибку. Давайте решим и ее. Для этого у нашей middleware надо переопределить метод «process_request»:
process_request
def process_request(self, request):
    response = super(CacheMiddleware, self).process_request(request)

    if response and 'Expires' in response:
        # заменяем 'max-age' заголовка 'Cache-Control'
        # значением, подсчитанным при помощи 'Expires'
        expires = http.parse_http_date(response['Expires'])
        timeout = expires - int(time.time())
        cache.patch_cache_control(response, max_age=timeout)

    return response


Если нет острой необходимости в том, чтобы непременно сохранять все HTTP-ответы в кэше (а нужны только заголовки HTTP-кэширования), то вместо всего вышеописанного в настройках проекта можно заменить основной драйвер кэша на фейковый (это решение также защищает и от последствий #15855):
CACHES = {
    'default': {
        'BACKEND': 'django.core.cache.backends.dummy.DummyCache',
    },
}

Далее, непонятно для чего, но UpdateCacheMiddleware кроме стандартных Expires и Cache-Control, добавляет также заголовки Last-Modified и ETag. И это при том, что FetchFromCacheMiddleware никак не обрабатывает соответствующие запросы (с заголовками If-Modified-Since, If-None-Match и пр.). Налицо нарушение основополагающего принципа единой обязанности. Полагаю, расчет был на то, что разработчик не забудет включить ConditionalGetMiddleware или хотя бы CommonMiddleware, польза от которых на самом деле весьма сомнительна, и на своих проектах я их никогда не включаю. Более того, если что-то все же вернет 304 Not Modified (такое бывает, например, при использовании декораторов last_modified или etag), то в такой ответ не попадут заголовки кэширования (Expires и Cache-Control), что заставит браузер возвращаться снова и снова (и получать 304 Not Modified), несмотря на то, что мы, казалось бы, включили HTTP-кэширование, которое должно говорить браузеру, что возвращаться назад нет смысла в течение указанного времени. Эту неточность устраняем в «process_response»:
process_response
def process_response(self, request, response):
    if not self._should_update_cache(request, response):
        return super(CacheMiddleware, self).process_response(request, response)

    last_modified = 'Last-Modified' in response
    etag = 'ETag' in response

    if response.status_code == 304:
        # добавляем в ответ Not Modified заголовки Expires и Cache-Control
        cache.patch_response_headers(response, cache_timeout)
    else:
        response_handle.response = response
        response_handle.request = request
        response_handle.middleware = self
        with patch(cache_middleware, 'learn_cache_key', lambda *_, **__: ''):
            # заменяем функцию расчета ключа для кэша заглушкой (просто оптимизация)

            with patch(self, 'cache', dummy_cache):
                # используем заглушку вместо драйвера кэша для того, чтобы
                # отложить сохранение response в кэш до того момента,
                # когда будут готовы все значения заголовка Vary,
                # см. https://code.djangoproject.com/ticket/15855

                response = super(CacheMiddleware, self).process_response(request, response)

    if not last_modified:
        # удаляем заголовок Last-Modified, если его не было до запуска метода
        del response['Last-Modified']
    if not etag:
        # удаляем заголовок ETag, если его не было до запуска метода
        del response['ETag']

    return response


Здесь стоит немного пояснить, что, если мы хотим, чтобы в ответ 304 Not Modified добавлялись заголовки Expires и Cache-Control, то декораторы last_modified и etag должны идти после cache_page, иначе у последнего не будет шанса обработать ответы такого типа:
@cache_page(cache_timeout=3600)
@etag(lambda request: 'etag')
def view(request):
    pass

Добавляем полезные фичи


Устранив все недостатки, вдруг понимаешь, что в полученном решении ну очень не хватает возможности задать вычисляемое (on-demand) значение времени кэширования, особенно если посмотреть на декораторы last_modified и etag, где такая возможность имеется.

И это еще не все. Также хочется как-то по-умному инвалидировать кэш, например, при изменении возвращаемой сущности. Это удобнее всего делать автоматическим изменением ключа для кэша, то есть ключ тоже хочется задавать не статически, а вычислять on-demand.

Самый простой и элегантный способ реализовать обе перечисленные потребности — это задать необходимые параметры в виде «ленивого» (lazy) выражения:

from django.utils.functional import lazy

@cache_page(
    cache_timeout=lazy(lambda: 3600, int)(),
    key_prefix=lazy(lambda: 'key_prefix', str)(),
)
def view(request):
    pass

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

Другой, более гибкий способ — это возможность передать в качестве значений для cache_timeout и key_prefix обычных функций с сигнатурой, соответствующей функции представления:

@cache_page(
    cache_timeout=lambda request, foo: 3600,
    key_prefix=lambda request, foo: 'key_prefix',
)
def view(request, foo):
    pass

Такой вариант позволил бы вычислять cache_timeout и key_prefix на основе самого запроса и его параметров, но требует еще одной доработки. Чтобы не утомлять более читателя большими кусками исходного кода, я просто дам ссылку на компонент, где это и все упомянутое выше уже реализовано в виде отдельного модуля Python: django-cache.

Заключение


Я не упомянул еще об одной полезной фиче, которую неплохо было бы иметь, о возможности клиента принудить сервер пропустить кэш, так, чтобы тот на запрос клиента отдал самые свежие данные. Это делается при помощи заголовка запроса Cache-Control: max-age=0. В django-cache такой возможности пока нет, но, возможно, в будущем такая опция появится.

Предвосхищая вопросы на тему почему бы все исправления и новые возможности сразу не законтрибутить в Django, отвечу, что я как раз планирую этим заняться в ближайшее время. Но новые возможности попадут только в следующую версию Django, скорее всего уже в 1.11, а django-cache уже сейчас умеет работать со всеми последними версиями (начиная с 1.8). Хотя исправления багов добавляют, как правило, во все поддерживаемые на текущий момент ветки.

Еще один баг


Когда заметка уже готовилась к публикации, на одном из проектов нашел еще одну неточность в функционале кэширования запросов Django. Суть его в том, что на так называемые условные запросы (содержащие заголовки If-Modified-Since и др.), cache_page всегда пытается получить результат из кэша и в случае успеха возвращает ответ с кодом 200. Такое поведение нежелательно в тех случаях, когда обработчик запроса может вернуть 304 Not Modified. Код фикса тут.

Комментарии (0)

© Habrahabr.ru