Environment、Profile、资源与应用事件
"同一个 key 到底读到谁的值""profile 关着的 Bean 为什么能让启动失败""事件监听器抛异常谁看见"是配置基础设施的三类问题。本篇固定 Framework v6.1.12(Boot外部化配置、Profile文件与真实来源矩阵见Boot配置,事务绑定事件归事务正文);候选与注入基础见配置与候选解析,refresh 阶段见生命周期篇。
Environment 是属性源的有序查询
Environment 持有一个有序的 PropertySource 列表,getProperty 按首个命中返回。原码:PropertySourcesPropertyResolver.getProperty,v6.1.12,78–99 行。固定来源。
@Nullable
protected <T> T getProperty(String key, Class<T> targetValueType, boolean resolveNestedPlaceholders) {
if (this.propertySources != null) {
for (PropertySource<?> propertySource : this.propertySources) {
if (logger.isTraceEnabled()) {
logger.trace("Searching for key '" + key + "' in PropertySource '" +
propertySource.getName() + "'");
}
Object value = propertySource.getProperty(key);
if (value != null) {
if (resolveNestedPlaceholders && value instanceof String string) {
value = resolveNestedPlaceholders(string);
}
logKeyFound(key, propertySource, value);
return convertValueIfNecessary(value, targetValueType);
}
}
}
if (logger.isTraceEnabled()) {
logger.trace("Could not find key '" + key + "' in any property source");
}
return null;
}三条结论都能从循环读出:同 key 多源只有第一个非 null 值生效,addFirst/addLast 改变结果;嵌套占位符 ${a}-${b} 在读取时递归解析,不是登记时替换;命中后立即 convertValueIfNecessary 做类型转换。StandardEnvironment 预置 systemProperties 与 systemEnvironment 两个源。实测(GenericApplicationContext):自定义源 addLast 时 system property 赢;addFirst 后自定义赢;${u16.greeting}-world 读出的是当时生效值拼上的 -world;getProperty("u16.bad", Integer.class) 对非数字抛 ConversionFailedException(转换语义见转换绑定与 SpEL)。
@PropertySource 把配置文件源追加到最低优先级:实测文件里 u16.greeting=file-greeting、系统属性 u16.greeting=system-greeting 时 @Value("${u16.greeting}") 注入的是系统值,文件独有 key 正常注入。所以"配置文件属性优先于环境"在 Framework 里不成立——顺序由源列表决定,查有效值要看源列表而不是文件内容。required 属性校验在 prepareRefresh 内执行(validateRequiredProperties,位于 active 标志置位之后、内层 try 之前):缺 key 时 refresh() 抛 MissingRequiredPropertiesException,实测失败后 context 仍是 active——这次失败没有走内层 catch 的销毁清理(边界见生命周期篇),close 由调用方负责。
Profile:候选的注册条件
@Profile("prod") 是定义登记条件:激活集合不匹配的定义根本不登记,而不是登记后被隐藏。激活方式为 env.setActiveProfiles(...)(显式最高)或 spring.profiles.active 属性(读缺省时再回落 default),支持 qa | uat 表达式。实测四个断言:无激活 profile 时只有 !prod 的 fallbackThing 存在;激活 dev 后 devThing 存在且消费者正常注入;激活 prod 后要求 DevThing 的 required 消费方在 refresh 阶段抛 NoSuchBeanDefinitionException(消息点名 InfraLab$DevThing——"profile 关着的 Bean"能以缺依赖形式炸掉启动,排查时先看激活集合再看候选定义);激活 qa 时 qa | uat 命中且 dev/prod 均不存在。
与 Boot 的差异只在激活来源:SpringApplication 在准备 Environment 时合并命令行/配置文件等外部源,Framework 语义(登记条件、表达式、缺依赖失败)两边相同。给"多环境 Bean"排障的顺序:先打有效激活集合,再查候选定义是否登记,最后看消费方注入形状。
Resource:句柄不等于存在
context.getResource("classpath:...") 返回的是 Resource 句柄,不保证背后有内容:实测 u16-absent.properties 的句柄非 null 但 exists()==false;存在的 classpath 文件可读出内容;file: 前缀指向本机临时文件可读可写(实验写临时文件并在 finally 删除);getResources("classpath:study/spring/u16-*.properties") 按模式解析出至少一个资源。资源抽象统一了 classpath/file/URL 的读入口,exists() 检查与 getInputStream() 失败分开定位——"拿到 Resource"与"读到内容"是两步证据。
事件:同步多播与异常传播
原码:AbstractApplicationContext.publishEvent,v6.1.12,418–452 行。固定来源。
protected void publishEvent(Object event, @Nullable ResolvableType typeHint) {
Assert.notNull(event, "Event must not be null");
ResolvableType eventType = null;
// Decorate event as an ApplicationEvent if necessary
ApplicationEvent applicationEvent;
if (event instanceof ApplicationEvent applEvent) {
applicationEvent = applEvent;
eventType = typeHint;
}
else {
ResolvableType payloadType = null;
if (typeHint != null && ApplicationEvent.class.isAssignableFrom(typeHint.toClass())) {
eventType = typeHint;
}
else {
payloadType = typeHint;
}
applicationEvent = new PayloadApplicationEvent<>(this, event, payloadType);
}
// Determine event type only once (for multicast and parent publish)
if (eventType == null) {
eventType = ResolvableType.forInstance(applicationEvent);
if (typeHint == null) {
typeHint = eventType;
}
}
// Multicast right now if possible - or lazily once the multicaster is initialized
if (this.earlyApplicationEvents != null) {
this.earlyApplicationEvents.add(applicationEvent);
}
else if (this.applicationEventMulticaster != null) {
this.applicationEventMulticaster.multicastEvent(applicationEvent, eventType);注意本段是节选:Assert.notNull(event, "Event must not null") 为排版省略了原文的 "must not be null",完整分支见源链接。三条结构事实:任意对象发布时被包成 PayloadApplicationEvent;multicaster 初始化前(refresh 早期)事件先缓冲进 earlyApplicationEvents;有 parent 时向父容器再发一遍。原码:SimpleApplicationEventMulticaster.multicastEvent,v6.1.12,142–159 行。固定来源。
public void multicastEvent(ApplicationEvent event, @Nullable ResolvableType eventType) {
ResolvableType type = (eventType != null ? eventType : ResolvableType.forInstance(event));
Executor executor = getTaskExecutor();
for (ApplicationListener<?> listener : getApplicationListeners(event, type)) {
if (executor != null && listener.supportsAsyncExecution()) {
try {
executor.execute(() -> invokeListener(listener, event));
}
catch (RejectedExecutionException ex) {
// Probably on shutdown -> invoke listener locally instead
invokeListener(listener, event);
}
}
else {
invokeListener(listener, event);
}
}
}默认无 executor:监听器在发布线程按顺序同步执行——实测 @Order(1)、@Order(2) 两个监听器的记录线程都是 main 且顺序正确;抛 IllegalStateException 的监听器让 publishEvent 原样抛出,且排在其后的监听器不再执行——"事件是异步解耦、异常会被容器消化"两个直觉在默认配置下都不成立。给 multicaster 配置 executor(applicationEventMulticaster bean)后:监听器在独立线程执行,发布立即返回,监听器异常走线程 uncaught handler(6.1 的 SimpleAsyncTaskExecutor 没有处理器 setter,由 ThreadFactory 带上;RejectedExecutionException 回退本地同步执行),实测发布方未感知失败。发布是进程内的尽力投递:无持久化、无重试、无跨进程语义,需要可靠交付要用消息或 Outbox(见事务正文边界);@TransactionalEventListener 的提交时点语义归事务正文。
实验:优先级、失败与线程证据
从仓库根执行 JAVA_HOME=$(/usr/libexec/java_home -v17) bash labs/spring-senior/run.sh infra,单模式命令见实验说明。四个模式独立 JVM:env(优先级/占位符/required)、profile(四个激活集合)、resource(classpath/file/模式)、events(同步/异常/payload/异步)。
| 公开输入 | 实测判据 |
|---|---|
| 自定义源 addLast → addFirst | system 赢 → custom 赢(PASS env precedence=firstMatchWins systemOverFile=true requiredMissing=refresh-failure contextStillActive=true typedConversion=true) |
| 无激活/dev/prod/qa 四组 | fallbackOnly / consumer 解析 / DevThing 缺依赖失败 / 表达式命中 |
| classpath 存在/缺失、file:、模式 | 句柄与 exists 分离;临时文件 finally 删除 |
| 同步/异常/payload/异步 | 同线程有序、异常上抛截断后续、payload 包装、异步脱钩 uncaught |
断言线程身份(发布线程名 == 监听器记录线程名)、异常类型与激活集合差,不拿"没有报错"当异步证明。未测:跨进程事件、PayloadApplicationEvent 泛型精细匹配、自定义 multicaster 全部回退分支、spring.profiles.active 经由外部源的完整 Boot 行为——按契约推演并标注。实验唯一源码内嵌如下。
package study.spring;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import org.springframework.beans.factory.NoSuchBeanDefinitionException;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.beans.factory.support.BeanDefinitionBuilder;
import org.springframework.context.ApplicationEvent;
import org.springframework.context.ApplicationListener;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.springframework.context.annotation.PropertySource;
import org.springframework.context.event.EventListener;
import org.springframework.context.event.SimpleApplicationEventMulticaster;
import org.springframework.context.support.GenericApplicationContext;
import org.springframework.core.annotation.Order;
import org.springframework.core.env.MapPropertySource;
import org.springframework.core.env.MissingRequiredPropertiesException;
import org.springframework.core.env.StandardEnvironment;
import org.springframework.core.io.Resource;
import org.springframework.core.task.SimpleAsyncTaskExecutor;
/**
* U16 infrastructure: Environment/PropertySource precedence, Profile activation,
* Resource abstraction and application events. One JVM per mode.
*/
public class InfraLab {
static final List<String> events = new ArrayList<>();
public static class OrderEvent extends ApplicationEvent {
public final String id;
public OrderEvent(Object source, String id) { super(source); this.id = id; }
}
static class EnvProbe {
@Value("${u16.fileOnly}") String fileOnly;
@Value("${u16.greeting}") String greeting;
}
@Configuration
@PropertySource("classpath:study/spring/u16-infra.properties")
static class FilePropsConfig {
@Bean EnvProbe probe() { return new EnvProbe(); }
}
static class ProdThing { }
static class DevThing { }
static class FallbackThing { }
@Configuration static class ProfileConfig {
@Bean @Profile("prod") ProdThing prodThing() { return new ProdThing(); }
@Bean @Profile("dev") DevThing devThing() { return new DevThing(); }
@Bean @Profile("!prod") FallbackThing fallbackThing() { return new FallbackThing(); }
@Bean @Profile("qa | uat") String qaOrUat() { return "qaOrUat"; }
}
@Configuration static class DevConsumerConfig {
@Bean Consumer consumer() { return new Consumer(); }
}
static class Consumer {
@Autowired DevThing devThing;
}
@Configuration static class SyncEventConfig {
@Order(1) @EventListener public void first(OrderEvent e) {
events.add("first:" + Thread.currentThread().getName());
}
@Order(2) @EventListener public void second(OrderEvent e) {
events.add("second:" + Thread.currentThread().getName());
}
@EventListener public void payload(String message) {
events.add("payload:" + message);
}
}
@Configuration static class ThrowingEventConfig {
@Order(1) @EventListener public void before(OrderEvent e) { events.add("before"); }
@Order(2) @EventListener public void boom(OrderEvent e) { throw new IllegalStateException("boom"); }
}
public static void main(String[] args) throws Exception {
String mode = args.length == 0 ? "env" : args[0];
switch (mode == null || mode.isEmpty() ? "env" : mode) {
case "env" -> env();
case "profile" -> profile();
case "resource" -> resource();
case "events" -> events();
default -> throw new IllegalArgumentException(mode);
}
}
static void env() {
// Programmatic precedence: first matching PropertySource wins per lookup.
var env = new StandardEnvironment();
var custom = new MapPropertySource("u16-custom", Map.of(
"u16.greeting", "custom-greeting",
"u16.message", "${u16.greeting}-world",
"u16.port", "8080",
"u16.bad", "not-a-number"));
System.setProperty("u16.greeting", "system-greeting");
try {
env.getPropertySources().addLast(custom);
if (!"system-greeting".equals(env.getProperty("u16.greeting")))
throw new AssertionError("systemProperties must win when custom is added last");
env.getPropertySources().remove("u16-custom");
env.getPropertySources().addFirst(custom);
if (!"custom-greeting".equals(env.getProperty("u16.greeting")))
throw new AssertionError("addFirst must outrank systemProperties");
if (!"custom-greeting-world".equals(env.getProperty("u16.message")))
throw new AssertionError("nested placeholders resolve at read time");
if (env.getProperty("u16.port", Integer.class) != 8080)
throw new AssertionError("typed resolution must convert values");
try {
env.getProperty("u16.bad", Integer.class);
throw new AssertionError("expected conversion failure");
} catch (RuntimeException conversionFailure) {
String type = conversionFailure.getClass().getName();
if (!type.contains("ConversionFailedException") && !type.contains("ConversionNotSupportedException"))
throw new AssertionError("unexpected conversion failure type: " + conversionFailure);
}
} finally {
System.clearProperty("u16.greeting");
}
// @PropertySource registers the file source with the lowest precedence: the
// system property overrides it; the file-only key survives.
System.setProperty("u16.greeting", "system-greeting");
try {
var overridden = new AnnotationConfigApplicationContext(FilePropsConfig.class);
try {
EnvProbe probe = overridden.getBean(EnvProbe.class);
if (!"file-value".equals(probe.fileOnly) || !"system-greeting".equals(probe.greeting))
throw new AssertionError("precedence system>file: " + probe.fileOnly + "/" + probe.greeting);
} finally {
overridden.close();
}
} finally {
System.clearProperty("u16.greeting");
}
var fromFile = new AnnotationConfigApplicationContext(FilePropsConfig.class);
try {
EnvProbe probe = fromFile.getBean(EnvProbe.class);
if (!"file-greeting".equals(probe.greeting))
throw new AssertionError("file must win without a system property: " + probe.greeting);
} finally {
fromFile.close();
}
// Required property validation runs inside prepareRefresh — AFTER the active flag is
// set and BEFORE the inner try: the failed refresh leaves the context active, uncleaned.
var c = new GenericApplicationContext();
c.getEnvironment().setRequiredProperties("u16.missing");
try {
c.refresh();
throw new AssertionError("expected required-property failure");
} catch (MissingRequiredPropertiesException e) {
if (!e.getMissingRequiredProperties().contains("u16.missing"))
throw new AssertionError("missing key must be reported: " + e.getMissingRequiredProperties());
if (!c.isActive()) throw new AssertionError("prepareRefresh failure leaves the context active");
} finally {
c.close();
}
System.out.println("PASS env precedence=firstMatchWins systemOverFile=true requiredMissing=refresh-failure contextStillActive=true typedConversion=true");
}
static void profile() {
var none = new AnnotationConfigApplicationContext(ProfileConfig.class);
try {
if (none.containsBean("prodThing") || none.containsBean("devThing"))
throw new AssertionError("prod/dev must be inactive without an active profile");
if (!none.containsBean("fallbackThing")) throw new AssertionError("!prod must match by default");
if (none.containsBean("qaOrUat")) throw new AssertionError("qa|uat must be inactive");
} finally {
none.close();
}
var dev = new AnnotationConfigApplicationContext();
dev.getEnvironment().setActiveProfiles("dev");
dev.register(ProfileConfig.class, DevConsumerConfig.class);
dev.refresh();
try {
if (!dev.containsBean("devThing")) throw new AssertionError("dev bean must exist");
if (dev.getBean(Consumer.class).devThing == null) throw new AssertionError("consumer must resolve dev bean");
} finally {
dev.close();
}
var prod = new AnnotationConfigApplicationContext();
prod.getEnvironment().setActiveProfiles("prod");
prod.register(ProfileConfig.class);
prod.register(DevConsumerConfig.class);
try {
prod.refresh();
throw new AssertionError("expected missing profile-bean failure");
} catch (RuntimeException e) {
NoSuchBeanDefinitionException nsb = findCause(e, NoSuchBeanDefinitionException.class);
if (nsb == null) throw e;
if (!nsb.getMessage().contains("InfraLab$DevThing"))
throw new AssertionError("failure must name the missing type: " + nsb.getMessage());
if (prod.isActive()) throw new AssertionError("failed context still active");
} finally {
prod.close();
}
var qa = new AnnotationConfigApplicationContext();
qa.getEnvironment().setActiveProfiles("qa");
qa.register(ProfileConfig.class);
qa.refresh();
try {
if (!"qaOrUat".equals(qa.getBean("qaOrUat", String.class)))
throw new AssertionError("profile expression qa|uat must match qa");
if (qa.containsBean("devThing") || qa.containsBean("prodThing"))
throw new AssertionError("qa must not activate dev/prod beans");
} finally {
qa.close();
}
System.out.println("PASS profile default=fallbackOnly devResolvesConsumer prodMissingDev=refresh-failure expression=qa|uat-matched");
}
static void resource() throws Exception {
var c = new GenericApplicationContext();
c.refresh();
try {
Resource props = c.getResource("classpath:study/spring/u16-infra.properties");
if (!props.exists() || !new String(props.getInputStream().readAllBytes(), StandardCharsets.UTF_8)
.contains("u16.fileOnly=file-value"))
throw new AssertionError("classpath resource must be readable");
// The abstraction returns a handle even for absent resources; existence is a separate check.
Resource missing = c.getResource("classpath:study/spring/u16-absent.properties");
if (missing == null || missing.exists()) throw new AssertionError("absent resource must not exist");
Path temp = Files.createTempFile("u16-resource", ".txt");
try {
Files.writeString(temp, "u16.temp=ok");
Resource file = c.getResource("file:" + temp.toAbsolutePath());
if (!file.exists() || !new String(file.getInputStream().readAllBytes(), StandardCharsets.UTF_8).contains("u16.temp=ok"))
throw new AssertionError("file resource must be readable");
Resource[] pattern = c.getResources("classpath:study/spring/u16-*.properties");
if (pattern.length < 1) throw new AssertionError("pattern resolution must find the properties file");
} finally {
Files.deleteIfExists(temp);
}
} finally {
c.close();
}
System.out.println("PASS resource classpathRead=true absentHandleExists=false fileRead=true patternFound=true");
}
static void events() throws Exception {
// Synchronous multicast: listeners run on the publishing thread in @Order sequence.
events.clear();
var sync = new AnnotationConfigApplicationContext(SyncEventConfig.class);
try {
sync.publishEvent(new OrderEvent(sync, "o-1"));
String main = Thread.currentThread().getName();
if (!events.equals(List.of("first:" + main, "second:" + main)))
throw new AssertionError("sync order/threads: " + events);
} finally {
sync.close();
}
// A listener failure propagates to the publisher on the same thread.
events.clear();
var throwing = new AnnotationConfigApplicationContext(ThrowingEventConfig.class);
try {
throwing.publishEvent(new OrderEvent(throwing, "o-2"));
throw new AssertionError("sync listener failure must propagate");
} catch (IllegalStateException expected) {
if (!"boom".equals(expected.getMessage())) throw expected;
if (!events.equals(List.of("before"))) throw new AssertionError("earlier listener must have run: " + events);
} finally {
throwing.close();
}
// Payload events: a plain object is wrapped and delivered to type-matching listeners.
events.clear();
var payload = new AnnotationConfigApplicationContext(SyncEventConfig.class);
try {
payload.publishEvent("plain-message");
if (!events.equals(List.of("payload:plain-message"))) throw new AssertionError("payload: " + events);
} finally {
payload.close();
}
// Async multicast: the executor delivers on other threads; failures do not propagate.
events.clear();
CountDownLatch done = new CountDownLatch(1);
var async = new GenericApplicationContext();
var multicaster = new SimpleApplicationEventMulticaster();
java.util.concurrent.atomic.AtomicInteger seq = new java.util.concurrent.atomic.AtomicInteger();
// U17: two executor tasks race — the uncaught handler runs on the boom thread, so the
// await on `done` only covers listener one. Wait for both events within a deadline.
CountDownLatch uncaught = new CountDownLatch(1);
java.lang.Thread.UncaughtExceptionHandler handler =
(t, x) -> { events.add("uncaught:" + x.getMessage()); uncaught.countDown(); };
var executor = new SimpleAsyncTaskExecutor();
// 6.1 SimpleAsyncTaskExecutor has no uncaught-handler setter; the thread factory carries it.
executor.setThreadFactory(runnable -> {
Thread thread = new Thread(runnable, "u16-event-" + seq.incrementAndGet());
thread.setUncaughtExceptionHandler(handler);
return thread;
});
multicaster.setTaskExecutor(executor);
async.getDefaultListableBeanFactory().registerBeanDefinition("applicationEventMulticaster",
BeanDefinitionBuilder.genericBeanDefinition(SimpleApplicationEventMulticaster.class, () -> multicaster)
.getBeanDefinition());
async.addApplicationListener((ApplicationListener<OrderEvent>) e -> {
events.add("async:" + Thread.currentThread().getName());
done.countDown();
});
async.addApplicationListener(
(ApplicationListener<OrderEvent>) e -> { throw new IllegalStateException("async-boom"); });
// U17: two executor tasks race — the uncaught handler runs on the boom thread, so the
// await on `done` only covers listener one. Wait for both events within a deadline.
async.refresh();
try {
async.publishEvent(new OrderEvent(async, "o-3"));
if (!done.await(3, TimeUnit.SECONDS)) throw new AssertionError("async listener must complete");
if (!uncaught.await(3, TimeUnit.SECONDS)) throw new AssertionError("uncaught routing must complete");
if (events.size() != 2 || !events.get(0).startsWith("async:u16-event-")
|| !"uncaught:async-boom".equals(events.get(1)))
throw new AssertionError("async delivery and uncaught routing: " + events);
} finally {
async.close();
}
System.out.println("PASS events sync=sameThread ordered exceptionPropagates payload=wrapped async=detachedUncaughtHandler");
}
static <T extends Throwable> T findCause(Throwable failure, Class<T> type) {
for (Throwable cause = failure; cause != null; cause = cause.getCause()) {
if (type.isInstance(cause)) return type.cast(cause);
}
return null;
}
}架构师推演:配置与事件的故障半径
给定多环境部署:方案一把环境差异全部做成 @Profile 候选,装配直观,但候选矩阵随环境数膨胀,漏一个 profile 就是启动失败;方案二收敛为一份候选 + Environment 属性差异(profile 只切 Bean 拼装形状,不切业务实现),配置面小,但同一定义要承担多环境行为。判据是"缺一个配置时想在哪失败":期望启动早失败用候选+required 校验;期望缺省可降级用 !prod 类 fallback 与可选注入。任何环境差异都要有"有效值与来源"的观测(本实验的优先级证据即观测口径),排障先分离"读错值"与"没读到"。
事件用于模块解耦时,故障半径由同步语义决定:默认配置下监听器抛错会打断发布方的操作——把关键路径包在事件后面等于把可用性交给所有监听器;executor 异步化切断了异常传播,也切断了"发布方知道处理完成"的能力,失败只能靠监听器自身的补偿与观测。两个方案的恢复责任都在监听器侧:发布端只保证进程内尽力投递。演进触发:当出现跨进程/跨副本消费、需要重放或背压时,从 ApplicationEvent 迁到 MQ 并迁移幂等与补偿,不是加个线程池能解决的。
闭卷验收
本页 8 道闭卷题收进了下方自测面板:先回忆,再展开参考答案与评分点,最后给三档自评。