Введение

На одном из проектов, над которым я работаю, я столкнулся с постоянно растущим временем сборки проекта на CI. Оказалось, что причина была в интеграционных тестах работы с БД в модулях с использованием Testcontainers. Каждый модуль запускал свой контейнер и в придачу применял на них миграции Liquibase.

В данной статье я хочу рассмотреть способ решения этой проблемы с помощью Gradle Build Services — механизма для разделения состояния между задачами. Цель - использовать только один контейнер PostgreSQL на всю сборку, и настроить все тестовые задачи на его использование.

Описание проекта и проблемы

Контекст

Имеется крупное монолитное Spring Boot приложение, разбитое на функциональные модули (features), каждый из которых представлен Gradle-проектом. Каждый модуль требует интеграционного тестирования с реальной базой данных PostgreSQL, для запуска которой используется Testcontainers. Управление схемой БД осуществляется с помощью Liquibase.

Проблема: при сборке с нуля (как в CI) тесты (test task) каждого модуля запускаются в своей JVM, что приводит к:

  • Запуску отдельного экземпляра контейнера PostgreSQL для каждого модуля.

  • Многократному применению одних и тех же миграций Liquibase.

  • Значительному росту общего времени сборки.

Тестовый проект

Для наглядности рассмотрим упрощенный проект со следующим стеком и структурой:

  • Стек: Spring Boot, Liquibase, Gradle, PostgreSQL, Testcontainers, .

  • Модули:

    • app — главный модуль (тест на поднятие контекста).

    • db — модуль с миграциями Liquibase.

    • feature1, feature2 — функциональные модули (тесты с проверкой БД).

Исходное решение и его недостатки

Изначально использовался удобный механизм Spring Boot Testcontainers & Service Connections. В каждом модуле использовалась конструкция примерно такого вида:

@SpringBootTest(classes = {Feature1Configuration.class})
@AutoConfigureJdbc
@Testcontainers
public class Feature1Test {

    @Container
    @ServiceConnection
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:latest");

    @Test
    public void test(@Autowired JdbcTemplate jdbcTemplate) {
        var rows = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM databasechangelog", Integer.class);
        assertEquals(1, rows);
    }
}

Был общий родительский тестовый класс, в котором стартовал контейнер с БД, который в свою очередь через механизм ServiceConnection использовался в тестах.

Результат сборки gradle build --profile :

Task

Duration

:feature1:test

16.990s

:feature2:test

16.368s

:app:test

16.371s

Общее время сборки

~27.703s

Суммарное время задач

~53s

Вывод: каждый модуль тратит ~16 секунд на запуск своего контейнера и накат миграций и при наличии большего числа модулей это приводит к увеличению общего времени сборки. Частично это может решиться параллельной сборкой, но при наличии большого количества модулей это все равно будет затратно.

Решение: Gradle Build Service

Что такое Gradle Build Services?

Gradle Build Services — это механизм для разделения состояния и ресурсов между задачами. Gradle управляет жизненным циклом сервиса, создавая его экземпляр при первом использовании и автоматически останавливая, когда он больше не нужен. Это идеально подходит для запуска общего контейнера.

Реализация сервиса

Создаем сервис, который запускает и останавливает контейнер.

// PostgresBuildService.java
public abstract class PostgresBuildService implements BuildService, AutoCloseable {

    public interface Params extends BuildServiceParameters {
        Property getImage();
    }

    private final PostgreSQLContainer postgres;

    public PostgresBuildService() {
        // Получаем имя образа из параметров
        String image = getParameters().getImage().get();
        postgres = new PostgreSQLContainer<>(image);
        postgres.start();
    }

    @Override
    public void close() {
        postgres.stop();
    }

    // Геттеры для подключения к БД
    public String getJdbcUrl() { return postgres.getJdbcUrl(); }
    public String getUsername() { return postgres.getUsername(); }
    public String getPassword() { return postgres.getPassword(); }
}

Реализация плагина

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

// PostgresBuildServicePlugin.java
public class PostgresBuildServicePlugin implements Plugin {

    @Override
    public void apply(Project project) {
        // Регистрируем сервис (один на все build)
        Provider serviceProvider = project.getGradle()
                .getSharedServices()
                .registerIfAbsent("postgres", PostgresBuildService.class, spec -> {
                    spec.getParameters().getImage().set("postgres:latest");
                });

        // Настраиваем все задачи типа Test
        project.getTasks().withType(Test.class, testTask -> {
            // Объявляем, что задача использует сервис
            testTask.usesService(serviceProvider);
            // Пробрасываем данные для подключения через переменные окружения
            testTask.environment("PG_BS_JDBC_URL", serviceProvider.get().getJdbcUrl());
            testTask.environment("PG_BS_USERNAME", serviceProvider.get().getUsername());
            testTask.environment("PG_BS_PASSWORD", serviceProvider.get().getPassword());
        });
    }
}

Настройка тестов

В коде тестов (или в application-test.properties) данные для подключения к контейнеру берутся из переменных окружения:

spring.datasource.url=${PG_BS_JDBC_URL}
spring.datasource.username=${PG_BS_USERNAME}
spring.datasource.password=${PG_BS_PASSWORD}

Использование переменных окружения обусловлено тем, что Gradle не учитывает изменение их значений при определелении входных данных (inputs) для задачи test. Если использовать вместо переменных окружения системные переменные Java, то при каждой сборке Gradle будет перезапускать тесты, даже если код не изменился.

Результаты внедрения

После применения плагина ко всем модулям картина кардинально меняется.

Результат сборки gradle build --profile:

Task

Duration

:app:test

12.549s

:feature1:test

2.045s

:feature2:test

2.047s

Общее время сборки

~20.574s

Суммарное время задач

~18.673s

Как видим задача :app:test выполнилась первой, инициировала создание сервиса, запуск контейнера и накат миграций. Последующие тестовые задачи (:feature1:test, :feature2:test) просто переиспользовали уже готовую БД. Это привело к сокращению общего времени сборки на ~25% и сокращению суммарного времени выполнения задач более чем в 2.5 раза.

Итоги

Gradle Build Services — это мощный инструмент для оптимизации сборок, позволяющий избежать дублирования ресурсоемких операций. В данном случае мы устранили главную "узкую точку": многократный запуск контейнеров и накат миграций. Решение легко масштабируется на любое количество модулей и может быть адаптировано для других тяжелых зависимостей (например, Kafka, Redis).

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

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