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 и всё остальное, что прилетело через starter’ы и transitive dependencies.
И это важный психологический момент: когда вы делаете bootJar, вы получаете артефакт, который можно передать другому человеку (или системе) — и он будет самодостаточным, потому что библиотеки лежат прямо внутри.
Что лежит в org/springframework/boot/loader/...
Это boot loader — механизм, благодаря которому java -jar работает именно «по-бутовски». Он читает манифест, понимает, какой main-класс запускать, и создаёт classpath так, чтобы JVM увидела ваши классы и вложенные зависимости.
И вот здесь появляется важная связка: layered jar будет выделять boot loader в отдельный слой, потому что по частоте изменений он живёт своей жизнью (обычно меняется только при обновлении версии Boot).
2. Layered jar и индекс layers.idx
До этого момента jar можно было воспринимать как чемодан: открыл — всё внутри, закрыл — пошёл. Но для container-friendly мышления чемодан неудобен: если вы поменяли одну футболку, вам приходится «пересобирать» весь чемодан заново. Layered jar — это как чемодан с секциями и ярлычками: вещи всё ещё в одном чемодане, но теперь можно однозначно сказать, что где лежит, и что меняется чаще. Поэтому в этой части мы вводим layered jar как тот же executable jar, но с добавленной мета-информацией о слоях.
Что такое layered jar в человеческих терминах
Layered jar — это executable jar Spring Boot, в котором присутствует описание слоёв: какие файлы относятся к «зависимостям», какие — к «коду приложения», какие — к «лоадеру» и т.д.
Ключевой файл, который нас интересует: layers.idx. Это индекс слоёв внутри jar. Он не делает приложение «быстрее само по себе», но делает структуру артефакта явной для инструментов, которые умеют эту структуру использовать.
Как проверить layered jar и когда нужна явная настройка
В текущем Boot baseline 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") {
// Optional: явно фиксируем layered packaging на задаче `bootJar`
layered()
}
Такую настройку удобно держать в build-файле, если вы сознательно хотите зафиксировать layered packaging или позже перейти к кастомизации слоёв. Но базовый вопрос сначала всегда один: есть ли у jar индекс слоёв и совпадает ли он с тем, как вы понимаете структуру сервиса.
Как выглядит идея layers.idx
Формат layers.idx можно воспринимать как «оглавление» или «карту упаковки». Он говорит: вот у нас есть слой dependencies, вот application, и т.д.
Схематично (упрощённо) мысль такая:
layers.idx
├─ dependencies -> часть BOOT-INF/lib (release зависимости)
├─ 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 (release) | редко | 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 говорит «где лежит файл». Логическая структура слоёв отвечает на другой вопрос: «к какой группе изменений относится файл и как его должен видеть packaging-механизм».
Посмотрим на несколько характерных соответствий:
| Где лежит файл в 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 слоя, а не прикладного кода.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