Ещё в январе я тихо заменил здесь 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" в честь Марка Антония (Оратора). Но это не тот самый известный Марк Антоний, который зажигал с Клеопатрой. Этот "... был расчётливым оратором, который умело подбирал наиболее сильные доводы в поддержку своей позиции и использовал их. Благодаря своей памяти он произносил только тщательно продуманные речи с рассчитанным эффектом, хотя всегда казалось, будто он выступает экспромтом."
Мне показалось, что для символа блога это самый удачный древний римлянин :-).
Комментарии: 23
До прочтения последнего абзаца думал, что блог назван в честь джаз-музыканта Маркуса Миллера по аналогии с названием фреймворка:)
Интересно, что за радикально простой джанговский интерфейс :-) Покажешь скриншотик?
P.S. Не нравится JS, принудительно добавляющий второй перенос строки в форме комментов. Зачем он нужен? Ага, и вообще от переносов строки отказался в пользу абзацев, вижу уже в предпросмотре. Странное решение.
P.P.S. А ещё я дня 2-3 назад письмо писал. Оно дошло?
Совершенно обычная админка, чего её скриншотить?
Второй перенос? Это, наверное, баг. У меня ничего такого не происходит. Это, наверное, баг.
Ах вот, кто это написал... Там не было никаких координат, и у меня не было шансов понять, о чём там вообще речь.
Для проверки пишу из FF (прошлый коммент с "Оперы" 10.60). Действительно, тут лишний перенос не ставится. Но вот одиночный перенос тоже съедается, видимо, так задумано.
Иван, вот вы, наверное, кучу времени тратите на то, чтобы перевести на английский. А никто не комментирует, кроме русских, и скорее всего, и не читает.
Читаю и не могу отделаться от ощущения, что это же можно было с помощью двух готовых велосипедов сделать... http://code.google.com/p/django-transmeta/, http://code.google.com/p/django-localeurl/
Отлично! Сначала ты сделал лучший форум что я видел, теперь и питоний блогодвижок в том же стиле.
Если не секрет, хватает минимального тарифа 512 МБ на Linode или на чём-то большем «сидишь»?
Большое спасибо очень познавательно.
Недавно тоже занимался вопросом многоязычности, но в моем случае я ставил перед собой задачу реализации неограниченного количества поддерживаемых языков. Добавление нового языка заключалось в добавлении записи о нем в python список который потом использовался для выбора при добавлении перевода
Получение же перевода по умолчанию заключалось в обходе списка и поиска записи перевода с таким языком, таким образом порядок в python списке можно рассматривать как приоритет языка.
Скажите, а как переносили контент из WP?
sbt:
Не так уж и кучу. На русский вариант больше уходит. А то, что не читают — не всё сразу. Раскрутится потихоньку.
mike_k:
Да, наверное. Но блогософт для меня — это больше фан, чем решение производственных задач.
Павел Власов:
На 768.
Aliens6:
Очень грязный однократный скрипт, с хаками для обхода всяких краевых случаев. Основная проблема была в том, что большая часть контента была написана на расширенной версии markdown'а, и для Питона нет библиотеки, которая бы его понимала. Поэтому приходилось скрин-скрейпить HTML, который потом переводить в честный markdown, где возможно.
Спасибо. Я правда сам люблю всегда свои костыли выдумывать. :) Но посмотреть результат работы и код очень интересно, познавательно и полезно. А вообще классно, когда велосипеды качественные :)
А зачем русские и английские комменты в одной куче? :)
В соответствии с первым юзкейсом. Насколько я знаю, большинство моих читателей вполне воспринимает английский.
Для чисто русского контента можно добавить в конце URL'а "ru/".
Круто, вот такая двуязычность мне давно была нужна.
не могу понять одного, почему в бранче вместе с исходниками нет базовых шаблонов в папке marcus/templates/ ? хотя бы каких-нить банальных, чтобы можно было сразу посмотреть, а не сидеть и рисовать..
Потому что с идеей базовых шаблонов я уже обжёгся ещё на Cicero: при изменении кода мне приходилось поддерживать два набора шаблонов. Это крайне утомительно.
Кроме того, повторю третий абзац поста: я не планировал блог, как коробочное приложение. Он вообще далёк от того, чтобы его можно было "сразу посмотреть" в отрыве от моего сайта, и не только из-за шаблонов.
Не знаю как в исходниках, но тут в блоге rss лента комментариев кривая.
Например:
спасибо. попробую прикрутить к своему сайту
Поправил, спасибо.
Чего делает
curry
?Это то же, что functools.partial — заворачивает функцию и несколько первых параметров, возвращая другую функцию, которая вызовет целевую с завернутыми параметрами и с новыми. То есть это способ "привязать" параметр к функции для последующего вызова. См. каррирование.
Иван, а не смотрели на http://code.google.com/p/django-modeltranslation/ ? Похоже делает то-же самое что и Ваш код...
Когда в джанге писал, то делал middleware, который определял язык и метил запрос. Потом использовать так:
Плюс можно было бы вместо несколько "не очень вкусного" добавления en/ в конце сделать через поддомены (в джанге это опять просто черед middleware).
Но последнее это просто к теме :)