Skip to content

环境抽象 (Environment Abstraction)

Environment 接口是集成在容器中的抽象,它模拟了应用程序环境的两个关键方面:配置方案 (Profiles)属性 (Properties)

配置方案 (Profile) 是一个命名的、逻辑上的 Bean 定义组,只有在给定的 Profile 处于激活状态时,才会向容器注册这些 Bean。无论是在 XML 中还是通过注解定义的 Bean,都可以分配给某个 Profile。Environment 对象在 Profile 方面的角色是确定哪些 Profile(如果有的话)当前是激活的,以及哪些 Profile(如果有的话)默认应该是激活的。

属性 (Properties) 在几乎所有应用中都扮演着重要角色,它们可能来源于多种渠道:属性文件、JVM 系统属性、系统环境变量、JNDI、Servlet 上下文参数、即席(ad-hoc)的 Properties 对象、Map 对象等。Environment 对象在属性方面的角色是为用户提供一个方便的服务接口,用于配置属性源并从中解析属性。

Bean 定义配置方案 (Bean Definition Profiles)

Bean 定义配置方案(Bean Definition Profiles)在核心容器中提供了一种机制,允许在不同的环境中注册不同的 Bean。“环境”一词对不同的用户可能意味着不同的东西,此特性可以帮助解决许多用例,包括:

  • 在开发中心使用内存数据库,而在测试或生产环境中从 JNDI 查找同一个数据源。
  • 仅当将应用部署到性能测试环境时才注册监控基础设施。
  • 为客户 A 和客户 B 的部署注册各自定制的 Bean 实现。

考虑第一个用例,即一个需要 DataSource 的实际应用。在测试环境中,配置可能如下所示:

java
@Bean
public DataSource dataSource() {
	return new EmbeddedDatabaseBuilder()
		.setType(EmbeddedDatabaseType.HSQL)
		.addScript("my-schema.sql")
		.addScript("my-test-data.sql")
		.build();
}
kotlin
@Bean
fun dataSource(): DataSource {
	return EmbeddedDatabaseBuilder()
			.setType(EmbeddedDatabaseType.HSQL)
			.addScript("my-schema.sql")
			.addScript("my-test-data.sql")
			.build()
}

现在考虑如何将此应用部署到 QA 或生产环境,前提是应用的数据源已注册在生产应用服务器的 JNDI 目录中。我们的 dataSource Bean 现在如下所示:

java
@Bean(destroyMethod = "")
public DataSource dataSource() throws Exception {
	Context ctx = new InitialContext();
	return (DataSource) ctx.lookup("java:comp/env/jdbc/datasource");
}
kotlin
@Bean(destroyMethod = "")
fun dataSource(): DataSource {
	val ctx = InitialContext()
	return ctx.lookup("java:comp/env/jdbc/datasource") as DataSource
}

问题在于如何根据当前环境在两这套配置之间切换。随着时间的推移,Spring 用户设计了许多方法来实现这一点,通常依赖于系统环境变量和包含 ${placeholder} 占位符的 XML <import/> 语句,这些占位符根据环境变量的值解析为正确的配置文件路径。Bean 定义配置方案是一个核心容器特性,它为此问题提供了解决方案。

如果我们将上述环境中特定 Bean 定义的用例泛化,最终会得到在某些上下文中注册某些 Bean 定义而在其它上下文中不注册的需求。你可以说你想在情境 A 中注册某个 Profile 的 Bean 定义,而在情境 B 中注册另一个 Profile。我们首先更新我们的配置以反映这一需求。

使用 @Profile

@Profile 注解让你能够指明一个组件只有在一个或多个指定的 Profile 处于激活状态时才符合注册条件。使用前面的示例,我们可以按如下方式重写 dataSource 配置:

java
@Configuration
@Profile("development")
public class StandaloneDataConfig {

