Типичный сценарий: вы пишете библиотеку-стартер payment-gateway-starter, которую подключают другие команды. Стартеру нужны свои настройки — URL платёжного шлюза, таймауты, флаги поведения. Есть два варианта их доставки:
payment-gateway: из README в свой application.yml. Работает, но дублирование, ошибки копипаста и «а какие дефолты вообще разумные?» ложатся на потребителя.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 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 — приложение всегда может переопределить дефолты стартера |
Недостаток очевиден: строку всё равно должен написать потребитель. Если хочется «подключил зависимость — и ничего больше делать не надо», нужен следующий раздел.
Это классический программный способ, которым сама экосистема 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:
EnvironmentPostProcessor вызывается до старта контекста — бины ещё не существуют, компонент-скан ничего не увидит. Spring находит реализации только сканированием META-INF/spring.factories на этапе подготовки Environment (механизм тот же, что и у автоконфигураций, но другой ключ).ApplicationEnvironmentPreparedEvent: SpringApplication.run() уже прочитал application.yml и переменные окружения (это делает встроенный ConfigDataEnvironmentPostProcessor), но контекст ещё не создан. Ваш постпроцессор просто дописывает источник в конец списка.org.springframework.boot.env.YamlPropertySourceLoader превращает YAML в плоскую карту ключей (payment-gateway.base-url) с сохранением origin-информации (какой файл и строка дали значение — это видно в actuator-эндпоинте /actuator/env). Он возвращает список, потому что один YAML-файл может содержать несколько документов через ---.
Если по какой-то причине нужен именно @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 (низший приоритет). Тогда цепочка разрешения свойства выглядит так:
| # | Источник | Типичная роль |
|---|---|---|
| 1 | System properties / env vars | Прод-переопределения при деплое (PAYMENT_GATEWAY_BASEURL=...) |
| 2 | application.yml приложения | Осознанная настройка интегратором |
| 3 | application-{profile}.yml | Настройки окружений |
| 4 (последний) | YAML-дефолты стартера (addLast) | Разумные значения «из коробки», видны только если ничего выше не задано |
/actuator/env показывает для каждого свойства все PropertySource, где оно встречается, и какой из источников победил. Если после подключения стартера «не применились мои значения» — первый шаг диагностики: найти свойство там и посмотреть, чей источник стоит выше.
Подтянуть значения в 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: у каждого механизма свой ключ и свой файл).
@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,
# если его нет — значение после двоеточия
application.getBean(...) здесь — гарантированный IllegalStateException на старте.
| Критерий | spring.config.import | EnvironmentPostProcessor | @PropertySource + YAML-фабрика |
|---|---|---|---|
| Кто пишет код | Никто — одна строка у потребителя | Автор стартера пишет класс + spring.factories | Автор стартера пишет фабрику |
| Действие потребителя | Добавить spring.config.import | Ничего — работает само при подключении jar | Ничего (если аннотация в автоконфигурации) |
| Работает до создания бинов (logging и пр.) | Да | Да | Нет — фаза конфигурационных классов |
| Профили внутри YAML | Да | Да (loader вернёт все документы) | Нет |
| Управление приоритетом | Автоматически (ниже импортирующего файла) | Полный контроль (addLast/addFirst, Ordered) | Ограниченный |
| Отсутствие файла | Контролируемо через optional: | Под контролем автора (можно молча пропустить) | Молчаливое игнорирование или падение |
| Когда выбирать | Явность важнее магии; единичные интеграции | Стартер «подключил и работает»; дефолты из jar | Чистый Spring Framework без Boot |
Применять YAML-дефолты в стартере стоит, когда:
Избегать стоит, когда:
Properties-класса и менять их осознанно, а YAML оставить для документирования.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);
});
}
}
ApplicationContextRunner не запускает фазу подготовки Environment, и ни spring.config.import, ни ваши EnvironmentPostProcessor не отработают — тест покажет «дефолтов нет», хотя в реальном приложении они есть. Инициализатор из spring-boot-test воспроизводит нужный кусок жизненного цикла. Для чистой проверки своего постпроцессора вне контекста достаточно вызвать postProcessEnvironment(new MockEnvironment(), new SpringApplication()) и ассертить содержимое environment.getPropertySources().