← Назад к списку тем

03. Кастомные YAML-настройки стартера

Как библиотека-стартер поставляет собственный YAML-файл с настройками и программно подтягивает их в Environment приложения: spring.config.import, EnvironmentPostProcessor + YamlPropertySourceLoader, почему @PropertySource не читает YAML, приоритеты PropertySource и привязка через @ConfigurationProperties.

Зачем это нужно: проблема «стартер со своими настройками»

Типичный сценарий: вы пишете библиотеку-стартер payment-gateway-starter, которую подключают другие команды. Стартеру нужны свои настройки — URL платёжного шлюза, таймауты, флаги поведения. Есть два варианта их доставки:

  • Заставить потребителя всё описать руками — каждый интегратор копирует блок payment-gateway: из README в свой application.yml. Работает, но дублирование, ошибки копипаста и «а какие дефолты вообще разумные?» ложатся на потребителя.
  • Положить в jar стартера свой YAML-файл (например, application-payment-gateway.yml) с разумными дефолтами и сделать так, чтобы он сам подхватывался в Environment приложения при старте — а потребитель переопределял бы только то, что ему нужно, в своём обычном application.yml.
# Внутри jar стартера: src/main/resources/application-payment-gateway.yml
# Дефолты, которые приложение получает "бесплатно" при подключении зависимости
payment-gateway:
  base-url: "https://api.payment-gw.example.com"
  connect-timeout: 2s
  read-timeout: 5s
  max-retries: 2
  idempotency-key-header: "X-Request-Id"
⚠️ Интуитивное решение не работает. Первый рефлекс — аннотация @PropertySource("classpath:application-payment-gateway.yml") на автоконфигурации стартера. Она не сработает: @PropertySource умеет читать только .properties-формат, YAML-парсер для неё не зарегистрирован, и при загрузке файла вы получите либо тихо пустой PropertySource, либо исключение о неразборчивом ресурсе — в зависимости от версии и того, как именно файл был найден. Способы обойти это и есть предмет темы ниже.

Базовое использование: spring.config.import

Начиная со Spring Boot 2.4 существует декларативный механизм импорта дополнительных конфигов — свойство spring.config.import. Потребитель добавляет одну строку, и файл из classpath (в том числе из чужого jar) попадает в общую систему конфигурации со всеми её возможностями — профили, плейсхолдеры, релаксед-биндинг:

# application.yml ПРИЛОЖЕНИЯ (потребителя стартера)
spring:
  config:
    import:
      - "classpath:application-payment-gateway.yml"   # из jar стартера
      - "optional:file:./local-overrides.yml"          # локальный файл, если есть

Ключевые свойства механизма:

ВозможностьЧто даёт
classpath:Импорт файла из classpath — включая ресурсы, упакованные в jar зависимостей (наш случай)
optional:Префикс «файла может не быть — не падай». Без него отсутствие файла = фатальная ошибка старта
file:Импорт по абсолютному или относительному пути файловой системы
ПрофилиВнутри импортированного файла работают spring.config.activate.on-profile и multi-document YAML (---)
ПриоритетИмпортированные источники имеют приоритет НИЖЕ, чем файл, в котором объявлен сам import — приложение всегда может переопределить дефолты стартера

Недостаток очевиден: строку всё равно должен написать потребитель. Если хочется «подключил зависимость — и ничего больше делать не надо», нужен следующий раздел.

Механика внутри: EnvironmentPostProcessor + YamlPropertySourceLoader

Это классический программный способ, которым сама экосистема Spring Boot решала эту задачу до появления spring.config.import (и которым до сих пор пользуются многие инфраструктурные стартеры). Он опирается на точку расширения жизненного цикла — EnvironmentPostProcessor: колбэк, который вызывается до создания контекста и всех бинов, когда уже собран объект Environment, но в него ещё можно дописывать PropertySource.

public class PaymentGatewayEnvironmentPostProcessor
        implements EnvironmentPostProcessor, Ordered {

    // Ресурс лежит в том же jar, что и этот класс
    private static final Resource DEFAULTS =
            new ClassPathResource("application-payment-gateway.yml");

    @Override
    public void postProcessEnvironment(ConfigurableEnvironment environment,
                                       SpringApplication application) {
        try {
            // Готовый парсер YAML из spring-boot: возвращает список
            // PropertySource — по одному на каждый документ (---) в файле
            YamlPropertySourceLoader loader = new YamlPropertySourceLoader();
            List<PropertySource<?>> sources =
                    loader.load("paymentGatewayDefaults", DEFAULTS);
            sources.forEach(environment.getPropertySources()::addLast);
        } catch (IOException e) {
            throw new IllegalStateException(
                "Не удалось загрузить дефолты payment-gateway-starter", e);
        }
    }

    @Override
    public int getOrder() {
        // После стандартного загрузчика application.yml,
        // чтобы наши дефолты гарантированно имели меньший приоритет
        return Ordered.LOWEST_PRECEDENCE;
    }
}