	@Bean
	public DataSource dataSource() {
		return new EmbeddedDatabaseBuilder()
			.setType(EmbeddedDatabaseType.HSQL)
			.addScript("classpath:com/bank/config/sql/schema.sql")
			.addScript("classpath:com/bank/config/sql/test-data.sql")
			.build();
	}
}
kotlin
@Configuration
@Profile("development")
class StandaloneDataConfig {

	@Bean
	fun dataSource(): DataSource {
		return EmbeddedDatabaseBuilder()
				.setType(EmbeddedDatabaseType.HSQL)
				.addScript("classpath:com/bank/config/sql/schema.sql")
				.addScript("classpath:com/bank/config/sql/test-data.sql")
				.build()
	}
}
java
@Configuration
@Profile("production")
public class JndiDataConfig {

	@Bean(destroyMethod = "") // (1)
	public DataSource dataSource() throws Exception {
		Context ctx = new InitialContext();
		return (DataSource) ctx.lookup("java:comp/env/jdbc/datasource");
	}
}
kotlin
@Configuration
@Profile("production")
class JndiDataConfig {

	@Bean(destroyMethod = "") // (1)
	fun dataSource(): DataSource {
		val ctx = InitialContext()
		return ctx.lookup("java:comp/env/jdbc/datasource") as DataSource
	}
}

(1) @Bean(destroyMethod = "") 禁用了默认的销毁方法推断。

::: note 注意 如前所述,对于 @Bean 方法,你通常会选择使用编程式 JNDI 查找(通过 Spring 的 JndiTemplate/JndiLocatorDelegate 或前面显示的直接 JNDI InitialContext ),而不是使用 JndiObjectFactoryBean 变体,因为后者会迫使你将返回类型声明为 FactoryBean 类型。 :::

Profile 字符串可以包含一个简单的 Profile 名称(例如 production)或一个 Profile 表达式。Profile 表达式允许表达更复杂的 Profile 逻辑(例如 production & us-east)。Profile 表达式支持以下运算符:

  • !: 逻辑“非”(NOT)
  • &: 逻辑“与”(AND)
  • |: 逻辑“或”(OR)

::: note 注意 如果不使用圆括号,不能混合使用 &| 运算符。例如,production & us-east | eu-central 不是有效的表达式,必须写成 production & (us-east | eu-central)。 :::

你可以将 @Profile 用作元注解,以创建定制的组合注解。以下示例定义了一个自定义的 @Production 注解,你可以将其用作 @Profile("production") 的直接替代品:

java
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Profile("production")
public @interface Production {
}
kotlin
@Target(AnnotationTarget.CLASS)
@Retention(AnnotationRetention.RUNTIME)
@Profile("production")
annotation class Production

提示

如果一个 @Configuration 类标注了 @Profile,除非一个或多个指定的 Profile 处于激活状态,否则与该类关联的所有 @Bean 方法和 @Import 注解都会被跳过。如果一个 @Component@Configuration 类标注了 @Profile({"p1", "p2"}),除非 Profile 'p1' 或 'p2' 已被激活,否则该类不会被注册或处理。如果给定的 Profile 带有“非”运算符前缀(!),则只有在 Profile 不激活时,标注的元素才会注册。例如,给定 @Profile({"p1", "!p2"}),如果 Profile 'p1' 激活或 Profile 'p2' 不激活,则会发生注册。

@Profile 也可以在方法级别声明,以便仅包含配置类中的某一个特定 Bean(例如,用于某个 Bean 的替代变体),如下例所示:

java
@Configuration
public class AppConfig {

	@Bean("dataSource")
	@Profile("development") // (1)
	public DataSource standaloneDataSource() {
		return new EmbeddedDatabaseBuilder()
			.setType(EmbeddedDatabaseType.HSQL)
			.addScript("classpath:com/bank/config/sql/schema.sql")
			.addScript("classpath:com/bank/config/sql/test-data.sql")
			.build();
	}

	@Bean("dataSource")
	@Profile("production") // (2)
	public DataSource jndiDataSource() throws Exception {
		Context ctx = new InitialContext();
		return (DataSource) ctx.lookup("java:comp/env/jdbc/datasource");
	}
}
kotlin
@Configuration
class AppConfig {

