通道热换别手写 getBean:配置一改立刻切 Bean,不必重启也不必 @Qualifier
同一能力对接多家供应商,是业务系统里最常见、也最容易把注入层写脏的需求:短信、支付、对象存储、OCR、大模型通道,几乎都会在运行中换一家。官方 Spring 的
@Autowired擅长「找到唯一候选」,却不擅长「按配置实时换人」。开源库 spring-smart-di(Apache-2.0,Maven 坐标io.github.burukeyou:spring-smart-di-all:0.2.0)把这件事收进注解:启动时智能选 Bean,或在每次方法调用时按环境变量 / 自定义工厂再解析一次。
微信源文把卖点写成「动态切换实现类」;社区转载大多停在短信示例。本文按官方仓库 README、核心源码与测试用例重写:三个注解分别解决什么、代理到底在哪一层转发、以及配置刷新、别名冲突、instanceof 失效这些生产坑。
一、多通道场景里,@Autowired 为什么经常帮倒忙
业务侧的需求通常很朴素:页面或配置中心改一个「当前通道」,下一笔请求就走新供应商。代码侧却要同时满足三件事——接口稳定、实现可插拔、调用点不要散落 if-else。Spring 默认注入模型对「唯一候选」极友好:接口只有一个实现,@Autowired 直接成功;一旦出现两家短信、三家支付,容器立刻抛 NoUniqueBeanDefinitionException。
团队常见的四个补丁,每一个都能跑,也每一个都会在半年后变成债:
| 做法 | 看起来省事的地方 | 半年后的成本 |
|---|---|---|
@Qualifier("aliyunSms") |
编译期钉死 Bean 名 | 换通道要改代码、发版 |
@Primary |
默认实现少写一行 | 默认一变,全站受影响 |
Map<String, SmsService> + 自写路由 |
完全可控 | 每个调用点重复读配置、漏判空 |
ApplicationContext.getBean(name) |
最灵活 | 业务代码绑死容器,单测与静态分析都痛 |
更隐蔽的一类问题发生在类继承而不是接口上。父类 Weather 本身是 @Component,同时又有 WeatherA、WeatherB 两个子类 Bean。此时 @Autowired Weather 会因为「类型匹配到多个」失败——即便你明明只想要父类自己那一枚。这不是业务写错,是 Spring 按类型收集候选的规则与「抽象父类也要可注入」的设计冲突。
spring-smart-di 把这两种痛拆开处理:@SmartAutowired 负责启动期更聪明地选 Bean(含「优先注入具体类自身」和环境变量选人);@AutowiredProxySPI / @AutowiredSPI 负责「接口多实现 + 配置驱动」,其中代理版本能在不重启进程的前提下换通道。它不是再造一套 IoC,而是在 Spring 的 BeanPostProcessor / FactoryBean 上补一层解析策略。
选型边界:如果你的「切换」发生在发版窗口、一天最多一次,用
@Qualifier或 Spring Cloud 的@RefreshScope重建 Bean 就够。这个库真正值钱的是调用点零感知、解析逻辑可插拔。
二、手写 Strategy Map 和 getBean 的真实成本
先把「不用库会怎么写」摊开,才看得出注解帮你藏掉了哪几步。以短信为例,配置中心或库表里有一项 sms.impl = 某移短信服务,业务代码往往长这样:
public void notifyUser(String phone, String text) {
String name = configCenter.get("sms.impl");
SmsService sms = springContext.getBean(name);
sms.send(phone, text);
}
三行看起来不长,但每个发信点都要复制:读配置、解析名字、取 Bean、空值处理、类型转换。配置值可能是 Bean 名、全限定类名,或运营能看懂的中文别名;解析规则一旦不统一,就会出现「配置写了中文、容器只认英文 Bean 名」的线上事故。单测时还得起完整 ApplicationContext,否则 getBean 直接空指针。
再往后,产品会要求「后台改完立刻生效、不要发版」。于是有人把 SmsService 做成 @RefreshScope,有人自己加一层 AtomicReference 缓存。RefreshScope 的语义是销毁并重建整个 Bean,依赖它的调用方如果持有旧引用,仍会打到旧通道;自己缓存则要处理并发与失效时机。这些都不是短信业务该关心的事。
@AutowiredProxySPI 的设计目标,就是把「读配置 → 解析名字 → 取 Bean」收进代理对象的方法拦截器:业务字段看起来仍是 SmsService smsService,每次 send() 才去问工厂「现在该用谁」。调用点不再出现 getBean,配置源也可以从环境变量换成数据库——只换工厂,不换业务。

