JavaRush /Курси /Spring Core /Від рядків до типізованих значень

Від рядків до типізованих значень

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

1. Рядкові значення в properties

Якщо чесно, конфігурація у вигляді тексту — це не змова проти Java-розробників. Це просто історично найзручніший і найпереносніший формат: .properties — текст, змінні середовища — рядки, аргументи командного рядка — теж рядки. Навіть якщо вам хочеться світу, де налаштування — це красиві об’єкти, реальність каже: «Тримайте "sms" і не вередуйте». Тому проблема виникає в будь-якому застосунку, навіть якщо він маленький і консольний.

Давайте зафіксуємо важливу думку: рядки в конфігу — це нормально. Ненормальне інше: коли ці рядки починають просочуватися всередину бізнес-коду, розмножуватися і перетворюватися на дрібні перевірки по всьому проєкту.

Типове налаштування в нашому ContextFlow може виглядати так:

# src/main/resources/contextflow.properties
# Людина читає це легко, але для застосунку це поки що просто рядки.
contextflow.app-name=ContextFlow
contextflow.notifications.default-channel=sms

Значення sms — це абсолютно адекватний запис для людини. Але для Java воно не є NotificationChannel.SMS. Це просто три символи, які можуть прийти з пробілами, в іншому регістрі, з друкарською помилкою або у вигляді «sms, будь ласка».

2. Як String потрапляє в сервіси

Проблема рідко з’являється «в чистому вигляді». Зазвичай усе починається невинно: ми читаємо рядок через @Value і зберігаємо його десь у біні, який відповідає за налаштування. А потім… потім хтось (інколи ви самі) вирішує: «Та що там, я в сервісі сам розберу». І ось це «сам розберу» — як той самий маленький сніжок, який за кілька тижнів перетворюється на лавину.

Спочатку справді здається, що все гаразд. Ось простий бін налаштувань, який отримує сире значення:

import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

@Component
public class RawNotificationSettings {

    // Сирі налаштування: поки що це просто String із конфігурації.
    private final String defaultChannel;

    public RawNotificationSettings(
            @Value("${contextflow.notifications.default-channel}") String defaultChannel) {
        // Тут немає валідації та конвертації — просто зберігаємо рядок як є.
        this.defaultChannel = defaultChannel;
    }

    public String defaultChannel() {
        return defaultChannel;
    }
}

На цьому місці багато хто заспокоюється. Контейнер підставив рядок — бін створився — застосунок стартує. Але далі в проєкті виникає питання: «А як вибрати NotificationSender, якщо в нас кілька реалізацій (email/sms/console)?». І тут з’являється спокуса зробити так, щоб сервіс сам здогадався, що означає цей рядок.

3. Антипатерн: розбір у бізнес-методі

Зараз буде трохи жорстко, але корисно: давайте подивимося на найпоширеніший антипатерн. Сервіс отримує рядок і сам перетворює його на доменний тип. Ніби «усього один рядок». Ніби «що може піти не так». Спойлер: піти не так може буквально все.

Почнімо з класичного valueOf().

// Сирі дані «як прийшли».
String rawValue = "sms";

// Enum.valueOf(...) вимагає ТОЧНОЇ назви константи, включно з регістром.
NotificationChannel channel = NotificationChannel.valueOf(rawValue);
// IllegalArgumentException: No enum constant ... NotificationChannel.sms

Коментар тут важливіший за код: valueOf() очікує точного збігу з назвою enum-константи, включно з регістром. Тобто "SMS" пройде, а "sms" — упаде. А ваша конфігурація якраз любить бути в нижньому регістрі, бо люди — не компілятор.

Наступна «еволюційна сходинка» — розробник додає нормалізацію, і код стає трохи довшим, але все одно живе всередині сервісу:

// Тепер у нас «захист» від пробілів і регістру.
String rawValue = " sms ";

// Але ця логіка нормалізації тепер живе в бізнес-коді.
NotificationChannel channel =
        NotificationChannel.valueOf(rawValue.trim().toUpperCase());
// channel = SMS

На цьому місці часто виникає відчуття перемоги. Ми перемогли пробіли і регістр. Але це перемога рівня «виніс сміття з кімнати — і одразу стало легше жити». Так, стало легше. Але сміття тепер у коридорі.

Проблеми такого підходу майже завжди однакові.

