Spring Boot 4.1.1 深度源码解析-第 25 章 · Actuator 与健康:端点机制、HealthIndicator、管理端口子上下文
本章目标:把
/actuator/health这一个 URL 背后所有参与协作的类全部读完。 读完之后你应该能回答:为什么management.endpoint.health.probes.enabled=true却没有/livez; 为什么management.server.port=8081会凭空多出一个ApplicationContext; 为什么把management.endpoints.web.base-path改成/会直接启动失败。
0. 从一个请求出发
假定一个最普通的 Spring Boot 4.1.1 Web 应用,只加了 spring-boot-starter-actuator。启动后执行:
$ curl -i http://localhost:8080/actuator/health
HTTP/1.1 200
Content-Type: application/vnd.spring-boot.actuator.v3+json
{"status":"UP"}
这一行 {"status":"UP"} 的背后,至少有 11 个自动配置类、2 个 Discoverer、1 个注册表、
1 个组解析器、1 个状态聚合器、1 个 HTTP 状态码映射器、1 个 HandlerMapping 参与了工作。
更麻烦的是:它们分布在 4 个不同的 Gradle 模块里,而其中最大的一个(spring-boot-health)
在 Spring Boot 3.x 里根本不存在。
本章按"请求到达之前"→"请求到达"→"请求到达之外(管理端口)"三段推进:
| 段 | 问题 | 涉及章节 |
|---|---|---|
| 装配期 | 端点如何被定义、被发现、被判定"可用"、被暴露 | §1–§4 |
| 运行期 | 一次 health 请求如何在组内聚合、如何决定 HTTP 状态码 | §6 |
| 部署期 | management.server.port 如何变出一个子容器 | §5 |
1. 模块拓扑:4.x 把 health 拆了出去
1.1 四个模块
module/spring-boot-actuator/ # 端点模型与所有非健康端点(纯库,无 .imports 注册自动配置)
module/spring-boot-actuator-autoconfigure/# 24 个自动配置类,注册在 *.imports
module/spring-boot-health/ # ★ 4.0.0 新增:health 的一切(库 + 7 个自动配置)
module/spring-boot-micrometer-observation/# 第 26 章主题,本章不展开
统计一下:
| 模块 | 主源码 Java 文件 | 自动配置数(.imports 行数) |
|---|---|---|
spring-boot-actuator | 约 150 | — |
spring-boot-actuator-autoconfigure | 82 | 24 |
spring-boot-health | 96 | 7 |
关键结构性事实:spring-boot-health 自己就有一份
META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports,内容 7 行:
org.springframework.boot.health.autoconfigure.actuate.endpoint.AvailabilityProbesAutoConfiguration
org.springframework.boot.health.autoconfigure.actuate.endpoint.HealthEndpointAutoConfiguration
org.springframework.boot.health.autoconfigure.application.AvailabilityHealthContributorAutoConfiguration
org.springframework.boot.health.autoconfigure.application.DiskSpaceHealthContributorAutoConfiguration
org.springframework.boot.health.autoconfigure.application.SslHealthContributorAutoConfiguration
org.springframework.boot.health.autoconfigure.contributor.HealthContributorAutoConfiguration
org.springframework.boot.health.autoconfigure.registry.HealthContributorRegistryAutoConfiguration
这意味着:你可以只用 health 而不用 actuator。spring-boot-health 的自动配置用
@ConditionalOnClass(name = "org.springframework.boot.actuate.endpoint.annotation.Endpoint")
把自己降级——没有 actuator 时,HealthEndpointAutoConfiguration 直接不匹配,
但 HealthContributorRegistryAutoConfiguration(无此条件)依然会装配注册表。
这就是 4.x "模块可单独使用"的设计意图。
1.2 包名迁移(3.x → 4.x)
这是升级时最容易踩的一处。对照表(左 3.x,右 4.x):
| 3.x | 4.x |
|---|---|
org.springframework.boot.actuate.health.HealthIndicator | org.springframework.boot.health.contributor.HealthIndicator |
org.springframework.boot.actuate.health.Health | org.springframework.boot.health.contributor.Health |
org.springframework.boot.actuate.health.Status | org.springframework.boot.health.contributor.Status |
org.springframework.boot.actuate.health.AbstractHealthIndicator | org.springframework.boot.health.contributor.AbstractHealthIndicator |
org.springframework.boot.actuate.health.HealthContributorRegistry | org.springframework.boot.health.registry.HealthContributorRegistry |
org.springframework.boot.actuate.health.StatusAggregator | org.springframework.boot.health.actuate.endpoint.StatusAggregator |
org.springframework.boot.actuate.health.HttpCodeStatusMapper | org.springframework.boot.health.actuate.endpoint.HttpCodeStatusMapper |
org.springframework.boot.actuate.endpoint.web.WebEndpointResponse | 不变 |
注意最后两行:StatusAggregator 和 HttpCodeStatusMapper 没有进 contributor 包,
而是进了 actuate.endpoint 包。原因是它们只在"端点"语境下有意义,与"健康贡献者"无关。
这个划分是有意为之的:contributor 包(20 个类)理论上可以脱离 actuator 单独使用,
actuate.endpoint 包(19 个类)是 actuator 专属。
1.3 spring-boot-actuator 为什么没有 .imports
spring-boot-actuator 模块下没有 META-INF/spring/ 目录。它是纯库模块:
HealthEndpoint、BeansEndpoint、EnvironmentEndpoint 这些端点类本身在这里,
但谁来 new 它们,由 spring-boot-actuator-autoconfigure 决定。
这与第 23 章看到的 spring-boot-jpa(同样没有 .imports)是同一个套路——
"库模块不注册自动配置"是 4.x 模块化的一条硬规矩。
2. 端点模型:@Endpoint 的三层抽象
2.1 第一层:注解
// module/spring-boot-actuator/.../endpoint/annotation/Endpoint.java
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Reflective
public @interface Endpoint {
String id() default "";
Access defaultAccess() default Access.UNRESTRICTED;
}
只有两个属性。id 为空时由 bean 名推导(见 §2.5)。defaultAccess 默认 UNRESTRICTED
——这很重要:默认所有端点都是"完全开放"的,安全完全交给 Spring Security 的 URL 规则,
而不是 actuator 自己的开关。
全仓库只有一个端点覆写了它:
// actuate/context/ShutdownEndpoint.java
@Endpoint(id = "shutdown", defaultAccess = Access.NONE)
也就是说:shutdown 是唯一一个"默认关闭"的端点。其他 15 个内置端点默认都是 UNRESTRICTED。
这解释了为什么文档反复强调"暴露 shutdown 等于把 kill 开关挂到公网"。
全仓库 @Endpoint 声明清点(grep -rn "@Endpoint(" --include=*.java module/*/src/main):
13 个在 spring-boot-actuator(auditevents beans configprops shutdown env info
loggers threaddump sbom scheduledtasks startup httpexchanges mappings),
1 个在 spring-boot-health(health),1 个在 -actuator-autoconfigure(conditions),
其余按模块分布:caches、flyway、liquibase、metrics、quartz、sessions、integrationgraph。
2.2 第二层:Operation
一个端点由若干 Operation 组成,Operation 有三种类型,由方法注解决定:
// endpoint/annotation/DiscoveredOperationsFactory.java
static {
Map<OperationType, Class<? extends Annotation>> operationTypes = new EnumMap<>(OperationType.class);
operationTypes.put(OperationType.READ, ReadOperation.class);
operationTypes.put(OperationType.WRITE, WriteOperation.class);
operationTypes.put(OperationType.DELETE, DeleteOperation.class);
OPERATION_TYPES = Collections.unmodifiableMap(operationTypes);
}
发现过程:
Collection<O> createOperations(EndpointId id, Object target) {
return MethodIntrospector
.selectMethods(target.getClass(), (MetadataLookup<O>) (method) -> createOperation(id, target, method))
.values();
}
MethodIntrospector.selectMethods 是 Spring Framework 的工具方法,
它会遍历类及其所有父类/接口的所有方法(含桥接方法、CGLIB 用户类),
对每个方法调用 MetadataLookup,返回非 null 的结果收集成 Map。
这里 MetadataLookup 就是"尝试把方法解析成一个 Operation"。
注意 createOperation(endpointId, target, method) 里的实现:
private @Nullable O createOperation(EndpointId endpointId, Object target, Method method) {
return OPERATION_TYPES.entrySet().stream()
.map((entry) -> createOperation(endpointId, target, method, entry.getKey(), entry.getValue()))
.filter(Objects::nonNull)
.findFirst()
.orElse(null);
}
它按 EnumMap 的迭代顺序(即 OperationType 的 ordinal 顺序:READ → WRITE → DELETE)
依次尝试,findFirst()。所以如果一个方法同时标了 @ReadOperation 和 @WriteOperation,
会静默地当成 READ——没有冲突检测。这是一个可以踩的坑,但源码没有告警。
找到注解后:
DiscoveredOperationMethod operationMethod =
new DiscoveredOperationMethod(method, operationType, annotation.asAnnotationAttributes());
OperationInvoker invoker = new ReflectiveOperationInvoker(target, operationMethod, this.parameterValueMapper);
invoker = applyAdvisors(endpointId, operationMethod, invoker);
return createOperation(endpointId, operationMethod, invoker);
ReflectiveOperationInvoker 是真正做反射调用的地方,它用 OperationMethod 描述方法签名,
用 ParameterValueMapper 把 HTTP 请求里的字符串转成目标类型。
ParameterValueMapper 的默认实现由 EndpointAutoConfiguration 提供:
@Bean
@ConditionalOnMissingBean
ParameterValueMapper endpointOperationParameterMapper(
@EndpointConverter ObjectProvider<Converter<?, ?>> converters,
@EndpointConverter ObjectProvider<GenericConverter> genericConverters) {
ConversionService conversionService = createConversionService(
converters.orderedStream().toList(), genericConverters.orderedStream().toList());
return new ConversionServiceParameterValueMapper(conversionService);
}
private ConversionService createConversionService(List<Converter<?, ?>> converters,
List<GenericConverter> genericConverters) {
if (genericConverters.isEmpty() && converters.isEmpty()) {
return ApplicationConversionService.getSharedInstance(); // ★ 走共享实例
}
ApplicationConversionService conversionService = new ApplicationConversionService();
converters.forEach(conversionService::addConverter);
genericConverters.forEach(conversionService::addConverter);
return conversionService;
}
@EndpointConverter 是一个限定注解:用户想给端点加自定义转换器时,
在自己的 Converter bean 上加 @EndpointConverter,它就会被吸进来,
而不会污染主应用的 ConversionService。
注意性能分支:没有任何 @EndpointConverter 转换器时,直接复用
ApplicationConversionService.getSharedInstance()(全局单例),
一旦有一个,就 new 一个全新的 ApplicationConversionService。
这是"快路径 vs 可扩展"的典型取舍。
2.3 Operation 级缓存
@Bean
@ConditionalOnMissingBean
CachingOperationInvokerAdvisor endpointCachingOperationInvokerAdvisor(Environment environment) {
return new CachingOperationInvokerAdvisor(new EndpointIdTimeToLivePropertyFunction(environment));
}
EndpointIdTimeToLivePropertyFunction 读的属性名是:
String name = String.format("management.endpoint.%s.cache.time-to-live", endpointId.toLowerCaseString());
BindResult<Duration> duration = Binder.get(this.environment).bind(name, DURATION);
return duration.map(Duration::toMillis).orElse(null);
即 management.endpoint.<id>.cache.time-to-live,返回 null 表示不缓存。
注意用的是 Binder 而不是 environment.getProperty——因为值可能是 10s 这样的
Duration 宽松格式,需要走第 8 章讲的 DurationStyle 解析。
2.4 第三层:ExposableEndpoint
ExposableEndpoint<O> 是"已被某个技术(web/jmx)暴露的端点"的抽象。
EndpointDiscoverer<E extends ExposableEndpoint<O>, O extends Operation> 是发现器基类,
其成员:
private final ApplicationContext applicationContext;
private final Collection<EndpointFilter<E>> endpointFilters;
private final Collection<OperationFilter<O>> operationFilters;
private final DiscoveredOperationsFactory<O> operationsFactory;
private final Map<EndpointBean, E> filterEndpoints = new ConcurrentHashMap<>();
private volatile @Nullable Collection<E> endpoints;
getEndpoints() 是 volatile + 懒加载 + 无锁双检查(第 12 章见过的同款写法):
public final Collection<E> getEndpoints() {
Collection<E> endpoints = this.endpoints;
if (endpoints == null) {
endpoints = discoverEndpoints();
this.endpoints = endpoints;
}
return endpoints;
}
为什么敢不加锁?因为 discoverEndpoints() 是幂等的,最坏情况是两个线程各算一遍,
结果等价。这比 synchronized 便宜得多。
2.5 发现流程的五步
private Collection<E> discoverEndpoints() {
Collection<EndpointBean> endpointBeans = createEndpointBeans(); // ①
addExtensionBeans(endpointBeans); // ②
return convertToEndpoints(endpointBeans); // ③④⑤
}
① createEndpointBeans()
String[] beanNames = BeanFactoryUtils.beanNamesForAnnotationIncludingAncestors(
this.applicationContext, Endpoint.class);
IncludingAncestors 是关键:管理端口子上下文(§5)可以从父容器拿到端点 bean 定义。
跳过 ScopedProxyUtils.isScopedTarget(beanName)(代理目标不算)。
如果两个 bean 的 EndpointId 相同,直接抛:
throw new IllegalStateException("Found two endpoints with the id '" + endpointBean.getId() + "': '"
+ endpointBean.getBeanName() + "' and '" + previous.getBeanName() + "'");
② addExtensionBeans()
@EndpointExtension 是"给已有端点补技术专属能力"的机制。
例如 HealthEndpoint 是技术无关的(返回 HealthDescriptor),
HealthEndpointWebExtension 给它补上"返回 HTTP 状态码"的能力(返回 WebEndpointResponse<...>)。
String[] beanNames = BeanFactoryUtils.beanNamesForAnnotationIncludingAncestors(
this.applicationContext, EndpointExtension.class);
for (String beanName : beanNames) {
ExtensionBean extensionBean = createExtensionBean(beanName);
EndpointBean endpointBean = byId.get(extensionBean.getEndpointId());
Assert.state(endpointBean != null, () -> ("Invalid extension '" + extensionBean.getBeanName()
+ "': no endpoint found with id '" + extensionBean.getEndpointId() + "'"));
addExtensionBean(endpointBean, extensionBean);
}
扩展找不到对应端点 → 直接 Assert.state 失败。所以你写了一个
@EndpointWebExtension(endpoint = FooEndpoint.class) 但 FooEndpoint 没注册,启动就炸。
③ convertToEndpoints():一个端点最多一个扩展
if (endpointBean.getExtensions().size() > 1) {
String extensionBeans = endpointBean.getExtensions().stream()
.map(ExtensionBean::getBeanName).collect(Collectors.joining(", "));
throw new IllegalStateException("Found multiple extensions for the endpoint bean "
+ endpointBean.getBeanName() + " (" + extensionBeans + ")");
}
④ Operation 的合并与"替换最后一个"
private void addOperations(MultiValueMap<OperationKey, O> indexed, EndpointId id, Access defaultAccess,
Object target, boolean replaceLast) {
Set<OperationKey> replacedLast = new HashSet<>();
Collection<O> operations = this.operationsFactory.createOperations(id, target);
for (O operation : operations) {
if (!isOperationFiltered(operation, id, defaultAccess)) {
OperationKey key = createOperationKey(operation);
O last = getLast(indexed.get(key));
if (replaceLast && replacedLast.add(key) && last != null) {
List<O> os = indexed.get(key);
os.remove(last); // ★ 扩展覆盖端点的同名操作
}
indexed.add(key, operation);
}
}
}
先加端点自己的操作(replaceLast=false),再加扩展的操作(replaceLast=true)。
replaceLast=true 时会把同 key 的最后一个删掉再追加,从而实现"扩展覆盖端点"。
replacedLast 这个 Set 保证:同一个 key 只会被替换一次。
如果扩展里有第二个同 key 的操作,就不再替换,于是 indexed.get(key) 会有两个元素,
随后被 assertNoDuplicateOperations 抓到:
throw new IllegalStateException("Unable to map duplicate endpoint operations: " + duplicates + " to "
+ endpointBean.getBeanName() + (extensions.isEmpty() ? "" : " (" + extensionBeanNames + ")"));
⑤ isInvocable()
protected boolean isInvocable(E endpoint) {
return !endpoint.getOperations().isEmpty();
}
一个端点如果所有操作都被 OperationFilter 过滤掉了(比如 access=NONE),
它就不会出现在 getEndpoints() 里。没有操作的端点等于不存在。
2.6 EndpointId:宽松得令人意外的相等性
private static final Pattern VALID_PATTERN = Pattern.compile("[a-zA-Z0-9.-]+");
private static final Pattern WARNING_PATTERN = Pattern.compile("[.-]+");
构造时三条断言(非空、只含合法字符、不以数字或大写字母开头)+ 一条 WARN。
注意矛盾点:VALID_PATTERN 允许 . 和 -,但一出现就打
"Endpoint ID 'xxx' contains invalid characters, please migrate to a valid format."——
"容忍旧写法但催促迁移",不是校验失败。loggedWarnings 是静态 HashSet,同一 value 只 WARN 一次。
相等性才是真正反直觉的地方:equals / hashCode 用的是
getAlphaNumerics(lowerCaseValue)——全小写、只保留 a-z0-9。于是
health == Health == he-alth == he.alth == hE-aL.tH
全部相等。实践中意味着 management.endpoint.HEALTH.access 与
management.endpoint.health.access 是同一个开关(前提是查找走 EndpointId 而非字面字符串)。
另两个工厂方法:of(Environment, String) 在
management.endpoints.migrate-legacy-ids=true 时做 value.replaceAll("[-.]+", "");
fromPropertyValue 只去 - 不去 .,且不保证结果合法(用于 include/exclude 的 * 通配)。
未核实:migrate-legacy-ids 我只在 EndpointId.of(Environment, String) 一处看到,
调用点是否覆盖全部条件判定未逐行核实(§10)。
2.7 ApiVersion:V2 与 V3 只差一个字段名
public enum ApiVersion implements Producible<ApiVersion> {
V2("application/vnd.spring-boot.actuator.v2+json"),
V3("application/vnd.spring-boot.actuator.v3+json");
public static final ApiVersion LATEST = ApiVersion.V3;
}
差异体现在 CompositeHealthDescriptor:
@JsonInclude(Include.NON_EMPTY)
public @Nullable Map<String, HealthDescriptor> getComponents() {
return (this.apiVersion == ApiVersion.V3) ? this.components : null;
}
@JsonInclude(Include.NON_EMPTY)
public @Nullable Map<String, HealthDescriptor> getDetails() {
return (this.apiVersion == ApiVersion.V2) ? this.components : null;
}
同一份数据,V2 叫 details,V3 叫 components。这就是 Spring Boot 2.2 改 JSON schema
时做的兼容:老客户端发 Accept: application/vnd.spring-boot.actuator.v2+json 还能拿到老格式。
注意 LATEST 是编译期常量 V3,没有运行时探测。
3. "可用"的判定:@ConditionalOnAvailableEndpoint
这是 Actuator 里最重要、也最容易被误解的一个条件注解。 它同时管两件事:能不能用(access)和 有没有暴露(exposure)。
3.1 注解定义
@Retention(RetentionPolicy.RUNTIME)
@Target({ ElementType.METHOD, ElementType.TYPE })
@Conditional(OnAvailableEndpointCondition.class)
public @interface ConditionalOnAvailableEndpoint {
@AliasFor(attribute = "endpoint") Class<?> value() default Void.class;
@AliasFor(attribute = "value") Class<?> endpoint() default Void.class;
EndpointExposure[] exposure() default {};
}
value / endpoint 互为别名(@AliasFor 双向)。当两者都是 Void.class 时,
从 @Bean 方法的返回类型推断:
Assert.state(metadata instanceof MethodMetadata && metadata.isAnnotated(Bean.class.getName()),
"EndpointCondition must be used on @Bean methods when the endpoint is not specified");
MethodMetadata methodMetadata = (MethodMetadata) metadata;
return ClassUtils.forName(methodMetadata.getReturnTypeName(), context.getClassLoader());
拿到目标类后找 @Endpoint:
MergedAnnotations annotations = MergedAnnotations.from(target, SearchStrategy.TYPE_HIERARCHY);
MergedAnnotation<Endpoint> endpoint = annotations.get(Endpoint.class);
if (endpoint.isPresent()) return endpoint;
MergedAnnotation<EndpointExtension> extension = annotations.get(EndpointExtension.class);
Assert.state(extension.isPresent(), "No endpoint is specified and the return type of the @Bean method is "
+ "neither an @Endpoint, nor an @EndpointExtension");
return getEndpointAnnotation(extension.getClass("endpoint")); // ★ 递归
递归是关键:对 @EndpointWebExtension(endpoint = HealthEndpoint.class),
它会跳到 HealthEndpoint 上去找 @Endpoint。所以给扩展 bean 加
@ConditionalOnAvailableEndpoint 等价于给端点本身加。
3.2 判定主流程
public ConditionOutcome getMatchOutcome(ConditionContext context, AnnotatedTypeMetadata metadata) {
...
EndpointId endpointId = EndpointId.of(environment, endpointAnnotation.getString("id"));
ConditionOutcome accessOutcome = getAccessOutcome(environment, endpointAnnotation, endpointId, message);
if (!accessOutcome.isMatch()) {
return accessOutcome; // ★ 短路
}
ConditionOutcome exposureOutcome = getExposureOutcome(context, conditionAnnotation, endpointAnnotation,
endpointId, message);
return (exposureOutcome != null) ? exposureOutcome : ConditionOutcome.noMatch(message.because("not exposed"));
}
两步:先 access,再 exposure,任一不过则不匹配。
3.3 Access 判定
private ConditionOutcome getAccessOutcome(Environment environment, MergedAnnotation<Endpoint> endpointAnnotation,
EndpointId endpointId, ConditionMessage.Builder message) {
Access defaultAccess = endpointAnnotation.getEnum("defaultAccess", Access.class);
Access access = getAccess(environment, endpointId, defaultAccess);
return new ConditionOutcome(access != Access.NONE,
message.because("the configured access for endpoint '%s' is %s".formatted(endpointId, access)));
}
判定极简:只要不是 NONE 就通过。READ_ONLY 也算通过——
它是在 §4.3 的 OperationFilter 层面把写操作滤掉的,不影响"端点存在与否"。
注意缓存:
private static final Map<Environment, EndpointAccessResolver> accessResolversCache = new ConcurrentReferenceHashMap<>();
private Access getAccess(Environment environment, EndpointId endpointId, Access defaultAccess) {
return accessResolversCache.computeIfAbsent(environment, PropertiesEndpointAccessResolver::new)
.accessFor(endpointId, defaultAccess);
}
ConcurrentReferenceHashMap 的 key 是 Environment、value 是 EndpointAccessResolver,
且 value 是软/弱引用(Spring 的 ConcurrentReferenceHashMap 默认 SOFT 级别)。
因为自动配置阶段会成千上万次调用这个条件,不缓存会反复 new 解析器并重新解析属性。
key 用 Environment 实例而非字符串,是因为同一个 JVM 可能有多个 ApplicationContext。
PropertiesEndpointAccessResolver 的解析顺序(四级):
public Access accessFor(EndpointId endpointId, Access defaultAccess) {
return this.accessCache.computeIfAbsent(endpointId,
(key) -> resolveAccess(endpointId.toLowerCaseString(), defaultAccess).cap(this.maxPermittedAccess));
}
private Access resolveAccess(String endpointId, Access defaultAccess) {
String accessKey = "management.endpoint.%s.access".formatted(endpointId);
String enabledKey = "management.endpoint.%s.enabled".formatted(endpointId);
Access access = this.properties.getProperty(accessKey, Access.class);
Boolean enabled = this.properties.getProperty(enabledKey, Boolean.class);
MutuallyExclusiveConfigurationPropertiesException.throwIfMultipleNonNullValuesIn((entries) -> {
entries.put(accessKey, access);
entries.put(enabledKey, enabled);
});
if (access != null) return access;
if (enabled != null) return (enabled) ? Access.UNRESTRICTED : Access.NONE;
return (this.endpointsDefaultAccess != null) ? this.endpointsDefaultAccess : defaultAccess;
}
整理成优先级表:
| 优先级 | 属性 | 语义 |
|---|---|---|
| 1 | management.endpoint.<id>.access | 直接给 NONE/READ_ONLY/UNRESTRICTED |
| 2 | management.endpoint.<id>.enabled(deprecated) | true→UNRESTRICTED,false→NONE |
| 3 | management.endpoints.access.default 或 management.endpoints.enabled-by-default(deprecated) | 全局默认 |
| 4 | @Endpoint(defaultAccess = ...) | 注解声明,默认 UNRESTRICTED |
1 和 2 互斥:同时配了就抛 MutuallyExclusiveConfigurationPropertiesException。
3 的两个属性也互斥,在 determineDefaultAccess 里同样处理。
这是一条很好的工程实践——迁移期的新旧属性不能并存,否则行为不可预测。
最后还有一个封顶:
this.maxPermittedAccess = properties.getProperty(
"management.endpoints.access.max-permitted", Access.class, Access.UNRESTRICTED);
...
.cap(this.maxPermittedAccess)
public Access cap(Access maxPermitted) {
return (ordinal() <= maxPermitted.ordinal()) ? this : maxPermitted;
}
Access 的 ordinal 是 NONE=0 < READ_ONLY=1 < UNRESTRICTED=2。
所以设 management.endpoints.access.max-permitted=read_only 后,
无论下面的端点怎么配,都不可能拿到 UNRESTRICTED。
这是给平台/运维团队留的"硬上限"——应用开发者无法自行突破。
3.4 Exposure 判定
private Set<EndpointExposure> getExposures(MergedAnnotation<ConditionalOnAvailableEndpoint> conditionAnnotation) {
EndpointExposure[] exposures = conditionAnnotation.getEnumArray("exposure", EndpointExposure.class);
return (exposures.length == 0) ? EnumSet.allOf(EndpointExposure.class)
: EnumSet.copyOf(Arrays.asList(exposures));
}
exposure 不写 = 要求"任一技术暴露即可"(allOf)。
写了 exposure = EndpointExposure.WEB = 必须 web 暴露。
EndpointExposure 枚举只有两个值,各自带默认 include:
public enum EndpointExposure {
JMX("health"),
WEB("health");
private final String[] defaultIncludes;
}
两个技术的默认 include 都只有 health。这就是为什么新应用开箱只有 /actuator/health。
贡献者集合:
private Set<EndpointExposureOutcomeContributor> getExposureOutcomeContributors(ConditionContext context) {
Environment environment = context.getEnvironment();
Set<EndpointExposureOutcomeContributor> contributors = exposureOutcomeContributorsCache.get(environment);
if (contributors == null) {
contributors = new LinkedHashSet<>();
contributors.add(new StandardExposureOutcomeContributor(environment, EndpointExposure.WEB));
if (environment.getProperty(JMX_ENABLED_KEY, Boolean.class, false)) { // spring.jmx.enabled
contributors.add(new StandardExposureOutcomeContributor(environment, EndpointExposure.JMX));
}
contributors.addAll(loadExposureOutcomeContributors(context.getClassLoader(), environment));
exposureOutcomeContributorsCache.put(environment, contributors);
}
return contributors;
}
三处细节:
- WEB 永远参与判定,JMX 要看
spring.jmx.enabled(默认 false)。 所以纯 Web 应用里 JMX 那个 contributor 根本不存在。 - 第三方可以通过
spring.factories的org.springframework.boot.actuate.autoconfigure.endpoint.condition.EndpointExposureOutcomeContributor键追加贡献者,构造器可以带Environment参数(ArgumentResolver.of(Environment.class, environment))。 - 又是
Environment作 key 的缓存。
StandardExposureOutcomeContributor:
StandardExposureOutcomeContributor(Environment environment, EndpointExposure exposure) {
this.exposure = exposure;
String name = exposure.name().toLowerCase(Locale.ROOT).replace('_', '-');
this.property = "management.endpoints." + name + ".exposure";
this.filter = new IncludeExcludeEndpointFilter<>(ExposableEndpoint.class, environment, this.property,
exposure.getDefaultIncludes());
}
@Override
public @Nullable ConditionOutcome getExposureOutcome(EndpointId endpointId, Set<EndpointExposure> exposures,
ConditionMessage.Builder message) {
if (exposures.contains(this.exposure) && this.filter.match(endpointId)) {
return ConditionOutcome.match(message.because("marked as exposed by a '" + this.property + "' property"));
}
return null;
}
属性名由枚举名小写生成:WEB → web,JMX → jmx,
即 management.endpoints.web.exposure.include/exclude 与 management.endpoints.jmx.exposure.*。
3.5 IncludeExcludeEndpointFilter:include 空时的语义
public final boolean match(EndpointId endpointId) {
return isIncluded(endpointId) && !isExcluded(endpointId);
}
private boolean isIncluded(EndpointId endpointId) {
if (this.include.isEmpty()) {
return this.defaultIncludes.matches(endpointId); // ★ 回退到默认 includes
}
return this.include.matches(endpointId);
}
private boolean isExcluded(EndpointId endpointId) {
if (this.exclude.isEmpty()) return false;
return this.exclude.matches(endpointId);
}
不对称:include 空 → 用默认(health);exclude 空 → 不排除任何人。
所以:
management.endpoints.web.exposure.include=health,info→ 只有这两个management.endpoints.web.exposure.exclude=health→ 只有 health 被排除, 剩下仍然只有 health 被排除吗?不对:include 为空 → 只有默认health入选, 再被 exclude 掉 → 一个都不剩。
这是个反直觉结论:只写 exclude=health 不会"暴露除 health 之外的一切",而是什么都不暴露。
要暴露除某几个之外的一切,必须写 include=*。
EndpointPatterns 的通配符处理:
for (String pattern : patterns) {
if ("*".equals(pattern)) matchesAll = true;
else endpointIds.add(EndpointId.fromPropertyValue(pattern));
}
boolean matches(EndpointId endpointId) { return this.matchesAll || this.endpointIds.contains(endpointId); }
只有 *,没有 foo* 这样的前缀通配。想做前缀匹配得自己实现
EndpointExposureOutcomeContributor。
match(E endpoint) 还有一个"放行"逻辑:
@Override
public boolean match(E endpoint) {
if (!this.endpointType.isInstance(endpoint)) {
return true; // Leave non-matching types for other filters
}
return match(endpoint.getEndpointId());
}
类型不匹配时返回 true(放行),交给别的 filter 处理。
这是过滤器链里常见的"我只管我认识的类型"。
4. 暴露层:Web 与 JMX
4.1 WebEndpointAutoConfiguration 的 bean 清单
@AutoConfiguration(after = EndpointAutoConfiguration.class)
@ConditionalOnWebApplication
@EnableConfigurationProperties(WebEndpointProperties.class)
public final class WebEndpointAutoConfiguration { ... }
它注册 8 个 bean(含内部类里的 3 个)。逐一说明它们各解决什么问题:
| bean | 类型 | 作用 |
|---|---|---|
webEndpointPathMapper | PathMapper | 把 management.endpoints.web.path-mapping.<id> 应用到路径 |
endpointMediaTypes | EndpointMediaTypes | 默认 EndpointMediaTypes.DEFAULT(application/json + vnd.spring-boot.actuator.v3+json 等) |
webEndpointDiscoverer | WebEndpointDiscoverer | 发现 @Endpoint + @EndpointWebExtension |
controllerEndpointDiscoverer | ControllerEndpointDiscoverer | 老的 @ControllerEndpoint(@Deprecated(removal)) |
pathMappedEndpoints | PathMappedEndpoints | 供 Spring Security 拿"所有已映射路径" |
webExposeExcludePropertyEndpointFilter | IncludeExcludeEndpointFilter<ExposableWebEndpoint> | 暴露过滤 |
webAccessPropertiesOperationFilter | OperationFilter<WebOperation> | 按 Access 过滤操作 |
servletEndpointDiscoverer(内部类) | ServletEndpointDiscoverer | @ServletEndpoint(同样 deprecated) |
webEndpointDiscoverer 的签名有 7 个参数,ObjectProvider + orderedStream() 是标准写法:
@Bean
@ConditionalOnMissingBean(WebEndpointsSupplier.class)
WebEndpointDiscoverer webEndpointDiscoverer(ParameterValueMapper parameterValueMapper,
EndpointMediaTypes endpointMediaTypes, ObjectProvider<PathMapper> endpointPathMappers,
ObjectProvider<AdditionalPathsMapper> additionalPathsMappers,
ObjectProvider<OperationInvokerAdvisor> invokerAdvisors,
ObjectProvider<EndpointFilter<ExposableWebEndpoint>> endpointFilters,
ObjectProvider<OperationFilter<WebOperation>> operationFilters) { ... }
注意 @ConditionalOnMissingBean(WebEndpointsSupplier.class):
条件是接口 WebEndpointsSupplier,bean 类型是实现类 WebEndpointDiscoverer。
这是"按类型而非按实现"注册条件的正确写法。
4.2 PathMappedEndpoints 与那个"启动失败"
@Bean
@ConditionalOnMissingBean
PathMappedEndpoints pathMappedEndpoints(Collection<EndpointsSupplier<?>> endpointSuppliers) {
String basePath = this.properties.getBasePath();
PathMappedEndpoints pathMappedEndpoints = new PathMappedEndpoints(basePath, endpointSuppliers);
if ((!StringUtils.hasText(basePath) || "/".equals(basePath))
&& ManagementPortType.get(this.applicationContext.getEnvironment()) == ManagementPortType.SAME) {
assertHasNoRootPaths(pathMappedEndpoints);
}
return pathMappedEndpoints;
}
private void assertHasNoRootPaths(PathMappedEndpoints endpoints) {
for (PathMappedEndpoint endpoint : endpoints) {
if (endpoint instanceof ExposableWebEndpoint webEndpoint) {
Assert.state(!isMappedToRootPath(webEndpoint),
() -> "Management base path and the '" + webEndpoint.getEndpointId()
+ "' actuator endpoint are both mapped to '/' "
+ "on the server port which will block access to other endpoints. "
+ "Please use a different path for management endpoints or map them to a "
+ "dedicated management port.");
}
}
}
这就回答了章首第三个问题:management.endpoints.web.base-path=/ 且管理端口同端口时启动失败。
原因是:如果 base path 是 /,任何映射在 / 的端点(如 health 组配了 additionalPath=server:/healthz
之外,更典型的是自己写的 @Endpoint(id="") 或某些端点)会独占根路径,
把 /actuator 之外的请求全部吃掉。Spring Boot 选择直接拒绝启动而不是静默出错。
注意判定条件里 ManagementPortType.SAME 是必要条件——
如果管理端口独立(§5),即使 base path 是 / 也只影响管理服务器,不影响业务,所以放行。
4.3 OperationFilter.byAccess:READ_ONLY 在这里生效
static <O extends Operation> OperationFilter<O> byAccess(EndpointAccessResolver accessResolver) {
return (operation, endpointId, defaultAccess) -> {
Access access = accessResolver.accessFor(endpointId, defaultAccess);
return switch (access) {
case NONE -> false;
case READ_ONLY -> operation.getType() == OperationType.READ;
case UNRESTRICTED -> true;
};
};
}
这是 Access.READ_ONLY 真正起作用的地方:端点"存在"(§3.3 只判 != NONE),
但写操作被过滤。前面 §2.5 第⑤步 isInvocable() 要求操作非空——
所以一个只有写操作的端点(如 shutdown)在 READ_ONLY 下会整体消失。
shutdown 端点只有一个 @WriteOperation,所以:
management.endpoint.shutdown.access=none → 端点不存在
management.endpoint.shutdown.access=read_only → 端点存在但没操作 → isInvocable=false → 端点不存在
management.endpoint.shutdown.access=unrestricted → 端点存在,可 POST
read_only 对 shutdown 等价于 none。这个推论来自源码,我未实测(列在 §10)。
过滤器在 EndpointDiscoverer 里的调用点是 LambdaSafe:
return LambdaSafe.callbacks(OperationFilter.class, this.operationFilters, operation)
.invokeAnd((f) -> f.match(operation, endpointId, defaultAccess))
.getResult();
LambdaSafe 的作用是:即使某个 filter 抛异常,也只记日志不让整个发现过程崩掉
(第 14 章讲过它的实现)。
4.4 JMX 侧
@AutoConfiguration(after = { JmxAutoConfiguration.class, EndpointAutoConfiguration.class })
@EnableConfigurationProperties({ JmxEndpointProperties.class, JmxProperties.class })
@ConditionalOnBooleanProperty("spring.jmx.enabled")
public final class JmxEndpointAutoConfiguration { ... }
spring.jmx.enabled 默认 false(与 §3.4 里 JMX_ENABLED_KEY 的判定一致)。
注意 @ConditionalOnBooleanProperty 无 matchIfMissing,所以不写就是 false。
一个容易被忽略的 bean:
@Bean
static LazyInitializationExcludeFilter eagerlyInitializeJmxEndpointExporter() {
return LazyInitializationExcludeFilter.forBeanTypes(JmxEndpointExporter.class);
}
static 方法 + LazyInitializationExcludeFilter:
当开了 spring.main.lazy-initialization=true 时,JmxEndpointExporter 必须提前初始化,
否则 MBean 永远注册不上去(没人触发它)。这是"懒初始化白名单"机制,
与第 23 章 eagerJpaMetamodelCacheCleanup 是同一套思路(按类型排除)。
DefaultEndpointObjectNameFactory 用
ObjectUtils.getIdentityHexString(this.applicationContext) 作为 contextId,
保证同一 JVM 内多个 ApplicationContext 的 MBean 名不冲突——
这与子上下文(§5)场景直接相关。
4.5 Jackson 3 与 Jackson 2 的双自动配置
两个自动配置同时注册在 .imports,靠 @ConditionalOnClass 二选一:
| 类 | 条件类 | 属性名 |
|---|---|---|
JacksonEndpointAutoConfiguration | tools.jackson.databind.json.JsonMapper(Jackson 3) | management.endpoints.jackson.isolated-json-mapper |
Jackson2EndpointAutoConfiguration(@Deprecated(since="4.0.0", forRemoval)) | ObjectMapper + Jackson2ObjectMapperBuilder | management.endpoints.jackson2.isolated-object-mapper |
两者都 matchIfMissing = true,且都独立 new 一个 mapper(默认 Include.NON_NULL)。
隔离的意义:Actuator 的输出格式必须稳定,不能因为用户改了主应用的 ObjectMapper 就跟着变。
新 mapper 通过 EndpointJsonMapperWebMvcConfigurer 只塞进端点链路的
JacksonJsonHttpMessageConverter,不替换全局。属性名的"去 2"正是版本迁移的痕迹。
5. 管理端口子上下文:一个凭空出现的 ApplicationContext
5.1 端口类型判定
// ManagementPortType.java
public static ManagementPortType get(Environment environment) {
Integer managementPort = getPortProperty(environment, "management.server.");
if (managementPort != null && managementPort < 0) {
return DISABLED;
}
Integer serverPort = getPortProperty(environment, "server.");
return ((managementPort == null
|| (serverPort == null && managementPort.equals(8080))
|| (managementPort != 0 && managementPort.equals(serverPort))) ? SAME : DIFFERENT);
}
三个返回值,判定逻辑拆开看:
| 条件 | 结果 |
|---|---|
management.server.port < 0 | DISABLED |
management.server.port 未配置 | SAME |
server.port 未配置 且 management.server.port == 8080 | SAME |
management.server.port != 0 且等于 server.port | SAME |
| 其他 | DIFFERENT |
两个特例:
management.server.port=0恒为DIFFERENT(即使server.port也是 0)。 因为0表示"随机端口",两个随机端口不可能相同。代码用managementPort != 0显式排除。server.port未配 +management.server.port=8080→ SAME。 这是为了不破坏"只写management.server.port=8080"这种历史配置—— 8080 恰好是server.port的默认值,写它等于没改。
未核实:server.port 的默认值 8080 在 @ConfigurationProperties 字段里而不在 Environment 中,
所以 serverPort == null 这个分支确实会发生;三条路径我推演后结论自洽,但未写测试验证(§10)。
逻辑自洽,但我未写测试验证。
5.2 OnManagementPortCondition:为什么非 Web 应用不匹配
public ConditionOutcome getMatchOutcome(ConditionContext context, AnnotatedTypeMetadata metadata) {
ConditionMessage.Builder message = ConditionMessage.forCondition("Management Port");
if (!isWebApplicationContext(context)) {
return ConditionOutcome.noMatch(message.because("non web application context"));
}
...
}
private boolean isWebApplicationContext(ConditionContext context) {
ResourceLoader resourceLoader = context.getResourceLoader();
if (resourceLoader instanceof ConfigurableReactiveWebApplicationContext) {
return true;
}
if (!ClassUtils.isPresent(CLASS_NAME_WEB_APPLICATION_CONTEXT, context.getClassLoader())) {
return false;
}
return resourceLoader instanceof WebApplicationContext;
}
这里有一个顺序上的讲究:先判 ConfigurableReactiveWebApplicationContext
(它在 spring-boot-web 模块里,是 Boot 自己的类,无 WebFlux 依赖也能加载),
再用字符串 "org.springframework.web.context.WebApplicationContext" 探测 servlet API 是否存在,
最后才 instanceof。写成:
resourceLoader instanceof WebApplicationContext // 直接这么写
会在 classpath 没有 spring-web 时抛 NoClassDefFoundError(instanceof 需要加载类型)。
所以必须先 ClassUtils.isPresent。这是"可选依赖用类名探测"定律的又一例
(第 22 章 DataSourceBuilder.lookupPooled 用字符串类名探测连接池,同一套路)。
5.3 ManagementContextAutoConfiguration:两条互斥分支
@AutoConfiguration
@AutoConfigureOrder(Ordered.LOWEST_PRECEDENCE)
public final class ManagementContextAutoConfiguration { ... }
LOWEST_PRECEDENCE——它必须排在最后,因为它要读 server.port / management.server.port,
而这些值可能来自任何地方;更重要的是它要等所有 Web 自动配置决定完。
分支 A:SAME
@Configuration(proxyBeanMethods = false)
@ConditionalOnManagementPort(ManagementPortType.SAME)
static class SameManagementContextConfiguration implements SmartInitializingSingleton {
@Override
public void afterSingletonsInstantiated() {
verifySslConfiguration();
verifyAddressConfiguration();
if (this.environment instanceof ConfigurableEnvironment configurableEnvironment) {
addLocalManagementPortPropertyAlias(configurableEnvironment);
}
}
private void verifySslConfiguration() {
Boolean enabled = this.environment.getProperty("management.server.ssl.enabled", Boolean.class, false);
Assert.state(!enabled, "Management-specific SSL cannot be configured as the management "
+ "server is not listening on a separate port");
}
private void verifyAddressConfiguration() {
Object address = this.environment.getProperty("management.server.address");
Assert.state(address == null, "Management-specific server address cannot be configured as the management "
+ "server is not listening on a separate port");
}
}
三件事:
- 校验:同端口时不允许配
management.server.ssl.enabled=true或management.server.address。 这是"配置与运行时语义冲突"的提前拦截——同端口只有一个服务器,无法同时有两个 SSL 配置。 注意用的是Assert.state(IllegalStateException)而不是失败分析器, 所以报错是裸的IllegalStateException,没有漂亮的诊断文本。 local.management.port别名:
static class LocalManagementPortPropertySource extends EnumerablePropertySource<Object> implements PropertySourceInfo {
private static final Map<String, String> PROPERTY_MAPPINGS =
Map.of("local.management.port", "local.server.port");
...
@Override
public boolean isImmutable() { return true; }
}
addLast 加到属性源链末尾,因为 local.server.port 只有在服务器启动后才由
WebServerApplicationContext 写进去,这里只是做一个"转发",不需要优先级。
implements PropertySourceInfo 且 isImmutable() == true 是给 Actuator 的 env 端点用的标记
(第 24 章讲过 PropertySourceInfo)。
为什么用 SmartInitializingSingleton 而不是 ApplicationListener?
因为它要在所有单例都建好之后执行,且不需要事件。同端口场景下这个别名对业务代码有用
(@Value("${local.management.port}"))。
@EnableManagementContext(ManagementContextType.SAME)—— 见 §5.7。
分支 B:DIFFERENT
@Configuration(proxyBeanMethods = false)
@ConditionalOnManagementPort(ManagementPortType.DIFFERENT)
@EnableConfigurationProperties(ManagementServerProperties.class)
static class DifferentManagementContextConfiguration {
@Bean
static ChildManagementContextInitializer childManagementContextInitializer(
ManagementContextFactory managementContextFactory, AbstractApplicationContext parentContext) {
return new ChildManagementContextInitializer(managementContextFactory, parentContext);
}
}
static bean 方法:为了在 BeanFactoryPostProcessor 阶段之前就能被实例化
(它是 SmartLifecycle,需要尽早注册)。同时它依赖 ManagementContextFactory——
这个 bean 由谁提供?见 §5.4。
5.4 ManagementContextFactory:由 Web 服务器模块提供
grep -rn "new ManagementContextFactory" --include=*.java .
输出 6 处,全部在 Web 服务器模块:
| 模块 | 类 | 参数 |
|---|---|---|
spring-boot-tomcat | TomcatServletManagementContextAutoConfiguration | SERVLET, ServletWebServerFactory.class |
spring-boot-tomcat | TomcatReactiveManagementContextAutoConfiguration | REACTIVE, ReactiveWebServerFactory.class |
spring-boot-jetty | JettyServletManagementContextAutoConfiguration | SERVLET, ServletWebServerFactory.class |
spring-boot-jetty | JettyReactiveManagementContextAutoConfiguration | REACTIVE, ReactiveWebServerFactory.class |
spring-boot-reactor-netty | NettyReactiveManagementContextAutoConfiguration | REACTIVE, ReactiveWebServerFactory.class |
spring-boot-reactor-netty | NettyReactiveManagementChildContextConfiguration | REACTIVE, ReactiveWebServerFactory.class |
Tomcat servlet 那个的完整定义:
@AutoConfiguration
@ConditionalOnClass(ManagementContextFactory.class)
@ConditionalOnWebApplication(type = Type.SERVLET)
@ConditionalOnManagementPort(ManagementPortType.DIFFERENT)
public final class TomcatServletManagementContextAutoConfiguration {
@Bean
static ManagementContextFactory servletWebChildContextFactory() {
return new ManagementContextFactory(WebApplicationType.SERVLET, ServletWebServerFactory.class,
TomcatWebServerConfiguration.class, TomcatServletWebServerAutoConfiguration.class);
}
}
这是"自动配置跨模块协作"的教科书案例:
spring-boot-actuator-autoconfigure 定义接口(ManagementContextFactory),
spring-boot-tomcat 提供实现,两者只通过 ManagementContextFactory 这个类型耦合,
且 @ConditionalOnClass(ManagementContextFactory.class) 保证 actuator 不在时 Tomcat 侧静默。
构造函数第三个参数 autoConfigurationClasses 是变长数组,
Tomcat 传了 TomcatWebServerConfiguration.class 和 TomcatServletWebServerAutoConfiguration.class
——子上下文里要注册哪些自动配置类,由 Web 服务器模块显式指定。
ManagementContextFactory 的创建逻辑:
public ConfigurableApplicationContext createManagementContext(ApplicationContext parentContext) {
Environment parentEnvironment = parentContext.getEnvironment();
ConfigurableEnvironment childEnvironment = ApplicationContextFactory.DEFAULT.createEnvironment(this.webApplicationType);
if (parentEnvironment instanceof ConfigurableEnvironment configurableParentEnvironment) {
postProcessChildEnvironment(childEnvironment, configurableParentEnvironment);
}
ConfigurableApplicationContext managementContext = ApplicationContextFactory.DEFAULT.create(this.webApplicationType);
managementContext.setEnvironment(childEnvironment);
managementContext.setParent(parentContext);
return managementContext;
}
private void postProcessChildEnvironment(ConfigurableEnvironment childEnvironment,
ConfigurableEnvironment parentEnvironment) {
childEnvironment.setConversionService((parentEnvironment).getConversionService());
SystemEnvironmentPropertySourceEnvironmentPostProcessor.postProcessEnvironment(childEnvironment, parentEnvironment);
}
三件事:共享 ConversionService、把父 Environment 的系统环境变量属性源替换成
SystemEnvironmentPropertySource 包装(支持 SERVER_PORT 这种下划线→点的宽松绑定,第 6 章)、
设置父子关系。
关键:子上下文的 Environment 是全新创建的,不是 parentContext.getEnvironment()。
它只继承了 ConversionService 和系统环境变量处理。
所以 application.properties 里的属性怎么进去的?答案是——
@ConfigurationProperties 绑定时会走 Environment,而子 Environment 是空的……
这不对。实际上,我在源码里没有找到把父 Environment 的属性源复制给子 Environment 的代码。
让我重新看一遍:ApplicationContextFactory.DEFAULT.createEnvironment(webApplicationType)
创建一个空 Environment;postProcessChildEnvironment 只做了两件事。
然后 ChildManagementContextInitializer.createManagementContext():
protected final ConfigurableApplicationContext createManagementContext() {
ConfigurableApplicationContext managementContext = this.managementContextFactory.createManagementContext(this.parentContext);
managementContext.setId(this.parentContext.getId() + ":management");
if (managementContext instanceof ConfigurableWebServerApplicationContext webServerApplicationContext) {
webServerApplicationContext.setServerNamespace("management");
}
if (managementContext instanceof DefaultResourceLoader resourceLoader) {
resourceLoader.setClassLoader(this.parentContext.getClassLoader());
}
CloseManagementContextListener.addIfPossible(this.parentContext, managementContext);
return managementContext;
}
还是没有属性拷贝。我据此推断:子上下文的 Environment 是通过 Spring Framework 的
AbstractApplicationContext.setParent() → getEnvironment() 的 PropertySourcesPlaceholderConfigurer
或 Environment 的 parent 链解析的?不,Spring 的 Environment 没有 parent 概念。
这里我必须诚实:我没有找到子容器如何获得 application.properties 属性的代码。
从行为上推测(Boot 的管理端口确实能读到 management.server.port),
一定存在某种机制,可能在以下位置之一(均未核实):
ConfigurableApplicationContext.setParent()在refresh()时通过getInternalParentBeanFactory()让 bean 查找走父容器,但属性不走。PropertySourcesPlaceholderConfigurer/PropertyPlaceholderAutoConfiguration被注册进子上下文(确实注册了,见下),它读的是子 Environment。- Spring Framework 的
AbstractEnvironment可能在上游有 merge 逻辑。
这是本章最该实测的一点,我在 §10 列为第 1 项,并给出断点位置。
(补充:我倾向认为子 Environment 的 PropertySources 在
ApplicationContextFactory.DEFAULT.createEnvironment() 里就已经包含了
configurationProperties 之类,但 createEnvironment 的实现在 ApplicationContextFactory
接口里,我未在本仓库找到 DEFAULT 常量的实现体。
ApplicationContextFactory 位于 core/spring-boot,DEFAULT 是 ApplicationContextFactory 的静态字段,
其实现应该是 ApplicationContextFactory.ofContextClass(...) 组合。未核实。)
5.5 ChildManagementContextInitializer:一个 SmartLifecycle
class ChildManagementContextInitializer implements BeanRegistrationAotProcessor, SmartLifecycle {
@Override
public void start() {
if (!(this.parentContext instanceof WebServerApplicationContext)) {
return; // ★ 父容器不是 Web 服务器容器就直接返回
}
ConfigurableApplicationContext managementContext = this.managementContext;
if (managementContext == null) {
managementContext = createManagementContext();
registerBeans(managementContext);
managementContext.refresh(); // ★ 真正的启动点
this.managementContext = managementContext;
}
else {
managementContext.start();
}
}
@Override
public int getPhase() {
return WebServerApplicationContext.GRACEFUL_SHUTDOWN_PHASE - 512;
}
}
三个要点:
start()里refresh():子容器是在父容器的 Lifecycle 启动阶段被刷新的, 不是一个独立的SpringApplication.run()。它没有ApplicationRunner、 没有SpringApplicationRunListener、没有 banner、没有ApplicationStartedEvent。 ——这解释了为什么管理端口子容器里@PostConstruct会跑,但ApplicationRunner不会。- phase =
GRACEFUL_SHUTDOWN_PHASE - 512:比优雅关闭早 512。 我未核实GRACEFUL_SHUTDOWN_PHASE的具体数值(它在WebServerApplicationContext里), 但方向明确:管理服务器要比主服务器先启动、后关闭吗?start()按 phase 升序,值越小越先启动 → 管理服务器先启动。stop()按 phase 降序 → 管理服务器后关闭。 即:管理端口先于业务端口可用,业务端口先于管理端口下线。这个顺序是合理的。 isRunning()/stop()的幂等:stop()里判断if (this.parentContext.isClosed()) managementContext.close(); else managementContext.stop();——父容器已关闭则彻底关掉子容器,否则只 stop(可以被再次 start)。
registerBeans:
private void registerBeans(ConfigurableApplicationContext managementContext) {
if (this.applicationContextInitializer != null) {
this.applicationContextInitializer.initialize(managementContext); // AOT 路径
return;
}
Assert.isInstanceOf(AnnotationConfigRegistry.class, managementContext);
AnnotationConfigRegistry registry = (AnnotationConfigRegistry) managementContext;
this.managementContextFactory.registerWebServerFactoryBeans(this.parentContext, managementContext, registry);
registry.register(EnableChildManagementContextConfiguration.class, PropertyPlaceholderAutoConfiguration.class);
if (isLazyInitialization()) {
managementContext.addBeanFactoryPostProcessor(new LazyInitializationBeanFactoryPostProcessor());
}
}
只注册两个 @Configuration:EnableChildManagementContextConfiguration(导入所有
@ManagementContextConfiguration(CHILD))和 PropertyPlaceholderAutoConfiguration
(${} 占位符解析)。没有注册 @EnableAutoConfiguration——
所以子容器里不会跑完整的自动配置,只跑 Web 相关的那一小撮。
这是"管理端口轻量"的根本原因。
懒初始化的传播:
private boolean isLazyInitialization() {
List<BeanFactoryPostProcessor> postProcessors = this.parentContext.getBeanFactoryPostProcessors();
return postProcessors.stream().anyMatch(LazyInitializationBeanFactoryPostProcessor.class::isInstance);
}
通过检查父容器的 BFPP 列表里有没有 LazyInitializationBeanFactoryPostProcessor 来判断,
而不是读 spring.main.lazy-initialization 属性。因为父容器的决定已经物化成 BFPP 了。
registerWebServerFactoryBeans:
public void registerWebServerFactoryBeans(ApplicationContext parentContext,
ConfigurableApplicationContext managementContext, AnnotationConfigRegistry registry) {
if (this.autoConfigurationClasses != null && this.autoConfigurationClasses.length > 0) {
registry.register(this.autoConfigurationClasses); // Tomcat 传的两个类
}
registerWebServerFactoryFromParent(parentContext, managementContext);
}
private void registerWebServerFactoryFromParent(ApplicationContext parentContext,
ConfigurableApplicationContext managementContext) {
try {
if (managementContext.getBeanFactory() instanceof BeanDefinitionRegistry registry) {
registry.registerBeanDefinition("ManagementContextWebServerFactory",
new RootBeanDefinition(determineWebServerFactoryClass(parentContext)));
}
}
catch (NoSuchBeanDefinitionException ex) {
// Ignore and assume auto-configuration
}
}
private Class<?> determineWebServerFactoryClass(ApplicationContext parent) throws NoSuchBeanDefinitionException {
Class<?> factoryClass = parent.getBean(this.webServerFactoryClass).getClass();
if (cannotBeInstantiated(factoryClass)) {
throw new FatalBeanException("ManagementContextWebServerFactory implementation " + factoryClass.getName()
+ " cannot be instantiated. To allow a separate management port to be used, a top-level class "
+ "or static inner class should be used instead");
}
return factoryClass;
}
private boolean cannotBeInstantiated(Class<?> factoryClass) {
return factoryClass.isLocalClass()
|| (factoryClass.isMemberClass() && !Modifier.isStatic(factoryClass.getModifiers()))
|| factoryClass.isAnonymousClass();
}
这里是"从父容器偷类型":parent.getBean(ServletWebServerFactory.class).getClass()
拿到父容器里实际用的工厂实现类(比如 TomcatServletWebServerFactory),
然后以 RootBeanDefinition 的形式注册到子容器,bean 名固定为 "ManagementContextWebServerFactory"。
为什么要这样?因为子容器没有跑自动配置,不知道该 new 哪个 ServletWebServerFactory。
从父容器"借"一个同样的类型,保证管理服务器和主服务器同构(都是 Tomcat)。
cannotBeInstantiated 的三种情况:局部类、非静态内部类、匿名类。
这三种都无法用无参构造器反射实例化,所以显式报错并给出修复建议
("用顶层类或静态内部类")。这是一个提前把反射失败转成可读错误的好例子。
5.6 AOT:子容器也要参与
ChildManagementContextInitializer 同时是 BeanRegistrationAotProcessor。
processAheadOfTime 判定"我是父容器里的那个我"
(registeredBean.getBeanFactory().equals(parentBeanFactory))后,
在编译期就把子容器建出来并 refresh,交给 ApplicationContextAotGenerator,
并用 generationContext.withName("Management") 让生成的类带 Management 前缀避免重名。
产物是一个 ApplicationContextInitializer,运行时通过
withApplicationContextInitializer(...) 装回,于是 §5.5 里 registerBeans 的第一条分支生效。
这解释了为什么 Native Image 下管理端口也能工作——不是运行时反射建容器,而是编译期生成好了初始化器。
5.7 ManagementContextConfiguration 的导入
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Import(ManagementContextConfigurationImportSelector.class)
@interface EnableManagementContext {
ManagementContextType value();
}
ManagementContextConfigurationImportSelector 是 DeferredImportSelector
(延迟导入,排在所有常规 @Import 之后)+ BeanClassLoaderAware,
自身 @Order(Ordered.LOWEST_PRECEDENCE)。
public String[] selectImports(AnnotationMetadata metadata) {
ManagementContextType contextType = (ManagementContextType) attributes.get("value");
List<ManagementConfiguration> configurations = getConfigurations();
OrderComparator.sort(configurations);
List<String> names = new ArrayList<>();
for (ManagementConfiguration configuration : configurations) {
if (configuration.getContextType() == ManagementContextType.ANY
|| configuration.getContextType() == contextType) {
names.add(configuration.getClassName());
}
}
return StringUtils.toStringArray(names);
}
protected List<String> loadFactoryNames() {
return ImportCandidates.load(ManagementContextConfiguration.class, this.classLoader).getCandidates();
}
ImportCandidates 是 Spring Boot 自己的 META-INF/spring/<全限定类名>.imports 加载器
(第 3 章讲过)。这里的全限定类名是
org.springframework.boot.actuate.autoconfigure.web.ManagementContextConfiguration。
仓库里 8 个模块贡献了这个文件:
spring-boot-jersey → 4 个
spring-boot-jetty → 2 个
spring-boot-reactor-netty→ 1 个
spring-boot-security → 1 个
spring-boot-servlet → 2 个
spring-boot-tomcat → 2 个
spring-boot-webflux → 2 个
spring-boot-webmvc → 2 个
合计 16 个候选类。它们的 @ManagementContextConfiguration(value = ...) 决定归属:
ANY(两种都用)、CHILD(仅子容器)、或默认 SAME(仅同端口)。
举例,WebMvcEndpointChildContextConfiguration:
@ManagementContextConfiguration(value = ManagementContextType.CHILD, proxyBeanMethods = false)
@ConditionalOnWebApplication(type = Type.SERVLET)
@ConditionalOnClass(DispatcherServlet.class)
@EnableWebMvc
class WebMvcEndpointChildContextConfiguration { ... }
排序规则有个陷阱,Javadoc 明确写了:
ManagementContextConfigurationclasses can be ordered using@Order. Ordering by implementingOrderedis not supported and will have no effect.
因为 readOrder 只读 @Order 注解:
private int readOrder(AnnotationMetadata annotationMetadata) {
Map<String, @Nullable Object> attributes = annotationMetadata.getAnnotationAttributes(Order.class.getName());
Integer order = (attributes != null) ? (Integer) attributes.get("value") : null;
return (order != null) ? order : Ordered.LOWEST_PRECEDENCE;
}
用 SimpleMetadataReaderFactory 读字节码(不加载类),
这是避免"为了排序而把 16 个配置类全加载进来"的优化。
5.8 子容器里的 DispatcherServlet:四个 false
DispatcherServlet dispatcherServlet = new DispatcherServlet();
// Ensure the parent configuration does not leak down to us
dispatcherServlet.setDetectAllHandlerAdapters(false);
dispatcherServlet.setDetectAllHandlerExceptionResolvers(false);
dispatcherServlet.setDetectAllHandlerMappings(false);
dispatcherServlet.setDetectAllViewResolvers(false);
默认 DispatcherServlet 会从容器含祖先容器里找所有 HandlerMapping/HandlerAdapter/
HandlerExceptionResolver/ViewResolver;子容器不能把业务的 MVC 基础设施吸进来
(否则管理端口会暴露业务的 Controller)。关掉自动探测后改用
CompositeHandlerMapping/CompositeHandlerAdapter/CompositeHandlerExceptionResolver,
且必须以 DispatcherServlet.HANDLER_MAPPING_BEAN_NAME 等约定 bean 名注册。
这是一个"把搜索范围缩小到本容器"的技巧。
5.9 管理服务器的定制:先抄父,再清空错误页,再改
ManagementWebServerFactoryCustomizer.customize()(getOrder() = 0)三步,顺序有语义:
customizeSameAsParentContext(factory):去父容器找同类型定制器应用一遍 (注释原文:"so e.g. the access logs go to the same place")。factory.setErrorPages(Collections.emptySet()):清空错误页——第 1 步可能把业务的/error页面带过来,而管理服务器有自己的ManagementErrorEndpoint。- 设置
port/ssl/serverHeader(取父ServerProperties的,与主服务器一致)/address。
AccessLogCustomizer 是访问日志前缀的基类(getOrder() = 1,比上面晚执行):
customizePrefix 用 startsWith 判断保证幂等(重复调用不叠加前缀)。
5.10 失败传播与关闭传播
private static class CloseManagementContextListener implements ApplicationListener<ApplicationEvent> {
@Override
public void onApplicationEvent(ApplicationEvent event) {
if (event instanceof ApplicationFailedEvent applicationFailedEvent) {
onApplicationFailedEvent(applicationFailedEvent);
}
}
private void onApplicationFailedEvent(ApplicationFailedEvent event) {
propagateCloseIfNecessary(event.getApplicationContext());
}
private void propagateCloseIfNecessary(@Nullable ApplicationContext applicationContext) {
if (applicationContext == this.parentContext) {
this.childContext.close();
}
}
}
只有父容器失败时才关子容器(判断 == this.parentContext)。
如果子容器自己失败,父容器由 SpringApplication 的错误处理接管。
5.11 两个 basePath 的清洗规则不一致
ManagementServerProperties.cleanBasePath:strip() → 补前导 / → 去尾部 /(自动补)。
WebEndpointProperties.setBasePath:先 Assert.isTrue(basePath.isEmpty() || basePath.startsWith("/"))
——必须以 / 开头或是空串,不补。两者都不去中间的重复斜杠。
这是有意的:管理服务器级的 basePath 宽松,端点级的严格。
5.12 子上下文的 bean 可见性小结
父容器(业务)
├── 业务 Controller、Service、Repository
├── 所有自动配置
├── HealthEndpoint + 所有 HealthContributor + HealthContributorRegistry
├── ChildManagementContextInitializer(SmartLifecycle)
└── ManagementContextFactory
│
│ setParent()
▼
子容器(:management)
├── ManagementContextWebServerFactory(从父偷来的类型)
├── DispatcherServlet(detectAll* 全 false)
├── CompositeHandlerMapping / Adapter / ExceptionResolver
├── WebMvcEndpointHandlerMapping(从父容器发现 ExposableWebEndpoint)
├── ManagementErrorEndpoint
└── 安全过滤器(从父容器借 springSecurityFilterChain)
端点 bean 在父容器,HandlerMapping 在子容器。
WebEndpointsSupplier 用 beanNamesForAnnotationIncludingAncestors
(§2.5)从父容器找到端点,在子容器里建映射。
这是"一份端点定义,两个服务器暴露"的实现方式。
安全过滤器的借用(ServletManagementChildContextConfiguration):
@Bean
Filter springSecurityFilterChain(HierarchicalBeanFactory beanFactory) {
BeanFactory parent = beanFactory.getParentBeanFactory();
Assert.state(parent != null, "'parent' must not be null");
return parent.getBean(BeanIds.SPRING_SECURITY_FILTER_CHAIN, Filter.class);
}
条件是 @ConditionalOnBean(name = BeanIds.SPRING_SECURITY_FILTER_CHAIN, search = SearchStrategy.ANCESTORS)
——在祖先容器里找。找到后直接 parent.getBean(...) 拿同一个实例,
而不是在子容器里重建一个过滤器链。这样管理端口和主端口共享同一套安全配置。
6. Health 体系全拆解
6.1 三层类型与两个注册表
先建立心智模型。spring-boot-health 把健康拆成三类东西:
| 层 | 类型 | 位置 | 职责 |
|---|---|---|---|
| 贡献者 | HealthContributor / HealthIndicator / HealthContributors | contributor 包 | 产生 Health |
| 注册表 | HealthContributorRegistry / ReactiveHealthContributorRegistry | registry 包 | 按名字收纳贡献者 |
| 端点 | HealthEndpoint / HealthEndpointWebExtension / HealthEndpointSupport | actuate.endpoint 包 | 遍历、聚合、序列化 |
HealthContributorRegistry 的自动配置:
@AutoConfiguration
public final class HealthContributorRegistryAutoConfiguration {
@Bean
@ConditionalOnMissingBean(HealthContributorRegistry.class)
DefaultHealthContributorRegistry healthContributorRegistry(Map<String, HealthContributor> contributorBeans,
ObjectProvider<HealthContributorNameGenerator> nameGeneratorProvider,
List<HealthContributorNameValidator> nameValidators) {
HealthContributorNameGenerator nameGenerator = nameGeneratorProvider
.getIfAvailable(HealthContributorNameGenerator::withoutStandardSuffixes);
return new DefaultHealthContributorRegistry(nameValidators, nameGenerator.registrar(contributorBeans));
}
@Configuration(proxyBeanMethods = false)
@ConditionalOnClass(Flux.class)
static class ReactiveHealthContributorRegistryConfiguration {
@Bean
@ConditionalOnMissingBean(ReactiveHealthContributorRegistry.class)
DefaultReactiveHealthContributorRegistry reactiveHealthContributorRegistry(
Map<String, ReactiveHealthContributor> contributorBeans, ...) { ... }
}
}
四点:
Map<String, HealthContributor>注入:Spring 会把容器里所有HealthContributor类型的 bean 按beanName → bean收集成 Map 注入(第 13 章讲过这个特性)。 所以"定义一个健康指示器 = 注册一个HealthContributorbean",没有额外的注册 API。- 响应式注册表需要
Flux在 classpath(@ConditionalOnClass(Flux.class))。 没有 Reactor 就不建ReactiveHealthContributorRegistry。 - 名字生成器默认
withoutStandardSuffixes(),见下。 - 两个注册表默认都会建(有 Reactor 时),且互不共享:
阻塞型只收
HealthContributor,响应式只收ReactiveHealthContributor。 这就是为什么HealthEndpoint的构造器里有"主注册表 + fallback 注册表"两个参数。
6.2 名字生成器:去掉 HealthIndicator 后缀
static HealthContributorNameGenerator withoutStandardSuffixes() {
return withoutSuffixes("healthindicator", "healthcontributor");
}
static HealthContributorNameGenerator withoutSuffixes(String... suffixes) {
return (beanName) -> {
for (String suffix : suffixes) {
if (beanName != null && beanName.toLowerCase(Locale.ENGLISH).endsWith(suffix)) {
return beanName.substring(0, beanName.length() - suffix.length());
}
}
return beanName;
};
}
比较用的是 beanName.toLowerCase(Locale.ENGLISH),但截取用的是原始 beanName。
所以 myDbHealthIndicator → lower = "mydbhealthindicator" 以 "healthindicator" 结尾 →
返回 beanName.substring(0, len - 15) = myDb(大小写保持)。
最终健康检查项的名字是 myDb,不是 mydb。这点在写
management.endpoint.health.group.live.include=myDb 时要注意大小写。
registrar 是默认方法,把 Map 转成"注册动作":
default <C> Consumer<BiConsumer<String, C>> registrar(Map<String, C> contributorBeans) {
return (initialRegistrations) -> {
contributorBeans.forEach((beanName, contributorBean) -> {
String contributorName = generateContributorName(beanName);
try {
initialRegistrations.accept(contributorName, contributorBean);
}
catch (RuntimeException ex) {
throw new IllegalStateException(
"Unable to register contributor named '%s' of type %s from bean '%s': %s"
.formatted(contributorName, contributorBean.getClass(), beanName, ex.getMessage()), ex);
}
});
};
}
异常包装把"哪个 bean、生成了什么名字、什么错"全部带上——这是给 §6.9 的名字冲突校验用的。
6.3 AbstractRegistry:写时复制 + 监视器
private volatile Map<String, C> contributors;
private final Object monitor = new Object();
void registerContributor(String name, C contributor) {
synchronized (this.monitor) {
Map<String, C> contributors = new LinkedHashMap<>(this.contributors);
registerContributor(contributors, name, contributor);
this.contributors = Collections.unmodifiableMap(contributors);
}
}
private void registerContributor(Map<String, C> contributors, String name, C contributor) {
Assert.hasText(name, "'name' must not be empty");
Assert.notNull(contributor, "'contributor' must not be null");
verifyName(name, contributor);
Assert.state(!contributors.containsKey(name),
() -> "A contributor named \"" + name + "\" has already been registered");
contributors.put(name, contributor);
}
读无锁、写加锁、写时复制:volatile Map + 每次写都 new 一个 LinkedHashMap。
读路径(getContributor、stream)完全不加锁,所以遍历时不会被 ConcurrentModificationException 打断。
代价是每次注册 O(n) 拷贝——但注册只在启动期发生,运行期只读,取舍合理。
LinkedHashMap 保证注册顺序 = 汇报顺序,所以 /actuator/health 里组件的排列顺序
就是 bean 的注册顺序(不是字母序)。注意 CompositeHealthDescriptor 里
this.components = new TreeMap<>(components) 会重新按字母排序——
所以最终 JSON 里是有序的,但注册表内部是插入序。
unregisterContributor 返回被移除的对象,同样是写时复制。
verifyName 会跑所有 HealthContributorNameValidator:
private void verifyName(String name, C contributor) {
Assert.state(StringUtils.hasText(name),
() -> "Name for contributor '%s' must not be empty".formatted(contributor));
if (!CollectionUtils.isEmpty(this.nameValidators)) {
this.nameValidators.forEach((nameValidator) -> nameValidator.validate(name));
}
}
6.4 内置指示器与它们的开关
| 指示器 | 自动配置 | 默认开? | 关键行为 |
|---|---|---|---|
ping | HealthContributorAutoConfiguration | 开 | 恒 UP |
diskspace | DiskSpaceHealthContributorAutoConfiguration | 开 | 低于阈值 DOWN |
ssl | SslHealthContributorAutoConfiguration | 开 | 证书无效 → OUT_OF_SERVICE(不是 DOWN) |
livenessState / readinessState | AvailabilityHealthContributorAutoConfiguration | 默认关 | 见下 |
liveness / readiness(探针组) | AvailabilityProbesAutoConfiguration | 开 | 见 §6.7 |
ping 只有一个:
@AutoConfiguration
public final class HealthContributorAutoConfiguration {
@Bean
@ConditionalOnEnabledHealthIndicator("ping")
PingHealthIndicator pingHealthContributor() { return new PingHealthIndicator(); }
}
public class PingHealthIndicator extends AbstractHealthIndicator {
@Override
protected void doHealthCheck(Health.Builder builder) throws Exception { builder.up(); }
}
ping 恒 UP 的意义:保证 /actuator/health 永远有内容。
即使你关掉了所有其他指示器,返回体里至少有一个 ping,
于是 components 非空、status 有值,不会出现空对象。
@ConditionalOnEnabledHealthIndicator 的判定:
class OnEnabledHealthIndicatorCondition extends SpringBootCondition {
private static final String DEFAULTS_PROPERTY_NAME = "management.health.defaults.enabled";
@Override
public ConditionOutcome getMatchOutcome(ConditionContext context, AnnotatedTypeMetadata metadata) {
String name = annotation.getString(MergedAnnotation.VALUE);
String propertyName = "management.health." + name + ".enabled";
if (environment.containsProperty(propertyName)) {
boolean match = environment.getProperty(propertyName, Boolean.class, true);
return new ConditionOutcome(match, message.because(propertyName + " is " + match));
}
boolean match = Boolean.parseBoolean(context.getEnvironment().getProperty(DEFAULTS_PROPERTY_NAME, "true"));
return new ConditionOutcome(match, message.because(DEFAULTS_PROPERTY_NAME + " is considered " + match));
}
}
两级:单项 management.health.<name>.enabled → 全局 management.health.defaults.enabled(默认 true)。
注意用的是 environment.containsProperty + getProperty,不是 Binder,
所以 management.health.diskspace.enabled=TRUE 这种大写值会走 Spring 的默认布尔转换
(Boolean.class 转换支持 true/yes/on/1 但不区分大小写吗?Spring 的 StringToBooleanConverter
对 true/on/yes/1 大小写不敏感)。未核实,列 §10。
diskspace 无论 UP 还是 DOWN 都带 5 个 details(total/free/threshold/path/exists),
低于阈值时先 WARN 再 builder.down()。
ssl 用 OUT_OF_SERVICE 而不是 DOWN(语义:DOWN=意外故障,OUT_OF_SERVICE=已知不可用);
isExpiringCertificate 判断"在 expiryThreshold 内会过期",但过期中的链仍算 valid,
只多列进 expiringChains,状态不变。
AvailabilityStateHealthIndicator 有一条硬约束:若没有 addDefaultStatus(...)
(即 statusMappings 里没有 null key),枚举的每个值都必须显式映射,
否则 assertAllEnumsMapped 直接 Assert.state 失败。"要么全覆盖,要么给默认值",二选一。
6.5 Health 与 Status 的序列化
Health.getStatus() 带 @JsonUnwrapped,所以 Status 不嵌套输出而是平铺;
Status.getCode() 带 @JsonProperty("status"),getDescription() 带 @JsonInclude(NON_EMPTY)。
于是 Health.up().build() → {"status":"UP"},而 new Status("FATAL","bad") →
{"status":"FATAL","description":"bad"}。Health.withoutDetails() 在 showDetails=false 时被调用:
保留 status、清空 details。
Status 的 equals/hashCode 只看 code,description 不参与——
这会影响 StatusAggregator 里 Set<Status> 的去重。
默认顺序(@since 4.1.0):List.of(DOWN, OUT_OF_SERVICE, UP, UNKNOWN),越靠前越严重。
6.6 聚合与 HTTP 状态码
StatusAggregator.of(List<String>) 是 4.1.0 才搬到接口上的工厂方法,
SimpleStatusAggregator 已 @Deprecated(since="4.1.0", forRemoval)(4.3.0 移除)。
核心:statuses.stream().filter(this::contains).min(comparator).orElse(Status.UNKNOWN),
comparator 为 Comparator.comparingInt(this::orderIndex).thenComparing(Status::getCode)。
两个陷阱:
filter(this::contains)会丢掉不在 order 列表里的自定义状态——它们对聚合结果完全无影响。 想让Status("WARMING_UP")生效,必须显式配management.endpoint.health.status.order=...。getUniformCode只保留字母数字并转小写,所以OUT_OF_SERVICE/out-of-service/Out_Of_Service在聚合时等价。
HttpCodeStatusMapper 默认只有两张映射:DOWN → 503、OUT_OF_SERVICE → 503,
其他一切(含 UNKNOWN 和所有自定义状态)都是 getOrDefault(..., STATUS_OK) = 200。
安全含义:自定义一个"警告"状态不会让 Kubernetes 摘流量,必须显式配
management.endpoint.health.status.http-mapping.<status>=<code>。
另注意 getUniformMappings 用 putIfAbsent(先出现的赢)而入参是 HashMap(无序)——
同时配 down 与 DOWN 两种写法,结果不确定。别这么写。
6.7 健康组(Groups)与探针(Probes):最容易踩坑的一节
HealthEndpointGroups 接口:
public interface HealthEndpointGroups {
HealthEndpointGroup getPrimary();
Set<String> getNames();
@Nullable HealthEndpointGroup get(String name);
default @Nullable HealthEndpointGroup get(AdditionalHealthEndpointPath path) { ... }
default Set<HealthEndpointGroup> getAllWithAdditionalPath(WebServerNamespace namespace) { ... }
}
AutoConfiguredHealthEndpointGroups 的构造:
AutoConfiguredHealthEndpointGroups(ApplicationContext applicationContext, HealthEndpointProperties properties) {
ListableBeanFactory beanFactory = (applicationContext instanceof ConfigurableApplicationContext configurableContext)
? configurableContext.getBeanFactory() : applicationContext;
Show showComponents = properties.getShowComponents();
Show showDetails = properties.getShowDetails();
Set<String> roles = properties.getRoles();
StatusAggregator statusAggregator = getNonQualifiedBean(beanFactory, StatusAggregator.class,
() -> StatusAggregator.of(properties.getStatus().getOrder()));
HttpCodeStatusMapper statusMapper = getNonQualifiedBean(beanFactory, HttpCodeStatusMapper.class,
() -> HttpCodeStatusMapper.of(properties.getStatus().getHttpMapping()));
this.primaryGroup = new AutoConfiguredHealthEndpointGroup(HealthContributorMembership.always(),
statusAggregator, statusMapper, showComponents, showDetails, roles, null);
this.groups = createGroups(properties.getGroup(), beanFactory, statusAggregator, statusMapper,
showComponents, showDetails, roles);
}
主组的 membership 是 always(),且 additionalPath 为 null(主组没有附加路径)。
getNonQualifiedBean 的"非限定"语义:
private <T> T getNonQualifiedBean(ListableBeanFactory beanFactory, Class<T> type, Supplier<T> fallback) {
List<String> candidates = new ArrayList<>();
for (String beanName : BeanFactoryUtils.beanNamesForTypeIncludingAncestors(beanFactory, type)) {
String[] aliases = beanFactory.getAliases(beanName);
if (!BeanFactoryAnnotationUtils.isQualifierMatch(
(qualifier) -> !qualifier.equals(beanName) && !ObjectUtils.containsElement(aliases, qualifier),
beanName, beanFactory)) {
candidates.add(beanName);
}
}
if (candidates.isEmpty()) return fallback.get();
if (candidates.size() == 1) return beanFactory.getBean(candidates.get(0), type);
return beanFactory.getBean(type);
}
它要找的是"没有 @Qualifier 的那个 bean"。逻辑读起来绕:
isQualifierMatch(predicate, beanName, beanFactory) 判断 bean 上是否有满足 predicate 的限定符,
predicate 是"限定符值不等于 bean 名且不在别名里"——也就是"这是一个真正的 @Qualifier 注解值"。
如果没有这样的限定符(!isQualifierMatch),就加入候选。
- 0 个候选 → 用 fallback(从属性构造)
- 1 个候选 → 直接用它
- 多个候选 →
beanFactory.getBean(type)(走 Spring 的 primary / 名字匹配规则)
这是为了让用户提供的 StatusAggregator bean 优先于属性配置,
同时又支持给某个组单独配一个 @Qualifier("liveness") StatusAggregator。
对应地,createGroups 里对每个组用 getQualifiedBean:
StatusAggregator statusAggregator = getQualifiedBean(beanFactory, StatusAggregator.class, groupName,
() -> createStatusAggregator(status.getOrder(), defaultStatusAggregator));
按组名作限定符去找。所以:
@Bean @Qualifier("live") StatusAggregator liveAggregator() { ... }
配合 management.endpoint.health.group.live.* 就能让 live 组用独立的聚合器。
groups 用 TreeMap(new TreeMap<>()),所以 getNames() 返回的是字母序,
且最终被 Collections.unmodifiableMap 包住。
可用性探针是本章最大的坑。AvailabilityProbesAutoConfiguration:
@AutoConfiguration(after = { AvailabilityHealthContributorAutoConfiguration.class,
ApplicationAvailabilityAutoConfiguration.class })
@ConditionalOnClass(name = "org.springframework.boot.actuate.endpoint.annotation.Endpoint")
@ConditionalOnBooleanProperty(name = "management.endpoint.health.probes.enabled", matchIfMissing = true)
public final class AvailabilityProbesAutoConfiguration {
@Bean @ConditionalOnMissingBean(name = "livenessStateHealthIndicator")
LivenessStateHealthIndicator livenessStateHealthIndicator(ApplicationAvailability applicationAvailability) { ... }
@Bean @ConditionalOnMissingBean(name = "readinessStateHealthIndicator")
ReadinessStateHealthIndicator readinessStateHealthIndicator(ApplicationAvailability applicationAvailability) { ... }
@Bean
AvailabilityProbesHealthEndpointGroupsPostProcessor availabilityProbesHealthEndpointGroupsPostProcessor(
Environment environment) { ... }
}
management.endpoint.health.probes.enabled 默认 true(matchIfMissing = true)。
所以探针组 liveness / readiness 默认就存在,
但注意这两个 HealthIndicator bean 跟
AvailabilityHealthContributorAutoConfiguration 里那两个同名
(都叫 livenessStateHealthIndicator / readinessStateHealthIndicator)!
两者都有 @ConditionalOnMissingBean(name = ...),
谁先注册谁赢——取决于自动配置排序(AvailabilityProbesAutoConfiguration
声明了 after = AvailabilityHealthContributorAutoConfiguration)。
由于都是 @ConditionalOnMissingBean(name=...)
(按名字判断,不是按类型),先执行的那个注册后,后一个就跳过。
结论:AvailabilityHealthContributorAutoConfiguration 先执行,
它的 @ConditionalOnBooleanProperty("management.health.livenessstate.enabled") 默认 false,
所以不注册;然后 AvailabilityProbesAutoConfiguration 注册。
即:探针默认存在,而独立的 liveness/readiness 贡献者默认不存在。
这个顺序结论我未实测,列 §10。
AvailabilityProbesHealthEndpointGroups(装饰器):
AvailabilityProbesHealthEndpointGroups(HealthEndpointGroups groups, boolean addAdditionalPaths) {
this.groups = groups;
this.probeGroups = createProbeGroups(addAdditionalPaths);
Set<String> names = new LinkedHashSet<>(groups.getNames());
names.addAll(this.probeGroups.keySet());
this.names = Collections.unmodifiableSet(names);
}
private Map<String, HealthEndointGroup> createProbeGroups(boolean addAdditionalPaths) {
Map<String, HealthEndpointGroup> probeGroups = new LinkedHashMap<>();
probeGroups.put(LIVENESS, getOrCreateProbeGroup(addAdditionalPaths, LIVENESS, "/livez", "livenessState"));
probeGroups.put(READINESS, getOrCreateProbeGroup(addAdditionalPaths, READINESS, "/readyz", "readinessState"));
return Collections.unmodifiableMap(probeGroups);
}
private HealthEndpointGroup getOrCreateProbeGroup(boolean addAdditionalPath, String name, String path, String members) {
HealthEndpointGroup group = this.groups.get(name);
if (group != null) {
return determineAdditionalPathForExistingGroup(addAdditionalPath, path, group);
}
AdditionalHealthEndpointPath additionalPath = (!addAdditionalPath) ? null
: AdditionalHealthEndpointPath.of(WebServerNamespace.SERVER, path);
return new AvailabilityProbesHealthEndpointGroup(additionalPath, members);
}
如果用户在 management.endpoint.health.group.liveness.* 里自己定义了 liveness 组,
探针机制不会覆盖它,只会在它没有 additionalPath 时给它套一层
DelegatingAvailabilityProbesHealthEndpointGroup 加上路径。
addAdditionalPaths 的来源:
AvailabilityProbesHealthEndpointGroupsPostProcessor(Environment environment) {
this.addAdditionalPaths = "true"
.equalsIgnoreCase(environment.getProperty("management.endpoint.health.probes.add-additional-paths"));
}
这里就回答了章首第一个问题:
management.endpoint.health.probes.enabled=true(默认)只创建组,
要不要挂到 /livez / /readyz 路径上,要看
management.endpoint.health.probes.add-additional-paths(默认 false,且不区分大小写的 "true")。
所以默认情况下:
/actuator/health/liveness可用(组名就是路径段)/livez不可用
很多人以为开了 probes 就有 /livez,实际要两个属性都开。
AvailabilityProbesHealthEndpointGroup 的硬编码:
@Override public boolean showComponents(SecurityContext securityContext) { return false; }
@Override public boolean showDetails(SecurityContext securityContext) { return false; }
@Override public StatusAggregator getStatusAggregator() { return StatusAggregator.getDefault(); }
@Override public HttpCodeStatusMapper getHttpCodeStatusMapper() { return HttpCodeStatusMapper.getDefault(); }
探针组永远不显示 details/components,且忽略用户配置的 status order 和 http mapping。 因为探针是给 kubelet 用的,输出必须稳定、最小、不受应用配置影响。
AdditionalHealthEndpointPath:
public static AdditionalHealthEndpointPath from(String value) {
Assert.hasText(value, "'value' must not be null");
String[] values = value.split(":");
Assert.isTrue(values.length == 2, "'value' must contain a valid namespace and value separated by ':'.");
Assert.isTrue(StringUtils.hasText(values[0]), "'value' must contain a valid namespace.");
WebServerNamespace namespace = WebServerNamespace.from(values[0]);
validateValue(values[1]);
return new AdditionalHealthEndpointPath(namespace, values[1]);
}
必须形如 server:/healthz 或 management:/healthz。
validateValue 我未读到实现体,但从 Javadoc
("The value must be limited to one path segment. For example, server:/healthz")
推断它只允许一段路径,即 server:/a/b 会失败。列 §10。
canonicalValue 用于 equals/hashCode(自动补前导 /),
value 用于输出(保持原样)。
toString() 是 namespace + ":" + value。
6.8 HealthEndpointSupport:一次请求的完整算法
主入口先判断第一个路径段是不是组名:
HealthEndpointGroup group = (path.length > 0) ? getGroup(serverNamespace, path) : null;
if (group != null) return getResult(apiVersion, group, securityContext, showAll, path, 1); // pathOffset=1
return getResult(apiVersion, this.groups.getPrimary(), securityContext, showAll, path, 0);
getGroup 先按组名找,再用 AdditionalHealthEndpointPath.of(serverNamespace, path[0]) 找附加路径。
HealthEndpoint.health() 传的 serverNamespace 是 null(JMX 无命名空间),
所以 JMX 下附加路径不可用。
核心递归里有三行决定了全部可观测行为:
boolean isSystemHealth = group == this.groups.getPrimary() && pathOffset == 0;
boolean isRoot = path.length - pathOffset == 0;
if (!showComponents && !isRoot) {
return null; // ★ 非根路径且不允许显示组件 → 404
}
这是安全核心:show-details=never 时只有根路径返回数据,任何子路径都返回 null → HTTP 404,
防止未授权用户枚举子路径探测组件。isSystemHealth 决定响应里是否带 groups 字段
(只有"主组 + 根路径"才带,即 SystemHealthDescriptor)。
叶子节点上做组过滤:
if (name.isEmpty() || group.isMember(name)) {
return getDescriptorAndLogIfSlow(contributor, name, showDetails);
}
return null; // ★ 不在组内 → 该组件不出现
IncludeExcludeHealthContributorMembership 对带 / 的名字会逐级上溯:
private boolean isIncludedName(String name) {
if (this.include.contains(name)) return true;
if (name.contains("/")) return isIncludedName(name.substring(0, name.lastIndexOf("/")));
return false;
}
所以 include=db 会把 db/primary、db/replica 全带上(父级 db 被包含)。exclude 同理。
慢指示器告警:getDescriptorAndLogIfSlow 用 Instant 计时,超过
slowIndicatorThreshold(默认 Duration.ofSeconds(10))只 WARN
("Health contributor %s took %s to respond",DurationStyle.SIMPLE 格式),
不影响结果(不会把组件标 DOWN)。
最后聚合:
Status status = statusAggregator.getAggregateStatus(
descriptors.values().stream().map(this::getStatus).collect(Collectors.toSet()));
descriptors = (!showComponents) ? null : descriptors;
先聚合再决定要不要丢弃 components:show-details=never 只影响展示,
不影响 status 的计算——聚合总是基于组内全部组件。
CompositeHealthDescriptor 是 sealed 类(permits SystemHealthDescriptor),
内部用 new TreeMap<>(components) 重新按字母排序(注册表内部是插入序)。
6.9 两个校验器
名字与组冲突:
class GroupsHealthContributorNameValidator implements HealthContributorNameValidator {
GroupsHealthContributorNameValidator(@Nullable HealthEndpointGroups groups) {
this.groupNames = (groups != null) ? groups.getNames() : Collections.emptySet();
}
@Override
public void validate(String name) throws IllegalStateException {
Assert.state(!this.groupNames.contains(name),
() -> "HealthContributor with name \"" + name + "\" clashes with group");
}
}
如果你的指示器叫 liveness,而探针组也叫 liveness → 启动失败。
这是必要的,否则 /actuator/health/liveness 到底指组还是指组件就说不清了。
组配置里的名字不存在:
public class HealthContributorMembershipValidator implements SmartInitializingSingleton {
@Override public void afterSingletonsInstantiated() { validateMembers(); }
private void validate(String property, @Nullable Set<String> names) {
if (CollectionUtils.isEmpty(names)) return;
for (String name : names) {
if ("*".equals(name)) return;
String[] path = name.split("/");
if (!contributorExists(path)) {
String description = "Health contributor '%s' defined in '%s' does not exist".formatted(name, property);
String action = "Update your application to correct the invalid configuration.\n"
+ "You can also set '%s' to false to disable the validation.".formatted(this.disableValidationProperty);
throw new FailureAnalyzedException(description, action);
}
}
}
}
SmartInitializingSingleton——所有单例建完后才校验(此时注册表才填满)。
抛的是 FailureAnalyzedException,会走第 16 章讲的 FailureAnalyzer 体系,
给出漂亮的诊断报告(含"你可以设置 xxx=false 关闭校验"的建议)。
开关:management.endpoint.health.validate-group-membership
(@ConditionalOnBooleanProperty(name = VALIDATE_MEMBERSHIP_PROPERTY, matchIfMissing = true),默认开)。
这里有一处代码瑕疵值得指出。HealthEndpointConfiguration 里:
(members) -> properties.getGroup().forEach((groupName, group) -> {
String property = "management.endpoint.health.group." + groupName;
members.member(property + ".include".formatted(groupName), group.getInclude());
members.member(property + ".exclude".formatted(groupName), group.getExclude());
})
".include".formatted(groupName) 中的 ".include" 不含任何格式占位符,
String.formatted(Object...) 对多余参数是忽略的(不抛异常),
所以这个调用等价于 ".include" 本身。结果是正确但多余的 formatted 调用。
我判断这是重构残留(可能原意是 "%s.include".formatted(...)),
但它没有 bug。列为 §10 的"代码观察"项,不影响行为。
6.10 HealthEndpoint vs HealthEndpointWebExtension
HealthEndpoint | HealthEndpointWebExtension | |
|---|---|---|
SecurityContext | SecurityContext.NONE | 真实上下文 |
showAll | true | false |
serverNamespace | null | 真实命名空间 |
| 返回 | HealthDescriptor | WebEndpointResponse<HealthDescriptor> |
| 用途 | JMX / 编程调用 | HTTP |
@ReadOperation
public HealthDescriptor health() {
HealthDescriptor health = health(ApiVersion.V3, EMPTY_PATH);
return (health != null) ? health : IndicatedHealthDescriptor.UP; // ★ 兜底
}
IndicatedHealthDescriptor.UP 是一个 static final 常量:
static final IndicatedHealthDescriptor UP = new IndicatedHealthDescriptor(Health.up().build());
Web 侧的兜底逻辑更细:
if (result == null) {
return (Arrays.equals(path, EMPTY_PATH))
? new WebEndpointResponse<>(IndicatedHealthDescriptor.UP, WebEndpointResponse.STATUS_OK) // 根路径 → 200 UP
: new WebEndpointResponse<>(WebEndpointResponse.STATUS_NOT_FOUND); // 子路径 → 404
}
根路径永远返回 200 + {"status":"UP"},即使一个组件都没有
(getAggregateDescriptor 返回 null 时)。子路径才 404。
HealthEndpoint.healthForPath 用 @Selector(match = Match.ALL_REMAINING) String... path
捕获剩余全部路径段,支持 /actuator/health/db/primary 这种多段路径。
响应式版本 ReactiveHealthEndpointWebExtension 的聚合用 Flux:
return Flux.fromIterable(contributions.entrySet())
.flatMap(NamedHealthDescriptor::create)
.collectMap(NamedHealthDescriptor::name, NamedHealthDescriptor::descriptor)
.map((components) -> this.getCompositeDescriptor(apiVersion, components, statusAggregator, showComponents, groupNames));
flatMap 保持并发:所有 Mono<Health> 同时订阅,而不是顺序执行。
这对慢指示器很重要——总耗时是最慢的那个,不是所有之和。
(阻塞版本是顺序 for 循环,总耗时是所有之和。这是响应式的一个实质优势。)
6.11 Contributor:sealed 接口抹平阻塞/响应式差异
sealed interface Contributor<H, D> extends Iterable<Contributor.Child<H, D>> { ... }
record Blocking(Object contributor) implements Contributor<Health, HealthDescriptor> { ... }
Blocking 是 record,靠 instanceof HealthContributors(复数=复合)判断 isComposite(),
每次下钻 new 一个新 record——值语义、天然不可变、天然线程安全。
Contributor.blocking(registry, fallbackRegistry) 里
fallbackRegistry.asHealthContributors() 把响应式注册表适配成阻塞视图再与阻塞注册表合成。
所以 ReactiveHealthIndicator 也能出现在阻塞的 /actuator/health 里;反向同理。
响应式侧的聚合用 Flux.fromIterable(...).flatMap(...).collectMap(...)——
flatMap 保持并发,总耗时是最慢的那个;而阻塞版本是顺序 for 循环,
总耗时是所有之和。这是响应式栈在慢指示器场景下的实质优势。
未核实:响应式→阻塞的适配必然要阻塞等待,我未核实 asHealthContributors() 的实现
(它在 ReactiveHealthContributorRegistry 里),若被调到不接受阻塞的线程上可能出问题(§10)。
6.12 附加路径如何挂到 HandlerMapping
AutoConfiguredHealthEndpointGroups 自己实现了 AdditionalPathsMapper
(只认 HealthEndpoint.ID,按 namespace 过滤后返回 getValue() 列表);
装饰器 AvailabilityProbesHealthEndpointGroups 也实现它并叠加两路结果。
反射到 Web 层,由两个自动配置分别处理两个命名空间:
| 命名空间 | 提供者 | 位置 |
|---|---|---|
SERVER | WebMvcHealthEndpointExtensionAutoConfiguration(@AutoConfiguration) | 主容器 |
MANAGEMENT | HealthEndpointWebExtensionConfiguration.HealthConfiguration(@ConditionalOnManagementPort(DIFFERENT)) | 子容器 |
两者都产出 AdditionalHealthEndpointPathsWebMvcHandlerMapping(healthEndpoint, groups.getAllWithAdditionalPath(ns))。
server:/healthz 挂主容器,management:/healthz 挂子容器——
这就是 WebServerNamespace 存在的意义:同一个端点类,两个服务器各自挂不同的路径。
7. 全部内置端点与它们的开关
按自动配置的行为模式分四类。全部 24 个 spring-boot-actuator-autoconfigure 自动配置里,
17 个是"端点自动配置",其余是基础设施(jackson / jmx / web / endpoint / management-context)。
7.1 纯 @ConditionalOnAvailableEndpoint 型(最常见)
| 自动配置 | 端点 bean | 额外条件 |
|---|---|---|
BeansEndpointAutoConfiguration | BeansEndpoint | 无 |
ThreadDumpEndpointAutoConfiguration | ThreadDumpEndpoint(bean 方法名 dumpEndpoint) | 无 |
HeapDumpWebEndpointAutoConfiguration | HeapDumpWebEndpoint | 无 |
ScheduledTasksEndpointAutoConfiguration | ScheduledTasksEndpoint | 无(ObjectProvider<ScheduledTaskHolder>,无则空列表) |
ConditionsReportEndpointAutoConfiguration | ConditionsReportEndpoint | @ConditionalOnMissingBean(search = SearchStrategy.CURRENT) |
EnvironmentEndpointAutoConfiguration | EnvironmentEndpoint + Web 扩展 | 无 |
ConfigurationPropertiesReportEndpointAutoConfiguration | ConfigurationPropertiesReportEndpoint + Web 扩展 | 无 |
InfoEndpointAutoConfiguration | InfoEndpoint | after = InfoContributorAutoConfiguration |
SbomEndpointAutoConfiguration | SbomEndpoint + Web 扩展 | @EnableConfigurationProperties(SbomProperties.class) |
AuditEventsEndpointAutoConfiguration | AuditEventsEndpoint | 依赖 AuditEventRepository |
HttpExchangesEndpointAutoConfiguration | HttpExchangesEndpoint | 依赖 HttpExchangeRepository |
MappingsEndpointAutoConfiguration | MappingsEndpoint | 依赖各 Web 栈的 HandlerMapping 描述器 |
HealthEndpointAutoConfiguration | HealthEndpoint | 见 §7.4 |
共同点是端点 bean 上都有 @ConditionalOnMissingBean——用户自己注册同名类型的 bean 即可完全接管。
ConditionsReportEndpointAutoConfiguration 用了
@ConditionalOnMissingBean(search = SearchStrategy.CURRENT):只在当前容器里查,
不去祖先容器。这对管理端口子上下文很重要——
子容器里可以再建一个只反映子容器条件的报告端点。
7.2 带自定义 @Conditional 型
StartupEndpointAutoConfiguration——除了 @ConditionalOnAvailableEndpoint,还有一个内部类条件:
static class ApplicationStartupCondition extends SpringBootCondition {
@Override
public ConditionOutcome getMatchOutcome(ConditionContext context, AnnotatedTypeMetadata metadata) {
ApplicationStartup applicationStartup = beanFactory.getApplicationStartup();
if (applicationStartup instanceof BufferingApplicationStartup) {
return ConditionOutcome.match(message.because("configured applicationStartup is of type BufferingApplicationStartup."));
}
return ConditionOutcome.noMatch(message.because("configured applicationStartup is of type "
+ applicationStartup.getClass() + ", expected BufferingApplicationStartup."));
}
}
必须把 ApplicationStartup 换成 BufferingApplicationStartup 才有 startup 端点
(spring.main.application-startup=buffered 或 new SpringApplicationBuilder().applicationStartup(...))。
默认的是 DefaultApplicationStartup(空实现,不记录)。
第 12 章讲过 ApplicationStartup 的埋点体系,这里是它的唯一消费端点。
LoggersEndpointAutoConfiguration——条件很有意思:
@Bean
@ConditionalOnBean(LoggingSystem.class)
@Conditional(OnEnabledLoggingSystemCondition.class)
@ConditionalOnMissingBean
LoggersEndpoint loggersEndpoint(LoggingSystem loggingSystem, ObjectProvider<LoggerGroups> springBootLoggerGroups) {
return new LoggersEndpoint(loggingSystem, springBootLoggerGroups.getIfAvailable(LoggerGroups::new));
}
static class OnEnabledLoggingSystemCondition extends SpringBootCondition {
@Override
public ConditionOutcome getMatchOutcome(ConditionContext context, AnnotatedTypeMetadata metadata) {
String loggingSystem = System.getProperty(LoggingSystem.SYSTEM_PROPERTY);
if (LoggingSystem.NONE.equals(loggingSystem)) {
return ConditionOutcome.noMatch(...);
}
return ConditionOutcome.match(message.because("enabled"));
}
}
它读的是 系统属性 LoggingSystem.SYSTEM_PROPERTY(= LoggingSystem.class.getName()),
不是 Spring Environment。这直接呼应第 24 章:
logging.system=none 会被 LoggingApplicationListener 写成系统属性,
所以这里必须用 System.getProperty 而不是 environment.getProperty。
如果日志系统被关掉(none → NoOpLoggingSystem),loggers 端点直接不注册——
因为改不了日志级别,注册了也是误导。
LoggerGroups 的兜底是 LoggerGroups::new(空组),
而第 24 章讲过 LoggingApplicationListener 会注册一个 springBootLoggerGroups bean。
ObjectProvider + getIfAvailable 是"有就用你的,没有就造个空的"的标准写法。
7.3 由其它模块提供的端点
caches / flyway / liquibase / metrics / quartz / sessions / integrationgraph
都在各自模块的 .imports 里,不属 spring-boot-actuator-autoconfigure。
这意味着不加对应 starter 就没有对应端点——端点跟着功能模块走,而不是跟着 actuator 走。
这是 4.x 模块化后最直观的收益。
7.4 Health 的四个自动配置如何串起来
HealthContributorRegistryAutoConfiguration (无前置条件,建两个注册表)
│
├─ DiskSpaceHealthContributorAutoConfiguration @ConditionalOnEnabledHealthIndicator("diskspace")
├─ SslHealthContributorAutoConfiguration @ConditionalOnEnabledHealthIndicator("ssl")
├─ HealthContributorAutoConfiguration ping
└─ AvailabilityHealthContributorAutoConfiguration liveness/readiness(默认关)
↓ after
AvailabilityProbesAutoConfiguration 探针组(默认开)
↓ after = HealthContributorRegistryAutoConfiguration
HealthEndpointAutoConfiguration
├─ @ConditionalOnClass(name = "...Endpoint")
├─ @ConditionalOnBean(type = "...HealthContributorRegistry")
├─ @ConditionalOnAvailableEndpoint(HealthEndpoint.class)
└─ @Import({ HealthEndpointConfiguration, WebExtension, ReactiveWebExtension })
HealthEndpointAutoConfiguration 的类体是空的,所有语义在注解里——
与第 23 章 HibernateJpaAutoConfiguration 一模一样的风格。
注意 @ConditionalOnBean(type = "org.springframework.boot.health.registry.HealthContributorRegistry")
用的是字符串类型名:因为 spring-boot-health 的 autoconfigure 包可能在没有
spring-boot-health contributor 类的情况下被扫描(模块拆分后的防御性写法),
用字符串就不会在类加载期失败。
8. 决策速查表
8.1 "我想让 /actuator/health 显示细节"
management.endpoint.health.show-details=always # 或 when_authorized
management.endpoint.health.show-components=always # 可选,默认跟 show-details
management.endpoint.health.roles=ACTUATOR # 仅 when_authorized 时生效
Show 三值:
| 值 | 无 SecurityContext(JMX/编程) | 有 SecurityContext |
|---|---|---|
NEVER | false | false |
ALWAYS | true | true |
WHEN_AUTHORIZED | false | 看 principal 与 roles |
isAuthorized 的细节:principal 为 null → false;roles 为空 → 任何已认证用户都放行;
roles 非空 → 先试 securityContext.isUserInRole(role),
若 principal 是 Spring Security 的 Authentication,再逐个比对 GrantedAuthority.getAuthority()
(用 equals 精确匹配,不自动加 ROLE_ 前缀)。
isSpringSecurityAuthentication 用 ClassUtils.isPresent("org.springframework.security.core.Authentication", null)
instanceof双保险。
8.2 "我想加一个自定义健康指示器"
@Component
public class MyHealthIndicator implements HealthIndicator {
@Override public Health health() {
return Health.up().withDetail("version", "1.0").build();
}
}
会发生的四件事:
- bean 名
myHealthIndicator→ 经withoutStandardSuffixes()变成my - 被
Map<String, HealthContributor>收集进DefaultHealthContributorRegistry - 名字与任何组名冲突 →
GroupsHealthContributorNameValidator启动失败 - 组配置里引用了不存在的名字 →
HealthContributorMembershipValidator抛FailureAnalyzedException
想保留 HealthIndicator 后缀就用 HealthContributorNameGenerator.unchanged() 注册一个 bean。
8.3 "我想要 /livez 和 /readyz"
两个属性都要开:
management.endpoint.health.probes.enabled=true # 默认已是 true
management.endpoint.health.probes.add-additional-paths=true # ★ 默认 false
且附加路径只在 WebServerNamespace.SERVER 上(源码硬编码),
即挂在主端口,不会自动挂到管理端口。要挂管理端口得自己写组:
management.endpoint.health.group.live.additional-path=management:/livez
management.endpoint.health.group.live.include=livenessState
8.4 "我想把管理端点独立到 8081"
management.server.port=8081
会发生的连锁反应:
ManagementPortType→DIFFERENTTomcatServletManagementContextAutoConfiguration(或其它 Web 服务器模块)注册ManagementContextFactoryChildManagementContextInitializer作为SmartLifecycle在 phaseGRACEFUL_SHUTDOWN_PHASE - 512启动refresh()一个 id 为<parentId>:management、serverNamespace 为management的新容器- 新容器里注册
ManagementContextWebServerFactory(从父偷来的类型)+ 两个@Configuration - 导入 16 个
@ManagementContextConfiguration候选中所有ANY/CHILD的(按@Order排序) WebMvcEndpointChildContextConfiguration建一个 detectAll 全 false 的DispatcherServlet- 端点 bean 仍来自父容器(
beanNamesForAnnotationIncludingAncestors)
此时不要配 management.server.ssl.enabled=true(同端口才禁)——反而是它唯一的用途。
8.5 "我的端点没出现在 /actuator"
按此顺序排查:
management.endpoints.web.exposure.include是否包含它(默认只有health)management.endpoint.<id>.access是否是nonemanagement.endpoints.access.max-permitted是否把它封顶了- 端点是否没有任何 operation(
isInvocable()要求非空) —— 典型:access=read_only而端点只有写操作 @ConditionalOnAvailableEndpoint的exposure是否指定得比实际窄- 是不是在子容器里找一个只在父容器注册的端点(或反之)
spring.jmx.enabled=false时 JMX 侧完全不可用
9. 从本章提炼的定律
延续前 24 章的编号(README 收录)。
第四十二定律:端点存在 ≠ 端点可用 ≠ 端点暴露
三个正交维度,各有各的开关:
- 存在:bean 是否被创建(
@ConditionalOnMissingBean/ 各种@ConditionalOnClass) - 可用:
Access != NONE(PropertiesEndpointAccessResolver四级 +max-permitted封顶) - 暴露:
IncludeExcludeEndpointFilter(include 空时回退到默认health)
外加第四个维度 操作级:OperationFilter.byAccess 决定单个 operation 是否可见。
任何一维不过,端点就从 /actuator 里消失,而失败原因完全不同。
第四十三定律:include 与 exclude 的空集语义不对称
include 空 → 回退到默认 includes(health);exclude 空 → 不排除任何人。
推论:只写 exclude=health 不会"暴露除 health 之外的一切",而是什么都不暴露。
要"除某几个之外全要",必须写 include=*。
第四十四定律:跨容器协作时,先定"谁持有 bean",再定"谁持有映射"
管理端口子上下文里,端点 bean 在父容器,HandlerMapping 在子容器。
beanNamesForAnnotationIncludingAncestors 是这条设计的使能器。
凡是要做"一份定义、多处暴露",先想清楚这个划分,否则会陷入循环依赖。
第四十五定律:子容器要"关掉自动探测,改用约定 bean 名显式注册"
DispatcherServlet 的四个 setDetectAllXxx(false) 是一个通用模式:
当容器有 parent 时,任何"扫描全部 X 类型 bean"的默认行为都会越界到祖先容器。
解法是关掉探测,用框架约定的 bean 名(HANDLER_MAPPING_BEAN_NAME 等)显式提供。
同类问题在 Spring 的很多 detectAllXxx / getBeansOfType 处都会复现。
第四十六定律:@ConditionalOnMissingBean(name = ...) 是按名判定,可用于"两个自动配置抢同一个名字"
AvailabilityHealthContributorAutoConfiguration 与 AvailabilityProbesAutoConfiguration
都声明 livenessStateHealthIndicator,都用 @ConditionalOnMissingBean(name = ...)。
胜负由自动配置顺序决定(after = ...),而不是由类型。
想让某个自动配置"让位",用 name 而不是 value。
第四十七定律:默认值写在代码里会分裂,写在属性里才统一
AvailabilityProbesHealthEndpointGroup 硬编码 showComponents=false / showDetails=false /
getDefault() 的聚合器与映射器——忽略用户配置。这是有意的:探针给机器看,必须稳定。
推论:当你设计一个"给机器消费"的接口时,不要让它继承"给人看"的配置。
第四十八定律:慢路径只告警,不改变结果
HealthEndpointSupport 的 slowContributorLoggingThreshold 只 WARN,不把组件标 DOWN。
理由:健康检查的结果语义与性能语义必须分离,
否则一次网络抖动会让整个实例被摘流量——这比慢本身危害大得多。
推论:任何"超时即失败"的设计都要先问"失败会导致什么"。
第四十九定律:宽容解析要配幂等写
Access 用 cap() 做单调封顶、customizePrefix 用 startsWith 判断避免前缀叠加、
getUniformCode 把多种写法归一——三者共性:输入可以千奇百怪,操作必须幂等。
凡是要做"宽松绑定"的地方,都要同时给出幂等保证。
第五十定律:把反射失败提前转成可读错误
ManagementContextFactory.determineWebServerFactoryClass 检查
isLocalClass() / 非静态 isMemberClass() / isAnonymousClass(),
并抛出带修复建议的 FatalBeanException("a top-level class or static inner class should be used instead")。
同理 EndpointDiscoverer 对"两个同 id 端点""多个扩展""重复 operation"都给了精确的
IllegalStateException 文案。让失败发生在启动期且说清怎么改,是框架对使用者最大的善意。
10. 本章未逐行核实 / 待你实测的项
按"最该先测"排序。
-
★ 子管理容器的 Environment 属性来源(§5.4)。 我在
ManagementContextFactory.createManagementContext()与ChildManagementContextInitializer.createManagementContext()里都没有找到 把父 Environment 的PropertySources复制给子 Environment 的代码;postProcessChildEnvironment只做了 ConversionService 与系统环境变量两件事。 但management.server.port确实能被读到,所以一定有我没找到的机制。 自测:在ChildManagementContextInitializer.createManagementContext()里managementContext.refresh()之前打断点,检查managementContext.getEnvironment().getPropertySources()的内容与顺序。 -
★
management.endpoint.health.probes.enabled与management.health.livenessstate.enabled的默认行为打架(§6.7)。 两个自动配置声明同名 bean,都带@ConditionalOnMissingBean(name = ...), 我按after = ...推断AvailabilityHealthContributorAutoConfiguration先执行且默认不注册、 由AvailabilityProbesAutoConfiguration兜底注册,未实测。 自测:/actuator/health看默认是否有livenessState/readinessState组件; 再分别设两个属性为 false 看变化。 -
management.endpoints.migrate-legacy-ids=true的实际覆盖面(§2.6)。 只有EndpointId.of(Environment, String)会走迁移逻辑, 而PropertiesEndpointAccessResolver用的是endpointId.toLowerCaseString()拼字符串 (不走EndpointId.of(Environment, ...)),所以迁移对access属性可能无效。 -
AdditionalHealthEndpointPath.validateValue是否真的限制"一段路径"(§6.7)。 Javadoc 这么说,我没读到实现体。自测:配server:/a/b看是否抛IllegalArgumentException。 -
read_only对shutdown是否等价于none(§4.3)。 由isInvocable()要求操作非空推得,未实测。 自测:设management.endpoint.shutdown.access=read_only后看/actuator里有没有shutdown。 -
SimpleStatusAggregator会静默丢弃未登记状态(§6.6)。 由filter(this::contains)推得。自测:自定义Status("WARMING_UP")且不配status.order, 看它是否影响聚合结果。 -
asHealthContributors()的阻塞行为(§6.11)。 响应式→阻塞适配必然要等待,未确认实现与线程安全性。 自测:注册一个ReactiveHealthIndicator,在阻塞端点里调用,看线程栈。 -
WebServerApplicationContext.GRACEFUL_SHUTDOWN_PHASE的具体数值(§5.5)。 我只确认了getPhase()返回GRACEFUL_SHUTDOWN_PHASE - 512,未读常量本身, 所以"管理服务器先启后停"的方向判断是基于 phase 语义的推断。 -
SslHealthIndicatorProperties.getCertificateValidityWarningThreshold()的默认值(§6.4)。 未读该 Properties 类。 -
@ConditionalOnEnabledHealthIndicator的布尔值大小写敏感性(§6.4)。 用environment.getProperty(name, Boolean.class, true), 依赖 Spring 默认StringToBooleanConverter,我未确认它对TRUE/On/YES的处理。 -
SslInfoContributor与SslHealthIndicator的SslInfobean 是否冲突(§7.1)。InfoContributorAutoConfiguration和SslHealthContributorAutoConfiguration都声明了SslInfobean(都带@ConditionalOnMissingBean),同名同类型,未确认谁赢。 -
HttpCodeStatusMapper的putIfAbsent+HashMap顺序不确定性(§6.6)。 理论上存在,实践上很少有人同时写两种大小写。 -
代码观察(非 bug):
HealthEndpointConfiguration里property + ".include".formatted(groupName)的formatted调用多余但无害 (".include"无占位符,多余参数被忽略)。疑似重构残留。
11. 小结与下一章
本章读完了 Actuator 的装配、暴露、聚合、多服务器四条主线。三个最值得记住的结构性事实:
- 4.x 把 health 拆成了独立模块(
spring-boot-health,96 个类,7 个自动配置), 包名从actuate.health.*迁到health.*,且它可以在没有 actuator 的情况下降级使用。 - "可用"与"暴露"是两个正交判定,分别由
PropertiesEndpointAccessResolver(四级 + 封顶)和IncludeExcludeEndpointFilter(include 空时回退默认)实现, 而include/exclude的空集语义不对称——这是最多人踩的坑。 - 管理端口子容器不是第二个
SpringApplication:它在父容器的SmartLifecycle.start()里refresh(),只注册两个@Configuration+ 从父容器偷来的 Web 服务器工厂类型, DispatcherServlet 的四个detectAll全关。端点 bean 在父、HandlerMapping 在子。
下一章(第 26 章)进入指标、观测与链路追踪:
spring-boot-micrometer-observation 模块、ObservationAutoConfiguration、
MeterRegistry 的复合与过滤、@Observed 切面、Prometheus 端点、
以及 OpenTelemetry 桥接——
本质上,可观测性的三根支柱(指标 / 日志 / 链路)在第 24 章讲了日志,
本章讲了健康(第四根支柱的雏形),下一章补齐剩下的两根。