Предисловие

В данной статье предлагаю рассмотреть историю создания мной сборщика Java проектов под названием Conveyor.

Зачем понадобилось писать велосипед, когда уже существуют Maven и Gradle? Конкретной причины нет, мне просто нравится писать код. Как известно, лучший код - это код, которого нет, потому что его не нужно поддерживать и в нем однозначно нет багов. Поэтому по основной работе я пишу мало, но навыки нужно оттачивать. Мне захотелось узнать, что происходит у существующих сборщиков Java проектов внутри, а лучшим способом разобраться в принципе работы, я считаю попытаться воспроизвести это самому. 

Для чего эта статья? Хотелось бы поделиться опытом написания проекта сложности выше средней (по моему мнению), описать с какими проблемами пришлось столкнуться, посмотреть на причины принятия технических решений, примеры использования шаблонов проектирования. При этом фокус будет делаться на том, каким проект получился в итоге. В заголовках указывается хэш коммита, на момент которого рассматриваем под лупой проект.

Для кого эта статья? В первую очередь эта статья направлена на пока еще не очень опытных инженеров, которые хотят познакомиться с проектом сложнее Hello world на Spring Boot и узнать что-то новое по дороге. Также приглашаю к обсуждению и критике опытных коллег в том числе. И хотя полученные знания весьма фрагментированы, есть надежда что ход повествования сможет завлечь читателя.

Первые шаги (d59a01c9)

Я очень люблю Test Driven Development. Мне действительно кажется, что при разработке серверной части эта практика помогает сделать ваш продукт более тестируемым, а значит устойчивым к изменениям и развитию в будущем. Надеюсь, описанное далее послужит доказательством к этим словам (рекомендую к прочтению книгу Test Driven Development: By Example). В процессе разработки всегда старался описать новый желаемый функционал в тесте.

@Test
void givenTaskBoundToLowerThanTargetStage_whenConstructToStage_thenTaskWasExecuted(
    @TempDir Path path,
    ConveyorModule module,
    BuilderFactory factory
) throws Exception {
    factory.repositoryBuilder(path)
        .schematicDefinition(
            factory.schematicDefinitionBuilder()
                .name("instant")
        )
        .jar(
            factory.jarBuilder("instant", path)
        )
        .install(path);

    module.construct(
        factory.schematicDefinitionBuilder()
            .repository(path)
            .plugin(
                "group",
                "instant",
                "1.0.0",
                Map.of("instant", "COMPILE-RUN")
            )
            .conveyorJson(path),
        Stage.TEST
    );

    assertThat(defaultConstructionDirectory(path).resolve("instant")).exists();
}

Если не получалось сразу написать тест, то проводил рефакторинг, чтобы добавить методов и классов для тестирования. После того, как свежий тест загорится красным, начинал непосредственно реализацию. Рефакторинг основной кодовой базы проводил в двух случаях: во-первых, когда все тесты проходят, но нужно навести порядок, во-вторых, когда текущая архитектура не поддерживает новые требования. Для простоты, всегда стоит разделять непосредственное внедрение новых способов использования от адаптации текущей кодовой базы для этого.

Чтобы повысить стабильность уже написанных тестов, они используют продукт только через публичный интерфейс, никаких “черных входов”. Это позволяет в любой момент убрать текущую реализацию и заменить ее новой, оставив при этом тесты не тронутыми (и зелеными). Помогает в этом архитектура портов и адаптеров (More Testable Code with the Hexagonal Architecture, The Clean Architecture, ). Таким образом, есть всего два публичных класса: интерфейс ConveyorModule (сейчас кажется, что название просто Conveyor было бы удачнее) и класс его реализующий ConveyorFacade. Остальные классы видимы только внутри пакета и являются деталями реализации. Это дало возможность не один раз проводить рефакторинг без опасений, что больше половины тестов перестанут компилироваться. Вынесение сторонних библиотек за интерфейсы предметной области помогает отсрочить окончательное решение о выборе зависимостей (можно, например, в процессе разработки пробовать различные варианты хранилищ не переписывая бизнес логику) и развернуть направление этих зависимостей.

Dependency inversion
Dependency inversion

Java модули впервые появились в 9-ой версии, но до сих пор многие разработки не прочувствовали их преимуществ (автор статьи в их числе). Также затрудняет ситуацию то, что некоторые популярные библиотеки не имеют module-info.java по разным причинам. И все же было принято решение сделать проект модульным сразу, так как механизм ServiceLoader позволяет подгружать из module path реализацию интерфейсов, если их предварительно объявить в module-info.java с помощью инструкции provides. Таким образом получаем из коробки механизм поиска и подключения плагинов для нашего сборщика. Это привело к множеству проблем в будущем, но об этом потом…

