Допрацьовуємо 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 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.