三、先接依赖:@EnableSmartDI 打开注入扩展
仓库是单模块 spring-smart-di-all,编译目标 Java 8,编译期依赖 spring-context 5.3.28,许可证 Apache-2.0,作者 caizhihao。对 Spring Boot 2.x / 3.x 来说,只要容器能扫到它的配置类即可,不必等官方 Boot Starter。
Maven:
<dependency>
<groupId>io.github.burukeyou</groupId>
<artifactId>spring-smart-di-all</artifactId>
<version>0.2.0</version>
</dependency>
在启动配置类上打开开关:
@SpringBootApplication
@EnableSmartDI
public class App {
public static void main(String[] args) {
SpringApplication.run(App.class, args);
}
}
@EnableSmartDI 本身只做一件事:@Import(SmartDIConfiguration.class)。而 SmartDIConfiguration 是 @ComponentScan("com.burukeyou.smartdi"),把后处理器、别名注册器、默认的 EnvironmentProxyFactory 扫进容器。没有这个注解,你写了 @AutowiredProxySPI 也不会有人接管字段注入。
落地时注意三点:
1. 扫描范围:如果你关闭了默认包扫描、又没用 @EnableSmartDI 的 Import,处理器不会生效,字段会保持 null 或启动失败。
2. 版本预期:源码按 Spring Framework 5.3 编写,用到了 AnnotatedElementUtils、ProxyFactory、AbstractLazyCreationTargetSource。Boot 3 / Spring 6 一般能兼容,但首次接入务必用官方测试用例里的短信例子冒烟。
3. 不要和手写 AutowiredAnnotationBeanPostProcessor 抢同一字段:同一字段只标一个注入注解,避免两个后处理器各写一次。
接好之后,你面对的是三套注解,而不是一个「万能 @Autowired」:启动期智能选 Bean、启动期按 SPI 钉死、运行期代理再解析。下一节先看最容易被忽略的 @SmartAutowired。
四、@SmartAutowired:父类本身也是 Bean 时怎么注入
@SmartAutowired 可以标在字段、方法、构造器、参数上,语义接近 @Autowired,但多了两条规则。源码在 SmartAutowiredBeanProcessor:
- 没写 key:如果注入类型是接口或抽象类,行为接近
getBean(Class);如果是具体类,则在getBeansOfType的结果里优先找「恰好是这个类」的那一枚,而不是把所有子类都算候选。父类自己是 Bean、同时又有子类 Bean 时,注入父类自身——这正是@Autowired会炸的场景。 - 写了
${weather.impl}:先environment.resolvePlaceholders,得到的值可以是 Bean 名、@BeanAliasName的别名,或带.的全限定类名(走Class.forName再按类型取)。
官方 README 的天气例子可以原样落地:
@Component
public class Weather { }
@Component
@BeanAliasName("天气A服务商")
public class WeatherA extends Weather { }
@Component
@BeanAliasName("天气B服务商")
public class WeatherB extends Weather { }
@Service
public class ForecastFacade {
@SmartAutowired
private Weather weather; // 注入 Weather 自己,而不是在 A/B 之间纠结
@SmartAutowired("${weather.impl}")
private Weather routed; // 按配置选 A 或 B
}
weather:
impl: 天气A服务商
@BeanAliasName 不是装饰。ProxySPIAliasNameRegister 在 BeanDefinitionRegistryPostProcessor 阶段遍历所有 Bean 定义,把别名写入 SpringBeanContext 的 ConcurrentHashMap。别名重复会直接抛「already exist」——运营文案可以中文化,但必须全局唯一。

