JavaRush /Курси /Spring Core /Converter<S, T>

Converter<S, T> для NotificationChannel

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

1. Обмеження вбудованої конвертації

ConversionService уже дав нам загальний механізм, але вбудованої конвертації String -> enum вистачає лише доти, доки значення з конфігу збігається з іменем enum-константи. Щойно людина пише sms замість SMS або залишає пробіли, суворе правило перестає допомагати. Саме тут і потрібен власний Converter: одне місце, де ми нормалізуємо вхідні дані й робимо помилку явною ще на старті.

Уявімо, що в contextflow.properties у нас є налаштування:

# Значення в конфігу надходить рядком і його легко записати в будь-якому регістрі
contextflow.notifications.default-channel=sms

А доменна модель у нас чесна й сувора:

import com.example.contextflow.domain.model.NotificationChannel;

// У коді ми хочемо працювати з доменним типом, а не з рядком
NotificationChannel channel = NotificationChannel.SMS;

Проблема в тому, що Java-enum за замовчуванням чутливі до регістру. Ось такий код виглядає невинно, але падає через один неправильний регістр:

// У конфіг надійшло "sms", а enum очікує суворо "SMS"
String raw = "sms";

NotificationChannel channel = NotificationChannel.valueOf(raw); // IllegalArgumentException

Якщо в цей момент почати розсипати trim().toUpperCase() по сервісах, ми просто повернемо рядковий парсинг назад у бізнес-шар. Нам потрібен інший хід: сервіси й далі працюють із NotificationChannel, а правило нормалізації та валідації живе в окремому Converter<String, NotificationChannel> на межі конфігурації.

2. Інтерфейс Converter<S, T>

На цьому етапі дуже важливо не переплутати: Converter — це не «якийсь магічний Spring-клас», а досить приземлена ідея. Це маленький перекладач: він бере значення одного типу й повертає значення іншого. Не більше. Якщо ваш Converter почав писати у файли, ходити в мережу або вибирати біни за профілем — вітаю, ви випадково збудували міні-монстра.

У Spring інтерфейс виглядає так (спрощено за змістом):

// S — тип вхідного значення, T — тип результату
public interface Converter<S, T> {

    // Єдина відповідальність: взяти S і перетворити на T
    T convert(S source);
}

Літери S і T — це generics, тобто «тип джерела» і «тип результату». Для нашої задачі:

- S буде String (бо з properties прилітає текст),
- T буде NotificationChannel (бо сервісам потрібен доменний тип).

І тут корисна практична думка: хороший Converter зазвичай чиста функція. Це означає, що якщо ви двічі сконвертуєте "sms" — ви двічі отримаєте один і той самий результат. Він не повинен залежати від часу, випадковості, зовнішніх сервісів і настрою кота.

Ще одне важливе правило для новачка: Converter — не місце для «розумних здогадок». Якщо конфіг неправильний, краще впасти на старті з зрозумілою помилкою, ніж «мовчки вибрати щось за замовчуванням». Тому що «мовчки» майже завжди означає «потім хтось буде мовчки плакати в логах».

3. StringToNotificationChannelConverter

Зараз ми зробимо рівно те, заради чого прийшли: напишемо конвертер, який приймає рядки на кшталт " sms " і "Sms", а повертає чесний NotificationChannel.SMS. Важливо: ми робимо це не для краси, а щоб конфіг був терпимішим до людського введення, але при цьому залишався суворо перевірюваним.

Для початку нагадаємо собі, як виглядає наш enum. Скоріше за все, він у вас уже є в ContextFlow, але корисно побачити, що саме ми конвертуємо:

// Доменний тип: список допустимих каналів сповіщень
public enum NotificationChannel {
    EMAIL, SMS, CONSOLE
}

Тепер пишемо конвертер. Я покажу варіант, який робить три речі: перевіряє порожнечу, нормалізує регістр і дає зрозумілу помилку.

import com.example.contextflow.domain.model.NotificationChannel;
import org.springframework.core.convert.converter.Converter;

public class StringToNotificationChannelConverter
        implements Converter<String, NotificationChannel> {

    @Override
    public NotificationChannel convert(String source) {
        // Порожнє або пробільне значення — це помилка конфігурації
        if (source == null || source.isBlank()) {
            throw new IllegalArgumentException("Канал сповіщень порожній");
        }

        // Нормалізуємо введення: прибираємо пробіли та приводимо до верхнього регістру
        return NotificationChannel.valueOf(source.trim().toUpperCase());
    }
}

Зверніть увагу на дві деталі, які дуже «по-дорослому» впливають на якість застосунку.

Перша деталь — isBlank(). У конфігу інколи випадково залишають порожнє значення: ключ є, а значення не дописали. Для нас це помилка конфігурації, а не «ну гаразд, нехай буде null».

