ESPN2026 WNBA playoffs: Schedule, scores for every gameThe Jerusalem PostIsrael not conducting genocide, not guilty of apartheid, new academic study showsInquirerGraft complaint filed vs Laguna governor over review class programDaily MaverickGROUNDUP: Elections 2026: How this North West town went from boom to bustCBS SportsRanking the pitching staffs of all 12 MLB playoff teams: Yankees rotation looks unbeatableBBC News BrasilDo jogo do bicho ao tigrinho: a história da jogatina e suas proibições no BrasilFootball ItaliaNations League Liveblog: Turkiye vs. ItalyStraits Times SportPremier League set for long wait as Man City face prospect of sanctionsBillboardAs Stevie Wonder’s ‘Songs In the Key of Life’ Turns 50, Musicians Reflect on Its Impact: ‘An Actual Gift to Humanity’ABC NewsFBI co-Deputy Director Andrew Bailey resignsCBS NewsIran says it's up to Trump to choose between war and diplomacy7sur7Cadre financier européen 2028-2034: “Les signaux d’alerte pour la Wallonie sont réels”, prévient Dolimont
The Daily Newsstand · Free, Always
Monday, September 28, 2026

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

Translate

Всем привет! Это Михаил Поливаха, технический руководитель проекта 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.

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

Sample of usage

Sample of usage

Итак, требование обманчиво простое:

Поставлять из библиотеки автоматически применяемые свойства по умолчанию, которые каждый отдельный сервис по-прежнему может переопределить.

Теперь посмотрим, как очевидные решения одно за другим незаметно нарушают это требование.

Почему решения, к которым обращаются все, разочаровывают

Попытка 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!)

Промежуточный вывод

Каждый наивный подход терпит неудачу по одной из трёх причин: либо выполняется не вовремя, либо получает неправильный приоритет, либо зависит от действий людей. На самом деле нам нужен механизм, который:

  1. срабатывает рано, пока Environment ещё формируется;

  2. позволяет добавить источник свойств с выбранным нами приоритетом. Для значений по умолчанию он может находиться ниже пользовательской конфигурации или выше неё, когда мы намеренно хотим побеждать в любом случае;

  3. работает автоматически (что было важно для 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, учитывайте различие в пакетах.

Его место в процессе запуска

Spring Boot config loading chain

Spring Boot config loading chain

Точное взаимное расположение промежуточных этапов обычно зависит от порядка выполнения, который, как мы сейчас увидим, и является по-настоящему тонким моментом.

Реальный пример: как мы открываем доступ к пользовательским эндпоинтам 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, когда:

  1. Вы поставляете конфигурацию из библиотеки в чужое приложение, и она должна применяться автоматически.

  2. Конфигурация должна существовать на раннем этапе, до создания бинов, до инициализации журналирования либо до или во время разрешения конфигурационных данных.

  3. Вам нужно вычислять, объединять или сканировать, чтобы получить значения, а не задавать их статически.

  4. Вам нужны переопределяемые значения по умолчанию (addLast) или значения, которые намеренно получают приоритет после объединения с пользовательскими данными (addFirst).

Выберите более простое решение, когда:

  • Вам нужны лишь статические значения по умолчанию для собственного приложения (а не библиотеки). Обычный application.yml в этом приложении прекрасно подойдёт.

  • Конфигурация используется только после запуска контекста. Обычные @ConfigurationProperties и бины проще и понятнее.

  • Вы собираетесь скрыть здесь важный параметр, которому на самом деле место в явном API или в свойстве, намеренно задаваемом пользователем.

  • Вы предоставляете значения по умолчанию для собственных свойств, а не для свойств Spring Boot. Для собственных @ConfigurationProperties использование EnvironmentPostProcessor, как правило, излишне усложняет решение.

Заключение

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

View the original on Хабр →

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.