required 默认 true,找不到 Bean 会抛 AutowiredInjectBeanException。它没有代理语义:配置改了,已经注入进去的字段不会跟着变,除非你自己刷新持有该字段的 Bean。需要「改配置立刻换通道」,请用下一节的代理 SPI,不要指望 @SmartAutowired 每次调用再查一次。
五、@AutowiredProxySPI:每次调用再解析当前实现
这是多供应商场景的主路径。接口上用 @EnvironmentProxySPI(或任意 @ProxySPI 元注解)声明「去哪读当前实现」,字段上用 @AutowiredProxySPI 注入代理,而不是某一家的具体类。
@EnvironmentProxySPI("${sms.impl}")
public interface SmsService {
void send(String message);
}
@BeanAliasName("某腾短信服务")
@Component
public class ASmsService implements SmsService {
@Override
public void send(String message) {
// 调某腾 SDK
}
}
@BeanAliasName("某移短信服务")
@Component
public class BSmsService implements SmsService {
@Override
public void send(String message) {
// 调某移 SDK
}
}
@Service
public class NotifyService {
@AutowiredProxySPI
private SmsService smsService;
public void ping() {
smsService.send("health-check");
}
}
sms:
impl: 某移短信服务
@EnvironmentProxySPI 的定义值得看一眼:它自己就标了 @ProxySPI(EnvironmentProxyFactory.class)。工厂从 Environment 解析占位符,再调用 SpringBeanContext.getBeanByAllName——先当 Bean 名取,取不到再当别名取。因此配置值可以是 aSmsService、某移短信服务,或你自己注册的名字。
代理为什么能「改完立刻生效」?ProxySPIFactoryBean 用 Spring AOP 的 ProxyFactory 给接口做 JDK 代理,并挂上 MethodInterceptor:每一次接口方法调用,都执行 factory.getProxy(targetClass, spiAnnotation),拿到当前 Bean 再 method.invoke。代理对象本身只创建一次(懒代理),变的是拦截器里解析出来的目标。测试类 TestAPI.testProxyAutowiredAnnotation 正是在循环里反复 proxySmsService.send(...),用来验证这一点。
和手写 Map 路由比,调用点完全无感知;和 @RefreshScope 比,它不销毁业务 Bean,只在调用瞬间换目标。代价是:每次调用多一次解析(环境占位符 + Map 查找),以及代理对象不能当具体类型用——这点放到排坑节展开。
六、自定义 @ProxySPI:从数据库或配置中心取通道
@EnvironmentProxySPI 只覆盖「Spring Environment 里有这个 key」的情况。运营后台把通道写在 sys_config 表、或 Nacos 的非 Environment 命名空间时,官方给出的扩展点是:自己做一个元注解 + 一个 AnnotationProxyFactory。
接口只有一个方法:
public interface AnnotationProxyFactory<T extends Annotation> {
Object getProxy(Class<?> targetClass, T spi);
}
按仓库测试包的形状,最小可用扩展如下(生产里把 Environment 换成 SysConfigMapper 即可):
@Inherited
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@ProxySPI(DbProxyFactory.class)
public @interface DBProxySPI {
String value();
}
@Component
public class DbProxyFactory implements AnnotationProxyFactory<DBProxySPI> {
@Autowired
private SysConfigMapper sysConfigMapper;
@Override
public Object getProxy(Class<?> targetClass, DBProxySPI spi) {
String configKey = spi.value();
String beanName = sysConfigMapper.getConfig(configKey);
return SpringBeanContext.getBeanByAllName(beanName);
}
}
@DBProxySPI("sms.impl")
public interface SmsService {
void send(String message);
}
工厂本身必须是 Spring Bean,因为 ProxySPIFactoryBean 用 SpringBeanContext.getBean(proxySPI.value()) 取工厂。接口上漏标 @ProxySPI(或漏标复合注解)时,创建代理会抛 BeanCreationException,提示「请标记 ProxySPI 的注解或者 ProxySPI 复合注解」。
ProxySpiLoader.load(SmsService.class) 提供编程式入口,和字段注入走同一套工厂。适合定时任务、非 Spring 管理的监听器里临时取一次当前通道。load 还会做类型校验:工厂返回的对象若不能赋值给目标接口,抛 SPIBeanTypeMismatchException,避免把支付 Bean 误塞进短信字段。
扩展时建议把「解析配置」和「取 Bean」拆开:配置源负责给出名字,getBeanByAllName 负责名字到实例。不要在工厂里 new 实现类,否则会绕过容器的生命周期、AOP 和销毁回调。
七、启动时钉死还是运行时再查:@AutowiredSPI 对比
官方第三套注解 @AutowiredSPI,使用方式和 @AutowiredProxySPI 几乎一样:接口仍要标 @ProxySPI / @EnvironmentProxySPI,字段改标 @AutowiredSPI。差别只在注入进去的是什么、何时解析:
| 注解 | 注入对象 | 解析时机 | 改配置后 | 适用 |
|---|---|---|---|---|
@AutowiredProxySPI |
JDK 代理 | 每次方法调用 | 下一调用即生效(Environment 已刷新的前提下) | 短信 / 支付 / 模型通道 |
@AutowiredSPI |
当时选中的具体 Bean | 容器启动、字段注入时一次 | 要重启(或重建持有该字段的 Bean) | 启动期已确定、追求零代理开销 |
@SmartAutowired |
具体 Bean | 启动期 | 同上 | 父类可注入、或用属性选人,但不走 SPI 工厂 |
仓库测试类 AllService 把三套注解写在一起,方便对照:
@Component
public class AllService {
@AutowiredSPI
public DBSmsService dbSmsService;
@AutowiredProxySPI
public SmsService proxySmsService;
@AutowiredSPI
public EnvironmentSmsService environmentSmsService;
@SmartAutowired(value = "${smart.live}")
public IService propSmartService;
@SmartAutowired
public AService aService;
}
选型可以记一句:「会不会在进程活着时换人」。会,用代理;不会,用 @AutowiredSPI 或 @SmartAutowired,少一层反射转发,也少一层 instanceof 陷阱。不要在同一接口上混用两套语义——字段 A 是代理、字段 B 是启动期钉死,排障时日志会对不上。
性能上,代理路径每次调用会:解析占位符 → 查别名表或 ApplicationContext.getBean → 反射 invoke。对短信这种 I/O 密集调用可以忽略;如果是在 tight loop 里每秒十万次的纯计算策略,启动期钉死或自己做带 TTL 的本地缓存更合适。官方拦截器里没有缓存「上一次的目标 Bean」,这是为了保证配置一变下一调用就换,也意味着你不能指望它帮你省掉 getBean 的开销。
八、源码里代理是怎么转发出去的
把调用链摊开,有助于判断它能不能和 @Transactional、@Async、自研 AOP 和平共处。