Друга деталь — ми не робимо «запасний варіант». Тобто ми не кажемо: «якщо що — візьмемо CONSOLE». Такий підхід маскує проблему: конфіг неправильний, але застосунок продовжив роботу в непередбачуваному режимі. Якщо це навчальний проєкт — ви просто не помітите, що щось пішло не так. Якщо це production — помітите, але пізно й у поганому настрої.

Якщо вам хочеться підтримати псевдоніми на кшталт stdoutCONSOLE, це теж робиться тут, але акуратно. Наприклад, так:

// Приклад підтримки псевдоніма: це все ще про формат вхідного рядка, а не про бізнес-логіку
String v = source.trim().toUpperCase();
if (v.equals("STDOUT")) v = "CONSOLE";
return NotificationChannel.valueOf(v);

Головне — не перетворювати конвертер на «бізнес-правила». Псевдоніми — це про формат вхідного рядка, а не про вибір стратегії сповіщень.

4. Реєстрація в ConversionService

Найчастіший сценарій новачка такий: «Я написав конвертер… і нічого не змінилося». І це логічно: Spring не телепат. Йому потрібно явно сказати: «Ось загальний механізм конвертації, і ось ще одне правило для нього».

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

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() {
        // Глобальний сервіс конвертації типів для всього ApplicationContext
        DefaultConversionService service = new DefaultConversionService();

        // Додаємо правило: String -> NotificationChannel
        service.addConverter(new StringToNotificationChannelConverter());

        return service;
    }
}

Тут відбувається важлива «контейнерна магія», але магія пояснювана. На старті ApplicationContext побачить bean з іменем conversionService і зрозуміє: «О, це головний перекладач типів». Після цього, коли Spring намагатиметься впровадити значення з @Value у параметр конструктора типу NotificationChannel, він звернеться до цього ConversionService.

Якщо дуже схематично, ланцюжок виглядає так:

flowchart TD
  A["contextflow.properties: 'sms'"] --> B["@Value('${...}') повертає String"]
  B --> C["ConversionService"]
  C --> D["NotificationChannel.SMS"]
  D --> E["впровадження через конструктор у бін"]

І це саме той момент, коли «рядок із файлу» перетворюється на «тип у коді» до входу в бізнес-логіку.

5. Використання з @Value і NotificationChannel

Коли конвертер зареєстровано, найприємніше — побачити результат у коді. Ми зробимо невеликий bean для налаштувань, який логічно тримати в інфраструктурному шарі, наприклад у infrastructure.configsupport. Він зберігає типізований конфіг.

import com.example.contextflow.domain.model.NotificationChannel;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

@Component
public class NotificationSettings {

    private final NotificationChannel defaultChannel;

    public NotificationSettings(
            // Spring читає рядок із properties і конвертує його в enum через ConversionService
            @Value("${contextflow.notifications.default-channel}") NotificationChannel defaultChannel
    ) {
        this.defaultChannel = defaultChannel;
    }

    public NotificationChannel getDefaultChannel() {
        return defaultChannel;
    }
}

Зверніть увагу, що тут немає ані trim(), toUpperCase(), ані valueOf(). І це не тому, що ми «ліниві». Це тому, що ми свідомо винесли конвертацію туди, де їй і місце.

Тепер можна зробити маленьку діагностичну перевірку під час старту. Наприклад, у вашому ScenarioRunner або в окремому ранері, який ви й так піднімаєте з контексту:

import org.springframework.context.annotation.AnnotationConfigApplicationContext;

try (var ctx = new AnnotationConfigApplicationContext(AppConfig.class)) {
    // Перевіряємо, що типізоване налаштування справді піднялося з конфігу
    NotificationSettings s = ctx.getBean(NotificationSettings.class);

    // Очікуємо побачити SMS, навіть якщо в конфігу написано "sms"
    System.out.println("Канал за замовчуванням = " + s.getDefaultChannel()); // Канал за замовчуванням = SMS
}

Якщо в contextflow.properties стоїть sms, ви побачите SMS. Це добрий знак: отже, конвертер справді бере участь у процесі.

І ось тепер важливий архітектурний бонус: ваш сервіс, який займається надсиланням сповіщень, може залежати не від рядка, а від NotificationChannel. Навіть якщо всередині у вас маршрутизація через switch (що в навчальному проєкті нормально), вона стане чистою і типобезпечною.

6. Розміщення конвертера в проєкті

Дуже хочеться покласти StringToNotificationChannelConverter поруч із NotificationChannel «тому що це ж пов’язано». Але це як покласти викрутку в пачку макаронів: зв’язок є (обидва на кухні), але користуватися незручно, і сенс шарів розмивається.

