Довелось мне обучать одного знакомого, желающего войти в ИТ (привет, Саша!). Человек он упорный, прошел разные курсы, стажировки, упорно продолжает идти вперед и уже вполне тянет на уровень джуна. Но иногда внезапно задает такие вопросы, из которых я понимаю, что у него огромные дыры в базовых знаниях и представлениях. На курсах этому, видимо, не учат.

Один из последних вопросов был про устройство сборки. И он показал явное непонимание того, как исходный код собирается в исполняемый файл и запускается. Начинающим обычно говорят в духе "вот создаешь Gradle-проект, в IDE жмешь кнопочку запуска и все работает". Gradle/Maven при этом представляются таким черным ящиком, в котором есть кнопка сборки и запуска, а внутри - черная магия. И как только возникает необходимость что-то в этом простом процессе изменить или понять - начинаются проблемы.

Сделаем свой Gradle, только маленький. Ну просто малюсенький.
Сделаем свой Gradle, только маленький. Ну просто малюсенький.

В этой статье я пробегусь по основам того, как в Java работает компиляция, а также покажу, как по шагам прийти к идее необходимости системы сборки и как написать свою простенькую систему. Ведь лучший способ понять, как что-то устроено внутри - сделать это самому.

.java и .class

Сперва небольшой ликбез.

Итак, наша Java (и прочие JVM языки такие как Kotlin) является языком с промежуточным байт-кодом.

Мы пишем исходный код на Java, сохраняем его в текстовом .java файле. Затем с помощью компилятора javac, идущего в комплекте поставки JDK, мы компилируем наш текст в байт-код в виде .class файла. Это уже бинарный файл, содержащий инструкции для виртуальной машины. Инструкции там примерно такие же, как и в любом другом машинном коде - сложить пару чисел, переместить содержимое из одной ячейки памяти в другую, вызвать указанный метод и т.п.

Полученный .class можно уже запустить с помощью специального приложения - виртуальной машины Java, JVM.

Зачем вся эта бодяга и почему бы сразу не исполнять наш код напрямую? Изначально идея была в том, чтобы единожды скомпилировав наше приложение в .class, мы потом могли запустить его на любой платформе, где есть JVM, хоть Windows, хоть Linux, хоть Java ME (помнит еще кто эту технологию?). В отличие от программ на других языках, которые сразу компилируются в нативный код, но он - разный для разных платформ, и чтобы запустить приложение на новой платформе его потребуется скомпилировать специально под нее.

То есть "другие" языки:

  • Нужно иметь компилятор под каждую целевую платформу.

  • Разработчик должен собрать и выложить отдельно версии для каждой целевой платформы.

  • Если разработчик недоступен или ваша платформа ему не интересна, а исходного кода нет - запустить программу вы не сможете.

А для JVM-языков:

  • Нужно иметь разные 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 тоже ничего не знает про то, где искать нужные библиотечные классы во время выполнения программы:

Запускаем JVM, передав ей кроме наших .class файлов еще и путь к .jar файлу с классами библиотеки
Запускаем JVM, передав ей кроме наших .class файлов еще и путь к .jar файлу с классами библиотеки

Если вместо цвета в консоли у вас выводятся спецсимволы, значит в вашей Windows по умолчанию поддержка цветной печати отключена, как ее включить описано тут

Подведем итог:

  • Сперва нам необходимо превратить все .java классы нашего проекта в скомпилированные .class файлы. Для этого мы должны вызвать компилятор javac, передав ему все .java файлы и все дополнительные библиотечные классы и архивы.

  • Затем мы должны запустить полученные .class файлы в JVM. Для этого надо вызвать java, передав ей пути ко всем местам где лежат наши .class, а так же имя главного класса, с которого надо начинать выполнение программы.

Наведем порядок

Хм, что-то в нашей директории стало слишком много всякого хлама. Вперемешку лежат исходные коды .java, скомпилированные файлы .class, зависимости .jar.

Давайте наведем немного порядок и разложим все по папочкам:

  • В src положим исходный код

  • В lib положим зависимости

  • В out будем собирать наши class файлы

Дополнительно давайте положим весь исходный код в пакет 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

Зачем нам куча .class файлов?

А ведь .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 не будут казаться вам черной магией, и станет немного понятнее, что там происходит под капотом.

Исходный код проекта можно найти в репозитории.

