Всем привет! Это вторая статья цикла о разработке приложений в нашей компании. В первой статье я рассказал про общую архитектуру некоторых наших проектов. Сегодня же опишу наши варианты решения часто встречающихся задач в рамках Django + Vue приложения.
Переводы в моделях
Скорее всего, все уже умеют работать с интернационализацией в Django приложениях: достаточно использовать методы gettext, gettext_lazy из стандартного модуля Django, собрать все переводы в .po файл, используя стандартную команду управления, написать переводы и скомпилировать .mo файл. Но что делать, когда бизнес требует переводить некоторые сущности, которые хранятся в БД?
Ради примера представим, что у нас есть небольшой проект для рисования. Фигуры, которые можно рисовать на холсте, ограничены набором объектов Shape (фигура):
class Shape(models.Model): name = models.CharField(max_length=15)
Объекты этой модели добавляются по мере внедрения функционала рисования, и их количество не безгранично. Если вы уже ознакомились с нашей архитектурой то знаете, что такие данные мы отправляем на клиентское приложение по WS сразу после подключения пользователя. Таким образом клиент получает список вида:
[ { id: 1, name: "Круг", }, { id: 2, name: "Квадрат", }, ... ]
Всё было хорошо, пока не появилась необходимость добавить механизм изменения языка на клиенте. Vue-i18n работает хорошо, но он не может переводить динамические данные, т.е. данные полученные из БД.
Теоретически может
В теории мы можем добавить в модель поле slug для хранения уникального значения с названием фигуры (circle, square, ...), по которому будем искать строку перевода с помощью vue-i18n: $t(main.shapes.${shape.slug}). Но у такого подхода есть минус — при добавлении каждой новой фигуры в БД необходимо обновлять словари перевода vue-i18n. Но зачем тогда хранить такие данные в БД, а не в обычном перечислении?
Для решения это задачи написано множество библиотек, одной из которых мы и воспользуемся — django-modeltranslation. Инструкции по подключению и настройке данного пакета в этой статье не будет, т.к. у него есть документация и, скорее всего, такие статьи уже есть на хабре.
Итак, регистрация модели как переводимой:
from modeltranslation.translator import TranslationOptions, register @register(Shape) class ShapeTranslationOptions(TranslationOptions): fields = ("name", )
После применения миграции, у таблицы, с которой связана модель, появятся дополнительные поля: name_<language_code> для каждого языка, указанного в настройках. После этого можно добавить названия для наших фигур на всех необходимых языках, перейдя в панель администратора Django.
На этом всё?
Теперь, клиентское приложение получит данные на том языке, на котором отправит запрос на получение этих данных. Таким образом, если у пользователя в браузере включен русский язык, он получит данные на русском языке. Если язык английский (например), он получит данные на своем языке (если наше приложение поддерживает английский):
[ { id: 1, name: "Circle", }, { id: 2, name: "Square", }, ... ]
Этого достаточно? Пользователи довольны? По большей части да. Недоволен только наш полиглот-тестировщик, который меняет язык каждые 3 секунды для тестирования интерфейса (спасибо ему за это =)).
Клиентское приложение не хранит названия фигур на всех языках, и для его отображения на нужном языке требуется обновить страницу или выполнить запрос на получение объектов заново. Ещё хуже дела обстоят в ситуации, когда кто-то добавляет или изменяет фигуру, после чего в сокет приходит сообщение:
{ event: "shape_added", data: { id: 3, name: "Треугольник", } }
Отправку сообщения инициировал пользователь, у которого активирован русский язык, поэтому и сообщение содержит информацию на русском языке.
Будем исправлять
Сериализатор, используемый для отправки списка объектов в клиентское приложение, выглядит следующим образом:
class ReadShapeSerializer(serializers.ModelSerializer): class Meta: model = Shape fields = ("id", "name")
В момент сериализации, методы DRF запрашивают у каждого объекта атрибут name, а пакет modeltranslation сам понимает текущий активированный язык и отдает значение в зависимости от языка: либо name_ru, либо name_en. Мы написали небольшую примесь для сериализатора:
from modeltranslation.translator import translator from modeltranslation.utils import get_translation_fields class TranslationFieldsMixin: def get_fields(self): opts = self.Meta orig_fields = opts.fields new_fields = [] trans_opts = translator.get_options_for_model(opts.model) for field_name in orig_fields: if field_name in trans_opts.fields: new_fields.extend(get_translation_fields(field_name)) else: new_fields.append(field_name) self.Meta.fields = tuple(new_fields) return super().get_fields()
Он меняет Meta.fields для сериализатора модели, убирает оттуда переводимые поля и добавляет поля с переводами:
class ReadShapeSerializer(TranslationFieldsMixin, serializers.ModelSerializer): class Meta: model = Shape fields = ("id", "name_ru", "name_en")
Таким образом, просто добавив примесь в сериализатор, каждый объект будет содержать все поля с переводами:
// Получение объектов по API [ { id: 1, name_ru: "Круг", name_en: "Circle", }, { id: 2, name_ru: "Квадрат", name_en: "Square", }, ... ] // Сокет событие { event: "shape_added", data: { id: 3, name_ru: "Треугольник", name_en: "Triangle", } }
Клиентское приложение
Сервер отправляет всё, а frontend-разработчики пусть страдают и пишут в каждом компоненте что-то вроде:
<div v-for="shape of shapes" :key="shape.id"> <span>{{ shape[`name${i18n.currentLanguage}`] }}</span> </div>
подумали мы и не стали так делать. Если вы смотрели демо-проект, то видели, что в клиентском приложении мы не работаем с объектами в сыром виде, для каждого объекта мы создаем экземпляр его класса и работаем с этим экземпляром:
class Shape { constructor(data) { this.id = data.id this.name = data.name } }
Для реализации возможности работы с объектами этого класса как и прежде, мы написали ещё один класс, который отвечает за получения переводимого атрибута на нужном языке:
export default class TranslatableModel { /** * Конструктор класса получает названия переводимых полей, * добавляет геттер получения оригинального поля "названиеПоля" * и метод установки этого поля. * **/ constructor(data) { const translationFields = this.getTranslationFields() for (const field of translationFields) { this.defineProperty(field) } } /** * Создает на экземпляре класса геттер и сеттер для работы с полем fieldName. * При обращении instance.field пытается найти instance.fieldRu * (если ru - это текущий язык). Если значение поля для текущего * языка не указано, перебирает запасные локализации (i18n.fallbackLocale), * для каждой из них пытается получить значение instance[field<locale>] * пока не найдет заполненное поле. * * Метод сеттер принимает все данные об объекте и берет * из них значения обрабатываемого поля со всеми вариантами перевода. * **/ defineProperty(fieldName) { Object.defineProperty(this, fieldName, { get() { for (const language of this.getUserLanguages()) { const transFieldName = `${fieldName}${language}` const value = this[transFieldName] if (!isEmpty(value)) { return value } } return undefined }, set(data) { const languages = this.getLanguages() for (const language of languages) { const transFieldName = `${fieldName}${language}` this[transFieldName] = data[transFieldName] } }, }) } /** * Пустой метод, сделанный только для того, чтобы не было вопросов, * почему в поле записывается весь объект data: * * class SomeClass extends TranslatableModel { * constructor(data) { * super(data) * this.name = data * } * * getTranslationFields() { * return ['name'] * } * } * * На самом деле в этом примере при вызове this.name = data * вызывается метод setter для поля name, который достает * из объекта data поля nameRu, nameEn, ... и устанавливает их как * атрибуты объекта. * * **/ getField(data) { return data } /** * Метод получения списка полей, подлежащих переводу. * **/ getTranslationFields() { return [] } /** * Возвращает список поддерживаемых языков. * **/ getLanguages() { const languages = Object.keys(i18n.global.messages) return languages.map(capitalize) } /** * Метод генератор, по очереди возвращает следующий язык, который * необходимо искать у экземпляра при доступе к атрибуту. * **/ *getUserLanguages() { yield capitalize(i18n.global.locale) const fallbackLanguages = this.getFallbackLanguages() for (const language of fallbackLanguages) { yield language } } /** * Метод возвращает список запасных локализаций. Т.к. i18n.fallbackLocale * может быть разного типа (строка, список строк, объект), приводит * всё к списку. * **/ getFallbackLanguages() { const userLocale = capitalize(i18n.global.locale) const fallbackConfig = i18n.global.fallbackLocale let locales if (isArray(fallbackConfig)) { locales = fallbackConfig } else if (isObject(fallbackConfig)) { locales = Object.keys(fallbackConfig) } else { locales = [fallbackConfig] } return locales.filter((lang) => lang !== userLocale).map(capitalize) } }
Осталось унаследовать наш класс фигуры от этого базового класса, чтобы всё заработало:
class Shape extends TranslatableModel { constructor(data) { super(data) this.id = data.id this.name = this.getField(data) } getTranslationFields() { return ['name'] } }
После всех преобразований, как на сервере, так и на клиенте, мы можем писать Vue компоненты как обычно, не обращая внимания на текущий активированный язык:
<div v-for="shape of shapes" :key="shape.id"> <span>{{ shape.name }}</span> </div>
Мы подготовили небольшой демонстрационный проект на codesandbox, где можно посмотреть, как это работает, и поиграть с получившимся результатом.
Спасибо за внимание!
