JavaRush /Курси /Spring Boot /Layered jar: чотири шари Spring Boot

Layered jar: чотири шари Spring Boot

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

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 є два великі «світи»:

  1. Код запуску (Spring Boot loader) — класи, які вміють стартувати застосунок і завантажувати вкладені jar-залежності.
  2. Вміст застосунку — ваш код, ваші ресурси і ваші залежності.

У спрощеному вигляді це виглядає так:

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 або
snapshot-dependencies
Оновили версію Spring Boot змінився loader + залежності spring-boot-loader і
dependencies
Підключили 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-шару, а не прикладного коду.

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