NotificationChannel — частина домену. Це смислова модель: які канали сповіщень існують у системі.

StringToNotificationChannelConverter — частина інфраструктури Spring, бо він розв’язує технічну задачу: як перетворити рядок із конфігурації на доменний тип.

Тому в ContextFlow розумно тримати конвертер у пакеті на кшталт:

- com.example.contextflow.support.conversion (як і запропоновано в ТЗ проєкту)

А конфігурацію, яка збирає шар конвертації, у:

- com.example.contextflow.config.core (або близько до core wiring)

Тоді виходить читабельна картина: домен чистий, Spring-механізми ізольовані, а налаштування конвертації не «протікають» у сервіси.

І ще один важливий момент: конвертер — це частина глобального механізму. Якщо ви почнете робити десять різних ConversionService по кутах застосунку, ви втратите передбачуваність. В одному місці конвертація працює так, в іншому — інакше. Зазвичай це закінчується словами «чому в одному біні нормально, а в іншому падає?!». Відповідь буде проста: «тому що ви самі собі зробили два різні світи».

7. Неправильне значення: fail-fast на старті

Давайте спеціально «зламаємо» конфіг. Поставимо, наприклад:

# Навмисно неправильний канал — щоб побачити fail-fast під час старту контексту
contextflow.notifications.default-channel=telepathy

Якби в нас був ручний парсинг у бізнес-методі, застосунок міг би спокійно стартувати, а впасти лише тоді, коли хтось уперше спробує надіслати сповіщення. Це неприємно: помилка проявляється пізно, неочевидно, а інколи взагалі в іншому потоці виконання (якби ми колись додали асинхронність).

З конвертером відбувається інше: Spring намагається створити NotificationSettings, бачить @Value(...) NotificationChannel, запускає конвертацію, і наш конвертер кидає IllegalArgumentException. У підсумку контекст не підніметься.

Так, звучить суворо. Але насправді це поведінка «як інженер любить»: fail-fast. Ми виявили помилку на межі конфігурації, у момент старту, коли ще не відбулося жодної бізнес-дії.

Важливо розуміти: у stack trace ви побачите не лише IllegalArgumentException, а й «обгортки» Spring — наприклад, щось на кшталт “failed to convert value” або “unsatisfied dependency”. Не лякайтеся. Це нормальне «пакування» помилки: Spring показує, який bean не зміг створити і чому.

Практична порада тут проста: коли ви бачите таку помилку, спочатку шукайте внизу причину (Caused by), а потім підіймайтеся вище й дивіться, який bean і яке поле або параметр впали. За кілька днів практики ви будете читати такі помилки майже так само швидко, як повідомлення месенджера — тільки менш приємно, але зате корисно.

8. Типові помилки під час роботи з конвертерами

Помилка № 1: конвертер починає ухвалювати бізнес-рішення.
Якщо всередині convert() ви раптом вибираєте, «який NotificationSender використовувати» або «яку стратегію знижки ввімкнути» — ви змішали шари. Конвертер має перекладати типи, а не керувати поведінкою застосунку. Інакше ви отримаєте дивний дизайн, де конфіг і бізнес-логіка переплелися в одному методі, який викликає контейнер «десь там».

Помилка № 2: невідоме значення перетворюється на «дефолт тихцем».
Фраза «ну якщо незрозуміло — візьмемо CONSOLE» звучить турботливо, але це турбота із підступом. Ви маскуєте помилку конфігурації, а потім дивуєтеся, чому сповіщення «раптом перестали приходити на SMS». Краще впасти одразу. Помилка конфігурації має бути гучною, інакше вона буде дорогою.

Помилка № 3: забули про пробіли й регістр, і конвертер вийшов марним.
Якщо ваш конвертер робить рівно NotificationChannel.valueOf(source) — ви майже нічого не виграли порівняно з вбудованою поведінкою. Мінімальний сенс власного конвертера зазвичай у нормалізації (trim(), toUpperCase()) або в зрозумілій помилці. Інакше ви просто написали код заради коду.

Помилка № 4: convert() повертає null як «нормальний результат».
Технічно Converter може повернути null, і іноді це використовується (наприклад, порожній рядок → null). Але для обов’язкового налаштування на кшталт default-channel це майже завжди погана ідея: ви отримаєте null у settings-bean і потім NPE в неочікуваному місці. Якщо налаштування обов’язкове — краще викидати виняток.

Помилка № 5: конвертер зареєстрували не в загальний conversionService, а «десь локально».
Часто роблять так: створили new DefaultConversionService() всередині одного сервісу й додали конвертер туди. У цьому місці все працює… а в інших — ні. У підсумку виходить «магія» вже вашого власного виробництва. Тримайте один спільний ConversionService bean і додавайте правила туди.

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