跳过正文
用 Spring Debugger 拆穿 Spring Boot「魔法」:属性、Bean 与事务的真实链路
  1. 文章/

用 Spring Debugger 拆穿 Spring Boot「魔法」:属性、Bean 与事务的真实链路

·5913 字·12 分钟
NeatGuyCoding
作者
NeatGuyCoding
目录

用 Spring Debugger 拆穿 Spring Boot「魔法」:属性、Bean 与事务的真实链路
#

Spring Boot 把对象生命周期、配置合并、条件装配和 AOP 代理叠在一起,日志里往往只剩一行「started successfully」。有经验的工程师知道问题多半出在启动链的某一环,但缺的是:在不往业务类里塞 ApplicationContext 的前提下,把「文件里写的值」和「容器里真的用的值」对齐,并把 Bean 定义、条件评估和代理边界看清楚。

IntelliJ 的 Spring Debugger 把这类检查收进断点会话:Evaluate Expression 旁路 Spring Properties;Beans 页签区分已加载与未加载定义;配置 inlay 提示运行时覆盖。下文按机制组织——属性在 Environment 阶段定型,Bean 在定义与实例化阶段分叉,事务在代理边界上生效——并用 Terminator 主题样例里的类名作锚点(演讲者观点:谜题用于教学,不宜直接当作团队规范)。若你维护的是 Spring Boot 3.x,EPP 注册键包名可能是 org.springframework.boot.env.EnvironmentPostProcessor;Boot 4 则迁移到 org.springframework.boot.EnvironmentPostProcessor,以所用版本 Javadoc 为准。


调试入口:Spring Properties 与「谁改写了这个键」
#

为什么
#

application.propertiesapplication-prod.properties、命令行参数和环境变量可以并存。若团队只比对 YAML 字面量,很容易误判「prod 一定覆盖 base」或「命令行永远最高」——而 Externalized Configuration 还允许在上下文刷新之前EnvironmentPostProcessor 插入更高优先级的 PropertySource

机制与约束
#

Boot 文档写明:基于文件的源有固定顺序,且 command line properties always take precedence over file-based property sources。但 MutablePropertySources.addFirst(...) 会把新源放在最高优先级Framework Javadoc)。因此「最终值」必须看运行时 Environment,不能只看投票时选中的那一层文件。

IntelliJ 在断点处通过 Evaluate Expression → Spring Properties 显示属性的实际解析值Review the current configuration 说明 inlay 可展示覆盖关系,并 Navigate to the property redefinition——跳转到改写该值的代码,不限于某个 application-*.properties

怎么做
#

SpringApplication.run 返回后或任意已暂停的断点:

// Evaluate Expression → 右侧菜单选 Spring Properties → 输入键名
// 例如 terminator.main.mission

多 profile 文件布局示例(与演示 OCR 一致的多环境命名):

图:application-dev.propertiesapplication-prod.propertiesterminator-validators.json 等同屏出现,说明配置源不止一份 base 文件。

图:断点停在 JavaonedemoApplicationmainspring.factories 注册 EnvironmentPostProcessor,Debug 窗口可见 Beans / Environment 等 Spring 专用页签。

属性谜题(terminator.main.mission 同时出现在 application.propertiesapplication-prod.properties、环境变量与命令行)在投票环节容易选错层:不能单凭「prod 文件」或「命令行」猜答案;演示运行日志出现 Sarah Connor 时,应立刻怀疑 EPP 而非 profile 覆盖失败(见下一节)。

常见误区
#

  • 用「常识优先级」代替 Spring Properties 里的 result
  • 以为 Navigate 只会打开 properties 文件;自定义 EPP 的 postProcessEnvironment 才是跳转目标。
  • 只打开 Actuator /env 端点却未在最早断点观察 EPP 执行前的中间态(若未启用 Actuator,Spring Properties 是更轻量的替代)。

EnvironmentPostProcessor:在 Bean 工厂之前就定型的 Environment
#

为什么
#

「配置写在 A,日志却是 B」类问题,时间线要前移到 ApplicationContext refresh 之前。Boot 4.x 的 EnvironmentPostProcessorprior to the application context being refreshed 被调用,用于合并 property source 或编程式改写。

机制与约束
#

内置与自定义 EPP 按 Ordered / @Order 排序。演示中的 TerminatorAcronymEpp 读取 terminator.main.mission,做首字母缩写并应用特殊规则(如 acronym 为 Sarah 时变为 Sarah Connor),再通过 environment.getPropertySources().addFirst(propertySource) 写回——这与官方 addFirst 语义一致。