	@Bean("dataSource")
	@Profile("development") // (1)
	fun standaloneDataSource(): DataSource {
		return EmbeddedDatabaseBuilder()
				.setType(EmbeddedDatabaseType.HSQL)
				.addScript("classpath:com/bank/config/sql/schema.sql")
				.addScript("classpath:com/bank/config/sql/test-data.sql")
				.build()
	}

	@Bean("dataSource")
	@Profile("production") // (2)
	fun jndiDataSource() =
		InitialContext().lookup("java:comp/env/jdbc/datasource") as DataSource
}

(1) standaloneDataSource 方法仅在 development Profile 中可用。 (2) jndiDataSource 方法仅在 production Profile 中可用。

::: note 注意 当 @Bean 方法上有 @Profile 时,可能会出现特殊情况:在具有相同 Java 方法名称的重载 @Bean 方法的情况下(类似于构造器重载),需要在所有重载方法上一致地声明 @Profile 条件。如果条件不一致,则只有重载方法中第一个声明上的条件才起作用。因此,@Profile 不能用于在具有特定参数签名的重载方法之间进行选择。同一个 Bean 的所有工厂方法之间的解析遵循 Spring 在创建时的构造器解析算法。

如果你想通过不同的 Profile 条件定义备选 Bean,请使用不同的 Java 方法名称,并通过 @Beanname 属性指向同一个 Bean 名称,如前面的示例所示。如果参数签名全部相同(例如,所有的变体都是无参工厂方法),这也是在有效的 Java 类中表示这种安排的唯一方式(因为只能有一个特定名称和参数签名的方法)。 :::

XML Bean 定义配置方案

在 XML 中,对应的是 <beans> 元素的 profile 属性。前面的示例配置可以重写为两个 XML 文件,如下所示:

xml
<beans profile="development"
	xmlns="http://www.springframework.org/schema/beans"
	xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
	xmlns:jdbc="http://www.springframework.org/schema/jdbc"
	xsi:schemaLocation="...">

	<jdbc:embedded-database id="dataSource">
		<jdbc:script location="classpath:com/bank/config/sql/schema.sql"/>
		<jdbc:script location="classpath:com/bank/config/sql/test-data.sql"/>
	</jdbc:embedded-database>
</beans>
xml
<beans profile="production"
	xmlns="http://www.springframework.org/schema/beans"
	xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
	xmlns:jee="http://www.springframework.org/schema/jee"
	xsi:schemaLocation="...">

	<jee:jndi-lookup id="dataSource" jndi-name="java:comp/env/jdbc/datasource"/>
</beans>

也可以避免这种拆分,并在同一个文件中嵌套 <beans/> 元素,如下例所示:

xml
<beans xmlns="http://www.springframework.org/schema/beans"
	xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
	xmlns:jdbc="http://www.springframework.org/schema/jdbc"
	xmlns:jee="http://www.springframework.org/schema/jee"
	xsi:schemaLocation="...">

	<!-- other bean definitions -->

	<beans profile="development">
		<jdbc:embedded-database id="dataSource">
			<jdbc:script location="classpath:com/bank/config/sql/schema.sql"/>
			<jdbc:script location="classpath:com/bank/config/sql/test-data.sql"/>
		</jdbc:embedded-database>
	</beans>

	<beans profile="production">
		<jee:jndi-lookup id="dataSource" jndi-name="java:comp/env/jdbc/datasource"/>
	</beans>
</beans>

spring-bean.xsd 已被限制为仅允许此类元素作为文件中的最后一部分。这应该有助于提供灵活性,而不会导致 XML 文件凌乱。

::: note 注意 XML 对应项不支持前面描述的 Profile 表达式。但是,可以使用 ! 运算符对 Profile 取反。还可以通过嵌套 Profile 来应用逻辑“与”,如下例所示:

xml
<beans xmlns="http://www.springframework.org/schema/beans"
	xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
	xmlns:jdbc="http://www.springframework.org/schema/jdbc"
	xmlns:jee="http://www.springframework.org/schema/jee"
	xsi:schemaLocation="...">

	<!-- other bean definitions -->

	<beans profile="production">
		<beans profile="us-east">
			<jee:jndi-lookup id="dataSource" jndi-name="java:comp/env/jdbc/datasource"/>
		</beans>
	</beans>
</beans>

在前面的示例中,如果 productionus-east Profile 同时激活,则会暴露 dataSource Bean。 :::

激活一个 Profile (Activating a Profile)

既然我们已经更新了配置,我们仍然需要指示 Spring 哪个 Profile 是激活的。如果我们现在启动示例应用,我们会看到抛出一个 NoSuchBeanDefinitionException,因为容器找不到名为 dataSource 的 Spring Bean。

激活 Profile 可以通过多种方式完成,但最直接的方法是对通过 ApplicationContext 提供的 Environment API 进行编程式调用。以下示例显示了如何操作:

java
AnnotationConfigApplicationContext ctx = new AnnotationConfigApplicationContext();
ctx.getEnvironment().setActiveProfiles("development");
ctx.register(SomeConfig.class, StandaloneDataConfig.class, JndiDataConfig.class);
ctx.refresh();
kotlin
val ctx = AnnotationConfigApplicationContext().apply {
	environment.setActiveProfiles("development")
	register(SomeConfig::class.java, StandaloneDataConfig::class.java, JndiDataConfig::class.java)
	refresh()
}

此外,你还可以通过 spring.profiles.active 属性声明式地激活 Profile,该属性可以通过系统环境变量、JVM 系统属性、web.xml 中的 Servlet 上下文参数,甚至作为 JNDI 中的条目来指定(参见 PropertySource 抽象)。在集成测试中,可以使用 spring-test 模块中的 @ActiveProfiles 注解声明激活的 Profile。

请注意,Profile 并非“非此即彼”的选择。你可以一次激活多个 Profile。在编程上,你可以向 setActiveProfiles() 方法提供多个 Profile 名称,该方法接受 String…​ 可变参数。以下示例激活了多个 Profile:

java
ctx.getEnvironment().setActiveProfiles("profile1", "profile2");
kotlin
ctx.getEnvironment().setActiveProfiles("profile1", "profile2")

声明式地,spring.profiles.active 可以接受以逗号分隔的 Profile 名称列表,如下例所示:

-Dspring.profiles.active="profile1,profile2"

默认 Profile (Default Profile)

默认 Profile 表示在没有 Profile 被激活时启有的 Profile。考虑以下示例:

java
@Configuration
@Profile("default")
public class DefaultDataConfig {

	@Bean
	public DataSource dataSource() {
		return new EmbeddedDatabaseBuilder()
			.setType(EmbeddedDatabaseType.HSQL)
			.addScript("classpath:com/bank/config/sql/schema.sql")
			.build();
	}
}
kotlin
@Configuration
@Profile("default")
class DefaultDataConfig {

	@Bean
	fun dataSource(): DataSource {
		return EmbeddedDatabaseBuilder()
				.setType(EmbeddedDatabaseType.HSQL)
				.addScript("classpath:com/bank/config/sql/schema.sql")
				.build()
	}
}

如果没有 Profile 激活,则会创建 dataSource。你可以将其视为为一个或多个 Bean 提供默认定义的一种方式。如果启用了任何 Profile,则默认 Profile 不适用。

默认 Profile 的名称是 default。你可以通过 EnvironmentsetDefaultProfiles() 或声明式地使用 spring.profiles.default 属性来更改默认 Profile 的名称。

PropertySource 抽象 (PropertySource Abstraction)

Spring 的 Environment 抽象在可配置的属性源层次结构上提供搜索操作。考虑以下列表:

java
ApplicationContext ctx = new GenericApplicationContext();
Environment env = ctx.getEnvironment();
boolean containsMyProperty = env.containsProperty("my-property");
System.out.println("Does my environment contain the 'my-property' property? " + containsMyProperty);
kotlin
val ctx = GenericApplicationContext()
val env = ctx.environment
val containsMyProperty = env.containsProperty("my-property")
println("Does my environment contain the 'my-property' property? $containsMyProperty")

