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: неправильне значення має ламати старт, щоб проблему було виявлено відразу.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