Spring Boot 4.1.1 深度源码解析-第 25 章 · Actuator 与健康:端点机制、`HealthIndicator`、管理端口子上下文

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。启动后执行:

bash 复制代码
$ 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 四个模块

bash 复制代码
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 第一层:注解

java 复制代码
// 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 自己的开关。

全仓库只有一个端点覆写了它:

java 复制代码
// 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 有三种类型,由方法注解决定:

java 复制代码
// 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);
}

发现过程:

java 复制代码
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) 里的实现:

java 复制代码
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------没有冲突检测。这是一个可以踩的坑,但源码没有告警。

找到注解后:

java 复制代码
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 提供:

java 复制代码
@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 级缓存

java 复制代码
@Bean
@ConditionalOnMissingBean
CachingOperationInvokerAdvisor endpointCachingOperationInvokerAdvisor(Environment environment) {
    return new CachingOperationInvokerAdvisor(new EndpointIdTimeToLivePropertyFunction(environment));
}

EndpointIdTimeToLivePropertyFunction 读的属性名是:

java 复制代码
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> 是发现器基类, 其成员:

java 复制代码
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 章见过的同款写法):

java 复制代码
public final Collection<E> getEndpoints() {
    Collection<E> endpoints = this.endpoints;
    if (endpoints == null) {
        endpoints = discoverEndpoints();
        this.endpoints = endpoints;
    }
    return endpoints;
}

为什么敢不加锁?因为 discoverEndpoints() 是幂等的,最坏情况是两个线程各算一遍, 结果等价。这比 synchronized 便宜得多。

2.5 发现流程的五步

java 复制代码
private Collection<E> discoverEndpoints() {
    Collection<EndpointBean> endpointBeans = createEndpointBeans();   // ①
    addExtensionBeans(endpointBeans);                                  // ②
    return convertToEndpoints(endpointBeans);                          // ③④⑤
}

① createEndpointBeans()

java 复制代码
String[] beanNames = BeanFactoryUtils.beanNamesForAnnotationIncludingAncestors(
        this.applicationContext, Endpoint.class);

IncludingAncestors 是关键:管理端口子上下文(§5)可以从父容器拿到端点 bean 定义。 跳过 ScopedProxyUtils.isScopedTarget(beanName)(代理目标不算)。

如果两个 bean 的 EndpointId 相同,直接抛:

java 复制代码
throw new IllegalStateException("Found two endpoints with the id '" + endpointBean.getId() + "': '"
        + endpointBean.getBeanName() + "' and '" + previous.getBeanName() + "'");

② addExtensionBeans()

@EndpointExtension 是"给已有端点补技术专属能力"的机制。 例如 HealthEndpoint 是技术无关的(返回 HealthDescriptor), HealthEndpointWebExtension 给它补上"返回 HTTP 状态码"的能力(返回 WebEndpointResponse<...>)。

java 复制代码
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():一个端点最多一个扩展

java 复制代码
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 的合并与"替换最后一个"

java 复制代码
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 抓到:

java 复制代码
throw new IllegalStateException("Unable to map duplicate endpoint operations: " + duplicates + " to "
        + endpointBean.getBeanName() + (extensions.isEmpty() ? "" : " (" + extensionBeanNames + ")"));

⑤ isInvocable()

java 复制代码
protected boolean isInvocable(E endpoint) {
    return !endpoint.getOperations().isEmpty();
}

一个端点如果所有操作都被 OperationFilter 过滤掉了(比如 access=NONE), 它就不会出现在 getEndpoints() 里。没有操作的端点等于不存在。

2.6 EndpointId:宽松得令人意外的相等性

java 复制代码
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。于是

ini 复制代码
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 只差一个字段名

java 复制代码
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:

java 复制代码
@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 注解定义

java 复制代码
@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 方法的返回类型推断:

java 复制代码
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:

java 复制代码
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 判定主流程

java 复制代码
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 判定

java 复制代码
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 层面把写操作滤掉的,不影响"端点存在与否"。

注意缓存:

java 复制代码
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 的解析顺序(四级):

java 复制代码
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 里同样处理。 这是一条很好的工程实践------迁移期的新旧属性不能并存,否则行为不可预测。

最后还有一个封顶:

java 复制代码
this.maxPermittedAccess = properties.getProperty(
        "management.endpoints.access.max-permitted", Access.class, Access.UNRESTRICTED);
...
.cap(this.maxPermittedAccess)
java 复制代码
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 判定

java 复制代码
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:

