从零手写Java SPI框架:解决ServiceLoader无缓存与按名获取难题
发布时间:2026/10/14 2:09:53 锦皓数字建站

1. 为什么我要自己写一个SPI框架第一次接触Java SPI是在做一个支付渠道对接的项目。当时系统需要支持多种支付方式微信、支付宝、银联各有一套实现最初的做法是在业务代码里写一堆if-else每加一个渠道就要改一次主流程编译、测试、上线折腾得够呛。后来同事说你试试SPI服务提供者接口JDK自带的能力不用引第三方依赖。我照着教程在META-INF/services目录下建了个文件写了几行全限定类名ServiceLoader.load()一调确实跑通了。但用着用着就发现不对劲了。ServiceLoader每次load都会重新实例化所有实现类没有单例缓存它只能按类型全量加载没法按名字精确获取某一个实现更麻烦的是它不支持依赖注入实现类里想用Spring容器里的Bean只能自己想办法。项目越往后做这些限制越让人难受。于是我就动了念头干脆自己写一个SPI框架把这些问题一次性解决掉。这篇文章记录的就是我从零手写一个SPI框架的完整过程。核心关键词是Java SPI、服务发现、扩展点加载、手写框架。我会把设计思路、核心代码、踩过的坑、排查问题的方法都摊开来讲。适合已经用过ServiceLoader、想深入理解SPI底层机制的同学也适合想自己造轮子但不知道从哪下手的开发者。读完你至少能拿到一个可运行的SPI框架理解它和JDK原生SPI的差异以及在实际项目里怎么选型。先说清楚一件事自己写SPI框架不是为了替代JDK的ServiceLoader而是为了在特定场景下获得更可控的加载行为。JDK的SPI设计目标是“发现并加载”它不关心你加载之后怎么用。而业务系统往往需要“按需加载、缓存复用、依赖管理”这些是原生SPI不提供的。理解了这一点后面的设计取舍就顺理成章了。2. 先搞清楚JDK原生SPI的机制和短板2.1 ServiceLoader到底做了什么要自己写框架第一步是把原生SPI的机制吃透。ServiceLoader的核心逻辑其实不复杂我把它拆成三步约定目录在classpath下的META-INF/services/目录中创建一个以服务接口全限定名命名的文件比如com.example.PayService。文件内容文件里每一行写一个实现类的全限定名比如com.example.impl.WechatPayService。加载过程调用ServiceLoader.load(PayService.class)时JDK会去扫描所有jar包和目录下的对应文件读取类名通过反射实例化然后返回一个可迭代的ServiceLoader对象。我写了一段最小验证代码你可以直接跑public interface PayService { String pay(String orderId); } public class WechatPayService implements PayService { Override public String pay(String orderId) { return wechat pay: orderId; } }然后在src/main/resources/META-INF/services/下建文件com.example.PayService内容写com.example.impl.WechatPayService。主程序里ServiceLoaderPayService loader ServiceLoader.load(PayService.class); for (PayService service : loader) { System.out.println(service.pay(ORDER_001)); }跑起来没问题。但这里有个细节很多人没注意ServiceLoader实现了Iterable每次迭代都会调用iterator()而iterator()内部会重新做一次完整的扫描和实例化。也就是说如果你写了两层for循环实现类会被new两次。我在项目里就因为这个踩过坑——某个实现类的构造函数里做了资源初始化结果被调了两次日志重复打印排查了半天。2.2 原生SPI的四个硬伤用久了之后我把原生SPI的问题归纳成四条无缓存重复实例化每次load或迭代都重新创建对象无法做到单例复用。对于有状态或初始化成本高的实现类这是致命的。无法按名称获取只能全量遍历然后自己判断哪个是想要的。如果实现类有十几个每次都要写循环加条件判断代码很丑。不支持优先级和排序加载顺序取决于文件读取顺序和classpath顺序不可控。多个实现同时存在时想指定默认实现很麻烦。缺乏依赖注入能力实现类无法方便地获取外部容器中的其他Bean只能靠静态持有或手动传递。注意JDK 9之后模块化系统对SPI做了一些增强但核心的加载语义没有变上面这些问题依然存在。理解了这些短板自己写框架的目标就明确了缓存实例、支持按名获取、支持优先级排序、预留依赖注入扩展点。接下来我按这个目标来设计。3. 框架整体设计从需求倒推结构3.1 核心需求拆解我把需求分成必须实现和可选扩展两类。必须实现的是框架能跑起来的基础可选扩展的是让框架更好用的加分项。必须实现定义扩展点接口的标记可以用注解也可以用接口继承扫描classpath下的配置文件读取实现类名反射实例化实现类并缓存起来提供按接口类型获取所有实现的方法提供按名称获取单个实现的方法可选扩展支持实现类优先级排序支持实现类的别名一个实现可以有多个名字支持懒加载和饿加载两种模式预留ExtensionFactory接口方便接入Spring等容器3.2 为什么选择“注解配置文件”双驱动设计的时候我纠结过一个问题扩展点的声明方式用纯注解还是纯配置文件还是两者结合纯注解的话实现类上打Extension(wechat)框架扫描指定包路径下的所有类找到带注解的类注册。这种方式代码简洁但扫描包路径在大型项目里性能不好而且跨jar包扫描需要额外的类路径处理。纯配置文件的话就是JDK SPI的路子实现类名写在文件里。这种方式加载快但配置和代码分离改个名字要同时改两处容易漏。最后我选了双驱动配置文件负责声明“有哪些实现类”注解负责声明“这个实现类叫什么名字、优先级多少”。这样既保留了配置文件加载快的优势又通过注解把元信息贴在实现类上减少配置文件的维护成本。配置文件里只写全限定类名名字和优先级从注解读读不到就用类名做默认名。这个取舍的逻辑是配置文件的职责是“发现”注解的职责是“描述”。两者各司其职不重叠。3.3 整体结构图文字描述框架分成四层配置层负责读取META-INF/spi/目录下的配置文件解析出实现类名列表。注册层负责把实现类名转换成ExtensionDefinition对象包含类名、别名、优先级、是否单例等信息。加载层负责按需实例化实现类维护实例缓存处理单例和原型两种生命周期。门面层对外暴露ExtensionLoader提供getExtension(name)、getExtensions()、getDefaultExtension()等API。这四层里注册层和加载层是核心也是坑最多的地方。下面我逐个拆解。4. 核心实现手把手写关键代码4.1 定义扩展点注解先定义两个注解。一个是SPI打在接口上标记这是一个扩展点接口同时可以指定默认实现的名字。另一个是Extension打在实现类上声明别名和优先级。Target(ElementType.TYPE) Retention(RetentionPolicy.RUNTIME) public interface SPI { String value() default ; } Target(ElementType.TYPE) Retention(RetentionPolicy.RUNTIME) public interface Extension { String value() default ; int order() default 0; }SPI的value用来指定默认实现名比如SPI(wechat)表示默认用微信支付。Extension的value是别名order是优先级数值越小优先级越高。这里有个设计细节为什么别名不直接用类名因为类名太长业务代码里写getExtension(com.example.impl.WechatPayService)很难看。别名是给人用的类名是给机器用的分开更合理。4.2 配置文件读取器配置文件放在META-INF/spi/目录下文件名是接口全限定名。内容每行一个实现类全限定名支持#开头的注释行。public class ExtensionConfigReader { private static final String DIR META-INF/spi/; public static ListString read(String interfaceName) { ListString result new ArrayList(); String path DIR interfaceName; try { ClassLoader cl Thread.currentThread().getContextClassLoader(); EnumerationURL urls cl.getResources(path); while (urls.hasMoreElements()) { URL url urls.nextElement(); try (BufferedReader reader new BufferedReader( new InputStreamReader(url.openStream(), StandardCharsets.UTF_8))) { String line; while ((line reader.readLine()) ! null) { line line.trim(); if (line.isEmpty() || line.startsWith(#)) { continue; } result.add(line); } } } } catch (IOException e) { throw new RuntimeException(read spi config failed: path, e); } return result; } }这段代码的关键点是getResources而不是getResource。前者返回所有匹配的URL后者只返回第一个。SPI的场景下多个jar包可能都提供了同一个接口的实现必须全部读出来。我一开始用了getResource结果只加载到本项目的实现依赖jar里的实现全丢了排查了好久才反应过来。提示读取时指定UTF-8编码。默认编码在不同环境下可能不一致配置文件里如果有中文注释容易出现乱码。4.3 扩展定义与注册读出来的类名需要进一步解析成ExtensionDefinition包含别名、优先级、单例标记等。public class ExtensionDefinition { private String name; private Class? clazz; private int order; private boolean singleton; // getter/setter 省略 }注册逻辑放在ExtensionLoader的初始化阶段private void loadDefinitions() { ListString classNames ExtensionConfigReader.read(interfaceClass.getName()); for (String className : classNames) { try { Class? clazz Class.forName(className, false, Thread.currentThread().getContextClassLoader()); if (!interfaceClass.isAssignableFrom(clazz)) { throw new IllegalStateException(className not implement interfaceClass); } Extension ext clazz.getAnnotation(Extension.class); ExtensionDefinition def new ExtensionDefinition(); def.setClazz(clazz); def.setName(ext ! null !ext.value().isEmpty() ? ext.value() : clazz.getSimpleName()); def.setOrder(ext ! null ? ext.order() : 0); def.setSingleton(true); definitions.put(def.getName(), def); } catch (ClassNotFoundException e) { throw new RuntimeException(class not found: className, e); } } }这里用Class.forName(className, false, cl)第二个参数false表示不立即初始化类。为什么要这样因为注册阶段只需要拿到Class对象和注解信息不需要触发静态代码块。如果实现类的静态初始化里有耗时操作或副作用提前触发会拖慢框架启动。真正实例化放到加载阶段做这是懒加载的体现。4.4 实例缓存与按名获取加载层的核心是缓存。我用ConcurrentHashMap存单例实例key是别名value是实例对象。private final MapString, Object singletonCache new ConcurrentHashMap(); public T getExtension(String name) { if (name null || name.isEmpty()) { name defaultName; } ExtensionDefinition def definitions.get(name); if (def null) { throw new IllegalArgumentException(no extension named: name); } if (def.isSingleton()) { return (T) singletonCache.computeIfAbsent(name, k - newInstance(def)); } return (T) newInstance(def); } private Object newInstance(ExtensionDefinition def) { try { return def.getClazz().getDeclaredConstructor().newInstance(); } catch (Exception e) { throw new RuntimeException(instantiate failed: def.getClazz(), e); } }computeIfAbsent是Java 8引入的原子操作能保证并发场景下只实例化一次。如果用containsKey加put两个线程同时判断为不存在就会new两次。这个坑我在压测的时候遇到过日志里同一个实现类的构造函数打印了两次。按名获取解决了原生SPI“只能遍历”的问题。业务代码里直接loader.getExtension(wechat)清晰明了。4.5 优先级排序与默认实现getExtensions()返回所有实现按order升序排列public ListT getExtensions() { return definitions.values().stream() .sorted(Comparator.comparingInt(ExtensionDefinition::getOrder)) .map(def - (T) getExtension(def.getName())) .collect(Collectors.toList()); }默认实现的确定逻辑优先取SPI注解的value如果为空取排序后的第一个。private String resolveDefaultName() { SPI spi interfaceClass.getAnnotation(SPI.class); if (spi ! null !spi.value().isEmpty()) { return spi.value(); } return definitions.values().stream() .min(Comparator.comparingInt(ExtensionDefinition::getOrder)) .map(ExtensionDefinition::getName) .orElse(null); }这个设计让“默认用哪个实现”变得可配置。比如支付场景测试环境默认用模拟支付生产环境默认用微信支付改一个注解值就行不用动业务代码。5. 实操验证从零跑通一个完整例子5.1 项目结构与依赖我建了一个Maven项目结构如下spi-demo ├── pom.xml └── src/main ├── java/com/example │ ├── PayService.java │ ├── impl/WechatPayService.java │ ├── impl/AlipayService.java │ └── Main.java └── resources/META-INF/spi └── com.example.PayServicepom.xml里不需要额外依赖JDK自带的反射和集合类就够了。这也是自己写框架的一个好处零依赖打包体积小。5.2 接口与实现类接口上打SPI指定默认实现为wechatSPI(wechat) public interface PayService { String pay(String orderId); }两个实现类分别打ExtensionExtension(value wechat, order 1) public class WechatPayService implements PayService { public WechatPayService() { System.out.println(WechatPayService init); } Override public String pay(String orderId) { return wechat pay: orderId; } } Extension(value alipay, order 2) public class AlipayService implements PayService { Override public String pay(String orderId) { return alipay: orderId; } }配置文件META-INF/spi/com.example.PayService内容com.example.impl.WechatPayService com.example.impl.AlipayService5.3 主程序验证public class Main { public static void main(String[] args) { ExtensionLoaderPayService loader ExtensionLoader.getLoader(PayService.class); // 按名获取 PayService wechat loader.getExtension(wechat); System.out.println(wechat.pay(ORDER_001)); // 再次获取验证缓存 PayService wechat2 loader.getExtension(wechat); System.out.println(same instance: (wechat wechat2)); // 默认实现 PayService defaultPay loader.getDefaultExtension(); System.out.println(default: defaultPay.pay(ORDER_002)); // 全量获取验证排序 loader.getExtensions().forEach(s - System.out.println(s.getClass().getSimpleName())); } }预期输出WechatPayService init wechat pay: ORDER_001 same instance: true default: wechat pay: ORDER_002 WechatPayService AlipayService注意WechatPayService init只打印了一次说明缓存生效了。same instance: true进一步验证了单例。排序输出里微信在前因为它的order是1小于支付宝的2。5.4 参数选择背后的计算order的取值不是随便定的。我习惯用10的倍数留间隔比如10、20、30这样以后想在中间插入新实现不用改已有代码。如果直接用1、2、3插入一个优先级介于1和2之间的实现就得把2改成33改成4牵一发动全身。单例标记默认是true因为大多数SPI实现是无状态的工具类单例足够。如果某个实现有状态比如持有连接池可以在Extension里加一个singletonfalse的属性框架里判断后每次new新对象。这个扩展点我预留了但默认不开避免增加使用者的心智负担。6. 踩坑记录与问题排查6.1 类加载器不一致导致加载失败第一次在Web项目里跑的时候报ClassNotFoundException。配置文件明明在类名也没写错。后来发现是类加载器的问题框架用Thread.currentThread().getContextClassLoader()读配置但Class.forName用的是框架自己的类加载器两者不是同一个导致读到了配置却加载不了类。解决办法是统一用线程上下文类加载器Class? clazz Class.forName(className, false, Thread.currentThread().getContextClassLoader());这个坑在容器化部署、热部署场景下特别常见。记住一个原则读资源和加载类用同一个类加载器。6.2 配置文件重复导致实现类被注册两次有一次依赖了两个jar包它们都提供了同一个接口的实现而且配置文件路径相同。getResources会把两个文件都读出来如果里面有重复的类名definitions这个Map会用后读的覆盖先读的。如果别名相同但类不同就会静默丢失一个实现。我的处理方式是在注册时检测重复别名ExtensionDefinition exist definitions.get(def.getName()); if (exist ! null !exist.getClazz().equals(def.getClazz())) { throw new IllegalStateException(duplicate extension name: def.getName() , exist.getClazz() vs def.getClazz()); }宁可启动时报错也不要运行时行为不确定。这是我在生产环境踩过坑之后定下的规矩。6.3 常见问题速查表问题现象可能原因排查方法解决方案加载不到任何实现配置文件路径或文件名错误检查META-INF/spi/下文件名是否为接口全限定名修正路径和文件名ClassNotFoundException类加载器不一致打印当前线程上下文类加载器统一使用上下文类加载器实现类被实例化多次未使用缓存或并发问题在构造函数打日志观察次数用computeIfAbsent保证原子性别名冲突多个jar包注册了同名实现启动时检查definitions的key抛异常或加命名空间前缀默认实现不对SPI的value拼写错误检查注解值和别名是否一致修正注解值或别名排序不符合预期order值相同打印各实现的order用10的倍数留间隔6.4 独家避坑技巧构造函数里不要做重操作即使有缓存首次加载仍会触发构造函数。如果里面有网络请求或大文件读取会拖慢启动。建议把初始化逻辑放到单独的init()方法由使用方显式调用。配置文件加注释说明每行实现类后面用#加一句用途说明比如com.example.impl.WechatPayService # 微信支付。半年后回来看能省很多回忆时间。别名用短横线分隔比如wechat-pay而不是wechatPay。短横线在配置文件和日志里更易读也避免了大小写敏感的问题。预留SPI文件的编码声明虽然我用了UTF-8但团队里有人用GBK保存导致中文注释乱码。可以在文件第一行加# encoding: utf-8作为约定虽然框架不解析但能提醒维护者。7. 和JDK原生SPI的对比与选型建议7.1 功能对比能力JDK ServiceLoader自研SPI框架实例缓存不支持支持单例缓存按名获取不支持支持优先级排序不支持支持order排序默认实现不支持支持注解指定依赖注入不支持预留扩展点零依赖是是学习成本低中7.2 什么时候用原生什么时候自研如果你的需求只是“发现所有实现并遍历”原生ServiceLoader足够了没必要造轮子。它的代码经过JDK团队多年打磨稳定性和兼容性都有保障。但如果你遇到下面任意一种情况自研框架的价值就体现出来了实现类有初始化成本需要单例复用需要按业务名称精确获取某个实现多个实现之间有优先级关系需要指定默认实现类需要和Spring等容器集成我的建议是先用原生SPI遇到瓶颈再自研。不要一上来就写框架那是过度设计。框架的复杂度应该由真实需求驱动而不是技术炫技。7.3 后续可扩展的方向这个框架目前是基础版还有几个方向可以继续打磨接入Spring容器实现ExtensionFactory接口让实现类从Spring的ApplicationContext里取而不是反射new。这样实现类里可以直接Autowired其他Bean。支持懒加载和饿加载切换目前是懒加载首次getExtension才实例化。可以加一个配置启动时预实例化所有单例适合对启动速度不敏感但对首次调用延迟敏感的场景。增加实现类的生命周期回调定义ExtensionLifecycle接口提供onLoad和onDestroy方法方便实现类管理资源。支持条件加载根据环境变量或配置开关决定是否加载某个实现适合灰度发布场景。这些扩展点我在设计时都留了接口但没实现因为当前项目用不到。框架设计的一个原则是预留扩展点但不提前实现。接口在那里需要的时候填代码就行不影响现有功能。8. 我在实际项目中的使用体会这个SPI框架后来在我负责的三个项目里都落地了。最直观的感受是业务代码里那些if-else分支消失了取而代之的是loader.getExtension(channel).pay(orderId)这样的一行调用。新增支付渠道时只需要加一个实现类、打上注解、在配置文件里加一行主流程代码一行都不用改。性能方面单例缓存带来的收益很明显。之前用原生SPI每次调用都要重新实例化一个支付实现类的构造函数里有签名校验耗时大概5毫秒QPS一高就成了瓶颈。换成自研框架后首次加载后实例常驻后续调用耗时降到微秒级。当然也有代价。框架本身有维护成本配置文件写错、别名冲突这些问题需要团队里有人懂原理才能快速排查。所以我在项目里配了一份简短的排查手册把上面那张速查表打印出来贴在工位上新人遇到问题先查表解决不了再找我。最后分享一个小技巧如果你也在写类似的框架建议在ExtensionLoader里加一个dump()方法把所有已注册的实现、别名、优先级、是否已实例化打印出来。排查问题时一眼就能看出注册状态比翻日志快得多。这个方法我几乎在每个自研框架里都会加成本极低收益极高。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。