Регистрация — не через аннотацию и не через бин, а через service-файл в resources стартера:

# src/main/resources/META-INF/spring.factories (в jar стартера)
org.springframework.boot.env.EnvironmentPostProcessor=\
com.example.payment.starter.PaymentGatewayEnvironmentPostProcessor

Почему именно так, а не @Component:

  1. EnvironmentPostProcessor вызывается до старта контекста — бины ещё не существуют, компонент-скан ничего не увидит. Spring находит реализации только сканированием META-INF/spring.factories на этапе подготовки Environment (механизм тот же, что и у автоконфигураций, но другой ключ).
  2. Фаза выполнения — событие ApplicationEnvironmentPreparedEvent: SpringApplication.run() уже прочитал application.yml и переменные окружения (это делает встроенный ConfigDataEnvironmentPostProcessor), но контекст ещё не создан. Ваш постпроцессор просто дописывает источник в конец списка.
🔑 YamlPropertySourceLoader — готовый парсер, писать свой не нужно. Класс org.springframework.boot.env.YamlPropertySourceLoader превращает YAML в плоскую карту ключей (payment-gateway.base-url) с сохранением origin-информации (какой файл и строка дали значение — это видно в actuator-эндпоинте /actuator/env). Он возвращает список, потому что один YAML-файл может содержать несколько документов через ---.

Обходной путь для @PropertySource: кастомная фабрика

Если по какой-то причине нужен именно @PropertySource (например, конфигурация используется и вне Spring Boot, в чистом Spring Framework), YAML можно прикрутить вручную — через собственную реализацию PropertySourceFactory, которая парсит файл тем же YamlPropertiesFactoryBean:

public class YamlPropertySourceFactory implements PropertySourceFactory {

    @Override
    public PropertySource<?> createPropertySource(String name,
                                                  EncodedResource resource) throws IOException {
        YamlPropertiesFactoryBean factory = new YamlPropertiesFactoryBean();
        factory.setResources(resource.getResource());
        Properties props = factory.getObject();
        return new PropertiesPropertySource(
                name != null ? name : resource.getResource().getFilename(),
                props);
    }
}

@Configuration
@PropertySource(
        value = "classpath:application-payment-gateway.yml",
        factory = YamlPropertySourceFactory.class)
class PaymentGatewayAutoConfiguration { }
🚨 У этого пути три серьёзных ограничения. Во-первых, @PropertySource обрабатывается ConfigurationClassPostProcessor'ом — то есть только тогда, когда контекст уже создан; такие источники не участвуют в ранней фазе конфигурации (например, в logging.level.*, который применяется раньше бинов). Во-вторых, нет поддержки профилей: --- spring.config.activate.on-profile внутри файла будет проигнорирован или сломает парсинг свойств. В-третьих, ignoreResourceNotFound по умолчанию false, но даже при true отсутствующий файл никак не логируется — отладить «почему дефолтов не видно» сложно. Для стартеров на Spring Boot правильный выбор — EnvironmentPostProcessor или spring.config.import; фабрика — костыль для чистого Spring без Boot.

Приоритеты: кто кого переопределяет

MutablePropertySources — упорядоченный список: чем раньше источник в списке, тем выше его приоритет при поиске свойства. От позиции добавления зависит, сможет ли приложение переопределить дефолты стартера:

// environment.getPropertySources() — упорядоченный список, поиск идёт сверху вниз
// addLast  → самый низкий приоритет: дефолты перекрываются всем, что объявлено в приложении
sources.addLast(propertySource);
// addFirst → самый высокий: стартер ПЕРЕБЬЁТ application.yml приложения (обычно не то, что нужно!)
sources.addFirst(propertySource);
// addBefore / addAfter относительно конкретного источника
sources.addBefore("applicationConfig: [classpath:/application.yml]", propertySource);

Для сценария «jar-дефолты + возможность переопределения» правильный порядок такой: дефолты стартера — addLast (низший приоритет). Тогда цепочка разрешения свойства выглядит так:

#ИсточникТипичная роль
1System properties / env varsПрод-переопределения при деплое (PAYMENT_GATEWAY_BASEURL=...)
2application.yml приложенияОсознанная настройка интегратором
3application-{profile}.ymlНастройки окружений
4 (последний)YAML-дефолты стартера (addLast)Разумные значения «из коробки», видны только если ничего выше не задано
🔑 Проверять приоритет удобно через actuator. Эндпоинт /actuator/env показывает для каждого свойства все PropertySource, где оно встречается, и какой из источников победил. Если после подключения стартера «не применились мои значения» — первый шаг диагностики: найти свойство там и посмотреть, чей источник стоит выше.

Привязка настроек к бинам стартера: @ConfigurationProperties

Подтянуть значения в Environment — полдела. Вторая часть — связать их с типизированным объектом конфигурации, который использует автоконфигурация стартера:

@ConfigurationProperties(prefix = "payment-gateway")
public class PaymentGatewayProperties {

    // Дубль дефолтов кодом — страховка, если YAML не подгрузился;
    // setter'ы позволяют переопределять из любого PropertySource
    private String baseUrl = "https://api.payment-gw.example.com";
    private Duration connectTimeout = Duration.ofSeconds(2);
    private Duration readTimeout = Duration.ofSeconds(5);
    private int maxRetries = 2;

    // геттеры и сеттеры ...
}

@AutoConfiguration
@EnableConfigurationProperties(PaymentGatewayProperties.class)
public class PaymentGatewayAutoConfiguration {

    @Bean
    PaymentGatewayClient paymentGatewayClient(PaymentGatewayProperties props) {
        return new DefaultPaymentGatewayClient(
            props.getBaseUrl(), props.getConnectTimeout(), props.getMaxRetries());
    }
}

Регистрация самой автоконфигурации — отдельный файл META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports (не путать с spring.factories для EnvironmentPostProcessor: у каждого механизма свой ключ и свой файл).

✅ Дефолты и в YAML, и в полях класса — осознанный паттерн, а не дублирование ради дублирования. Значения в полях @ConfigurationProperties-класса — последняя линия обороны: они применяются, даже если YAML-файл стартера по какой-то причине не попал в Environment (сломался postprocessor, файл переименовали). А YAML-версия нужна для discoverability: интегратор открывает файл в jar и видит полный список настраиваемых ключей. Расхождения между ними ловятся тестом на равенство дефолтов (см. раздел тестирования).

Продвинутые приёмы

Условная загрузка по флагу. Иногда дефолты должны применяться только при включённом стартере. Внутри EnvironmentPostProcessor можно читать уже загруженные свойства и ветвиться:

@Override
public void postProcessEnvironment(ConfigurableEnvironment env, SpringApplication app) {
    // enabled=true — дефолт из самого же файла стартера или из кода
    if (!env.getProperty("payment-gateway.enabled", Boolean.class, true)) {
        return; // стартер выключен — дефолты не добавляем
    }
    // ... загрузка YAML как выше
}

Программный аналог spring.config.import. Если хочется, чтобы потребитель не писал строку import вручную, но использовать spring.factories по каким-то причинам нельзя (например, несколько постпроцессоров конфликтуют по порядку), допустимо добавить источник-«импортёр»: EnvironmentPostProcessor, который читает список файлов из собственного ключа и грузит их все — фактически мини-реализация ConfigData-механизма. Делать это стоит только если готовых средств действительно мало; в остальных случаях проще EnvironmentPostProcessor напрямую.

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

# в application-payment-gateway.yml (внутри jar стартера)
payment-gateway:
  base-url: "${external.gateway.url:https://api.payment-gw.example.com}"
  # сначала берётся external.gateway.url из приложения/env,
  # если его нет — значение после двоеточия
⚠️ Не делайте в EnvironmentPostProcessor ничего тяжёлого и не полагайтесь на бины. Постпроцессор выполняется до контекста: ни один бин ещё не создан, авто-wiring недоступен, база данных не поднята. Всё, что можно посчитать — только из файловой системы, classpath и уже загруженных свойств. Попытка достать что-то через application.getBean(...) здесь — гарантированный IllegalStateException на старте.

Сравнение способов