По-перше, розбір починає дублюватися. Один сервіс робить trim().toUpperCase(), інший робить strip().toUpperCase(Locale.ROOT), третій чомусь підтримує псевдоніми "text""TEXT", четвертий просто забув і залишив valueOf().

По-друге, помилка конфігурації проявляється пізно. Якщо розбір живе в бізнес-методі, то застосунок може стартувати, обробити частину сценаріїв, а потім упасти «десь посередині» через неправильне налаштування. Це особливо неприємно, тому що помилка вже не виглядає як «помилка старту» — вона виглядає як «випадкова помилка під час виконання».

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

Щоб це було зовсім очевидно, давайте порівняємо два підходи у вигляді невеликої таблиці:

Питання Коли розбираємо в сервісі Коли розбираємо на межі конфігурації
Де живе знання про формат рядка? У бізнес-коді (погано видно, легко розмножується) В інфраструктурі конфігурації (одне місце)
Коли проявиться помилка? Пізно, під час виконання, посеред сценарію Рано, під час створення бінів (fail-fast)
Скільки разів ви напишете розбір? Зазвичай багато (і щоразу трохи по-різному) Зазвичай один раз (або один механізм)
Що отримує сервіс? «сирий» String доменний тип (NotificationChannel, ReportFormat)

4. Conversion і binding

Час ввести два терміни, але без академічного туману. Type conversion — це перетворення значення одного типу на значення іншого типу. У нашому випадку: рядок "sms" перетворюємо на NotificationChannel.SMS. Binding — це коли ми «прив’язуємо» вже готове значення до точки в об’єкті: параметра конструктора або поля (через setter), щоб бін отримав своє значення як частину створення.

Якщо хочеться аналогію, то conversion — це перекладач, а binding — це кур’єр, який приносить перекладений текст у потрібний кабінет. Перекладач не має ходити по всьому офісу і шукати людей, а кур’єр не має сам у голові перекладати з японської. Розділення ролей робить систему менш хаотичною.

Ось як виглядає ланцюжок, який ми хочемо отримати в голові (а потім у проєкті):

flowchart TD
    A["contextflow.properties
contextflow.notifications.default-channel=sms"] --> B["PropertySource / Environment"] --> C["@Value(${...}) плейсхолдер"] --> D["сирий рядок: \"sms\""] --> E["перетворення типів"] --> F["типізоване значення: NotificationChannel.SMS"] --> G["звʼязування до параметра конструктора"] --> H["готовий бін"]

Зверніть увагу на важливий момент: на рівні сервісу нас цікавить лише H, готовий бін, який уже живе у світі нормальних типів. Усе «брудне» — рядки, пробіли, регістр, друкарські помилки — має зупинитися раніше, на межі конфігурації.

І тут з’являється приємна новина: Spring як контейнер насправді вже вміє виконувати conversion і binding у межах створення бінів. Просто ми поки що не вмикали це свідомо і не розширювали під наші доменні типи. Поки нам важливо зрозуміти архітектурний намір: типи мають з’являтися якомога раніше.

5. Цільовий стиль: типи замість рядків

Давайте тепер зафіксуємо, як виглядає цільовий стан у коді. Ми хочемо, щоб у застосунку були біни налаштувань, які відповідають за читання конфігурації, і щоб ці біни повертали назовні вже типізовані значення. Сервісний шар при цьому не має знати, що десь був ключ contextflow.notifications.default-channel, і вже точно не має робити trim() або toUpperCase().

Спочатку покажемо, як виглядає те саме сире рішення (ми вже бачили його вище):

import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

@Component
public class RawNotificationSettings {

    // Сервісам доведеться самим вирішувати, що таке "sms" і як його розбирати.
    private final String defaultChannel;

    public RawNotificationSettings(
            @Value("${contextflow.notifications.default-channel}") String defaultChannel) {
        this.defaultChannel = defaultChannel;
    }

    public String defaultChannel() {
        return defaultChannel;
    }
}

А тепер — цільовий варіант. Він майже такий самий… але різниця принципова:

import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

@Component
public class TypedNotificationSettings {

    // Тут уже доменний тип: сервіси отримують готове значення, а не рядок.
    private final NotificationChannel defaultChannel;