Комментарии (14)


  1. koyokeyo
    14.09.2023 10:28

    Хороший


  1. JediPhilosopher Автор
    14.09.2023 10:28
    +2

    На правах рекламы. Вообще в последнее время буквально все мои знакомые решили перекатиться в ИТ так или иначе. Спектр профессий исходный там разный, от пианистки до инженера-электрика. И задают вопросы все примерно одинаковые.

    Отвечать мне всем одно и то же надоело, поэтому я запилил ответы на вопросы в виде книги. Первая часть там - общие вопросы, типа "какой язык выбрать", вторая часть - конкретно начальная траектория вкатывания в Java. Какие навыки нужны от человека лично мне, чтобы я взял его джуном на работу.

    Может кому пригодится https://egor-smirnov.gitbook.io/voiti-v-aiti/vvedenie/voiti-v-aiti

    Написано оно до начала СВО, поэтому разделы про поиск работы и зарплаты сейчас, возможно, утратили актуальность


  1. javax
    14.09.2023 10:28
    +2

    Полезно. Я бы еще указал, что система директорий это некая договоренность. Очень удобно - приходишь в чужой проект и сразу знаешь что где лежит.

    И еще - винда это не грех, но там же есть WSL, так что можно даже на винде работать по взрослому в Linux, bash etc


    1. JediPhilosopher Автор
      14.09.2023 10:28
      +1

      Никогда не пробовал в винде писать баш-скрипты. Как по мне - какое-то извращение.
      Да и в целом для такой задачи баш не сильно лучше Cmd. Мой уровень незнания для них примерно одинаковый. Скажу по секрету, часть скрипта мне вообще ChatGPT написал.


      1. aspect04tenor
        14.09.2023 10:28

        Так если ваш уровень незнания одинаковый, то почему бы не попросить chatgpt писать на баше?

        Вы же Java девелопер, зачем писать то, что запустится только под виндой, если можно написать то, что запустится везде?


        1. JediPhilosopher Автор
          14.09.2023 10:28

          Затем что мне удобнее было сделать так, как я сделал. Если сделаете форк моего репозитория и перепишете скрипты на баше, или на чем угодно еще - дайте ссылку, добавлю в статью.


    1. unreal_undead2
      14.09.2023 10:28

      Я не понимаю, почему автор make не запользовал - достаточно низкоуровнево, чтобы показать какими командами реально собирается проект, но без необходимости вручную анализировать аргументы командной строки и т.п.


      1. JediPhilosopher Автор
        14.09.2023 10:28
        +1

        Потому что идея была как раз в том, чтобы показать аргументы командной строки, от простого к сложному. А make здесь - лишнее звено, которое еще надо отдельно изучать. Это ведь уже и есть система сборки. А задача была - показать как прийти к самой идее необходимости такой системы.


        1. unreal_undead2
          14.09.2023 10:28

          make всё таки общеизвестная система, не привязанная к коннкретному языку

          идея была как раз в том, чтобы показать аргументы командной строки

          Аргументы, передаваемые в javac и т.п. в Makefile и так будут в явном виде. А вот аргументы вашего скрипта и код типа if "%1"=="build" - это скорее лишняя сущность, отвлекающая от сути. Но если предполагается, что кто-то из читателей не знаком с make - может, проще и на cmd...


  1. Aleus1249355
    14.09.2023 10:28
    +2

    Какая адекватная статья!

    Я хотя и имею более 6 лет опыта в Java с многими тысячами строк кода и в общем-то хорошими скилами (если говнокодю, то знаю об этом ????). Но пришёл я уже в настроенный проект с maven и не было необходимости/времени разобраться как сборщик работают.

    Спасибо!


  1. Rusrst
    14.09.2023 10:28

    Да уж, про неперекомпиляцию сразу вспоминается инкременальная сборка gradle и то, что частенько она не работает :)


    1. JediPhilosopher Автор
      14.09.2023 10:28
      +1

      Про перекомпиляцию мне вспоминаются времена, когда я программировал на C++. Там своя атмосфера, когда изменение одного .h файла приводит к пересборке огромного проекта, так как этот заголовок включается во все файлы с исходным кодом.

      Приходилось использовать систему распределенной сборки IncrediBuild, стоявшую на всех машинах разработчиков и собиравшую проект распределенно по всей студии. Только тогда получалось какое-то разумное время.

      Слава Богу, в Java такой ерунды нет.


      1. tuxi
        14.09.2023 10:28

        Видать не филонили вы там ))) А то и пообедать и три раза кофе можно успеть испить пока пересоберется)


        1. JediPhilosopher Автор
          14.09.2023 10:28
          +1

          Мы игры делали, так что в это время можно было играть в какой-то другой билд или другой проект студии )