Многие разработчики хотят, чтобы их продукт был доступен максимально широкому кругу пользователей. И локализация на языки целевой аудитории может достаточно положительно сказаться на её росте. Вряд ли в данной статье будет что-то новое для людей, которые собаку съели на локализации, однако постараюсь поделиться максимально полезными советами по реализации для тех, кто только начинает свой путь.
Я являюсь выпускником МИП-15 2023. В свободное от работы время делаю фэнтезийное MMORPG в телеграме — Krezar Tavern. Не модные нынче миниаппы, а классический чат‑бот, без монетизации, блокчейнов и прочего, чисто пет‑проект для души. Все исходники лежат в открытом доступе на гитхабе.
Урок 1. Форматирование строк
Дано: два персонажа имеют возможность устроить дуэль. У текста начала дуэли может быть 1 и более вариаций для каждого языка. В каждом тексте упоминаются два участника в произвольном порядке.
Давайте напишем наивную реализацию и попробуем её улучшить:
public static String initDuel(Language language, Personage initiator, Personage acceptor) {
final var templates = switch (language) {
case RU -> List.of(
initiator.badgeWithName() + " явно намеревается дать по щам " + acceptor.badgeWithName() + "!",
"Гоп стоп! " + acceptor.badgeWithName() + " стопанули за углом таверны! " + initiator.badgeWithName()
+ " - серьезная персона и не собирается церемониться на дуэли!"
);
case EN -> List.of(initiator.badgeWithName() + " starts a duel with " + acceptor.badgeWithName() + "!");
};
return RandomUtils.getRandomElement(templates);
}
Какие тут недостатки?
Лишние конкатенации строк. По факту нам нужна одна готовая строка, а не все.
Легко ошибиться при копипасте. Субъективно, но по ощущением именно так.
Сложно читать
Форматирование в стиле СИ-строк
"%s явно намеревается дать по щам %s!".formatted(initiator.badgeWithName(), acceptor.badgeWithName())
Выглядит уже красивее, однако лишнее создание строк никуда не делось.
Форматирование с позиционными аргументами
"{0} явно намеревается дать по щам {1}!";
....
MessageFormat.format(RandomUtils.getRandomElement(templates), initiator.badgeWithName(), acceptor.badgeWithName())
Уже лучше, мы контролируем порядок слов, не создаем строки лишний раз, но страдает читаемость. Без контекста не понять, что такое 0, а что такое 1. На этом примере не так видно, но посмотрим сюда:
final var template = """
?${0} ${1}${2}
${3}${4} (${5}) ${6}${7} (${8})
${9}${10} (${11}) ${12}${13} (${14})
+${15}${16}""";
Страшно, правда?
Форматирование с именованными аргументами
На самом деле, всё что надо сделать в предыдущем пункте — добавить аргументам имена.
"${initiator_icon_with_name} явно намеревается дать по щам ${acceptor_icon_with_name}!"
Выглядит сразу намного понятнее, особенно для неподготовленного человека (а разработчик на следующий день после написания кода уже является неподготовленным человеком).
Реализация в Java
Отдельная заметка как с этим работать. В Java встроенного инструмента нет. Я провёл несколько экспериментов в лоб из 4 решений: через replace, Regexp, StringSubstitutor из apache, и StringBuilder. По итогу выиграло решение через builder, конечная реализация — здесь.
Финальный код для старта дуэли будет выглядеть примерно так:
final var templates = switch (language) {
case RU -> List.of(
"${initiator_icon_with_name} явно намеревается дать по щам ${acceptor_icon_with_name}!",
"""
Гоп стоп! ${acceptor_icon_with_name} стопанули за углом таверны! ${initiator_icon_with_name} \
- серьезная персона и не собирается церемониться на дуэли!"""
);
case EN -> List.of("${initiator_icon_with_name} starts a duel with ${initiator_icon_with_name}!");
};
final var params = new HashMap<String, Object>();
params.put("initiator_icon_with_name", initiator.badgeWithName());
params.put("acceptor_icon_with_name", acceptor.badgeWithName());
return StringNamedTemplate.format(
RandomUtils.getRandomElement(templates),
params
);
А бонусом, тот страшный пример с 17ью аргументами:
final var template = """
?${personage_badge_with_name} ${health_icon}${remain_health}
${normal_attack_icon}${normal_damage_value} (${normal_damage_count}) ${crit_attack_icon}${crit_damage_value} (${crit_damage_count})
${damage_blocked_icon}${damage_blocked_value} (${damage_blocked_count}) ${dodge_icon}${dodged_damage_value} (${dodged_damage_count})
+${reward_value}${money_icon}""";
Согласитесь, что стало понятнее, о чём здесь речь.
Урок 2. Частичная локализация
Совет актуален для тех разработчиков, кто в силах поддерживать один‑два перевода, а остальные делает сообщество. Очевидно, что сообщество будет отставать на некоторое время от выкатки фич. Многие используют следующую схему в таких случаях, и вряд ли есть что‑то лучше:
Есть локализация по‑умолчанию, которая покрывает 100%
Если по какой‑то локализации не хватает перевода, тогда берётся из дефолта.
Проще всего реализовать с помощью Map:
private static final Map<Language, List<String>> initDuelMap = new HashMap<>() {{
put(Language.RU, List.of(....));
put(Language.EN, List.of(....));
}};
public static String initDuel(Language language, Personage initiator, Personage acceptor) {
var templates = initDuelMap.get(language);
if (templates == null) {
templates = initDuelMap.get(Language.DEFAULT);
}
final var params = new HashMap<String, Object>();
params.put("initiator_icon_with_name", initiator.badgeWithName());
params.put("acceptor_icon_with_name", acceptor.badgeWithName());
return StringNamedTemplate.format(
RandomUtils.getRandomElement(templates),
params
);
}
Урок 3. Файлы Локализации
В примерах выше локализация лежит прямо рядом с кодом. Казалось бы, что в этом плохого?
Иногда локализацией занимаются отдельные люди, которые не разбираются в программировании, для них модифицировать исходники — сложно.
Много лишнего в исходниках — когда просматриваешь код, локализация будет просто отвлекать внимание от основной логики.
Сложно вносить изменения — даже если разработчик один, будет тратится лишнее время, чтобы найти нужный файл и конкретное место для правок.
Очевидное решение — вынести локализацию в отдельные файлы. Я выбрал формат TOML для этих целей.
Почему TOML
Небольшая табличка‑сравнение полуторагодовой давности, с разными форматами
Всем критериям удовлетворяли yaml и toml, но yaml мне категорически не нравится. И за время использования toml стал моим любимым языком конфигурации.
Итоговое решение, к которому я пришёл:
Файлы локализации разбиты на домены по языкам. Лучше много маленьких файлов, чем мало больших.
├── en
│ ├── duel.toml
└── ru
├── duel.toml
В момент старта приложения файлы парсятся в классы ресурсов и сохраняются в Map вида <Language, Resource>
ResourceUtils.doAction(
LOCALIZATION_PATH + language.value() + DUEL_PATH,
// DuelLocalization содержит внутри статический параметр для сохранения ресурсов
it -> DuelLocalization.add(language, extractClass(mapper, it, DuelResource.class))
);
Дальше в рантайме достаём нужную локализацию из Map
public static String initDuel(Language language, PersonageMention initiatorMention, PersonageMention acceptorMention) {
final var params = new HashMap<String, Object>();
params.put("mention_initiator_icon_with_name", initiatorMention.value());
params.put("mention_acceptor_icon_with_name", acceptorMention.value());
return StringNamedTemplate.format(
/*
resources - Это обертка над Map, в которой скрыт дублирующийся код
по работе с массивами и объектами по умолчанию
*/
resources.getOrDefaultRandom(language, DuelResource::initDuel),
params
);
}
Подход не идеальный: храним локализацию в памяти, куча статик методов, но с такой структуры точно можно начать и модифицировать далее под свои нужды.
Урок 4. Словоформы
Во многих языках у одного слова может быть множество различных форм. В русском языке это в основном выражено падежами и родами. Рассмотрим на примере:
“This ${item} will be worth ${value} ${currency}”.
На английском нет проблем с подстановкой слов, а вот как может выглядеть итоговая строка на русском:
«Этот копьё будет стоить 10 золото» или «Это палица стоит 5 золото».
Как исправить?
Используем иконки вместо слов где можем, например «?» вместо «золото».
Оптимизируем предложения, чтобы наши подлежащие были независимы от окружения.
«Копьё будет стоить 10?» и «Spear will be worth 10?».
Если вдруг, всё же возникает потребность в разных преобразованиях для языков, то вполне возможно что придётся писать специфичный код для каждой локализации.
Вот так в моём проекте выглядит работа с формами слов:
private static String itemWithPrefixAndSuffixModifier(Language language, Item item, Modifier prefix, Modifier suffix) {
final var params = new HashMap<String, Object>();
final var objectLocale = item.object().getLocaleOrDefault(language);
params.put("object", objectLocale.text());
// Считаем, что у Modifier есть либо нужная форма либо форма WITHOUT, которая подходит всем
params.put("prefix_modifier", prefix.getLocaleOrDefault(language).getFormOrWithout(objectLocale.form()));
params.put("suffix_modifier", suffix.getLocaleOrDefault(language).getFormOrWithout(objectLocale.form()));
return StringNamedTemplate.format(
resources.getOrDefault(language, ItemResource::itemWithPrefixAndSuffixModifier),
params
);
}
Урок 5. Словарь
Если у вас есть большой проект, который изобилует большим количеством терминов, названий и так далее, задумайтесь о заведении словаря. Его не нужно использовать непосредственно для отображению игроку. Но с его помощью переводчик сможет сделать намного более согласованный текст и у ваших игроков будет более полный контекст.
Например, в моём проекте персонажи являются Искателями. В онлайн переводчиках предлагаются следующие варианты: Finder, Searcher, Seeker, Looker. Или это может касаться географических названий, когда не всегда понятны принципы транслитерации или адаптации, как легендарный Stormwind/Штормград. Поэтому важно фиксировать подобные моменты в словаре.
Заключение
Я надеюсь, что перечисленные выше советы помогут начинающим локализаторам избежать ряда граблей. На самом деле эта сфера намного глубже, существует специальное ПО для этих целей и даже целые студии. Однако, нужны такие сложные (а иногда и дорогие) вещи далеко не всем. Можно начать с использования перечисленного выше, а дальше дорабатывать решение под себя. Если я упустил что‑то важное из виду, дополните меня в комментариях.
ffddrrtt
На мой взгляд написание очередной реализации i18n это плохая идея даже (особенно) для начинающих. Локализация текста это одна из проблем которые во многом уже решены, по крайней мере для большинства стандартных задач (плюрализация, переменные и т.п.).
Использование одной из имеющихся библиотек даст возможность сэкономить время и избежать хождения по граблям, a также позволит использовать специальные форматы и инструменты для переводчиков (для тех же комьюнити переводов).
Homyakin Автор
В Java есть встроенный инструмент для создания локализаций, и первая попытка была именно с её использованием. Однако, она не удовлетворяла моим требованиям: частичная локализация, поддержка формата toml в ресурсах (доступны только properties), возможность прикрутить типизацию, разбить локализацию по доменам.
Других популярных библиотек под это дело замечено особо не было, но тут я и не потратил особо много времени на их поиск, поэтому было принято решение написать своё. Да и в целом я люблю делать велосипеды.
В любом случае, описанные в статье принципы подойдут любой реализации, своей или библиотечной.
domix32
попробуйте мозиловский fluent. хотя если Java то будут нюансы
Homyakin Автор
Спасибо за наводку, действительно формат выглядит достаточно хорошо. Вот только вот реализацию на Java надо будет делать самому.
Пока для моих потребностей хватает текущего решения, но если придётся расширять, сначала поэкспериментирую с fluent.