JavaRush /Курси /Spring Core /ConversionService: п...

ConversionService: перетворення типів

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

1. Роль загального ConversionService

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

Для ContextFlow це особливо помітно на NotificationChannel і ReportFormat. Сервісу сповіщень потрібен саме NotificationChannel, а сервісу звітів — ReportFormat, а не String, який ще треба очистити від пробілів і привести до верхнього регістру. Якщо кожен клас сам розбирає рядок, конфігурація знову просочується в бізнес-логіку.

ConversionService: canConvert(...) і convert(...)

Якщо вас насторожує слово «service» у назві (після десятка *Service у проєкті це нормально), то сприймайте ConversionService як «єдиного перекладача типів». Він не про бізнес, а про інфраструктуру контейнера: йому байдуже, що саме ви перетворюєте — канал сповіщень, числа, enum-и чи щось іще. Він просто відповідає за перетворення.

На рівні ментальної моделі новачкові досить запам’ятати два методи. Перший — canConvert(...). Він відповідає на запитання: «Чи вмієш ти переводити з одного типу в інший?». Другий — convert(...): «Перетвори це значення на потрібний тип». Важливо, що Spring намагається тримати цей механізм централізованим: замість того щоб кожен клас сам вирішував, як розбирати рядок, ми звертаємося до одного спільного перекладача.

Невеликий приклад програмного використання — суто щоб «помацати» API руками, як кіт перевіряє пакет на міцність:

package com.example.contextflow.support.conversion;

import org.springframework.core.convert.ConversionService;
import org.springframework.stereotype.Component;

@Component
public class ConversionProbe {
    // Інфраструктурний сервіс перетворення, який надає Spring
    private final ConversionService conversionService;

    public ConversionProbe(ConversionService conversionService) {
        // Важливо: ми не створюємо конвертер самі, а беремо з контексту
        this.conversionService = conversionService;
    }

    public void demo() {
        // Перетворюємо рядок у Integer через загальний механізм
        Integer n = conversionService.convert("42", Integer.class);

        // Тут важливий сам факт перетворення через ConversionService
        System.out.println("n = " + n); // n = 42
    }
}

Тут важлива не сама цифра 42 (хоча цифра 42, звісно, особлива), а факт: ми не пишемо Integer.parseInt(...), а користуємося загальним механізмом.

Тепер додамо canConvert(...), щоб побачити «карту можливостей»:

import org.springframework.core.convert.ConversionService;

// Швидка перевірка: чи вміє ConversionService перетворювати String -> Integer?
boolean ok = conversionService.canConvert(String.class, Integer.class);
System.out.println("Підтримка String -> Integer = " + ok); // true

І ось цей true — ключовий: якщо Spring уміє перетворювати рядок на число, значить @Value може передавати в конструктор int, Integer, long і так далі без ручного коду розбору (за умови, що контейнер використовує ConversionService, про що поговоримо далі).

2. DefaultConversionService з коробки

Часто здається, що «перетворення» — це щось складне, що доводиться писати самому. Насправді DefaultConversionService уже містить пристойний набір вбудованих перетворень. Він уміє перетворювати рядки на числа, числа — в рядки, рядки — у boolean і назад, а також багато іншого. Тобто значна частина ваших parseInt(...) і Boolean.valueOf(...) може просто зникнути з коду.

Для навчального проєкту важливо не запам’ятати список усіх перетворень (це шлях до «я вивчив Spring як словник»), а зрозуміти принцип: є стандартний набір конвертерів, і його можна розширювати під свої типи. Нам зараз потрібна саме ця базова комплектація, щоб ConversionService сприймався не як «ще одна утиліта», а як загальний механізм контейнера.

Невелика таблиця, щоб картина стала відчутною:

Вихідне значення Цільовий тип Що отримуємо
"10" Integer 10
"true" Boolean true
"CSV" ReportFormat ReportFormat.CSV
"SMS" NotificationChannel NotificationChannel.SMS

Саме останній рядок особливо цікавий для ContextFlow: оскільки NotificationChannel — це enum, базовий DefaultConversionService зазвичай уже вміє перетворювати String у enum. Але є нюанс: стандартне перетворення enum-ів, як правило, чутливе до регістру, тобто "SMS" — окей, а "sms" — уже ні. І це не баг, а просто «чесна поведінка за замовчуванням»: Spring не здогадується за вас, що ви мали на увазі.

Давайте прямо в коді перевіримо, чи підтримується String -> NotificationChannel:

import org.springframework.core.convert.ConversionService;

// Перевіряємо, що перетворення String -> enum загалом зареєстровано
boolean ok = conversionService.canConvert(String.class, NotificationChannel.class);
System.out.println("Підтримка String -> NotificationChannel = " + ok); // true