在前面的代码片段中,我们看到了一种高级的方法来查询 Spring,当前环境中是否定义了 my-property 属性。为了回答这个问题,Environment 对象在一组 PropertySource 对象上执行搜索。PropertySource 是对任何键值对源的简单抽象,Spring 的 StandardEnvironment 配置了两个 PropertySource 对象——一个代表 JVM 系统属性集(System.getProperties()),另一个代表系统环境变量集(System.getenv())。

::: note 注意 对于 StandardEnvironment,这些默认属性源存在于独立应用中。StandardServletEnvironment 则填充了其它的默认属性源,包括 Servlet 配置、Servlet 上下文参数,以及(如果 JNDI 可用的话)JndiPropertySource。 :::

具体来说,当你使用 StandardEnvironment 时,如果在运行时存在 my-property 系统属性或 my-property 环境变量,则调用 env.containsProperty("my-property") 返回 true。

提示

执行的搜索是层级制的。默认情况下,系统属性优先于环境变量。因此,如果在调用 env.getProperty("my-property") 期间两个地方都设置了 my-property 属性,则系统属性值“胜出”并被返回。请注意,属性值不会合并,而是会被前面的条目完全覆盖。

对于常见的 StandardServletEnvironment,完整的层次结构如下,最高优先级的条目在顶部:

  1. ServletConfig 参数(如果适用——例如,在 DispatcherServlet 上下文的情况下)
  2. ServletContext 参数(web.xml 中的 context-param 条目)
  3. JNDI 环境变量(java:comp/env/ 条目)
  4. JVM 系统属性(-D 命令行参数)
  5. JVM 系统环境(操作系统环境变量)

最重要的是,整个机制是可配置的。也许你有一个想集成到此搜索中的自定义属性源。为此,请实现并实例化你自己的 PropertySource,并将其添加到当前 EnvironmentPropertySources 集中。以下示例显示了如何操作:

java
ConfigurableApplicationContext ctx = new GenericApplicationContext();
MutablePropertySources sources = ctx.getEnvironment().getPropertySources();
sources.addFirst(new MyPropertySource());
kotlin
val ctx = GenericApplicationContext()
val sources = ctx.environment.propertySources
sources.addFirst(MyPropertySource())

在前面的代码中,MyPropertySource 在搜索中被添加为最高优先级。如果它包含 my-property 属性,则该属性会被检测并返回,优于任何其它 PropertySource 中的任何 my-property 属性。MutablePropertySources API 暴露了许多方法,允许对属性源集进行精确操作。

使用 @PropertySource

@PropertySource 注解提供了一种方便且声明式的机制,用于向 Spring 的 Environment 添加 PropertySource

给定一个名为 app.properties 的文件,其中包含键值对 testbean.name=myTestBean,以下 @Configuration 类使用 @PropertySource,使得调用 testBean.getName() 返回 myTestBean

java
@Configuration
@PropertySource("classpath:/com/myco/app.properties")
public class AppConfig {

	@Autowired
	Environment env;

	@Bean
	public TestBean testBean() {
		TestBean testBean = new TestBean();
		testBean.setName(env.getProperty("testbean.name"));
		return testBean;
	}
}
kotlin
@Configuration
@PropertySource("classpath:/com/myco/app.properties")
class AppConfig {

	@Autowired
	private lateinit var env: Environment

	@Bean
	fun testBean() = TestBean().apply {
		name = env.getProperty("testbean.name")!!
	}
}

@PropertySource 资源位置中存在的任何 ${…​} 占位符都会根据已在环境中注册的属性源集进行解析,如下例所示:

java
@Configuration
@PropertySource("classpath:/com/${my.placeholder:default/path}/app.properties")
public class AppConfig {

	@Autowired
	Environment env;

