[Перевод] Скрытый секрет конфигурации Spring Boot. EnvironmentPostProcessor

Всем привет! Это Михаил Поливаха, технический руководитель проекта Axelix (Open Source-продукта, который помогает как дебажить, так и находить распространённые проблемы и неэффективные решения в приложениях Spring Boot).
Я хочу рассказать о части Spring Framework, с которой многие никогда не работали напрямую, хотя почти все сталкивались с проблемой, которую она решает. Начну со сценария, который, как мне кажется, многим покажется знакомым:
Представим следующую ситуацию. Вы сопровождаете общую библиотеку, внутренний стартер company-commons, от которого зависит каждый сервис. И вы хотите, чтобы он задавал несколько разумных значений по умолчанию для всех приложений, подключающих этот стартер Spring Boot.
Вы помещаете application.yml в src/main/resources библиотеки, задаёте в нём свойства, выпускаете библиотеку, и, к сожалению, она ведёт себя совсем не так, как вы ожидали. Если вы когда-либо сталкивались с похожими проблемами, эта статья для вас.
TL;DR: application.yml не подходит для этой задачи, а в Spring Boot есть специально предназначенный для неё механизм, который редко упоминают в доке, это EnvironmentPostProcessor (кстати, в Axelix мы активно его используем).
Если вам интересны подробности, давайте разбираться!
Проблема, с которой рано или поздно сталкиваются все
Вот упрощённая схема. У вас есть множество сервисов. Все они зависят от одного общего модуля, назовём его company-commons. Вы хотите, чтобы этот модуль добавлял конфигурацию по умолчанию в каждый подключивший его сервис:
разумные настройки Jackson
доступность эндпоинтов Actuator по умолчанию
размер пула соединений
формат журналирования и т.д.
Надеюсь, идея понятна. Это может быть всё, что иначе пришлось бы копировать в сорок разных файлов application.yml.
Но (и в этом вся суть) каждый сервис по-прежнему должен иметь возможность переопределить любое из этих значений по умолчанию в собственной конфигурации. Библиотека задаёт общий бейзлайн, а не предел.