java 复制代码
public enum EndpointExposure {
    JMX("health"),
    WEB("health");
    private final String[] defaultIncludes;
}

两个技术的默认 include 都只有 health 。这就是为什么新应用开箱只有 /actuator/health。

贡献者集合:

java 复制代码
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;
}

三处细节:

  1. WEB 永远参与判定,JMX 要看 spring.jmx.enabled(默认 false)。 所以纯 Web 应用里 JMX 那个 contributor 根本不存在。
  2. 第三方可以通过 spring.factories 的 org.springframework.boot.actuate.autoconfigure.endpoint.condition.EndpointExposureOutcomeContributor 键追加贡献者,构造器可以带 Environment 参数(ArgumentResolver.of(Environment.class, environment))。
  3. 又是 Environment 作 key 的缓存。

StandardExposureOutcomeContributor:

java 复制代码
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 空时的语义

java 复制代码
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 的通配符处理:

java 复制代码
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) 还有一个"放行"逻辑:

java 复制代码
@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 清单

java 复制代码
@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() 是标准写法:

java 复制代码
@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 与那个"启动失败"

java 复制代码
@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 在这里生效

java 复制代码
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,所以:

ini 复制代码
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:

java 复制代码
return LambdaSafe.callbacks(OperationFilter.class, this.operationFilters, operation)
    .invokeAnd((f) -> f.match(operation, endpointId, defaultAccess))
    .getResult();

LambdaSafe 的作用是:即使某个 filter 抛异常,也只记日志不让整个发现过程崩掉 (第 14 章讲过它的实现)。

4.4 JMX 侧

java 复制代码
@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:

java 复制代码
@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 端口类型判定

java 复制代码
// 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 应用不匹配

java 复制代码
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。写成:

java 复制代码
resourceLoader instanceof WebApplicationContext   // 直接这么写

会在 classpath 没有 spring-web 时抛 NoClassDefFoundError(instanceof 需要加载类型)。 所以必须先 ClassUtils.isPresent。这是"可选依赖用类名探测"定律的又一例 (第 22 章 DataSourceBuilder.lookupPooled 用字符串类名探测连接池,同一套路)。

5.3 ManagementContextAutoConfiguration:两条互斥分支

java 复制代码
@AutoConfiguration
@AutoConfigureOrder(Ordered.LOWEST_PRECEDENCE)
public final class ManagementContextAutoConfiguration { ... }

LOWEST_PRECEDENCE------它必须排在最后,因为它要读 server.port / management.server.port, 而这些值可能来自任何地方;更重要的是它要等所有 Web 自动配置决定完。

分支 A:SAME

java 复制代码
@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");
    }
}

三件事:

  1. 校验 :同端口时不允许配 management.server.ssl.enabled=true 或 management.server.address。 这是"配置与运行时语义冲突"的提前拦截------同端口只有一个服务器,无法同时有两个 SSL 配置。 注意用的是 Assert.state(IllegalStateException)而不是失败分析器, 所以报错是裸的 IllegalStateException,没有漂亮的诊断文本。
  2. local.management.port 别名:
java 复制代码
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}"))。

  1. @EnableManagementContext(ManagementContextType.SAME) ------ 见 §5.7。

分支 B:DIFFERENT

java 复制代码
@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 服务器模块提供

java 复制代码
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 那个的完整定义:

java 复制代码
@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 的创建逻辑:

java 复制代码
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():

java 复制代码
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), 一定存在某种机制,可能在以下位置之一(均未核实):

  1. ConfigurableApplicationContext.setParent() 在 refresh() 时通过 getInternalParentBeanFactory() 让 bean 查找走父容器,但属性不走。
  2. PropertySourcesPlaceholderConfigurer / PropertyPlaceholderAutoConfiguration 被注册进子上下文(确实注册了,见下),它读的是子 Environment。
  3. 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

java 复制代码
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;
    }
}

三个要点:

  1. start() 里 refresh() :子容器是在父容器的 Lifecycle 启动阶段 被刷新的, 不是一个独立的 SpringApplication.run()。它没有 ApplicationRunner、 没有 SpringApplicationRunListener、没有 banner、没有 ApplicationStartedEvent。 ------这解释了为什么管理端口子容器里 @PostConstruct 会跑,但 ApplicationRunner 不会。
  2. phase = GRACEFUL_SHUTDOWN_PHASE - 512 :比优雅关闭早 512。 我未核实 GRACEFUL_SHUTDOWN_PHASE 的具体数值(它在 WebServerApplicationContext 里), 但方向明确:管理服务器要比主服务器先启动、后关闭 吗? start() 按 phase 升序,值越小越先启动 → 管理服务器先启动 。 stop() 按 phase 降序 → 管理服务器后关闭。 即:管理端口先于业务端口可用,业务端口先于管理端口下线。这个顺序是合理的。
  3. isRunning() / stop() 的幂等 :stop() 里判断 if (this.parentContext.isClosed()) managementContext.close(); else managementContext.stop(); ------父容器已关闭则彻底关掉子容器,否则只 stop(可以被再次 start)。

