Ohhnews

分类导航

$ cd ..
foojay原文

Spring Boot 配置的隐藏秘诀:EnvironmentPostProcessor

#spring boot#environmentpostprocessor#配置管理#默认配置#java

大家好!我是 Mikhail Polivakha,Axelix 项目的技术负责人(这是一个开源产品,帮助你发现 Spring Boot 应用中的常见问题和低效之处——顺便说一句,给我们点个 Star 吧!)。

我想聊聊 Spring Boot 中一个很多人从未直接接触过、但几乎每个人都遇到过它所要解决问题的东西。我先从一个我猜你会觉得熟悉的场景说起:

我们假设下面这种情况。你维护着一个共享库,一个内部的“company-commons”starter,每个服务都依赖它。你希望为所有引入这个 Spring Boot starter 的应用设置一些合理的默认值。

于是你把 application.yml 放进库的 src/main/resources,在那里设置属性,发布出去,结果行为完全不是你所期望的。如果你曾遇到过类似问题,那这篇文章就是为你写的。

简而言之: application.yml 并不是做这件事的正确工具,Spring Boot 有一个为此专门设计的钩子,但教程里很少提到它——EnvironmentPostProcessor(顺便说一句,在 Axelix 中我们大量使用它)。

如果你对具体细节感兴趣,那就让我们深入看看!

每个人最终都会遇到的问题

这里是一个简化后的场景。你有很多服务,它们都依赖同一个共享模块,我们称它为 company-commons。你希望这个模块为每个引入它的服务提供默认配置:

  • 合理的 Jackson 配置
  • 默认的 actuator 端点暴露配置
  • 连接池大小
  • 日志格式

你应该明白意思了。就是那些否则会被复制粘贴到四十个不同 application.yml 文件里的内容。

但是(这才是关键),每个服务仍然必须能够在其自己的配置中覆盖这些默认值中的任何一个。 这个库提供的是共同的下限,而不是上限。

$ mermaid
graph TD
  L["company-commons<br/>(default properties)"] --> S1["orders-service<br/>overrides some"]
  L --> S2["billing-service<br/>overrides others"]
  L --> S3["reporting-service<br/>uses defaults as-is"]

所以这个需求看似简单,实则不然:

从库中发布默认属性,使其自动生效,同时任何单个服务仍然可以覆盖它们。

现在,让我们逐一看看那些显而易见的方案是如何悄悄无法满足这个需求的。

为什么大家常想到的方案都令人失望

尝试 1:在库中打包一个 application.yml

这是每个人首先会尝试的做法。你把一个 application.yml 放进 company-commons,现在 classpath 上会有两个 application.yml 文件:库中的一个和服务中的一个。

问题在于:Spring Boot 不会“合并”两个位于相同约定位置的 application.yml 文件。它会从众所周知的位置解析配置数据,而另一个位于 classpath 其他位置的 application.yml 不会以你想象的方式参与这一解析。取决于 classpath 顺序和打包方式,你的库中的文件可能被静默忽略,也可能以某种让人觉得随意的行为生效。无论哪种情况,starter 的 application.yaml 都不会作为默认值起作用,行为会相当不可预测,这当然不是好方案。

尝试 2:发布 application-commons.yml 并让团队激活一个 profile

更干净,对吧?把默认值放在一个 profile 下,并在文档里写:

只需把 commons 加到你的 spring.profiles.active 中,就大功告成了。

问题就出在“只需”这个词上。现在,你的默认值是否正确,取决于每个团队、每个服务、永远都要记得激活那个 profile。只要有一个服务漏掉了,默认值就会悄然消失,这可能根据自定义 starter 的性质导致事故或安全风险。

所以我们希望平台来强制实现这种行为,而不是靠 README 里埋着的一句话。

尝试 3:使用带 @PropertySource / @Bean 的 @Configuration 类

现在我们是在写真正的代码,所以肯定完全可控了吧。并非如此,因为这次的问题在于时机。

@Configuration 类是在 ApplicationContext 构建过程中被处理的。但很多重要配置在那一刻之前就已被消费:

  • 日志系统很早就初始化了;
  • Spring Boot 自己的配置数据机制(负责加载 application.yml、解析 profile、处理 spring.config.import 的那套东西)已经在 ConfigDataEnvironmentPostProcessor 中运行过了;
  • @PropertySource 的属性优先级很别扭。我是什么意思?嗯,它别扭在于:通过 @PropertySource 总体控制属性源的顺序通常是个糟糕且不可预测的主意。

因此,基于 bean 的方式可以读取配置,但一般来说,要塑造配置的基础已经太晚了。