    public TypedNotificationSettings(
            @Value("${contextflow.notifications.default-channel}")
            NotificationChannel defaultChannel) {
        // Якщо конвертація не спрацює — краще впасти під час старту (fail-fast).
        this.defaultChannel = defaultChannel;
    }

    public NotificationChannel defaultChannel() {
        return defaultChannel;
    }
}

Тут ми зробили одну річ: попросили контейнер створити бін, у конструктор якого потрібно підставити не String, а NotificationChannel. Це означає, що десь у контейнері має існувати здатність перетворити String на NotificationChannel.

Важливо: прямо зараз у вас може бути думка: «Так, а це точно спрацює без додаткового налаштування?». І це дуже правильна думка. Ми поки що не обговорювали, як саме Spring обирає та застосовує конвертери. Але тут достатньо втримати образ результату: контейнер має зуміти перетворити String на NotificationChannel до того, як значення потрапить у сервісний шар.

6. Fail-fast для конфігурації

Є одна інженерна звичка, яка відрізняє «код, що інколи працює», від «застосунку, який не соромно запускати»: краще впасти на старті, ніж тихо жити неправильно. У Spring це вбудовано в стиль мислення: контейнер намагається зібрати граф об’єктів цілком, перевірити залежності й зробити так, щоб при першому виклику бізнес-методу все було готово.

Коли конфігурація приходить рядком і розбирається десь у бізнес-методі, ви втрачаєте цю перевагу. Застосунок може стартувати, а потім раптово впасти під час першого замовлення, тому що хтось написав у конфігу smms замість sms. Такі помилки особливо підступні, тому що їх зазвичай ловлять не тести (якщо тест не пройшов цим шляхом), а реальний запуск.

Коли ви переносите conversion на межу (у момент binding у конструктор біну), помилка стає «гучною і ранньою». Контекст не збереться, і застосунок скаже: «Друзі, у мене неправильне налаштування. Я навіть не почну вдавати, що все добре». У навчальному консольному застосунку це виглядає так само корисно, як і в продакшені: ви запускаєте проєкт і відразу бачите проблему.

У ContextFlow це особливо доречно, тому що налаштування на кшталт default notification channel або report format — це не «декор». Це реально впливає на поведінку: куди піде повідомлення, який формат звіту вийде, куди записувати файл. Чим раніше ми перевіримо цю конфігурацію — тим менше сюрпризів буде в сценаріях.

7. Типові помилки під час конвертації

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

Помилка №2: розібрати один раз, але все одно всередині бізнес-методу.
Інколи розробник розуміє, що дублювання — зло, і виносить розбір у приватний метод сервісу. Це покращення, але не перемога: формат конфігурації все одно протікає в сервісний шар. Сервіс починає знати занадто багато про те, що є «сирим входом», хоча має жити у світі доменних типів.

Помилка №3: «підстрахуватися» мовчазним значенням за замовчуванням, яке приховує помилку.
Налаштувати ${key:DEFAULT} — зручно, але легко перетворити це на спосіб сховати проблему. Наприклад, якщо канал повідомлення не задано, можна «про всяк випадок» зробити CONSOLE. І ви справді отримаєте робочий запуск… тільки потім довго з’ясовуватимете, чому в демо-режимі повідомлення «чомусь» не йдуть у SMS. Значення за замовчуванням гарні, коли вони змістовні та очікувані, а не коли вони маскують неправильну конфігурацію.

Помилка №4: змішати читання конфігурації та бізнес-дію в одному класі.
Дуже хочеться зробити «розумний сервіс»: він і читає @Value, і вибирає реалізацію, і надсилає повідомлення, і пише аудит, і, якщо що, сам себе заспокоює. У підсумку у вас з’являється клас-комбайн, який складно тестувати, складно читати і складно розвивати. Набагато спокійніше, коли є окремий бін налаштувань, який відповідає лише за типізовану конфігурацію, а сервісний шар використовує готові типи й займається сценарієм.

Помилка №5: розкидати ключі properties по проєкту.
Коли ключі на кшталт contextflow.notifications.default-channel з’являються у трьох класах, ви отримуєте «магічний рядок» уже іншого рівня: змінити ключ — означає знайти всі місця, де його згадано. Тому хороша звичка — тримати @Value і ключі поруч із біном налаштувань, а сервісам давати залежності на «налаштування як об’єкт», а не «налаштування як рядок».

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