	@Bean
	public TestBean testBean() {
		TestBean testBean = new TestBean();
		testBean.setName(env.getProperty("testbean.name"));
		return testBean;
	}
}
kotlin
@Configuration
@PropertySource("classpath:/com/\${my.placeholder:default/path}/app.properties")
class AppConfig {

	@Autowired
	private lateinit var env: Environment

	@Bean
	fun testBean() = TestBean().apply {
		name = env.getProperty("testbean.name")!!
	}
}

假设 my.placeholder 存在于已注册的某个属性源中(例如,系统属性或环境变量),占位符将解析为相应的值。如果没有,则使用 default/path 作为默认值。如果未指定默认值且无法解析属性,则抛出 IllegalArgumentException

::: note 注意 @PropertySource 可以用作可重复注解。@PropertySource 也可以用作元注解,以创建带有属性覆盖的自定义组合注解。 :::

语句中的占位符解析 (Placeholder Resolution in Statements)

从历史上看,元素中占位符的值只能通过 JVM 系统属性或环境变量来解析。现在情况不再如此。由于 Environment 抽象集成在整个容器中,因此很容易通过它路由占位符的解析。这意味着你可以按任何你喜欢的方式配置解析过程。你可以更改搜索系统属性和环境变量的优先级,或者完全删除它们。你还可以根据需要向其中添加自己的属性源。

具体来说,只要 customer 属性在 Environment 中可用,以下语句无论该属性定义在哪里都能工作:

xml
<beans>
	<import resource="com/bank/service/${customer}-config.xml"/>
</beans>

补充教学 —— Environment:不仅仅是获取配置那么简单

在 Spring 框架中,Environment 扮演着“大管家”的角色。理解它的运作机制对于开发高度可配置的应用至关重要。

1. Profile 的“组合拳”:表达式系统 文档中提到的 &(与)、|(或)、!(非)是非常强大的。

  • 技巧:你可以定义细粒度的 Profile。比如 db-mysqlcloud-aws。然后通过 @Profile("db-mysql & cloud-aws") 来定义只有在 AWS 上使用 MySQL 时才生效的 Bean。
  • 不要滥用:如果你发现你的 Profile 表达式写得像数学公式一样复杂,通常意味着你该重新考虑你的模块划分了。

2. 属性优先级:“谁的声音大听谁的” Spring 的 PropertySource 是有序的列表。

  • 开发者的救星:利用“系统属性优先于环境变量”这一点。在 Linux 生产环境中,环境变量可能是全局定义的,但你可以通过在启动命令中加入 -Dmy.prop=value 来临时覆盖掉那个全局变量,而不需要修改服务器配置。
  • 顺序很重要:当使用 sources.addFirst()sources.addLast() 时,你就是在定义“谁说了算”。

3. @PropertySource 的局限性与增强

  • 只支持 .properties.xml:默认情况下,@PropertySource 不支持 YAML 文件。
  • 解决办方案:在 Spring Boot 中,这通常不是问题,因为 Boot 的自动配置会处理 application.yml。但在纯 Spring 中,如果你坚持要用 YAML,你需要实现一个自定义的 PropertySourceFactory

4. 默认 Profile 的妙用default Profile 是一个非常有用的兜底机制。

  • 最佳实践:把最基础的、不依赖特定外部环境的配置(比如内存缓存、简单的 Mock 实现)放在 default 中。这样任何新同事拉下代码,不需要进行任何环境变量配置(即不激活任何 Profile),应用也能“跑起来”。

5. 动态环境感知 除了通过注解,你的 Bean 也可以通过实现 EnvironmentAware 接口或直接 @Autowired Environment 来在运行时检测当前的 Profile 或获取属性:

java
if (env.acceptsProfiles(Profiles.of("dev"))) {
    // 执行一些仅在开发环境进行的特殊逻辑,比如打印更详细的日志
}

总结Environment 抽象将“环境配置”与“代码逻辑”彻底解耦。掌握了它,你的应用就能像变色龙一样,随时适应不同的部署环境。

Based on Spring Framework.