Язык программирования 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 и выбирающее строку из подгруженных заранее ресурсов (без использования кодогенерации).
В завершение приглашаю вас на бесплатный вебинар, где мы рассмотрим как теоретические, так и практические аспекты использования машины состояний, а также пределы их применения. Полученные на вебинаре знания позволят более широко и осознанно применять конечные автоматы в задачах разработки и получать более эффективный код.