Ещё в январе я тихо заменил здесь 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" в честь Марка Антония (Оратора). Но это не тот самый известный Марк Антоний, который зажигал с Клеопатрой. Этот "... был расчётливым оратором, который умело подбирал наиболее сильные доводы в поддержку своей позиции и использовал их. Благодаря своей памяти он произносил только тщательно продуманные речи с рассчитанным эффектом, хотя всегда казалось, будто он выступает экспромтом."

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

Комментарии: 29 (feed)

  1. dgl

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

  2. dgl

    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. - > I decided that it's now worth giving a technical overview.

    The blog doesn't have much special in it. -> There is nothing special about the blog.

    navigational links don't loose page numbers, etc. -> loose - освобождать, спускать с цепи, lose - терять

    when it gets a concrete time value -> when it gets a specific time value

    grammatical and spelling errors! - grammatical and spelling mistakes!

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

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

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

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

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

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

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

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

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

  5. navigational links don't loose page numbers, etc. -> loose - освобождать, спускать с цепи, lose - терять

    when it gets a concrete time value -> when it gets a specific time value

    Fixed those (the first one is a typo). Thanks!

    As for others I don't agree that they were incorrect, sorry :-)

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

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

  7. sbt

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

  8. mike_k

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

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

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

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

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

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

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

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

    LANG = (
        ("ru", "Russian"),
        ("en", "English"),
    )
    
  11. Скажите, а как переносили контент из WP?

  12. sbt:

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

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

    mike_k:

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

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

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

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

    На 768.

    Aliens6:

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

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

  13. Случайный прохожий
    1. English version shows "Иван Сагалаев" instead of "Ivan Sagalaev" for comments.

    2. "Обязательное поле" — error message for English version.

    3. Legend image http://softwaremaniacs.org/media/style/markdown-legend.png in Russian.

  14. Случайный прохожий

    "Other topics" shows much more articles for English version then you actually have:

    http://softwaremaniacs.org/blog/category/en/

    1. "Обязательное поле" — error message for English version.
    2. "Other topics" shows much more articles for English version then you actually have:

    Fixed these, thanks!

    Legend image http://softwaremaniacs.org/media/style/markdown-legend.png in Russian.

    This is a known thing. Gotta pull myself together to remake this both in Russian and in English.

    English version shows "Иван Сагалаев" instead of "Ivan Sagalaev" for comments.

    And this one is a much deeper problem than translation. User names are from another app and it's tricky.

  15. GrAndSE

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

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

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

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

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

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

  19. Фёдор

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

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

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

  21. qbuben@blogspot.com

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

    Например:

    http://softwaremaniacs.org/blog/<bound method Comment.author_url of <Comment: 2010-07-27, marcus-bilingual-blog, Иван Сагалаев>>

  22. tier

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

  23. http://softwaremaniacs.org/blog/<bound method Comment.author_url of <Comment: 2010-07-27, marcus-bilingual-blog, Иван Сагалаев>>

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

  24. ash

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

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

  26. vitalka.com

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

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

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

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

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

  28. A while ago I reported on switching this blog to a custom software named Marcus. Despite its source code being available in the open I didn't intend developing it into a full-blown project for two reasons: a) maintaining it would have taken much more time than I could afford and b) being completely anal about my own blog software I didn't want to piss off contributors by constantly rejecting all the features they would propose. Anyway, if someone felt so compelled they could take the code and start developing it on their own.

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

Текст через пустую строку превращается в отдельные абзацы, цитата отделяется символами > слева, список состоит из пунктов с дефисом слева, курсив выделяется * с каждой стороны, жирный - двойными **, блоки кода отступают слева на 4 пробела