1. Складні типи в конфігурації
Наразі CatalogProperties уже може жити в контексті як звичайний бін. Але якщо binder уміє зв’язувати лише рядки, числа та прапорці, catalog-service усе одно не перейде на типізовану конфігурацію: у каталозі є список курсів, вкладена ціна, enum, дати та службові типи на кшталт інтервалів і розмірів.
Тому зараз дивимося не на набір розрізнених трюків, а на ті форми зв’язування, без яких повний CatalogProperties просто не збереться.
Коли ви тільки починаєте, здається, що конфігурація має бути дуже простою: пара рядків, пара чисел і максимум один булевий прапорець «увімкнено/вимкнено». Але реальне життя швидко приносить «а давайте список курсів із конфігурації», «а давайте ціну як об’єкт», «а давайте інтервал оновлення в секундах» і ще «а давайте ліміт розміру в мегабайтах». І ось тут у новачка зазвичай з’являється бажання… розбирати все вручну.
Щоб трохи заспокоїтися: Spring Boot спеціально зробив @ConfigurationProperties таким, щоб ви могли описувати конфігурацію типами, а не рядковими пазлами. Це не «преміум-можливість для сеньйорів», а спосіб уникнути побутового болю рівня «хтось написав 10MB, а наш код очікував 10Mb і все впало» (або, що ще гірше, не впало, а тихо почало працювати неправильно).
Корисно уявляти просту схему того, що відбувається під час запуску:
flowchart TD A["application.yaml + profile-файли + imports"] --> B["Environment
(підсумкові властивості)"] B --> C["Біндер @ConfigurationProperties
(конвертація типів)"] C --> D["CatalogProperties / інші properties-класи"] D --> E["Ваші біни
(service, runner, controller)"]
Отже, складність тут не в тому, щоб зробити YAML хитрішим. Складність якраз у тому, що ви не хочете переносити YAML-структуру в голову розробника. Ви хочете перенести її в Java-класи, де IDE допоможе автодоповненням, а компілятор — типами.
2. Вкладені об’єкти: коли одне значення — це структура
Вкладені об’єкти в конфігурації з’являються не через любов до краси YAML (хоча YAML теж старається), а тому, що деякі речі логічно є зв’язаним набором полів. Класичний приклад — ціна: майже ніколи вона не зводиться до «просто числа», тому що поруч завжди ховається валюта. І якщо ви зберігаєте все як один рядок, то самі собі додаєте роботи з розбором і перевірками.
Уявімо шматок конфіга курсу в catalog-data.yaml (або прямо в application.yaml, якщо ви ще не винесли дані — не страшно, структура однакова):
app:
catalog:
courses:
- slug: "spring-boot"
title: "Spring Boot"
price:
amount: 19900
currency: "GBP"
Замість того щоб зберігати price: "19900 GBP" і парсити це через split(" ") (а потім страждати від подвійних пробілів, «£» і чиєїсь фантазії), ми описуємо вкладений об’єкт.
Мінімальний properties-клас для ціни:
package com.example.catalogservice.config;
public class MoneyProperties {
// Сума в мінімальних грошових одиницях (наприклад, копійках),
// щоб не працювати з double/float у конфігурації.
private long amount;
// Валюта як рядок (наприклад, "GBP").
// За бажанням це теж можна зробити enum, але рядок — нормальний старт.
private String currency;
// getters and setters
}
І елемент списку курсів, який використовує вкладений об’єкт:
package com.example.catalogservice.config;
public class CourseItemProperties {
// Унікальний ідентифікатор курсу в URL або каталозі
private String slug;
// Зрозуміла для людини назва курсу
private String title;
// Вкладений об’єкт: біндер заповнить його з price.amount і price.currency
private MoneyProperties price;
// getters and setters
}
Тут важливо зрозуміти механіку: binder бачить, що поле price — це окремий об’єкт, і намагається заповнити його з підшляху price.amount і price.currency. Вам не потрібно писати окремі @Value("${...}") для кожного вкладеного поля. Ви описали форму даних — і Boot зв’язує її сам.
Щоб переконатися, що це справді працює, можна (на рівні раннера під час запуску) вивести щось просте. Так, System.out.println ми згодом замінимо логами (це буде в модулі про логування), але зараз нам важлива механіка:
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.stereotype.Component;
@Component
public class PriceDebugRunner implements ApplicationRunner {
// Клас із properties уже має бути зареєстрований як бін (через scan/enable)
private final CatalogProperties properties;
public PriceDebugRunner(CatalogProperties properties) {
// Впроваджуємо конфігурацію як типізований об’єкт, а не як набір рядків
this.properties = properties;
}
@Override
public void run(ApplicationArguments args) {
// Для простоти беремо перший курс (у реальному коді краще спершу перевірити, чи список не порожній)
var first = properties.getCourses().get(0);
// Перевіряємо, що вкладена структура теж зв’язалася коректно
System.out.println(first.getPrice().getCurrency()); // GBP
}
}
Сенс вкладеності ще й у тому, що вона робить конфігурацію читабельною: price.currency очима читається як «валюта ціни», а не як загадковий суфікс у рядку.
3. Списки (List): конфігурація як джерело даних
Коли у вашій конфігурації з’являється повторювана сутність (курси, правила, ліміти, пресети), ідея «запхнути це в один рядок і розібрати потім» чомусь приходить дуже швидко. Мабуть, це спадок роботи з .properties і вічного формату a=b,c=d,e=f. Але з YAML і @ConfigurationProperties можна жити значно щасливіше.
Для списку курсів YAML виглядає природно: courses: і далі елементи з дефісами.
app:
catalog:
courses:
- slug: "spring-boot"
title: "Spring Boot"
track: "SPRING"
- slug: "spring-core"
title: "Spring Core"
track: "SPRING"
У CatalogProperties це відображається полем List<CourseItemProperties>.
import java.util.ArrayList;
import java.util.List;
import org.springframework.boot.context.properties.ConfigurationProperties;
@ConfigurationProperties("app.catalog")
public class CatalogProperties {
// Список курсів із app.catalog.courses (YAML із дефісами)
// Ініціалізація захищає від NPE, якщо courses не задано в конфігурації.
private List<CourseItemProperties> courses = new ArrayList<>();
public List<CourseItemProperties> getCourses() {
return courses;
}
public void setCourses(List<CourseItemProperties> courses) {
// Біндер присвоїть сюди новий список, сформований із конфігурації
this.courses = courses;
}
}
Тут є маленький, але важливий момент: ми ініціалізуємо список порожнім ArrayList. Це не «фіча дня про defaults», а просто побутовий захист від NullPointerException, щоб код, який робить getCourses().size(), не падав, якщо конфігурація з якоїсь причини порожня.
Далі ви вже можете працювати з courses як із нормальною колекцією Java-об’єктів. Наприклад, вивести кількість курсів:
import org.springframework.stereotype.Component;
@Component
public class CoursesCountPrinter {
// Знову ж таки: тут не рядки й не @Value, а вже готова структура
private final CatalogProperties properties;
public CoursesCountPrinter(CatalogProperties properties) {
this.properties = properties;
}
public int coursesCount() {
// Якщо courses не задано, повернеться 0 (завдяки ініціалізації списку)
return properties.getCourses().size();
}
}
І зверніть увагу, що в прикладному коді ми взагалі не думаємо про те, звідки дані взялися: з application.yaml, з catalog-data.yaml через spring.config.import, із env vars або із зовнішньої локації. Це вже турбота конфігураційного шару, а не бізнес-коду.
Якщо трохи узагальнити, то List у конфігурації — це сигнал: «конфіг містить набір однотипних об’єктів». І це рівно той випадок, коли типізована конфігурація показує свою головну цінність: ви не «парсите дані», ви «отримуєте структуру».
4. enum у конфігурації: менше рядків — менше випадкового болю
У конфігурації дуже легко почати зберігати все як рядки. На старті здається, що це гнучко: сьогодні пишемо "SPRING", завтра "spring", післязавтра "Spring"… а потім раптово з’ясовується, що три різні значення означають одне й те саме, але порівняння equalsIgnoreCase розповзлися по проєкту. І так, десь обов’язково з’явиться рядок "Sprnig" (це не описка, а майбутня легенда вашого проєкту).
Якщо значення береться з обмеженого списку, це майже завжди кандидат на enum. У нашому catalog-service це ідеально лягає на CourseTrack і CourseLevel.
Приклад enum у домені (він у нас уже є за ТЗ проєкту):
package com.example.catalogservice.catalog.domain;
public enum CourseTrack {
// Явний список допустимих значень.
// Якщо в конфігу виявиться "SPRNG", застосунок упаде під час запуску — і це добре.
SPRING, JAVA_BACKEND, DATA, INFRA, ADVANCED
}
І тепер у CourseItemProperties ви можете використовувати цей enum прямо як тип поля:
import com.example.catalogservice.catalog.domain.CourseTrack;
public class CourseItemProperties {
private String slug;
// Boot спробує сконвертувати рядок із YAML в enum-константу
private CourseTrack track;
// getters and setters
}
У YAML це виглядає так:
track: "SPRING"
Boot під час зв’язування спробує перетворити рядок в enum. І тут приємна новина: у properties binding Boot зазвичай досить поблажливий до конвертації (включно з м’якшим ставленням до регістру та стилю запису). Але в навчальному проєкті я раджу не зловживати «він сам здогадається», а писати значення так, щоб вони збігалися з enum-константами: це простіше читати, простіше шукати по проєкту і простіше пояснювати.
Найважливіше: якщо в конфігу виявиться значення, якого немає в enum (наприклад, track: "SPRNG"), застосунок, найімовірніше, не зможе запуститися, тому що binder не зможе виконати конвертацію. І це хороша поведінка: краще побачити проблему на старті, ніж отримати «порожній список курсів» і шукати баг дві години.
5. Карти (Map): словники та відображення без ручних форматів
Map у конфігурації потрібна, коли у вас не «список однотипних об’єктів», а саме словник: ключ → значення. Це зручно для іменованих відображень, перейменувань, простих довідників і прапорців, які залежать від імені.
Наприклад, ви хочете мати читабельні відображення треків у UI/logs/landing page, але не хочете жорстко вшивати їх у код. Тоді можна зробити карту trackDisplayNames.
YAML:
app:
catalog:
track-display-names:
SPRING: "Spring"
JAVA_BACKEND: "Java Backend"
DATA: "Data"
Java-поле:
import java.util.HashMap;
import java.util.Map;
import com.example.catalogservice.catalog.domain.CourseTrack;
public class CatalogProperties {
// Ключі — enum, тому YAML-ключі теж мають відповідати enum-константам
// (або принаймні успішно конвертуватися в них).
private Map<CourseTrack, String> trackDisplayNames = new HashMap<>();
// getters and setters
}
Тут цікавий момент: ключ карти — CourseTrack, тобто enum. Boot пробуватиме сконвертувати YAML-ключі в enum. Виходить дуже приємний ефект: ви отримуєте типізовані ключі, а не рядкову самодіяльність.
Використання, наприклад, у коді генерації «людських» рядків:
public String displayNameFor(CourseTrack track) {
// Якщо мапа не містить відображення — використовуємо ім’я enum як запасний варіант
return properties.getTrackDisplayNames().getOrDefault(track, track.name());
}
Другий поширений варіант Map — звичайна Map<String, String>, коли ключі справді довільні. Наприклад, якби ми хотіли зберігати лейбли для різних «бейджів» на цільовій сторінці:
app:
catalog:
badges:
featured: "Рекомендуємо"
new: "Новинка"
Java:
import java.util.HashMap;
import java.util.Map;
public class CatalogProperties {
// Довільні рядкові ключі (наприклад, "featured", "new")
private Map<String, String> badges = new HashMap<>();
// getters and setters
}
Важлива думка: Map майже завжди краща, ніж рядок у форматі key=value,key=value. Тому що рядок — це запрошення до помилок, до екранування, до пробілів і до того самого split(","), який працює рівно до першої коми у значенні.
6. Duration і DataSize у конфігурації
Duration і DataSize — це два типи, які особливо добре показують філософію Boot: конфігурація має бути читабельною для людини, а код має працювати з типами, а не з «сирим рядком».
Duration: “30s”, “5m”, “2h” замість long мілісекунд
Уявімо, що в нас є якась періодична поведінка (навіть якщо ми поки не робимо реальний scheduler — нам зараз важливе зв’язування). Ми хочемо написати:
app:
catalog:
refresh-period: 30s
І отримати в Java вже Duration, щоб працювати з нею типобезпечно.
import java.time.Duration;
import org.springframework.boot.context.properties.ConfigurationProperties;
@ConfigurationProperties("app.catalog")
public class CatalogProperties {
// Значення задається в YAML як 30s/5m/2h і конвертується в java.time.Duration
// Якщо не задано — буде null (тому можна або задати значення за замовчуванням, або перевіряти).
private Duration refreshPeriod;
public Duration getRefreshPeriod() {
return refreshPeriod;
}
public void setRefreshPeriod(Duration refreshPeriod) {
this.refreshPeriod = refreshPeriod;
}
}
А далі в коді:
Duration d = properties.getRefreshPeriod();
System.out.println(d.toSeconds()); // 30
Важливо: для Duration у Boot підтримуються людиночитабельні суфікси на кшталт ms, s, m, h, d. Також часто підтримується формат ISO-8601 на кшталт PT30S. Але в навчальному проєкті краще дотримуватися простого і читабельного стилю: 30s, 5m, 2h. Це зручно і вам, і тим, хто читатиме конфіг після вас (тобто вам же, але через місяць, коли ви забудете, що означало число 30000).
DataSize: “64KB”, “10MB” замість незрозумілих чисел
DataSize — тип зі Spring (org.springframework.util.unit.DataSize), який зручно використовувати для будь-яких лімітів «за розміром даних». Так, наш catalog-service не завантажує файли і не працює з multipart, але ліміти розміру можуть траплятися будь-де: наприклад, ми хочемо обмежити «preview» або розмір якоїсь частини даних, яку вважаємо розумною.
YAML:
app:
catalog:
preview-payload-limit: 64KB
Java:
import org.springframework.util.unit.DataSize;
public class CatalogProperties {
// Значення задається як 64KB/10MB і конвертується в DataSize
private DataSize previewPayloadLimit;
public DataSize getPreviewPayloadLimit() {
return previewPayloadLimit;
}
public void setPreviewPayloadLimit(DataSize previewPayloadLimit) {
this.previewPayloadLimit = previewPayloadLimit;
}
}
Використання:
DataSize limit = properties.getPreviewPayloadLimit();
System.out.println(limit.toBytes()); // 65536
Підтримувані суфікси зазвичай виглядають як B, KB, MB, GB (і далі за потреби). Важливо писати одиниці явно. Якщо ви залишите просто число, ви змусите всіх гадати: це байти? кілобайти? мегабайти? чи «це кількість символів, але ми чомусь назвали це size»?
І ось тут з’являється маленький методичний кайф: замість того щоб парсити рядок "64KB" вручну, ви одразу працюєте з DataSize. IDE не дає вам випадково скласти кілобайти із секундами, а код починає виглядати як інженерний текст, а не як «магічний калькулятор».
7. Relaxed binding: зіставлення імен
Саме тут зазвичай виникає питання: «Зачекайте, але в YAML у нас kebab-case, а в Java поля camelCase. Це взагалі як зв’язується?» І ось тут Spring Boot робить вам подарунок: у нього є концепція relaxed binding — акуратного зіставлення імен, яке розуміє, що max-featured-count, max_featured_count і maxFeaturedCount — це по суті одне й те саме ім’я в різних стилях.
Наприклад, такий YAML:
app:
catalog:
max-featured-count: 4
maintenance-mode: false
startup-report-enabled: true
Спокійно зв’язується в такі поля:
public class CatalogProperties {
private int maxFeaturedCount;
private boolean maintenanceMode;
private boolean startupReportEnabled;
// getters and setters
}
Це здається дрібницею, але на практиці це величезний плюс: YAML залишається читабельним (kebab-case зазвичай легше для очей), а Java залишається звичною Java (camelCase).
Єдине, що я б рекомендував як дисципліну: оберіть один стиль у YAML (зазвичай kebab-case) і дотримуйтеся його. Boot пробачить вам суміш стилів… але ваша команда (і ви в майбутньому) — не завжди.
8. Типові помилки під час прив’язування складних структур
Помилка № 1: зберігати «структуру» одним рядком і парсити її вручну.
Дуже часто новачки намагаються записати ціну як price: "19900 GBP" або список курсів як courses: "spring-boot,spring-core,...", а потім героїчно розбирають це через split. Це швидко перетворюється на крихкий формат, який ламається від пробілів, ком у значеннях і будь-яких «випадкових покращень» конфіга. Вкладений об’єкт і List розв’язують цю проблему системно: конфіг залишається структурою, а код отримує типи.
Помилка № 2: використовувати List або Map без generic-параметрів.
У Java можна (на жаль) написати private List courses; і компілятор не завжди змусить вас негайно схаменутися. Але для binding це погана ідея: ви втрачаєте тип елемента, і далі в коді починається «кастинг-вечірка». У properties-класах завжди вказуйте List<CourseItemProperties> і Map<CourseTrack, String> явно, щоб типи працювали на вас.
Помилка № 3: плутати список і карту на рівні YAML.
Списки в YAML — це дефіси -, карти — це key: value. Якщо ви один раз помилилися відступом, YAML перетворює ваш «список об’єктів» на «один об’єкт із ключем - slug» (це звучить як жарт, але це реальний біль). За таких помилок застосунок може або не запуститися, або запуститися з несподівано порожніми даними. Якщо у вас раптом courses.size() дорівнює 0 — спочатку підозрюйте YAML-відступи.
Помилка № 4: писати значення enum «як-небудь».
Так, Boot часто досить поблажливий, але це не привід перетворювати конфіг на конкурс художньої самодіяльності. Якщо у вас CourseTrack.SPRING, то в конфігу пишіть SPRING. Тоді пошук по проєкту стає простим, а ризик «майже такого ж» значення зменшується.
Помилка № 5: не вказувати одиниці для Duration і DataSize (або вказувати їх непослідовно).
Число 30000 без одиниць у конфігурації — це загадка. Час це? Розмір це? Чи «кількість чогось»? Пишіть 30s, 5m, 64KB, 10MB. По-перше, це читабельно. По-друге, це знижує ймовірність помилки на порядок. По-третє, це рятує вас від ситуації «ми думали, що це секунди, а виявилося мілісекунди».
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