JavaRush /Курси /Spring Core /Типізована конфігурація Co...

Типізована конфігурація ContextFlow

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

1. Типізовані властивості ContextFlow

На цей момент у нас уже є всі деталі окремо: значення надходять із properties як рядки, спільний ConversionService може стати контейнерним перекладачем типів, а для доменних випадків у нас є власні правила конвертації. Тепер завдання практичне — зібрати все це в ContextFlow, щоб рядки остаточно зупинилися на межі конфігурації, а сервіси працювали тільки з типами.

У нашому проєкті це особливо помітно на двох налаштуваннях: який канал сповіщень вважати типовим і в якому форматі генерувати звіт. Обидва вибори природно живуть у вигляді enum, тому що варіантів небагато, а нам важливо, щоб IDE та компілятор не дозволяли писати в них довільний текст.

Ось мінімальна карта того, що ми типізуємо:

Налаштування Ключ у properties Було в коді (погана звичка) Стане в коді (нормально)
Типовий канал сповіщень contextflow.notifications.default-channel String NotificationChannel
Формат звіту contextflow.report.format String ReportFormat

Якщо знову підтягнути сюди String, повернеться весь знайомий набір: valueOf(), trim(), toUpperCase() і пізні помилки вже всередині сервісів. Тому далі збираємо варіант, де конфігурація залишається текстом тільки зовні, а всередину застосунку входять NotificationChannel і ReportFormat.

2. ReportFormat і контракт у properties

Перед тим як писати конвертери, важливо зробити річ, яку розробники часто пропускають: домовитися про типи й про те, які значення взагалі допустимі. Із NotificationChannel нам простіше — він уже частина домену, а варіанти очевидні: EMAIL, SMS, CONSOLE. А ось формат звіту ми сьогодні оформимо явно, щоб перестати зберігати в конфігу загадкові рядки на кшталт "text" або "csv-like" і не гадати, що автор мав на увазі.

Додамо enum ReportFormat. За структурою проєкту логічно покласти його в com.example.contextflow.domain.model (або поруч із частиною звітності, якщо у вас так склалося), тому що це поняття використовуватиметься і в сервісному шарі, і в конфігурації.

public enum ReportFormat {
    // Рівно перелічені значення — це і є «контракт» конфігурації.
    TEXT,
    CSV
}

Тепер давайте визначимося, які значення ми хочемо бачити в contextflow.properties. Якщо залишити все за замовчуванням, то багато вбудованих enum-конвертерів очікують точного збігу. Тобто для CSV потрібно написати CSV, а не csv. Але в реальному житті люди пишуть csv, тому що вони не зобов’язані пам’ятати Java-угоди про верхній регістр і, чесно кажучи, не повинні.

Тому наш «контракт» на рівні зовнішнього конфігу буде м’якшим: ми дозволяємо csv, CSV, csv — а Spring акуратно приводить це до ReportFormat.CSV ще на межі конфігурації.

Ось як це може виглядати в src/main/resources/contextflow.properties:

# Зовні — звичайний текстовий конфіг.
# Ми спеціально пишемо в нижньому регістрі, щоб показати «мʼякий» контракт.
contextflow.notifications.default-channel=sms
contextflow.report.format=csv

Зверніть увагу на дрібницю: файл залишається простим текстом. Жодної магії. Магія (у хорошому сенсі) буде в тому, що рядок не доживе до сервісного шару як рядок.

3. Спільний ConversionService

Тут ми нічого не пишемо заново з нуля. У ContextFlow нам потрібні два шари правил конвертації.

Для NotificationChannel залишаємо окремий StringToNotificationChannelConverter: для каналу сповіщень корисно мати власне правило нормалізації й зрозуміле повідомлення про помилку саме для цього доменного типу.

Для звичайних enum-випадків на кшталт ReportFormat достатньо StringToEnumIgnoringCaseConverterFactory: вона покриває повторюваний шаблон trim -> upper -> Enum.valueOf(...) і не змушує клонувати один і той самий converter під кожен enum.