Итак, требование обманчиво простое:
Поставлять из библиотеки автоматически применяемые свойства по умолчанию, которые каждый отдельный сервис по-прежнему может переопределить.
Теперь посмотрим, как очевидные решения одно за другим незаметно нарушают это требование.
Почему решения, к которым обращаются все, разочаровывают
Попытка 1: включённый в библиотеку application.yml
Это первое, что многие пробуют. Человек помещает application.yml внутрь company-commons, и теперь в classpath находятся два файла application.yml: один из библиотеки и один из сервиса.
Но есть нюанс: Spring Boot не «объединяет» два файла application.yml, находящихся в одном и том же стандартном расположении. Он загружает конфигурационные данные из известных ему расположений, и второй application.yml, находящийся где-то ещё в classpath, участвует в этом процессе не так, как вы себе представляете. В зависимости от порядка элементов в classpath и способа упаковки файл вашей библиотеки может быть молча проигнорирован или вести себя так, что результат покажется произвольным. В любом случае application.yaml стартера не будет служить источником значений по умолчанию: вместо этого поведение окажется просто непредсказуемым, и это определённо неудачное решение.
Попытка 2: поставлять application-commons.yml и просить команды активировать профиль
Вроде, выглядит аккуратнее. Поместите значения по умолчанию в профиль и напишите в документации:
Просто добавьте
commonsвspring.profiles.active, и всё заработает.
Проблема кроется в самом слове «просто». Теперь корректность ваших значений по умолчанию зависит от того, будут ли все команды, в каждом сервисе и всегда помнить об активации этого профиля. Если забыть о нём в одном сервисе, значения по умолчанию молча исчезнут, что в зависимости от назначения пользовательского стартера может привести к инцидентам или угрозам безопасности.
Поэтому мы хотим, чтобы платформа гарантировала такое поведение, а не чтобы оно зависело от предложения, затерянного в README.
Попытка 3: класс @Configuration с @PropertySource / @Bean
Теперь мы пишем настоящий код, поэтому наверняка получаем полный контроль. Не совсем: на этот раз проблема заключается во времени выполнения.
Класс @Configuration обрабатывается во время построения ApplicationContext. Но значительная часть важной конфигурации используется ещё до этого момента:
система журналирования инициализируется очень рано;
собственный механизм конфигурационных данных Spring Boot (тот, что загружает
application.yml, разрешает профили и обрабатываетspring.config.import) уже отработал вConfigDataEnvironmentPostProcessor;приоритет свойств из
@PropertySourceзадавать неудобно. Что я имею в виду? Управлять общим порядком источников свойств через@PropertySourceв целом неудачная и непредсказуемая идея.
Таким образом, подход на основе бинов может читать конфигурацию, но, как правило, применяется слишком поздно, чтобы формировать её основу.
Если значение по умолчанию должно влиять на журналирование, расположение конфигурационных данных или что-либо ещё, определяемое во время запуска, @Bean уже не сможет повлиять на результат.
Попытка 4: просто задокументировать свойства
«Добавьте эти пять свойств в свой
application.yml».
Это попытка 2, только даже без профиля. Она полностью перекладывает работу и, что ещё хуже, риск допустить ошибку на каждую команду, использующую библиотеку. Это совсем неудачное решение.
P.S. Должен признать небольшой (большой) embarrassment: мы просили сделать именно это в релизе Axelix 1.1, но начиная с Axelix 1.2 это больше не будет требоваться (кстати, благодаря внешнему контрибьютеру из Open Source!)
Промежуточный вывод
Каждый наивный подход терпит неудачу по одной из трёх причин: либо выполняется не вовремя, либо получает неправильный приоритет, либо зависит от действий людей. На самом деле нам нужен механизм, который:
срабатывает рано, пока
Environmentещё формируется;позволяет добавить источник свойств с выбранным нами приоритетом. Для значений по умолчанию он может находиться ниже пользовательской конфигурации или выше неё, когда мы намеренно хотим побеждать в любом случае;
работает автоматически (что было важно для Axelix): не нужно активировать профиль или помнить о каком-либо свойстве.
К счастью, такой механизм существует. Он называется EnvironmentPostProcessor.
Суть EnvironmentPostProcessor
В Spring Framework есть объект типа ConfigurableEnvironment. На раннем этапе запуска этот объект содержит все источники свойств, профили и вычисленные значения свойств до обновления ApplicationContext (что важно).
Таким образом, EnvironmentPostProcessor представляет собой обратный вызов, который срабатывает в этот промежуток (то есть до обновления) и получает Environment, который можно изменять:
public class CompanyDefaultsEnvironmentPostProcessor implements EnvironmentPostProcessor {
@Override
public void postProcessEnvironment(ConfigurableEnvironment environment, SpringApplication application) {
environment
.getPropertySources()
.addLast(new MapPropertySource("company-defaults", Map.of(
// override the actuator's default
"management.endpoints.web.exposure.include", "health,info",
// override the jackson's default
"spring.jackson.default-property-inclusion", "non_null"
)));
}
}
Здесь стоит отметить несколько моментов:
addLast(...)помещает наш источник свойств в конец порядка разрешения, то есть задаёт ему самый низкий приоритет. Все остальные источники:application.ymlсервиса, переменные окружения и аргументы командной строки проверяются раньше. Наши значения применяются, только если их не задал никто другой. Это в точности поведение «значений по умолчанию».Он выполняется достаточно рано, чтобы эти свойства уже существовали до начальной загрузки необходимых бинов и до большинства операций Spring Boot с
Environment.
Как Spring вообще находит наш EnvironmentPostProcessor?
Важно понимать, что EnvironmentPostProcessor не может быть обычным Spring-бином. Только задумайтесь: он должен выполниться до появления ApplicationContext, поэтому контейнера бинов, в котором его можно было бы найти, ещё нет. Это классическая проблема курицы и яйца.
Хорошая новость в том, что эта проблема хорошо известна в Spring Boot и уже решена с помощью spring.factories (и нет, этот файл больше НЕ используется для классов @AutoConfiguration).
Итак, нам нужно зарегистрировать наш EnvironmentPostProcessor в META-INF/spring.factories (обычная пара ключ-значение):
org.springframework.boot.EnvironmentPostProcessor=\
com.acme.commons.CompanyDefaultsEnvironmentPostProcessor
Spring Boot обнаруживает их с помощью SpringFactoriesLoader, того же механизма, который лежит в основе многих процессов ранней загрузки. Ещё раз обратите внимание: речь идёт о spring.factories, а не о более новом файле AutoConfiguration.imports, используемом для классов автоконфигурации. Это разные вещи.
P.S. Небольшое замечание, появившееся после ревью. В Spring Boot 4 интерфейс переместился из
org.springframework.boot.env.EnvironmentPostProcessorвorg.springframework.boot.EnvironmentPostProcessor. Если вы поддерживаете несколько поколений Boot, учитывайте различие в пакетах.
Его место в процессе запуска