Что же имеем на момент рассматриваемого коммита? На вход принимаем путь к файлу с описанием проекта в формате JSON и стадию сборки (clean, compile, test, archive, publish по аналогии с Maven). Читаем конфигурацию, загружаем указанными плагины вместе с прямыми и транзитивными зависимостями, достаем из плагинов задачи, привязанные к стадии сборки вплоть до указанной, и исполняем их. Стоит сказать пару слов о выборе версий зависимостей. Оказывается существует несколько подходов: Maven выбирает самую ближайшую в графе версию, а Gradle - самую старшую. Нельзя сказать, что какой-то метод однозначно лучше, однако при использовании Maven порядок объявления зависимостей в pom.xml влияет на выбор итоговых версий. На мой взгляд выбор старшей версии является более логичным, такой стратегии и придерживался при реализации своего алгоритма.

@Override
public BuildResult build(Path projectDefinitionPath, Stage stage) {
    if (!Files.exists(projectDefinitionPath)) {
        return new CouldNotFindProjectDefinition(projectDefinitionPath);
    }
    var projectDefinition = jsonReader.read(projectDefinitionPath, ProjectDefinition.class);
    var repository = new DirectoryRepository(
        projectDefinitionPath.getParent().resolve(projectDefinition.repository()),
        jsonReader
    );
    var artifacts = pluginsWithDependencies(repository, projectDefinition.plugins())
        .stream()
        .map(artifactDefinition -> repository.artifact(artifactDefinition.name(), artifactDefinition.version()))
        .toList();
    ServiceLoader.load(moduleLayer(artifacts), ConveyorPlugin.class)
        .stream()
        .map(Provider::get)
        .map(conveyorPlugin -> bindings(conveyorPlugin, projectDefinitionPath, projectDefinition))
        .flatMap(Collection::stream)
        .filter(binding -> binding.stage().compareTo(stage) <= 0)
        .map(ConveyorTaskBinding::task)
        .forEach(ConveyorTask::execute);
    return new BuildSucceeded(projectDefinitionPath, projectDefinition.name(), projectDefinition.version());
}

Можно заметить, что вместо выбрасывания исключения, если не смогли найти файл с описанием проекта, возвращается реализация CouldNotFindProjectDefinition sealed интерфейса BuildResult. Попытался писать код без использования исключений (просто попробовать, некоторые считают их формой goto), чтобы компилятор позволял обрабатывать все ветки исполнения программы. Однако быстро разочаровался в таком подходе. Исключения очень плотно сидят как в стандартной библиотеке Java, так и в сторонних. Если сильно хочется, чтобы компилятор подсказывал, где могут случиться ошибки, можно использовать проверяемые исключений. Основной их минус в отсутствии обратной совместимости при добавлении или удалении из сигнатуры метода такого исключения, но применении во внутреннем API может быть оправданным. Я же использовал только непроверяемые.

Для тестирования есть заготовленные описания проекта в ресурсах, а код тестовых плагинов находится в соседних Gradle модулях. Как индикатор выполнения той или иной задачи, они создают различные файлы при сборке, которые тесты потом проверяют.

Генерация тестовых проектов во время выполнения (3c15dc5e)

Пришло время первого рефакторинга тестов. Стало очевидно, что код плагинов слишком похож, да и сложно было бы ориентироваться в структуре проекта при увеличении количества тестовых плагинов. Нам нужно сгенерировать код во время выполнения с возможностью небольшой настройки, скомпилировать его и упаковать его в JAR для дальнейшего использования. С подобной задачей раньше сталкивался, до компилятора можно достучаться через ToolProvider.getSystemJavaCompiler(), получить список скомпилированных классов можно с помощью декоратора StandardJavaFileManager, собственные реализации SimpleJavaFileObject помогут как получить исходный код из String, так и сохранить байткод в массив байтов. Оглядываясь назад, могу сказать что использование временных файлов и директорий для компиляции во время выполнения оказалось проще, чем компиляция в памяти.

private Collection<FileObject> compiled() {
    var compiler = ToolProvider.getSystemJavaCompiler();
    var fileManager = new InMemoryFileManager(standardJavaFileManager(compiler));
    compiler.getTask(
            null,
            fileManager,
            null,
            List.of("--module-path", modulePath()),
            List.of(),
            List.of(
                new StringJavaFileObject(packageName(), className() + ".java", classSourceCode()),
                new StringJavaFileObject("module-info.java", moduleInfoSourceCode())
            )
        )
        .call();
    return fileManager.compiled();
}
private static final class InMemoryFileManager extends ForwardingJavaFileManager<StandardJavaFileManager> {

    private final Collection<InMemoryJavaFileObject> inMemoryJavaFileObjects = new ArrayList<>();

    InMemoryFileManager(StandardJavaFileManager fileManager) {
        super(fileManager);
    }

    @Override
    public JavaFileObject getJavaFileForOutput(
        Location location,
        String className,
        JavaFileObject.Kind kind,
        FileObject sibling
    ) {
        var inMemoryJavaFileObject = new InMemoryJavaFileObject(className);
        inMemoryJavaFileObjects.add(inMemoryJavaFileObject);
        return inMemoryJavaFileObject;
    }

    Collection<FileObject> compiled() {
        return List.copyOf(inMemoryJavaFileObjects);
    }
}
private static final class InMemoryJavaFileObject extends SimpleJavaFileObject {

    private final String className;
    private final ByteArrayOutputStream outputStream = new ByteArrayOutputStream();

