1. Что такое артефакт и почему JAR — это «посылка» с вашей программой
Когда вы пишете код, у вас есть ощущение, что «программа — это мои .kt файлы». Но как только вы нажимаете Run или запускаете сборку, происходит магия инженерной бюрократии: исходники превращаются в результат сборки, который можно передать другому человеку, серверу или самому себе через неделю, когда вы забудете, где была кнопка Run.
Вот этот результат сборки и называют артефактом. На JVM самый привычный артефакт — JAR-файл. Его удобно представлять как посылку: внутри лежат скомпилированные .class файлы (а иногда и ресурсы), плюс служебные метаданные. Идея простая: вы не носите с собой исходники и компилятор, вы отдаёте готовый «упакованный результат».
Чтобы не путаться, держите в голове простую таблицу:
| Термин | Простыми словами | Пример |
|---|---|---|
| Исходники | То, что вы пишете | |
| Сборка | Процесс превращения исходников в результат | |
| Артефакт | То, что получилось после сборки | |
| JAR | Формат артефакта для JVM | Один файл .jar |
И важная мысль дня: IDE часто скрывает детали, а JAR заставляет вас увидеть мир таким, какой он есть. Иногда это полезно. Иногда — внезапно больно, но тоже полезно.
2. Как Kotlin превращается в то, что JVM умеет запускать
Сейчас будет маленькое «закулисье», ровно настолько, чтобы понимать JAR и точку входа, но не настолько, чтобы мы уходили в тёмные леса байткода и шаманизма JVM. Kotlin/JVM компилируется в Java-совместимые .class файлы. И уже эти .class файлы JVM реально и запускает.
Особенность Kotlin, которую важно знать именно сегодня: top-level функции (то есть функции вне классов) всё равно должны куда-то «приземлиться» в мире JVM, где всё крутится вокруг классов. Поэтому Kotlin складывает такие функции в специальный класс, имя которого обычно строится из имени файла.
Например, если у вас есть файл Main.kt и в нём написано:
package app
fun main() {
println("Hello from JAR") // Hello from JAR
}
то на уровне JVM это будет выглядеть так, будто существует класс app.MainKt с методом main. С точки зрения Kotlin это почти незаметно, пока вы не начинаете настраивать сборку и запуск JAR, где нужно указать «а какой именно main запускать?».
Запомните как рабочее правило (без попытки запомнить всё навсегда): если ваш main лежит в файле Main.kt в пакете app, то имя точки входа для JVM обычно будет app.MainKt.
3. Точка входа: как JAR понимает, какой main запускать
Когда вы запускаете программу из IDE, она обычно сама понимает, какой main вы выбрали (потому что вы нажали зелёную кнопку именно рядом с ним). Но когда вы запускаете JAR вне IDE, «зелёной кнопки» нет, и JVM нужна инструкция: какой класс считать стартовым.
Есть два самых базовых способа запуска:
- java -cp ... <MainClass> — вы явно говорите JVM, какой класс стартовый.
- java -jar app.jar — вы говорите: «внутри JAR уже написано, что стартовать».
Второй способ работает, только если JAR запускаемый (runnable). А запускаемым его делает наличие файла манифеста с записью про главный класс. Внутри JAR есть служебный файл:
META-INF/MANIFEST.MF
И в нём может быть строка (упрощённо):
Main-Class: app.MainKt
Можно представить это как наклейку на коробке: «Открывать отсюда».
Нарисуем простую схему, чтобы не было ощущения, что это всё «магия»:
flowchart TD
A[Ваши .kt файлы] --> B[Компиляция Kotlin/JVM]
B --> C[.class файлы]
C --> D[JAR упаковка]
D --> E["JAR + META-INF/MANIFEST.MF"]
E --> F["java -jar ... запускает Main-Class"]
Мини-настройка в Gradle: где указывают main class
В рамках этого дня мы не углубляемся в то, как устроен Gradle внутри, но полезно увидеть, как это выглядит в build.gradle.kts, чтобы не пугаться.
Самый понятный «маркер намерения» — подключить плагин application и указать mainClass. Пример (фрагмент):
plugins {
kotlin("jvm") version "2.3.0"
application
}
application {
mainClass.set("app.MainKt")
}
Это читается почти как человеческий язык: «это приложение, точка входа такая-то». Под капотом Gradle поможет и с запуском, и с формированием метаданных.
Если у вас в проекте несколько main, то именно здесь вы «объявляете главного». И это сразу снижает хаос: вы больше не угадываете, какой main запускался вчера.
4. Где искать JAR после сборки
Когда вы только учитесь, кажется, что программа живёт в src. Когда вы чуть опытнее, вы понимаете: программа живёт в артефакте, а src — это рецепт. Gradle по умолчанию складывает результаты сборки в папку build, а JAR чаще всего лежит в:
build/libs/
Именно туда стоит заглядывать, если вы хотите увидеть то, что реально запускается вне IDE.
Чтобы не превращать этот раздел в сборник заклинаний, держите одну таблицу: «что делаю — что получаю».
| Действие | Что делает Gradle | Что вы ожидаете увидеть |
|---|---|---|
|
Собирает проект целиком (включая проверки, если настроены) | В папке появляются результаты |
|
Собирает именно JAR-упаковку | В появляется |
(если включён ) |
Запускает приложение через Gradle | Вывод в консоли, как при обычном запуске |
На Windows вместо ./gradlew обычно используется gradlew.bat, но смысл тот же: вы просите Gradle сделать действия, которые IDE раньше делала «втихаря».
5. Запуск вне IDE: почему args, env и рабочая директория важны
На этом моменте у многих случается первая «взрослая» встреча с реальностью: «в IDE работает, а в терминале — нет». И это не потому, что Kotlin обиделся. Просто IDE во время запуска подставляет массу настроек: рабочую директорию, аргументы, переменные окружения, иногда даже кодировку консоли. Вне IDE вы получаете «чистый запуск», где всё зависит от того, как именно вы команду запустили.
Чтобы сделать это ощущение максимально конкретным, продолжим наше учебное консольное приложение. Пусть оно называется CliBox (коробка с командами — звучит как что-то, что потом можно продать, если добавить туда слово «AI»).
Диагностика окружения: печатаем args, env и working directory
Мы сделаем маленькую функцию, которую можно вызывать в начале main. Она ничего «умного» не делает — только выводит факты. Но именно такие функции спасают часы отладки.
package app
fun printRunDiagnostics(args: Array<String>) {
val dir = System.getProperty("user.dir")
val mode = System.getenv("APP_MODE") ?: "not set"
println("Args count = ${args.size}") // Args count = ...
println("APP_MODE = $mode") // APP_MODE = ...
println("Working dir = $dir") // Working dir = ...
}
Обратите внимание: System.getenv("APP_MODE") возвращает nullable, и мы сразу применяем ?:. Это ровно тот навык, который вам уже пригодился в теме про null-safety.
Теперь подключим это в main:
package app
fun main(args: Array<String>) {
printRunDiagnostics(args)
println("CliBox started") // CliBox started
}
Аргументы командной строки: не предполагайте, что они есть
Очень частая ошибка при запуске вне IDE — ожидать, что аргументы «как-то появятся». Но если вы не передали аргументы, args.size == 0, и args[0] ломается.
Сделаем команду hello, которая берёт имя из args:
package app
fun main(args: Array<String>) {
val name = if (args.size >= 2) args[1] else "stranger"
val cmd = if (args.isNotEmpty()) args[0] else "help"
if (cmd == "hello") println("Hello, $name!") else println("Use: hello <name>")
// Hello, Alice! (пример)
}
Здесь формат простой: первый аргумент — команда, второй — параметр. Это не самый мощный CLI, но он отлично подходит для понимания того, что происходит при запуске JAR.
6. JAR в реальной жизни: библиотека, приложение и «прогон руками»
Важно не попасть в ловушку «раз JAR, значит запускается». JAR — это контейнер, и он может быть как библиотекой, так и приложением. У библиотеки может не быть точки входа вообще: её подключают в другой проект как зависимость, а запускать «саму по себе» не предполагается.
У приложения, наоборот, есть смысл «запустить и получить поведение». Тогда нужен main и обычно нужен Main-Class в манифесте (если вы хотите запускать через java -jar).
Иногда полезно знать запасной вариант запуска без runnable-манифеста. Он выглядит так:
java -cp app.jar app.MainKt
Это читается так: «положи app.jar в classpath и запусти класс app.MainKt». Но для начинающего лучше стремиться к понятному runnable-сценарию, чтобы запуск был коротким и предсказуемым.
Мини-прогон: от сборки до запуска
Сейчас соберём в голове полный маршрут. Представьте, что вы хотите отправить своё приложение другу (или на сервер), и IDE там нет. Тогда вы обычно делаете две части: собираете артефакт и запускаете его.
Сборка в терминале в корне проекта (там, где лежит build.gradle.kts):
./gradlew build
После этого проверяете папку:
ls build/libs
И находите JAR (имя зависит от version и названия проекта), например:
app-1.0.0.jar
Запуск:
java -jar build/libs/app-1.0.0.jar hello Alice
И ожидаете увидеть что-то вроде:
Hello, Alice!
Если у вас в main ещё включена диагностика, вы увидите и аргументы, и рабочую директорию, и значение APP_MODE.
Переменные окружения: «подкрутить поведение» без переписывания кода
В реальности программы часто получают настройки через env-переменные, потому что это удобно для запуска в разных окружениях. Даже для учебного CliBox можно добавить логику: если APP_MODE=dev, то печатать дополнительный текст.
package app
fun isDevMode(): Boolean {
val mode = System.getenv("APP_MODE") ?: return false
return mode.lowercase() == "dev"
}
И используем:
package app
fun main(args: Array<String>) {
if (isDevMode()) println("Dev mode is ON") // Dev mode is ON
println("CliBox started") // CliBox started
}
Тогда при запуске (примерно, синтаксис зависит от оболочки):
APP_MODE=dev java -jar build/libs/app-1.0.0.jar
Программа меняет поведение без изменения исходников — только за счёт окружения. Именно поэтому запуск вне IDE так важен: вы начинаете видеть, что программа — это не только код, но и условия запуска.
Почему «в IDE работает, а в JAR нет»: как думать, чтобы не паниковать
Когда программа ломается вне IDE, мозг обычно предлагает два пути: «я всё сломал» и «компьютер сломался». Есть более спокойный третий путь: сравнить условия запуска.
В IDE у вас могли быть настроены аргументы, которые вы забыли передать в java -jar. У вас могла быть выставлена рабочая директория на корень проекта, а в терминале вы запустили JAR из другой папки. У вас могла быть установлена переменная окружения в конфигурации Run, а в обычной консоли она не задана.
Поэтому полезная привычка: перед тем как чинить код, распечатайте факты. Ровно для этого мы и писали printRunDiagnostics. Это маленький «фонарик»: он не чинит проблему, но помогает не блуждать в темноте с криком «где я?».
7. Типичные ошибки при сборке и запуске JAR
Ошибка №1: думать, что «любой JAR запускается командой java -jar».
Формат JAR один, но смысл может быть разный. Если JAR — библиотека, у него может не быть точки входа, и это нормально. Даже если у вас есть main в исходниках, JAR может быть собран без нужного Main-Class в манифесте, и тогда java -jar не поймёт, что запускать. В таких случаях либо настраивают runnable JAR, либо запускают через -cp с указанием main-класса.
Ошибка №2: несколько main в проекте и «угадайка», какой запускается.
На ранних этапах обучения легко создать второй main «для эксперимента», потом третий «для теста», а потом удивляться, почему Gradle запускает не то. IDE здесь обычно спасает, потому что вы кликаете по конкретному main, а вот сборка JAR хочет однозначности. Лучше заранее держать один главный main для приложения и не превращать проект в парк аттракционов.
Ошибка №3: обращение к args[0] без проверки размера.
В IDE вы могли всегда запускать программу с аргументами (потому что один раз настроили Run Configuration), а в терминале забыли их передать. Итог — ошибка выхода за границы массива. Лечится простой дисциплиной: сначала проверяем args.isNotEmpty() или args.size, и только потом читаем по индексу.
Ошибка №4: ожидание, что env-переменная «точно задана».
System.getenv("X") возвращает String?, и это честный контракт: переменной может не быть. В IDE вы могли задать её в настройках запуска, а в терминале она пустая. Если код не готов к null, вы получите либо неправильное поведение, либо аварийное завершение, либо «странные значения по умолчанию». Elvis-оператор ?: и аккуратные проверки здесь — ваши друзья.
Ошибка №5: непонимание рабочей директории и относительных путей (даже если вы пока не работаете с файлами).
Даже если вы ещё не читаете файлы, рабочая директория влияет на многое: где вы запускаете процесс, какие относительные пути будут «считаться», откуда вы ожидаете ресурсы. В IDE рабочая директория часто выставлена в корень проекта автоматически, а в терминале вы можете стоять в совершенно другом месте. Поэтому вывод System.getProperty("user.dir") — неожиданно полезная диагностика.
Ошибка №6: пытаться «чинить» Gradle, не понимая, что вы чините конфиг, а не приложение.
Когда сборка не получается, новички иногда начинают добавлять строки в build.gradle.kts почти как в волшебный свиток. Хорошая стратегия мягче: помнить, что build.gradle.kts — это конфигурация сборки, править её маленькими шагами, следить за скобками и кавычками и после изменений обновлять модель проекта (sync), иначе IDE и Gradle могут жить «в разных реальностях».
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