вторник, 21 ноября 2006 г.

Трансляция charset в объектах request и response в Django

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

При этом стандартной кодировкой системы (шаблоны, данные в базе данных), в которой работает это приложение, является Windows-1251. Конвертировать данные в базе и т.п. - not an option, т.к. с этими данными работает еще куча уже написанного софта, который так привык. Соответственно, во избежание глюков с кодировкой, желательно, чтобы DEFAULT_CHARSET был Windows-1251, и все внутренние операции со строками и базой были в этой кодировке.

Что делаем? Пишем middleware.

[python]
# module proj.middleware.translationmiddleware
from django.conf import settings
from django.http import *
from urllib import quote, unquote

import re
CHARSET_RE = re.compile(r'(.+; charset=)(.+)', re.IGNORECASE)
SKIP_CONTENT_FOR = (HttpResponseRedirect, HttpResponsePermanentRedirect, HttpResponseNotModified, HttpResponseNotAllowed)

# HTML numeric character entities translation,
# see http://www.w3.org/TR/html4/sgml/entities.html
def makeCodes():
eCodes = range(160, 256) # Latin-1 chars
eCodes += range(913, 938) # greek capital
eCodes += range(945, 983) # greek small
eCodes += [402, 8226, 8230, 8242, 8243, 8254, 8260, 8472, # symbols, math
8465, 8476, 8482, 8501, 8592, 8593, 8594, 8595,
8596, 8629, 8656, 8657, 8658, 8659, 8660, 8704,
8706, 8707, 8709, 8711, 8712, 8713, 8715, 8719,
8721, 8722, 8727, 8730, 8733, 8734, 8736, 8743,
8744, 8745, 8746, 8747, 8756, 8764, 8773, 8776,
8800, 8801, 8804, 8805, 8834, 8835, 8836, 8838,
8839, 8853, 8855, 8869, 8901, 8968, 8969, 8970,
8971, 9001, 9002, 9674, 9824, 9827, 9829, 9830]
eCodes += [338, 339, 352, 353, 376, 710, 732, #markup, international; minus amp, lt, gt, quot
8194, 8195, 8201, 8204, 8205, 8206, 8207,
8211, 8212, 8216, 8217, 8218, 8220, 8221, 8222,
8224, 8225, 8240, 8249, 8250, 8364]
return eCodes

eCodes = makeCodes()

ESCAPE_RE = re.compile(u'([%s])' % u''.join([unichr(x) for x in eCodes]))
UNESCAPE_RE = re.compile('(%s)' % '|'.join([('&#%d;' % x) for x in eCodes]))
def entEscape(s):
return ESCAPE_RE.sub(lambda x: u'&#%d;' % ord(x.groups()[0]), s)
def entUnescape(s):
return UNESCAPE_RE.sub(lambda x: unichr(int(x.groups()[0][2:-1])), s)

# do we want to escape/unescape entities when converting?
ESCAPE_ENTITIES = True
UNESCAPE_ENTITIES = True


class TranslationMiddleware(object):
def __init__(self):
self.trCset = getattr(settings, 'TRANSLATE_CHARSET', 'UTF-8')
self.defCset = settings.DEFAULT_CHARSET

def tr(self, data, _from, _to):
if _to == _from:
return data

# make unicode data
data = data.decode(_from)

if ESCAPE_ENTITIES and _from.lower().startswith('utf'):
data = entEscape(data)
if UNESCAPE_ENTITIES and _to.lower().startswith('utf'):
data = entUnescape(data)

return data.encode(_to)

def process_request(self, request):
GET = request.GET.copy()
POST = request.POST.copy()

for dic in (GET, POST):
for key in dic:
newlist = []
for val in dic.getlist(key):
newlist.append(self.tr(val, self.trCset, self.defCset))
dic.setlist(key, newlist)
dic._mutable = False

request.GET, request.POST = GET, POST
if hasattr(request, '_request'):
del(request._request)

for key in request.COOKIES:
request.COOKIES[key] = self.tr(request.COOKIES[key], self.trCset, self.defCset)


