JavaRush /Курси /Spring Boot /Прив’язування складних структур

Прив’язування складних структур

Spring Boot
Рівень 17 , Лекція 3
Відкрита

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. По-перше, це читабельно. По-друге, це знижує ймовірність помилки на порядок. По-третє, це рятує вас від ситуації «ми думали, що це секунди, а виявилося мілісекунди».

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