注册方式(Boot 3.x 常见键名;Boot 4 包名可能迁移,以所用版本 Javadoc 为准):

# META-INF/spring.factories
org.springframework.boot.env.EnvironmentPostProcessor=\
  more.riddles.infra.TerminatorAcronymEpp
public class TerminatorAcronymEpp implements EnvironmentPostProcessor {
  private static final String PROPERTY_NAME = "terminator.main.mission";
  @Override
  public void postProcessEnvironment(ConfigurableEnvironment env, SpringApplication app) {
    String mission = env.getProperty(PROPERTY_NAME);
    // 演示逻辑:缩写 + 规则 → 新 PropertySource → addFirst
  }
}

图:TerminatorAcronymEpp implements EnvironmentPostProcessorPROPERTY_NAME = "terminator.main.mission"

图:注释说明 acronym 等于 Sarah(大小写不敏感)时的分支逻辑。

排错时建议对照三条证据链:spring.factories(或 imports)里的 FQCN → EPP 源码里的 addFirst / 改写规则 → Spring Properties 中的最终字符串。演示注释写「Environment post-processor that transforms the terminator.main.mission property」,与字幕中「built-in EPP 先跑、自定义在后」的口述一致(演讲者观点:内置处理器数量约十余种,精确列表随 Boot 版本变化,不必背表,只需知道还有一层)。

常见误区
#

  • 在 Bean 已创建后再去改 Environment,为时已晚。
  • 忽略 EPP 后仍坚持「命令行一定赢」——在 addFirst 之后,命令行也可能被压过(机制并存,非文档矛盾)。
  • TerminatorAcronymEpp 的演示输出 Sarah Connor 当成框架默认行为。

组件扫描、条件装配与 IDE 里的 Bean 状态
#

为什么
#

@ComponentScan 决定候选组件Classpath Scanning 写明:候选类须匹配过滤器且有对应 bean definition 注册——不等于全部会实例化。@Conditional / @ConditionalOnProperty 在定义注册前就必须 match。

机制与约束
#

切换 @ComponentScan(basePackages = "puzzler2") 会改变扫描列表,但 profile 或条件不满足时,IDE 可能列出类型却没有运行时 Bean(演讲者观点:静态导航与运行时装配可能脱节)。

IntelliJ — Review loaded beansgreen = 已加载,transparent = 未加载,yellow = mock。这与「猜 Bean」相比,更可靠的是断点下看容器。

@SpringBootApplication
@ComponentScan(basePackages = "puzzler2") // 演示中在 puzzler1/2/3 间切换
public class JavaonedemoApplication { }

图:SpringApplication.run=== Application started successfully === 同屏,说明在改扫描包后验证的是运行结果而非仅项目树。

常见误区
#

  • 把 Project 视图里看到的 @Component 类等同于「一定有一个单例 Bean」。
  • 不结合 Spring Properties 查看条件属性是否真为 true

@ConditionalOnProperty 与 legacy XML primary:两条定义路径
#

为什么
#

排查「为什么注入的不是带 @ConditionalOnProperty 的那个实现」时,必须同时查条件注解XML / @ImportResource 是否重复声明了同名 bean。

机制与约束
#

@ConditionalOnProperty:默认 missing attributes do not matchhavingValue = "true" 时属性须存在且匹配。条件不满足则不注册该组件定义。

演示中 IneedYourClothesTerminatorValidator 带:

@ConditionalOnProperty(
    name = "terminator.model.ineedyourcloses.enabled",
    havingValue = "true")
@Component
public class IneedYourClothesTerminatorValidator implements TerminatorValidator { }

Spring Properties 里没有该启用键,故扫描路径不应产生该 BeanDefinition;但若 legacy XML 仍声明同名 bean 且 primary="true",注入点会选 primary 候选——与类上的 @Conditional 无关(机制与官方条件语义一致;XML 细节以演示仓库为准)。

图:@ConditionalOnProperty@Component 出现在 puzzler2.service 包下的 validator 类上。

图:调试 Paused 时同屏可见 application-prod.properties 与上述 validator 源码。

常见误区
#

  • 只看注解,不打开 Beans 视图或 getBeanDefinition 查第二来源。
  • 以为 @Component 上的条件会「关掉」XML 里已注册的 bean。

Evaluate Expression:在调试会话里读 BeanDefinition
#

为什么
#

临时 @Autowired ApplicationContext 或打日志会污染代码。Spring Debugger 允许在表达式里访问 all properties and beanseven when not in the current execution context

机制与约束
#

从可见的 ConfigurableApplicationContext context 可向下取 BeanFactory,查看 depends-on、bean 名、注入字段实际类型。IntelliJ 文档未逐字列出 getBeanDefinition("skynet"),但该 API 属于 ConfigurableListableBeanFactory 常规用法(合理推断,非 IntelliJ 逐字保证)。

// 断点处(需 context 在作用域内):
context.getBeanFactory().getBeanDefinition("skynet");
// 或对字段:skynet.getTerminatorValidator().getClass().getName()

图:Debug 中 main 线程 RUNNING,项目树列出多个 TerminatorValidator 实现供对照注入结果。

图:编辑器上下文菜单含 Evaluate Expression...,与查看 SpringApplication 导入同屏。

常见误区
#

  • 只在 Variables 窗格看字段快照,不查 定义阶段primary / depends-on
  • 在未暂停或未进入含 context 的栈帧时求值失败却归咎于 Spring。

List<T> 注入:显式 @Bean List 与按类型收集
#

为什么
#

同时存在 @Bean List<TerminatorValidator> 和多个 TerminatorValidator 实现时,Skynet 构造函数里的 List 内容并不直观。Framework 对集合注入的默认语义是:向 List<T> 注入时聚合容器中该类型的 beanUsing @Autowired)。

机制与约束
#

演讲中的谜题选项包括:仅 Rev9(显式 List bean)、T800+AI(个体 bean)、三者皆有、或异常。演讲者称 Spring Boot 3.3 之前行为与部分观众直觉不同,3.3 之后演示走向「按类型收集 validators」(演讲者观点;本次未能从 Boot 3.3 Release Notes 独立核实 changelog,升级务必用集成测试验证)。

Mermaid diagram 1