registerBeans:

java 复制代码
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 相关的那一小撮。 这是"管理端口轻量"的根本原因。

懒初始化的传播:

java 复制代码
private boolean isLazyInitialization() {
    List<BeanFactoryPostProcessor> postProcessors = this.parentContext.getBeanFactoryPostProcessors();
    return postProcessors.stream().anyMatch(LazyInitializationBeanFactoryPostProcessor.class::isInstance);
}

通过检查父容器 的 BFPP 列表里有没有 LazyInitializationBeanFactoryPostProcessor 来判断, 而不是读 spring.main.lazy-initialization 属性。因为父容器的决定已经物化成 BFPP 了。

registerWebServerFactoryBeans:

java 复制代码
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 的导入

java 复制代码
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Import(ManagementContextConfigurationImportSelector.class)
@interface EnableManagementContext {
    ManagementContextType value();
}

ManagementContextConfigurationImportSelector 是 DeferredImportSelector (延迟导入,排在所有常规 @Import 之后)+ BeanClassLoaderAware, 自身 @Order(Ordered.LOWEST_PRECEDENCE)。

java 复制代码
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:

java 复制代码
@ManagementContextConfiguration(value = ManagementContextType.CHILD, proxyBeanMethods = false)
@ConditionalOnWebApplication(type = Type.SERVLET)
@ConditionalOnClass(DispatcherServlet.class)
@EnableWebMvc
class WebMvcEndpointChildContextConfiguration { ... }

排序规则有个陷阱,Javadoc 明确写了:

ManagementContextConfiguration classes can be ordered using @Order. Ordering by implementing Ordered is not supported and will have no effect.

因为 readOrder 只读 @Order 注解:

java 复制代码
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

java 复制代码
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)三步,顺序有语义:

  1. customizeSameAsParentContext(factory):去父容器找同类型定制器应用一遍 (注释原文:"so e.g. the access logs go to the same place")。
  2. factory.setErrorPages(Collections.emptySet()):清空错误页 ------第 1 步可能把业务的 /error 页面带过来,而管理服务器有自己的 ManagementErrorEndpoint。
  3. 设置 port / ssl / serverHeader(取父 ServerProperties 的 ,与主服务器一致)/ address。

AccessLogCustomizer 是访问日志前缀的基类(getOrder() = 1,比上面晚执行): customizePrefix 用 startsWith 判断保证幂等(重复调用不叠加前缀)。

5.10 失败传播与关闭传播

java 复制代码
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 可见性小结

ruby 复制代码
父容器(业务)
├── 业务 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):

java 复制代码
@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 的自动配置:

java 复制代码
@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, ...) { ... }
    }
}

四点:

  1. Map<String, HealthContributor> 注入 :Spring 会把容器里所有 HealthContributor 类型的 bean 按 beanName → bean 收集成 Map 注入(第 13 章讲过这个特性)。 所以"定义一个健康指示器 = 注册一个 HealthContributor bean",没有额外的注册 API。
  2. 响应式注册表需要 Flux 在 classpath (@ConditionalOnClass(Flux.class))。 没有 Reactor 就不建 ReactiveHealthContributorRegistry。
  3. 名字生成器默认 withoutStandardSuffixes(),见下。
  4. 两个注册表默认都会建 (有 Reactor 时),且互不共享 : 阻塞型只收 HealthContributor,响应式只收 ReactiveHealthContributor。 这就是为什么 HealthEndpoint 的构造器里有"主注册表 + fallback 注册表"两个参数。

6.2 名字生成器:去掉 HealthIndicator 后缀

java 复制代码
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 转成"注册动作":

java 复制代码
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:写时复制 + 监视器

java 复制代码
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:

java 复制代码
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 只有一个:

java 复制代码
@AutoConfiguration
public final class HealthContributorAutoConfiguration {
    @Bean
    @ConditionalOnEnabledHealthIndicator("ping")
    PingHealthIndicator pingHealthContributor() { return new PingHealthIndicator(); }
}
java 复制代码
public class PingHealthIndicator extends AbstractHealthIndicator {
    @Override
    protected void doHealthCheck(Health.Builder builder) throws Exception { builder.up(); }
}

ping 恒 UP 的意义:保证 /actuator/health 永远有内容 。 即使你关掉了所有其他指示器,返回体里至少有一个 ping, 于是 components 非空、status 有值,不会出现空对象。

@ConditionalOnEnabledHealthIndicator 的判定:

java 复制代码
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)。

两个陷阱:

  1. filter(this::contains) 会丢掉不在 order 列表里的自定义状态 ------它们对聚合结果完全无影响。 想让 Status("WARMING_UP") 生效,必须显式配 management.endpoint.health.status.order=...。
  2. 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 接口:

java 复制代码
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 的构造:

java 复制代码
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 的"非限定"语义:

java 复制代码
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:

java 复制代码
StatusAggregator statusAggregator = getQualifiedBean(beanFactory, StatusAggregator.class, groupName,
        () -> createStatusAggregator(status.getOrder(), defaultStatusAggregator));

按组名作限定符去找。所以:

java 复制代码
@Bean @Qualifier("live") StatusAggregator liveAggregator() { ... }

配合 management.endpoint.health.group.live.* 就能让 live 组用独立的聚合器。

groups 用 TreeMap (new TreeMap<>()),所以 getNames() 返回的是字母序 , 且最终被 Collections.unmodifiableMap 包住。

可用性探针 是本章最大的坑。AvailabilityProbesAutoConfiguration:

java 复制代码
@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(装饰器):

java 复制代码
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 的来源:

java 复制代码
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 的硬编码:

java 复制代码
@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:

java 复制代码
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:一次请求的完整算法

主入口先判断第一个路径段是不是组名:

java 复制代码
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 下附加路径不可用。

核心递归里有三行决定了全部可观测行为:

java 复制代码
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)。

叶子节点上做组过滤:

java 复制代码
if (name.isEmpty() || group.isMember(name)) {
    return getDescriptorAndLogIfSlow(contributor, name, showDetails);
}
return null;                          // ★ 不在组内 → 该组件不出现

IncludeExcludeHealthContributorMembership 对带 / 的名字会逐级上溯:

java 复制代码
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)。

最后聚合:

java 复制代码
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 两个校验器

名字与组冲突:

java 复制代码
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 到底指组还是指组件就说不清了。

组配置里的名字不存在:

java 复制代码
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 里:

