1. @ConfigurationProperties і application.yml
Коли проєкт невеликий, application.yml здається кінцевою точкою: «Ну ось же налаштування — що ще потрібно?». Але проблема з’являється трохи пізніше — коли ці значення починають використовуватися в кількох місцях. У коді виникають «магічні рядки», дублікати, і кожен клас починає самостійно читати конфіг. @ConfigurationProperties — це спосіб зробити налаштування частиною зрозумілої моделі застосунку, як DTO, тільки для конфігурації.
Уявіть, що ви зробили сховище вкладень і в трьох місцях вам потрібен шлях ./data/attachments. Спершу ви написали Path.of("./data/attachments"), потім — «ну гаразд, скопіюю», а втретє — «ой, тут уже інший шлях, бо я тестував». Так народжуються баги рівня «у мене не працює на машині колеги», а колега відповідає щось на кшталт «ну то не запускай, якщо не працює». І це, звісно, не той стиль спілкування, який ми хочемо заохочувати.
Тут і виникає ідея: якщо є блок налаштувань app.attachments, чому б не подати його як один Java-об’єкт і не передавати цей об’єкт у ті частини застосунку, яким він справді потрібен? Тоді в нас буде одна точка істини, а в розробника — менше підстав влаштовувати археологічні розкопки в репозиторії.
2. Прив’язка властивостей: префікс, relaxed names і типи
Досі YAML для нас був чимось на кшталт файла з параметрами. Але Spring Boot ставиться до нього серйозніше: він уміє читати значення з різних джерел, зводити їх в одну конфігурацію і прив’язувати (bind) до Java-типів. Якщо говорити грубо, це «Jackson для налаштувань»: тільки замість JSON у нас властивості, а замість ObjectMapper — механізм конфігураційного прив’язування Spring Boot.
Головна ідея така: ви описуєте клас-форму налаштувань, а Boot заповнює його значеннями з конфігурації під час старту застосунку. Потім ви впроваджуєте цей об’єкт як звичайний bean, і він стає зручним, типізованим джерелом налаштувань для сервісів та інфраструктури.
Щоб це стало зрозуміліше, розберімо три базові елементи: префікс, правила іменування та типи.
Префікс і межі app.*
Префікс у @ConfigurationProperties — це буквально «адреса» гілки в YAML. Він відповідає на запитання: яку частину дерева налаштувань ми читаємо зараз? У нашому проєкті ми домовилися: усе, що стосується нашого застосунку, лежить під app.*. Отже, вкладення логічно тримати під app.attachments.* — так видно, що це наше налаштування, а не налаштування Spring.
Це важливо не лише з естетичних міркувань. Коли налаштування лежать під spring.*, ви ризикуєте самі себе обдурити: за тиждень забудете, що це ваше, і почнете шукати в Google spring.attachments.storage-dir, ніби це офіційний ключ. У Spring і так достатньо ключів; не треба додавати туди свої. Це як підписувати власні конспекти іменем чужого студента: іноді смішно, але частіше просто незручно.
У нашому випадку префікс буде таким:
// Читаємо гілку app.attachments.* з application.yml
@ConfigurationProperties(prefix = "app.attachments")
І це означає: «заповни цей клас усім, що лежить у YAML за шляхом app.attachments.*».
Relaxed binding і імена ключів
У YAML ми здебільшого пишемо ключі через дефіс: storage-dir, max-file-size, max-request-size. У Java, навпаки, прийнято camelCase: storageDir, maxFileSize. І Spring Boot уміє це примирити: він підтримує так званий relaxed binding — м’які правила зіставлення імен.
Якщо сказати простіше, Boot розуміє, що ці варіанти — «приблизно одне й те саме»:
| У конфігурації | У Java | Чому це працює |
|---|---|---|
| storage-dir | storageDir | дефіс перетворюється на межу слова |
| storage_dir | storageDir | підкреслення також вважається межею слова |
| storageDir | storageDir | збігається напряму |
Це означає: ми можемо писати YAML читабельно для людини, а Java-код — читабельно для Java-розробника, і вони будуть дружити. Але в relaxed binding є і зворотний бік: якщо ви почнете змішувати стилі в одному файлі («тут дефіс, тут camelCase, а тут раптом підкреслення»), воно, можливо, і спрацює, але людям читати буде болючіше. А коли читати боляче — легше помилитися.
Типи і конвертація значень
Спокуса зберігати все рядками дуже сильна. Рядок же універсальний: у нього можна запхати і шлях, і число, і розмір, і час. Проблема в тому, що універсальним він лишається лише до першого бага, бо будь-яка помилка в рядку стає помітною тільки під час виконання, та й то часто в найнесподіваніший момент.
Spring Boot уміє прив’язувати властивості не лише до String, а й до багатьох корисних типів. Наприклад, чисел (int, long), булевих значень (boolean), колекцій (List<String>), а також деяких прикладних типів на кшталт Duration або DataSize (залежно від налаштувань і того, що ви використовуєте). У межах цієї лекції ми не будемо перетворювати конфігурацію на окрему дисципліну, але запам’ятаємо принцип: якщо значення за змістом не рядок, не варто вперто тримати його рядком.
Для storage-dir ми цілком можемо почати з рядка, тому що це хороший старт для новачка, а вже всередині storage-шару обережно перетворити його на Path. Важливіше зараз інше: ми хочемо, щоб шлях був не «рядком посеред класу», а значенням, яке прийшло зі зрозумілого конфігураційного об’єкта.
3. Реєстрація класу налаштувань
Навіть якщо ви написали красивий клас із @ConfigurationProperties, Spring сам по собі не зобов’язаний його «побачити» і створити як bean. І тут виникає запитання: як зробити так, щоб цей клас узагалі з’явився в ApplicationContext? У Spring Boot для цього є кілька шляхів, і важливо обрати один зрозумілий, а не плодити зоопарк підходів.
Найзручніший і найпростіший для навчального проєкту шлях — увімкнути сканування конфігураційних класів через @ConfigurationPropertiesScan. Ви ставите анотацію на ваш @SpringBootApplication, і Boot сам знайде всі класи з @ConfigurationProperties у зоні сканування. Це схоже на те, як Spring знаходить @Service, тільки для конфігурації.
Другий шлях — @EnableConfigurationProperties(AttachmentProperties.class). Він більш явний: ви прямо перелічуєте, які класи потрібно зареєструвати. Це нормально, але в невеликому навчальному проєкті може швидко перетворитися на список усього на світі в одному місці.
Третій шлях — поставити на клас @Component. Так теж можна, але зазвичай це вважається менш вдалим стилем, тому що конфігураційні класи починають змішуватися зі «звичайними компонентами», а ще з’являється відчуття, ніби це якийсь сервіс, хоча це просто контейнер даних.
У Task Tracker API ми обираємо @ConfigurationPropertiesScan як основний і зрозумілий шлях: він масштабується й не вимагає ручної реєстрації кожного нового блоку налаштувань.
4. AttachmentProperties у Task Tracker API
Зараз у нас є конкретна мета: налаштування вкладень мають жити в YAML під app.attachments.*, а в коді мають бути представлені типізованим об’єктом AttachmentProperties, який можна впроваджувати в реалізацію storage. Ми зробимо це у три кроки: оформимо YAML-гілку, створимо Java-клас (або record) і підключимо сканування, щоб Spring Boot зареєстрував bean.
По суті, це «мініверсія» того самого шляху, який ми вже проходили з DTO: спочатку описуємо форму даних, потім даємо механізму (Jackson там, Boot binder тут) заповнювати її автоматично, а далі використовуємо цей об’єкт у потрібному шарі.
YAML: один ключ — одне місце істини
Почнімо з конфігурації. В application.yml ми вже домовилися тримати налаштування застосунку окремо від налаштувань Spring. Тому блок вкладень матиме такий вигляд:
app:
attachments:
# Відносний шлях зазвичай переносніший між машинами та CI
storage-dir: ./data/attachments
Тут важлива дрібниця, яка згодом економить години: відносний шлях ./data/attachments зазвичай переносніший, ніж абсолютний /tmp/... або C:\.... Абсолютні шляхи добрі, коли ви точно розумієте, де і як запускаєте застосунок. Але в навчальному проєкті ми хочемо, щоб «скопіював — запустив — працює», а не «скопіював — переписав половину шляхів — запустив».
І ще момент: ми навмисно називаємо ключ storage-dir, а не щось на кшталт path або root. У конфігурації дуже цінується самодокументованість. Через місяць, відкривши YAML, ви повинні розуміти зміст ключа без телепатії.
Java: record із @ConfigurationProperties і валідацією
Тепер створімо клас налаштувань. За структурою проєкту він має жити в пакеті config, наприклад:
src/main/java/com/example/tasktracker/config/AttachmentProperties.java
Для компактності й «контейнерності» нам зручно зробити його record. Record добре підходить, коли об’єкт — це просто набір даних, і ми не хочемо там бізнес-логіки (а в налаштуваннях бізнес-логіки точно бути не повинно).
package com.example.tasktracker.config;
import jakarta.validation.constraints.NotBlank;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.validation.annotation.Validated;
@Validated // Увімкнули валідацію конфігурації на старті застосунку
@ConfigurationProperties(prefix = "app.attachments") // Читаємо app.attachments.* з application.yml
public record AttachmentProperties(
@NotBlank // Без цього шляху storage не зможе працювати коректно
String storageDir
) {
}
Тут одразу кілька важливих речей, і кожна — практична:
— prefix = "app.attachments" говорить, звідки брати значення. Поле storageDir буде заповнюватися з app.attachments.storage-dir.
— @Validated вмикає валідацію цього об’єкта під час старту застосунку (за умови, що у вас підключений validation starter, а він у нас уже є в базовому наборі курсу).
— @NotBlank робить властивість обов’язковою: якщо вона порожня або не задана, застосунок упаде під час старту. І це добре. Серйозно. Краще побачити помилку одразу, ніж отримати «раптово не зберігаються вкладення» після кількох кліків у API.
Boot уміє і звичайний клас із полем, геттером та сеттером, але для Task Tracker API надалі тримаємо одну канонічну форму — record: менше шуму, більше сенсу, і storage залежатиме саме від нього.
Використання в LocalAttachmentStorage
Тепер найприємніше: ми перестаємо вигадувати шлях усередині storage-реалізації і починаємо отримувати його ззовні.
Припустімо, у нас є локальна реалізація storage:
src/main/java/com/example/tasktracker/infrastructure/storage/LocalAttachmentStorage.java
Ось мінімальна версія, яка показує ідею:
import com.example.tasktracker.config.AttachmentProperties;
import java.nio.file.Path;
import org.springframework.stereotype.Service;
@Service
public class LocalAttachmentStorage {
private final Path root; // Коренева директорія, куди складаємо всі вкладення
public LocalAttachmentStorage(AttachmentProperties props) {
// Беремо шлях не з "думки класу", а з типізованої конфігурації
this.root = Path.of(props.storageDir());
}
}
Тут важлива архітектурна перемога: LocalAttachmentStorage більше не знає, «який шлях правильний». Він знає лише, що шлях приходить з AttachmentProperties. Тобто storage стає придатним до повторного використання і залежним від оточення, а не «сам собі режисер».
Якщо ви раніше всередині LocalAttachmentStorage писали щось на кшталт:
// Погана ідея: хардкодимо шлях прямо в класі й потім шукаємо такі рядки по всьому проєкту
private final Path root = Path.of("./data/attachments");
то тепер цей хардкод можна сміливо прибрати. Нехай шлях живе там, де йому й належить: у зовнішній конфігурації.
Зазвичай storage ще й створює директорію під час старту. Тоді всередині класу може бути такий фрагмент (показую лише метод, щоб не роздувати приклад):
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.file.Files;
public void initStorageDir() {
try {
// createDirectories безпечний: створює весь ланцюжок і не падає, якщо папка вже існує
Files.createDirectories(root);
} catch (IOException e) {
// Обгортаємо checked-виняток, щоб не тягнути IOException крізь усі шари
throw new UncheckedIOException(e);
}
}
Як саме ви викликаєте initStorageDir() (наприклад, через @PostConstruct) — це вже деталь реалізації. Головне зараз: root береться з типізованого конфігураційного об’єкта, а не з внутрішнього уявлення класу.
5. Валідація конфігурації
Коли ми говоримо «валідація», у розробника часто одразу виникає картинка: @Valid на @RequestBody, помилки полів, 400 Bad Request. Але в конфігурації інша природа. Конфіг — це не користувацьке введення, а введення з оточення. І помилки конфігурації зазвичай краще ловити під час старту застосунку, бо інакше ви отримаєте дивні збої вже в процесі роботи: файл не зберігся, директорія не створилася, у шляху немає прав.
Валідація конфігурації — це про те, щоб застосунок або стартував у коректному стані, або чесно сказав: «я не можу стартувати, бо мені бракує налаштувань». Це сильно спрощує підтримку. Особливо коли ви запускаєте застосунок не лише у себе, а й у колеги, на CI та десь ще. Для storage-dir це особливо важливо: без нього файлове сховище не може працювати чесно, тому в цьому проєкті ми вважаємо цю властивість обов’язковою.
Якщо властивість не задано
Припустімо, ви забули додати в YAML app.attachments.storage-dir. Що станеться? Без валідації ваш storageDir стане null, і ви отримаєте помилку пізніше, коли будете викликати Path.of(null). Це поганий сценарій: помилка вилізе там, де ви взагалі-то очікуєте роботу з файлом, а не «чому в мене null».
З @Validated і @NotBlank відбувається більш цивілізована річ: застосунок падає під час старту, тому що конфігурація некоректна. Тобто ви не отримаєте «напівпрацюючий» сервіс, який на читання відповідає, а на завантаження файлів — падає.
Це схоже на перевірку DTO: краще одразу повернути 400, ніж прийняти сміття і потім десь у сервісі «випадково» отримати NPE. Тільки тут замість 400 у нас помилка старту, і це нормально.
Межа між конфігом і request validation
Є тонка, але важлива межа. Валідація DTO запиту відповідає за те, що клієнт надіслав коректний запит. Якщо клієнт надіслав поганий JSON або не пройшов @Size, ми формуємо ProblemDetail і повертаємо 400.
Валідація конфігурації відповідає за те, що застосунок налаштований коректно. Клієнт тут взагалі ні до чого. Якщо конфіг зламаний, застосунок не повинен намагатися якось жити далі й потім повертати клієнту 500 на завантаження. Йому краще взагалі не стартувати, інакше ви отримаєте дуже дивний API: частина ендпоїнтів відповідає, частина — падає, і незрозуміло, що з цим робити.
Тому думка проста: валідація конфігурації — це страховка для розробника й середовища виконання, а не для клієнта API. І це окремий шар дисципліни, який робить сервіс передбачуваним.
6. @Value і @ConfigurationProperties
У багатьох новачків перша реакція така: «Навіщо мені @ConfigurationProperties, якщо можна написати @Value("${...}") і все?». І справді: @Value існує, працює і іноді цілком доречний. Проблема в тому, що @Value добре почувається в малих дозах, а у великих перетворює проєкт на музей рядкових констант.
Ось типовий приклад із @Value:
import org.springframework.beans.factory.annotation.Value;
// Зазвичай це поле знаходиться всередині класу @Component/@Service/@Configuration
@Value("${app.attachments.storage-dir}")
private String storageDir;
Для одного налаштування це нормально. Але щойно у вас з’являється кілька пов’язаних налаштувань (storage-dir, можливо, якийсь прапорець, допустимі типи тощо), @Value починає дробити конфігурацію по всьому проєкту. І замість «одного об’єкта налаштувань» у вас виходить «полювання за рядками» по всіх класах.
Порівняймо підходи в табличці:
| Критерій | @Value | @ConfigurationProperties |
|---|---|---|
| Зручно для однієї властивості | Так | Можна, але зазвичай надлишково |
| Групування пов’язаних налаштувань | Погано | Чудово (один об’єкт) |
| Типобезпечність і конвертація | Обмежено | Добре підтримується |
| Валідація конфігурації | Неочевидно | Природно через @Validated |
| Читабельність коду | Швидко погіршується зі зростанням | Зазвичай покращується зі зростанням |
Практичне правило для нашого курсу: якщо у вас з’являється шматок налаштувань, який хочеться назвати як сутність, наприклад attachments, то це майже завжди кандидат на @ConfigurationProperties. Для Task Tracker API це саме наш випадок: вкладення — окрема підсистема, і мати AttachmentProperties як один об’єкт — логічно.
7. Типові помилки під час роботи з @ConfigurationProperties
Помилки тут зазвичай не складні, просто прикрі. У більшості випадків це «не той префікс», «не там лежить клас», «забули увімкнути сканування», і ви годину дивитеся на null, ніби він має сам злякатися та піти.
Помилка №1: забули зареєструвати клас налаштувань (немає @ConfigurationPropertiesScan).
Ви написали AttachmentProperties, вказали префікс, навіть YAML зробили красивим — і все одно bean не створюється. У результаті Spring не може впровадити AttachmentProperties у LocalAttachmentStorage, і застосунок падає під час старту з помилкою про те, що bean потрібного типу не знайдено. Лікується просто: додайте сканування конфігураційних властивостей в основний клас застосунку.
package com.example.tasktracker;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
@SpringBootApplication
@ConfigurationPropertiesScan // Увімкнули пошук усіх @ConfigurationProperties у межах пакетного сканування
public class TaskTrackerApplication {
public static void main(String[] args) {
// Запускаємо застосунок Spring Boot і піднімаємо ApplicationContext
SpringApplication.run(TaskTrackerApplication.class, args);
}
}
Помилка №2: префікс не збігається зі структурою YAML.
Це класика: у YAML у вас app.attachments.storage-dir, а в коді ви написали prefix = "app.attachment" або prefix = "app.attachment-storage". У результаті binder нічого не знаходить, поле залишається null, і ви отримуєте падіння десь далі. Хороший спосіб не ловити це регулярно — проговорювати собі повний шлях: app.attachments.storage-dir, і лише потім писати prefix app.attachments.
Помилка №3: намагаються зберігати все в одному гігантському класі налаштувань.
Іноді хочеться зробити AppProperties, покласти туди взагалі все і радіти. За тиждень це перетворюється на «файл на 200 рядків», де важко зрозуміти, що до чого належить. Для навчального проєкту краще тримати властивості за функціональними блоками: AttachmentProperties, можливо, згодом ще щось. Це підвищує читабельність і робить залежності сервісів точнішими: storage залежить лише від attachment-налаштувань, а не від усього застосунку.
Помилка №4: читають налаштування прямо в контролері.
Конфігурація — це не частина web-контракту. Якщо контролер починає читати AttachmentProperties і вирішувати, куди зберігати файли, він стає важчим і починає знати занадто багато про інфраструктуру. У нашому проєкті правильніше, щоб контролер просто приймав запит і делегував роботу сервісу/storage-шару, а вже storage читав налаштування. Так межі шарів залишаються чесними.
Помилка №5: плутають валідацію конфігурації та валідацію вхідного запиту.
Буває спокуса зробити «єдиний валідатор усього»: і DTO, і конфіга. Але семантика різна. Невалідний запит — це 400 і ProblemDetail. Невалідна конфігурація — це помилка старту застосунку. Якщо змішати ці два світи, ви отримаєте хаос: наприклад, endpoint для завантаження іноді відповідатиме «INVALID_INPUT», хоча проблема в тому, що сервер налаштований неправильно.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