Ohhnews

分类导航

$ cd ..
DZone Java原文

使用 Service Loader 扩展 Java 库的功能

#java#service loader#spi#插件架构#库开发

在设计 Java 库时,可扩展性往往是一项关键需求,尤其是在项目的后期阶段。库的开发者希望允许用户在不修改核心代码库的情况下添加自定义行为或提供自己的实现。Java 通过 Service Loader API 解决了这一需求,这是一种用于在运行时发现并加载给定接口实现的内置机制。

Service Loader 实现了应用程序编程接口 (API) 与其实现之间的清晰分离,使其成为插件式架构和服务提供者接口 (SPI) 的理想选择。在本文中,我们将探讨如何在实践中使用 Service Loader,并了解其在构建可扩展 Java 库时的优势与局限性。

示例用法

在演示项目中,该库允许基于注解自定义命名策略,并为此提供了专门的 SPI 实现。

SPI 定义

首先,让我们从核心库模块中的 SPI 开始:

$ java
public interface TypeAliasHandler<T extends Annotation> {
    Class<T> getSupportedAnnotation();
    String getTypeName(T annotation, Class<?> annotatedClass);
}

为了使 Service Loader API 能够发现该接口的实现,必须在类路径的 META-INF/services/ 目录下创建一个配置文件。文件名必须与接口的全限定名完全一致。在该文件中,按行依次列出所有实现类的全限定类名。这种机制允许 Service Loader 在运行时自动查找并加载所有可用的实现。

内置提供者

在同一个 JAR 文件中,我们可以定义内置注解及其默认行为。为了保持架构的一致性和便利性,负责内置注解的处理程序也实现了该 SPI 接口。这种方法确保了内部和外部实现都能被 Service Loader 机制统一处理。

$ java
public class BuiltInTypeAliasHandler implements TypeAliasHandler<TypeAlias> {
    @Override
    public Class<TypeAlias> getSupportedAnnotation() {
        return TypeAlias.class;
    }
    @Override
    public String getTypeName(TypeAlias annotation, Class<?> annotatedClass) {
        return annotation.value();
    }
}

注解定义如下:

$ java
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface TypeAlias {
    String value();
}

实现必须定义在以下路径中: META-INF/services/com.github.alien11689.serviceloaderdemo.coreservice.spi.TypeAliasHandler

内容如下: com.github.alien11689.serviceloaderdemo.coreservice.builtin.BuiltInTypeAliasHandler

扩展模块

你可以创建一个单独的项目(或 JAR 文件)来提供自定义注解及其实现。这样的扩展模块可以独立于主库进行开发,并根据需要添加到类路径中。这展示了 Service Loader 的真正威力——无需修改主库的源代码即可添加新功能,且无需重新编译或重新部署核心库。

首先是注解:

$ java
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface CustomTypeAlias {
    String nameOfTheType();
}

以及:

$ java
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface UpperCasedClassSimpleNameTypeAlias { }

它们的处理程序(SPI 实现):

$ java
@ServiceProvider
public class CustomTypeAliasHandler implements TypeAliasHandler<CustomTypeAlias> {
    @Override
    public Class<CustomTypeAlias> getSupportedAnnotation() {
        return CustomTypeAlias.class;
    }
    @Override
    public String getTypeName(CustomTypeAlias annotation, Class<?> annotatedClass) {
        return annotation.nameOfTheType();
    }
}

以及:

$ java
@ServiceProvider
public class UpperCasedClassSimpleNameTypeAliasHandler implements TypeAliasHandler<UpperCasedClassSimpleNameTypeAlias> {
    @Override
    public Class<UpperCasedClassSimpleNameTypeAlias> getSupportedAnnotation() {
        return UpperCasedClassSimpleNameTypeAlias.class;
    }
    @Override
    public String getTypeName(UpperCasedClassSimpleNameTypeAlias annotation, Class<?> annotatedClass) {
        return annotatedClass.getSimpleName().toUpperCase();
    }
}

由于我使用了 Avaje 提供的 @ServiceProvider 注解,因此无需手动创建 META-INF/services/...TypeAliasHandler 文件。它会在构建过程中自动生成,内容如下:

com.github.alien11689.serviceloaderdemo.extensions.custom.CustomTypeAliasHandler
com.github.alien11689.serviceloaderdemo.extensions.uppercased.UpperCasedClassSimpleNameTypeAliasHandler

发现实现

