Dubbo SPI扩展机制详解
定位:Dubbo 第 03 篇(核心机制篇),讲透 Dubbo 的扩展点加载机制------这是理解 Dubbo 一切可插拔能力的钥匙
适用版本:Dubbo 3.x(JDK 8+/17)
目录
- [一、SPI 设计动机](#一、SPI 设计动机)
- [二、ExtensionLoader 核心机制](#二、ExtensionLoader 核心机制)
- [三、@Adaptive 自适应扩展](#三、@Adaptive 自适应扩展)
- [四、@Activate 条件激活](#四、@Activate 条件激活)
- [五、IOC 与 AOP(Wrapper)](#五、IOC 与 AOP(Wrapper))
- 六、自定义扩展实战
- 七、总结
- 八、常见高频面试题
一、SPI 设计动机
1.1 框架为什么必须可插拔
回看 02 篇的调用链:协议、序列化、集群容错、负载均衡、代理工厂、过滤器------每一环都存在多种合理选择,而且不同团队会有自定义诉求。框架不可能写死任何一个,因此需要一个统一的"面向扩展点编程"的机制:
框架定义扩展点接口(如 LoadBalance)
↕ SPI 机制按配置/上下文选择实现
业务/第三方提供具体实现(如自研一致性哈希)
SPI(Service Provider Interface) 就是这套"接口与实现分离、运行时动态装配"的机制。
1.2 Java 原生 SPI 的不足
Java 自带 ServiceLoader(META-INF/services/ + 迭代器),但有硬伤:
| 问题 | 后果 |
|---|---|
| 全量加载 | 一次实例化所有实现,浪费资源;某个实现类加载失败会导致全部失败 |
| 无法按名获取 | 只能遍历,不能"给我 random 那个" |
| 无依赖注入 | 扩展实现之间无法互相依赖 |
| 无包装能力 | 无法给扩展统一加装饰器 |
对 RPC 框架来说,"按需、按名、可注入、可包装"都是刚需,所以 Dubbo 自研了一套增强 SPI。
1.3 Dubbo SPI 的增强点
Java SPI Dubbo SPI
───────────── ─────────────────────────────
全量加载 → 懒加载 + 三级缓存
只能遍历 → 按 key 精确获取
无默认值 → @SPI("default") 指定默认实现
无动态选择 → @Adaptive 按 URL 参数动态选
无条件启用 → @Activate 按场景批量激活
无依赖注入 → setter 自动 IOC + Wrapper AOP
二、ExtensionLoader 核心机制
2.1 一个扩展点一个 Loader
ExtensionLoader 是 SPI 的中枢,每个扩展点接口对应一个实例:
java
ExtensionLoader<LoadBalance> loader =
ExtensionLoader.getExtensionLoader(LoadBalance.class);
LoadBalance lb = loader.getExtension("random"); // 按名取,单例
LoadBalance dft = loader.getDefaultExtension(); // 取 @SPI 默认
2.2 扩展文件放在哪
按优先级从三个目录扫描(都在 classpath 下):
| 目录 | 用途 |
|---|---|
META-INF/dubbo/internal/ |
Dubbo 框架内部扩展 |
META-INF/dubbo/ |
用户自定义扩展的标准位置 |
META-INF/services/ |
兼容 Java 原生 SPI 格式 |
文件名 = 扩展点接口的全限定名 ,内容 = key=实现类全限定名:
# META-INF/dubbo/org.apache.dubbo.rpc.cluster.LoadBalance
random=org.apache.dubbo.rpc.cluster.loadbalance.RandomLoadBalance
roundrobin=org.apache.dubbo.rpc.cluster.loadbalance.RoundRobinLoadBalance
leastactive=org.apache.dubbo.rpc.cluster.loadbalance.LeastActiveLoadBalance
这个 key 就是配置里写的名字 ------loadbalance = "roundrobin" 就是在这里匹配的。这也解释了"配置值从哪来":全部来自 SPI 文件的 key。
2.3 加载流程与缓存
第一次 getExtension("random")
▼
检查缓存 → 未命中
▼
扫描三个目录,找到同名文件
▼
解析出 key→类名 映射(只存 Class,不实例化)
▼
按 key 找到类 → 实例化(单例)
▼
IOC 注入 + Wrapper 包装(见第五节)
▼
放入实例缓存,返回
缓存分三层:扩展类缓存 (Class 级)、扩展实例缓存 (已创建的单例)、自适应适配器缓存(@Adaptive 生成物,下节讲)。加载过程加锁,保证并发安全。
懒加载是关键:文件里写了 20 个实现,只会实例化真正用到的那个------这正是对 Java SPI"全量加载"的修正。
2.4 @SPI 注解:指定默认实现
java
@SPI("random") // 默认实现是 key 为 random 的那个
public interface LoadBalance {
<T> Invoker<T> select(List<Invoker<T>> invokers, ...);
}
配置没指定时用默认;getDefaultExtension() 也返回它。
三、@Adaptive 自适应扩展
3.1 解决什么问题
有些扩展点的实现选择依赖运行时参数 。例如负载均衡:同一个 LoadBalance 引用,不同请求可能要用不同算法------选择依据在 URL 参数 loadbalance 里。但生成代理/包装集群时,URL 还没确定,此时无法选定实现。
解决思路:不提前选定,而是生成一个"适配器",把选择推迟到真正调用时,从方法参数里的 URL 现场决定。
3.2 两种用法
用法一:注解在方法上(最常用)------由 Dubbo 自动生成适配器:
java
@SPI("random")
public interface LoadBalance {
@Adaptive // 标注在方法上
<T> Invoker<T> select(List<Invoker<T>> invokers, URL url, Invocation inv);
}
生成的适配器逻辑等价于:
java
public Invoker select(List invokers, URL url, Invocation inv) {
// ① 从 URL 取参数决定用哪个实现(key 默认是接口小写名 "loadbalance")
String name = url.getParameter("loadbalance", "random");
// ② 按名从 ExtensionLoader 拿真实例
LoadBalance real = ExtensionLoader.getExtensionLoader(LoadBalance.class)
.getExtension(name);
// ③ 委托执行
return real.select(invokers, url, inv);
}
约束:方法参数里必须能拿到 URL (直接参数或可从参数对象 getUrl() 获取),否则无法生成适配器。
用法二:注解在类上(手工适配器)------用于逻辑复杂、自动生成覆盖不了的场景:
java
@Adaptive // 标注在类上:这个类本身就是适配器
public class AdaptiveProtocol implements Protocol {
// 内部自行实现"按 url.getProtocol() 委托到具体 Protocol"的逻辑
}
Protocol、Transporter 等核心扩展点用的是这种手工适配器。
3.3 自适应扩展的意义
没有 @Adaptive:扩展必须在启动时定死 → 无法按服务/按请求动态切换
有了 @Adaptive:扩展选择推迟到调用期 → 多服务共享同一框架却各用各的实现
这就是"一个 JVM 里 A 服务用 random、B 服务用一致性哈希"能成立的原因------每次调用现场读各自 URL 的参数。
四、@Activate 条件激活
4.1 场景:一次取一组扩展
@Adaptive 是"选一个",@Activate 是"按条件激活一批"------典型场景是 Filter 链:提供方要装一批入口过滤器,消费方要装另一批,还要根据 URL 里有没有某参数决定是否启用。
4.2 注解与获取
java
@Activate(group = {CommonConstants.PROVIDER, CommonConstants.CONSUMER},
value = "token") // 仅当 URL 含 token 参数时激活
public class TokenFilter implements Filter { ... }
java
// 框架内部按组取全部激活的 Filter
List<Filter> filters = loader.getActivateExtension(
url, "filter", group);
参数含义:
| 注解参数 | 作用 |
|---|---|
group |
只在指定侧激活:PROVIDER / CONSUMER / 两者 |
value |
URL 中存在这些参数名才激活(条件开关) |
order |
链内排序 |
调用方的三个入参:url(条件来源)、key(URL 里额外手工指定的扩展名列表,如 filter = "myFilter")、group(当前是提供方还是消费方)。
4.3 自定义扩展如何混入内置链
自定义 Filter 想插进默认链,两种方式:
- 类上加
@Activate(group = ...),全场景自动生效; - 不加注解,通过配置
filter = "myFilter"显式挂载(此时按配置中myFilter与-前缀组合决定增删)。
五、IOC 与 AOP(Wrapper)
5.1 SPI 的 IOC:setter 自动注入
扩展实例创建后,Dubbo 扫描它的 setter 方法,按参数类型从其他扩展点的 Loader 里找实例注入:
java
public class MyFilter implements Filter {
private Protocol protocol;
// Dubbo 看到 setProtocol(Protocol),自动注入 Protocol 的自适应实例
public void setProtocol(Protocol protocol) {
this.protocol = protocol;
}
}
这让扩展实现之间可以互相依赖(Filter 依赖 Protocol、Router 依赖 LoadBalance),而无需自己 new 或依赖 Spring 容器。
5.2 SPI 的 AOP:Wrapper 包装
约定:一个类若实现了扩展点接口 X,且构造函数接收 X 类型参数,类名为 XxxWrapper,则被识别为包装器。
真实实现:RandomLoadBalance
包装器: LoadBalanceWrapper(LoadBalance inner) ← 持有原实现,前后加逻辑
加载时自动套上:
getExtension("random")
→ new RandomLoadBalance()
→ IOC 注入
→ 被 LoadBalanceWrapper 包裹(若存在,可多层嵌套)
→ 返回最外层
效果等同装饰器模式:统一加日志/监控/校验,不侵入每个实现。框架内部对 Protocol 就有这样的 Wrapper(加入配置合并、注册逻辑等)。
5.3 完整装配顺序
类加载 → 实例化 → setter IOC 注入 → Wrapper 层层包装 → 缓存
六、自定义扩展实战
以自定义负载均衡为例,完整四步:
6.1 实现扩展接口
java
public class IpHashLoadBalance extends AbstractLoadBalance {
@Override
protected <T> Invoker<T> doSelect(List<Invoker<T>> invokers,
URL url, Invocation invocation) {
// 按调用方 IP 哈希,同一来源固定打到同一台(简例)
String clientIp = RpcContext.getServiceContext().getRemoteHost();
int index = Math.abs(clientIp.hashCode()) % invokers.size();
return invokers.get(index);
}
}
6.2 写 SPI 配置文件
# META-INF/dubbo/org.apache.dubbo.rpc.cluster.LoadBalance
iphash=com.demo.lb.IpHashLoadBalance
文件位置:自己工程的 src/main/resources/META-INF/dubbo/,文件名即扩展点接口全限定名。
6.3 配置引用
java
@DubboReference(loadbalance = "iphash")
private GreetingService greetingService;
或全局:dubbo.consumer.load-balance=iphash。
6.4 验证与进阶
- 验证:调用时断点打在
doSelect,确认命中; - 覆盖内置:配置文件用相同 key (如
random=...)即可替换默认实现------这是"不换配置值、只换实现"的手段,慎用; - 其他高频自定义扩展点:
Filter(横切逻辑)、Router(灰度路由)、Cluster(自定义容错)、Protocol(私有协议接入)。
七、总结
- SPI 是 Dubbo 一切可插拔能力的地基:协议、序列化、容错、负载均衡、代理、过滤器全部是扩展点;记住"配置里的值 = SPI 文件里的 key"。
- ExtensionLoader :一个扩展点一个 Loader,从
META-INF/dubbo(/internal)与services三目录扫描,懒加载 + 三级缓存,按 key 精确取单例,@SPI指定默认。 - @Adaptive 把实现选择推迟到调用期,从方法参数的 URL 现场决定,实现"同一框架、每服务各选各的";方法上标注自动生成适配器,类上标注则手写适配器。
- @Activate 按 group/value 条件批量激活扩展,是 Filter 链装配的机制;自定义 Filter 可注解自动挂载或配置显式挂载。
- IOC + Wrapper AOP 让扩展之间可依赖、可统一增强,装配顺序为"实例化 → 注入 → 包装"。
- 自定义扩展四步:实现接口 → 写
META-INF/dubbo/配置 → 配置引用 key → 验证;同 key 可覆盖内置实现。
八、常见高频面试题
1. Dubbo SPI 和 Java SPI 有什么区别?
要点:文件位置不同(META-INF/dubbo vs META-INF/services),且 Dubbo 用 key=类名格式支持按名获取;加载策略不同------Java SPI 全量实例化,Dubbo 懒加载加缓存;Dubbo 额外提供 @SPI 默认值、@Adaptive 动态选择、@Activate 条件激活、setter IOC 注入与 Wrapper AOP。本质区别:Java SPI 面向"发现所有实现",Dubbo SPI 面向"按需装配一个/一组实现"。
2. @Adaptive 注解的原理是什么?为什么方法参数里必须有 URL?
要点:@Adaptive 标注方法时,Dubbo 用字节码生成自适应适配器类:调用时先从参数中的 URL 取出扩展名(key 默认是接口小写名),再向 ExtensionLoader 按名获取真实例并委托执行。选择推迟到调用期,所以同一扩展点可被不同服务按各自 URL 参数动态选用。必须有 URL 是因为实现选择的依据就在 URL 参数里,没有 URL 就无从决定用哪个实现。
3. ExtensionLoader 是怎么加载扩展的?缓存如何组织?
要点:每个扩展点接口对应一个 ExtensionLoader;扫描 META-INF/dubbo/internal、META-INF/dubbo、META-INF/services 三目录下以接口全限定名命名的文件,解析 key→类名映射;懒加载,第一次按 key 获取才实例化;缓存三层------扩展类缓存、实例缓存(单例)、自适应适配器缓存;加载加锁保证并发安全。
4. @Activate 是干什么的?Filter 链怎么用它装配?
要点:@Activate 用于按条件批量激活扩展,典型是 Filter。注解参数 group 限定提供方/消费方侧,value 表示 URL 需含指定参数才激活,order 控制排序。框架通过 getActivateExtension(url, key, group) 收集所有满足条件的 Filter,与配置里手工指定的合并成最终链。自定义 Filter 加 @Activate 可全场景生效,或用配置 filter = "xxx" 显式挂载。
5. Dubbo SPI 如何实现依赖注入(IOC)?
要点:扩展实例创建后,ExtensionLoader 扫描其 setter 方法,按参数类型找到对应扩展点的 Loader,取其(自适应)实例注入。这让 Filter 可以注入 Protocol、Router 可以注入其他扩展,扩展实现间解耦于 Spring 容器之外。注意它只支持 setter 注入,不支持构造器注入。
6. 什么是 Wrapper?Dubbo SPI 的 AOP 怎么实现?
要点:实现扩展点接口、构造函数接收同类型参数、命名为 XxxWrapper 的类会被识别为包装器;加载真实扩展后自动用 Wrapper 层层包裹(装饰器模式),调用先经过 Wrapper 再进真实实现。用于统一横切(如 Protocol 的 Wrapper 里做配置合并、注册逻辑),不侵入各实现。
7. 如何写一个自定义的负载均衡策略并让它生效?
要点:四步------① 继承 AbstractLoadBalance 实现 doSelect;② 在 META-INF/dubbo/org.apache.dubbo.rpc.cluster.LoadBalance 文件里写 自定义key=全限定类名;③ 配置引用:@DubboReference(loadbalance="key") 或全局配置;④ 断点/日志验证命中。若用与内置相同的 key 会覆盖内置实现。
8. 为什么 Dubbo 里配置值(如 loadbalance=random)能直接对应到实现类?
要点:这些值就是 SPI 配置文件中的 key。ExtensionLoader 解析 META-INF/dubbo/ 下以接口全限定名命名的文件,得到 key→实现类映射;配置指定 key 后,@Adaptive 适配器或调用处按 key 向 Loader 获取对应实例。所以"新增一个配置可选值"等价于"注册一个 SPI 扩展"。
9. @SPI 注解的 value 有什么作用?
要点:指定该扩展点的默认实现 key。当配置未显式指定时(如没配 loadbalance),框架用默认值(LoadBalance 默认 "random");getDefaultExtension() 返回该实现。注意 @SPI 只定义默认值,本身不启用自适应,动态选择还需要 @Adaptive。
10. Dubbo 的扩展机制和 Spring 的 Bean 机制能互相替代吗?
要点:定位不同,不能简单替代。Dubbo SPI 服务于框架内部扩展点:按 key 装配、支持自适应/条件激活、不依赖 Spring 容器(Dubbo 可脱离 Spring 使用);Spring 负责业务 Bean 的生命周期与业务层 DI。二者衔接点:Dubbo 的 Spring Boot Starter 把 @DubboService/@DubboReference 桥接到 Spring 容器,业务实现类仍是 Spring Bean,而其内部调用链上的扩展装配走 SPI。
