Ещё в январе я тихо заменил здесь WordPress на самописный блог. Публично это отразилось разве что в двух твитах, и никакого поста я про это не написал. Отчасти из-за лени, отчасти от того, что всё там было довольно банально.

Один из плюсов собственного блогософта в том, что к нему можно прикручивать новые фичи именно в таком виде, в котором хочется. И после реализации в блоге двуязычности, я посчитал, что стоит сделать небольшой технический обзор.

Сразу скажу, что я не планировал блог, как коробочное приложение. И хотя написан он в автономном стиле, он, всё же, достаточно своеобразен и вряд ли подойдёт по фичам большинству блогеров.

Код

Код можно смотреть в бранче на Launchpad'е. Его получилось немного — около 860 строк, и он, как по мне, хорошо читается. Интересно, что примерно 250 строк из всех ушло как раз на двуязычность. Впрочем, строчек непосредственно в коде блога получилось так немного, потому что много чего вынесено в библиотеки:

Фичи

Ничего экстраординарного блог собой не представляет. Я просто реализовал всё то, чем пользовался в 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

  1. dgl

    До прочтения последнего абзаца думал, что блог назван в честь джаз-музыканта Маркуса Миллера по аналогии с названием фреймворка:)

  2. Тормоз

    Интересно, что за радикально простой джанговский интерфейс :-) Покажешь скриншотик?

    P.S. Не нравится JS, принудительно добавляющий второй перенос строки в форме комментов. Зачем он нужен? Ага, и вообще от переносов строки отказался в пользу абзацев, вижу уже в предпросмотре. Странное решение.

    P.P.S. А ещё я дня 2-3 назад письмо писал. Оно дошло?

  3. Ivan Sagalaev

    Интересно, что за радикально простой джанговский интерфейс :-) Покажешь скриншотик?

    Совершенно обычная админка, чего её скриншотить?

    P.S. Не нравится JS, принудительно добавляющий второй перенос строки в форме комментов.

    Второй перенос? Это, наверное, баг. У меня ничего такого не происходит. Это, наверное, баг.

    P.P.S. А ещё я дня 2-3 назад письмо писал. Оно дошло?

    Ах вот, кто это написал... Там не было никаких координат, и у меня не было шансов понять, о чём там вообще речь.

  4. Тормоз без OpenID

    Для проверки пишу из FF (прошлый коммент с "Оперы" 10.60). Действительно, тут лишний перенос не ставится. Но вот одиночный перенос тоже съедается, видимо, так задумано.

  5. sbt

    Иван, вот вы, наверное, кучу времени тратите на то, чтобы перевести на английский. А никто не комментирует, кроме русских, и скорее всего, и не читает.

  6. mike_k

    Читаю и не могу отделаться от ощущения, что это же можно было с помощью двух готовых велосипедов сделать... http://code.google.com/p/django-transmeta/, http://code.google.com/p/django-localeurl/

  7. Павел Власов

    Отлично! Сначала ты сделал лучший форум что я видел, теперь и питоний блогодвижок в том же стиле.

    Если не секрет, хватает минимального тарифа 512 МБ на Linode или на чём-то большем «сидишь»?

  8. puumku

    Большое спасибо очень познавательно.

    Недавно тоже занимался вопросом многоязычности, но в моем случае я ставил перед собой задачу реализации неограниченного количества поддерживаемых языков. Добавление нового языка заключалось в добавлении записи о нем в python список который потом использовался для выбора при добавлении перевода

    lang = models.CharField(max_length=2, choices=Settings.LANG, default="ru")
    

    Получение же перевода по умолчанию заключалось в обходе списка и поиска записи перевода с таким языком, таким образом порядок в python списке можно рассматривать как приоритет языка.

    LANG = (
        ("ru", "Russian"),
        ("en", "English"),
    )
    
  9. Aliens6

    Скажите, а как переносили контент из WP?

  10. Ivan Sagalaev

    sbt:

    Иван, вот вы, наверное, кучу времени тратите на то, чтобы перевести на английский. А никто не комментирует, кроме русских, и скорее всего, и не читает.

    Не так уж и кучу. На русский вариант больше уходит. А то, что не читают — не всё сразу. Раскрутится потихоньку.

    mike_k:

    Читаю и не могу отделаться от ощущения, что это же можно было с помощью двух готовых велосипедов сделать...

    Да, наверное. Но блогософт для меня — это больше фан, чем решение производственных задач.

    Павел Власов:

    Если не секрет, хватает минимального тарифа 512 МБ на Linode или на чём-то большем «сидишь»?

    На 768.

    Aliens6:

    Скажите, а как переносили контент из WP?

    Очень грязный однократный скрипт, с хаками для обхода всяких краевых случаев. Основная проблема была в том, что большая часть контента была написана на расширенной версии markdown'а, и для Питона нет библиотеки, которая бы его понимала. Поэтому приходилось скрин-скрейпить HTML, который потом переводить в честный markdown, где возможно.

  11. GrAndSE

    Спасибо. Я правда сам люблю всегда свои костыли выдумывать. :) Но посмотреть результат работы и код очень интересно, познавательно и полезно. А вообще классно, когда велосипеды качественные :)

  12. Тормоз

    А зачем русские и английские комменты в одной куче? :)

  13. Ivan Sagalaev

    А зачем русские и английские комменты в одной куче? :)

    В соответствии с первым юзкейсом. Насколько я знаю, большинство моих читателей вполне воспринимает английский.

    Для чисто русского контента можно добавить в конце URL'а "ru/".

  14. Cyber

    Круто, вот такая двуязычность мне давно была нужна.

  15. Фёдор

    не могу понять одного, почему в бранче вместе с исходниками нет базовых шаблонов в папке marcus/templates/ ? хотя бы каких-нить банальных, чтобы можно было сразу посмотреть, а не сидеть и рисовать..

  16. Ivan Sagalaev

    Потому что с идеей базовых шаблонов я уже обжёгся ещё на Cicero: при изменении кода мне приходилось поддерживать два набора шаблонов. Это крайне утомительно.

    Кроме того, повторю третий абзац поста: я не планировал блог, как коробочное приложение. Он вообще далёк от того, чтобы его можно было "сразу посмотреть" в отрыве от моего сайта, и не только из-за шаблонов.

  17. qbuben@blogspot.com

    Не знаю как в исходниках, но тут в блоге rss лента комментариев кривая.

    Например:

    http://softwaremaniacs.org/blog/>

  18. tier

    спасибо. попробую прикрутить к своему сайту

  19. Ivan Sagalaev

    http://softwaremaniacs.org/blog/>

    Поправил, спасибо.

  20. ash

    Чего делает curry?

  21. Ivan Sagalaev

    Это то же, что functools.partial — заворачивает функцию и несколько первых параметров, возвращая другую функцию, которая вызовет целевую с завернутыми параметрами и с новыми. То есть это способ "привязать" параметр к функции для последующего вызова. См. каррирование.

  22. vitalka.com

    Иван, а не смотрели на http://code.google.com/p/django-modeltranslation/ ? Похоже делает то-же самое что и Ваш код...

  23. qbuben

    Когда в джанге писал, то делал middleware, который определял язык и метил запрос. Потом использовать так:

    getattr(obj, 'text_'+request.lang)
    
    setattr(obj, 'text_'+request.lang, text_value)
    

    Плюс можно было бы вместо несколько "не очень вкусного" добавления en/ в конце сделать через поддомены (в джанге это опять просто черед middleware).

    Но последнее это просто к теме :)

Добавить комментарий