Якщо у вас це true, то перша версія типізованого налаштування вже можлива: достатньо, щоб у properties лежало SMS, а не sms. Але тут же видно й межу вбудованої поведінки: людині звичніше написати sms, а контейнеру за замовчуванням цього недостатньо.

3. ConversionService у чистому Spring

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

У Spring є домовленість: якщо в контексті є бін з ім’ям conversionService і він типу ConversionService, контейнер використовує його як «головного перекладача» під час встановлення властивостей і зв’язування значень. Тобто ім’я тут — не косметика, а сигнал контейнеру: «Ось цей бін — інфраструктурний».

Додамо конфігурацію. У реальному проєкті ви, найімовірніше, просто додасте метод до вже наявного класу @Configuration, який відповідає за інфраструктуру.

package com.example.contextflow.config.core;

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() {
        // Важливо: імʼя біна має бути conversionService,
        // щоб Spring використовував його для binding (наприклад, для @Value)
        return new DefaultConversionService();
    }
}

Зверніть увагу на хитрий момент: ім’я біна береться з імені методу. Метод називається conversionService(), отже і бін буде називатися conversionService. Якщо ви назвeте метод myCoolConversionService(), то бін створиться, але контейнер може не зрозуміти, що це «головний перекладач». У результаті ви отримаєте дивний ефект: ConversionService начебто є, але @Value продовжує поводитися так, ніби його немає, або поводиться інакше, ніж ви очікували.

Тепер покажемо, що це реально працює на рівні @Value-binding. Зробимо bean налаштувань, який отримує типізоване enum-значення:

package com.example.contextflow.infrastructure.notification;

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(
            // Значення приходить рядком із properties, але в конструкторі ми хочемо доменний тип
            @Value("${contextflow.notifications.default-channel}") NotificationChannel defaultChannel) {
        this.defaultChannel = defaultChannel;
    }
}

А тепер ключовий момент: значення в contextflow.properties має бути таким, що конвертується. Для стандартного перетворення enum-ів це зазвичай означає «так само, як називається константа».

Файл: src/main/resources/contextflow.properties

# Важливо: за замовчуванням enum-перетворення чутливе до регістру
contextflow.notifications.default-channel=SMS

Якщо все зроблено правильно, Spring сам перетворить рядок "SMS" на NotificationChannel.SMS і передасть його в конструктор. Тобто бізнес-код навіть не здогадується, що десь були рядки. Він працює з типом — саме так, як ми й хотіли.

4. Програмне перетворення в коді

Коли ви вперше дізнаєтеся про ConversionService, з’являється спокуса: «О! Тоді я всюди робитиму conversionService.convert(...), і життя стане ідеальним». Не зовсім так. Ідея в тому, щоб перетворення відбувалося на межі конфігурації та binding, а бізнес-шар жив у типах.

Однак є сценарії, де програмне перетворення доречне й навіть корисне. Наприклад, ви читаєте користувацьке введення з консолі (а ContextFlow у нас орієнтований на консоль), отримуєте рядок і хочете привести його до доменного типу ще до того, як підете в бізнес-логіку. Це теж межа — просто не межа properties, а межа введення.

Припустімо, в одному зі сценаріїв ми хочемо дозволити оператору вручну вибрати канал сповіщення — суто для демонстрації. Тоді ви можете зробити невеликий адаптер, який перетворює рядок на NotificationChannel через загальний механізм, а не через valueOf(...).

package com.example.contextflow.application.scenario;

import com.example.contextflow.domain.model.NotificationChannel;
import org.springframework.core.convert.ConversionService;
import org.springframework.stereotype.Component;

@Component
public class ConsoleChannelReader {
    // Використовуємо той самий ConversionService, що й контейнер (єдині правила перетворення)
    private final ConversionService conversionService;

    public ConsoleChannelReader(ConversionService conversionService) {
        this.conversionService = conversionService;
    }

    public NotificationChannel readFrom(String raw) {
        // raw — це користувацьке введення (рядок), яке ми приводимо до доменного enum
        return conversionService.convert(raw, NotificationChannel.class);
    }
}

Це виглядає як парсинг, але принципова різниця така: ви всередині цього класу не знаєте, як саме перетворюється рядок. Ви делегуєте це єдиній інфраструктурі. Сьогодні це DefaultConversionService, завтра — той самий сервіс, але з доданим власним конвертером. Ваш код не змінюється. Змінюється інфраструктура, а це й є spring-way мислення.

І так, тут спливе той самий нюанс: якщо raw = "sms", стандартне перетворення enum-ів може впасти. Саме на цьому місці й з’являється власний конвертер: загальний механізм уже є, тепер йому потрібне зрозуміле правило для доменного типу.

5. Помилки перетворення і fail-fast