    InMemoryJavaFileObject(String className) {
        super(URI.create(className.replace('.', '/') + ".class"), Kind.CLASS);
        this.className = className;
    }

    @Override
    public String getName() {
        return className;
    }

    @Override
    public OutputStream openOutputStream() {
        return outputStream;
    }

    @Override
    public InputStream openInputStream() {
        return new ByteArrayInputStream(outputStream.toByteArray());
    }
}
private static final class StringJavaFileObject extends SimpleJavaFileObject {

    private final String source;

    StringJavaFileObject(String packageName, String name, String source) {
        super(URI.create(packageName).resolve(name), Kind.SOURCE);
        this.source = source;
    }

    StringJavaFileObject(String name, String source) {
        this("", name, source);
    }

    @Override
    public CharSequence getCharContent(boolean ignoreEncodingErrors) {
        return source;
    }
}

Наращивание функционала (b8286e27)

Стала заметен один нюанс выбора версии зависимостей. Если брать просто старшую версию, то возможна ситуация, когда зависимость А версии 1, которая требовала версию 2 зависимости Б, в итоговом списке не оказывается, потому что старшей оказывается версия 2 зависимости А. Это сложно описать словами, предлагаю взглянуть на иллюстрацию ниже.

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

Также появилось возможность достучаться до конфигурации плагина из описания проекта в самом плагине, что открывает возможности по тонкой настройке процесса сборки. В дополнение к этому теперь появились свойства в виде пар ключ-значение с возможностью их интерполяции в конфигурацию плагинов с помощью привычного Maven синтаксиса ${property.key}. Механизм интерполяции реализован максимально просто, с помощью регулярного выражения.

Задачи в процессе сборки сортируются сначала по возрастанию стадии сборки а в пределах одной стадии по шагам (prepare, run, finalize). Типичными примерами разделения стадий дополнительно на шаги может быть копирование ресурсов в директорию с классами после их компиляции, либо создание манифеста перед упаковкой в JAR.

Не менее ключевой возможностью является получение списка файлов с зависимостями во время выполнения задач. Без этого не будет возможна компиляция и запуск тестов в случае необходимости сторонних библиотек. При этом можно указать область видимости для каждой зависимости - implementation либо test. Конечно, в реальном мире есть примеры, когда зависимость имеет смысл только на этапе компиляции (Lombok) или во время выполнения (мосты для перенаправления логирования SLF4J). Однако, для упрощений было решено отказаться от такого гранулярного разделения.

Products (e6240a6b)

Сперва наперво добавил предопределенные свойства: путь к директории с проектом и путь к директории с файлами, необходимыми для сборки (аналог target для Maven и build для Gradle). Их можно изменить для каждого проекта в отдельности и они не наследуются.

Потом произошел первый прорыв в части предметной области. Существует необходимость передавать файлы от плагина к плагину (например, путь к директории с классами должен быть один и тот же как при компиляции, так и при упаковке в JAR) и от проекта к проекту (например, когда один проект зависит от другого проекта и требует его описание и классы). И если проблема с плагинами не стоит так остро, она решается за счет использования одного свойства и интерполяции его в несколько конфигураций плагинов, то с проектами сложнее. Опять же, мы можем просто указать координаты другого проекта в зависимостях и искать артефакт в репозитории. В этом случае, придется сначала собрать полностью один проект, а потом начать собирать другой. Но основная цель, добавить в скором будущем возможность собирать несколько проектов в рамках одной сборки (похоже на мультимодульные проекты Maven или Gradle, когда при сборке корневого проекта также собираются дочерние).

В конечном итоге, получив вдохновение от концепции inputs/outputs в Gradle, процесс стал выглядеть следующим образом. Продукт - это путь к файлу либо директории определенного типа: source, resource, exploded jar, test source, test resource, test exploded jar, jar. Есть подозрение, что заранее определенных типов может не хватать, но сейчас этот вопрос остро не стоял. Каждая задача на вход получает список продуктов сборки. Перед стартом сборки мы автоматически добавляем в список путь к файлу с описанием проекта. В конце каждая задача выплевывает произведенные ей продукты, которые также добавляются в общий перечень. Далее передаем его в следующую задачу, и так пока не выполним их все. Такая система позволяет сократить необходимость конфигурировать каждый плагин в отдельности. Например, мы один раз настраиваем директорию, куда попадут классы, а остальные плагины уже будут знать о ней для копирования ресурсов, создания JAR и т.д.

Наследование (b7d5433c)

Пришло время добавить крайне важный и облегчающий жизнь способ переиспользования конфигурации - наследование. Изначально была мысль отказаться от наследования в пользу агрегации (импорта), но все таки на второй взгляд наследование оказалось более естественным. Мне кажется, это связано с тем, что дерево наследования описаний проектов логично ложится на структуру директорий в файловой системе.