关键类都在 com.burukeyou.smartdi:
1. 注册:@EnableSmartDI → 扫描 ProxySPIAutowiredBeanProcessor、SPIAutowiredBeanProcessor、SmartAutowiredBeanProcessor、ProxySPIAliasNameRegister、EnvironmentProxyFactory。
2. 字段注入(代理):后处理器发现 @AutowiredProxySPI,用 ProxySPIFactoryBean 产出对象。getObject() 第一次调用时 createLazyProxy()。
3. 代理创建:ProxyFactory + AbstractLazyCreationTargetSource,addInterface(targetClass),再 addAdvice(MethodInterceptor)。
4. 拦截:AnnotationProxyFactory springBean = SpringBeanContext.getBean(proxySPI.value()),然后 Object bean = springBean.getProxy(targetClass, spiAnno),method.invoke(bean, args)。目标为 null 时抛 ProxySPIRuntimeException。
5. Environment 工厂:environment.resolvePlaceholders(spi.value()),失败则包装成「can not find yml spiBean for value …」。
@AutowiredProxySPI 源码里 required 被注释掉了,当前实现等价于「解析不到就抛,没有静默 null」。README 里那套「required=false 打 warn 返回 null」的分支,在 ProxySPIFactoryBean 里同样被注释——按源码现状写代码,不要按注释掉的愿望写。
和 Spring AOP 的叠加顺序:业务 Bean 上的事务切面作用在你的 @Service,不作用在 SPI 目标上。如果具体短信实现自己标了 @Transactional,因为最终是 method.invoke(concrete, args),不会再走该实现上的 Spring 代理,事务注解可能失效。需要事务时,把事务放在外层业务方法,或保证工厂返回的是容器里的代理对象(getBean 取到的已经是 Spring 代理,这一点是成立的——invoke 打在代理上,事务还能在)。不要 new ASmsService()。