def process_response(self, request, response):
klass = response.__class__
if klass not in SKIP_CONTENT_FOR:
new_response = klass()
new_response.content = self.tr(response.content, self.defCset, self.trCset)
else:
new_response = response

new_response._charset = self.trCset

for key in response.headers:
if key == 'Content-Type':
ctype = CHARSET_RE.sub(r'\1%s' % self.trCset, response.headers[key])
new_response[key] = ctype
elif key == 'Location':
value = unquote(response.headers[key])
value = self.tr(value, self.defCset, self.trCset)
new_response[key] = quote(value, RESERVED_CHARS)
else:
new_response[key] = self.tr(response.headers[key], self.defCset, self.trCset)

for key in response.cookies:
value = self.tr(response.cookies[key].value, self.defCset, self.trCset)
kwargs = {}
for var in ('max_age', 'path', 'domain', 'secure', 'expires'):
kwargs[var] = response.cookies[key].get(var.replace('_', '-'), None)

new_response.set_cookie(key, value, **kwargs)

return new_response
[/python]

В данном случае мне нужно было транслировать из и в UTF-8, но это можно переопределить в settings.py проекта, установив переменную TRANSLATE_CHARSET. Значение кодировки для "внутреннего пользования" и для трансляции из и во внешний мир устанавливаются при инициализации объекта middleware.

Middleware определяет методы process_request и process_response, для транслирования кодировки в данных пришедшего запроса и для перекодировки ответа клиенту, соответственно.

К моменту запуска process_request мы уже имеем готовый объект request с заполненными объектами request.GET и request.POST. Эти объекты - неизменяемые экземпляры класса QueryDict, так что сначала делаются их копии, которые можно изменять. После трансляции данных в нужную внутреннюю кодировку, новым объектам тоже ставится флаг неизменяемости, и они присваиваются вместо старых request.GET и request.POST. request.FILES, конечно, не трогаем, как и request.raw_post_data — эти данные остаются в оригинальной кодировке, так что если будет нужно что-то получать из них, с кодировкой нужно будет разбираться отдельно. Дополнительно, удаляем атрибут request._request, если он присутствует (это если уже был доступ к атрибуту request.REQUEST), чтобы не осталось ссылок на старые GET и POST.
Также транслируем cookies. request.COOKIES — обычный dict, так что его не копируем, а изменяем по месту.

process_response осуществляет обратную операцию - перекодировку ответа сервера. Создается новый объект new_response того же класса, что и оригинальный response, перекодируем его содержимое, если класс response это поддерживает (у объекта есть содержимое, а не только заголовки); если этот объект не должен содержать контента, то новый объект не создается, работаем со старым.

Также перекодируем cookies и HTTP-заголовки. Как правило, заголовки не содержат ничего кроме ASCII, но это может быть и редирект - заголовок Location, который вполне может содержать не-ASCII-данные, закодированные URL-кодировкой. В этом случае нам надо раскодировать его обратно перед трансляцией в другую кодировку, а после перекодировки - закодировать обратно функцией urllib.quote.

process_response возвращает объект new_response, из которого потом и строится ответ сервера клиенту.

В файле settings.py нашего проекта в начало списка MIDDLEWARE_CLASSES вставляем путь к нашему middleware - "proj.middleware.translationmiddleware.TranslationMiddleware" (мы ставим его первым, для того, чтобы его process_request отработал прежде других middleware и обработки запроса, а process_response — в конце обработки, после всех других middleware, непосредственно перед выдачей ответа), рестарт приложения, voilà! Страницы отдаются в нужной "внешней" кодировке, а данные запросов перекодируются во "внутреннюю" на лету.

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

Данный код опробован в Django, работающего по FastCGI. Я не пробовал его под mod_python, но, полагаю, разницы быть не должно.

1 комментарий:

  1. Отличная статья!
    Спасибо. Только что наклюнулась подобная проблема.
    Чиатю Ваши статьи с превиликим удовольствием. Вот думаю надо и свои писать по чуть-чуть :)
    Пиши ещё :)

    ОтветитьУдалить

Постоянные читатели