上个月我干了一件特别没有技术含量的事:把订单服务、库存服务、售后服务三个仓库里各一份的短消息通知代码翻出来对了一遍。发送逻辑差不多,重试次数是三个魔法数字,渠道配置三个 yml 各写各的。产品说把重试从 2 次调成 3 次,我改一处配置的活儿硬是变成了改三个仓库、发三次版、回归三遍。这类代码不属于任何一个业务,属于「所有服务都要用一份配置管理起来」的公共设施,starter 就是为它准备的封装形态。
这篇我用 Spring Boot 4.1.1 把通知能力收进一个自研 starter:双模块结构是我对着官方制品解包反推出来的,属性类、条件装配、注册文件一个不少;自动配置的每个条件分支我都用 ApplicationContextRunner 写了断言,注册文件错位会怎么样我也起了真实应用做反例,全部数字来自同一次运行。看完能带走一套可以直接抄的双模块工程骨架。
starter 的解剖事实
动手写之前我先拆官方制品,想看一个标准的 starter 到底由什么组成。下面这些全部来自本地仓库 4.1.1 制品解包,不是文档转述。
spring-boot-starter-aspectj 和 spring-boot-starter-batch-jdbc 两个 jar 解开,内容少得出乎我的意料:
1
2
3
4
spring-boot-starter-aspectj-4.1.1.jar
├── META-INF/LICENSE.txt
├── META-INF/NOTICE.txt
└── META-INF/MANIFEST.MF Spring-Boot-Jar-Type: dependencies-starter
零 class、零配置文件。starter 本体是空壳,MANIFEST 里的 Spring-Boot-Jar-Type: dependencies-starter 写明了它的角色:只做依赖聚合。引一个 starter 坐标,等于把一组版本受管的依赖带进 classpath。
代码在另一半。我把 spring-boot-autoconfigure-4.1.1.jar 解开,META-INF/spring/ 目录下就两个文件:
1
2
3
META-INF/spring/
├── aot.factories
└── org.springframework.boot.autoconfigure.AutoConfiguration.imports
AutoConfiguration.imports 是自动配置的注册文件,每行一个全限定类名,# 开头是注释。Boot 启动时按这个固定路径扫描所有 jar,把列出的类交给容器处理。jar 里没有 spring.factories,自动配置注册只认这一个文件。
自动配置类本身长什么样,@AutoConfiguration 注解的字节码给出答案,我用 javap 解出的属性清单:
1
2
3
4
org.springframework.boot.autoconfigure.AutoConfiguration
String value() 注册名,缺省用类名
Class<?>[] before() String[] beforeName() 排在其他自动配置之前
Class<?>[] after() String[] afterName() 排在其他自动配置之后
注解元标注了 @Configuration(proxyBeanMethods = false),自动配置类不必再写 @Configuration。条件注解都在 org.springframework.boot.autoconfigure.condition 包:@ConditionalOnClass 类路径检查、@ConditionalOnMissingBean 容器让位、@ConditionalOnProperty 属性开关。属性类的 @ConfigurationProperties 和 @EnableConfigurationProperties 在 org.springframework.boot.context.properties 包,位于 spring-boot jar。
这么拆下来,一个自研 starter 要做的事就三件:autoconfigure 模块写代码加注册文件,starter 模块写 pom 聚合依赖,条件注解控制装配。工程骨架我照这个结论搭。
双模块结构与依赖
工程三个模块:autoconfigure 是主角,starter 是空壳,app 是演示应用。
1
2
3
4
springboot4-starter/
├── order-notify-spring-boot-autoconfigure 代码与注册文件
├── order-notify-spring-boot-starter 空壳聚合,只有 pom
└── order-notify-app 引用 starter 的演示应用
aggregator 的 pom 继承 spring-boot-starter-parent:4.1.1,三个模块的版本全部交给 BOM:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<parent>
<groupId> org.springframework.boot</groupId>
<artifactId> spring-boot-starter-parent</artifactId>
<version> 4.1.1</version>
<relativePath/>
</parent>
<groupId> com.xncoding</groupId>
<artifactId> springboot4-starter</artifactId>
<version> 1.0.0</version>
<packaging> pom</packaging>
<modules>
<module> order-notify-spring-boot-autoconfigure</module>
<module> order-notify-spring-boot-starter</module>
<module> order-notify-app</module>
</modules>
autoconfigure 模块的依赖清单是整个 starter 里最值得照抄的部分。spring-boot-autoconfigure 提供全部注解,两个 optional 依赖各管一件事:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
<dependency>
<groupId> org.springframework.boot</groupId>
<artifactId> spring-boot-autoconfigure</artifactId>
</dependency>
<dependency>
<groupId> org.slf4j</groupId>
<artifactId> slf4j-api</artifactId>
<optional> true</optional>
</dependency>
<dependency>
<groupId> tools.jackson.core</groupId>
<artifactId> jackson-databind</artifactId>
<optional> true</optional>
</dependency>
slf4j 不随 spring-boot-autoconfigure 传递,要日志就得自己声明。Jackson 3 标了 optional:编译期用得到,引用方不会传递得到它,classpath 上有没有 Jackson 由引用方自己决定,这个差别后面会变成一个真实的条件分支。starter 模块只有一个依赖:
1
2
3
4
5
6
7
<dependencies>
<dependency>
<groupId> com.xncoding</groupId>
<artifactId> order-notify-spring-boot-autoconfigure</artifactId>
<version> ${project.version}</version>
</dependency>
</dependencies>
引用方写 order-notify-spring-boot-starter 一个坐标,自动配置模块随它进入 classpath。和官方 starter 的分工完全一致:starter 管依赖聚合,autoconfigure 管装配。
核心代码
属性类:order.notify 前缀三个属性
属性是 starter 的配置面。属性类用 @ConfigurationProperties 标前缀,字段加 setter,绑定交给 Boot:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
@ConfigurationProperties ( prefix = "order.notify" )
public class OrderNotifyProperties {
/** 是否启用订单通知,false 时自动配置整体退场,容器里不会出现任何相关 bean */
private boolean enabled = true ;
/** 发送失败后的重试次数,0 表示只发一次不重试 */
private int maxRetries = 2 ;
/** 短信渠道:logging 走日志打桩,noop 直接丢弃(starter 内置两种,业务可整体覆盖) */
private String channel = "logging" ;
// getter / setter 省略
}
三个属性各管一段:enabled 是总开关,max-retries 控制重试,channel 决定内置网关用哪个。默认值我都写在字段上,引用方不配置时行为也是确定的。yml 里写 order.notify.max-retries 或 order.notify.maxRetries 都绑得上,松散绑定是属性机制自带的。
服务接口与默认实现
对外暴露一个接口,默认实现把渲染、重试、发送三步串起来:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
public interface OrderNotifyService {
void notify ( String event , String orderNo , String phone );
}
public class DefaultOrderNotifyService implements OrderNotifyService {
private final SmsClient smsClient ;
private final MessageRenderer renderer ;
private final OrderNotifyProperties properties ;
private String prefix = "" ;
@Override
public void notify ( String event , String orderNo , String phone ) {
String content = prefix + renderer . render ( event , orderNo );
int attempts = Math . max ( 1 , properties . getMaxRetries () + 1 );
for ( int attempt = 1 ; attempt <= attempts ; attempt ++ ) {
try {
smsClient . send ( phone , content );
if ( attempt > 1 ) {
log . info ( "NOTIFY_RETRY_OK event={} orderNo={} attempt={}/{}" ,
event , orderNo , attempt , attempts );
}
return ;
} catch ( RuntimeException ex ) {
if ( attempt == attempts ) {
throw ex ;
}
log . warn ( "NOTIFY_RETRY event={} orderNo={} attempt={}/{} reason={}" ,
event , orderNo , attempt , attempts , ex . getMessage ());
}
}
}
}
重试语义我取的是「失败后再试 maxRetries 次」,总尝试次数等于 maxRetries 加 1。每次失败记 WARN,最后一次直接把异常抛回调用方,让失败可见。SmsClient 和 MessageRenderer 是两个单方法接口,前者抽象短信网关,后者抽象内容渲染,默认实现都由自动配置按条件提供。
自动配置:四个条件各管一个分支
自动配置类是 starter 的装配核心,全文贴出来:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
@AutoConfiguration
@ConditionalOnProperty ( prefix = "order.notify" , name = "enabled" ,
havingValue = "true" , matchIfMissing = true )
@EnableConfigurationProperties ( OrderNotifyProperties . class )
public class OrderNotifyAutoConfiguration {
@Bean
@ConditionalOnMissingBean ( SmsClient . class )
@ConditionalOnProperty ( prefix = "order.notify" , name = "channel" ,
havingValue = "logging" , matchIfMissing = true )
public SmsClient loggingSmsClient () {
return new LoggingSmsClient ();
}
@Bean
@ConditionalOnMissingBean ( SmsClient . class )
@ConditionalOnProperty ( prefix = "order.notify" , name = "channel" ,
havingValue = "noop" )
public SmsClient noopSmsClient () {
return new NoopSmsClient ();
}
@Bean
@ConditionalOnMissingBean ( OrderNotifyService . class )
public OrderNotifyService defaultOrderNotifyService ( OrderNotifyProperties properties ,
SmsClient smsClient ,
MessageRenderer renderer ,
ObjectProvider < OrderNotifyCustomizer > customizers ) {
DefaultOrderNotifyService service =
new DefaultOrderNotifyService ( smsClient , renderer , properties );
customizers . orderedStream (). forEach ( c -> c . customize ( service ));
return service ;
}
@Configuration ( proxyBeanMethods = false )
@ConditionalOnClass ( ObjectMapper . class )
static class JacksonRendererConfiguration {
@Bean
@ConditionalOnMissingBean ( MessageRenderer . class )
MessageRenderer jacksonMessageRenderer ( ObjectMapper objectMapper ) {
return new JacksonMessageRenderer ( objectMapper );
}
}
@Configuration ( proxyBeanMethods = false )
@ConditionalOnMissingClass ( "tools.jackson.databind.ObjectMapper" )
static class PlainRendererConfiguration {
@Bean
@ConditionalOnMissingBean ( MessageRenderer . class )
MessageRenderer plainMessageRenderer () {
return new PlainMessageRenderer ();
}
}
}
条件分四层看。类上的 @ConditionalOnProperty 管 order.notify.enabled,matchIfMissing = true 表示属性缺省按开启算,配成 false 时整类跳过,容器里不会出现本 starter 的任何 bean。两个 SmsClient 方法靠 channel 属性二选一,@ConditionalOnMissingBean 保证业务注册了自己的网关时内置实现退位。defaultOrderNotifyService 方法上的 @ConditionalOnMissingBean 是「业务可覆盖默认实现」的机关:容器里已有同类型 bean 时这个方法不执行。customizer 通过 ObjectProvider 逐个应用,构造完默认服务后做最后微调,业务想加一个前缀不必整体换掉实现。
渲染器的分支我拆成了两个内部配置类。@ConditionalOnClass 只判断类在不在 classpath,Jackson 在就走 JSON 渲染,不在就走纯文本回退。写成内部类而不是直接标在 @Bean 方法上,是因为类级条件在类加载前判定,方法签名里引用不存在的类不会引发 NoClassDefFoundError。
注册文件:一行定生死
注册文件放在 autoconfigure 模块的 resources 下,路径和文件名一个字符都不能差:
1
src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
文件内容一行:
1
com.xncoding.starter.order.notify.OrderNotifyAutoConfiguration
Boot 启动时调用 ImportCandidates.load(),按 META-INF/spring/<注解全名>.imports 的固定格式调 classLoader.getResources() 收集候选。这条机制有个容易被忽略的事实:所有 jar 的注册文件共享同一个资源路径,框架 jar 和业务 jar 的条目全靠逐行汇总。路径写错、文件名拼错、漏打一个包,启动过程没有任何告警,自动配置静默缺失,表象就是「引了 starter 却什么都没发生」。
这件事反直觉的地方在于它完全静默。编译不报错,启动不报错,日志里一个字都不提,只有业务功能凭空消失。我在后面专门做了一个反例测试,把这个故障形态实拍下来。
演示应用:引用方写多少代码
app 模块引一个 starter 坐标,yml 配三个属性:
1
2
3
4
5
6
7
8
server :
port : ${SERVER_PORT:18170}
order :
notify :
enabled : true
max-retries : 2
channel : logging
业务代码只有两段。一段注册自己的打桩网关,前两次发送必失败,顺便替掉 starter 内置实现:
1
2
3
4
@Bean
public SmsClient flakySmsClient () {
return new FlakySmsClient ( totalCalls , failedCalls );
}
另一段用 customizer 做收口,给所有通知补一个业务前缀:
1
2
3
4
@Bean
public OrderNotifyCustomizer retailPrefixCustomizer () {
return service -> service . setPrefix ( "[零售]" );
}
控制器注入 OrderNotifyService 直接发通知,业务代码感知不到网关、渲染器、重试这些细节:
1
2
3
4
5
@PostMapping ( "/api/orders/{orderNo}/notify" )
public Map < String , Object > notify ( @PathVariable String orderNo ) {
notifyService . notify ( "ORDER_CREATED" , orderNo , "13800001234" );
// ...
}
有一处我要点名:控制器的包名用 com.xncoding.orderapp,与 starter 的 com.xncoding.starter.order.notify 完全错开。@SpringBootApplication 默认扫描主类所在包,两者有重叠时组件扫描会把自动配置类再注册一遍,自动配置和组件扫描双份生效,条件判断的次序就乱了。
完整案例
我把应用起起来,按场景打一轮。打桩网关前两次发送必失败,正好把重试撑满:
1
2
3
POST /api/orders/SK-9001/notify 网关前两次失败,第 3 次成功 -> 200
POST /api/orders/SK-9002/notify 网关已恢复,一次成功 -> 200
GET /api/ops/sms-calls 网关调用对账
第一条通知的响应体:
content 这一个字段就把两个装配结果摆出来了:[零售] 前缀来自 customizer,JSON 结构来自 Jackson 渲染器。smsClient 显示的是 FlakySmsClient,业务注册的网关生效,starter 内置的 loggingSmsClient 如实让位。响应耗时 52 ms,里面含着两次失败重试。说实话,重试、前缀、渲染三件事在同一条响应里同时对上号,挺有意思。
日志里的重试序列与配置对得上,max-retries: 2 换算成 3 次尝试:
1
2
3
4
5
6
7
NOTIFY_RETRY event=ORDER_CREATED orderNo=SK-9001 attempt=1/3 reason=模拟网关抖动 #1
NOTIFY_RETRY event=ORDER_CREATED orderNo=SK-9001 attempt=2/3 reason=模拟网关抖动 #2
NOTIFY_RETRY_OK event=ORDER_CREATED orderNo=SK-9001 attempt=3/3
STUB_SMS fail #1 phone=13800001234
STUB_SMS fail #2 phone=13800001234
STUB_SMS ok call#3 phone=13800001234 content=[零售]{"event":"ORDER_CREATED","orderNo":"SK-9001"}
STUB_SMS ok call#4 phone=13800001234 content=[零售]{"event":"ORDER_CREATED","orderNo":"SK-9002"}
再调对账接口逐笔核:网关共 4 次调用,失败 2 次,成功 2 次。第一条通知占 3 次(失败两次加成功一次),第二条占 1 次,一笔一笔都对得上。两条通知加一轮重试跑完,应用日志 ERROR 0 行,失败全程走 WARN。装配正确与否,这份数字就是铁证,全部来自同一次运行,我没有手工拼过任何一个数。
测试怎么写
15 个用例我分两组。autoconfigure 模块用 ApplicationContextRunner 起最小上下文断言条件分支,app 模块用 MockMvc 和真实启动覆盖端到端行为。
ApplicationContextRunner 是条件装配测试的标准工具,一个 runner 反复配属性跑断言。有个细节我第一次跑就栽在这里:要把 JacksonAutoConfiguration 一起装配,否则 ObjectMapper bean 不存在,@ConditionalOnClass 只查类不查 bean,上下文会起不来:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
private final ApplicationContextRunner runner = new ApplicationContextRunner ()
. withConfiguration ( AutoConfigurations . of ( OrderNotifyAutoConfiguration . class ,
org . springframework . boot . jackson . autoconfigure . JacksonAutoConfiguration . class ));
@Test
void default_enables_full_stack_with_jackson_renderer () {
runner . run ( ctx -> {
assertThat ( ctx ). hasSingleBean ( OrderNotifyService . class );
assertThat ( ctx ). hasSingleBean ( JacksonMessageRenderer . class );
assertThat ( ctx . getBean ( OrderNotifyProperties . class ). getMaxRetries ()). isEqualTo ( 2 );
});
}
@Test
void enabled_off_removes_every_bean_of_this_starter () {
runner . withPropertyValues ( "order.notify.enabled=false" ). run ( ctx -> {
assertThat ( ctx ). doesNotHaveBean ( OrderNotifyService . class );
assertThat ( ctx ). doesNotHaveBean ( SmsClient . class );
assertThat ( ctx ). doesNotHaveBean ( OrderNotifyProperties . class );
});
}
@Test
void user_provided_service_backs_off_default () {
OrderNotifyService mine = ( event , orderNo , phone ) -> { };
runner . withBean ( "mine" , OrderNotifyService . class , () -> mine ). run ( ctx -> {
assertThat ( ctx . getBean ( OrderNotifyService . class )). isSameAs ( mine );
assertThat ( ctx ). hasSingleBean ( SmsClient . class );
});
}
@Test
void jackson_missing_falls_back_to_plain_renderer () {
runner . withClassLoader ( new FilteredClassLoader ( ObjectMapper . class )). run ( ctx -> {
assertThat ( ctx ). hasSingleBean ( PlainMessageRenderer . class );
assertThat ( ctx ). doesNotHaveBean ( JacksonMessageRenderer . class );
});
}
四个用例对应四条分支:默认全装配、开关关闭整组退场、用户 bean 优先、缺类回退。FilteredClassLoader 从 classpath 里藏掉指定类,一行就能模拟「引用方没引 Jackson」的部署形态。同款写法我再覆盖 channel=noop 切换网关与属性松散绑定,autoconfigure 模块共 10 个用例全绿。
imports 注册文件的反例是我觉得本篇最值钱的测试。我自定义了一个 classloader,从资源枚举里滤掉自家 jar 贡献的那份 imports,再起一个真实的 SpringApplication:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
static class HidingClassLoader extends ClassLoader {
HidingClassLoader ( ClassLoader parent ) {
super ( parent );
}
@Override
public Enumeration < URL > getResources ( String name ) throws IOException {
Enumeration < URL > delegate = super . getResources ( name );
if ( ! IMPORTS_PATH . equals ( name )) {
return delegate ;
}
List < URL > kept = new ArrayList <> ();
while ( delegate . hasMoreElements ()) {
URL url = delegate . nextElement ();
if ( ! url . toString (). contains ( "order-notify" )) {
kept . add ( url );
}
}
return Collections . enumeration ( kept );
}
}
@Test
void hidden_imports_file_means_silent_missing_autoconfiguration () {
List < String > candidates = ImportCandidates . load ( AutoConfiguration . class ,
new HidingClassLoader ( Thread . currentThread (). getContextClassLoader ()))
. getCandidates ();
assertThat ( candidates )
. doesNotContain ( "com.xncoding.starter.order.notify.OrderNotifyAutoConfiguration" )
. contains ( "org.springframework.boot.autoconfigure.aop.AopAutoConfiguration" );
Thread . currentThread (). setContextClassLoader ( new HidingClassLoader ( original ));
try ( ConfigurableApplicationContext ctx = new SpringApplicationBuilder (
OrderNotifyAppApplication . class )
. web ( WebApplicationType . NONE )
. run ( "--order.notify.enabled=true" )) {
assertThat ( ctx . getBeansOfType ( OrderNotifyService . class )). isEmpty ();
assertThat ( ctx . containsBean ( "loggingSmsClient" )). isFalse ();
}
}
跑下来有个细节是我踩过才明白的:过滤条件必须按 URL 挑出自家那份,不能按资源名整体隐藏。所有 jar 的 imports 文件都同名,整体藏掉会把框架 jar 里的几百条自动配置一并干掉,故障形态就失真了。过滤后的结果正反两面都干净:候选列表里自家条目消失,AopAutoConfiguration 这些框架条目还在;上下文照常启动、零告警,starter 装配的 bean 一个都不存在。一个反例证明了两件事:注册文件按这个路径被读取,读不到时故障静默。
app 模块另外 5 个用例走 MockMvc:通知端点 200、响应里的前缀与 JSON 与配置同源、customizer 前缀生效、starter 内置网关让位给业务 bean。15 个用例合计全绿。
避坑指南
坑一,注册文件路径错一个字符,一切静默。 文件必须叫 org.springframework.boot.autoconfigure.AutoConfiguration.imports,放在 META-INF/spring/ 下。路径或文件名不对,启动零告警,自动配置不装配,表象是「引了依赖没效果」。排查时先确认 jar 里这个文件的确切路径,再看内容里的全限定类名有没有拼错。
坑二,@ConditionalOnClass 只查类不查 bean。 类路径上有 Jackson 不等于容器里有 ObjectMapper。被依赖的 bean 来自别的自动配置时,测试里要把那个自动配置一起 AutoConfigurations.of(...) 进 runner,生产环境靠 starter 传递依赖保证。
坑三,条件分支写在方法级和类级的判定时机不同。 方法级 @ConditionalOnClass 的方法签名引用了缺失类时,类加载阶段就抛 NoClassDefFoundError,条件没有机会判定。回退分支要拆成独立的内部配置类,类级条件在类加载前完成判定。
坑四,optional 依赖决定引用方看到什么。 autoconfigure 模块里 Jackson 标 optional,编译期可用但不传递。引用方引了 web 就有 Jackson,走 JSON 渲染;没引就走纯文本。不标 optional 会把 Jackson 强塞给所有引用方,标了不写回退分支则会在缺类环境直接崩,两头都要顾。
坑五,组件扫描不能覆盖自动配置的包。 @SpringBootApplication 默认扫描主类所在包,业务包与 starter 包有重叠时,自动配置类被组件扫描再注册一遍,@ConditionalOnMissingBean 的判定次序被打乱。业务包名与 starter 包名保持完全错开,或者显式限定扫描范围。
坑六,@ConditionalOnMissingBean 依赖注册顺序。 它只在「轮到自己时容器还没有同类型 bean」的条件下成立,所以只适合放在自动配置类里。业务 @Configuration 里同类 bean 先注册,自动配置后处理,让位逻辑天然成立;两个自动配置类之间互让位时,用 @AutoConfiguration 的 before / after 控制次序。
坑七,重试语义要写进属性注释。 max-retries 是「失败后再试几次」还是「总共尝试几次」,两种理解差一次,容易闹笑话。本工程取前者,总尝试次数等于 maxRetries 加 1,注释里写明,测试里断言,使用方少一轮猜谜。
坑八,starter 模块不要写代码。 starter 只留 pom,把依赖聚齐。往 starter 里塞类,引用方就绕过了 autoconfigure 的条件体系,代码在不在 classpath 都会被强行加载,条件装配形同虚设。
坑九,验收清单里加一条 bean 存在性检查。 启动成功不代表装配成功。集成测试或验收环境里断言关键 bean 存在(或对着 /actuator/beans 查一眼),starter 该出现的东西出现了,才算装配通过。
小结
什么时候该自己写一个 starter?我的判断标准就一条:这段能力是不是在两个以上服务里以「同一份逻辑、各自一份配置」的形态重复出现。是,就收进 starter;只在一个服务里用的,一个普通配置类就够了,别为了模式而模式。真要动手,把纪律守住:starter 模块只留 pom,注册文件路径一个字符都不能错,条件注解按开关、类路径、bean 让位分层摆好,测试用 runner 把每个分支钉死,验收清单里加一条 bean 存在性检查。同一套骨架装防重令牌、审计埋点这类横切能力都适用,代价是维护者要一直盯着注册文件与条件注解这两处,starter 的静默故障全从这里来。
没解决的事也有两件。一是更复杂的条件组合,比如多个 @Conditional 叠加、自定义 Condition 类的场景,这次的 runner 写法够不够用我没测过;二是 starter 发到私服后的版本升级策略,属性改名的兼容窗口怎么留,工程里只能靠纪律没有机制保障。这两件事等我真的被坑到了,再补一篇。
参考链接