在其中一个模块中(甚至是提供 SPI 的模块),应该包含使用 Service Loader API 来发现并使用所有实现的代码。在本例中,我将发现代码放在了 core 模块中,这是一种实用的做法——中心模块可以聚合所有可用的实现,并为应用程序的其他部分提供便捷的访问方式。

在静态初始化块中,Service Loader 会扫描整个类路径以查找配置文件,并自动创建所有已发现实现的实例:

$ java
public class TypeAliasProvider {
    private static Map<Class<? extends Annotation>, TypeAliasHandler<?>> annotationToTypeNameHandler = new HashMap<>();
    static {
        var loader = ServiceLoader.load(TypeAliasHandler.class);
        loader.forEach(typeNameHandler -> 
            annotationToTypeNameHandler.put(typeNameHandler.getSupportedAnnotation(), typeNameHandler));
    }
    // ...
}

在同一个类中,可以根据给定类上存在的注解来使用已发现的实现:

$ java
public class TypeAliasProvider {
    // ...
    public String getTypeName(Object o) {
        var aClass = o.getClass();
        for (Annotation annotation : aClass.getAnnotations()) {
            var typeNameHandler = annotationToTypeNameHandler.get(annotation.annotationType());
            if (typeNameHandler != null) {
                return typeNameHandler.getTypeName(annotation, aClass);
            }
        }
        return aClass.getName();
    }
}

一起测试一下

为了有效地测试扩展机制,所有 SPI 实现都必须存在于类路径中。这意味着你需要在测试项目中同时包含核心模块(包含 SPI 定义)和所有包含特定实现的扩展模块。Service Loader 将自动发现所有可用的服务,并在执行测试期间启用它们。

测试类:

$ java
@TypeAlias("class_a")
class ClassWithDefaultTypeAlias { }

@CustomTypeAlias(nameOfTheType = "Class B with custom alias")
class ClassWithCustomTypeAlias { }

@UpperCasedClassSimpleNameTypeAlias
class UpperCaseClass { }

参数化测试:

$ java
class TypeAliasExtensionMappingTest {
    private final TypeAliasProvider typeAliasProvider = new TypeAliasProvider();

    @ParameterizedTest
    @MethodSource("objectToTypeName")
    void should_map_object_to_type_name(Object o, String expectedTypeName) {
        Assertions.assertEquals(expectedTypeName, typeAliasProvider.getTypeName(o));
    }

    private static Stream<Arguments> objectToTypeName() {
        return Stream.of(
            arguments(new Object(), "java.lang.Object"),
            arguments(new ClassWithDefaultTypeAlias(), "class_a"),
            arguments(new ClassWithCustomTypeAlias(), "Class B with custom alias"),
            arguments(new UpperCaseClass(), "UPPERCASECLASS")
        );
    }
}

完整代码

完整的示例代码可以在我的 GitHub 上找到。该演示最初旨在展示 Javers 的扩展可能性。

优点

  • 轻量且无依赖 —— Service Loader 是 JDK 的一部分,不需要任何额外的运行时库。
  • 标准化解决方案 —— 在所有 JVM 环境中表现一致。
  • 自动服务发现 —— 无需在代码中显式注册,即可在运行时发现实现。
  • 解耦架构 —— 促进了核心与插件之间的清晰分离。

缺点

  • 无构造函数参数 —— 服务实现必须提供无参构造函数,这使得配置和依赖注入变得困难。可能需要额外的 SPI 方法(例如 void configure(Properties properties))。
  • 无内置依赖注入 —— Service Loader 不管理依赖项、作用域或生命周期。
  • 公共类要求 —— 实现必须声明为 public,这限制了封装性。
  • 可配置性有限 —— 开箱即用不支持基于条件或环境的服务加载。
  • 调试困难 —— 服务定义缺失或错误可能会在运行时静默失败。
  • 不适合复杂系统 —— 对于高级用例,Spring 或 Guice 等完整的 DI 框架提供了更大的灵活性。

总结

Service Loader 是构建可扩展 Java 库的一个简单而强大的工具。在需要最小化依赖、可移植性和清晰 API 边界的场景中,它表现出色。虽然它存在一些显著的局限性——特别是在构造函数灵活性、依赖注入和可见性约束方面——但它仍然是轻量级扩展机制的绝佳选择。借助 Avaje 等工具,可以减轻 Service Loader 的一些传统痛点,使其成为现代 Java 库设计的更具吸引力的选项。