通道热换别手写 getBean:配置一改立刻切 Bean,不必重启也不必 @Qualifier

通道热换别手写 getBean:配置一改立刻切 Bean,不必重启也不必 @Qualifier

通道热换别手写 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」——运营文案可以中文化,但必须全局唯一。

通道热换别手写 getBean:配置一改立刻切 Bean,不必重启也不必 @Qualifier

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 和平共处。

Mermaid Diagram

关键类都在 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/ 给出可直接跑的短信通道最小例子,行为与官方测试包对齐。

💻 配套实训环境与动手练习

本文涉及的相关技术指令、开发环境与工具链已内置在边学边练在线实验室中,无需繁琐安装配置,随时在浏览器中实践体验:

文章版权声明:除非注明,否则均为边学边练网络文章,版权归原作者所有

最新文章

热门文章

本栏目文章