java 复制代码
(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
java 复制代码
@ReadOperation
public HealthDescriptor health() {
    HealthDescriptor health = health(ApiVersion.V3, EMPTY_PATH);
    return (health != null) ? health : IndicatedHealthDescriptor.UP;   // ★ 兜底
}

IndicatedHealthDescriptor.UP 是一个 static final 常量:

java 复制代码
static final IndicatedHealthDescriptor UP = new IndicatedHealthDescriptor(Health.up().build());

Web 侧的兜底逻辑更细:

java 复制代码
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:

java 复制代码
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 接口抹平阻塞/响应式差异

java 复制代码
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,还有一个内部类条件:

java 复制代码
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------条件很有意思:

java 复制代码
@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 的四个自动配置如何串起来

kotlin 复制代码
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 显示细节"

properties 复制代码
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 "我想加一个自定义健康指示器"

java 复制代码
@Component
public class MyHealthIndicator implements HealthIndicator {
    @Override public Health health() {
        return Health.up().withDetail("version", "1.0").build();
    }
}

会发生的四件事:

  1. bean 名 myHealthIndicator → 经 withoutStandardSuffixes() 变成 my
  2. 被 Map<String, HealthContributor> 收集进 DefaultHealthContributorRegistry
  3. 名字与任何组名冲突 → GroupsHealthContributorNameValidator 启动失败
  4. 组配置里引用了不存在的名字 → HealthContributorMembershipValidator 抛 FailureAnalyzedException

想保留 HealthIndicator 后缀就用 HealthContributorNameGenerator.unchanged() 注册一个 bean。

8.3 "我想要 /livez 和 /readyz"

两个属性都要开:

properties 复制代码
management.endpoint.health.probes.enabled=true                      # 默认已是 true
management.endpoint.health.probes.add-additional-paths=true          # ★ 默认 false

且附加路径只在 WebServerNamespace.SERVER 上(源码硬编码), 即挂在主端口,不会自动挂到管理端口。要挂管理端口得自己写组:

properties 复制代码
management.endpoint.health.group.live.additional-path=management:/livez
management.endpoint.health.group.live.include=livenessState

8.4 "我想把管理端点独立到 8081"

properties 复制代码
management.server.port=8081

会发生的连锁反应:

  1. ManagementPortType → DIFFERENT
  2. TomcatServletManagementContextAutoConfiguration(或其它 Web 服务器模块)注册 ManagementContextFactory
  3. ChildManagementContextInitializer 作为 SmartLifecycle 在 phase GRACEFUL_SHUTDOWN_PHASE - 512 启动
  4. refresh() 一个 id 为 <parentId>:management、serverNamespace 为 management 的新容器
  5. 新容器里注册 ManagementContextWebServerFactory(从父偷来的类型)+ 两个 @Configuration
  6. 导入 16 个 @ManagementContextConfiguration 候选中所有 ANY/CHILD 的(按 @Order 排序)
  7. WebMvcEndpointChildContextConfiguration 建一个 detectAll 全 false 的 DispatcherServlet
  8. 端点 bean 仍来自父容器(beanNamesForAnnotationIncludingAncestors)

此时不要 配 management.server.ssl.enabled=true(同端口才禁)------反而是它唯一的用途。

8.5 "我的端点没出现在 /actuator"

按此顺序排查:

  1. management.endpoints.web.exposure.include 是否包含它(默认只有 health)
  2. management.endpoint.<id>.access 是否是 none
  3. management.endpoints.access.max-permitted 是否把它封顶了
  4. 端点是否没有任何 operation (isInvocable() 要求非空) ------ 典型:access=read_only 而端点只有写操作
  5. @ConditionalOnAvailableEndpoint 的 exposure 是否指定得比实际窄
  6. 是不是在子容器里找一个只在父容器注册的端点(或反之)
  7. 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. 本章未逐行核实 / 待你实测的项

按"最该先测"排序。

  1. ★ 子管理容器的 Environment 属性来源 (§5.4)。 我在 ManagementContextFactory.createManagementContext() 与 ChildManagementContextInitializer.createManagementContext() 里都没有找到 把父 Environment 的 PropertySources 复制给子 Environment 的代码; postProcessChildEnvironment 只做了 ConversionService 与系统环境变量两件事。 但 management.server.port 确实能被读到,所以一定有我没找到的机制。 自测:在 ChildManagementContextInitializer.createManagementContext() 里 managementContext.refresh() 之前打断点,检查 managementContext.getEnvironment().getPropertySources() 的内容与顺序。

  2. ★ management.endpoint.health.probes.enabled 与 management.health.livenessstate.enabled 的默认行为打架 (§6.7)。 两个自动配置声明同名 bean,都带 @ConditionalOnMissingBean(name = ...), 我按 after = ... 推断 AvailabilityHealthContributorAutoConfiguration 先执行且默认不注册、 由 AvailabilityProbesAutoConfiguration 兜底注册,未实测 。 自测:/actuator/health 看默认是否有 livenessState / readinessState 组件; 再分别设两个属性为 false 看变化。

  3. management.endpoints.migrate-legacy-ids=true 的实际覆盖面 (§2.6)。 只有 EndpointId.of(Environment, String) 会走迁移逻辑, 而 PropertiesEndpointAccessResolver 用的是 endpointId.toLowerCaseString() 拼字符串 (不走 EndpointId.of(Environment, ...) ),所以迁移对 access 属性可能无效。

  4. AdditionalHealthEndpointPath.validateValue 是否真的限制"一段路径" (§6.7)。 Javadoc 这么说,我没读到实现体。自测:配 server:/a/b 看是否抛 IllegalArgumentException。

  5. read_only 对 shutdown 是否等价于 none (§4.3)。 由 isInvocable() 要求操作非空推得,未实测。 自测:设 management.endpoint.shutdown.access=read_only 后看 /actuator 里有没有 shutdown。

  6. SimpleStatusAggregator 会静默丢弃未登记状态 (§6.6)。 由 filter(this::contains) 推得。自测:自定义 Status("WARMING_UP") 且不配 status.order, 看它是否影响聚合结果。

  7. asHealthContributors() 的阻塞行为 (§6.11)。 响应式→阻塞适配必然要等待,未确认实现与线程安全性。 自测:注册一个 ReactiveHealthIndicator,在阻塞端点里调用,看线程栈。

  8. WebServerApplicationContext.GRACEFUL_SHUTDOWN_PHASE 的具体数值 (§5.5)。 我只确认了 getPhase() 返回 GRACEFUL_SHUTDOWN_PHASE - 512,未读常量本身, 所以"管理服务器先启后停"的方向判断是基于 phase 语义的推断。

  9. SslHealthIndicatorProperties.getCertificateValidityWarningThreshold() 的默认值(§6.4)。 未读该 Properties 类。

  10. @ConditionalOnEnabledHealthIndicator 的布尔值大小写敏感性 (§6.4)。 用 environment.getProperty(name, Boolean.class, true), 依赖 Spring 默认 StringToBooleanConverter,我未确认它对 TRUE/On/YES 的处理。

  11. SslInfoContributor 与 SslHealthIndicator 的 SslInfo bean 是否冲突 (§7.1)。 InfoContributorAutoConfiguration 和 SslHealthContributorAutoConfiguration 都声明了 SslInfo bean(都带 @ConditionalOnMissingBean),同名同类型,未确认谁赢。

  12. HttpCodeStatusMapper 的 putIfAbsent + HashMap 顺序不确定性(§6.6)。 理论上存在,实践上很少有人同时写两种大小写。

  13. 代码观察(非 bug) :HealthEndpointConfiguration 里 property + ".include".formatted(groupName) 的 formatted 调用多余但无害 (".include" 无占位符,多余参数被忽略)。疑似重构残留。


11. 小结与下一章

本章读完了 Actuator 的装配、暴露、聚合、多服务器四条主线。三个最值得记住的结构性事实:

  1. 4.x 把 health 拆成了独立模块 (spring-boot-health,96 个类,7 个自动配置), 包名从 actuate.health.* 迁到 health.*,且它可以在没有 actuator 的情况下降级使用。
  2. "可用"与"暴露"是两个正交判定 ,分别由 PropertiesEndpointAccessResolver (四级 + 封顶)和 IncludeExcludeEndpointFilter(include 空时回退默认)实现, 而 include/exclude 的空集语义不对称------这是最多人踩的坑。
  3. 管理端口子容器不是第二个 SpringApplication :它在父容器的 SmartLifecycle.start() 里 refresh(),只注册两个 @Configuration + 从父容器偷来的 Web 服务器工厂类型, DispatcherServlet 的四个 detectAll 全关。端点 bean 在父、HandlerMapping 在子。

下一章(第 26 章)进入指标、观测与链路追踪 : spring-boot-micrometer-observation 模块、ObservationAutoConfiguration、 MeterRegistry 的复合与过滤、@Observed 切面、Prometheus 端点、 以及 OpenTelemetry 桥接------ 本质上,可观测性的三根支柱(指标 / 日志 / 链路)在第 24 章讲了日志, 本章讲了健康(第四根支柱的雏形),下一章补齐剩下的两根。

相关推荐
鶴哥只手遮天1 小时前
Spring Boot 4.1.1 深度源码解析-第 27 章 · 可执行归档与 Loader:`JarLauncher` 与嵌套 jar
java·后端·开源
鶴哥只手遮天1 小时前
Spring Boot 4.1.1 深度源码解析-第 16 章 · 排序、元数据与报告:`AutoConfigurationSorter`、配置元数据、条件报告
java·后端·开源
鶴哥只手遮天1 小时前
Spring Boot 4.1.1 深度源码解析-第 14 章 · 自动配置引擎:`AutoConfigurationImportSelector` 全流程
java·后端·开源
鶴哥只手遮天1 小时前
Spring Boot 4.1.1 深度源码解析-第 24 章 · 日志系统:`LoggingApplicationListener` 与结构化日志
java·后端·开源
鶴哥只手遮天1 小时前
Spring Boot 4.1.1 深度源码解析-第 22 章 · 数据源、连接池与事务
java·后端·开源
鶴哥只手遮天1 小时前
Spring Boot 4.1.1 深度源码解析-第 17 章 应用类型推断与上下文工厂:`WebApplicationType`
java·后端·开源
鶴哥只手遮天1 小时前
Spring Boot 4.1.1 深度源码解析-第 23 章 · JPA / Hibernate 与持久化模块
java·后端·开源
鶴哥只手遮天1 小时前
Spring Boot 4.1.1 深度源码解析-第 12 章 · 事件总线与 Bootstrap 上下文
java·后端·开源
鶴哥只手遮天1 小时前
Spring Boot 4.1.1 深度源码解析-第 05 章 · `ApplicationProperties` 与 `spring.main.*`:启动配置
java·后端·开源