Ещё в январе я тихо заменил здесь WordPress на самописный блог. Публично это отразилось разве что в двух твитах, и никакого поста я про это не написал. Отчасти из-за лени, отчасти от того, что всё там было довольно банально.
Один из плюсов собственного блогософта в том, что к нему можно прикручивать новые фичи именно в таком виде, в котором хочется. И после реализации в блоге двуязычности, я посчитал, что стоит сделать небольшой технический обзор.
Сразу скажу, что я не планировал блог, как коробочное приложение. И хотя написан он в автономном стиле, он, всё же, достаточно своеобразен и вряд ли подойдёт по фичам большинству блогеров.
Код
Код можно смотреть в бранче на Launchpad'е. Его получилось немного — около 860 строк, и он, как по мне, хорошо читается. Интересно, что примерно 250 строк из всех ушло как раз на двуязычность. Впрочем, строчек непосредственно в коде блога получилось так немного, потому что много чего вынесено в библиотеки:
- pingdjack используется для рассылки и приёма pingback'ов
- scipio с его антиспам-конвейером заведует авторизацией по OpenID в комментариях
- subhub реализует персональный PSHB-хаб, благодаря чему посты мгновенно появляются в яндексовом Поиске по блогам и, следовательно, Подписках — новом воплощении старой Я.Ленты.
Фичи
Ничего экстраординарного блог собой не представляет. Я просто реализовал всё то, чем пользовался в WP, включая некоторые вещи, реализованные плагинами. Поэтому никто ничего и не заметил: всё выглядит так же. Только, вот, не глючит: типографика исключается из фрагментов кода, не сбиваются ссылки в листалках страниц и т.д.
Правится всё в стандартной джанговской админке. Интерфейс получился радикально проще, чем WP'шный dashboard в стиле "у-нас-самая-лучшая-блог-система-смотрите-сколько-тут-всего". Единственная интересная кастомизация админки — это FilterSpec, который работает для моих любимых полей типа "булевская дата": обычный DateTimeField(null=True)
, который работает и как флажок по признаку наличия там значения, и одновременно как время. Типичный пример — поле "published" у статей: пока оно None, статья считается драфтом, как только туда пишется время — оно становится временем публикации.
А самым сложным оказались... фиды. Нет, Джанго конечно поддерживает их из коробки, но оказалось, что далеко не так, как мне хотелось. Про это я хочу написать отдельно, есть про что поворчать :-).
Двуязычность
Главный принцип, которым я руководствовался при проектировании двуязычности — ни в коем случае не реализовывать универсальную равноправную мультиязычность произвольных данных на произвольное количество языков. Это сложно реализовать, и ещё сложнее — реализовать эффективно. Вместо этого я определил два конкретных ограниченных юзкейса, от которых и плясал:
- Текущая аудитория блога может читать по-русски и в меньшей степени по-английски, поэтому должна видеть посты на обоих языках с предпочтением русского.
- Для англоязычной аудитории русский язык в целом бесполезен, поэтому нужен вид блога с только англоязычным контентом.
Конечно, не все люди укладываются в эти две схемы, но поскольку весь текст так или иначе всё равно доступен, я решил не усложнять ни код, ни интерфейс поддержкой более редких случаев.
Модели
Модели, которым нужен многоязычный контент, явно хранят поля для обоих языков:
class Article(models.Model):
title_ru = models.CharField(max_length=255, blank=True)
text_ru = models.TextField(blank=True)
title_en = models.CharField(max_length=255, blank=True)
text_en = models.TextField(blank=True)
# ...
Однако напрямую работать с этим было бы неудобно: везде приходилось бы писать проверки на то, какие из этих полей заполнены. Это решается следующим образом.
У модели есть методы, которые отдают контент, смотря на переданный в них язык:
class Article(models.Model):
def title(self, language=None):
# вернуть title_ru или title_en
title.needs_language = True
def html(self, language=None):
# сформатировать text_ru или text_en
html.needs_language = True
def get_absolute_url(self, language=None):
# сформировать URL в зависимости от языка
get_absolute_url.needs_language = True
# и т.д.
Конкретно про логику этих методов я расскажу позже, а сейчас интересен атрибут "needs_language", который используется специальным проксиком Translation. Его задача — неявно передавать требуемый язык во все методы, которые его хотят. В этот проксик заворачиваются все многоязычные объекты, чтобы комфортно их использовать в шаблонах, где по {{ article.title }}
вызовется article.title(language)
.
class Translation(object):
def __init__(self, obj, language):
super(Translation, self).__init__()
self.obj = obj
self.language = language
def __getattr__(self, name):
attr = getattr(self.obj, name)
if getattr(attr, 'needs_language', None):
attr = curry(attr, self.language)
return attr
Для пущего удобства я написал ещё и фильтр "translate", который позволяет "переводить" (то есть заворачивать в проксик) объекты прямо в шаблонах. Фильтр умный — понимает одиночные объекты, плоские последовательности и даже деревья, представленные как вложенные последовательности:
{% with comment.article|translate:language as a %}
...
{% for cat in article.categories.all|translate:language %}
...
{% tree object_list|astree:"parent"|translate:language %}
URL'ы и логика выдачи контента
Разделив аудиторию на две категории, мне понадобилось два вида URL'ов, чтобы выдавать им разный контент. Я остановился на том, что для контента, выдаваемого англоязычной аудитории, к URL'у добавляется частичка "en/". Русскоязычные посты в этом случае просто не показываются. Если же частички нет — выдаётся весь контент, с предпочтением версии на русском языке, если она есть. В качестве сайд-эффекта работает ещё и частичка "ru/" для выдачи чисто русскоязычного контента, хотя она нигде и не рекламируется в интерфейсе.
Здесь, кстати, хорошо сыграло решение явно держать в моделях отдельные поля для каждого языка, а не выносить их в отдельные таблицы — получились очень простые запросы. Например, чтобы выдать статьи доступные только на английском языке, достаточно простого фильтра, никаких лишних join'ов:
Article.objects.exclude(text_en='')
Таким образом, из URL'а можно получить три значения того, на каком языке пользователь запрашивает контент: "en", "ru" и None. Аналогично, сам контент тоже может быть доступен в трёх языковых конфигурация: на русском, на английском и на обоих (формально есть ещё четвёртая, когда нет ни одного языка, но она мало интересна :-) ). Соответственно, все языкозависимые методы объектов должны уметь отдавать правильный контент в зависимости от этих двух параметров.
Логика не сложная, хотя и требует некоторого внимания к деталям. На примере метода Article.title это выглядит так:
def title(self, language=None):
if language:
return self.title_en if language == 'en' else self.title_ru
else:
return self.title_ru or self.title_en
Замечу, что если явно попросить у чисто русскоязычной статьи title на английском, выдастся пустая строка. Я решил не выкидывать никаких Exception'ов на этом уровне, потому что на уровне приложения объекты с отсутствующем на данном языке контентом просто отфильтровываются и такой ситуации на сайте не возникает.
Перевод интерфейса
Вся инфраструктура для перевода интерфейса есть в джанговской системе интернационализации. Я, правда, не пользуюсь middleware, которая определяет язык по настройкам пользователя, так как у меня язык определяется URL'ом. Поэтому в начале каждой вьюхи, которая получает язык, я инициализирую перевод вручную:
translation.activate(language or 'ru')
Часть or 'ru'
выбирает русский язык интерфейса, когда в URL'е он не задан.
Кстати, из-за наличия понятия языка по умолчанию, мне не хватило в шаблоне джанговской переменной LANGUAGE_CODE, которая содержит текущий язык перевода. Поэтому во всех таких вьюхах я ещё и свой language передаю в шаблон.
Можно заметить, что одинаковые строчки в нескольких вьюхах нарушают DRY (о, ужас!). Это правда. Но это как раз тот случай, где я решил не писать обобщённого кода, потому что возни с подключением middleware и процессоров контекста оказалось бы, по ощущениям, слишком много, чтобы считать это однозначным выигрышем.
Название
Блог называется "Marcus" в честь Марка Антония (Оратора). Но это не тот самый известный Марк Антоний, который зажигал с Клеопатрой. Этот "... был расчётливым оратором, который умело подбирал наиболее сильные доводы в поддержку своей позиции и использовал их. Благодаря своей памяти он произносил только тщательно продуманные речи с рассчитанным эффектом, хотя всегда казалось, будто он выступает экспромтом."
Мне показалось, что для символа блога это самый удачный древний римлянин :-).
Комментарии: 29
До прочтения последнего абзаца думал, что блог назван в честь джаз-музыканта Маркуса Миллера по аналогии с названием фреймворка:)
I'm not a native speaker either, but here is what I found:
I decided that it's now worth giving it a technical overview. - >