如果你的默认值需要影响日志、配置数据位置,或启动期间解析的任何内容,@Bean 根本插不上话。

尝试 4:只把属性写进文档

“把这五个属性加到你的 application.yml 中。”

这是连 profile 都没有的尝试 2。它把工作完全转嫁出去,更糟的是,把做错的风险也转嫁给每一个使用方团队。这根本不是好方案。

附注:我必须承认有点小尴尬,因为我们在 Axelix 1.1 版本中曾经要求这样做,但从 Axelix 1.2 起我们不再需要了(顺便感谢那位外部开源贡献者!)。

小结

每一种朴素方案都会以三种方式之一失败:要么运行时机不对,要么落在了错误的优先级上,要么依赖于人去做什么。我们真正想要的是一个钩子,它能:

  1. 运行得足够早,在 Environment 仍在组装时执行。
  2. 让我们按自己选择的优先级添加属性源。作为默认值时放在用户配置之下,或者当我们有意无论如何都要胜出时放在上面。
  3. 是自动的(这对 Axelix 很重要)——不需要激活 profile,不需要记住某个属性。

幸运的是,这个钩子存在。它叫作 EnvironmentPostProcessor。

EnvironmentPostProcessor 的本质

Spring Framework 有一个 ConfigurableEnvironment 类型的对象。这个对象在启动早期保存你所有的属性源、profile 和已解析属性——在 ApplicationContext 刷新之前(这一点很重要)。

所以,EnvironmentPostProcessor 是一个在该窗口期间(即在 refresh 之前)触发的回调,并会拿到 Environment 供其修改:

$ java
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、环境变量、命令行参数——都会先被查询。只有在没有其他来源设置值时,我们的值才会生效。这正是“默认值”的行为。
  • 它运行得足够早,因此这些属性会在必要 bean 被引导之前、以及 Spring Boot 对 Environment 所做的大部分处理之前就位。

Spring 到底如何找到我们的 EnvironmentPostProcessor?

需要认识到的一件重要事情是:EnvironmentPostProcessor 根本不能是一个普通的 Spring bean。想想看——它必须在 ApplicationContext 存在之前运行,所以那时还没有 bean 容器可供查找。这是一个经典的先有鸡还是先有蛋问题。

但好消息是,这个问题在 Spring Boot 中相当常见,并且已经通过 spring.factories 解决了(不,这个文件现在不再用于 @AutoConfiguration 类)。

所以,在 Spring Boot 3 中,我们需要在 META-INF/spring.factories 中注册我们的 EnvironmentPostProcessor(只是一个简单的键值对):

org.springframework.boot.env.EnvironmentPostProcessor=\
com.acme.commons.CompanyDefaultsEnvironmentPostProcessor

Spring Boot 通过 SpringFactoriesLoader 发现这些处理器,这也是其许多早期引导过程背后的同一机制。再次注意,这里是 spring.factories,而不是用于自动配置类的较新的 AutoConfiguration.imports 文件。它们是不同的。

附注:这里补一个小勘误。在 Spring Boot 4 中,该接口从 org.springframework.boot.env.EnvironmentPostProcessor 移到了 org.springframework.boot.EnvironmentPostProcessor。如果你支持多个 Boot 版本,请注意包名。

它在启动流程中的位置

$ mermaid
graph TD
  A["SpringApplication.run()"] --> B["Environment created"]
  B --> C["EnvironmentPostProcessors fire<br/>(spring.factories)"]
  C --> D["ConfigData loaded<br/>application.yml, profiles, spring.config.import"]
  D --> E["Logging system initialized"]
  E --> F["ApplicationContext refreshed<br/>@Configuration, @Bean, @ConfigurationProperties"]
  F --> G["Application ready"]

中间步骤的确切交错顺序通常取决于排序,而正如我们即将看到的,这才是真正微妙的部分。

一个真实示例:我们如何暴露自定义 Actuator 端点

让我展示一个正是这种模式的真实生产用法。Axelix 提供 Spring Boot starter,团队会把它添加到自己的服务中。starter 实际上就是一个库,必须向别人的应用贡献一些配置——也就是上面问题的精确形态。

重要的是,Axelix 定义了自己的 actuator 端点(Axelix Master 就是这样与你的服务通信的)。要让这些端点可访问,它们必须列在 management.endpoints.web.exposure.include 中。我们本可以在文档里写“请把 axelix-* 加到你的暴露列表中”——在 1.1 时代我们确实这么做了(我很抱歉 🙈)。但你已经知道我对依赖人们记住事情的方案是什么看法。