九、生产排坑:刷新、别名、类型校验、AOP
把官方源码和真实配置中心对上,以下几条最容易踩。
1. 「改 YAML 立刻生效」有前提。 拦截器每次都会 resolvePlaceholders,但 Environment 里的值必须先被刷新。纯本地 application.yml 改磁盘不会进内存;Nacos / Apollo 需要走 Spring Cloud 的 Environment 刷新(或你在自定义工厂里直接读远程)。没刷新 Environment,代理再勤快也是旧值。
2. 别名必须全局唯一。 registerBeanAlias 发现重复直接抛。中文别名适合运营,但两个模块都叫「默认短信」会在启动期炸掉。建议约定「领域 + 供应商」,例如 sms.aliyun、sms.tencent。
3. 代理不是实现类。 smsService instanceof ASmsService 恒为 false;getClass() 拿到的是 JDK 代理。需要分支时,在接口上加 name() / vendor() 方法,让当前目标自己报告身份。
4. 类型校验只在 ProxySpiLoader.load 里做。 字段注入的拦截器路径,工厂如果返回了错误类型,会在 method.invoke 时以反射异常爆出。自定义工厂务必 return 目标接口的实现,最好在工厂末尾自己 isAssignableFrom 一道。
5. 别名注册依赖 Bean 定义上的 class 名。 ProxySPIAliasNameRegister 用 beanDefinition.getBeanClassName() + Class.forName。工厂方法 @Bean 若没有 class 名、或用了 CGLIB 增强导致 class 名对不上,别名可能注册失败。实现类用 @Component + @BeanAliasName 最稳。
6. 并发下的「换通道」。 两次 send 之间配置变了,两次调用可以打到两家供应商——这是按次解析的本意。不要假设「一个请求生命周期内通道不变」;若必须不变,在请求入口把当前 Bean 取出来放进局部变量,后续都用这个引用。
7. 空实现与降级。 当前代理路径找不到 Bean 就抛。要做「通道挂了切备用」,在自定义工厂里实现主备逻辑,而不是指望注解的 required=false(它还没接通)。
8. 安全。 配置值最终会拿去 getBean / Class.forName。如果运营后台允许任意字符串,等于开放「指定容器里任何一个 Bean」。校验白名单,或只允许已经登记过的别名。
十、什么时候不该上这个库
spring-smart-di 很薄:一个模块、Java 8、没有 Boot Starter、没有指标、没有官方缓存与降级。GitHub 星数也还少,生产接入要按「小而稳的注入扩展」来预期,而不是当服务治理平台。
更适合继续用手写或官方能力的情况:
- 通道只在发版时变:
@Qualifier+ 配置文件,心智负担更低。 - 要实现灰度、按租户、按地区分流:一次调用可能对应多个目标,代理「全接口换一个 Bean」的模型不够;用策略 Map 或自己的
Router。 - 需要熔断、限流、调用指标:在通道适配器外面包 Resilience4j / Micrometer,不要改工厂去堆治理逻辑。
- 接口方法是
default或equals/hashCode:JDK 代理对Object方法的转发要自己确认;不要把代理放进HashMap当 key。 - 要在 Kotlin 值类、GraalVM native image 里走反射:额外做可达性配置,官方没有现成 hint。
若你的痛点正好是「多个实现 + 配置驱动 + 调用点不想看见容器」,它比满项目 getBean 干净得多。接法也短:依赖 0.2.0、@EnableSmartDI、接口标 @EnvironmentProxySPI 或自定义 @ProxySPI、字段标 @AutowiredProxySPI。配置源换成库表或配置中心时,只新增一个工厂,业务方法不用动。
仓库:https://github.com/burukeYou/spring-smart-di ;坐标
io.github.burukeyou:spring-smart-di-all:0.2.0。下文practice/给出可直接跑的短信通道最小例子,行为与官方测试包对齐。
💻 配套实训环境与动手练习
本文涉及的相关技术指令、开发环境与工具链已内置在边学边练在线实验室中,无需繁琐安装配置,随时在浏览器中实践体验: