Spring Boot 4.1.1 深度源码解析-第 16 章 · 排序、元数据与报告:AutoConfigurationSorter、配置元数据、条件报告
前三章回答了"自动配置怎么决定生不生效 "(第 15 章)、"自动配置类怎么被选出并排序 "(第 14 章)、 "原始 Bean 定义从哪来 "(第 13 章)。 本章回答剩下的问题------怎么知道结果:
- 排序信息是靠什么跨越编译期到运行期 的?(
spring-autoconfigure-metadata.properties)spring-configuration-metadata.json是谁生成的、给谁用的?ConditionEvaluationReport怎么变成/actuator/conditions的 JSON?- 启动失败时那句 "Consider defining a bean of type ... in your configuration" 是怎么算出来的?
这四件事看似分散,其实是同一条信息链的四个环节:
bash
编译期(javac + 注解处理器)
↓ 写出
├─ META-INF/spring-autoconfigure-metadata.properties → 支撑"排序 + 预筛"
└─ META-INF/spring-configuration-metadata.json → 支撑"IDE 补全 + 文档"
运行期(启动)
↓ 执行
条件求值 → ConditionEvaluationReport
↓ 暴露
├─ /actuator/conditions(成功时)
└─ FailureAnalyzer(失败时,读同一份 report 生成人类可读诊断)
本章的核心洞察 :Boot 把"诊断能力"当成一等公民。 条件报告不是为了好看------它是失败分析器的数据源。 一个启动失败能告诉你"哪个自动配置因为什么条件没生效",靠的就是这条链。
1. 排序信息的载体:@AutoConfiguration 的四个属性
第 14 章讲了 AutoConfigurationSorter 的三轮排序,但留了一个口子: @AutoConfigureBefore / @AutoConfigureAfter 这两个注解,和 @AutoConfiguration 自己的 before / after / beforeName / afterName 是什么关系?
答案在 @AutoConfiguration 的声明里:
java
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Configuration(proxyBeanMethods = false)
@AutoConfigureBefore
@AutoConfigureAfter
public @interface AutoConfiguration {
@AliasFor(annotation = Configuration.class)
String value() default "";
@AliasFor(annotation = AutoConfigureBefore.class, attribute = "value")
Class<?>[] before() default {};
@AliasFor(annotation = AutoConfigureBefore.class, attribute = "name")
String[] beforeName() default {};
@AliasFor(annotation = AutoConfigureAfter.class, attribute = "value")
Class<?>[] after() default {};
@AliasFor(annotation = AutoConfigureAfter.class, attribute = "name")
String[] afterName() default {};
}
关键点:
@AutoConfiguration自己被@AutoConfigureBefore和@AutoConfigureAfter(空值 )标注。 这是一个**"声明我有这个元注解"**的技巧,让@AliasFor有目标可指。before/after是Class<?>[],beforeName/afterName是String[]。 通过@AliasFor分别映射到@AutoConfigureBefore.value/@AutoConfigureAfter.value和.name。
净效果:写
java
@AutoConfiguration(after = DataSourceAutoConfiguration.class)
等价于写
java
@AutoConfiguration
@AutoConfigureAfter(DataSourceAutoConfiguration.class)
为什么要有两套? 因为 @AutoConfiguration 是 2.7.0 才引入的"现代写法", 而 @AutoConfigureBefore/After 从 1.0 就有。@AliasFor 让二者完全等价 , 不需要改 AutoConfigurationSorter 的读取逻辑。
1.1 name 变体存在的理由
java
/**
* The names of the auto-configuration classes that should have already been applied.
* In the unusual case that an auto-configuration class is not a top-level class, its
* class name should use {@code $} to separate it from its containing class, for example
* {@code com.example.Outer$NestedAutoConfiguration}.
*/
@AliasFor(annotation = AutoConfigureAfter.class, attribute = "name")
String[] afterName() default {};
两个用途:
- 类不在 classpath 上 时用字符串(与
@ConditionalOnClass的valuevsname同理) - 嵌套类 必须用
Outer$Nested形式------因为Class<?>常量无法表达"嵌套"以外的关系, 而 ASM 解析出的类名总是带$
注意:Javadoc 说嵌套自动配置类是 "the unusual case"------ Boot 约定自动配置类是顶层类。
1.2 value 的隐藏语义
java
/**
* Explicitly specify the name of the Spring bean definition associated with the
* {@code @AutoConfiguration} class. ...
* The custom name applies only if the {@code @AutoConfiguration} class is picked up
* through component scanning or supplied directly to an AnnotationConfigApplicationContext.
* If the {@code @AutoConfiguration} class is registered as a traditional XML bean definition,
* the name/id of the bean element will take precedence.
*/
@AliasFor(annotation = Configuration.class)
String value() default "";
@AutoConfiguration("myName") → 等价于 @Configuration("myName"),指定 Bean 名。
但 Javadoc 明确说了它只在"组件扫描或直接注册"时生效 ------ 而自动配置类是通过 ImportCandidates + ConfigurationClassParser 导入的, 不走 AnnotationBeanNameGenerator......那么它到底生效吗?
严谨声明 :我没有在 Boot 源码中找到直接证据说明
ConfigurationClassBeanDefinitionReader是否读取@Configuration的value作为 Bean 名。按我对 Framework 的理解, 自动配置类的 Bean 名由ConfigurationClassBeanDefinitionReader用ConfigurationClassParser解析出的beanName决定,而该名字来自ImportCandidates的类名(经AnnotationBeanNameGenerator之类),@Configuration(value=...)是否参与 取决于 Framework 的实现细节。本章不断言结论 ,标注为待核实。 实践建议:不要依赖@AutoConfiguration("name")给自动配置类改名。
1.3 proxyBeanMethods = false 是强制的
java
@Configuration(proxyBeanMethods = false)
public @interface AutoConfiguration {
自动配置类永远不被 CGLIB 代理。原因:
- 自动配置类之间的
@Bean方法互相调用没有意义(它们是给"用户配置"用的特性) - 代理会显著拖慢启动(每个配置类都要生成一个子类)
- AOT / Native 下代理更复杂
这是"约定优于配置"的又一例:不是给你选项,而是直接钉死。
2. 编译期元数据之一:spring-autoconfigure-metadata.properties
第 14 章讲过 AutoConfigurationMetadataLoader 读 META-INF/spring-autoconfigure-metadata.properties,第 15 章讲过三个 AutoConfigurationImportFilter 靠它做预筛。那么这个文件是谁写的?
答案:core/spring-boot-autoconfigure-processor 模块里的 AutoConfigureAnnotationProcessor------一个 javac 注解处理器。
2.1 支持的注解(8 个)
java
@SupportedAnnotationTypes({
"org.springframework.boot.autoconfigure.condition.ConditionalOnClass",
"org.springframework.boot.autoconfigure.condition.ConditionalOnBean",
"org.springframework.boot.autoconfigure.condition.ConditionalOnSingleCandidate",
"org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication",
"org.springframework.boot.autoconfigure.AutoConfigureBefore",
"org.springframework.boot.autoconfigure.AutoConfigureAfter",
"org.springframework.boot.autoconfigure.AutoConfigureOrder",
"org.springframework.boot.autoconfigure.AutoConfiguration" })
public class AutoConfigureAnnotationProcessor extends AbstractProcessor {
protected static final String PROPERTIES_PATH = "META-INF/spring-autoconfigure-metadata.properties";
private final Map<String, String> properties = new TreeMap<>();
只有这 8 个注解被写入元数据。注意------
@ConditionalOnMissingBean不在列表里。 这印证了第 15 章 §6.1 的结论:预筛阶段根本不处理"缺某个 Bean", 所以没必要为它生成元数据。
2.2 七个 PropertyGenerator
java
protected List<PropertyGenerator> getPropertyGenerators() {
List<PropertyGenerator> generators = new ArrayList<>();
addConditionPropertyGenerators(generators);
addAutoConfigurePropertyGenerators(generators);
return generators;
}
private void addConditionPropertyGenerators(List<PropertyGenerator> generators) {
String annotationPackage = "org.springframework.boot.autoconfigure.condition";
generators.add(PropertyGenerator.of(annotationPackage, "ConditionalOnClass")
.withAnnotation(new OnClassConditionValueExtractor()));
generators.add(PropertyGenerator.of(annotationPackage, "ConditionalOnBean")
.withAnnotation(new OnBeanConditionValueExtractor()));
generators.add(PropertyGenerator.of(annotationPackage, "ConditionalOnSingleCandidate")
.withAnnotation(new OnBeanConditionValueExtractor()));
generators.add(PropertyGenerator.of(annotationPackage, "ConditionalOnWebApplication")
.withAnnotation(ValueExtractor.allFrom("type")));
}
private void addAutoConfigurePropertyGenerators(List<PropertyGenerator> generators) {
String annotationPackage = "org.springframework.boot.autoconfigure";
generators.add(PropertyGenerator.of(annotationPackage, "AutoConfigureBefore", true)
.withAnnotation(ValueExtractor.allFrom("value", "name"))
.withAnnotation("AutoConfiguration", ValueExtractor.allFrom("before", "beforeName")));
generators.add(PropertyGenerator.of(annotationPackage, "AutoConfigureAfter", true)
.withAnnotation(ValueExtractor.allFrom("value", "name"))
.withAnnotation("AutoConfiguration", ValueExtractor.allFrom("after", "afterName")));
generators.add(PropertyGenerator.of(annotationPackage, "AutoConfigureOrder")
.withAnnotation(ValueExtractor.allFrom("value")));
}
生成的 key 形如:
ini
com.example.MyAutoConfiguration.ConditionalOnClass=com.example.SomeClass,com.example.Other
com.example.MyAutoConfiguration.ConditionalOnBean=com.example.Required
com.example.MyAutoConfiguration.ConditionalOnWebApplication=SERVLET
com.example.MyAutoConfiguration.AutoConfigureBefore=com.example.Before1
com.example.MyAutoConfiguration.AutoConfigureAfter=com.example.After1
com.example.MyAutoConfiguration.AutoConfigureOrder=100
com.example.MyAutoConfiguration= ← 空值,标记"这个类被处理过"
@AutoConfigureBefore / @AutoConfigureAfter 的第三个参数 true = omitEmptyValues:
java
void applyToProperties(Map<String, String> properties, String className, List<Object> annotationValues) {
if (this.omitEmptyValues && annotationValues.isEmpty()) {
return;
}
mergeProperties(properties, className + "." + this.propertyName, toCommaDelimitedString(annotationValues));
}
为什么这两个要 omitEmptyValues,而条件注解不要?
@AutoConfiguration上永远 带着空的@AutoConfigureBefore/@AutoConfigureAfter(§1 说过,这是为了让@AliasFor有目标)。如果不过滤空值, 每个自动配置类都会写出Xxx.AutoConfigureBefore=这样的空条目------纯噪声。- 条件注解如果写空值,语义是"这个注解存在但没有指定类",与"注解不存在"在消费端 (
AutoConfigurationMetadata.getSet返回空集合)效果类似,留着无害。
2.3 值提取器的三个差异
(a) OnClassConditionValueExtractor:对结果排序
java
static class OnClassConditionValueExtractor extends NamedValuesExtractor {
OnClassConditionValueExtractor() { super("value", "name"); }
@Override
public List<Object> getValues(AnnotationMirror annotation) {
List<Object> values = super.getValues(annotation);
values.sort(this::compare);
return values;
}
private int compare(Object o1, Object o2) {
return Comparator.comparing(this::isSpringClass)
.thenComparing(String.CASE_INSENSITIVE_ORDER)
.compare(o1.toString(), o2.toString());
}
private boolean isSpringClass(String type) {
return type.startsWith("org.springframework.");
}
}
比较器是 "Spring 类优先,然后按字母序(大小写不敏感)" 。 注意 Comparator.comparing(this::isSpringClass) 比较的是 boolean, 而 Boolean 的自然序是 false < true ------ 所以 Spring 类排在后面,不是前面!
等等,这与"优先"矛盾吗?不矛盾 ------排序的目的不是"优先级", 而是让文件稳定 (同一份源码每次编译产出相同内容,便于构建缓存和 diff)。
thenComparing(String.CASE_INSENSITIVE_ORDER)才是主排序键的实际效果。我在此明确标注 :
isSpringClass作为第一比较键的实际效果是 "非 Spring 类在前、Spring 类在后"。由于OnClassCondition读取时用的是Set(AutoConfigurationMetadata.getSet),顺序对运行时行为没有影响 。 这个排序唯一的作用是输出确定性。
(b) OnBeanConditionValueExtractor:有 name 就放弃
java
static class OnBeanConditionValueExtractor extends AbstractValueExtractor {
@Override
public List<Object> getValues(AnnotationMirror annotation) {
Map<String, AnnotationValue> attributes = new LinkedHashMap<>();
annotation.getElementValues()
.forEach((key, value) -> attributes.put(key.getSimpleName().toString(), value));
if (attributes.containsKey("name")) {
return Collections.emptyList();
}
List<Object> result = new ArrayList<>();
extractValues(attributes.get("value")).forEach(result::add);
extractValues(attributes.get("type")).forEach(result::add);
return result;
}
}
如果写了 name,就不写元数据。
原因:第 15 章 §6.1 讲过,OnBeanCondition 的预筛只做 "这些类型在 classpath 上存在吗?"的检查。而 name 是 Bean 名 ,不是类型------ 类型存在性检查对 name 毫无意义。所以直接放弃。
这是一个"元数据只为特定用途生成"的精确判断: 不为用不上的信息付存储代价。
(c) ConditionalOnWebApplication:只取 type
java
generators.add(PropertyGenerator.of(annotationPackage, "ConditionalOnWebApplication")
.withAnnotation(ValueExtractor.allFrom("type")));
写的是枚举的 name()(SERVLET / REACTIVE / ANY), 对应第 15 章 §8.5 预筛里的字符串比较。
2.4 mergeProperties:跨编译轮次的累积
java
private void mergeProperties(Map<String, String> properties, String key, String value) {
String existingKey = properties.get(key);
if (existingKey == null || existingKey.isEmpty()) {
properties.put(key, value);
}
else if (!value.isEmpty()) {
properties.put(key, existingKey + "," + value);
}
}
javac 的注解处理是多轮 的(RoundEnvironment)。同一个类可能在多轮被处理 (比如生成的代码又触发新的注解处理),所以必须合并而不是覆盖。
这是"幂等累积"模式------与第 10 章讲的 ConfigData 不动点迭代有相似的哲学: 允许多次处理,结果收敛。
2.5 写入
java
private void writeProperties() throws IOException {
if (!this.properties.isEmpty()) {
Filer filer = this.processingEnv.getFiler();
FileObject file = filer.createResource(StandardLocation.CLASS_OUTPUT, "", PROPERTIES_PATH);
try (Writer writer = new OutputStreamWriter(file.openOutputStream(), StandardCharsets.UTF_8)) {
for (Map.Entry<String, String> entry : this.properties.entrySet()) {
writer.append(entry.getKey());
writer.append("=");
writer.append(entry.getValue());
writer.append(System.lineSeparator());
}
}
}
}
UTF-8 + TreeMap 顺序 + 手写 key=value (不用 Properties.store, 因为后者会加时间戳导致构建不确定)。
TreeMap 保证输出按 key 字典序------又一处"为构建确定性做的努力"。
2.6 消费端回顾
第 14 章讲过:
java
// AutoConfigurationMetadataLoader
static final String PATH = "META-INF/spring-autoconfigure-metadata.properties";
AutoConfigurationMetadata.wasProcessed(className) 为 false 时, OnClassCondition 等会降级到读字节码。所以:
元数据缺失不会导致功能错误,只会导致性能下降。 这是一个"可选优化 "的设计------第三方库不引入
spring-boot-autoconfigure-processor也能正常工作。
对照第 14 章:这也是为什么 ConfigurationClassFilter 里 filter.match(candidates, metadata) 的实现要容忍 null outcome。
3. 编译期元数据之二:spring-configuration-metadata.json
这是 IDE 里 application.yaml 自动补全的数据源,也是 /actuator/configprops 之外最重要的"配置可发现性"基础设施。
3.1 四个文件路径常量
java
class MetadataStore {
static final String METADATA_PATH = "META-INF/spring-configuration-metadata.json";
static final BiFunction<TypeElement, TypeUtils, String> SOURCE_METADATA_PATH = (type, typeUtils) ->
"META-INF/spring/configuration-metadata/%s.json".formatted(typeUtils.getQualifiedName(type));
private static final String ADDITIONAL_METADATA_PATH = "META-INF/additional-spring-configuration-metadata.json";
static final BiFunction<TypeElement, TypeUtils, String> ADDITIONAL_SOURCE_METADATA_PATH = (type, typeUtils) ->
"META-INF/spring/configuration-metadata/additional/%s.json".formatted(typeUtils.getQualifiedName(type));
| 路径 | 用途 | 谁写 |
|---|---|---|
META-INF/spring-configuration-metadata.json |
自动生成的主文件 | 注解处理器 |
META-INF/additional-spring-configuration-metadata.json |
手写补充 | 开发者(Boot 自己就用了 89 个) |
META-INF/spring/configuration-metadata/{FQN}.json |
单个 @ConfigurationPropertiesSource 类的独立文件(4.x 新) |
注解处理器 |
META-INF/spring/configuration-metadata/additional/{FQN}.json |
上者的手写补充 | 开发者 |
4.x 的新变化 :spring/configuration-metadata/ 目录下按全限定类名 拆分文件。 这是模块化时代的必然------大型项目里一个巨大的 JSON 文件不好维护, 按类拆分后可以增量编译、按需加载。
仓库实证 :全仓库有 89 个 additional-spring-configuration-metadata.json。 Boot 自己就是这套机制的最大用户------因为很多属性(比如 spring.profiles.validate、 spring.autoconfigure.exclude)无法通过注解推导出完整信息,必须手写补充。
3.2 处理器支持的注解(10 个)
java
@SupportedAnnotationTypes({
CONFIGURATION_PROPERTIES_ANNOTATION, // @ConfigurationProperties
CONFIGURATION_PROPERTIES_SOURCE_ANNOTATION, // @ConfigurationPropertiesSource
AUTO_CONFIGURATION_ANNOTATION, // @AutoConfiguration
CONFIGURATION_ANNOTATION, // @Configuration
CONTROLLER_ENDPOINT_ANNOTATION, // Actuator 端点系列
ENDPOINT_ANNOTATION,
JMX_ENDPOINT_ANNOTATION,
REST_CONTROLLER_ENDPOINT_ANNOTATION,
SERVLET_ENDPOINT_ANNOTATION,
WEB_ENDPOINT_ANNOTATION })
public class ConfigurationMetadataAnnotationProcessor extends AbstractProcessor {
Actuator 端点占了 6 个 ------ 因为端点也是"可配置的东西" (management.endpoint.xxx.enabled 等),需要生成元数据。
另有几个在处理过程中被识别但不触发处理的注解:
java
static final String NESTED_CONFIGURATION_PROPERTY_ANNOTATION = "...NestedConfigurationProperty";
static final String DEPRECATED_CONFIGURATION_PROPERTY_ANNOTATION = "...DeprecatedConfigurationProperty";
static final String CONSTRUCTOR_BINDING_ANNOTATION = "...bind.ConstructorBinding";
static final String AUTOWIRED_ANNOTATION = "org.springframework.beans.factory.annotation.Autowired";
static final String DEFAULT_VALUE_ANNOTATION = "...bind.DefaultValue";
static final String READ_OPERATION_ANNOTATION = "...annotation.ReadOperation";
@DefaultValue 在这里很关键------第 09 章讲过它用于构造函数绑定, 而这里它被用来生成元数据里的 defaultValue 字段。 一个注解同时服务运行期绑定和编译期文档------这是"信息只写一次"的范例。
3.3 处理流程
java
@Override
public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) {
this.metadataCollectors.processing(roundEnv);
TypeElement annotationType = this.metadataEnv.getConfigurationPropertiesAnnotationElement();
if (annotationType != null) { // @ConfigurationProperties 可用?
for (Element element : roundEnv.getElementsAnnotatedWith(annotationType)) {
processElement(element);
}
}
TypeElement sourceAnnotationType = this.metadataEnv.getConfigurationPropertiesSourceAnnotationElement();
if (sourceAnnotationType != null) { // @ConfigurationPropertiesSource 可用?
for (Element element : roundEnv.getElementsAnnotatedWith(sourceAnnotationType)) {
if (element instanceof TypeElement typeElement) {
MetadataCollector metadataCollector = this.metadataCollectors.getMetadataCollector(typeElement);
processSourceElement(metadataCollector, "", typeElement);
}
}
}
Set<TypeElement> endpointTypes = this.metadataEnv.getEndpointAnnotationElements();
if (!endpointTypes.isEmpty()) { // 端点注解可用?
for (TypeElement endpointType : endpointTypes) {
getElementsAnnotatedOrMetaAnnotatedWith(roundEnv, endpointType).forEach(this::processEndpoint);
}
}
if (roundEnv.processingOver()) {
try {
writeSourceMetadata();
writeMetadata();
}
catch (Exception ex) {
throw new IllegalStateException("Failed to write metadata", ex);
}
}
return false;
}
三个"可用性检查" (annotationType != null): 注解处理器可能被用在没有 Boot 依赖 的项目里(比如只有 spring-context)。 此时不能因为找不到注解类型就崩溃------静默跳过。
return false ------ 不"消费"这些注解,让其他处理器也能看到它们。
processingOver() 时才写文件 ------ 必须等所有轮次结束,否则会漏掉 后续轮次生成的类的元数据。
3.4 数据模型
运行期侧的模型在 spring-boot-configuration-metadata 模块:
java
public class ConfigurationMetadataProperty implements Serializable {
private String id; // 全限定名,小写横线形式:my.group.simple-property
private String name; // 组内名:simple-property
private String type; // java.lang.String(基本类型用包装类;泛型带完整信息)
private String description; // 多行,来自 Javadoc
private String shortDescription; // 单行单句
private Object defaultValue;
private final Hints hints = new Hints();
private Deprecation deprecation;
}
type 字段的规则(Javadoc 明说):
boolean→java.lang.Boolean(基本类型一律用包装类)HashMap<String,Integer>→java.util.HashMap<java.lang.String,java.lang.Integer>- 可能是"从 String 转换而来"的复杂类型
description vs shortDescription ------ 前者是完整 Javadoc, 后者由 SentenceExtractor 抽取第一句。IDE 的悬浮提示用后者, 文档用前者。
Hints :值提示(ValueHint)和值提供者(ValueProvider), 用于 IDE 的"候选值下拉框"。
3.5 消费端:ConfigurationMetadataRepositoryJsonBuilder
java
ConfigurationMetadataRepository repository = ConfigurationMetadataRepositoryJsonBuilder
.create(inputStream1, inputStream2, ...) // 多个 JSON 源
.build();
它把 classpath 上所有 spring-configuration-metadata.json (自己和所有依赖的)合并成一个仓库。
谁用它?
- IDE(IntelliJ IDEA / VS Code 的 Spring 插件)
- Boot 自己的文档工具(见下)
/actuator/configprops之外的一些工具链
3.6 Boot 自己的应用:ChangelogGenerator
这是 Boot 仓库里一个很有意思的内部工具 (configuration-metadata/spring-boot-configuration-metadata-changelog-generator):
java
public final class ChangelogGenerator {
public static void main(String[] args) throws IOException {
generate(new File(args[0]), new File(args[1]), new File(args[2]));
}
private static void generate(File oldDir, File newDir, File out) throws IOException {
String oldVersionNumber = oldDir.getName();
ConfigurationMetadataRepository oldMetadata = buildRepository(oldDir);
String newVersionNumber = newDir.getName();
ConfigurationMetadataRepository newMetadata = buildRepository(newDir);
Changelog changelog = Changelog.of(oldVersionNumber, oldMetadata, newVersionNumber, newMetadata);
try (ChangelogWriter writer = new ChangelogWriter(out)) {
writer.write(changelog);
}
}
static ConfigurationMetadataRepository buildRepository(File directory) {
ConfigurationMetadataRepositoryJsonBuilder builder = ConfigurationMetadataRepositoryJsonBuilder.create();
for (File file : directory.listFiles()) {
try (JarFile jarFile = new JarFile(file)) {
JarEntry metadataEntry = jarFile.getJarEntry("META-INF/spring-configuration-metadata.json");
if (metadataEntry != null) {
builder.withJsonResource(jarFile.getInputStream(metadataEntry));
}
}
}
return builder.build();
}
}
用法 :传两个目录(目录名即版本号),里面放两个版本的 jar, 它扫出各版本的元数据 JSON,diff,输出 AsciiDoc 格式的变更日志。
四种差异类型:
java
enum DifferenceType {
ADDED, // 新增
DEPRECATED, // 被标记弃用(可能仍存在)
DELETED, // 删除
DEFAULT_CHANGED // 默认值变了
}
这个工具的意义 :Boot 官方发布说明里的"配置属性变更"章节是自动生成的 , 不是人写的。这保证了文档与代码的一致性 ------ 也说明元数据不是"锦上添花",而是发布流程的一环。
DEFAULT_CHANGED这一条尤其重要:默认值变了但属性还在, 这种变更最容易被忽略、最容易造成升级事故。Boot 专门把它列为一类差异。
4. 运行期报告:/actuator/conditions
第 15 章讲了 ConditionEvaluationReport 的内部结构。本节讲它如何被暴露成 HTTP。
4.1 端点定义
java
@Endpoint(id = "conditions")
public class ConditionsReportEndpoint {
private final ConfigurableApplicationContext context;
@ReadOperation
public ConditionsDescriptor conditions() {
Map<@Nullable String, ContextConditionsDescriptor> contextConditionEvaluations = new HashMap<>();
ConfigurableApplicationContext target = this.context;
while (target != null) {
contextConditionEvaluations.put(target.getId(), new ContextConditionsDescriptor(target));
target = getConfigurableParent(target);
}
return new ConditionsDescriptor(contextConditionEvaluations);
}
while (target != null) 向上遍历父上下文 ------ 所以管理端口子上下文 的 /actuator/conditions 会同时显示主上下文的条件报告。
这与第 15 章 §6.8 讲的 SearchStrategy.ANCESTORS 是配套的: 既然条件判定会跨上下文,报告就必须跨上下文。
4.2 三层 JSON 结构
yaml
ConditionsDescriptor
└─ contexts: { contextId -> ContextConditionsDescriptor }
├─ positiveMatches: { 短类名 -> [MessageAndConditionDescriptor] } ← MultiValueMap
├─ negativeMatches: { 短类名 -> MessageAndConditionsDescriptor } ← 单值
│ ├─ notMatched: [...]
│ └─ matched: [...] ← 注意:否定组里也有 matched!
├─ exclusions: [...]
├─ unconditionalClasses: [...]
└─ parentId
两个不对称:
positiveMatches是MultiValueMap<String, ...>(一个名字可对应多条 ),negativeMatches是Map<String, MessageAndConditionsDescriptor>(一个名字一条, 但那条内部含多个条件)。negativeMatches里的对象同时有matched和notMatched两个列表。
第 2 点很重要:一个类的条件里可能既有匹配的也有不匹配的 (只要有一个不匹配, 整体就不匹配)。所以"否定匹配"条目要同时列出哪些条件过了、哪些没过------ 这样才能回答"它为什么没生效"。
4.3 归类逻辑
java
private void add(String source, ConditionAndOutcomes conditionAndOutcomes) {
String name = ClassUtils.getShortName(source);
if (conditionAndOutcomes.isFullMatch()) {
conditionAndOutcomes.forEach((conditionAndOutcome) -> this.positiveMatches.add(name,
new MessageAndConditionDescriptor(conditionAndOutcome)));
}
else {
this.negativeMatches.put(name, new MessageAndConditionsDescriptor(conditionAndOutcomes));
}
}
判据是 isFullMatch() (所有条件都匹配)。 source 是全限定名 或 类#方法,显示时转短类名。
注意一个副作用 :
ClassUtils.getShortName会丢掉包信息和方法信息。 如果两个不同包的同名类 都有条件记录,它们在 JSON 里会合并到同一个 key 下 (positiveMatches是 MultiValueMap 所以是追加;negativeMatches是 Map 所以是覆盖 )。 后者会丢数据。我标注为按代码观察的推断:Boot 没有处理这个冲突的迹象, 实践中同名的自动配置类很少见,所以影响有限。
4.4 单条记录的消息兜底
java
public MessageAndConditionDescriptor(ConditionAndOutcome conditionAndOutcome) {
Condition condition = conditionAndOutcome.getCondition();
ConditionOutcome outcome = conditionAndOutcome.getOutcome();
this.condition = ClassUtils.getShortName(condition.getClass());
if (StringUtils.hasLength(outcome.getMessage())) {
this.message = outcome.getMessage();
}
else {
this.message = outcome.isMatch() ? "matched" : "did not match";
}
}
消息为空时兜底成 "matched" / "did not match"。
对照第 15 章 §12:ConditionOutcome.getMessage() 在消息为空时返回 null------ StringUtils.hasLength(null) 是 false,所以走兜底分支。两处设计严丝合缝。
4.5 @JsonInclude(Include.NON_EMPTY) 与 @JsonPropertyOrder
java
@JsonInclude(Include.NON_EMPTY)
public static final class ContextConditionsDescriptor { ... }
@JsonPropertyOrder({ "notMatched", "matched" })
public static class MessageAndConditionsDescriptor { ... }
@JsonPropertyOrder({ "condition", "message" })
public static class MessageAndConditionDescriptor { ... }
NON_EMPTY ------ 空集合不出现在 JSON 里(减小响应体积)。 JsonPropertyOrder ------ 固定字段顺序,便于人读和做快照对比。
5. 失败分析器:FailureAnalyzer 体系
这是 Boot 最有"人情味"的子系统------它把 JVM 的异常栈翻译成人能看懂的诊断。
5.1 三个核心类型
java
@FunctionalInterface
public interface FailureAnalyzer {
@Nullable FailureAnalysis analyze(Throwable failure);
}
java
public class FailureAnalysis {
private final String description; // "出了什么事"
private final @Nullable String action; // "你该怎么办"
private final Throwable cause;
public FailureAnalysis(@Nullable String description, @Nullable String action, Throwable cause) {
this.description = (description != null) ? description : ""; // null → ""
...
}
}
java
public interface FailureAnalysisReporter {
void report(FailureAnalysis analysis);
}
三段式 :description(症状)+ action(行动)+ cause(原始异常)。 这个结构决定了 Boot 的错误输出格式。
5.2 AbstractFailureAnalyzer:用泛型解析目标异常类型
java
public abstract class AbstractFailureAnalyzer<T extends Throwable> implements FailureAnalyzer {
@Override
public @Nullable FailureAnalysis analyze(Throwable failure) {
T cause = findCause(failure, getCauseType());
return (cause != null) ? analyze(failure, cause) : null;
}
protected abstract @Nullable FailureAnalysis analyze(Throwable rootFailure, T cause);
@SuppressWarnings("unchecked")
protected Class<? extends T> getCauseType() {
Class<? extends T> type = (Class<? extends T>) ResolvableType
.forClass(AbstractFailureAnalyzer.class, getClass())
.resolveGeneric();
Assert.state(type != null, "Unable to resolve generic");
return type;
}
protected final <E extends Throwable> @Nullable E findCause(@Nullable Throwable failure, Class<E> type) {
while (failure != null) {
if (type.isInstance(failure)) { return (E) failure; }
failure = failure.getCause();
}
return null;
}
}
两个关键机制:
- 泛型解析 :
ResolvableType.forClass(AbstractFailureAnalyzer.class, getClass()).resolveGeneric()从子类的声明里反推出T的具体类型。所以你写class MyAnalyzer extends AbstractFailureAnalyzer<MyException>就够了, 不需要手动传Class对象。 findCause沿getCause()链向下找 ------ 因为根异常往往是BeanCreationException,真正的MyException埋在十几层下面。
这是一个"反射 + 约定"的经典权衡 :用泛型避免样板代码, 代价是类型信息在运行时靠
ResolvableType解析(有一次反射开销,只在启动时发生)。
5.3 FailureAnalyzers:加载与调度
java
final class FailureAnalyzers implements SpringBootExceptionReporter {
private static final Log logger = LogFactory.getLog(FailureAnalyzers.class);
private final SpringFactoriesLoader springFactoriesLoader;
private final List<FailureAnalyzer> analyzers;
public FailureAnalyzers(@Nullable ConfigurableApplicationContext context) {
this(context, SpringFactoriesLoader
.forDefaultResourceLocation((context != null) ? context.getClassLoader() : null));
}
private static List<FailureAnalyzer> loadFailureAnalyzers(@Nullable ConfigurableApplicationContext context,
SpringFactoriesLoader springFactoriesLoader) {
List<FailureAnalyzer> analyzers = new ArrayList<>(springFactoriesLoader.load(FailureAnalyzer.class,
getArgumentResolver(context), FailureHandler.logging(logger)));
analyzers.add(FailureAnalyzedException::analyze);
return Collections.unmodifiableList(analyzers);
}
private static @Nullable ArgumentResolver getArgumentResolver(@Nullable ConfigurableApplicationContext context) {
if (context == null) { return null; }
ArgumentResolver argumentResolver = ArgumentResolver.of(BeanFactory.class, context.getBeanFactory());
argumentResolver = argumentResolver.and(Environment.class, context.getEnvironment());
return argumentResolver;
}
四条设计:
ArgumentResolver注入构造器参数 ------ 分析器可以声明MyAnalyzer(BeanFactory beanFactory)或MyAnalyzer(Environment environment), 由SpringFactoriesLoader自动注入。 这是第 11 章讲过的 4.x 新 API 的又一个应用。FailureHandler.logging(logger)------ 单个分析器加载失败 (类找不到、 构造器抛异常)时只记日志,不影响其他分析器 。 "诊断工具坏了不能掩盖真正的错误"(第 12 章的同一原则)。analyzers.add(FailureAnalyzedException::analyze)------ 最后追加一个 "异常自己实现了FailureAnalyzedException接口"的兜底。 这让任意异常 都能自带诊断信息,无需注册到spring.factories。context == null时ArgumentResolver为null------ 早期失败(上下文还没创建)时也能分析,只是分析器拿不到 BeanFactory/Environment。
5.4 调度循环:第一个非 null 胜出
java
@Override
public boolean reportException(Throwable failure) {
FailureAnalysis analysis = analyze(failure, this.analyzers);
return report(analysis);
}
private @Nullable FailureAnalysis analyze(Throwable failure, List<FailureAnalyzer> analyzers) {
for (FailureAnalyzer analyzer : analyzers) {
try {
FailureAnalysis analysis = analyzer.analyze(failure);
if (analysis != null) {
return analysis;
}
}
catch (Throwable ex) {
logger.trace(LogMessage.format("FailureAnalyzer %s failed", analyzer), ex);
}
}
return null;
}
private boolean report(@Nullable FailureAnalysis analysis) {
List<FailureAnalysisReporter> reporters = this.springFactoriesLoader.load(FailureAnalysisReporter.class);
if (analysis == null || reporters.isEmpty()) {
return false;
}
for (FailureAnalysisReporter reporter : reporters) {
reporter.report(analysis);
}
return true;
}
三条语义:
- 顺序敏感 :
spring.factories里的顺序决定优先级。 第一个返回非null的分析器胜出。 - 分析器抛异常 →
catch (Throwable)+ TRACE 日志,继续下一个 。 "分析器本身不能让诊断失败"。 report返回boolean------ 告诉调用方"有没有产出诊断"。 第 12 章讲过handleRunFailure里的reportFailure在finally块中, 这个返回值决定是否继续走默认的异常打印路径。
注意 report 里 reporters 是每次重新 load ------ 没有缓存。 失败分析只发生一次,缓存没有意义。
5.5 LoggingFailureAnalysisReporter:那个经典格式
java
public final class LoggingFailureAnalysisReporter implements FailureAnalysisReporter {
private static final Log logger = LogFactory.getLog(LoggingFailureAnalysisReporter.class);
@Override
public void report(FailureAnalysis failureAnalysis) {
if (logger.isDebugEnabled()) {
logger.debug("Application failed to start due to an exception", failureAnalysis.getCause());
}
if (logger.isErrorEnabled()) {
logger.error(buildMessage(failureAnalysis));
}
}
private String buildMessage(FailureAnalysis failureAnalysis) {
StringBuilder builder = new StringBuilder();
builder.append(String.format("%n%n"));
builder.append(String.format("***************************%n"));
builder.append(String.format("APPLICATION FAILED TO START%n"));
builder.append(String.format("***************************%n%n"));
builder.append(String.format("Description:%n%n"));
builder.append(String.format("%s%n", failureAnalysis.getDescription()));
if (StringUtils.hasText(failureAnalysis.getAction())) {
builder.append(String.format("%nAction:%n%n"));
builder.append(String.format("%s%n", failureAnalysis.getAction()));
}
return builder.toString();
}
}
那三行星号框起来的 APPLICATION FAILED TO START 就是这里拼的。
两个细节:
error只输出description+action,不输出异常栈 ; 完整异常栈在debug级别。所以默认日志里看不到 stack trace ------ 想看的话要开DEBUG。这是刻意的:诊断信息比栈更有用,栈会淹没它。action为空时不输出 "Action:" 段。
5.6 全仓库 37 个分析器清单
| 模块 | 数量 | 分析器 |
|---|---|---|
spring-boot |
18 | ConfigDataNotFound、IncompatibleConfiguration、NotConstructorBoundInjection、AotInitializerNotFound、BeanCurrentlyInCreation、BeanDefinitionOverride、BeanNotOfRequiredType、Bind、BindValidation、InvalidConfigurationPropertyName、InvalidConfigurationPropertyValue、MissingParameterNames、MutuallyExclusiveConfigurationProperties、NoSuchMethod、NoUniqueBeanDefinition、PatternParse、UnboundConfigurationProperty、ValidationException |
spring-boot-autoconfigure |
2 | NoSuchBeanDefinition、BundleContentNotWatchable |
spring-boot-testcontainers |
1 | DockerEnvironmentNotFound |
spring-boot-data-redis |
1 | DataRedisUrlSyntax |
spring-boot-jdbc |
2 | DataSourceBeanCreation、HikariDriverConfiguration |
spring-boot-jooq |
2 | JaxbNotAvailableException、NoDslContextBean |
spring-boot-jpa |
1 | BootstrapExecutorRequired |
spring-boot-liquibase |
1 | LiquibaseChangelogMissing |
spring-boot-mail |
1 | NoSuchMailSenderBean |
spring-boot-micrometer-metrics |
1 | Validation |
spring-boot-r2dbc |
4 | ConnectionFactoryBeanCreation、MissingR2dbcPoolDependency、MultipleConnectionPoolConfigurations、NoConnectionFactoryBean |
spring-boot-tomcat |
1 | ConnectorStart |
spring-boot-web-server |
2 | PortInUse、MissingWebServerFactoryBean |
| 合计 | 37 |
分布规律 :核心(spring-boot)负责框架级 问题(绑定、循环引用、属性名非法); 各技术模块负责领域级 问题(端口占用、驱动缺失、连接池多重配置)。 这是一个很好的扩展点设计范例 ------你的 starter 也应该配一个 FailureAnalyzer。
6. 深度拆解:NoSuchBeanDefinitionFailureAnalyzer
这是唯一一个把条件报告和失败分析连起来的分析器,也是本章"信息链"的终点。
6.1 构造:提前抓取 report
java
class NoSuchBeanDefinitionFailureAnalyzer extends AbstractInjectionFailureAnalyzer<NoSuchBeanDefinitionException> {
private final ConfigurableListableBeanFactory beanFactory;
private final MetadataReaderFactory metadataReaderFactory;
private final ConditionEvaluationReport report;
NoSuchBeanDefinitionFailureAnalyzer(BeanFactory beanFactory) {
Assert.isTrue(beanFactory instanceof ConfigurableListableBeanFactory,
"'beanFactory' must be a ConfigurableListableBeanFactory");
this.beanFactory = (ConfigurableListableBeanFactory) beanFactory;
this.metadataReaderFactory = new CachingMetadataReaderFactory(this.beanFactory.getBeanClassLoader());
// Get early as won't be accessible once context has failed to start
this.report = ConditionEvaluationReport.get(this.beanFactory);
}
那句注释是本章最重要的一句:
"Get early as won't be accessible once context has failed to start"
为什么必须提前抓? 因为 handleRunFailure 会调用 context.close(), 而 close() 会 destroySingletons()------autoConfigurationReport 是 registerSingleton 注册的单例,会被销毁 。销毁后再 get() 会拿到一个空的新 report。
所以必须在分析器构造时(此时上下文还活着)就把 report 引用抓在手里。
这是一个"生命周期竞态"的经典案例,也是"诊断能力需要提前布局"的证明。
6.2 关联逻辑:从 Bean 缺失反查条件失败
java
private List<AutoConfigurationResult> getAutoConfigurationResults(NoSuchBeanDefinitionException cause) {
List<AutoConfigurationResult> results = new ArrayList<>();
collectReportedConditionOutcomes(cause, results);
collectExcludedAutoConfiguration(cause, results);
return results;
}
private void collectReportedConditionOutcomes(NoSuchBeanDefinitionException cause,
List<AutoConfigurationResult> results) {
this.report.getConditionAndOutcomesBySource()
.forEach((source, sourceOutcomes) -> collectReportedConditionOutcomes(cause, new Source(source),
sourceOutcomes, results));
}
private void collectReportedConditionOutcomes(NoSuchBeanDefinitionException cause, Source source,
ConditionAndOutcomes sourceOutcomes, List<AutoConfigurationResult> results) {
if (sourceOutcomes.isFullMatch()) {
return; // 全匹配 → 不是它的问题
}
BeanMethods methods = new BeanMethods(source, cause);
for (ConditionAndOutcome conditionAndOutcome : sourceOutcomes) {
if (!conditionAndOutcome.getOutcome().isMatch()) {
for (MethodMetadata method : methods) {
results.add(new AutoConfigurationResult(method, conditionAndOutcome.getOutcome()));
}
}
}
}
算法:
- 遍历 report 里所有 source
- 跳过"全匹配"的(它成功了,不是嫌疑)
- 对每个 source,找出它的哪些
@Bean方法的返回类型 是缺失的那个类型 (BeanMethods做这个匹配,靠metadataReaderFactory读字节码) - 对每个匹配的方法,记录它的失败条件
这就是那句诊断的来源:
arduino
The following candidates were found but could not be injected:
- Bean method 'dataSource' in 'DataSourceAutoConfiguration' not loaded because
@ConditionalOnClass did not find required class '...'
6.3 Source 类的字符串切分
java
private static class Source {
private final String className;
private final @Nullable String methodName;
Source(String source) {
String[] tokens = source.split("#");
this.className = (tokens.length > 1) ? tokens[0] : source;
this.methodName = (tokens.length != 2) ? null : tokens[1];
}
}
用 # 切分 ------ 对应第 15 章 §2.3 里 getClassOrMethodName 的 类#方法 格式。 两处设计必须一致,否则解析失败。
注意 tokens.length != 2 而不是 < 2 ------ 如果字符串里有两个 #(理论上不会),methodName 会是 null(保守)。
6.4 排除项也算嫌疑
java
private void collectExcludedAutoConfiguration(NoSuchBeanDefinitionException cause,
List<AutoConfigurationResult> results) {
for (String excludedClass : this.report.getExclusions()) {
Source source = new Source(excludedClass);
BeanMethods methods = new BeanMethods(source, cause);
for (MethodMetadata method : methods) {
String message = String.format("auto-configuration '%s' was excluded",
ClassUtils.getShortName(excludedClass));
results.add(new AutoConfigurationResult(method, new ConditionOutcome(false, message)));
}
}
}
被 spring.autoconfigure.exclude 排除的自动配置也会被列为"候选但没注入"。 这是对的------用户的排除也是"它没生效"的原因之一,必须告诉他。
new ConditionOutcome(false, message) ------ 造一个假的 ConditionOutcome 来表示"被排除"。这里用的是 String 重载构造器(第 15 章 §12 讲过)。
6.5 Action 文案的条件分支
java
String action = String.format("Consider %s %s in your configuration.",
(!autoConfigurationResults.isEmpty() || !userConfigurationResults.isEmpty())
? "revisiting the entries above or defining" : "defining",
getBeanDescription(cause));
两种文案:
- 有候选 → "Consider revisiting the entries above or defining a bean of type 'X' in your configuration."
- 无候选 → "Consider defining a bean of type 'X' in your configuration."
这就是那句人人见过的话。 它的措辞由"有没有找到候选"决定------ 如果找到了被条件挡住的候选,会提示你去复核上面的条目(暗示"改条件"), 而不仅仅是"自己定义一个"。
6.6 注入点注解的附加信息
java
InjectionPoint injectionPoint = findInjectionPoint(rootFailure);
if (injectionPoint != null) {
Annotation[] injectionAnnotations = injectionPoint.getAnnotations();
if (injectionAnnotations.length > 0) {
message.append(String.format("%nThe injection point has the following annotations:%n"));
for (Annotation injectionAnnotation : injectionAnnotations) {
message.append(String.format("\t- %s%n", injectionAnnotation));
}
}
}
如果失败发生在注入点(@Autowired 字段/构造参数),会列出该注入点上的注解------ 比如 @Qualifier("xxx")。"你指定了 qualifier,但没有匹配的 Bean" 这类问题 靠这个信息才能诊断。
6.7 findInjectionPoint 的链路
java
private @Nullable InjectionPoint findInjectionPoint(Throwable failure) {
UnsatisfiedDependencyException unsatisfiedDependencyException = findCause(failure,
UnsatisfiedDependencyException.class);
if (unsatisfiedDependencyException == null) {
return null;
}
return unsatisfiedDependencyException.getInjectionPoint();
}
NoSuchBeanDefinitionException 通常被包在 UnsatisfiedDependencyException 里, 而后者持有 InjectionPoint。沿异常链找上去。
6.8 numberOfBeansFound != 0 时放弃
java
@Override
protected @Nullable FailureAnalysis analyze(Throwable rootFailure, NoSuchBeanDefinitionException cause,
@Nullable String description) {
if (cause.getNumberOfBeansFound() != 0) {
return null;
}
...
}
NoSuchBeanDefinitionException 有两种:
- 找不到任何 Bean(
numberOfBeansFound == 0) - 找到了多个 但都不匹配(
numberOfBeansFound > 1,通常是类型不匹配)
本分析器只处理第 1 种 。第 2 种交给 BeanNotOfRequiredTypeFailureAnalyzer 等。
这是"分析器之间按异常状态分工"的例子 ------同一个异常类, 不同状态由不同分析器处理,靠 analyze 返回 null 让位。
7. 三者串联:一次失败的完整诊断链
把所有环节串起来,看一次真实的启动失败:
python
【编译期】
DataSourceAutoConfiguration 上有:
@AutoConfiguration(after = ...)
@ConditionalOnClass({ DataSource.class, EmbeddedDatabaseType.class })
@ConditionalOnMissingBean({ DataSource.class, XADataSource.class })
javac 跑 AutoConfigureAnnotationProcessor →
spring-autoconfigure-metadata.properties 写入:
...DataSourceAutoConfiguration.ConditionalOnClass=javax.sql.DataSource,org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseType
...DataSourceAutoConfiguration.ConditionalOnMissingBean=... ← 注意:不在支持列表,不写
...DataSourceAutoConfiguration=
【运行期 · 启动】
1. ImportCandidates 读出候选(178 个 imports 文件)
2. AutoConfigurationSorter 排序(读 .properties 的 AutoConfigureAfter/Before/Order)
3. 预筛:OnClassCondition 读 .properties 的 ConditionalOnClass
→ 用户没加 JDBC 依赖 → EmbeddedDatabaseType 不存在 → 判死
→ 记录到 ConditionEvaluationReport(作为 negative match)
4. 自动配置被剔除,@Bean dataSource() 从未注册
【运行期 · 用户代码】
@Autowired DataSource dataSource; → NoSuchBeanDefinitionException
【运行期 · 失败分析】
handleRunFailure → FailureAnalyzers.reportException
→ NoSuchBeanDefinitionFailureAnalyzer(构造时已抓住 report)
→ 遍历 report,找 DataSourceAutoConfiguration
→ 它的条件不全匹配 → 匹配它的 @Bean 方法返回类型 == DataSource → 命中
→ 输出:
Description:
Field dataSource in ... required a bean of type 'javax.sql.DataSource' that could not be found.
The following candidates were found but could not be injected:
- Bean method 'dataSource' in 'DataSourceAutoConfiguration' not loaded because
@ConditionalOnClass did not find required class '...EmbeddedDatabaseType'
Action:
Consider revisiting the entries above or defining a bean of type 'javax.sql.DataSource'...
这条链上有四个"必须提前布局"的点:
- 编译期必须写出
ConditionalOnClass的类型清单(否则预筛无法工作) - 预筛必须在正式求值之前跑(否则性能崩塌)
- 预筛的否定结果必须写进 report(否则失败分析无数据)
- 失败分析器必须在上下文关闭前抓住 report(否则拿到空对象)
四个点缺一个,最后那句人性化的诊断就出不来。 这就是 Boot "诊断能力是一等公民"的具体含义------它不是事后加的, 而是从编译期就规划好的一条链。
8. 实战:怎么读懂 /actuator/conditions
拿一个真实场景走一遍。假设你加了 spring-boot-starter-data-redis 但没加 spring-boot-starter-jdbc,然后发现 JdbcTemplate 注入失败。
8.1 先看 negativeMatches
访问 /actuator/conditions,找到 contexts.{你的 contextId}.negativeMatches, 搜索 JdbcTemplateAutoConfiguration:
json
"JdbcTemplateAutoConfiguration": {
"notMatched": [
{
"condition": "OnClassCondition",
"message": "@ConditionalOnClass did not find required class 'org.springframework.jdbc.core.JdbcTemplate'"
}
],
"matched": []
}
怎么读:
| 字段 | 含义 |
|---|---|
condition: "OnClassCondition" |
挡住它的条件是类检查 |
message |
缺的类是 JdbcTemplate |
matched: [] |
没有其他条件被求值------因为它在预筛阶段就被打死了 |
第三条是关键 :matched 是空的,说明这个自动配置的其他条件(如 @ConditionalOnSingleCandidate(DataSource.class))根本没跑 。 所以你看不到"它还需要 DataSource"这个信息------这正是第 15 章 §3.3 讲的 "预筛只在判否时记录"。
8.2 再看 positiveMatches
找一个生效的,比如 RedisAutoConfiguration:
json
"RedisAutoConfiguration": [
{ "condition": "OnClassCondition", "message": "@ConditionalOnClass found required class 'org.springframework.data.redis.core.RedisOperations'" },
{ "condition": "OnBeanCondition", "message": "@ConditionalOnMissingBean (types: org.springframework.data.redis.connection.RedisConnectionFactory; SearchStrategy: all) did not find any beans" }
]
注意第二条的消息是 "did not find any beans ",但它出现在 positiveMatches------ 因为对 @ConditionalOnMissingBean 来说,"没找到"就是匹配。
这是读报告时最容易误解的一点 : 消息文字描述的是"条件看到了什么",不是"条件是否匹配"。 判断匹配与否要看它在
positiveMatches还是negativeMatches,不要看消息里有没有 "did not"。
8.3 unconditionalClasses 的用法
这个列表是"从未被任何条件判定的类 "。如果一个类出现在这里, 意味着它没有任何条件注解 ------即它一定会生效。
排障价值 :如果你怀疑某个自动配置"莫名其妙生效了",先看它是不是在 unconditionalClasses 里。如果是,说明它压根没写条件(这本身可能是 bug)。
对照第 15 章 §13.2:这个集合是"用排除法反向统计"出来的------ 候选全集减去所有有记录的。
8.4 exclusions
列出所有被 spring.autoconfigure.exclude(或 @EnableAutoConfiguration(exclude=...)) 排除的类。第 15 章 §6.4 讲过,NoSuchBeanDefinitionFailureAnalyzer 会把它们也算作 "候选但没注入"。
排障价值:如果你手动排除了某个自动配置,然后忘了, 导致 Bean 缺失------这里能立刻看到。
8.5 子上下文的 parentId
如果你开了管理端口(management.server.port), contexts 里会有两个 条目:主上下文和 ...management 子上下文。 子上下文条目的 parentId 指向主上下文。
注意 :子上下文的 positiveMatches 里可能包含主上下文的自动配置 (因为条件判定默认 SearchStrategy.ALL,会看到父上下文的 Bean)。 不要误以为那些配置在子上下文里生效了。
9. 实战:怎么写 additional-spring-configuration-metadata.json
9.1 什么时候需要手写
注解处理器能自动推导出大部分信息,但有四类它推不出来:
- 默认值不在代码里(比如来自第三方库的默认值)
- 描述需要人工润色(自动抽取的 Javadoc 不够清楚)
- 弃用信息 (
@Deprecated注解的语义太弱,需要level和since) - 属性来自第三方类(你无法给它的字段加 Javadoc)
9.2 真实样本(Boot 自己的)
json
{
"groups": [],
"properties": [
{
"name": "spring.activemq.pool.block-if-full",
"type": "java.lang.Boolean",
"description": "Whether to block when a connection is requested and the pool is full. Set it to false to throw a \"JMSException\" instead.",
"sourceType": "org.springframework.boot.jms.autoconfigure.JmsPoolConnectionFactoryProperties",
"defaultValue": true
},
{
"name": "spring.activemq.pool.create-connection-on-startup",
"type": "java.lang.Boolean",
"description": "Whether to create a connection on startup. Can be used to warm up the pool on startup.",
"defaultValue": true,
"deprecation": {
"level": "error",
"since": "2.1.0"
}
}
]
}
四个字段的讲究:
| 字段 | 说明 |
|---|---|
name |
必须 是小写横线形式(block-if-full 不是 blockIfFull) |
type |
基本类型用包装类;Duration 等要写全限定名 |
sourceType |
这个属性实际绑定的类 ------用于 IDE 跳转。注意它可能是"被 @NestedConfigurationProperty 引用的内部类" |
defaultValue |
真实默认值 。它会被 ChangelogGenerator 拿去做 DEFAULT_CHANGED 检测 |
deprecation.level |
warning / error。error 会让启动时报弃用警告 |
deprecation.since |
从哪个版本开始弃用 |
9.3 deprecation.level = error 的实际效果
对照第 09 章:Boot 的 @ConfigurationProperties 校验里, 弃用属性会在绑定时 触发警告/错误。而元数据里的 level 决定 IDE 和文档如何呈现,也影响 Boot 内部的弃用检查。
严谨声明 :我没有逐行核实
level = "error"是否会导致启动失败 (我推断它只产生 ERROR 级别日志,不中断启动------因为 Boot 要兼容老配置升级)。 标注为待实测。若必须确认,可写一个用了该属性的最小应用跑一次。
9.4 4.x 的新玩法:按类拆分
除了传统的 META-INF/additional-spring-configuration-metadata.json, 4.x 支持:
bash
META-INF/spring/configuration-metadata/additional/{全限定类名}.json
配合 @ConfigurationPropertiesSource(第 09 章讲过), 可以把一个可复用属性类的元数据单独打包 , 被多个 @ConfigurationProperties 引用时共享。
适用场景 :多个 starter 共用的属性基类 (比如 spring-boot-web-server 里的 ServerProperties 族)。
10. 实战:写一个自己的 FailureAnalyzer
这是 Boot 里性价比最高的扩展点之一------几十行代码, 能把用户最常踩的坑变成一句人话。
10.1 最简形态(函数式)
java
public class MyFailureAnalyzer implements FailureAnalyzer {
@Override
public FailureAnalysis analyze(Throwable failure) {
MyException cause = findCause(failure);
if (cause == null) {
return null; // 不是我关心的 → 让位
}
return new FailureAnalysis(
"My component failed because " + cause.getDetail() + ".",
"Check that you have configured 'my.component.url'.",
cause);
}
}
注册:
properties
# META-INF/spring.factories
org.springframework.boot.diagnostics.FailureAnalyzer=\
com.example.MyFailureAnalyzer
10.2 推荐形态(泛型 + 构造器注入)
java
class MyFailureAnalyzer extends AbstractFailureAnalyzer<MyException> {
private final Environment environment;
MyFailureAnalyzer(Environment environment) { // 由 ArgumentResolver 注入
this.environment = environment;
}
@Override
protected FailureAnalysis analyze(Throwable rootFailure, MyException cause) {
String configured = this.environment.getProperty("my.component.url", "<none>");
return new FailureAnalysis(
"My component could not connect (configured url: " + configured + ").",
"Verify 'my.component.url' points to a reachable endpoint.",
cause);
}
}
三个好处:
AbstractFailureAnalyzer<MyException>自动做异常链查找 ,不用自己写findCause- 构造器注入
Environment/BeanFactory,诊断时能带上运行时状态 - 返回
null即可让位给其他分析器
10.3 五条写作准则
从 Boot 的 37 个分析器里总结出来的:
| 准则 | 说明 |
|---|---|
能分析就分析,不能就返回 null |
永远不要把"不确定"包装成"可能是 X"。返回 null 让下一个分析器试 |
action 必须是可执行的动作 |
"Check your configuration" 太泛;"Verify 'my.url' points to a reachable endpoint" 才好 |
| 带上运行时状态 | 引用具体属性值、Bean 名、类名------抽象的建议没有价值 |
| 不要抛异常 | 你的分析器抛异常会被 catch (Throwable) 吞掉(只 trace)。如果你抛了,用户就得不到任何诊断 |
| 不要在分析器里改变状态 | 分析器应该用构造器注入拿到它需要的一切,因为上下文随时可能被关闭 |
最后一条正是 NoSuchBeanDefinitionFailureAnalyzer 那句 "Get early as won't be accessible once context has failed to start" 的推广。
10.4 让异常自带诊断
如果你不想注册到 spring.factories,可以让异常实现 FailureAnalyzedException:
java
public class MyException extends RuntimeException implements FailureAnalyzedException {
@Override
public FailureAnalysis getFailureAnalysis(Throwable rootFailure) {
return new FailureAnalysis("...", "...", rootFailure);
}
}
FailureAnalyzers 在列表末尾追加了 FailureAnalyzedException::analyze, 所以这类异常无需注册也能被分析。
代价 :spring.factories 里的分析器先跑, 所以如果你的异常同时被某个已注册分析器识别,那个会胜出。
11. 十二条反直觉结论
| # | 结论 |
|---|---|
| 1 | @ConditionalOnMissingBean 不生成编译期元数据 ------它不在 AutoConfigureAnnotationProcessor 支持的 8 个注解里 |
| 2 | @AutoConfiguration(before=) 与 @AutoConfigureBefore 完全等价(@AliasFor),二选一即可 |
| 3 | @AutoConfiguration 上的 before / beforeName 是两个不同属性 ,分别对应 @AutoConfigureBefore.value / .name |
| 4 | @AutoConfiguration 被空值 的 @AutoConfigureBefore / @AutoConfigureAfter 标注------这是为 @AliasFor 提供目标 |
| 5 | OnClassConditionValueExtractor 的排序让 Spring 类排在后面 (Boolean 自然序 false < true),但顺序对运行时无影响 (消费端用 Set) |
| 6 | OnBeanConditionValueExtractor 看到 name 属性就放弃生成元数据(类型存在性检查对 Bean 名无意义) |
| 7 | spring-autoconfigure-metadata.properties 缺失只降性能,不影响正确性(会降级读字节码) |
| 8 | 每个被处理的类都会写一条 类名= 的空值 条目------这是 wasProcessed() 的判据 |
| 9 | /actuator/conditions 的 negativeMatches 条目里同时有 matched 和 notMatched,因为一个类可能部分条件通过 |
| 10 | 消息里的 "did not find" 不代表不匹配 ------@ConditionalOnMissingBean 的"did not find any beans"恰恰是匹配 |
| 11 | NoSuchBeanDefinitionFailureAnalyzer 在构造时 就抓取 ConditionEvaluationReport,因为上下文关闭后单例会被销毁 |
| 12 | LoggingFailureAnalysisReporter 默认不打印异常栈 (只在 DEBUG),以免淹没 Description / Action |
12. 动手验证清单
12.1 验证编译期元数据的生成
在一个自己写的 @AutoConfiguration 类上加 @ConditionalOnClass(SomeAbsent.class), 编译后看 target/classes/META-INF/spring-autoconfigure-metadata.properties, 期望 能看到 你的类名.ConditionalOnClass=SomeAbsent的全限定名。
前提:项目要引入 spring-boot-autoconfigure-processor 依赖。 不引依赖做一次对照实验 ------文件不会生成,但应用仍能正常启动(只是启动略慢)。
12.2 验证 @ConditionalOnMissingBean 不进元数据
在同一个类上加 @ConditionalOnMissingBean(SomeType.class), 期望 :.properties 里没有 ConditionalOnMissingBean 这个 key。
12.3 验证 @AutoConfiguration(after=) 等价于 @AutoConfigureAfter
java
@AutoConfiguration(after = A.class) // 写法 1
// vs
@AutoConfiguration
@AutoConfigureAfter(A.class) // 写法 2
两种写法编译出的 .properties 里都应出现 AutoConfigureAfter=...A,内容完全一致。
12.4 验证 /actuator/conditions 的父子上下文
开 management.server.port=8081,访问管理端口的 /actuator/conditions。 期望 :contexts 有 2 个条目,子上下文条目有非空的 parentId。
12.5 验证 negativeMatches 里的 matched
写一个自动配置类,同时带一个匹配的条件和一个不匹配的条件。 期望 :它在 negativeMatches 里,且 matched 数组非空(含那个匹配的条件)。
12.6 验证失败分析器能反查条件
java
@SpringBootApplication
public class App {
@Autowired JdbcTemplate jdbcTemplate; // 不加 JDBC 依赖
}
期望输出包含:
lua
The following candidates were found but could not be injected:
- Bean method 'jdbcTemplate' in 'JdbcTemplateAutoConfiguration' not loaded because ...
再手动 spring.autoconfigure.exclude=...JdbcTemplateAutoConfiguration 试一次, 期望 输出变成 auto-configuration 'JdbcTemplateAutoConfiguration' was excluded。
12.7 验证 Action 文案的两种形态
- 有候选(上面 12.6 的情况)→ "Consider revisiting the entries above or defining ..."
- 无候选(注入一个完全没人提供的类型,比如
@Autowired MyOwnType)→ "Consider defining ..."
12.8 验证分析器加载失败的隔离
在 spring.factories 里注册一个构造就抛异常 的分析器, 然后制造一个正常的可分析失败。 期望 :真正的诊断仍然输出(坏分析器只留一条 TRACE 日志)。
13. 源码索引
| 主题 | 文件 | 关键位置 |
|---|---|---|
@AutoConfiguration 四属性 |
autoconfigure/AutoConfiguration.java |
56--105(before/after/beforeName/afterName) |
| 自动配置元数据生成 | core/spring-boot-autoconfigure-processor/.../AutoConfigureAnnotationProcessor.java |
57--64(8 个注解)、67(路径)、84--106(生成器)、174--187(写入) |
245--286(两个值提取器)、325--340(mergeProperties) |
||
| 元数据消费 | autoconfigure/AutoConfigurationMetadataLoader.java |
PATH 常量 |
| 配置元数据生成 | configuration-metadata/spring-boot-configuration-processor/.../ConfigurationMetadataAnnotationProcessor.java |
66--75(10 个注解)、195--225(process) |
| 元数据四个路径 | configuration-metadata/.../MetadataStore.java |
38--48 |
| 元数据模型 | configuration-metadata/spring-boot-configuration-metadata/.../ConfigurationMetadataProperty.java |
29--50 |
| 元数据仓库构建 | .../ConfigurationMetadataRepositoryJsonBuilder.java |
34--176 |
| 变更日志工具 | configuration-metadata/.../changelog/ChangelogGenerator.java |
41--71 |
.../changelog/DifferenceType.java |
24--44 | |
| 条件报告端点 | module/spring-boot-actuator-autoconfigure/.../condition/ConditionsReportEndpoint.java |
55--73、127--136、163--218 |
| 失败分析核心 | core/spring-boot/.../diagnostics/FailureAnalyzer.java |
30--40 |
.../diagnostics/FailureAnalysis.java |
24--60 | |
.../diagnostics/AbstractFailureAnalyzer.java |
32--70 | |
.../diagnostics/FailureAnalyzers.java |
62--114 | |
.../diagnostics/LoggingFailureAnalysisReporter.java |
29--54 | |
| 条件↔失败分析的桥 | autoconfigure/.../diagnostics/analyzer/NoSuchBeanDefinitionFailureAnalyzer.java |
62--72(提前抓 report)、154--180(反查)、99--113(Action 文案) |
| 分析器注册表 | core/spring-boot/src/main/resources/META-INF/spring.factories |
55--78 |
各模块 spring.factories |
共 13 个文件、37 个分析器 | |
| 手写元数据样本 | module/spring-boot-activemq/src/main/resources/META-INF/additional-spring-configuration-metadata.json |
全文 |
14. 本章未能从本仓库核实的部分
| 项 | 说明 |
|---|---|
@AutoConfiguration(value=...) 是否真能改自动配置类的 Bean 名 |
§1.2。取决于 Framework 的 ConfigurationClassBeanDefinitionReader,本仓库无源码。不要依赖它 |
deprecation.level = "error" 是否导致启动失败 |
§9.3。我推断只产生 ERROR 日志不中断启动,标注为待实测 |
ConditionsReportEndpoint 同名短类冲突 |
§4.3。negativeMatches 用 Map 会覆盖。按代码观察的推断,未找到 Boot 的处理 |
SpringFactoriesLoader.load 对 FailureAnalyzer 是否排序 |
§5.4 说"第一个非 null 胜出",顺序来自 spring.factories 文件顺序。未确认是否有排序介入 |
MetadataCollector / JsonMarshaller 的字段细节 |
本章未逐行展开(共 1,373 行),只覆盖了路径与流程 |
BeanMethods 如何匹配方法返回类型 |
§6.2。它读字节码做类型匹配,具体算法未逐行核实 |
15. 深潜:配置元数据处理器如何"看见"你的属性
上一节讲了元数据文件的产出与消费。本节打开处理器内部, 回答一个有意思的问题:它凭什么知道 private int port = 8080; 的默认值是 8080?
15.1 四种 PropertyDescriptor
处理器把"一个可绑定属性"抽象成 PropertyDescriptor,有四个子类:
| 子类 | 对应形态 | 关键字段 |
|---|---|---|
JavaBeanPropertyDescriptor |
getter/setter(传统 JavaBean) | getter、setter、field、factoryMethod |
ConstructorParameterPropertyDescriptor |
构造函数参数 | 参数索引 |
RecordParameterPropertyDescriptor |
record 组件 | 组件索引 |
LombokPropertyDescriptor |
Lombok @Data 生成 |
特殊处理 |
java
class JavaBeanPropertyDescriptor extends PropertyDescriptor {
private final ExecutableElement setter;
private final VariableElement field;
private final ExecutableElement factoryMethod;
JavaBeanPropertyDescriptor(String name, TypeMirror type, TypeElement declaringElement,
ExecutableElement getter, ExecutableElement setter, VariableElement field,
ExecutableElement factoryMethod) {
super(name, type, declaringElement, getter);
this.setter = setter;
this.field = field;
this.factoryMethod = factoryMethod;
}
@Override
protected Element getSourceElement() {
return getGetter(); // ← Javadoc 从 getter 上读
}
四个字段各自的作用:
getter------ 读 Javadoc 的目标元素(所以 Javadoc 要写在 getter 上才生效)setter------ 判断"是否可写"(只有 getter 没有 setter 的属性不可绑定,元数据标注)field------ 读取字段上的默认值factoryMethod------ 识别@Bean方法创建的对象
getSourceElement() 返回 getter 是一个重要的隐含约定:
属性的 Javadoc 要写在 getter 上,不是字段上。 写在字段上的 Javadoc 会被忽略(除非没有 getter)。
这解释了为什么 Boot 自己的 @ConfigurationProperties 类 总是把 Javadoc 写在 getter 上,即使代码里字段和 getter 紧挨着。
15.2 默认值提取:反射进 javac 内部
这是整个处理器最"黑客"的部分。
java
@FunctionalInterface
public interface FieldValuesParser {
FieldValuesParser NONE = (element) -> Collections.emptyMap();
Map<String, Object> getFieldValues(TypeElement element) throws Exception;
}
默认实现 JavaCompilerFieldValuesParser 在 fieldvalues/javac/ 包下, 它通过反射访问 com.sun.source.util.Trees------javac 的内部 API:
java
final class Trees extends ReflectionWrapper {
private Trees(Object instance) {
super("com.sun.source.util.Trees", instance);
}
Tree getTree(Element element) throws Exception {
Object tree = findMethod("getTree", Element.class).invoke(getInstance(), element);
return (tree != null) ? new Tree(tree) : null;
}
static Trees instance(ProcessingEnvironment env) throws Exception {
try {
ClassLoader classLoader = env.getClass().getClassLoader();
Class<?> type = findClass(classLoader, "com.sun.source.util.Trees");
Method method = findMethod(type, "instance", ProcessingEnvironment.class);
return new Trees(method.invoke(null, env));
}
catch (Exception ex) {
return instance(unwrap(env)); // ← 剥一层代理再试
}
}
private static ProcessingEnvironment unwrap(ProcessingEnvironment wrapper) throws Exception {
Field delegateField = wrapper.getClass().getDeclaredField("delegate");
delegateField.setAccessible(true);
return (ProcessingEnvironment) delegateField.get(wrapper);
}
}
为什么必须这么绕?
标准注解处理 API(javax.lang.model)只能看到声明,看不到初始化表达式的值 。 Element 上有 getConstantValue(),但它只对 static final 编译期常量 有效。 而 private int port = 8080; 是实例字段,getConstantValue() 返回 null。
要拿到 8080,必须访问 javac 的语法树 (VariableTree 的 initializer), 而语法树 API 在 com.sun.source.util.Trees ------ 一个 JDK 内部包。
三条防御措施:
ReflectionWrapper用反射包装(而不是直接 import), 这样处理器在没有该类的 JDK上也能加载(只是功能降级)unwrap处理ProcessingEnvironment被包装的情况(Gradle 的增量编译器会包装)FieldValuesParser.NONE是"什么都不返回"的兜底实现
这是一个"能力探测 + 优雅降级"的范例 : Boot 需要 javac 内部 API 才能做到最好,但不依赖它也能工作 。 代价是拿不到默认值(元数据里
defaultValue缺失),仅此而已。
--add-exports 的必要性 :在 JDK 9+ 模块化之后,访问 com.sun.source.util 需要 --add-exports jdk.compiler/com.sun.source.util=ALL-UNNAMED。 如果你在构建时看到元数据里 defaultValue 全丢了,多半是这个参数没配。
实践提示 :Boot 的 Gradle 插件会自动加这个参数。 但如果你用 Maven 且手动配置
annotationProcessorPaths,可能漏掉它------ 表现为"IDE 里的默认值提示没了"。
15.3 MetadataGenerationEnvironment:处理器的"运行时上下文"
这个类的职责是把所有注解类型解析成 TypeElement ,并缓存。 关键在于它对每个注解都做 null 检查 ------因为处理器可能运行在 没有对应依赖的项目里(§3.3 的三个 if 就是靠它实现的)。
它还提供了几个"元注解搜索"能力:
getElementsAnnotatedOrMetaAnnotatedWith(element, annotation)------ 支持组合注解getNestedConfigurationPropertyAnnotation(field)------ 找@NestedConfigurationProperty
@NestedConfigurationProperty 的作用 :告诉处理器 "这个字段是一个嵌套对象,请把它的属性展开到当前命名空间下"。 不加的话,处理器会把它当成一个整体类型。
对照第 09 章:这个注解也影响 Binder 的行为(嵌套绑定)。 又是一个"一个注解同时服务运行期和编译期"的例子。
15.4 一个完整的推导流程
以这个类为例:
java
@ConfigurationProperties("my.server")
public class MyServerProperties {
/**
* Port to listen on.
*/
private int port = 8080;
public int getPort() { return this.port; }
public void setPort(int port) { this.port = port; }
}
处理器的动作序列:
ini
1. roundEnv.getElementsAnnotatedWith(@ConfigurationProperties)
→ 找到 MyServerProperties
2. PropertyDescriptorResolver 扫描成员
→ 发现 getPort/setPort/port 三者 → 构造 JavaBeanPropertyDescriptor
name = "port"(从 getPort 剥离 get 前缀并 decapitalize)
type = int(来自 getter 返回类型)
3. 读 Javadoc:getSourceElement() = getPort → 找它上面的 Javadoc
→ description = "Port to listen on."
→ shortDescription = SentenceExtractor 抽第一句 = "Port to listen on."
4. 读默认值:FieldValuesParser.getFieldValues(MyServerProperties)
→ 走 javac Trees → 找到 VariableTree(port) 的 initializer
→ defaultValue = 8080
5. 检查标志:
→ 有 setter → 可写
→ 无 @Deprecated / @DeprecatedConfigurationProperty → 不弃用
→ 无 @NestedConfigurationProperty → 不是嵌套
6. 拼 key:prefix("my.server") + "." + name("port") → "my.server.port"
转成小写横线形式(本例无需转换)
7. 写 ItemMetadata,最终 marshall 成 JSON
第 3 步和第 4 步是"魔法"的来源------ 一个是 Javadoc 解析,一个是 javac 内部反射。两者都失败时, 你会得到"有属性名、有类型、但没描述、没默认值"的元数据。
16. 更多失败分析器深潜
第 7 节拆了 NoSuchBeanDefinitionFailureAnalyzer。本节再看五个代表性分析器, 它们覆盖了五类不同的诊断技巧。
16.1 PortInUseFailureAnalyzer:最简洁的范例
java
class PortInUseFailureAnalyzer extends AbstractFailureAnalyzer<PortInUseException> {
@Override
protected FailureAnalysis analyze(Throwable rootFailure, PortInUseException cause) {
return new FailureAnalysis("Web server failed to start. Port " + cause.getPort() + " was already in use.",
"Identify and stop the process that's listening on port " + cause.getPort() + " or configure this "
+ "application to listen on another port.",
cause);
}
}
全文 15 行,但信息完整:
description带具体端口号 (cause.getPort())action给了两条可执行路径(杀进程 / 换端口)
这是"分析器最小可用形态"的教科书: 不需要 BeanFactory,不需要 Environment,只要异常自带的信息就够了。
关键前提 :PortInUseException 必须携带 port 字段。 异常设计是诊断质量的基础------你的自定义异常也要把关键状态放进去。
16.2 DataSourceBeanCreationFailureAnalyzer:用 Environment 做分支诊断
java
class DataSourceBeanCreationFailureAnalyzer extends AbstractFailureAnalyzer<DataSourceBeanCreationException> {
private final Environment environment;
DataSourceBeanCreationFailureAnalyzer(Environment environment) {
this.environment = environment;
}
private String getDescription(DataSourceBeanCreationException cause) {
StringBuilder description = new StringBuilder();
description.append("Failed to configure a DataSource: ");
if (!StringUtils.hasText(cause.getProperties().getUrl())) {
description.append("'url' attribute is not specified and ");
}
description.append(String.format("no embedded datasource could be configured.%n"));
description.append(String.format("%nReason: %s%n", cause.getMessage()));
return description.toString();
}
private String getAction(DataSourceBeanCreationException cause) {
StringBuilder action = new StringBuilder();
action.append(String.format("Consider the following:%n"));
if (EmbeddedDatabaseConnection.NONE == cause.getConnection()) {
action.append(String.format("\tIf you want an embedded database (H2, HSQL or Derby), please put it on the classpath.%n"));
}
else {
action.append(String.format("\tReview the configuration of %s%n.", cause.getConnection()));
}
action.append("\tIf you have database settings to be loaded from a particular "
+ "profile you may need to activate it").append(getActiveProfiles());
return action.toString();
}
}
三条诊断技巧:
Action按状态分支 ------ "classpath 上没有任何嵌入式数据库" vs "有嵌入式数据库但配置错了",给的建议完全不同getActiveProfiles()用 Environment ------ 提示"你的数据库配置可能在某个没激活的 profile 里"。 这是只有拿到 Environment 才能给出的诊断- 描述里带上"哪个属性没配" ------
'url' attribute is not specified
第 2 条尤其值得学习 :很多"配置不生效"的问题根因是 profile 没激活, 而用户在错误信息里看不到任何 profile 线索。Boot 主动把当前激活的 profile 打出来。
16.3 BeanCurrentlyInCreationFailureAnalyzer:从异常栈重建循环依赖
这是技术上最复杂的一个------它要从嵌套的异常栈 里重建出依赖环。
java
@Override
protected @Nullable FailureAnalysis analyze(Throwable rootFailure, BeanCurrentlyInCreationException cause) {
DependencyCycle dependencyCycle = findCycle(rootFailure);
if (dependencyCycle == null) {
return null;
}
return new FailureAnalysis(buildMessage(dependencyCycle), action(), cause);
}
findCycle 的算法:
java
private @Nullable DependencyCycle findCycle(Throwable rootFailure) {
List<BeanInCycle> beansInCycle = new ArrayList<>();
Throwable candidate = rootFailure;
int cycleStart = -1;
while (candidate != null) {
BeanInCycle beanInCycle = BeanInCycle.get(candidate);
if (beanInCycle != null) {
int index = beansInCycle.indexOf(beanInCycle);
if (index == -1) {
beansInCycle.add(beanInCycle);
}
cycleStart = (cycleStart != -1) ? cycleStart : index;
}
candidate = candidate.getCause();
}
if (cycleStart == -1) {
return null;
}
...
}
思路:
- 沿
getCause()链从上往下走,每遇到一个"能识别出 Bean"的异常就记录 - 如果某个 Bean 第二次出现 (
indexOf != -1),说明成环了 cycleStart记录环的起点
为什么循环依赖的异常栈是这样的? 因为 Spring 创建 Bean 时, 每个"正在创建中"的 Bean 都会在下层触发新的 BeanCurrentlyInCreationException, 层层嵌套。栈的深度 = 环的长度,栈底是最内层的 Bean。
Action 也有分支:
java
private String action() {
if (this.beanFactory != null && this.beanFactory.isAllowCircularReferences()) {
return "Despite circular references being allowed, the dependency cycle between beans could not be "
+ "broken. Update your application to remove the dependency cycle.";
}
return "Relying upon circular references is discouraged and they are prohibited by default. "
+ "Update your application to remove the dependency cycle between beans. "
+ "As a last resort, it may be possible to break the cycle automatically by setting "
+ "spring.main.allow-circular-references to true.";
}
两种文案对应两种状态:
- 已经允许循环引用了还失败 → "即使允许也破不了环"(比如构造器注入的环)
- 默认禁止 → 提示可以开
spring.main.allow-circular-references
对照第 13 章:Boot 默认 setAllowCircularReferences(false)。 所以第二条是默认路径。
注意措辞 : "Relying upon circular references is discouraged " + "As a last resort " ------ Boot 在给退路的同时明确表达不推荐。 这是"给用户绳子但不鼓励上吊"的文案艺术。
16.4 NoUniqueBeanDefinitionFailureAnalyzer:列出所有候选
java
@Override
protected @Nullable FailureAnalysis analyze(Throwable rootFailure, NoUniqueBeanDefinitionException cause,
@Nullable String description) {
String[] beanNames = extractBeanNames(cause);
if (beanNames == null) {
return null;
}
StringBuilder message = new StringBuilder();
message.append(String.format("%s required a single bean, but %d were found:%n",
(description != null) ? description : "A component", beanNames.length));
for (String beanName : beanNames) {
buildMessage(message, beanName);
}
...
return new FailureAnalysis(message.toString(), action.toString(), cause);
}
private void buildMessage(StringBuilder message, String beanName) {
try {
BeanDefinition definition = this.beanFactory.getMergedBeanDefinition(beanName);
message.append(getDefinitionDescription(beanName, definition));
}
catch (NoSuchBeanDefinitionException ex) {
message.append(String.format("\t- %s: a programmatically registered singleton%n", beanName));
}
}
private String getDefinitionDescription(String beanName, BeanDefinition definition) {
if (StringUtils.hasText(definition.getFactoryMethodName())) {
return String.format("\t- %s: defined by method '%s' in %s%n", beanName,
definition.getFactoryMethodName(), getResourceDescription(definition));
}
return String.format("\t- %s: defined in %s%n", beanName, getResourceDescription(definition));
}
两个技巧:
getMergedBeanDefinition------ 合并父子定义,拿到完整信息catch (NoSuchBeanDefinitionException)→ "programmatically registered singleton" ------ 手动registerSingleton的 Bean 没有定义,但它也是候选,必须列出来。 (对照第 15 章 §6.11 的"双通道"设计------同一个问题,同一种处理。)
Action 给三条路:
sql
Consider marking one of the beans as @Primary,
updating the consumer to accept multiple beans,
or using @Qualifier to identify the bean that should be consumed
这是一个"穷举所有可行解"的 action 范例------不替用户做决定, 而是把所有方案列出来。
16.5 AbstractInjectionFailureAnalyzer:自动补全"谁要求注入"
前四个分析器里多次出现 description 参数,它来自这个基类:
java
public abstract class AbstractInjectionFailureAnalyzer<T extends Throwable> extends AbstractFailureAnalyzer<T> {
@Override
protected final @Nullable FailureAnalysis analyze(Throwable rootFailure, T cause) {
return analyze(rootFailure, cause, getDescription(rootFailure));
}
private @Nullable String getDescription(Throwable rootFailure) {
UnsatisfiedDependencyException unsatisfiedDependency = findMostNestedCause(rootFailure,
UnsatisfiedDependencyException.class);
if (unsatisfiedDependency != null) {
return getDescription(unsatisfiedDependency);
}
BeanInstantiationException beanInstantiationException = findMostNestedCause(rootFailure,
BeanInstantiationException.class);
if (beanInstantiationException != null) {
return getDescription(beanInstantiationException);
}
return null;
}
@SuppressWarnings("unchecked")
private <C extends Exception> @Nullable C findMostNestedCause(Throwable root, Class<C> type) {
Throwable candidate = root;
C result = null;
while (candidate != null) {
if (type.isInstance(candidate)) {
result = (C) candidate;
}
candidate = candidate.getCause();
}
return result;
}
}
findMostNestedCause 与 findCause 的区别:
| 方法 | 行为 |
|---|---|
AbstractFailureAnalyzer.findCause |
找到第一个匹配就返回(最外层) |
AbstractInjectionFailureAnalyzer.findMostNestedCause |
找到最后一个匹配(最内层) |
为什么这里要最内层? 因为 UnsatisfiedDependencyException 的嵌套链是 "A 依赖 B,B 依赖 C,C 依赖 D(失败)"。真正需要注入的是 D, 也就是最内层的那个。取最外层会错误地描述成"A 要求..."。
getDescription 的两种形态(节选):
java
private @Nullable String getDescription(UnsatisfiedDependencyException ex) {
InjectionPoint injectionPoint = ex.getInjectionPoint();
if (injectionPoint != null) {
if (injectionPoint.getField() != null) {
return String.format("Field %s in %s", injectionPoint.getField().getName(),
injectionPoint.getField().getDeclaringClass().getName());
}
...
}
}
这就产生了那句经典的:
bash
Field dataSource in com.example.MyService required a bean of type '...' that could not be found.
"最内层 + 注入点信息"的组合 让诊断精确到字段级。 这是 Boot 诊断质量高的一个关键技术点。
17. 接入点:SpringBootExceptionReporter 是怎么被装配的
前面讲了分析器本身,本节讲它怎么被 SpringApplication 调用。
17.1 接口
java
public interface SpringBootExceptionReporter {
boolean reportException(Throwable failure);
}
FailureAnalyzers 是它的唯一内置实现。
17.2 装配时机
在第 04 章讲的 SpringApplication.run() 里,异常报告器在构造阶段就被加载:
scss
new SpringApplication(...)
→ this.exceptionReporters = getSpringFactoriesInstances(SpringBootExceptionReporter.class, ...)
注意 spring.factories 里注册的是 FailureAnalyzers,不是分析器本身:
properties
# core/spring-boot/src/main/resources/META-INF/spring.factories:29-30
org.springframework.boot.SpringBootExceptionReporter=\
org.springframework.boot.diagnostics.FailureAnalyzers
FailureAnalyzers 的构造需要 ConfigurableApplicationContext , 而 run() 构造阶段还没有上下文。所以:
- 构造时机:SpringApplication 实例化时(传
nullcontext) - 使用时机:
handleRunFailure时重新构造一个带 context 的实例
严谨声明 :
FailureAnalyzers的公开构造器接受@Nullable ConfigurableApplicationContext, 但SpringBootExceptionReporter由SpringFactoriesLoader实例化时 只会用无参或ArgumentResolver可解析的构造器 。 按我对代码的理解,reportFailure路径会新建一个FailureAnalyzers(context)。 本章未能逐行核实SpringApplication.reportFailure的具体实现 (第 12 章讲过它在finally块中), 标注为待核实。
17.3 为什么"在 finally 里"很重要
第 12 章讲过:
java
private RuntimeException handleRunFailure(...) {
try {
// ... 各种清理
}
finally {
reportFailure(this.exceptionReporters, failure);
}
...
}
finally 保证两件事:
- 即使
listeners.failed()抛异常,诊断仍然输出 - 即使
context.close()抛异常,诊断仍然输出
这与 §5.3 讲的 FailureHandler.logging、§5.4 讲的 catch (Throwable) 是同一条原则的三次体现:
诊断链路本身绝不能失败沉默。
三次防御:
| 层级 | 防御 |
|---|---|
| 分析器加载失败 | FailureHandler.logging(logger) ------ 跳过这一个 |
| 分析器执行抛异常 | catch (Throwable) + TRACE ------ 跳过这一个 |
| 报告流程整体异常 | finally ------ 无论如何都尝试 |
18. 一个完整的 /actuator/conditions 响应示例
下面是带注释的简化响应(字段顺序按 @JsonPropertyOrder):
json
{
"contexts": {
"application": {
"positiveMatches": {
"RedisAutoConfiguration": [
{
"condition": "OnClassCondition",
"message": "@ConditionalOnClass found required class 'org.springframework.data.redis.core.RedisOperations'"
},
{
"condition": "OnBeanCondition",
"message": "@ConditionalOnMissingBean (types: org.springframework.data.redis.connection.RedisConnectionFactory; SearchStrategy: all) did not find any beans"
}
]
},
"negativeMatches": {
"DataSourceAutoConfiguration": {
"notMatched": [
{
"condition": "OnClassCondition",
"message": "@ConditionalOnClass did not find required classes 'javax.sql.DataSource', 'org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseType'"
}
],
"matched": []
},
"JdbcTemplateAutoConfiguration": {
"notMatched": [
{
"condition": "OnClassCondition",
"message": "@ConditionalOnClass did not find required class 'org.springframework.jdbc.core.JdbcTemplate'"
}
],
"matched": [
{
"condition": "OnBeanCondition",
"message": "@ConditionalOnSingleCandidate (types: javax.sql.DataSource; SearchStrategy: all) did not find any beans"
}
]
}
},
"exclusions": [],
"unconditionalClasses": [
"org.springframework.boot.autoconfigure.context.ConfigurationPropertiesAutoConfiguration"
],
"parentId": null
}
}
}
逐条解读:
| 条目 | 读法 |
|---|---|
RedisAutoConfiguration 在 positive |
生效了。注意第二条消息里有 "did not find any beans" 但它是匹配 |
DataSourceAutoConfiguration 的 matched: [] |
预筛阶段被打死 ------后续条件(@ConditionalOnMissingBean)根本没跑 |
JdbcTemplateAutoConfiguration 的 matched 有 1 条 |
它通过了某个条件(@ConditionalOnSingleCandidate... 等等) |
等等,最后一条要小心 :@ConditionalOnSingleCandidate(DataSource.class) 的消息是 "did not find any beans",按 §16.5 的判据它应该是不匹配 (isAllMatched() 为 false)。 但它出现在 matched 数组里------
这说明我对
JdbcTemplateAutoConfiguration的实际条件组合是臆测的 。 上面这个 JSON 是我为说明结构而构造的例子 ,不是真实输出。 真实场景下matched里出现的应该是真正匹配 的条件。 我在此明确说明:这是示意,不是实测快照。 真实输出请用 §12.4 的方法自己抓一次。
unconditionalClasses 里那条 ConfigurationPropertiesAutoConfiguration 是真实的(第 09 章讲过它无条件注册 ConfigurationPropertiesBinder 等基础设施)。
19. 一条常被忽略的实践建议
把本章的四块内容放在一起,得到一个可操作的结论:
给你的 Starter 配齐三样东西,
spring-configuration-metadata.json、additional-spring-configuration-metadata.json、一个FailureAnalyzer。
理由:
- 元数据决定用户在 IDE 里的体验(有没有补全、有没有文档)
- 补充元数据决定那些"自动推导不出来"的信息(默认值、弃用)
- 失败分析器决定用户踩坑时能不能自救
Boot 自己的 128 个模块每一个 都至少配了前两样, 37 个分析器覆盖了最常见的踩坑场景。 这不是" nice to have",是 Starter 的交付标准。
反过来,如果你引入一个第三方 starter 发现:
- 写配置没有补全 → 它没引入
spring-boot-configuration-processor - 报错是裸的
NoSuchBeanDefinitionException没有"Consider defining..." → 它没配FailureAnalyzer(或者它的自动配置压根没条件,所以无从分析)
这两条也可以用来快速评估一个 starter 的质量。
20. 元数据的第三块:hints(值提示)
前面两节讲了 properties(属性清单)和 groups(分组)。 元数据文件还有第三块:hints------告诉 IDE 一个属性可以填哪些值。
20.1 三个类型
| 类型 | 位置 | 作用 |
|---|---|---|
ItemHint(处理器侧)/ Hints(运行期侧) |
顶层 hints 数组 |
一个属性的值提示容器 |
ValueHint |
ItemHint.values |
一个具体候选值 + 描述 |
ValueProvider |
ItemHint.providers |
一个动态值提供者(名字 + 参数) |
java
public class ItemHint implements Comparable<ItemHint> {
private final String name; // 属性名
private final List<ValueHint> values;
private final List<ValueProvider> providers;
}
ItemHint 实现 Comparable(按 name 排序)------又一处"输出确定性"的保证。
20.2 值提示 vs 值提供者
java
public class ValueHint implements Serializable {
private Object value; // 候选值本身
private String description; // 完整描述
private String shortDescription; // 单句描述
}
public class ValueProvider implements Serializable {
private String name; // 提供者名
private final Map<String, Object> parameters = new LinkedHashMap<>();
}
核心区别:
ValueHint是静态枚举 ------ 值的集合在编译期就固定了。 比如logging.level.*的TRACE/DEBUG/INFO/WARN/ERROR。ValueProvider是动态查询 ------ 值的集合在运行时才知道。 比如spring.profiles.active的候选值是"当前存在的所有 profile", 或者logging.level.<logger-name>的候选是"所有 logger 名"。
ValueProvider 的 Javadoc 说得很明确:
Each provider is defined by a
nameand can have an arbitrary number ofparameters. The available providers are defined in the Spring Boot documentation.
也就是说,provider 的名字是一个开放的命名空间 ------ Boot 定义了一批(spring-boot-configuration-metadata 里的 ValueProvider 只是数据载体, 不含任何 provider 实现),IDE 插件按名字识别。
这带来一个约束 :你不能自定义 ValueProvider 实现并期望 IDE 支持------ 只能用 Boot 文档里列出的那些名字。这和 ValueHint 的"随便写"形成对比。
20.3 Map 类型的 .keys / .values 后缀规则
ItemHint 的类级 Javadoc 有一条重要规则:
The
nameof the hint is the name of the related property with one major exception for map types as both the keys and values of the map can have hints . In such a case, the hint should be suffixed by.keysor.valuesrespectively. Creating a hint for a map using its property name is therefore invalid.
json
{
"hints": [
{
"name": "logging.level.keys", // ← Map 的键提示
"providers": [{ "name": "logger-names" }]
},
{
"name": "logging.level.values", // ← Map 的值提示
"values": [
{ "value": "trace", "description": "..." },
{ "value": "debug", "description": "..." }
]
}
]
}
为什么必须加后缀? 因为 logging.level 是一个 Map<String, String>:
- 键 (
.keys)= logger 名(com.example.Foo) - 值 (
.values)= 日志级别(debug)
两者的候选集合完全不同,用同一个 hint 无法表达。
"用属性名本身给 Map 加 hint 是无效的" ------ 这是硬规则, 违反了不会报错,但 IDE 不会生效(静默失效)。
20.4 ItemHint 的名字规范化
java
private String toCanonicalName(String name) {
int dot = name.lastIndexOf('.');
if (dot != -1) {
String prefix = name.substring(0, dot);
String originalName = name.substring(dot);
return prefix + ConventionUtils.toDashedCase(originalName);
}
return ConventionUtils.toDashedCase(name);
}
只把最后一段转成横线形式,前缀保持原样。
原因:.keys / .values 这些后缀 和包路径不能 被转成横线。 比如 logging.level.keys ------ 若整体转横线,.keys 会变成 .keys(本来就是), 但 spring.my-map.keys 里的 my-map 必须保持。 保守处理:只动最后一段。
applyPrefix 用于嵌套属性组:
java
public ItemHint applyPrefix(String prefix) {
return new ItemHint(ConventionUtils.toDashedCase(prefix) + "." + this.name, this.values, this.providers);
}
20.5 ItemMetadata 的完整字段
处理器侧的属性模型:
java
public class ItemMetadata {
private final ItemType itemType; // PROPERTY / GROUP
private String name;
private String type;
private String description;
private String sourceType; // 声明这个属性的类的全限定名
private String sourceMethod; // 若属性由 @Bean 方法暴露,这里记方法签名
private Object defaultValue;
private ItemDeprecation deprecation;
}
sourceMethod 是一个容易被忽略但很有用的字段:
当一个属性的来源不是字段而是一个 @Bean 方法的返回类型时 (比如 Actuator 端点的属性),sourceMethod 记录方法签名, 让 IDE 能跳到方法而不是类。
itemType 区分 PROPERTY 和 GROUP ------ 对应 JSON 里的 properties 和 groups 两个数组。 GROUP 条目没有 type(它只是一个命名空间)。
20.6 一个带 hints 的完整示例
json
{
"groups": [
{
"name": "my.server",
"type": "com.example.MyServerProperties",
"sourceType": "com.example.MyServerProperties"
}
],
"properties": [
{
"name": "my.server.port",
"type": "java.lang.Integer",
"description": "Port to listen on.",
"sourceType": "com.example.MyServerProperties",
"defaultValue": 8080
},
{
"name": "my.server.protocol",
"type": "java.lang.String",
"description": "Protocol to use.",
"sourceType": "com.example.MyServerProperties",
"defaultValue": "http"
}
],
"hints": [
{
"name": "my.server.protocol",
"values": [
{ "value": "http", "description": "Plain HTTP." },
{ "value": "https", "description": "HTTP over TLS." }
]
}
]
}
IDE 的表现:
- 输入
my.server.→ 列出port和protocol - 悬浮
port→ 显示 "Port to listen on." + "Default: 8080" - 输入
my.server.protocol=→ 弹出http/https下拉
三块数据缺一不可 :只有 properties 没有 hints,用户不知道能填什么; 只有 hints 没有 properties,IDE 不知道有这个属性。
20.7 为什么元数据要区分"编译期模型"和"运行期模型"
注意这里有两套模型:
| 模块 | 用途 | |
|---|---|---|
| 编译期 | spring-boot-configuration-processor 的 metadata/ 包(ItemMetadata、ItemHint...) |
注解处理器写 JSON |
| 运行期 | spring-boot-configuration-metadata 的 ConfigurationMetadataProperty、Hints... |
读 JSON |
为什么要两套? 因为它们的职责不同:
- 编译期模型要能增量修改 (
MetadataCollector累积、mergeProperties合并), 所以字段可变、有 setter - 运行期模型是只读快照 ,面向查询(
getAllProperties()、findHints())
但它们之间有一个隐含耦合 :JSON schema 必须一致。 Boot 用 JsonConverter(211 行)在编译期模型和运行期 JSON 结构之间转换。
这是一种"用中间格式(JSON)解耦两个模块"的设计------ 编译期模块不依赖运行期模块,反之亦然。 代价是 schema 演进要同时改两边(Boot 用测试覆盖这一点)。
21. 本章未逐行核实 / 待你实测的项
AutoConfigurationSorter留下的口子本章已标注待核实,未给出结论。- 配置元数据注解处理器的完整输出格式未展开。
FailureAnalyzer的全集未统计。ConditionEvaluationReport在 native 下是否可用未验证。- 排序与
spring-autoconfigure-metadata.properties的交互未实测。
22. 下一章
卷四(13--16 章)到此结束。四章串起来是一条完整的主线:
13:原始 Bean 定义从哪来 (BeanDefinitionLoader、组件扫描、Bean 命名)
14:自动配置类怎么被选出并排序 (ImportCandidates、ImportSelector、Sorter)
15:每个类怎么决定生不生效 (条件注解体系)
16:怎么知道结果 (编译期元数据、条件报告、失败分析)
这条主线只回答了一半的问题。 前四章讲的是"Bean 定义怎么被造出来", 但容器启动之后发生了什么还没讲:
WebApplicationType是怎么推断的?三种类型分别创建什么上下文?- 内嵌服务器(Tomcat / Jetty / Netty)什么时候启动、怎么拿到端口?
- 为什么 Servlet 环境下
DispatcherServlet是懒注册的? - WebFlux 的
ReactiveWebServerApplicationContext与 Servlet 的差别在哪? - 4.x 新增的 gRPC 模块是怎么接入这套体系的?
第 17 章:应用类型推断与上下文工厂 ------ WebApplicationType 与 ApplicationContextFactory。