Сначала идея сделать наличие шаблона (аналог parent в Maven) обязательным, т.е. либо явно указывать либо неявно наследоваться от super template (аналог super parent в Maven), показалась удачной. В позже пришлось от нее отказаться. Во-первых, наличие неявного шаблона слишком неочевидно. Во-вторых, слишком сложно было бы работать с описаниями проектов локальными и в репозиториях. Ведь для сборки локального проекта, можно брать заранее определенную последнюю версию super template, но в случае отсутствия явно указанного шаблона для зависимости из репозитория как быть? Эта зависимость была собрана с учетом определенной версии, есть вероятность, что с другой версией что-то пойдет не так. Как вариант, можно брать описание локального проекта и всегда явно указывать используемый шаблон перед отправкой в репозиторий. В этом случае описания проектов локального и в репозитории будут отличаться. Тем не менее на данном этапе я пошел этим путем.

Шаг за шагом свойства, плагины и зависимости стали наследоваться из шаблона с возможностью переопределения, Перестали учитывать тестовые транзитивные зависимости для прямых зависимостей с областью implementation. В тестах вынесли кодогенерацию по отдельным классам и разделили имеющиеся тесты по тестируемому функционалу (ConveyorPluginTests, InheritanceTests, PropertiesTests и т.д.). Появилась возможность сборки дочерних проектов в рамках одного процесса, при этом учитываются связи между ними, чтобы определить порядок сборки. При этом должно соблюдаться всего несколько правил: проект-родитель собирается раньше своих дочерних; проект, который необходим для сборки другого, собирается раньше него.

Также в это коммите можно заметить пачку классов относящихся к неизменяемым коллекциям: ImmutableList, ImmutableSet и т.д. Я очень щепетильно относился к вопросу неизменяемости, стараясь везде копировать коллекцию при добавлении в нее новых элементов. Поэтому создание подходящего API для используемых коллекций и вынесение общей логики в отдельные классы может показаться логичным шагом. Но в будущем я пришел к выводу, что это добавляет лишнюю когнитивную нагрузку бизнес логике. В будущем на границах предметной области я все еще использовал тактику защитного копирования изменяемых объектов, но вот внутри моего домена я научился доверять своему коду. Я не предпринимал никаких попыток защититься от null либо изменения состава коллекций, однако с другой стороны я и не использовал null и не изменял объекты в месте, отличном от их создания. Идеальная золотая середина.

К слову, можно заметить, что бизнес логика была уже не раз переписана с нуля, по мере углубления понимания процессов. Однако тесты при этом менялись не слишком драматично, так же как и публичный API Conveyor. Непередаваемое ощущение, когда осознаешь, что после проделанной работы все функции работают также хорошо как и прежде, а бонусом идет лучшая расширяемость для новых.

Документация (f6ae313e)

Пришел тот час, когда стало сложно держать в голове что уже реализовано, какие стороны проекта протестированы, какие правила поведения были обозначены и т.п. А значит лучше информацию из головы вытащить и перевести в более наглядный вид, в нашем случае в README.md. Вместе с этим снова перетасовал тесты, чтобы название классов лучше коррелировали с разделами в документации (PropertiesFeatureTests, TasksFeatureTests, PluginsFeatureTests и т.п.).

# Conveyor

A build tool for Java projects

# Features

* Schematic
    * This is a definition of a project
    * Schematic is defined by an optional group, name and an optional version. If group or version
      is absent, then the schematic is defined by its template's group or version
    * A schematic can be constructed up to the specified stage: CLEAN, COMPILE, TEST, ARCHIVE or
      PUBLISH
* Dependency version resolution
    * Given the same dependency is required but with different versions, the highest version wins
      taken into account the presence of the dependency requiring that version in the result class
      path
    * Version precedence is determined by the following rules:
        * Part of the version before the first dash is considered version components, after -
          qualifiers
        * Dot, dash and transition between characters and digits constitute a separator
        * When version components are equal, a version with qualifiers has lower precedence than a
          version without
        * Precedence for two versions is determined by comparing version components and then
          qualifiers from left to right until a difference is found as follows:
            * Identifiers consisting of digits are compared numerically
            * Identifiers with letters are compared lexically
            * Numeric identifiers always have lower precedence than non-numeric identifiers
            * A larger set of identifiers has a higher precedence than a smaller set, if all the
              preceding identifiers are equal
    * Preferences are defined in a schematic with a group, a name and a version
    * Preferences can be imported from a schematic by defining that schematic with a group, a name
      and a version as the inclusion in preferences. Given the same artifact preference is imported
      with different versions, then the highest version wins
    * Plugins are used with defined version. If plugin does not define its version, then version
      defined in preferences is used
    * Direct dependencies are used with defined version. If dependency does not define its version,
      then version defined in preferences is used
    * Transitive dependencies are used with versions defined in preferences. If preferences do not
      contain the dependency, the version defined in a schematic requiring this dependency is used
      ...

Также появились четкие очертания конечной цели этого проекта: нужно, чтобы Conveyor собирался с помощью Conveyor. Т.е. CI будет выглядеть следующим образом: собираем JAR архивы сначала с помощью gradle, а потом снова их собираем, но уже с помощью Conveyor. Очень уж хотелось наконец опробовать свой сборщик в действии, поэтому начал писать файлы conveyor.json (аналог pom.xml и build.gradle), подмечать и исправлять ошибки. Первой такой ошибкой стало не совсем логичная резолюция относительного пути локального репозитория. Раньше он считался относительным к директории текущего проекта, и ломался в дочерних проектах при наследовании. Удалось исправить за счет преобразования его в абсолютный относительно директории проекта, в котором он объявлен.

К сожалению, от Gson пришлось отказаться в пользу Jackson. Для работы Gson требовалось слишком много настройки, эта библиотека не умела работать с Path и Optional “из коробки”. И хотя API Jackson мне лично не совсем по вкусу, он победил за счет поддержки многих стандартных типов Java.

Рефакторинг наследования (21713f2b)

После предоставления возможности определения предпочтений версий прямых и транзитивных зависимостей (аналог dependency management в Maven и version catalogs с platform в Gradle), обратил внимание на неуклюжесть реализации наследования. Изначально, оно достигалось наличием в модели описания проекта поля с шаблоном для него. У такой “матрешки” были несколько минусов: необходимость в заглушке для super template, так как он единственный не имеет шаблона; необходимость лениво строить не локальную часть иерархии наследования (нам необходима часть информации для принятия первоначальных решений, например, нужно ли вообще собирать проект, какие репозитории использовать и т.д.); сложность определения первого и последнего звена в локальной части. Поэтому была придумана более плоская модель на основе простого списка. В качестве бонуса, логика слияния моделей при наследовании и логика их создания из файла оказались разнесены по разным классам (Hierarchy и StandaloneSchematicModel соответственно).

Кроме того начал вырисовываться конечный вариант ubiquitous language (названия различных частей несколько раз менялись). Пользователь определяет проект в файле conveyor.json, который называется схемой (schematic definition). Схема содержит в себе группу, название и версию проекта (аналог group ID, artifact ID и версии в Maven и Gradle); группу, название и версию шаблона; дочерние проекты (inclusions, аналог modules в Maven); репозитории; свойства; предпочтения версий зависимостей; плагины и их конфигурация; зависимости с их областью и исключениями. 

Интеграция с Maven Central (c6f9f47a)

Не обошли стороной полезную возможность вместо определение предпочтений версии для каждой зависимости импортировать их все сразу, что является аналогом импорта BOM в Maven и Gradle.

Возможность использования локальной директории в качестве репозитория была с самого начала. Со временем пришло осознание, что невозможность импорта зависимостей из публичного репозитория сильно сказывается на возможности применения Conveyor в более-менее реальных проектах. Поэтому, было принято решение, что необходимо также поддерживать интеграцию с публичным удаленным репозиторием типа Maven 2 (https://maven.apache.org/repository/layout.html). На бумаге выглядит не все так сложно: отправляем запрос посредством Java HTTP client, читаем POM с помощью Java DocumentBuilder, преобразуем его в schematic definition и работаем дальше с нашим форматом. Сложности кроются в этой конверсии, хотя единственной большой проблемой было то, что в dependency management в Maven возможно задать область для зависимости, но этого нельзя сделать с помощью предпочтений версий в Conveyor. Для оптимизации скачанные файлы кэшируются в директории .conveyor-cache, находящейся в директории корневого проекта (по аналогии с node_modules в NPM).

Изменилось версионирование. Первоначально использовался простой integer в качестве версии, теперь же пришлось мигрировать на строку. Сортировка версий также подверглась улучшениям. Были определены (со временем) правила, чтобы дать однозначный ответ на вопрос какая версия старше: 1.0.FINAL, 1.0.0-rc.3 либо 1beta-SNAPSHOT. В целом соблюдаются правила из спецификации semantic version (https://semver.org) с добавлением некоторых пунктов из опыта Maven.

В том числе было решено отказаться от неявного super template. За счет этого сильно упростилась логика построения иерархии наследование: либо шаблон указан и мы его берем во внимание, либо его нет. Данное изменение сделало наконец возможным унификацию описаний проектов находящихся локально и в репозитории (раньше модели для локального schematic definition и хранимого manual слегка различались, что не облегчало бизнес логику). 

Не могу не затронуть одну из ключевых проблем Conveyor - использование Java модулей стало вставлять палки в колеса. В данном случае для тестирования интеграции с удаленными репозиториями хотелось использовать WireMock, а для подготовки тестовых POM - Jackson XML. Обе этих библиотеки не поддерживают модульность, а так как аналогов тому же WireMock нет - пришлось идти на ухищрения. После множества проб и ошибок, вариант с распаковкой архива с классами, добавлением вручную написанного module-info.java (сам в шоке, что сработало). Да, Intellij IDEa была недовольна. Да, пришлось создать пустые классы com.example.Dummy.java, чтобы можно было указать пакеты для экспорта в модуле. Но эти костыли явно намекали, что модульность доставляет больше проблем, чем пользы…

Conveyor плагины (8388139d)

Теперь, когда работа (на первый взгляд) над основным функционалом подошла к логическому завершению, настало время увидеть Conveyor в действии. Так как ядро выступает в своем роде координатором, основную работу должны выполнять подключаемые плагины. Работа над ними не доставляла больших сложностей, на данном этапе получились: clean-conveyor-plugin, compile-conveyor-plugin, resources-conveyor-plugin, junit-jupiter-conveyor-plugin, archive-conveyor-plugin. Сложности только возникали с конфигурацией JUnit Jupiter engine для запуска тестов, очень помог разбор устройства junit-platform-console (https://github.com/junit-team/junit5), который занимается похожей задачей. 

Для удобства, conveyor-cli содержит простой Main класс запускающий сборку вместе с минимальным набором зависимостей (так называемый uber JAR). Его и можно считать нашим дистрибутивом для конечного пользования.

В процессе тестирования сборки Conveyor при помощи него самого, были выявлены и исправлены многие недочеты: задачи выполнялись не в порядке определения своих плагинов в schematic definition (т.е. задачи в рамках одного этапа и шага могли идти вразнобой); дочерние проекты указанные относительным путем разрешались исходя из рабочей директории, а не директории с проектом; были проблемы с чтением POM без явно указанной группы и версии; нашлись ошибки в алгоритме сортировки проектов в порядке сборки и другие. Из нового функционала появилось только возможность исключать транзитивные зависимости.

Отказ от Java модулей (e26115ca)

Последней каплей послужило то, что при создании нового слоя с модулями, требуемыми для запуска тестов проекта, возникала ошибка доступа, так как модуль декларирующий “exports org.junit.platform.commons.logging to org.junit.jupiter.api” находился на слой выше самого “org.junit.jupiter.api”. Решение напрашивалось само собой, отказаться от них в пользу старого доброго class path. На удивление, переход оказался очень плавным и принес множество улучшений. Во-первых, отпала необходимость ручного латания WireMock и Jackson XML, можно их использовать напрямую. Во-вторых, динамически загружать классы стало легче с помощью URLClassLoader, чем вручную строить слои из модулей. Были небольшие опасения за загрузку плагинов через ServiceLoader. Но выяснилось, что он существовал еще до Java 9, и работает посредством волшебного файла в директории META-INF/services с указанием на класс-реализацию. 

В процессе рефакторинга удалил из проекта и jimfs - реализацию файловой системы в оперативной памяти (https://github.com/google/jimfs). По задумке, она должна была ускорить выполнение тестов (казалось, что использование временной директории на диске с помощью JUnit 5 @TempDir медленнее) и избавить от остающегося в основной файловой системе мусора. Но с мусором в временных директориях должна справляться операционная система, тесты занимали все то же время, и вообще появились случайные ошибки при параллельном их запуске. 

Конец? (24ac1069)

Как я говорил, конечной целью проекта является его “самосборка”. Добавил недостающий плагины: публикующий артефакты в указанный репозиторий (есть поддержка только локальной директории) и создающий архив со всеми зависимостями. Появился (наконец то) простенький CI: создание дистрибутива с помощью Gradle, а затем создание с помощью Conveyor, чтобы проверить идентичность. Улучшил вывод информации во время выполнения посредством Java System.Logger. Для тех, кто не в курсе (как и я был): это фасад аналогичный SLF4J появившийся в 9-ой версии. Это позволяет избавить себя от лишней зависимости, при этом не лишая пользователя при желании настроить вывод под себя. По умолчанию, используется java.util.logging, и это все еще крайне неуклюжая в настройке система логирования.

В процессе финальной полировке постарался сгладить некоторые острые углы. Например, при запуске не обязательно передавать название стадии в верхнем регистре (для последующего создания enum). Проект можно включать не только указанием пути к файлу schematic definition, но и папке с conveyor.json. Добавилось свойство conveyor.schematic.group для интерполяции. Стало не обязательным указание группы и версии проекта, они берутся из шаблона при их отсутствии. Информация об отсутствующей зависимости корректно выводится на экран. Должен сказать, файл schematic definition стал сильно напоминать POM, хотя это конечно не плохо.

{
  "group": "com.github.maximtereshchenko.conveyor",
  "name": "conveyor-template",
  "version": "1.0.0",
  "inclusions": [
    "./archive-conveyor-plugin",
    "./clean-conveyor-plugin",
    "./compile-conveyor-plugin",
    "./compiler",
    "./conveyor-api",
    "./conveyor-cli",
    "./conveyor-common-api",
    "./conveyor-core",
    "./conveyor-plugin-api",
    "./conveyor-plugin-test",
    "./executable-conveyor-plugin",
    "./jackson-adapter",
    "./junit-jupiter-conveyor-plugin",
    "./publish-conveyor-plugin",
    "./resources-conveyor-plugin",
    "./spring-boot-conveyor-plugin",
    "./spring-boot-launcher",
    "./test-common",
    "./zip-archive"
  ],
  "repositories": [
    {
      "name": "maven-central",
      "uri": "https://repo1.maven.org/maven2"
    },
    {
      "name": "gradle-repository",
      "path": "./.gradle-repository"
    },
    {
      "name": "conveyor-repository",
      "path": "./.conveyor-repository"
    }
  ],
  "properties": {
    "junit-bom.version": "5.10.2",
    "jackson-bom.version": "2.17.0",
    "assertj.version": "3.25.1",
    "wiremock.version": "3.4.2",
    "slf4j-jdk14.version": "2.0.13",
    "apiguardian-api.version": "1.1.2"
  },
  "preferences": {
    "inclusions": [
      {
        "group": "org.junit",
        "name": "junit-bom",
        "version": "${junit-bom.version}"
      },
      {
        "group": "com.fasterxml.jackson",
        "name": "jackson-bom",
        "version": "${jackson-bom.version}"
      }
    ],
    "artifacts": [
      {
        "group": "${conveyor.schematic.group}",
        "name": "clean-conveyor-plugin",
        "version": "${conveyor.schematic.version}"
      },
      {
        "group": "${conveyor.schematic.group}",
        "name": "compile-conveyor-plugin",
        "version": "${conveyor.schematic.version}"
      },
      {
        "group": "${conveyor.schematic.group}",
        "name": "resources-conveyor-plugin",
        "version": "${conveyor.schematic.version}"
      },
      {
        "group": "${conveyor.schematic.group}",
        "name": "junit-jupiter-conveyor-plugin",
        "version": "${conveyor.schematic.version}"
      },
      {
        "group": "${conveyor.schematic.group}",
        "name": "archive-conveyor-plugin",
        "version": "${conveyor.schematic.version}"
      },
      {
        "group": "${conveyor.schematic.group}",
        "name": "executable-conveyor-plugin",
        "version": "${conveyor.schematic.version}"
      },
      {
        "group": "${conveyor.schematic.group}",
        "name": "publish-conveyor-plugin",
        "version": "${conveyor.schematic.version}"
      },
      {
        "group": "${conveyor.schematic.group}",
        "name": "conveyor-plugin-api",
        "version": "${conveyor.schematic.version}"
      },
      {
        "group": "${conveyor.schematic.group}",
        "name": "zip-archive",
        "version": "${conveyor.schematic.version}"
      },
      {
        "group": "${conveyor.schematic.group}",
        "name": "test-common",
        "version": "${conveyor.schematic.version}"
      },
      {
        "group": "${conveyor.schematic.group}",
        "name": "conveyor-plugin-test",
        "version": "${conveyor.schematic.version}"
      },
      {
        "group": "${conveyor.schematic.group}",
        "name": "compiler",
        "version": "${conveyor.schematic.version}"
      },
      {
        "group": "${conveyor.schematic.group}",
        "name": "spring-boot-launcher",
        "version": "${conveyor.schematic.version}"
      },
      {
        "group": "${conveyor.schematic.group}",
        "name": "conveyor-common-api",
        "version": "${conveyor.schematic.version}"
      },
      {
        "group": "${conveyor.schematic.group}",
        "name": "conveyor-core",
        "version": "${conveyor.schematic.version}"
      },
      {
        "group": "${conveyor.schematic.group}",
        "name": "jackson-adapter",
        "version": "${conveyor.schematic.version}"
      },
      {
        "group": "${conveyor.schematic.group}",
        "name": "conveyor-api",
        "version": "${conveyor.schematic.version}"
      },
      {
        "group": "org.assertj",
        "name": "assertj-core",
        "version": "${assertj.version}"
      },
      {
        "group": "org.wiremock",
        "name": "wiremock",
        "version": "${wiremock.version}"
      },
      {
        "group": "org.slf4j",
        "name": "slf4j-jdk14",
        "version": "${slf4j-jdk14.version}"
      },
      {
        "group": "org.apiguardian",
        "name": "apiguardian-api",
        "version": "${apiguardian-api.version}"
      }
    ]
  },
  "plugins": [
    {
      "group": "${conveyor.schematic.group}",
      "name": "clean-conveyor-plugin"
    },
    {
      "group": "${conveyor.schematic.group}",
      "name": "compile-conveyor-plugin"
    },
    {
      "group": "${conveyor.schematic.group}",
      "name": "resources-conveyor-plugin"
    },
    {
      "group": "${conveyor.schematic.group}",
      "name": "junit-jupiter-conveyor-plugin"
    },
    {
      "group": "${conveyor.schematic.group}",
      "name": "archive-conveyor-plugin"
    },
    {
      "group": "${conveyor.schematic.group}",
      "name": "publish-conveyor-plugin",
      "configuration": {
        "repository": "conveyor-repository"
      }
    }
  ]
}

И хотя поставленная цель была достигнута, хотелось проверить Conveyor в тандеме со Spring Boot…

Теперь точно конец (a7f09a60)

За основу берем простенький Spring Boot проект (https://github.com/spring-guides/gs-spring-boot/tree/main) и пытаемся его собирать. Первое, что бросилось в глаза: вывод информации на экран прекращался во время тестов. Путем долгого дебаггинга обнаружилось, что Spring при инициализации выставляет уровень в severe. Однако рядом нашелся способ отключить инициализацию системный свойством org.springframework.boot.logging.LoggingSystem=none. Второй момент, который также вызывал вопросы: после успешной сборки процесс не завершался а зависал. После второй череды дебаггинга виноватый оказался все еще работающий Tomcat. Я был почти уверен, что проблема где-то в моем коде, но кроме существующего shutdown hook способов завершить его работу не всплыло. Поэтому System.exit(0) в конце спас положение. 

К сожалению, уже написанный плагин для создания uber JAR не справлялся с работой в случае Spring Boot. Проект запускался, но собственные классы в контекст не попадали. Расследование показало, что это связано с механизмом загрузки ресурсов в ClassLoader. В случае запуска архива становилось невозможным просканировать пакеты. Посидев, посмотрев как эту проблему решает spring-boot-maven-plugin, было решено сделать наоборот. Самым простый показалось решение распаковать все зависимости из uber JAR во временную папку и запускать основной main класс через уже привычный URLClassLoader. Дешево, сердито, но работает.

{
  "group": "com.example",
  "name": "spring-boot-complete",
  "version": "0.0.1-SNAPSHOT",
  "template": {
    "group": "org.springframework.boot",
    "name": "spring-boot-starter-parent",
    "version": "3.2.0"
  },
  "repositories": [
    {
      "name": "maven-central",
      "uri": "https://repo1.maven.org/maven2"
    },
    {
      "name": "conveyor",
      "path": "./.conveyor-repository"
    }
  ],
  "plugins": [
    {
      "group": "com.github.maximtereshchenko.conveyor",
      "name": "clean-conveyor-plugin",
      "version": "1.0.0"
    },
    {
      "group": "com.github.maximtereshchenko.conveyor",
      "name": "compile-conveyor-plugin",
      "version": "1.0.0"
    },
    {
      "group": "com.github.maximtereshchenko.conveyor",
      "name": "resources-conveyor-plugin",
      "version": "1.0.0"
    },
    {
      "group": "com.github.maximtereshchenko.conveyor",
      "name": "junit-jupiter-conveyor-plugin",
      "version": "1.0.0"
    },
    {
      "group": "com.github.maximtereshchenko.conveyor",
      "name": "spring-boot-conveyor-plugin",
      "version": "1.0.0",
      "configuration": {
        "launched-class": "com.example.springboot.Application"
      }
    }
  ],
  "dependencies": [
    {
      "group": "org.springframework.boot",
      "name": "spring-boot-starter-web",
      "exclusions": [
        {
          "group": "ch.qos.logback",
          "name": "logback-classic"
        }
      ]
    },
    {
      "group": "org.slf4j",
      "name": "slf4j-jdk14"
    },
    {
      "group": "org.springframework.boot",
      "name": "spring-boot-starter-actuator"
    },
    {
      "group": "org.springframework.boot",
      "name": "spring-boot-starter-test",
      "scope": "test"
    }
  ]
}

Итоги

На этой счастливой ноте, давайте подведем итоги. Проект (https://github.com/maximtereshchenko/conveyor) оказался ожидаемо сложным и довольно интересным. Его идея давно витала в воздухе, но предыдущие попытки не увенчались успехом. Убийцы уже существующих инструментов не получилось, хотя этого и не планировалось. Что самое удивительное, изменилось мое персональное отношение к Maven и Gradle. Если раньше я отдавал предпочтение Gradle, то прочувствовав на себе все проблемы при проектировании, я наконец осознал всю солидность и надежность Maven. Это чувствуется даже внешне, задумывалось перенять лучшие наработки обоих, но в конце получился на 90% Maven. Развивать проект дальше планов нет, пока. Но векторов развития однозначно еще много осталось. 

Спасибо за внимание! Напишите в комментариях свое мнение, мне будет интересно его узнать. Хорошего дня!

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


  1. dyadyaSerezha
    10.05.2024 10:27
    +2

    Может, я не понял чего, но сборщик проектов работает с неким файлом описания проекта и собирает проект согласно ему. Дочитал до какого-то теста, до каких-то BuilderFactory, jar'ов и прочих модулей и классов, хотя не сказано ни слова, что за язык предполагается использовать для описания проекта и хотя бы самые общие черты будущего сборщика. Как-то очень странно такое читать, дальше просто не стал.


    1. sshikov
      10.05.2024 10:27
      +1

      Во втором абзаце:

      Зачем понадобилось писать велосипед, когда уже существуют Maven и Gradle? Конкретной причины нет, мне просто нравится писать код. 

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

      Скажем, мы хотим файл проекта как у мавена, т.е. статический, но более читабельный для человека, и чтобы писать было проще. Чтобы его можно было обрабатывать любым инструментом, и не требовалось выполнять код сборщика, чтобы получить скажем список зависимостей.

      Насколько это реально полезно - вопрос второй, но сразу возникает вопрос первый - вот команда мавена в свое время (причем очень давно) сделала проект полиглот, с теми же примерно целями. И много вы видели файлов, написанных в синтаксисе, отличном от штатного pom.xml? Я - ни одного. То есть, даже если такая потребность и была, она настолько несущественна, что никто этим не пользуется.


      1. dyadyaSerezha
        10.05.2024 10:27
        +1

        Во втором абзаце причина, а не цель. Я же писал про цель - что именно хотел сделать автор? Абсолютно неясно. С тем же успехом он мог начать с "hello world" и постепенно добавлять любые модули и классы - просто так, потому что "нравится программировать".