所以,从 1.2 起,starter 会通过 EnvironmentPostProcessor 自己贡献这些端点。问题在于,management.endpoints.web.exposure.include 很可能是用户已经设置过的属性。因此我们必须把我们的端点添加到最终用户的值中,绝不能覆盖它们。下面是实际代码(略有删减):

$ java
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、解析 profile 并处理 spring.config.import。

为什么?因为我们需要读取用户的 management.endpoints.web.exposure.include,而这个值很可能存在于他们的 application.yml 中。如果我们在配置数据加载之前运行,那个属性“还不会在那里”,我们就会针对空集合进行合并,从而悄然丢掉用户的配置。

这就是值得记住的经验法则:

如果 EnvironmentPostProcessor 要读取已有配置,就必须在配置数据加载之后运行。如果它要影响配置数据如何加载(例如 YAML 文件的配置位置、imports 或激活的 profile),就必须在配置数据加载之前运行。

使用前值得了解的坑

EnvironmentPostProcessor 之所以强大,正是因为它运行得如此早,而这种“早”也是一把双刃剑:

  • 不能注入应用上下文 bean。 再说一次,它不是 bean,所以你不能向其中 @Autowired 任何东西。保持它自包含。
  • 尚不能记日志。 你的处理器运行时,日志系统可能尚未初始化,因此从处理器内部记日志并不可靠;不过 Spring Boot 提供了 DeferredLogFactory,如果你确实需要,它可以缓冲输出直到日志系统就绪。
  • 优雅失败。 这里的异常可能在非常早的阶段破坏整个启动过程,而且诊断信息很差。如果你的逻辑可能失败(例如 classpath 扫描、解析),请做好防护。
  • 它是全局的。 对于 classpath 中带有你的库的每个应用,它都会触发。这正是特性所在,但也意味着你应该让工作保持有条件且廉价,并在无事可做时尽早退出(如上面的示例用 * 检查所做的那样)。
  • 读取结构化或宽松绑定属性时,优先使用 Binder 而不是手工解析(集合、列表、kebab-case 与 camelCase)。Spring Boot 自己就是这么做的。

何时该用它,何时不该用

在以下情况下使用 EnvironmentPostProcessor:

  1. 你正在从库向别人的应用发布配置,并且它应当自动生效。
  2. 配置必须尽早就位——在 bean 之前、在日志之前,或在配置数据解析之前/前后。
  3. 你需要派生、合并或扫描来计算值,而不是静态地声明它们。
  4. 你想要保持可覆盖的默认值(addLast),或者在与用户输入合并后有意胜出(addFirst)。

在以下情况下选择更简单的方案:

  • 你只是需要为自己应用(不是库)提供静态默认值。在该应用中使用普通的 application.yml 完全没问题。
  • 配置只在上下文启动后才被消费。普通的 @ConfigurationProperties 和 bean 更简单,也更易发现。
  • 你可能会想在这里隐藏一个重要参数,而它其实应该属于显式 API 或用户有意设置的属性。
  • 你是为自己的属性提供默认值,而不是为 Spring Boot 的属性提供默认值。对于你自己的 @ConfigurationProperties,EnvironmentPostProcessor 通常是过度复杂化。## 结语

上面的示例是开源的,如果你想了解各部分是如何组合在一起的,可以去仓库中阅读真实代码。

保重!
分享本页

发现错误,或有内容要补充?在 GitHub 上编辑此页

[LOADING...]
作者

Mikhail Polivakha

Mikhail Polivakha 是 Axelix Labs 的创始人兼 CEO,也是一名专注于 Java 和 Spring Boot 的软件工程师。他为包括 Spring Data 和 Spring Boot 在内的开源项目做出贡献,并在 Spring I/O 和 Devoxx 上发表演讲。

相关文章

调试 [LOADING...]
[LOADING...]Braham Shakti2026年9月3日 1,213 次浏览

未发现数据库操作:编写 Spring Boot 分析器让我对 Spring 有了哪些认识

调试开发者工具Java

Java [LOADING...]
[LOADING...]Ted Kupolov2026年10月15日 611 次浏览

无需 Prometheus 和 Grafana 的轻量级 Spring Boot 监控

JavaSpring可观测性

Java [LOADING...]
[LOADING...]Jérôme LELEU2026年10月23日 179 次浏览

如何使用 OpenID Connect 和 pac4j 保护 Spring Boot 应用

JavaSpring安全

AI [LOADING...]
[LOADING...][LOADING...]2 位作者 2026年7月31日 1,076 次浏览

如何创建 Spring Boot 欺诈评分服务

AIDeep Netts机器学习

加入讨论