Делаем свою простейшую систему сборки для Java
Довелось мне обучать одного знакомого, желающего войти в ИТ (привет, Саша!). Человек он упорный, прошел разные курсы, стажировки, упорно продолжает идти вперед и уже вполне тянет на уровень джуна. Но иногда внезапно задает такие вопросы, из которых я понимаю, что у него огромные дыры в базовых знаниях и представлениях. На курсах этому, видимо, не учат.
Один из последних вопросов был про устройство сборки. И он показал явное непонимание того, как исходный код собирается в исполняемый файл и запускается. Начинающим обычно говорят в духе "вот создаешь Gradle-проект, в IDE жмешь кнопочку запуска и все работает". Gradle/Maven при этом представляются таким черным ящиком, в котором есть кнопка сборки и запуска, а внутри - черная магия. И как только возникает необходимость что-то в этом простом процессе изменить или понять - начинаются проблемы.
В этой статье я пробегусь по основам того, как в Java работает компиляция, а также покажу, как по шагам прийти к идее необходимости системы сборки и как написать свою простенькую систему. Ведь лучший способ понять, как что-то устроено внутри - сделать это самому.
.java и .class
Итак, наша Java (и прочие JVM языки такие как Kotlin) является языком с промежуточным байт-кодом.
Мы пишем исходный код на Java, сохраняем его в текстовом .java файле. Затем с помощью компилятора javac
, идущего в комплекте поставки JDK, мы компилируем наш текст в байт-код в виде .class файла. Это уже бинарный файл, содержащий инструкции для виртуальной машины. Инструкции там примерно такие же, как и в любом другом машинном коде - сложить пару чисел, переместить содержимое из одной ячейки памяти в другую, вызвать указанный метод и т.п.
Полученный .class можно уже запустить с помощью специального приложения - виртуальной машины Java, JVM.
Зачем вся эта бодяга и почему бы сразу не исполнять наш код напрямую? Изначально идея была в том, чтобы единожды скомпилировав наше приложение в .class, мы потом могли запустить его на любой платформе, где есть JVM, хоть Windows, хоть Linux, хоть Java ME (помнит еще кто эту технологию?). В отличие от программ на других языках, которые сразу компилируются в нативный код, но он - разный для разных платформ, и чтобы запустить приложение на новой платформе его потребуется скомпилировать специально под нее.
- Нужно иметь компилятор под каждую целевую платформу.
- Разработчик должен собрать и выложить отдельно версии для каждой целевой платформы.
- Если разработчик недоступен или ваша платформа ему не интересна, а исходного кода нет - запустить программу вы не сможете.
- Нужно иметь разные JVM, а компилятор один (в те времена JVM была простой, а компиляторы - сложными, и это было преимуществом).
- Имея скомпилированное приложение, вы можете запустить его на любой платформе, где есть JVM, от разработчика приложения вам ничего не нужно.
Вот и родился этот подход с промежуточным форматом. Но для сборки и запуска он да, представляет некоторые неудобства, ведь нужно научиться работать с двумя разными инструментами командных строки - компилятором javac
и виртуальной машиной java
.
Компилируем один файл
Итак, напишем простой HelloWorld:
public class HelloWorld { public static void main(String[] args) { System.out.println("Hello World"); } }
И теперь скомпилируем его вручную:
javac HelloWorld.java
Ура, мы получили .class файл. Можно заглянуть внутрь - увидим кучу разных байт. Это и есть инструкции JVM, плюс всякая служебная информация.
Дальше этот .class файл мы можем запустить в JVM. Для этого нам надо вызвать JVM (java.exe
), сказать ей где искать наши классы (-cp .
говорит искать классы в этой же папке) и какой класс надо запустить (наш HelloWorld
)
java -cp . HelloWorld
Все работает. javac скомпилировал из нашего исходного кода .class, а виртуальная машина Java запустила его и вывела результат.
Компилируем несколько файлов
Окей, один файл - это недостаточно по-джавовски. Маловато энтерпрайза и абстрактных фабрик. Давайте добавим еще два класса, делающих некую работу, и положим их в пакет print:
import print.*; public class HelloWorld { public static void main(String[] args) { IHelloWorldPrinter printer = new ConsoleHelloWorldPrinter(); printer.print("Hello World"); } } // print/IHelloWorldPrinter.java public interface IHelloWorldPrinter { public void print(String str); } // print/ConsoleHelloWorldPrinter.java public class ConsoleHelloWorldPrinter implements IHelloWorldPrinter { @Override public void print(String str) { System.out.println(str); } }
Теперь чтобы скомпилировать наше приложение надо передать javac уже три файла:
javac HelloWorld.java print/ConsoleHelloWorldPrinter.java print/IHelloWorldPrinter.java
И JVM должна знать, где они все лежат, и ей тоже нужны все три чтобы запустить наше приложение. javac
по умолчанию кладет скомпилированные .class файлы рядом с исходными .java файлами, так что нам надо указать java.exe
что классы надо искать в том числе в папке ./print:
java -cp .;print HelloWorld
Добавим зависимость
Все еще недостаточно энтерпрайзнутости. Например, мы хотим выводить в консоль разными цветами. Для этого мы возьмем библиотеку JColor и добавим к проекту.
Возьмем где-то .jar файл (не важно где, на сайте автора, например). .jar по сути это просто .zip архив с теми же самыми .class файлами. Можно его открыть любым архиватором и убедиться:
Иногда, кстати, приходится внутри что-то смотреть или даже подменять, так что имейте в виду - всегда можно его распаковать и запаковать обратно.
Jar удобен чтобы не копировать кучу скомпилированных файлов по одному, можно засунуть в архив и распространять и использовать единым файлом.
Итак, слегка модернизируем наш CosoleHelloWorldPrinter, чтобы он печатал текст каким-нибудь другим цветом:
import com.diogonunes.jcolor.*; public class ConsoleHelloWorldPrinter implements IHelloWorldPrinter { @Override public void print(String str) { System.out.println(Ansi.colorize(str, Attribute.YELLOW_TEXT(), Attribute.MAGENTA_BACK())); } }
Если мы сейчас попробуем запустить javac
, то он ругнется и скажет что не знает, где ему брать классы из пакета com.diogonunes.jcolor
. Чтобы он их нашел - надо ему в явном виде указать путь к .jar файлу:
javac -cp JColor-5.5.1.jar;. HelloWorld.java print/ConsoleHelloWorldPrinter.java print/IHelloWorldPrinter.java
и на этапе запуска тоже, ведь java
тоже ничего не знает про то, где искать нужные библиотечные классы во время выполнения программы:
Если вместо цвета в консоли у вас выводятся спецсимволы, значит в вашей Windows по умолчанию поддержка цветной печати отключена, как ее включить описано тут
- Сперва нам необходимо превратить все .java классы нашего проекта в скомпилированные .class файлы. Для этого мы должны вызвать компилятор
javac
, передав ему все .java файлы и все дополнительные библиотечные классы и архивы. - Затем мы должны запустить полученные .class файлы в JVM. Для этого надо вызвать
java
, передав ей пути ко всем местам где лежат наши .class, а так же имя главного класса, с которого надо начинать выполнение программы.
Наведем порядок
Хм, что-то в нашей директории стало слишком много всякого хлама. Вперемешку лежат исходные коды .java, скомпилированные файлы .class, зависимости .jar.
Давайте наведем немного порядок и разложим все по папочкам:
Дополнительно давайте положим весь исходный код в пакет helloworld
. Использование классов без пакетов в Java приносит некоторые трудности. Так что переложим код в src/helloworld, и соответствующим образом изменим package
и import
директивы.
Нам потребуется немного модифицировать наши командные строки для сборки и запуска приложения:
javac -d out -cp lib/JColor-5.5.1.jar;src src/helloworld/HelloWorld.java src/helloworld/print/ConsoleHelloWorldPrinter.java src/helloworld/print/IHelloWorldPrinter.java java -cp lib/JColor-5.5.1.jar;out HelloWorld
Что-то мне надоело каждый раз прописывать вручную все имена файлов. А если мы добавим еще несколько .java с исходным кодом? Нам придется опять дописывать их к командным строкам запуска javaс
и java
. Очень легко что-то пропустить, забыть или перепутать.
Не, мыжпрограммисты. Давайте напишем скриптик, который компилирует все классы, лежащие в src. К сожалению, javac
такого из коробки не умеет и может только обрабатывать список файлов, переданный в командной строке. Ничего, сгенерируем временный список со всеми исходными файлами с помощью команды dir, положим его во временный файл build/sources.txt, а затем прочитаем его через javac
. Дополнительно еще переделаем вывод, сложим классы в out/classes:
mkdir build dir /s /B .java > build/sources.txt javac -d out/classes -cp lib/;src /sources.txt
А ведь .jar это удобно. Давайте сделаем сборку в него. Добавим еще строчку, собирающую содержимое нашей папки out
cd out/classes jar cf ../HelloWorld.jar .
Теперь вместо мешанины отдельных классов у нас есть один готовый файл нашего приложения, лежащий в out/HelloWorld.jar.
Автоматизируем
Заранее прошу прощения у всех линуксоидов, но напишу я скрипты свои на Windows Shell. Поскольку сейчас в моде Gradle, назовем наш скрипт microgradle.
Итак, на этапе сборки нам понадобятся все файлы лежащие в src и все .jar файлы лежащие в lib. Как было показано выше, сперва соберем исходные файлы из src в промежуточный файл, скормим его компилятору javac, получим собранные файлы в /out/classes, затем с помощью утилиты jar
соберем это в архив.
На этапе запуска - возьмем все .jar файлы из out и lib и передадим их приложению java
.
Добавим две команды для нашего скрипта, build
для сборки и run
для запуска:
@echo off if "%1"=="build" ( echo Building... mkdir build dir /s /B *.java > build/sources.txt javac -d out/classes -cp lib/*;src @build/sources.txt cd out/classes jar cf ../HelloWorld.jar . echo Build complete ) else if "%1"=="run" ( echo Running... call java -cp ./out/*;lib/* %2 ) else ( echo Unknown command: %1 )
Теперь можно выполнить microgradle build
в папке с правильной структурой файлов, и наша микросборочная система все скомпилирует. А затем сделать microgradle run <Имя стартового класса>
- и вуаля, мы получаем запущенное приложение
Ура, поздравляю, мы сделали простенькую систему сборки. Теперь можно создать нужную структуру папок, положить в них исходный код на Java и собирать или запускать его одной строчкой. Все как у больших Gradle/Maven.
Управление зависимостями
Конечно, про "все как у больших" я пошутил. Нашей системе не хватает еще как минимум одной очень важной вещи - управления зависимостями.
В примере выше мы просто откуда-то скачивали jar файл с библиотекой. Окей если такой файл один, вручную это сделать просто, но что если их много? Откуда их качать, где хранить?
Для решения этих вопросов в современные системы сборки встроены системы управления зависимостями. Вы в описании проекта лишь указываете, что вам нужна библиотека такой-то версии, а они дальше сами сходят в интернет в один из репозиториев, найдут там эту библиотеку, скачают jar файл и добавят его куда надо.
Репозитории поддерживаются различными организациями, как коммерческими так и нет. В Java-мире крупнейший такой репозиторий - Maven Central. Изначально, как понятно из названия, созданный для нужд системы сборки Maven, но через стандартный интерфейс им могут пользоваться и другие системы сборки и их встроенные системы управления зависимостями.
С репозиториями, кстати, иногда бывают проблемы. Недавно компания JFrog решила прибить свой репозиторий JCenter, который был вторым по популярности. Это сломало очень много билдов различных проектов, так как многие библиотеки, особенно для Android, выкладывались только туда (процесс публикации там был проще, чем в Maven Central).
Так что никакой магии тут нет. Есть онлайн-хранилища библиотек, которые кто-то поддерживает, крупные и малые (используя различное ПО вы можете создать и свое собственное приватное хранилище для своей организации). Авторы библиотек выкладывают свои .jar файлы туда, а затем различные системы сборки ищут и скачивают эти файлы оттуда.
Реализовывать свою собственную систему управления зависимости слишком сложно, и тут мы этого делать уже не будем. Но мы можем использовать готовую, например, Apache Ivy. Эта система встроена в систему сборки Apache Ant, но может использоваться и независимо, в виде отдельного консольного приложения.
Напишем файл ivy.xml
с конфигурацией и положим в корень нашего проекта. Опишем тут, что для работы нашего проекта нам нужна библиотека JColor версии 5.5.1:
<ivy-module version="2.0"> <info organisation="com.example" module="microgradle"/> <dependencies> <dependency org="com.diogonunes" name="JColor" rev="5.5.1" conf="default"/> </dependencies> </ivy-module>
Положим в корень нашего проекта исполняемый архив Ivy (взять его можно с официального сайта) и допишем таск dependencies
в наш скрипт microgradle
@echo off if "%1"=="build" ( echo Building... mkdir build dir /s /B *.java > build/sources.txt javac -d out/classes -cp lib/*;src @build/sources.txt cd out/classes jar cf ../HelloWorld.jar . echo Build complete ) else if "%1"=="run" ( echo Running... call java -cp ./out/*;lib/* %2 ) else if "%1"=="dependencies" ( echo Resolving dependencies... mkdir lib java -jar ./ivy-2.5.2.jar -retrieve "lib/[artifact]-[type]-[revision].[ext] ) else ( echo Unknown command: %1 )
Вуаля, теперь нам не нужно хранить нашу зависимость вручную в папке lib. Все что надо - прописать в нашем файлике, затем сделать шаг microgradle dependencies
. Он запустит под капотом Ivy, который прочитает наш ivy.xml и скачает с репозитория Maven Central все что там написано в папку lib.
После этого шаг build
уже подхватит эти скачанные .jar файлы и все соберет как надо.
Что дальше
Дальше можно придумывать много чего. Например, полноценные системы сборки не перекомпилируют файлы, если в них нет изменений, в отличие от нашей. А на проектах с сотнями файлов это важно. Полноценные системы имеют локальный кеш зависимостей, имеют конфигурируемые настройки, плагины, возможность создавать собственные шаги сборки и много-много чего еще.
Но наша микросистема уже имеет все основные черты настоящей системы сборки:
- Имеет структуру папок и файлов с описанием проекта
- Умеет скачивать зависимости
- Умеет компилировать проект
- Умеет запускать
Вполне достаточно для сборки маленького проекта.
Заключение
Этим туториалом я попытался показать, как вообще эти системы работают, а главное - зачем они нужны. Мы по шагам прошли от ручной компиляции отдельных файлов, которая с каждым шагом становилась все более сложной и громоздкой, к автоматической системе. Умеющей управлять зависимостями, автоматически подхватывать новые файлы, собирать и запускать наше приложение. Повторив тем самым в миниатюре эволюцию реальных систем.
Надеюсь, теперь Gradle или Maven не будут казаться вам черной магией, и станет немного понятнее, что там происходит под капотом.
Исходный код проекта можно найти в репозитории.