Многоязычность на Kotlin-бэкэнде
https://t.me/sqlhubЯзык программирования Kotlin часто ассоциируется с мобильной разработкой для Android и это неудивительно, учитывая что он принят Google как официальный язык разработки, и принес множество необходимых и удобных языковых конструкций и кооперативной многозадачности, при этом сохраняя совместимость на уровне байт-кода с ранними версиями JVM. Но применимость языка существенно выше и имеющиеся библиотеки (как созданные для Java, так и разработанные специально для Kotlin) позволяют создавать обычные приложения (например, на JavaFX или с использованием платформенных графических библиотек и Kotlin Native), а также создавать код для бэкэнда c подключениям к базам данных, кэшам, очередям сообщений и т.д. При этом, если для мобильной разработки проблем с поддержкой многоязычных сообщений не возникает (благодаря механизму ресурсов, в том числе строк, которые могут быть переопределены для конкретной локали), то для бэкэнда это становится нетривиальной задачей. В этой статье мы обсудим несколько подходов для создания бэкэнда с поддержкой нескольких языков.
Прежде всего отметим сценарии, где можно встретиться с необходимостью выполнения выбора подходящего под язык сообщения:
- у вас используется подход "Backend Driven UI" и структура и атрибуты элементов интерфейса доставляются в мобильное приложение с бэкэнда (в этом случае нередко текстовые сообщения передаются непосредственно текстом, поскольку могут включать в себя дополнительные преобразования - вставка чисел, согласование родов и падежей и т.д.);
- сервер возвращает локализованное сообщение об ошибке, которое будет необходимо показать пользователю;
- в json-ответе необходимо выполнить перевод строк на язык пользовательского интерфейса (например, список стран при регистрации)
- ...
В действительности в некоторых случаях уместно использовать базу данных и создавать локализованные представления в ней (например, для списка стран). Но во всех тех случаях, когда содержание сообщения известно заранее и либо неизменно, либо является шаблоном для подстановки значений (например, как в случае Backend Driven UI), то разумно искать решение непосредственно основанное на коде.
Первое наиболее очевидное решение - использование обычного Map с языковыми версиями сообщения. Для указания языка можно использовать класс java.util.Locale, либо непосредственно, либо получая из него необходимый компонент (например, language вернет только идентификатор языка, без учета региона и варианта). Например, если в поле session["locale"] на сервере сохранена актуальная локаль для сессии пользователя, код с возвратом сообщения об ошибке о недопустимой операции может выглядеть таким образом:
object Messages { val invalidOperationError = mapOf( "ru": "Выполнена недопустимая операция", "en": "An illegal operation was performed", "de": "illegale Operation durchgeführt", "fr": "opération illégale effectuée", "tr": "yasa dışı işlem yapıldı", "cs": "извршена незаконита операција", } } fun invalidOperation(session: Locale) = Messages.invalidOperationError[session.language]
У такого решения есть ряд недостатков, прежде всего в сообщения нельзя добавлять данные (например, подставлять имя пользователя или количество товаров в корзине), а также выполнять согласования грамматических конструкций (например, изменять окончания в приветствии "Уважаемый" - "Уважаемая"). Частично проблему решает использование библиотеки для подстановки значений (например, kotlin-format), но все равно очень много кода приходится создавать вручную (а здесь еще и придется делать специальные функции, которые принимают правильные типы аргументов). Поэтому рассмотрим альтернативные решения поддержки многоязычности.
Для Kotlin-бэкэнда можно использовать библиотеки, созданные изначально для Java, например JTranslation. Библиотека использует json-файлы и подгружает их динамически во время выполнения. В нашем случае будет необходимо создать 6 json-файлов (в каталоге Resources/translation) и разместить в них объект, в котором ключами будут идентификаторы фразы (например, "invalid_action"), а значениями - перевод на соответствующий язык. Далее в коде создается объект класса перевода и получается доступ к строкам:
val JTranslation = JTranslationBuilder(Languages.ru, Languages.de, Languages.en).build() JTranslation.getLangWithLocale(session["locale"].language, "invalid_action")
Дополнительно в json-строках можно использовать подстановки в виде {}, в которые будут размещаться значения, переданные после идентификатора строки. Значения преобразуются в строковое представление через .toString(). Также JTranslation поддерживает замену emoji, записанных в виде :SMILE: на соответствующий юникод-символ.
Альтернативное решение - использование кодогенерации для создания функций, генерирующих корректный текст с учетом локали и других атрибутов фразы. Мы рассмотрим библиотеку i18n4k, специально созданную для использования в Kotlin-приложениях. Библиотека работает на всех платформах (в том числе, Kotlin Native) и представляет плагин для генерации кода на основе .properties-файлов. Для установки добавим в конфигурацию gradle зависимость и плагин, а также настроим список доступных языков:
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile plugins { kotlin("jvm") version "1.7.20" id("de.comahe.i18n4k") version "0.5.0" //другие плагины } i18n4k { sourceCodeLocales = listOf("en", "de", "ru", "cs", "fr", "tr") } dependencies { implementation("de.comahe.i18n4k:i18n4k-core-jvm:0.5.0") testImplementation(kotlin("test")) //другие зависимости } //...
Теперь файлы для локализации будет размещать в каталоге src/main/i18n (или src/commonMain/i18n для проекта Kotlin Multiplatform). Для удобства ввода unicode-символов в IDE можно включить автоматическое преобразование кодировки (в IntelliJ IDEA File Encodings -> Default Encoding (ISO-8859-1), Transparent native-to-ascii conversion (включен).
Файлы properties имеют общее название prefix_language[_region[_variant]].properties и содержат пары ключ=значение (по одной на строку). Также можно использовать плагины к IDE (например, Resource Bundle Editor) для удобства редактирования переводов. Название префикса совпадает с названием сгенерированного класса. Например, в нашем случае мы создадим файлы ErrorMessages_en.properties (с английским вариантом), ErrorMessages_ru.properties (русский) и т.д.
Выполним кодогенерацию (она также связывается с задачей build в gradle):
./gradlew generateI18n4kFiles
После генерации, мы можем увидеть в каталоге build/generated/sources/i18n4k новый файл с названием ErrorMessages.kt, который содержит get-методы для получения строк и список переводов, извлеченный из properties-файла, например:
public object ErrorMessages : MessageBundle() { /** * An invalid operation was performed */ @JvmStatic public val invalid_operation: LocalizedString = getLocalizedString0(0) init { registerTranslation(ErrorMessages_en) } } /** * Translation of message bundle 'ErrorMessages' for locale 'en'. Generated by i18n4k. */ private object ErrorMessages_en : MessagesProvider { @JvmStatic private val _data: Array<String?> = arrayOf( "An invalid operation was performed") public override val locale: Locale = Locale("en") public override val size: Int get() = _data.size public override fun `get`(index: Int): String? = _data[index] }
Для доступа к соответствующему переводу можно использовать вызов метода (название совпадает с ключом сообщения):
println(ErrorMessages.invalid_operation())
Также в строках могут использоваться подстановки, в этом случае они будут представлены как аргументы функции (допускается использование до 5 подстановок):
hello=Hello, {0} from {1} println(ErrorMessages.hello("World", "Kotlin Backend"))
Для выбора текущего языка можно использовать объект конфигурации:
val config = I18n4kConfigDefault() i18n4k = config config.locale = Locale("en")
Также из конфигурации можно вызывать форматирование сообщения (подстановку значений), например так:
println(config.messageFormatter.format("Hello {0}", listOf("Test"), Locale.US))
Но что насчет вариантов использования фразы, зависящих от аргумента? Здесь мы можем использовать функции расширения и рефлексию Kotlin. Определим перечисление:
enum class Gender { MALE, FEMALE, OTHER, }
и создадим три строки под разное приветствие:
hello_MALE=Уважаемый {0} hello_FEMALE=Уважаемая {0} hello_OTHER=Здравствуйте, {0}
Теперь создадим функцию расширения, которая будет находить подходящую строку для выбранного пола:
fun ErrorMessages.genderize(gender: Gender, prefix: String, vararg args: Any):String { val field = this::class.java.getDeclaredField("${prefix}_${gender.name}") field.trySetAccessible() val method = field.get(this) return when (args.size) { 0 -> method.toString() 1 -> (method as LocalizedStringFactory1).createString(args[0]) 2 -> (method as LocalizedStringFactory2).createString(args[0], args[1]) //и т.д. до 5 аргументов else -> "?" } }
При вызове используем обычное обращение к ErrorMessages, но дополнительно передаем префикс и, при необходимости, значения для подстановки:
println(ErrorMessages.genderize(Gender.FEMALE, "hello", "Maria"))
Аналогично можно сделать функции расширения для выбора подходящей строки для числовых значений (для нуля, одного, от 2 до 4 и т.д.).
При использовании фреймворка Spring локализация реализуется внутри него (также через properties-файлы, но строковые ресурсы выбираются автоматически, например в Thymeleaf). Также есть решение для ktor, работающее аналогично рассмотренному, но подключаемое как расширение ktor и выбирающее строку из подгруженных заранее ресурсов (без использования кодогенерации).
источник