Помилка конфігурації — це не той випадок, де варто бути надто ввічливим. Якщо ви написали contextflow.report.format=CVS замість CSV, то застосунок не повинен «якось здогадатися». Він має сказати: «Друже, конфігурацію зламано». І чим раніше, тим краще.

У контейнерному світі Spring це називається fail-fast: якщо значення не можна перетворити в потрібний тип під час створення біна, контекст не підніметься. Для production це іноді боляче (сервіс не запустився), але з інженерного погляду це часто добре: помилка не сховалася, а проявилася відразу.

Ось приклад невдалого налаштування:

# Помилка: значення в нижньому регістрі (для enum за замовчуванням це часто проблема)
contextflow.notifications.default-channel=sms

І типізованої ін’єкції:

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

Під час старту ви побачите виняток, пов’язаний із неможливістю перетворення. Часто десь у причинах буде щось на кшталт IllegalArgumentException щодо enum-значення. У цей момент важливо не панікувати, а зрадіти: контейнер упіймав проблему там, де їй і місце — на межі конфігурації, а не «після 15 хвилин роботи сценарію».

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

import org.springframework.core.convert.ConversionFailedException;

try {
    // Намагаємося перетворити введення користувача на доменний enum
    return conversionService.convert(raw, NotificationChannel.class);
} catch (ConversionFailedException ex) {
    // Користувачеві краще віддати зрозуміле повідомлення, а не стек-трейс Spring
    throw new IllegalArgumentException("Канал має бути EMAIL, SMS або CONSOLE: " + raw);
}

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

6. Мініпрактика в ContextFlow

Зараз хочеться не просто повірити на слово, а власноруч переконатися, що ConversionService став частиною контейнера і реально впливає на поведінку @Value. Це хороший момент для маленького, але показового експерименту: ми додамо conversionService як інфраструктурний бін, потім зробимо типізоване налаштування, а потім спеціально зламаємо значення в properties і подивимося, де саме падає застосунок.

Спочатку додайте ConversionConfig (або метод conversionService() до наявного конфігураційного класу). Переконайтеся, що контекст справді піднімає цей бін і що він доступний для DI (можете тимчасово впровадити ConversionService у будь-який @Component і вивести canConvert(String.class, Integer.class)).

Потім виберіть один конкретний ключ, який вам зрозумілий. Наприклад, contextflow.report.format. Створіть enum:

package com.example.contextflow.domain.model;

public enum ReportFormat {
    TEXT, CSV
}

І bean налаштувань:

package com.example.contextflow.application.reporting;

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

@Component
public class ReportingSettings {
    private final ReportFormat format;

    public ReportingSettings(@Value("${contextflow.report.format}") ReportFormat format) {
        this.format = format;
    }
}

Тепер установіть значення в properties так, щоб воно збігалося з константою enum:

contextflow.report.format=CSV

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

На цьому місці вже добре видно, де закінчується вбудоване перетворення і навіщо додавати власне правило: csv, CSV, Csv та інші людські варіації мають зводитися до одного доменного типу передбачувано й централізовано.

7. Типові помилки під час роботи з ConversionService

Помилка №1: створити DefaultConversionService «десь у коді» і думати, що контейнер сам почне ним користуватися.
Іноді роблять так: всередині якогось @Component пишуть new DefaultConversionService() і використовують його вручну. Це працює локально, але ніяк не впливає на @Value-binding, тому що контейнер про цього «перекладача» не знає. Якщо ви хочете контейнерне перетворення, ConversionService має бути біном контексту, і бажано з правильним ім’ям.

Помилка №2: зареєструвати бін, але назвати його не conversionService, а якось надто творчо.
Творчість — це чудово, але у Spring є домовленості. Якщо ви назвали бін myConversionService, ви можете впроваджувати його вручну, але контейнер може не розпізнати його як «головний conversion service» і не використати під час binding. Якщо хочете і творчість, і домовленість, використовуйте @Bean(name = "conversionService") і називайте метод хоч bobTheConverter() — контейнеру важливіше ім’я біна.

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

Помилка №4: очікувати від DefaultConversionService, що він буде «розумним» і сам здогадається про регістр, пробіли та псевдоніми.
Вбудоване перетворення часто суворе. Це правильно: суворі правила простіше супроводжувати. Якщо вам потрібна більш «людська» поведінка (наприклад, приймати sms, Sms, SMS), це робиться явно — власним конвертером. І це якраз наступний крок, а не причина ображатися на Spring.

Помилка №5: намагатися «полагодити» неправильну конфігурацію тихим дефолтом прямо в conversion-шарі.
Іноді пишуть: «якщо рядок невідомий — поверну CONSOLE і все працюватиме». Так, застосунок запуститься. Але він запуститься з неправильною конфігурацією, і ви потім довго шукатимете, чому сповіщення раптом пішли не туди. Для properties краще fail-fast: неправильне значення має ламати старт, щоб проблему було виявлено відразу.

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