1. Термін binding: одне слово, різні механізми
Точку розширення MVC ми вже підготували. Але перш ніж під’єднувати до неї конвертери та форматувальники, важливо не переплутати два дуже схожі на вигляд потоки даних: рядок із HTTP-запиту та рядок із конфігурації застосунку. Зовні обидва схожі на «якийсь текст», а всередині в них різні правила, різний час життя і різні місця налаштування.
Слово binding у Spring — це як слово «ключ» в українській мові: ключ від дверей, ключ у музиці й ключ у криптографії наче пишуться однаково, але краще не намагатися відкрити квартиру нотним ключем, хоч ідея драматична. У Spring під «binding» зазвичай розуміють «перетворення зовнішніх даних на Java-типи»: рядки в int, текст у enum, JSON в об’єкт, YAML у налаштування тощо.
Проблема в тому, що зовнішні дані бувають дуже різними. HTTP-запит приходить під час виконання, багато разів, від різних людей і часом із сюрпризами. Конфігурація застосунку читається під час старту, зазвичай один раз, і керує поведінкою сервісу як системи. Обидва процеси справді «перекладають рядки в типи», але живуть у різних шарах, у різний час і з різними правилами гри. Якщо ви їх переплутаєте, отримаєте дивні очікування, дивні баги й дуже філософські запитання до логів.
Джерела рядків: URL запиту та application.yaml
Дуже корисно одразу й чесно розділити: звідки саме прийшов рядок, який Spring зараз намагається перетворити на LocalDate або CourseTrack. Для web-binding джерело майже завжди одне й те саме: HTTP-запит. Query-параметри (?limit=20&track=SPRING) і path variables (/courses/spring-boot) фізично приходять як текст в URL. Плюс є заголовки, кукі, тіло запиту — але сьогодні нам важливі саме query-параметри.
Для конфігураційного binding джерело інше: файли конфігурації (application.yaml), змінні середовища, system properties, CLI args. Вони теж текстові за природою, навіть якщо YAML виглядає «структурно». І ось тут мозок новачка робить логічний стрибок: «О! І там рядки, і тут рядки. Отже, конвертація одна й та сама». А Spring такий: «Чудово. Але ні».
Щоб закріпити, порівняймо на одному прикладі — рядок у URL і рядок у YAML.
URL (web):
/api/catalog/courses?limit=20&track=SPRING
YAML (config):
app:
catalog:
max-featured-count: 4 # ліміт "featured" курсів, політика сервісу
В обох випадках є 20, SPRING і 4. Але в першому випадку це введення користувача або клієнта в конкретному запиті, а в другому — налаштування застосунку як системи. І далі ці дані мандрують по різних конвеєрах.
2. Web-binding у MVC: ?limit=20 перетворюється на int
Розберімо шлях query-параметра без магії. Браузер або клієнт надсилає запит, в URL лежать рядки, і Spring MVC має розв’язати дві задачі: який метод контролера викликати та як підготувати аргументи цього методу. Для @RequestParam Spring бере значення параметра як рядок і намагається перетворити його на тип параметра методу: int, boolean, enum, LocalDate тощо. Це відбувається на кожному запиті, безпосередньо перед викликом вашого контролера.
Схематично, дуже спрощено, web-binding виглядає так:
flowchart TD
A["HTTP-запит
...?limit=20"] --> B["Spring MVC
аргументи методу"]
B --> C["ConversionService
рядок -> тип"]
C --> D["@RequestParam int limit"]
D --> E["Виклик методу контролера"]
Найзручніше те, що якщо ви одразу оголошуєте типи в сигнатурі методу, контролер стає коротким і чесним. Він не «парсить рядок», а описує контракт: «мені потрібні int limit і CourseTrack track».
Наприклад:
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import java.util.List;
@GetMapping("/api/catalog/courses") // кінцева точка каталогу курсів
public List<CourseCard> findCourses(
@RequestParam(defaultValue = "20") int limit, // MVC перетворить рядок на int (або поверне 400 у разі помилки)
@RequestParam(required = false) CourseTrack track // null, якщо параметр не передали
) {
// Контролер не займається парсингом і валідацією "вручну" — він приймає типи
return service.find(limit, track);
}
Тут ключовий момент: limit і track у запиті приходять рядками, але ваш метод не бачить їх як рядки. Цю «брудну роботу» робить MVC.
І ще один момент, який часто дивує: якщо Spring не зміг перетворити рядок на потрібний тип, ваш метод взагалі не викликається. Контролер навіть не встигає почати роботу. Зазвичай це закінчується стандартною відповіддю з помилкою, часто 400 Bad Request, — без того, щоб ви вручну ловили NumberFormatException. Це не «Spring злий», а спроба захистити вас від ручного парсингу в кожному методі.
3. MVC ConversionService: перекладач між рядком і типом
Якщо уявити Spring MVC як аеропорт, то ConversionService — це та сама людина біля стійки, яка перекладає «пасажирську» мову, тобто рядки в запиті, на «службову» мову, тобто Java-типи. Ви майже не бачите її в коді контролера, але саме вона пояснює, чому можна написати @RequestParam int limit і не займатися Integer.parseInt(...) під кожною кінцевою точкою.
Важливо не переплутати: ConversionService не про JSON і не про Jackson. Jackson підключається, коли йдеться про тіло запиту або відповіді в JSON, і то через HTTP message converters. Query-параметри — це переважно про conversion і formatting, а не про серіалізацію.
Якщо спробувати зробити все «вручну», контролер миттєво перетворюється на «String-центр», де життя складається з parseInt, trim, toUpperCase і тихої ненависті до користувачів, які надіслали limit= 20 з пробілами.
Анти-приклад (так можна, але так сумно):
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import java.util.List;
@GetMapping("/api/catalog/courses")
public List<CourseCard> findCourses(@RequestParam String limit) {
// Вручну тягнути рядок із запиту — майже завжди погана ідея: це розповзеться по контролерах
int value = Integer.parseInt(limit.trim()); // може впасти, і буде не дуже красиво
return service.find(value, null); // доменні параметри ви втрачаєте, поки намагаєтеся "розпарсити" життя
}
Коли ви бачите такий код, майже завжди варто зупинитися і запитати себе: «А я точно хочу, щоб кожен мій контролер займався ручним розбором рядків?» Зазвичай відповідь така: «Ні. Я хочу жити».
І тут згадуємо, що в лекції 1 ми завели WebConfiguration implements WebMvcConfigurer. Саме сюди, за потреби, додають правила web-конвертації, якщо стандартних можливостей не вистачає. Важливо: це стосується web-шару, тобто значень, що приходять із запиту. Це не «універсальний конвертер на весь застосунок».
4. Config-binding у Spring Boot: рядки в налаштування застосунку
Тепер перемкнімося в інший режим мислення. Конфігурація застосунку не приходить щоразу. Її читають під час старту, і вона відповідає на запитання: «Яким буде цей застосунок у цьому оточенні?» Наприклад: як називається сервіс, які прапорці ввімкнені, скільки елементів показувати, які функції активні.
Схема, теж спрощена, виглядає так:
flowchart TD
A["application.yaml / env vars / args"] --> B["Spring Boot binder"]
B --> C["Java-об'єкти налаштувань"]
C --> D["Біни отримують налаштування
через DI"]
І так, тут теж є «binding»: рядкові значення з YAML мають стати int, boolean, enum, Duration тощо. Але цей binding живе у світі конфігурації та стартового створення застосунку, а не у світі HTTP-запитів.
Для ілюстрації, чисто щоб побачити інший контекст, можна уявити майбутню конфігураційну модель CatalogProperties:
import org.springframework.boot.context.properties.ConfigurationProperties;
@ConfigurationProperties("app.catalog") // префікс ключів у application.yaml
public class CatalogProperties {
// Політика застосунку як системи, а не "параметр запиту"
private int maxFeaturedCount;
public int getMaxFeaturedCount() {
return maxFeaturedCount; // те, що прочиталося з YAML/env/args на старті
}
public void setMaxFeaturedCount(int maxFeaturedCount) {
// Якщо тут опиниться "banana" замість числа — це спливе на старті, а не на конкретному HTTP-запиті
this.maxFeaturedCount = maxFeaturedCount;
}
}
І відповідний YAML:
app:
catalog:
max-featured-count: 4
Зверніть увагу на дві відмінності, які часто вислизають.
По-перше, в YAML ключ max-featured-count (kebab-case), а в Java поле maxFeaturedCount (camelCase). Для конфігураційного binding це нормальна ситуація: Spring Boot уміє пов’язувати такі імена. У web-binding такого автоматичного «перейменування» зазвичай не мають на увазі, тому що web-binding працює з іменами параметрів запиту (?limit=), а не з «ключами налаштувань».
По-друге, якщо конфігурацію зламано, наприклад замість числа в YAML лежить banana, це проявиться на старті. Це не «поганий запит», а «застосунок як система не може стартувати». І поведінка тут інша: частіше ви отримаєте помилку старту, а не акуратну реакцію на один конкретний HTTP-запит.
5. Порівняння: web-binding vs config-binding
Щоб перестати плутатися, корисно один раз порівняти механіки як дві різні підсистеми. Вони справді обидві конвертують рядки, але мета в них різна, і тому правила та точки кастомізації теж відрізняються.
| Характеристика | Web-binding (Spring MVC) | Config-binding (Spring Boot) |
|---|---|---|
| Джерело даних | HTTP-запит (query params, path variables тощо) | application.yaml, env vars, system properties, args |
| Коли відбувається | На кожен запит, перед викликом контролера | Під час старту застосунку, під час створення контексту |
| Основна мета | Перетворити вхідні дані клієнта на типи для обробника запиту | Налаштувати поведінку застосунку без правки коду |
| Де ви бачите результат | У параметрах методу контролера | В об'єктах налаштувань, які впроваджуються через DI |
| Що при помилці конвертації | Зазвичай запит не доходить до методу контролера, клієнт отримує помилку | Часто проблему видно під час старту: застосунок не зібрано або зібрано некоректно |
| Куди додають правила конвертації | Точки розширення MVC (WebMvcConfigurer, converters/formatters) | Конфігураційна модель і механіки Boot binder (у межах курсу — пізніше) |
Головний практичний висновок дуже простий: якщо ви зараз дивитеся на @RequestParam, думайте «MVC». Якщо дивитеся на YAML і налаштування сервісу, думайте «Boot config». Не намагайтеся лікувати одне через налаштування іншого, як не намагаються лагодити принтер налаштуваннями мікрохвильової печі, хоча в офісі іноді і таке буває.
6. catalog-service: типи в контролері і типи в конфігу
Тепер приземлимо теорію на наш catalog-service, де є доменні типи CourseTrack, CourseLevel і фільтри в кінцевих точках каталогу. У web-шарі ми хочемо, щоб контролер приймав доменні типи, а не рядки. Це робить сигнатуру читабельною: метод одразу показує, які фільтри підтримує кінцева точка.
Наприклад, умовно так:
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import java.time.LocalDate;
import java.util.List;
@GetMapping("/api/catalog/courses")
public List<CourseCard> findCourses(
@RequestParam(required = false) CourseTrack track, // прийде рядком із URL, MVC перетворить у enum/тип
@RequestParam(required = false) CourseLevel level, // аналогічно: web-binding, а не читання з YAML
@RequestParam(required = false) LocalDate launchedAfter // формат дати визначають правила MVC conversion/formatting
) {
// Тут уже типи, а не "сирий текст": сервісу простіше жити
return service.find(track, level, launchedAfter);
}
Тут track, level, launchedAfter — це web-binding. Вони беруться з URL, і Spring MVC намагається їх перетворити.
А тепер уявіть конфігураційний шар, не заглиблюючись у деталі: наприклад, ліміт featured-курсів. Це не введення користувача, а політика сервісу. Сьогодні ліміт 4, завтра 6 — і ми хочемо змінювати це без правки коду. Такий параметр логічно має жити в конфігурації застосунку, а не в query-параметрі кожного запиту.
Наприклад, ось так, чисто як ідея конфігурації:
app:
catalog:
max-featured-count: 4
І тут народжується класична плутанина: новачок дивиться на max-featured-count і думає, що MVC автоматично застосує це правило до @RequestParam int limit. Ні, це різні шари. MVC не читає ваші app.catalog.* як «значення за замовчуванням для запиту». Для запиту значення за замовчуванням задається або defaultValue прямо в @RequestParam, або логікою в сервісі, або окремою доменною політикою, але це все не «магічно з YAML на кожну кінцеву точку».
7. Де налаштовувати конвертацію
Після цієї лекції хочеться поставити в голові два маленькі дорожні знаки. Перший: WebMvcConfigurer та його методи — це про web-шар. Якщо ви додаєте правила в addFormatters, ви впливаєте на те, як Spring MVC перетворює рядок із URL на Java-тип. Це чудово, корисно і безпечно, але не робить вашу конфігурацію «раптом типобезпечною» і не налаштовує те, як Boot читає application.yaml.
Другий знак: конфігураційні механізми Boot — це про те, як застосунок збирається і живе як система. Вони не зобов’язані збігатися з правилами web-binding, бо задачі різні. Конфіг — це керованість сервісу, web-binding — це зручний і передбачуваний контракт кінцевих точок.
Якщо хочеться запам’ятати це однією картинкою, можна так:
flowchart TD
subgraph R["Під час виконання: кожен HTTP-запит"]
Q["URL: ?track=SPRING"] --> MVC["MVC binding
(ConversionService)"]
MVC --> C["Аргументи методу контролера"]
end
flowchart TD
subgraph S["Startup: старт застосунку"]
Y["YAML / env / args"] --> B["Boot config binding
(binder)"]
B --> P["Властивості/налаштування"]
end
Щойно ви починаєте думати: «А можна одним налаштуванням змусити і запити розбиратися, і config binding працювати однаково?», — найімовірніше, ви намагаєтеся склеїти дві різні підсистеми. У Java це зазвичай закінчується тим, що ви отримуєте «універсальний» код, який складніший і менш передбачуваний, ніж два прості рішення на своїх місцях.
8. Типові помилки під час web- та config-binding
Перед тим як ми підемо далі за планом, корисно спокійно проговорити типові помилки, на які наступають майже всі. Це не «соромні помилки» — це нормальна частина дорослішання у Spring, тому що термінологія схожа, а механіки різні. Але якщо впізнавати ці ситуації заздалегідь, ви зекономите собі багато часу й трохи нервових клітин, а вони, як відомо, у бекенді не відновлюються, хоча логів ми напишемо достатньо.
Помилка №1: вважати, що web-binding і config-binding — це один механізм, бо «і там рядки, і тут рядки».
Зазвичай це проявляється так: розробник додає налаштування в application.yaml і чекає, що @RequestParam почне оброблятися «за тими самими правилами». У реальності MVC і Boot binder — різні підсистеми, і зміни в одній не зобов’язані впливати на іншу. Лікується дуже просто: завжди ставте собі запитання «це значення прийшло з HTTP чи з конфігурації застосунку?».
Помилка №2: залишати параметри контролера рядками й вручну парсити їх у кожному методі.
Такий код швидко розповзається: trim, parseInt, toUpperCase починають жити в контролері, і контролер перетворюється на суміш «контракт API» і «бібліотека парсингу рядків». Spring MVC уміє конвертувати базові типи сам, а для доменних форматів є правила, які можна централізувати. Контролеру краще залишатися тонкою межею, а не фабрикою з виробництва NumberFormatException.
Помилка №3: очікувати, що defaultValue у @RequestParam — це те саме, що «дефолт у конфігурації».
defaultValue — це правило для конкретної кінцевої точки та конкретного параметра запиту: якщо параметр не прийшов, MVC підставить рядок і спробує перетворити його на тип. Конфігураційне значення за замовчуванням — це правило поведінки сервісу як системи. Це різні рівні. Якщо змішати їх, ви отримаєте API, у якому частина значень за замовчуванням «розмазана» по контролерах, а частина живе в налаштуваннях, — і дуже важко зрозуміти, що насправді є політикою застосунку.
Помилка №4: намагатися «полагодити» незрозумілу поведінку bindingʼа вмиканням @EnableWebMvc.
Це як лікувати дивний звук у машині зняттям дверей: іноді звук справді зникає, але тепер у вас інша проблема. @EnableWebMvc перемикає MVC у більш ручний режим і легко ламає корисні Boot defaults. Якщо ваша мета — точкове налаштування конвертації вхідних значень, коректніше триматися WebMvcConfigurer і невеликих додавань поверх автонастроювання.
Помилка №5: думати, що якщо тип один і той самий (CourseTrack), то й правила введення мають бути «універсальними».
CourseTrack може приходити із запиту, де користувачі люблять писати java-backend, і з конфігурації, де зазвичай пишуть акуратні значення на кшталт JAVA_BACKEND. Ці джерела поводяться по-різному, і ви маєте право обробляти їх по-різному. Уніфікація будь-якою ціною часто робить гірше: ви ускладнюєте систему заради краси, а не заради зрозумілої поведінки.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