Boot 集成条件与生产就绪实验(U24)
独立新增工程:Liberica JDK21.0.11+11、Boot4.0.9 / Framework7.0.10、Security7.0.8、Micrometer1.16.8、micrometer-tracing-bridge-otel1.6.8、OpenTelemetry SDK trace1.55.0;本机macOS27.0.1 arm64。依赖由4.0.9 parent/BOM管理,server的公开dependency:tree与es8的固定8.19.23客户端线留在日志。工程拆成三个互不混拼的类路径:probe(条件/替换探针)、server(真实Actuator运行时)、es8(ES8显式适配,不在聚合modules里,避免与Boot默认Rest5条件互染)。
从仓库根运行完整公开入口:
JAVA_HOME=$(/usr/libexec/java_home -v21) python3 labs/boot-readiness/run.py需要Python3、Maven3.9.9、本机JDK21,以及本机127.0.0.1可达的MySQL(root空密码)、Redis、MongoDB与本机ES 8.19.x(9200)。数据库只执行SELECT 1与连接借用;Mongo仅PING与库名核对(不写文档);Redis只用database 13与每次独有的u24:session:<uuid>命名空间;ES只读info()。MySQL凭据不同则该组如实失败,不猜其他凭据。
入口为所有子进程显式设置同一JAVA_HOME/PATH并核对主版本21。每个命令600秒上限;服务绑定loopback/port0,启动到真实STARTED/READY后发请求;finally定向终止、wait自有进程,必要强杀会判失败。原始日志在 ~/.cache/software-engineering-study/java-depth-impl/u24/runtime/,短产物为evidence。
条件与替换探针(probe)
mvn -q -pl probe test 用真实Boot/组件类跑ApplicationContextRunner,结果写target/probe-results.json、target/messaging-results.json、target/session-results.json。每个场景同时记录条件评估报告(matched/outcomes原文)。
| 组件 | 实际断言与观察 |
|---|---|
| JDBC | 默认Hikari连本机MySQL SELECT 1成功;用户DataSource使默认池退让且JdbcTemplate指向用户Bean;两个无Primary候选使JdbcTemplate消失;过滤EmbeddedDatabaseType类后整组退让 |
| Redis | 默认Lettuce工厂database13、真实PING;用户factory按类型退让;名为otherTemplate的同类型模板不使按名redisTemplate退让 |
| Mongo | 每次独有合成库名u24_<uuid>、PING ok、工厂database与URI一致;用户MongoClient退让;writes=0 |
| 缓存 | 无@EnableCaching时加starter也没有CacheManager;simple为ConcurrentMapCacheManager;用户CacheManager退让;redis缓存真实TTL≤5s且只删除本票key |
| 四种消息 | Kafka/Rabbit/JMS/Pulsar默认模板装配、各自退让点、JMS无ConnectionFactory则无JmsTemplate、Rabbit双factory无Primary则无template;brokerMessages=0,不做任何broker投递断言 |
| HTTP客户端 | 默认RestClient.Builder为prototype;用户Builder按类型退让 |
| Session | Servlet分支默认RedisSessionRepository在独有namespace真实保存/读取;indexed为RedisIndexedSessionRepository且configure-action=none时ConfigureRedisAction为NO_OP;用户SessionRepository接管使默认退让;过滤Session类整组退让;两组都只删除本票namespace下的key |
ES8 显式适配(es8)
mvn -f es8/pom.xml dependency:tree核对elasticsearch-java:8.19.23。探针先尝试在8.x-only类路径上加载Boot默认ES自动配置:真实结果是配置类内省失败(NoClassDefFoundError: co/elastic/clients/transport/rest5_client/Rest5ClientTransport),不是"条件不匹配"的优雅退让——默认链面向Rest5客户端线,8.x适配不能靠条件判读混进去。随后用用户Bean显式接线低层RestClient+RestClientTransport+ElasticsearchClient(Jackson3JsonpMapper),真实info()返回服务端8.19.23,writes=0,Context关闭后自有RestClient确实不再运行。
真实Actuator运行时(server)
server是可执行Boot应用:actuator+security+restclient+opentelemetry starter,RecordingExporter把OTel SDK Span收在本进程内(不外发Collector),LabController暴露受控业务入口,ControlEndpoint是自定义@Endpoint(READ+WRITE)。Python入口用自有依赖fixture(/dependency健康、/echo回显traceparent与定制header)驱动三组场景:
| 场景 | 实际断言与观察 |
|---|---|
| default-security | 匿名/actuator/health200、/actuator/health/readiness200且UP;匿名/actuator/info401、ops鉴权后404(未暴露);managementSecurityFilterChain与默认用户源在容器中 |
| custom-security | 用户SecurityFilterChain+UserDetailsService使默认管理链与默认用户源退让;info 401/错密码401/reader 403/ops 200且build.artifact=readiness-server;labcontrol端点READ+WRITE;/actuator/env200但合成私值不出现;show-details=when-authorized:匿名与reader看不到components、ops看得到;/readyz、/livez在业务端口返回200 UP;计数器u24.orders.accepted真实值3;审计事件含AUTHENTICATION_SUCCESS/AUTHORIZATION_FAILURE/AUTHENTICATION_FAILURE/U24_ORDER_REPLAY;带traceparent调用下游回显同一traceId与auto-customizerheader,裸RestClient.create()两者皆空;SDK span SERVER/CLIENT均已结束 |
| readiness-timeline | --management.server.port=0独立管理端口;业务与管理端口都在started事件前已监听;runner用release文件占住在started与ready之间:此时/actuator/health/readiness503 OUT_OF_SERVICE、/actuator/health/liveness200 UP、/lab/gated503;释放后ready、readiness 200 UP、/lab/gated200 |
Boot4版本差异如实记录:就绪/存活探针的状态串是UP/OUT_OF_SERVICE/DOWN(ReadinessStateHealthIndicator把ACCEPTING_TRAFFIC映射为UP、REFUSING_TRAFFIC映射为OUT_OF_SERVICE),不是Boot3的ACCEPTING_TRAFFIC字面量;探针默认启用(management.endpoint.health.probes.enabled matchIfMissing=true),/livez、/readyz附加路径由management.endpoint.health.probes.add-additional-paths=true加到业务端口。
只重跑受影响的组
完整入口默认重建并跑全部(probe→es8→server构建→两组运行时→时间线→evidence)。三段之间没有共享状态:只改探针断言可单独 mvn -q -pl probe test;只改server源码可 mvn -q -pl server package 后用run.py同参数重启。evidence只在完整入口下重写,不把分段运行拼成一次调用。
完整文件
公开入口与聚合构建:run.py、pom.xml。probe:pom.xml、ConditionProbeTest.java、MessagingProbeTest.java、SessionProbeTest.java。server:pom.xml、application.properties、ReadinessApp.java、LabController.java、ReadyConfiguration.java、RecordingExporter.java、ControlEndpoint.java。es8:pom.xml、Es8Probe.java。文件由站点按原路径发布;使用仓库或下载时保留目录关系。
单一实现
管理安全与默认链的分界:
package study.ready;
import org.springframework.boot.actuate.audit.*;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.health.contributor.*;
import org.springframework.boot.restclient.RestClientCustomizer;
import org.springframework.context.annotation.*;
import org.springframework.core.env.Environment;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.core.userdetails.*;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.web.client.RestClient;
@Configuration(proxyBeanMethods=false)
class ReadyConfiguration {
@Bean RecordingExporter recordingExporter(){return new RecordingExporter();}
@Bean RestClientCustomizer labHeader(){return builder->builder.defaultHeader("X-U24-Client","auto-customizer");}
@Bean @ConditionalOnProperty(name="lab.audit",havingValue="true",matchIfMissing=true)
InMemoryAuditEventRepository auditRepository(){var repository=new InMemoryAuditEventRepository();repository.setCapacity(64);return repository;}
@Bean HealthIndicator orderDependencyHealthIndicator(Environment env){
String url=env.getRequiredProperty("lab.dependency-url");
return ()->{try{String response=RestClient.create().get().uri(url).retrieve().body(String.class);return "up".equals(response)?Health.up().build():Health.down().withDetail("reason","unexpected synthetic dependency body").build();}catch(RuntimeException error){return Health.down().withDetail("failure",error.getClass().getSimpleName()).build();}};
}
@Configuration(proxyBeanMethods=false)
@ConditionalOnProperty(name="lab.custom-security",havingValue="true")
static class CustomSecurity {
@Bean UserDetailsService users(){return new InMemoryUserDetailsManager(User.withUsername("ops").password("{noop}ops-password").roles("OPS").build(),User.withUsername("reader").password("{noop}reader-password").roles("READER").build());}
@Bean SecurityFilterChain customChain(HttpSecurity http)throws Exception{
http.authorizeHttpRequests(auth->auth.requestMatchers("/actuator/health/**","/livez","/readyz","/lab/open","/lab/gated").permitAll().requestMatchers("/actuator/**","/lab/**").hasRole("OPS").anyRequest().authenticated());
http.httpBasic(Customizer.withDefaults());return http.build();
}
}
}package study.ready;
import java.nio.file.*;
import java.util.*;
import java.util.concurrent.TimeUnit;
import org.springframework.boot.*;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.event.*;
import org.springframework.boot.web.server.context.WebServerInitializedEvent;
import org.springframework.context.annotation.Bean;
import org.springframework.context.event.EventListener;
import org.springframework.core.env.Environment;
@SpringBootApplication
public class ReadinessApp {
static final List<String> events=new java.util.concurrent.CopyOnWriteArrayList<>();
public static void main(String[] args){SpringApplication.run(ReadinessApp.class,args);}
@EventListener void bound(WebServerInitializedEvent event){
String namespace=event.getApplicationContext().getServerNamespace();
System.out.println((namespace==null?"BUSINESS_PORT ":"MANAGEMENT_PORT ")+event.getWebServer().getPort());events.add(namespace==null?"business-listening":"management-listening");
}
@EventListener void started(ApplicationStartedEvent event){events.add("started");System.out.println("STARTED");}
@EventListener void ready(ApplicationReadyEvent event){events.add("ready");System.out.println("READY");}
@Bean ApplicationRunner runner(Environment env){return args->{
events.add("runner-enter");String file=env.getProperty("lab.release-file");
if(file!=null){long end=System.nanoTime()+TimeUnit.SECONDS.toNanos(20);while(!Files.exists(Path.of(file))){if(System.nanoTime()>end)throw new IllegalStateException("controlled runner was not released");Thread.sleep(20);}}
events.add("runner-exit");System.out.println("RUNNER_EXIT");
};}
}业务入口、指标/追踪/审计断言在LabController.java;自定义端点在ControlEndpoint.java;进程内Span导出器在RecordingExporter.java;条件/替换/会话探针与ES8适配在probe与es8模块。正文直接引用这些实际编译文件,不复制第二套实现。
未运行
真实Kafka/RabbitMQ/Artemis/Pulsar broker投递与确认、ES写入与查询语义、Mongo写入、生产Collector/注册表、K8s探针服务与摘流、指标基数预算与审计持久化、Jetty/Netty/外置容器、Native镜像。它们不由本票loopback测试结果抵扣,分别由U48及broker票、U18/U27、U64–U66、U28等承接。