Залишилося зареєструвати обидва механізми в одному conversionService, який контейнер використовуватиме під час зв’язування.

import com.example.contextflow.support.conversion.StringToEnumIgnoringCaseConverterFactory;
import com.example.contextflow.support.conversion.StringToNotificationChannelConverter;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.convert.ConversionService;
import org.springframework.core.convert.support.DefaultConversionService;

@Configuration
public class ConversionConfig {

    @Bean
    public ConversionService conversionService() {
        DefaultConversionService service = new DefaultConversionService();

        // Загальне правило для звичайних enum-випадків, наприклад ReportFormat.
        service.addConverterFactory(new StringToEnumIgnoringCaseConverterFactory());

        // Спеціальне правило для NotificationChannel, якщо хочемо окрему валідацію та повідомлення про помилку.
        service.addConverter(new StringToNotificationChannelConverter());

        return service;
    }
}

Ось обіцяна схема: як саме проходить значення:

flowchart TD
    A["contextflow.properties (рядки)"] --> B["${...} placeholder"]
    B --> C["ConversionService (рядок -> тип)"]
    C --> D["Settings bean (NotificationSettings/ReportingSettings)"]
    D --> E["Service layer (працює з enum)"]

Сенс у тому, що сервіси — це остання зупинка, де взагалі мають існувати тільки типи, а не «сирий текст із середовища».

4. Settings-beans і @Value

Тепер ми робимо важливий архітектурний крок: увесь «бруд» конфігурації — ключі, placeholder-и, значення за замовчуванням — ми тримаємо в спеціальних settings-beans. Це не магія і не окремий рівень архітектури на кшталт “enterprise”, а просто дисципліна: якщо завтра ключ перейменується, ви зміните його в одному місці, а не шукатимете по всьому проєкту ${contextflow....} як розробницькі пасхалки.

NotificationSettings

import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

@Component
public class NotificationSettings {

    // Тут уже живе тип, а не рядок.
    private final NotificationChannel defaultChannel;

    public NotificationSettings(
            // Значення за замовчуванням корисне для локального старту без повного конфігу.
            @Value("${contextflow.notifications.default-channel:CONSOLE}")
            NotificationChannel defaultChannel) {
        this.defaultChannel = defaultChannel;
    }

    public NotificationChannel defaultChannel() {
        // Назовні віддаємо типізоване значення — сервіси не знають про ключі та рядки.
        return defaultChannel;
    }
}

Зверніть увагу на дві деталі. Ми залишили значення за замовчуванням CONSOLE, щоб проєкт міг стартувати навіть із мінімальним конфігом: це зручно для локальних запусків. І найголовніше: у полі та назовні в нас не рядок, а NotificationChannel.

ReportingSettings

import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

@Component
public class ReportingSettings {

    // Типізоване налаштування формату звіту.
    private final ReportFormat format;

    public ReportingSettings(
            // Значення за замовчуванням: якщо ключ не задано, використовуємо TEXT.
            @Value("${contextflow.report.format:TEXT}") ReportFormat format) {
        this.format = format;
    }

    public ReportFormat format() {
        return format;
    }
}

Значення після двокрапки тут — це fallback тільки на випадок, коли ключ взагалі відсутній. Це зручно для локального старту, поки конфіг ще не заповнений повністю. Але якщо ключ є і в ньому написано telegram або csvv, конвертація все одно впаде: default не скасовує правило «невідоме значення = помилка конфігурації».

Тепер у нас є дві маленькі, чесні, легко тестовані сутності. Вони не розв’язують бізнес-завдання, не надсилають сповіщення і не пишуть звіти. Їхня робота — бути межею, на якій конфігурація перестає бути строковим болотом і стає типізованою реальністю.

5. Сервіси без парсингу рядків

На цьому кроці відбувається те, заради чого ми взагалі все це починали: сервісний шар починає виглядати як нормальний Java-код, а не як “код для виживання рядків”. У хорошому сервісі ви хочете бачити switch по enum, компіляторну перевірку варіантів і зрозумілу точку помилки при зламаному конфігу.