Критерийspring.config.importEnvironmentPostProcessor@PropertySource + YAML-фабрика
Кто пишет кодНикто — одна строка у потребителяАвтор стартера пишет класс + spring.factoriesАвтор стартера пишет фабрику
Действие потребителяДобавить spring.config.importНичего — работает само при подключении jarНичего (если аннотация в автоконфигурации)
Работает до создания бинов (logging и пр.)ДаДаНет — фаза конфигурационных классов
Профили внутри YAMLДаДа (loader вернёт все документы)Нет
Управление приоритетомАвтоматически (ниже импортирующего файла)Полный контроль (addLast/addFirst, Ordered)Ограниченный
Отсутствие файлаКонтролируемо через optional:Под контролем автора (можно молча пропустить)Молчаливое игнорирование или падение
Когда выбиратьЯвность важнее магии; единичные интеграцииСтартер «подключил и работает»; дефолты из jarЧистый Spring Framework без Boot

Когда применять, когда избегать

Применять YAML-дефолты в стартере стоит, когда:

  • стартер — внутренняя платформенная библиотека, используемая многими сервисами, и у каждого сервиса была бы одинаковая «шапка» настроек;
  • настройки имеют безопасные значения по умолчанию (таймауты, лимиты retry, имена хедеров), а переопределяется обычно 1–2 ключа;
  • важен опыт «подключил зависимость — клиент заработал» (developer experience внутренних команд).

Избегать стоит, когда:

  • значения обязаны задаваться явно (URL прода, секреты, ключи API) — «тихие» дефолты здесь опасны: сервис незаметно уйдёт на дефолтный URL вместо требуемого окружением;
  • конфиг сильно зависит от окружения потребителя (разные схемы БД, разные облака) — дефолт всё равно будет перезаписан везде;
  • вы планируете часто менять дефолты между версиями стартера: изменение значений в jar — невидимое для потребителя поведение-дрейф при обновлении зависимости; безопаснее держать дефолты в коде Properties-класса и менять их осознанно, а YAML оставить для документирования.
🚨 Смена значений дефолтов в новой версии стартера — скрытый breaking change. Если в v2.0 вы поправите read-timeout: 5s → 30s в YAML внутри jar, все сервисы, полагавшиеся на дефолт, изменят поведение таймаута без единой строчки изменений в их коде — и узнают об этом по продовым инцидентам. Правило: дефолты в jar меняются только мажорной версией и с changelog; минорные обновления не трогают значения, только добавляют новые ключи.

Как тестировать

Главный инструмент для тестов стартера — ApplicationContextRunner: он позволяет поднимать минимальный контекст с вашей автоконфигурацией и проверять эффект от разных наборов свойств, не стартуя целое приложение:

class PaymentGatewayDefaultsTest {

    // withInitializer прогоняет EnvironmentPostProcessor'ы так же,
    // как это делает настоящий SpringApplication.run()
    private final ApplicationContextRunner runner = new ApplicationContextRunner()
            .withInitializer(new ConfigDataApplicationContextInitializer())
            .withUserConfiguration(PaymentGatewayAutoConfiguration.class);

    @Test
    void defaultsFromStarterYamlAreApplied() {
        runner.run(context -> {
            assertThat(context.getEnvironment()
                    .getProperty("payment-gateway.max-retries", Integer.class))
                    .isEqualTo(2); // значение из jar стартера
        });
    }

    @Test
    void applicationCanOverrideStarterDefaults() {
        runner.withPropertyValues("payment-gateway.read-timeout=15s")
              .run(context -> {
                  PaymentGatewayProperties props =
                          context.getBean(PaymentGatewayProperties.class);
                  assertThat(props.getReadTimeout())
                          .isEqualTo(Duration.ofSeconds(15)); // приложение победило
              });
    }

    @Test
    void yamlDefaultsMatchJavaFieldDefaults() {
        // защита от расползания двух наборов дефолтов (см. info-box выше)
        runner.run(context -> {
            PaymentGatewayProperties fresh = new PaymentGatewayProperties();
            assertThat(context.getBean(PaymentGatewayProperties.class))
                    .usingRecursiveComparison().isEqualTo(fresh);
        });
    }
}
🔑 ConfigDataApplicationContextInitializer — недостающее звено. Без него ApplicationContextRunner не запускает фазу подготовки Environment, и ни spring.config.import, ни ваши EnvironmentPostProcessor не отработают — тест покажет «дефолтов нет», хотя в реальном приложении они есть. Инициализатор из spring-boot-test воспроизводит нужный кусок жизненного цикла. Для чистой проверки своего постпроцессора вне контекста достаточно вызвать postProcessEnvironment(new MockEnvironment(), new SpringApplication()) и ассертить содержимое environment.getPropertySources().