Точное взаимное расположение промежуточных этапов обычно зависит от порядка выполнения, который, как мы сейчас увидим, и является по-настоящему тонким моментом.
Реальный пример: как мы открываем доступ к пользовательским эндпоинтам Actuator
Покажу реальный пример использования этого шаблона в промышленной эксплуатации. Axelix поставляет стартеры Spring Boot, которые команды подключают к своим сервисам. По сути, стартер представляет собой просто библиотеку, которая должна добавлять некоторую конфигурацию в чужое приложение, то есть в точности соответствует описанной выше задаче.
Важно то, что Axelix определяет собственные эндпоинты Actuator (именно через них Axelix Master взаимодействует с вашим сервисом). Чтобы эти эндпоинты были доступны, их нужно перечислить в management.endpoints.web.exposure.include. Мы могли бы написать в документации: «Добавьте axelix-* в список доступных эндпоинтов», и во времена версии 1.1 именно так и поступали (простите 🙈). Но вы уже знаете, как я отношусь к решениям, которые зависят от человеческой памяти.
Поэтому начиная с версии 1.2 стартер самостоятельно добавляет эндпоинты с помощью EnvironmentPostProcessor. Нюанс заключается в том, что пользователь, скорее всего, уже задал свойство management.endpoints.web.exposure.include. Поэтому мы должны добавить свои эндпоинты к эндпоинтам конечного пользователя и НЕ ДОЛЖНЫ перезаписывать их. Вот реальный код (слегка сокращённый):
public class AxelixEndpointsEnvironmentPostProcessor implements EnvironmentPostProcessor, Ordered {
public static final String INCLUDED_PROPERTY = "management.endpoints.web.exposure.include";
@Override
public void postProcessEnvironment(ConfigurableEnvironment environment, SpringApplication application) {
Set<String> current = Binder.get(environment)
.bind(INCLUDED_PROPERTY, Bindable.setOf(String.class))
.orElse(Collections.emptySet());
if (current.contains("*")) {
return; // the user already exposed everything, nothing to do
}
Set<String> merged = new LinkedHashSet<>();
if (current.isEmpty()) {
merged.add("health"); // preserve Spring Boot's own default, which we'd otherwise shadow
}
current.forEach(merged::add);
merged.addAll(discoverAxelixEndpointIds(environment)); // classpath scan for @Endpoint
environment
.getPropertySources()
.addFirst(new MapPropertySource("axelix", Map.of(INCLUDED_PROPERTY, String.join(",", merged))));
}
@Override
public int getOrder() {
return ConfigDataEnvironmentPostProcessor.ORDER + 1;
}
}
Здесь стоит обратить внимание на несколько моментов:
С помощью
Binderмы сначала читаем существующее пользовательское значение, учитывая заданную пользователем конфигурацию, и полностью прекращаем работу, если пользователь уже открыл доступ к*.Мы объединяем пользовательские эндпоинты с нашими, а не заменяем их.
Вот и всё. Довольно просто.
Тонкий момент: порядок выполнения
Помните, я говорил, что порядок выполнения является по-настоящему тонким моментом? Вот он. Посмотрите на getOrder():
return ConfigDataEnvironmentPostProcessor.ORDER + 1;
Мы намеренно запускаемся сразу после ConfigDataEnvironmentPostProcessor, встроенного процессора, который загружает application.yml, разрешает профили и обрабатывает spring.config.import.
Почему? Потому что нам нужно прочитать пользовательское значение management.endpoints.web.exposure.include, которое, вероятнее всего, находится в его application.yml. Если бы мы запускались до загрузки конфигурационных данных, этого свойства попросту «ещё не существовало бы», и мы выполняли бы объединение с пустым множеством, незаметно отбрасывая пользовательскую конфигурацию.
Вот полезное практическое правило:
EnvironmentPostProcessor, который читает существующую конфигурацию, должен выполняться после загрузки конфигурационных данных. Тот, который задаёт источник конфигурации (например, новый YAML-файл), должен выполняться до неё.
Нюансы, которые стоит знать перед использованием
EnvironmentPostProcessor обладает большими возможностями именно потому, что выполняется так рано, и эта особенность представляет собой обоюдоострый меч:
Нет внедрения зависимостей. Повторюсь, это не бин, поэтому внедрить в него что-либо с помощью
@Autowiredнельзя. Он должен быть самодостаточным.Журналирование (пока) недоступно. На момент запуска вашего процессора система журналирования может быть ещё не инициализирована, поэтому вести журнал из него ненадёжно. Однако, если это действительно необходимо, Spring Boot предоставляет
DeferredLogFactory, позволяющую буферизовать сообщения до готовности системы журналирования.Обрабатывайте сбои корректно. Исключение на этом этапе может прервать весь запуск на самой ранней стадии и оставить мало диагностической информации. Если ваша логика может завершиться ошибкой (например, при сканировании classpath или разборе данных), предусмотрите защиту.
Он действует глобально. Он срабатывает в каждом приложении, в classpath которого присутствует ваша библиотека. Это и есть его преимущество, но по этой же причине работа должна выполняться только при необходимых условиях, быть нетрудоёмкой и прекращаться как можно раньше, если делать нечего (как в приведённом выше примере с проверкой
*).При чтении структурированных свойств или свойств с нестрогой привязкой (множеств, списков,
kebab-caseиcamelCase) предпочитайтеBinderручному разбору. Именно его использует сам Spring Boot.
Когда его следует использовать, а когда нет
Используйте EnvironmentPostProcessor, когда:
Вы поставляете конфигурацию из библиотеки в чужое приложение, и она должна применяться автоматически.
Конфигурация должна существовать на раннем этапе, до создания бинов, до инициализации журналирования либо до или во время разрешения конфигурационных данных.
Вам нужно вычислять, объединять или сканировать, чтобы получить значения, а не задавать их статически.
Вам нужны переопределяемые значения по умолчанию (
addLast) или значения, которые намеренно получают приоритет после объединения с пользовательскими данными (addFirst).
Выберите более простое решение, когда:
Вам нужны лишь статические значения по умолчанию для собственного приложения (а не библиотеки). Обычный
application.ymlв этом приложении прекрасно подойдёт.Конфигурация используется только после запуска контекста. Обычные
@ConfigurationPropertiesи бины проще и понятнее.Вы собираетесь скрыть здесь важный параметр, которому на самом деле место в явном API или в свойстве, намеренно задаваемом пользователем.
Вы предоставляете значения по умолчанию для собственных свойств, а не для свойств Spring Boot. Для собственных
@ConfigurationPropertiesиспользованиеEnvironmentPostProcessor, как правило, излишне усложняет решение.
Заключение
Приведённый выше пример имеет открытый исходный код, поэтому, если хотите увидеть, как все части сочетаются друг с другом, изучите реальную реализацию в репозитории.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.