图:Variables 中 context = {AnnotationConfigApplicationContext@...},与 SpringApplication.run(JavaonedemoApplication.class, ..args 同屏。

图:幻灯片提问 List<TerminatorValidator> 的内容,选项含 Rev9、T800+AI、全部三者或 Exception。

怎么做
#

断点停在 Skynet 构造后:

terminatorValidator.size();
terminatorValidator.stream().map(Object::getClass).toList();

常见误区
#

  • 假设 List.of(new Rev9...)@Bean 一定会原样注入。
  • 不区分 Boot 小版本 就照搬旧谜题答案。

Spring Framework 7 的 BeanRegistrar:定义阶段动态注册
#

为什么
#

validator 清单来自 JSON、类名由运维配置携带时,用 Class.forName + 程序化注册比手写 dozens of @Bean 更贴近现实;包名重构会导致启动期 ClassNotFoundException

机制与约束
#

Programmatic Bean Registration(Framework 7):通过 @Import 导入实现 BeanRegistrar 的类,在 尚无 bean 实例 时调用 register(BeanRegistry, Environment)BeanRegistry.Spec 支持 orderprimaryprototypelazyInitdescription 等;当前 Javadoc 无 qualifier(...)——演讲者称 qualifier 仍依赖类上的 @Qualifier(与 API 面一致,属演讲者归纳)。

public class TerminatorValidatorRegistrar implements BeanRegistrar {
  @Override
  public void register(BeanRegistry registry, Environment env) {
    for (var v : loadConfiguration().getValidators()) {
      Class<?> clazz = Class.forName(v.getClassName()); // 拼写错误 → 启动失败
      registry.registerBean(v.getBeanName(), clazz,
          spec -> {
            if ("prototype".equalsIgnoreCase(v.getScope())) spec.prototype();
            spec.order(v.getOrder());
          });
    }
  }
}

图:TerminatorValidatorRegistrar implements BeanRegistrarloadConfiguration() 从外部配置加载。

图:registerValidator(BeanRegistry registry, ValidatorConfigDTO.ValidatorDefinition ...) 方法可见。

启动失败时可加 --debug 查看 condition evaluation report(Boot 常规手段;演示控制台 OCR 含 Beans / Health / Mappings / Environment 页签)。

常见误区
#

  • 在 Bean 已实例化后试图用 Registrar「补注册」同名单例。
  • 认为 JSON 里的 FQCN 会随 IDE 重构自动更新。

启动时间线:把问题钉在正确的阶段
#

为什么
#

用 BeanPostProcessor 解释属性来源、或用 EPP 解释事务代理,都会错位。教学上可把启动粗分为四段(综合模型;Spring 无与下述完全同序的单页官方示意图,各段均有独立文档支撑——见 P09 核实结论)。

机制与约束
#

阶段典型动作权威锚点
EnvironmentPostProcessors合并/改写 PropertySourcesBoot EnvironmentPostProcessor Javadoc
BeanRegistrar / 定义注册注册 BeanDefinition,无实例Framework 7 programmatic registration
BeanFactory 实例化singleton / prototype 创建Bean lifecycle 文档
BeanPostProcessor初始化前后增强、AOP 代理factory-extension

Mermaid diagram 2

图:幻灯片标题 BeanFactoryPostProcessor,说明在 bean 实例化之前可修改 bean definition(与 BPP 阶段区分)。

常见误区
#

  • 混淆 BeanFactoryPostProcessor(改定义)与 BeanPostProcessor(改实例)。
  • 在已 refresh 的上下文里用调试器反推 EPP 顺序却不看 spring.factories 注册列表。

Prototype 与销毁回调:@PreDestroy 别用在错误 scope 上
#

为什么
#

四选一谜题常考「prototype 上的 @PreDestroy 会不会在 context.close() 时执行」。若误以为所有 scope 都会走统一销毁链,prototype 资源可能泄漏。

机制与约束
#

Prototype scope:Spring does not manage the complete lifecycleconfigured destruction lifecycle callbacks are not calledclient code must clean up@PostConstruct 仍可在创建时调用;@PreDestroy 主要可靠场景是 singleton(与演讲结论一致)。

@Component @Scope("prototype")
class T800 {
  @PostConstruct void init() { }
  @PreDestroy void shutdown() { /* 勿指望容器在 close 时调用 */ }
}

常见误区
#

  • context.close() 验证 prototype 的 @PreDestroy
  • 把电影梗当成容器行为。

事务与自调用:REQUIRES_NEW 经不起 this.save()
#

为什么
#

@JavaOneTransactionalService 组合了 @Service 与类级 @Transactional(REQUIRED, timeout=10000)save 方法标 REQUIRES_NEW。若 saveAll 内部直接调用 save,不经代理,则方法级传播不生效。

机制与约束
#

Spring 文档明确:self-invocation does not lead to an actual transaction——even if the invoked method is @Transactional。外部经代理进入 saveAll 时外层 REQUIRED 事务存在;内部 this.save() 无新事务;若外层因异常回滚,演示结论为 Nothing will be saved(演讲简化场景,完整分析还须看 rollbackFor 与异常传播)。

@Retention(RetentionPolicy.RUNTIME)
@Service
@Transactional(propagation = Propagation.REQUIRED, timeout = 10_000)
public @interface JavaOneTransactionalService {}

@JavaOneTransactionalService
public class TerminatorTalkingModule {
  public void saveAll() {
    save(record); // 自调用 → 无 REQUIRES_NEW 代理边界
  }
  @Transactional(propagation = Propagation.REQUIRES_NEW)
  public void save(Record r) { }
}

图:package com.jb.terminator_transactions.annotations@Retention 元注解组合事务服务接口。

图:spring.datasource.url=jdbc:postgresql://localhost:5432/terminator_dbspring.application.name=terminator_transactions 同屏。

深入理解代理边界见 Understanding AOP Proxies

若必须在内层使用独立事务,应通过注入自身代理ApplicationContext.getBean 获取当前 bean,或拆到另一个 Spring bean——而不是在同类里直接 this.save()。演示工程里 PostgreSQL 与 spring.jpa.hibernate.ddl-auto=update 只为让「是否落库」可见;生产环境仍应以事务日志与集成测试验证回滚路径,而非只记谜题口号「Nothing will be saved」。

常见误区
#

  • 认为方法上的 REQUIRES_NEW 一定能「救」内层调用。
  • 不区分 注入的代理this 引用
  • 把组合元注解 @JavaOneTransactionalService 当成会自动修复自调用的语法糖。

可复现的排错清单
#

  1. 属性:断点 → Spring Properties → 查键 → Navigate 到覆盖逻辑 → 查 META-INF/spring.factories 中的 EPP。
  2. Bean 是否存在:Beans 页签 / getBeanDefinition / 条件属性与 XML primary
  3. 集合注入:对 List 字段求 size()getClass(),勿信旧版本谜题记忆。
  4. 动态注册:核对 JSON FQCN、--debug、Registrar 的 scope / order
  5. 事务:对外部调用单步进入代理;警惕同类内部调用。
  6. 版本:EPP 注册键、Boot 3.3 前后 List 行为、Framework 7 BeanRegistrar 均以当前依赖版本文档为准,勿跨版本套用谜题答案。

掌握调试器并不能替代阅读 Reference,但能把「魔法」还原为可点击的源码与定义——这正是 Spring Debugger 的设计目标:在断点处回答「这个值从哪来、这个 Bean 为何存在、这次调用有没有经过代理」。


参考与延伸阅读
#

相关文章