Реєстр відправників за каналом

Щоб сервісу було зручно отримувати відправника за NotificationChannel, зробимо простий реєстр. Це можна тримати як @Component в інфраструктурі.

import java.util.Map;
import org.springframework.stereotype.Component;

@Component
public class NotificationSenderRegistry {

    // Зв’язка "канал -> конкретна реалізація відправника".
    private final Map<NotificationChannel, NotificationSender> senders;

    public NotificationSenderRegistry(
            // Конкретні реалізації приходять із Spring як біни.
            EmailNotificationSender email,
            SmsNotificationSender sms,
            ConsoleNotificationSender console) {
        // Явно мапимо enum на реалізацію — це простіше й надійніше, ніж рядки.
        this.senders = Map.of(
                NotificationChannel.EMAIL, email,
                NotificationChannel.SMS, sms,
                NotificationChannel.CONSOLE, console
        );
    }

    public NotificationSender byChannel(NotificationChannel channel) {
        // Тут важливо: якщо каналу немає в мапі — це інфраструктурна проблема конфігурації бінів.
        return senders.get(channel);
    }
}

Так, тут ми явно пов’язуємо канал із реалізацією. І це нормально: канал — доменна сутність, реалізації відправників — інфраструктура, і реєстр якраз і служить мостом між ними.

NotificationDispatchService без рядків

import org.springframework.stereotype.Service;

@Service
public class NotificationDispatchService {

    private final NotificationSettings settings;
    private final NotificationSenderRegistry registry;

    public NotificationDispatchService(NotificationSettings settings,
                                       NotificationSenderRegistry registry) {
        this.settings = settings;
        this.registry = registry;
    }

    public NotificationSender defaultSender() {
        // Жодних valueOf/trim/toUpperCase: ми вже на боці типів.
        return registry.byChannel(settings.defaultChannel());
    }
}

У цьому сервісі більше немає valueOf, trim, toUpperCase. Він працює з типами та зрозумілими об’єктами. Виклик defaultSender() або поверне коректного відправника, або, якщо ви забудете зареєструвати щось у реєстрі, дасть вам null, і ви швидко побачите проблему — уже на рівні «інфраструктурна карта неповна», а не «конфіг якийсь дивний».

Вибір ReportFormatter за ReportFormat

Формат звіту — глобальне налаштування. Отже, нам зручно створити один bean ReportFormatter, який вибирається в конфігурації на старті.

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class ReportingConfig {

    @Bean
    public ReportFormatter reportFormatter(ReportingSettings settings,
                                          TextReportFormatter text,
                                          CsvReportFormatter csv) {
        // Вибір реалізації робимо один раз на старті за типізованим налаштуванням.
        return settings.format() == ReportFormat.CSV ? csv : text;
    }
}

Ця маленька конструкція робить одразу дві корисні речі. По-перше, вона гарантує, що весь ReportingService працює з єдиним formatter-ом. По-друге, вона переносить вибір реалізації до конфігураційного шару, а не в бізнес-сервіс. І ви знову не бачите в сервісах жодного рядка на кшталт "csv".

6. Fail-fast під час помилок конфігурації

Найприємніше в типізованій конфігурації — не те, що “код красивіший”. Найприємніше — що помилка конфігурації проявляється одразу, під час старту контейнера. Це називається fail-fast: якщо застосунок не може чесно працювати, він має відмовитися запускатися, а не чекати моменту, коли клієнт уже натиснув кнопку “Створити замовлення”.

Уявімо, що хтось — зазвичай це «хтось» на кшталт вас через тиждень, і ви ж потім будете сваритися — написав у contextflow.properties так:

contextflow.notifications.default-channel=telegram

Контейнер спробує створити NotificationSettings. Для цього він має впровадити NotificationChannel. Для цього він викличе наш StringToNotificationChannelConverter. Конвертер чесно скаже: “не знаю такого каналу”, і кине IllegalArgumentException. Підсумок — контекст не стартує, і ви бачите проблему там, де їй і місце: на межі конфігурації.

