Переводы полей моделей Django + Vue
Всем привет! Это вторая статья цикла о разработке приложений в нашей компании. В первой статье я рассказал про общую архитектуру некоторых наших проектов. Сегодня же опишу наши варианты решения часто встречающихся задач в рамках 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_
для каждого языка, указанного в настройках. После этого можно добавить названия для наших фигур на всех необходимых языках, перейдя в панель администратора 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-разработчики пусть страдают и пишут в каждом компоненте что-то вроде:
{{ shape[`name${i18n.currentLanguage}`] }}
подумали мы и не стали так делать. Если вы смотрели демо-проект, то видели, что в клиентском приложении мы не работаем с объектами в сыром виде, для каждого объекта мы создаем экземпляр его класса и работаем с этим экземпляром:
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]
* пока не найдет заполненное поле.
*
* Метод сеттер принимает все данные об объекте и берет
* из них значения обрабатываемого поля со всеми вариантами перевода.
* **/
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 компоненты как обычно, не обращая внимания на текущий активированный язык:
{{ shape.name }}
Мы подготовили небольшой демонстрационный проект на codesandbox, где можно посмотреть, как это работает, и поиграть с получившимся результатом.
Спасибо за внимание!