1. Структура Spring Boot executable jar
Коли ми говоримо jar, у голові Java-розробника часто автоматично постає одна й та сама картинка: META-INF, маніфест, кілька .class — і поїхали. Але Spring Boot jar — це трохи хитріша «матрьошка»: він має запускатися як застосунок, одночасно тягнути всередині цілий набір бібліотек і обходитися без зовнішнього сервера застосунків. Тому перше, що нам потрібно сьогодні, — спокійна й зрозуміла модель фізичної структури Boot-jar, щоб layered jar далі не виглядав як магічний контейнер у контейнері.
Один факт — «jar запускається» — уже замало. Тепер важливо побачити, як цей jar улаштований фізично, інакше розмова про шари зависне в повітрі.
Почнімо з простої думки: jar — це zip-архів. Тобто його можна «подивитися» стандартними інструментами, і в цьому немає жодного кощунства.
Зберемо jar (якщо він уже зібраний — команда просто виконається швидко):
# Повне повторне збирання артефакту (jar буде в build/libs)
./gradlew clean bootJar
# Беремо саме executable jar з bootJar, а не *-plain.jar
JAR="$(find build/libs -maxdepth 1 -type f -name '*.jar' ! -name '*-plain.jar' | head -n 1)"
# Перевіряємо, що jar справді зʼявився, і дивимося розмір
ls -lh "$JAR"
Тепер зазирнемо всередину. Не треба розпаковувати весь архів і влаштовувати хаос на диску — досить просто вивести список файлів:
# Дивимося перші 20 записів усередині jar, щоб зрозуміти «скелет» архіву
jar tf "$JAR" | head -n 20
Ви побачите, що в executable jar є два великі «світи»:
- Код запуску (Spring Boot loader) — класи, які вміють стартувати застосунок і завантажувати вкладені jar-залежності.
- Вміст застосунку — ваш код, ваші ресурси і ваші залежності.
У спрощеному вигляді це виглядає так:
catalog-service.jar
├─ META-INF/
├─ org/springframework/boot/loader/...
└─ BOOT-INF/
├─ classes/ <-- ваш код + ресурси (application.yaml, static, і т.д.)
└─ lib/ <-- залежності (інші jar усередині jar)
Майже завжди зручно окремо «виокремити» ключові місця:
# Ваші класи та ресурси застосунку
jar tf "$JAR" | grep '^BOOT-INF/classes' | head
# Підключені залежності (вкладені jar)
jar tf "$JAR" | grep '^BOOT-INF/lib' | head
# Spring Boot loader (те, що робить jar виконуваним)
jar tf "$JAR" | grep '^org/springframework/boot/loader' | head
Що лежить у BOOT-INF/classes
Це ваш застосунок у найпрямішому сенсі. Там будуть:
— скомпільовані класи (наприклад, com/example/catalogservice/...);
— ресурси з src/main/resources/ (наприклад, application.yaml, catalog-data.yaml, статичні файли static/index.html тощо).
Якщо ви змінюєте Java-код контролера або правите YAML, то змінюєте саме цю частину.
Що лежить у BOOT-INF/lib
Це залежності: Spring Framework, Spring Boot, Jackson, Logback, Micrometer і все інше, що прилетіло через стартери та транзитивні залежності.
І це важливий психологічний момент: коли ви робите bootJar, ви отримуєте артефакт, який можна передати іншій людині або системі, — і він буде самодостатнім, тому що бібліотеки лежать просто всередині.
Що лежить у org/springframework/boot/loader/...
Це boot loader — механізм, завдяки якому java -jar працює саме так, як очікує Spring Boot. Він читає маніфест, розуміє, який main-клас запускати, і створює classpath так, щоб JVM побачила ваші класи і вкладені залежності.
І ось тут зʼявляється важливий звʼязок: layered jar виділяє boot loader в окремий шар, тому що за частотою змін він живе своїм життям — зазвичай змінюється лише під час оновлення версії Boot.
2. Layered jar і індекс layers.idx
До цього моменту jar можна було сприймати як валізу: відкрили — усе всередині, закрили — пішли. Але для мислення, зручного для контейнерів, така валіза незручна: якщо ви змінили одну річ, доводиться «перезбирати» все знову. Layered jar — це валіза із секціями та ярличками: речі все ще в одній валізі, але тепер можна чітко сказати, що де лежить і що змінюється частіше. Тому в цій частині ми вводимо layered jar як той самий executable jar, але з доданою метаінформацією про шари.
Що таке layered jar у людських термінах
Layered jar — це executable jar Spring Boot, у якому є опис шарів: які файли належать до «залежностей», які — до «коду застосунку», які — до «лоадера» тощо.
Ключовий файл, який нас цікавить: layers.idx. Це індекс шарів усередині jar. Він не робить застосунок швидшим сам по собі, але робить структуру артефакту явною для інструментів, які вміють цю структуру використовувати.
Як перевірити layered jar і коли потрібне явне налаштування
У поточній базовій версії Boot layered jar зазвичай уже приходить разом зі звичайним bootJar, тому спершу краще не вмикати його «про всяк випадок», а просто перевірити, що всередині справді є індекс шарів:
# Шукаємо індекс шарів усередині jar
jar tf "$JAR" | grep 'layers.idx'
Найчастіше ви побачите шлях на кшталт:
BOOT-INF/layers.idx
Явне налаштування має сенс, коли ви хочете свідомо зафіксувати цю поведінку в build-файлі або далі кастомізувати шари. Наприклад, так можна явно залишити layered packaging на задачі bootJar:
tasks.named<org.springframework.boot.gradle.tasks.bundling.BootJar>("bootJar") {
// Необов'язково: явно фіксуємо layered packaging на задачі `bootJar`
layered()
}
Таке налаштування зручно тримати в build-файлі, якщо ви свідомо хочете зафіксувати layered packaging або пізніше перейти до кастомізації шарів. Але базове питання спочатку завжди одне: чи є в jar індекс шарів і чи збігається він із тим, як ви розумієте структуру сервісу.
Як виглядає ідея layers.idx
Формат layers.idx можна сприймати як «зміст» або «карту пакування». Він каже: ось у нас є шар dependencies, ось application тощо.
Схематично (спрощено) думка така:
layers.idx
├─ dependencies -> частина BOOT-INF/lib (релізні залежності)
├─ spring-boot-loader -> класи loader у корені jar
├─ snapshot-dependencies -> частина BOOT-INF/lib (SNAPSHOT-залежності)
└─ application -> BOOT-INF/classes + ресурси застосунку
І тут важливо не переплутати: шари — це логічне групування, а не обовʼязково «одна папка = один шар». Так, у типовій схемі багато що збігається з папками, але цей збіг — приємний бонус, а не сенс механізму.
3. Чотири стандартні шари Spring Boot
Зараз у нас уже є дві моделі того самого jar: фізична — папки та файли, і логічна — шари. Далі ми робимо найголовніше в лекції: розбираємо чотири стандартні шари, які Spring Boot використовує за замовчуванням. Наша мета — щоб назви dependencies і application перестали бути просто словами, а стали інженерними «ящиками в голові»: що туди потрапляє, чому так і як це повʼязано з тим, що ви реально змінюєте в проєкті.
Нижче — зручна таблиця. Прочитайте її двічі: спершу, щоб зрозуміти, а потім, щоб усе остаточно стало на місце.
| Шар | Що це за змістом | Що там зазвичай лежить | Як часто змінюється | Приклад у catalog-service |
|---|---|---|---|---|
| dependencies | «Стабільні бібліотеки» | BOOT-INF/lib/*.jar (реліз) | рідко | Spring, Jackson, Logback, Micrometer |
| spring-boot-loader | «Механізм запуску jar» | org/springframework/boot/loader/** | дуже рідко | змінюється під час оновлення версії Boot |
| snapshot-dependencies | «Нестабільні залежності» | BOOT-INF/lib/*SNAPSHOT*.jar | частіше, якщо є | часто взагалі порожній шар |
| application | «Ваш застосунок» | BOOT-INF/classes/** + ресурси застосунку | часто | класи контролерів, YAML, static/index.html |
dependencies: «каміння фундаменту»
Цей шар майже завжди найважчий за розміром — у кілобайтах або мегабайтах, — тому що туди потрапляють ваші бібліотеки. І це добре: бібліотеки зазвичай займають більше місця, ніж ваш код, особливо в маленькому навчальному сервісі.
Туди потрапляють залежності, які у вашому проєкті зафіксовані як звичайні релізи. У типовому Boot-сервісі це означає: ви можете десять разів змінювати контролери та сервіси, але залежності залишаться тими самими, доки ви не оновите build.gradle.kts.
Практично це означає таке: якби ми говорили про пакування в контейнерний образ, цей шар хотілося б кешувати максимально агресивно. Але й без контейнерів корисно мислити саме так: поки я не чіпаю залежності, ця частина артефакту стабільна.
spring-boot-loader: «те, що робить jar виконуваним»
Дуже часта помилка початківців — думати, що Spring Boot loader «це частина мого застосунку». Насправді це частина пакування.
Це ті класи, які дозволяють java -jar запустити ваш застосунок і коректно зібрати classpath із того, що лежить усередині BOOT-INF/lib. Якщо сильно спростити, то loader — це «мінідвигун», який запускає ваш код, але сам вашим кодом не є.
Чому його виділено в окремий шар? Тому що він змінюється не тоді, коли ви правите CourseCatalogController, а тоді, коли оновлюєте версію Spring Boot. І це рідкісна подія порівняно з щоденними правками застосунку.
snapshot-dependencies: «зона підвищеної турбулентності»
SNAPSHOT-залежності — це залежності, які потенційно можуть змінюватися без зміни версії у звичному сенсі: умовно, «сьогоднішній снапшот» і «завтрашній снапшот».
У навчальних проєктах і в нормальному production-житті SNAPSHOT зазвичай намагаються уникати. Тому чесний сценарій для catalog-service такий: шар може бути порожнім. І це нормально. Він існує не тому, що «обовʼязково має бути заповненим», а тому, що Spring Boot пропонує зрозумілу структуру на випадок, якщо снапшоти все ж є.
З інженерного погляду це дуже логічно: снапшоти оновлюються частіше, отже їх вигідно відділяти від стабільних релізних бібліотек, щоб не «псувати кеш» усього шару dependencies.
application: «ваш код і все, що ви вважаєте частиною застосунку»
Це шар, який ви змінюєте найчастіше. Причому «код» тут — не тільки .class файли.
У application зазвичай потрапляють:
— ваші скомпільовані класи;
— application.yaml, application-*.yaml, catalog-data.yaml;
— статичні файли (static/index.html);
— іноді допоміжні індекс-файли на кшталт layers.idx (так, сам індекс шарів часто живе поруч, тому що він «привʼязаний» до конкретного збирання застосунку).
Якщо ви змінили щось у src/main/java або src/main/resources, то майже напевно змінився саме цей шар.
4. Каталоги і шари: як це повʼязано
Пастка тут дуже людська: ви подивилися jar, побачили каталог BOOT-INF/lib і вирішили, що «це і є шар dependencies». У більшості випадків воно схоже на правду, але layered jar якраз існує, щоб працювати точніше, ніж наша інтуїція. У цій частині ми акуратно розводимо два поняття: фізичне розташування файлів в архіві і логічне групування в шари, тому що плутанина між ними далі призводить до дивних висновків і помилок.
Фізична структура jar каже, де лежить файл. Логічна структура шарів відповідає на інше запитання: до якої групи змін належить файл і як його має бачити механізм пакування.
Подивімося на кілька характерних відповідностей:
| Де лежить файл у jar | Приклад | До якого шару зазвичай потрапляє | Чому так |
|---|---|---|---|
| BOOT-INF/classes/** | com/example/.../*.class, application.yaml | application | це ваш код і ресурси |
| BOOT-INF/lib/*.jar | spring-webmvc-*.jar | dependencies | релізна бібліотека |
| BOOT-INF/lib/*SNAPSHOT*.jar | some-lib-1.2.3-SNAPSHOT.jar | snapshot-dependencies | залежність змінюється частіше |
| org/springframework/boot/loader/** | JarLauncher.class | spring-boot-loader | механізм запуску |
Зверніть увагу на ключовий момент: один і той самий каталог BOOT-INF/lib може «розкладатися» на два шари. Саме тому на рівні мислення «каталог ≠ шар» — це не занудство, а корисна дисципліна.
Якщо ви хочете відчути це руками, можна зробити дуже маленьке спостереження: подивіться на вміст BOOT-INF/lib і просто прикиньте, чи є серед jar-ів щось зі словом SNAPSHOT. У більшості випадків — ні, і тоді шар snapshot-dependencies буде порожній. Але якби він був, фізично файл жив би в тій самій папці, а логічно — в іншому шарі.
Ще один тонкий момент: шар spring-boot-loader фізично лежить у корені jar, у вигляді пакета org/..., а не в BOOT-INF. Це незвично, якщо ви подумки вважаєте BOOT-INF «домом застосунку». Але історично так і задумано: loader має бути доступний одразу під час запуску jar.
Щоб не загубитися, корисно тримати таку схему — вона дуже проста, і саме тому працює:
flowchart TD
J["catalog-service.jar"] --> L1["spring-boot-loader — корінь jar"]
J --> BI["BOOT-INF"]
BI --> C["classes — application"]
BI --> LIB["lib — dependencies + snapshot-dependencies"]
5. Зміни проєкту і шари jar
Коли ви тільки навчаєтеся, хочеться звести збирання до однієї кнопки — «Зібрати». Але щойно ви живете з проєктом хоча б тиждень, збирання перетворюється на щоденну рутину: ви збираєте, запускаєте, передаєте, пакуєте, знову збираєте. І тут раптово зʼясовується, що найдорожче в житті — не кава, а повторно перезбирати те, що не змінювалося. Саме тому layered jar — не про красу, а про економію часу і ясність: він привʼязує тип зміни до частини артефакту.
Давайте прямо зіставимо звичайні дії розробника і те, які шари змінюються.
| Що ви зробили | Що змінилося в jar | Який шар найчастіше змінюється |
|---|---|---|
| Змінили Java-код (контролер/сервіс) | .class файли | application |
| Змінили application.yaml або catalog-data.yaml | ресурси в BOOT-INF/classes | application |
| Додали або оновили залежність у build.gradle.kts | jar-и в BOOT-INF/lib | dependencies або |
| Оновили версію Spring Boot | змінився loader + залежності | spring-boot-loader і |
| Підключили SNAPSHOT-залежність | новий jar у BOOT-INF/lib | snapshot-dependencies |
І ось тут стає зрозумілим сенс чотирьох шарів: вони розділяють артефакт за «ритмом життя». Найнервовіший шар — application (ми в ньому живемо щодня). Найспокійніші — dependencies і spring-boot-loader. snapshot-dependencies — це спеціально виділена «зона неспокою», якщо у вашому проєкті є снапшоти.
Якщо ви тримаєте це в голові, layered jar перестає бути «ще одним терміном із Boot» і стає зручним способом відповісти на практичне запитання: чому мій артефакт змінюється так, як змінюється, і які частини можна вважати стабільними.
6. Типові помилки при layered jar
У цій темі найчастіше помиляються не тому, що люди «погані», а тому, що jar виглядає як один файл, і мозок дуже хоче вважати його монолітом. Layered jar ламає цю звичку: фізично файл один, але логічно він розділений на частини, і це треба прийняти як нову інженерну оптику.
Помилка №1: вважати, що BOOT-INF/lib цілком дорівнює шару dependencies.
Каталог справді майже повністю збігається із шаром залежностей, але layered jar мислить точніше: усередині BOOT-INF/lib можуть опинитися SNAPSHOT-залежності, і вони логічно потраплять у snapshot-dependencies. Якщо цей момент упустити, далі ви починаєте неправильно пояснювати, чому «щось перезібралося» або чому шар порожній.
Помилка №2: сприймати spring-boot-loader як «частину мого застосунку» і намагатися його “оптимізувати”.
Boot loader — це інфраструктурний механізм запуску, а не ваш бізнес-код. Він лежить у jar рівно для того, щоб java -jar умів зібрати classpath із вкладених jar-ів. Спроби «прибрати зайве» тут зазвичай закінчуються тим, що артефакт перестає бути executable, і ви отримуєте дуже сумний ClassNotFoundException.
Помилка №3: панікувати через порожній snapshot-dependencies.
Дуже багато проєктів узагалі не використовують SNAPSHOT-залежності, і це правильно, тому шар snapshot-dependencies може бути порожнім. Це не баг і не «недозбирання», а просто наслідок того, що ви використовуєте релізні версії бібліотек.
Помилка №4: плутати «шар» і «каталог» та робити висновки за назвою папки.
Layered jar — це не «ще одна папка в архіві». Шари описуються індексом layers.idx, і шар може включати файли з різних місць, а один каталог може бути розділений на кілька шарів. Якщо триматися тільки фізичної структури, ви втрачаєте половину сенсу layered packaging.
Помилка №5: думати, що layered jar вимагає змінювати код застосунку.
У цій темі взагалі не потрібно переписувати контролери, сервіси чи репозиторії. Ми працюємо з пакуванням і структурою артефакту, а не з архітектурою catalog-service. Якщо ви раптом ловите себе на думці «треба б переробити сервіс, щоб jar став layered», це хороший момент зупинитися і нагадати собі: layered jar — це задача build/packaging-шару, а не прикладного коду.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