Якщо ви захочете швидко побачити цю поведінку наживо, можна зробити маленьку перевірку в main() (якщо у вас є точка входу), просто щоб переконатися, що застосунок падає на старті, а не «десь потім»:

import org.springframework.context.annotation.AnnotationConfigApplicationContext;

public class ContextFlowApplication {

    public static void main(String[] args) {
        // Ідея проста: якщо конфіг зламаний, контекст не підніметься.
        try (var ctx = new AnnotationConfigApplicationContext(ContextFlowAppConfig.class)) {
            System.out.println("Контекст запущено!"); // Не виведеться при зламаному конфігу.
        }
    }
}

Зламаний конфіг — і ви навіть не побачите “Контекст запущено!”. Це хороший біль: швидкий, чесний і в правильному місці.

7. Типові помилки під час типізації конфігурації

Після такої лекції зазвичай хочеться негайно типізувати взагалі все підряд, включно з ім’ям кота в properties. Це нормальний ентузіазм, але його варто тримати під контролем. Типізація конфігурації справді робить проєкт міцнішим, але разом із цим вимагає дисципліни: де конвертувати, де зберігати ключі, як обробляти помилки і як не затягнути бізнес-логіку в шар conversion. Давайте розберемо найчастіші промахи, які трапляються саме на цьому кроці.

Помилка №1: “Сервісу простіше, я прямо тут розпарсю рядок”.
Зазвичай це починається з одного рядка valueOf(), а закінчується тим, що у вас у трьох сервісах три різні версії «нормалізації»: десь trim(), десь toUpperCase(), десь ще й replace("-", "_"). Конфігурація перетворюється на лотерею: трохи змінили формат — і одна частина застосунку живе, а інша падає. Лікується це просто: усе, що може бути типом, має стати типом на межі конфігурації, у settings-bean і через ConversionService.

Помилка №2: “Давайте при невідомому значенні мовчки візьмемо default”.
Це дуже підступна річ: застосунок стартує, усе «наче працює», але насправді працює не так, як ви хотіли. Якщо в конфігу написали sms, а ви раптом вирішили, що невідоме значення — це CONSOLE, ви можете тижнями не помічати, що сповіщення йдуть не туди. Для навчального і для звичайного backend-застосунку краще правило «невідоме значення — це помилка». Нехай контейнер падає одразу, зате ви розумієте, що система не погоджується з конфігом.

Помилка №3: conversionService зареєстрували, але назвали інакше.
У Spring є кілька точок «convention over configuration». Одна з них — ім’я conversionService. Якщо ви назвали bean, наприклад, myConversionService, він як об’єкт існуватиме в контексті, але контейнер не буде автоматично використовувати його як загальний механізм конвертації для @Value і binding. У підсумку ви будете дивитися на коректний код і думати, чому він не працює. Це той випадок, коли ім’я має значення, і воно має бути простим: conversionService.

Помилка №4: конвертер починає вирішувати за бізнес-код.
Іноді хочеться зробити в конвертері «розумно»: якщо прийшло невідоме значення, вибрати найкращий канал; якщо прийшло sms, але SMS вимкнено, вибрати email; якщо прийшло csv, але «сьогодні у нас свято», вибрати text. Усе це звучить весело, але це вже не conversion. Конвертер — перекладач, а не менеджер продукту. Щойно він починає вибирати поведінку, ви втрачаєте передбачуваність і перетворюєте старт застосунку на шоу «вгадай, що я мав на увазі».

Помилка №5: ключі properties розмазано по всьому коду.
Сьогодні ми зробили settings-beans саме для того, щоб ключі жили в одному місці. Якщо за пару днів ви знову почнете писати @Value("${contextflow.report.format}") прямо в ReportingService, ви відкотите дисципліну назад. Налаштування мають залишатися налаштуваннями: одна точка входу, типізоване значення назовні, жодного «прямого доступу до сирого конфігу» з бізнес-шару.

1
Опитування
Конвертація налаштувань, рівень 13, лекція 4
Недоступний
Конвертація налаштувань
Spring binding і типи
Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