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

В завершение приглашаю вас на бесплатный вебинар, где мы рассмотрим как теоретические, так и практические аспекты использования машины состояний, а также пределы их применения. Полученные на вебинаре знания позволят более широко и осознанно применять конечные автоматы в задачах разработки и получать более эффективный код.

Комментарии (0)