第3章 编写第一个SPI服务:定义接口、编写实现、配置文件、加载调用
本章将引导你从零搭建一个完整的SPI服务,涵盖接口定义、实现类编写、服务描述文件创建以及客户端加载调用的全流程。这也是后续章节中所有模块边界优化的基础。
3.1 整体实现流程
一次标准的SPI调用包含四个角色:服务接口、服务提供者、服务描述文件、ServiceLoader客户端。下图展示了它们之间的协作关系。
3.2 定义服务接口
服务接口是SPI的契约,所有提供者都必须实现该接口。它通常是一个抽象类型(接口或抽象类),定义一组业务方法。
// 文件:src/main/java/com/example/spi/IShape.java
package com.example.spi;
/**
* 图形绘制服务接口 —— SPI 契约
*/
public interface IShape {
/**
* 绘制图形
* @return 绘制结果的字符串描述
*/
String draw();
}
接口设计建议:SPI接口尽量使用接口(interface)而非抽象类,避免多重继承限制。方法签名应保持稳定,因为接口变更会影响所有提供者。
3.3 编写两个实现类
每个实现类都必须拥有一个公共无参构造器(可以是默认构造器)。ServiceLoader在加载时会通过Class.newInstance()实例化,如果没有无参构造器会抛出InstantiationException。
3.3.1 圆形实现
// 文件:src/main/java/com/example/spi/impl/RoundShape.java
package com.example.spi.impl;
import com.example.spi.IShape;
public class RoundShape implements IShape {
@Override
public String draw() {
return "绘制圆形:半径 10px,圆心 (0,0)";
}
}
3.3.2 方形实现
// 文件:src/main/java/com/example/spi/impl/SquareShape.java
package com.example.spi.impl;
import com.example.spi.IShape;
public class SquareShape implements IShape {
@Override
public String draw() {
return "绘制正方形:边长 20px,左下角 (5,5)";
}
}
常见错误:实现类不能带参数构造器且未显式声明无参构造器。如果你的类中定义了有参构造器,请务必同时添加一个无参构造器。
3.4 创建服务描述文件
服务描述文件是SPI的“注册表”,必须放置在META-INF/services/目录下,文件名与接口的全限定名一致。文件内容为每行一个实现类的全限定名。
# 文件:META-INF/services/com.example.spi.IShape
# 注意:该文件路径相对于类路径根目录
com.example.spi.impl.RoundShape
com.example.spi.impl.SquareShape
| 配置项 | 要求 | 说明 |
|---|---|---|
| 文件名 | 接口的全限定名 | 例如 com.example.spi.IShape,大小写敏感 |
| 文件路径 | META-INF/services/ |
相对于编译后 classpath 根目录 |
| 编码 | UTF-8 | 避免出现中文乱码 |
| 每行内容 | 实现类的完全限定名 | 不含空格,可以有空行或 # 注释行 |
| 实例化方式 | 无参构造器反射调用 | 如果构造器抛出异常,该提供者会被跳过 |
3.5 编写客户端加载并调用
客户端使用java.util.ServiceLoader统一加载所有提供者,然后通过迭代器获取实例。
// 文件:src/main/java/com/example/spi/client/ShapeClient.java
package com.example.spi.client;
import com.example.spi.IShape;
import java.util.ServiceLoader;
public class ShapeClient {
public static void main(String[] args) {
// 加载所有 IShape 服务提供者
ServiceLoader<IShape> loader = ServiceLoader.load(IShape.class);
System.out.println("===== 使用 SPI 加载所有图形服务 =====");
int index = 0;
for (IShape shape : loader) {
index++;
System.out.println("服务 #" + index + ":" + shape.draw());
}
if (index == 0) {
System.err.println("警告:未发现任何 IShape 服务提供者!");
}
}
}
3.6 编译与运行
假设你使用 Maven 或 Gradle 管理项目,类路径会自动包含src/main/resources/META-INF/services/下的文件。如果使用纯命令行编译,请确保资源文件被正确复制到输出目录。
# 编译所有 Java 文件
javac -d out src/main/java/com/example/spi/IShape.java src/main/java/com/example/spi/impl/RoundShape.java src/main/java/com/example/spi/impl/SquareShape.java src/main/java/com/example/spi/client/ShapeClient.java
# 复制服务描述文件到输出目录
mkdir -p out/META-INF/services
cp src/main/resources/META-INF/services/com.example.spi.IShape out/META-INF/services/
# 运行客户端
java -cp out com.example.spi.client.ShapeClient
预期输出结果:
===== 使用 SPI 加载所有图形服务 =====
服务 #1:绘制圆形:半径 10px,圆心 (0,0)
服务 #2:绘制正方形:边长 20px,左下角 (5,5)
3.7 调试与常见错误
- 找不到服务文件:检查编译后
META-INF/services目录是否存在于 classpath 中。使用System.out.println(ShapeClient.class.getResource("/META-INF/services/com.example.spi.IShape"));进行定位。 - 没有加载任何实现:确认文件名与接口全限定名完全一致(包括大小写)。检查文件内容是否每行末尾有回车符或空格。
- InstantiationError / NoSuchMethodException:实现类缺少 public 无参构造器。显式添加
public RoundShape() {}即可解决。 - ClassNotFoundException:实现类的全限定名拼写错误,或者该类未被编译/打包到 classpath 中。
3.8 性能与稳定性要点
- 延迟加载:
ServiceLoader使用迭代器,调用iterator()时才会查找服务描述文件并反射实例化,而不是在load()时。如果提供者很多,可以考虑按需获取。 - 缓存机制:同一个
ServiceLoader实例会缓存已经加载过的服务,多次迭代不会重复实例化。但不同ServiceLoader实例之间独立。 - 线程安全:
ServiceLoader不是线程安全的。在多线程环境中应使用synchronized或ConcurrentHashMap包装自己的加载逻辑。 - 资源释放:在
Java 9+中,ServiceLoader实现了AutoCloseable,可以使用 try-with-resources 管理流式模块,但在传统类路径模式下仍可通过reload()清除缓存。
第一个SPI服务已完成构建。
你现在掌握了从接口定义、实现编写、配置文件创建到客户端调用的完整链路。下一阶段将深入
你现在掌握了从接口定义、实现编写、配置文件创建到客户端调用的完整链路。下一阶段将深入
ServiceLoader的加载机制与类加载细节,帮助你理解SPI在大型项目中的边界隔离能力。