JavaRush /Курси /Spring Core /Bean messageSource т...

Bean messageSource та i18n

Spring Core
Рівень 16 , Лекція 2
Відкрита

1. Bundles і місток у контейнер

Якщо покласти messages.properties у src/main/resources, у проєкті стане більше файлів, але магія не запрацює: Spring не почне «телепатично» витягати звідти тексти у ваші сервіси. Щоб повідомлення стали частиною застосунку, контейнерові потрібен об’єкт, який уміє за ключем і Locale повертати рядок. Саме таким об’єктом і є MessageSource.

Уявіть, що bundle-файли — це «книга» на полиці. Добре, що книга є. Але поки у вас немає «бібліотекаря», який знає, де вона стоїть, як її відкрити й яку сторінку знайти, ви все одно писатимете рядки вручну. MessageSource — якраз цей бібліотекар.

Нижче — спрощена схема того, що ми хочемо отримати під час виконання:

flowchart TD
    S["Сервіс OrderTextService"] --> MS["bean MessageSource"]
    MS --> B["набори повідомлень messages_ru.properties messages.properties"]
    B --> T["Готовий текст: Замовлення ORD-1 створено"]

Важливий нюанс: ApplicationContext уміє шукати повідомлення, тому що він «ширший», ніж просто контейнер впровадження залежностей. Але він не зберігає тексти всередині себе — він делегує їх MessageSource. І тут з’являється головний герой лекції: bean із правильним ім’ям і правильним налаштуванням.

2. Ім’я bean messageSource як контракт

Слово «контракт» звучить трохи суворо, але тут воно доречне. У Spring є домовленість: якщо в контексті є bean з іменем messageSource, то ApplicationContext використовуватиме його для пошуку повідомлень. Якщо такого bean немає, Spring підніме власну службову реалізацію, яка в i18n майже не допоможе.

Найнеприємніша пастка для новачка виглядає так: ви створили свій ResourceBundleMessageSource, але назвали метод, скажімо, messages() — і раптом отримаєте не зручну локалізацію, а загадкову помилку автозв’язування. Причина проста: у контейнері виявиться два MessageSource — ваш і службовий.

Щоб це було не абстрактно, ось маленька таблиця поведінки:

Що ви зробили в конфігурації Що відбувається в контексті Типовий симптом
Нічого не налаштували Spring створює службовий messageSource Тексти не знаходяться, getMessage повертає неочікуваний результат
Створили bean і назвали його messageSource Spring використовує ваше джерело повідомлень Усе працює, повідомлення читаються з bundles
Створили ResourceBundleMessageSource, але назвали bean інакше Spring усе одно створить службовий messageSource + у вас буде другий NoUniqueBeanDefinitionException під час впровадження MessageSource

Тут немає «магії» в поганому сенсі. Це просто правило: ім’я bean — частина конфігурації. Іноді ім’я — це лише спосіб знайти компонент. А іноді, як тут, — це вбудована точка інтеграції Spring.

3. ResourceBundleMessageSource: мінімум

Зараз нам потрібен навчальний варіант за замовчуванням: найпростіший і найзрозуміліший спосіб. Для bundles на classpath чудово підходить ResourceBundleMessageSource. Він працює поверх звичної моделі messages.properties, messages_ru.properties, messages_en.properties.

Важливо: basename вказується без суфіксів локалі та без .properties. Тобто якщо файли називаються messages*.properties, то basename — це просто "messages".

Мінімальний @Bean (його можна покласти у ваш поточний AppConfig — неважливо, головне, щоб метод називався messageSource):

import org.springframework.context.MessageSource;
import org.springframework.context.annotation.Bean;
import org.springframework.context.support.ResourceBundleMessageSource;

@Bean
MessageSource messageSource() {
    // Джерело повідомлень, яке читає файли з classpath (src/main/resources)
    ResourceBundleMessageSource source = new ResourceBundleMessageSource();

    // Базова назва наборів повідомлень: без _ru/_en і без .properties
    source.setBasename("messages");

    // Явно задаємо кодування, щоб не залежати від середовища
    source.setDefaultEncoding("UTF-8");

    // Повертаємо як MessageSource: сервісам не важливо, яка реалізація всередині
    return source;
}

Кілька пояснень, щоб ви не відчували, що «просто скопіювали заклинання».

setBasename("messages") говорить: «шукай bundles у classpath, починаючи з messages». Тобто Spring підбиратиме правильний файл під Locale і правила резервного вибору, які ми обговорювали в попередній лекції.

setDefaultEncoding("UTF-8") у сучасній Java зазвичай уже не критичний (починаючи з Java 9 властивості в ResourceBundle читаються в UTF-8), але в навчальному проєкті я люблю робити це явно. По-перше, так спокійніше. По-друге, якщо ви колись повернетеся до старого коду або оточення, не доведеться влаштовувати «свято кодувань».

А ось приклад того, як виглядає «опора» на боці ресурсів (ми це вже робили в лекції 2, просто нагадаю контекст):

# src/main/resources/messages.properties
order.created=Order {0} created
# src/main/resources/messages_ru.properties
order.created=Замовлення {0} створено

Зверніть увагу: ключ один і той самий. Змінюються лише значення.

4. Перевірка під час запуску

Поки ми не почали впроваджувати MessageSource у сервіси, корисно зробити маленьку «перевірку труб» — не в сенсі сантехніки, а на рівні інтеграції: переконатися, що контекст справді бачить bundles.

Так, ApplicationContext уміє getMessage(...), тому що він реалізує MessageSource. Це зручно для стартової частини застосунку, де ви й так тримаєте контекст у руках. Усередині бізнес-сервісів так робити не треба — ми ще повернемося до цього за хвилину.

Мініперевірка в main():

import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import java.util.Locale;

try (var ctx = new AnnotationConfigApplicationContext(AppConfig.class)) {
    // defaultMessage ("N/A") потрібен, щоб під час запуску не падати через друкарську помилку в ключі
    String msg = ctx.getMessage(
            "order.created",
            new Object[]{"ORD-1"}, // аргументи для {0}, {1}...
            "N/A",
            Locale.ENGLISH // явно перевіряємо конкретну локаль
    );

    System.out.println(msg); // Order ORD-1 created
}

Тут ми використовуємо перевантаження getMessage, де є defaultMessage. Це чудовий навчальний запобіжник: якщо ви припустилися помилки в ключі або забули файл, застосунок не падає з величезним stack trace на рівному місці, а видає зрозумілу підстраховку.

Якщо ви хочете спеціально побачити «жорстку» поведінку, можна використати перевантаження без defaultMessage. Тоді за відсутності ключа отримаєте NoSuchMessageException. У бойовому коді це інколи корисно (fail-fast), але на початковому етапі частіше зручніше мати значення за замовчуванням.

5. Впровадження MessageSource у сервіс

Тепер робимо найважливіше: починаємо користуватися MessageSource як звичайною залежністю, через constructor injection. Тут багато новачків інстинктивно звертають не туди й починають тягнути в сервіс ApplicationContext, бо «там же все є».

Так, там усе є. Але це як носити із собою цілий супермаркет заради хліба. Працює, проте дивно. І головне — такий підхід перетворює ваш сервіс на service locator, а ми весь курс вчилися рівно протилежному: залежності мають бути видимими й фіксованими.

У ContextFlow зручно виділити маленький сервіс, який відповідає лише за тексти: наприклад, OrderTextService. Він будуватиме короткі повідомлення для консолі, сповіщень або аудиту (саме короткі фрази; великі шаблони листів — це все ще Resource з попередньої лекції).

Приклад «серця» такого сервісу:

import org.springframework.context.MessageSource;
import java.util.Locale;

public class OrderTextService {
    private final MessageSource messages;

    public OrderTextService(MessageSource messages) {
        // Впроваджуємо MessageSource як звичайну залежність, без ApplicationContext
        this.messages = messages;
    }

    public String orderCreated(String orderId, Locale locale) {
        // {0} у properties буде замінено на orderId
        return messages.getMessage(
                "order.created",
                new Object[]{orderId},
                "Замовлення створено", // значення за замовчуванням, якщо ключ не знайдено
                locale
        );
    }
}

Зверніть увагу на кілька речей.

По-перше, сервіс не знає, де лежать файли. Він не читає resources. Він не відкриває потоки. Він просто каже: «мені потрібен текст за ключем». Це робить код чистим і легким.

По-друге, Locale ми поки передаємо як параметр. Так, це трохи багатослівніше, ніж коли б ми ховали локаль десь глобально. Зате ви одразу бачите, що текст залежить від локалі, а не «чомусь інколи не тією мовою».

По-третє, ключ "order.created" поки що рядком. Це нормально для перших кроків, але ближче до кінця дня ми наведемо дисципліну, щоб ключі не розповзалися по всьому проєкту, як блискітки після новорічної вечірки (вони потім знаходяться навіть у липні).

6. LocaleSelector і локаль за замовчуванням

У вебзастосунках Locale часто приходить «сама» (браузер, заголовки, LocaleResolver). У нас невебовий застосунок, тому все чесно: локаль або зберігається в даних (наприклад, у Customer), або задається за замовчуванням у конфігурації (contextflow.locale.default). І найважливіше — це правило вибору локалі не повинно дублюватися в десяти місцях.

Для цього зручно завести маленький помічник: LocaleSelector. Його робота проста: якщо у клієнта є preferredLocale, використовуємо її, інакше беремо локаль за замовчуванням.

Ось максимально прямолінійна реалізація:

import java.util.Locale;

public class LocaleSelector {
    private final Locale defaultLocale;

    public LocaleSelector(Locale defaultLocale) {
        // Локаль за замовчуванням — централізоване правило, а не "у кожному сервісі по-своєму"
        this.defaultLocale = defaultLocale;
    }

    public Locale choose(Locale preferredLocale) {
        // Якщо у користувача локаль не задана, повертаємо значення за замовчуванням
        return preferredLocale != null ? preferredLocale : defaultLocale;
    }
}

А ось приклад, як дістати локаль за замовчуванням із properties (ми спираємося на попередні лекції про @Value та перетворення; якщо перетворення для Locale у вас не налаштоване — можна тримати мовний тег як рядок і парсити його вручну):

import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import java.util.Locale;

@Bean
Locale defaultLocale(@Value("${contextflow.locale.default:en}") String tag) {
    // Наприклад: "en", "ru", "ru-RU" — усе це мовний тег
    return Locale.forLanguageTag(tag);
}

Після цього LocaleSelector також можна зібрати як bean (у тій самій конфігурації):

import org.springframework.context.annotation.Bean;

@Bean
LocaleSelector localeSelector(Locale defaultLocale) {
    // Збираємо LocaleSelector із локалі за замовчуванням, щоб правило вибору було єдиним
    return new LocaleSelector(defaultLocale);
}

Тепер OrderTextService можна зробити трохи охайнішим: приймати preferredLocale і вибирати підсумкову локаль усередині сервісу через LocaleSelector. Так бізнес-код не буде щоразу думати: «а що, якщо preferredLocale null?».

7. Дисципліна ключів: MessageKeys

Коли проєкт маленький, рядковий ключ "order.created" здається безпечним. Коли проєкт зростає, ви одного дня отримаєте ситуацію: в одному місці "order.created", в іншому "order.create", у третьому "orderCreated", а потім дві години шукаєте, чому «все працювало вчора».

Тому корисна мінімальна дисципліна — тримати ключі в одному місці. Не треба будувати «суперархітектуру ключів» з генерацією та reflection. Нам достатньо простого класу-константи.

Наприклад так:

public final class MessageKeys {
    // Єдина точка правди для ключів (щоб не розмножувати рядкові літерали по проєкту)
    public static final String ORDER_CREATED = "order.created";
    public static final String ORDER_CANCELLED = "order.cancelled";

    private MessageKeys() {
        // Забороняємо створювати екземпляри: це утилітний клас
    }
}

І тоді виклик стає менш крихким:

return messages.getMessage(
        MessageKeys.ORDER_CREATED, // використовуємо константу замість "ручного" рядка
        new Object[]{orderId},
        "Замовлення створено",
        locale
);

Так, це все ще рядки. Але тепер це одна точка правди, а не двадцять «майже однакових» літералів по проєкту.

Невелика табличка «що ми лікуємо»:

Як виглядає У чому проблема Як краще в навчальному проєкті
"order.created" у кожному другому класі Помилки, неузгодженість, складно змінювати MessageKeys.ORDER_CREATED
Ключі як цілі фрази ("Order created successfully") Змінюється текст — ламається ключ, складно шукати Короткий смисловий код (order.created)
Склеювання рядків у коді Погано локалізується, каша з логіки й тексту Параметри {0}, {1} + Object[] args

Ми не намагаємося зробити ідеально. Ми намагаємося зробити так, щоб проєкт залишався читабельним, а i18n не перетворювався на мінідетектив.

8. Типові помилки під час підключення MessageSource

Коли ви вперше підключаєте MessageSource, помилки найчастіше не складні, а… прикрі. Як коміт «fix typo» на 200 рядків. Нижче — найчастіші граблі, які трапляються саме в чистому Spring-застосунку без Spring Boot, і як їх розпізнати за симптомами.

Помилка №1: bean називається не messageSource, і Spring створює ще один messageSource сам.
Це виглядає особливо підступно: ви начебто створили ResourceBundleMessageSource, а застосунок раптом падає під час запуску через неоднозначність, бо MessageSource тепер два. Симптом часто виглядає як NoUniqueBeanDefinitionException для MessageSource. Лікується банально: метод @Bean має називатися messageSource() (або задайте ім’я явно через @Bean("messageSource")), щоб Spring не піднімав паралельно службову реалізацію за замовчуванням.

Помилка №2: ви вказали неправильний basename.
Якщо файли називаються messages_ru.properties, а ви написали source.setBasename("message"), то контейнер не знайде набори повідомлень, і ви почнете отримувати лише defaultMessage (якщо ви його передали) або виняток NoSuchMessageException. Тут корисно пам’ятати правило: basename — це спільний корінь імені файлу без локалі та без розширення. У нашому дні це строго "messages".

Помилка №3: ви очікуєте, що MessageSource замінить Resource і почне зберігати великі шаблони.
MessageSource — про короткі повідомлення за ключем, зазвичай 1–2 рядки. Так, технічно можна запхати туди «полотно» на сторінку тексту, але це швидко перетвориться на муку: properties-файли погано підходять для великих шаблонів, а параметри {0} і екранування спецсимволів стануть вашим хобі. Для великих текстів, шаблонів повідомлень і заголовків звітів у нас уже є правильний інструмент: Resource і файли в templates/.

Помилка №4: Locale вибирається в кожному сервісі по-своєму, і поведінка стає непередбачуваною.
Дуже легко почати писати «якщо preferredLocale null — візьмемо Locale.ENGLISH» в одному місці, «якщо null — візьмемо ru» в іншому, а в третьому — взагалі не перевіряти null. Потім ви бачите, що частина повідомлень англійською, частина іншою мовою, і думаєте, що у вас «зламалася локалізація». Насправді зламалася дисципліна вибору локалі. Винесіть правило в LocaleSelector і використовуйте його всюди однаково.

Помилка №5: ключі розповзлися по проєкту рядками, і ви ловите помилки лише під час виконання.
На відміну від Java-коду, ключі в properties компілятор не перевіряє. Тому помилка — це не червоне підсвічування в IDE, а сюрприз під час запуску. Мінімальні ліки — MessageKeys (або інший єдиний клас чи місце для ключів). Більш просунуті підходи існують, але для поточного курсу нам достатньо не плодити рядкові літерали.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