【Java】简单优雅的加载外部 jar 中的 Class|插件化

1,640 阅读10分钟

在鸽了那么亿段时间之后,我又回来了

那么今天主要就是来聊聊如何动态加载一个jar中的类

想直接看 Wiki 的同学可以点这里

需求

先来说说为什么会有这个需求

我之前做物联网相关业务平台时,要求这个平台能够接入各种设备

但是不同类型不同厂家不同协议的设备的接入方式完全不一样,字段也不一样

比如会有灯,摄像头,屏,广播等等各种各样的设备

灯会有开关等功能,摄像头会有预览回放等功能,屏会有播放视频等功能,广播会有播放音频调节音量等功能

有些设备通过TCP或是MQTT直连,有些设备通过HTTP或是SDK和厂家平台对接,还有一些通过OneNetOceanConnect这些第三方物联网平台对接

就算是相同类型的设备,比如摄像头,也会有海康的摄像头和大华的摄像头等等

所以在一开始的时候,每次对接一种设备,就相当于写一个if分支

我觉得这样肯定不是长久之计,就考虑将这块内容做个优化

于是想到了用动态属性加插件化的方式(能够解决一些痛点,但是有得就有失,这是后话了)

动态属性就先不展开了,而插件化就是通过动态加载jar中的类来实现

思路

那么这个插件化要怎么做呢

首先我们先列举一下这些设备之间相同和不同的点

相同点

  • 都是设备
  • 都需要操作(控制,查询等)

不同点

  • 属性不同
  • 对接方式不同(操作方式不同)

接着我们只要抽象相同点来解决不同点就行了

其中属性不同可以通过动态属性的方式解决

而操作方式不同这个问题就可以定义一个操作接口

public interface DeviceOperation {

    /**
     * 设备操作
     *
     * @param device  设备
     * @param opType  操作类型
     * @param opValue 操作值
     * @return 操作结果
     */
    OperationResult operate(Device device, String opType, Object opValue);
}

那么当我们需要对接海康摄像头时,就可以实现一个HikvisionCameraOperation并且打成一个jar(插件包),然后让我们的业务服务动态加载这个类并实例化,就可以实现对海康摄像头的操作了

这样实现的好处是:

  • 设备操作的代码不会和业务代码耦合,可以单独修复bug更新版本

这样实现的坏处是:

  • 由于插件的实现在不同的项目中,开发时调试起来会更加麻烦

示例

基于上述的思路,我们先在我们的插件项目中实现海康摄像头的具体操作类HikvisionCameraOperation,然后添加一个配置文件plugin.properties,设置一个属性device.type=HikvisionCamera即设备类型为海康摄像头,最后打包成hikvision-camera.jar

接着我们在业务服务中注入一个设备操作服务实例DeviceOperationService,添加一个设备类型和设备操作实现类的缓存Map<String, DeviceOperation>

当我们加载hikvision-camera.jar时,将提取到的device.typeHikvisionCameraOperation实例缓存起来

等到我们调用海康摄像头的操作功能时,先根据设备的设备类型从缓存中获得对应实现类HikvisionCameraOperation的实例,然后调用operate方法就能操作摄像头了

那么我们现在要怎么实现动态加载类呢

于是乎我自己实现了一个库

先上一个简单的写法

@Slf4j
@Service
public class DeviceOperationService {

    /**
     * 缓存设备类型和对应的操作对象
     */
    private final Map<String, DeviceOperation> operationMap = new ConcurrentHashMap<>();

    /**
     * 插件提取配置
     */
    private final JarPluginConcept concept = new JarPluginConcept.Builder()
            //回调到标注了@OnPluginExtract的方法
            .extractTo(this)
            .build();

    /**
     * 插件匹配回调
     *
     * @param operation  匹配到的 DeviceOperation 实例
     * @param deviceType 配置文件中定义的设备类型
     */
    @OnPluginExtract
    public void onPluginExtract(DeviceOperation operation, @PluginProperties("device.type") String deviceType) {
        operationMap.put(deviceType, operation);
    }

    /**
     * 加载 jar 插件
     *
     * @param filePath jar 文件路径
     */
    public void load(String filePath) {
        concept.load(filePath);
    }

上面就是提取插件的写法

首先定义一个JarPluginConcept,主要是做一些配置,如过滤器(按包名,类名等等),或者是提取器(如提取类,实例,配置文件等等)

接着定义一个方法并标注@OnPluginExtract,参数就是你需要的内容(可以是类,实例,或者配置文件中的某个属性等等),通过extractTo进行绑定

最后调用JarPluginConcept#load传入jar的文件路径后,就会触发回调把设备类型和对应的实现类放入缓存

这样我们就能通过设备类型从缓存中获得对应的实现类,实现特定功能的调用

@Slf4j
@Service
public class DeviceOperationService {

    /**
     * 缓存设备类型和对应的操作对象
     */
    private final Map<String, DeviceOperation> operationMap = new ConcurrentHashMap<>();

    /**
     * 操作一个设备
     *
     * @param device  设备对象
     * @param opType  操作类型
     * @param opValue 操作值
     * @return 操作结果
     */
    public OperationResult operate(Device device, String opType, Object opValue) {
        //获得设备类型
        String deviceType = device.getDeviceType();
        //根据设备类型获得操作实现类
        DeviceOperation operation = operationMap.get(deviceType);
        if (operation == null) {
            throw new DeviceOperationNotFoundException(deviceType + " not found");
        }
        return operation.operate(device, opType, opValue);
    }
}

设计

在说整个设计思路之前,先说说我提前想到的一些细节想法

因为基于上一个版本的库(之前实现过一个类似功能的库)我发现有很多地方不好用,就想着借着这个库都优化掉

  • 类型推导

之前实现的库都是直接指定一个Class参数,然后去匹配

后来发现又有读取配置文件的需求,就硬生生加了一个读取配置文件的if分支

所以在实现这个库的时候我就想着,能不能根据使用者定义的类型来推导

比如方法参数的类型是Class<DeviceOperation>Class<? extends DeviceOperation>就能推导出是DeviceOperation的类或子类,List<? extends DeviceOperation>就是DeviceOperation的实现类的实例列表,Properties就可能是.properties后缀的配置文件等等

然后再定义一个接口,支持其他类型的扩展,这样就算我的库里没对应的实现,使用者也可以通过自定义来解决一些不支持的类型的问题

  • 动态解析

之前实现的库直接会把所有的.class文件加载成类

但如果我现在只想得到所有的类名或者是里面的配置文件,那么类加载这个步骤就完全没必要了

所以我就在想能不能需要提取什么就解析什么,如果我们只要提取类,就解析类但不解析配置文件,如果只要配置文件,就解析配置文件但不解析类

于是我把jar的解析分成了很多步,提取文件路径名称,转化类名,加载类,实例化对象,提取.properties文件名,加载配置文件为Properties等等

然后不同的解析器会依赖其他的解析器作为前置解析器

比如我们的方法参数是Class<? extends DeviceOperation>,所以我们需要“加载类(解析器)”,而“加载类(解析器)”又需要依赖“转化类名(解析器)”,“转化类名(解析器)”又需要“提取文件路径名称(解析器)”等等,一层一层往上依赖

可以近似理解为GradleMaven中的依赖传递

这样做的好处就是不会有一些额外的解析逻辑做无用功,使用者也不需要手动添加一堆不知道什么功能的解析器

  • 插件依赖其他的jar

之前实现的库没办法依赖其他的jar,如果必须依赖,那么就需要在业务服务中添加依赖才能正常使用

所以我想到只要把依赖的jar也当作插件加载进来,不就可以加载到对应的类了么

比如有些设备对接需要用到Netty,那么就可以把Netty的包作为一个基础插件,其他的插件都在Netty这个插件的基础上构建

框架

接下来就从总体框架讲讲这个库的设计思路

首先java中其实自带了spi的功能,也能够实现一定程度上的插件化

那么这两者有什么区别呢

spi的设计思想是基于类加载这个java的独有体系(狭义上讲),而这个库是以“插件”这个概念为基础,动态加载类只是针对java在插件化这个概念上的一种具体实现方式,你完全可以把一个Excel作为一个插件来解析,而“插件”这个概念也可以应用于其他的开发语言

抽象

插件

从“插件”这个概念来说,显而易见,我们需要一个Plugin接口,然后jar文件可以实现JarPluginExcel可以实现ExcelPlugin

然后有一个管理类PluginConcept来加载对应的插件

public interface PluginConcept {

    /**
     * 加载插件
     *
     * @param o 插件源
     * @return 插件 {@link Plugin}
     */
    Plugin load(Object o);
}

以加载外部jar为例,我们可以传入文件路径,然后返回一个JarPlugin

插件工厂

我们可以传入一个文件路径,也可以传入一个File对象,难道我们要一个一个枚举出来么?

显然不可能,我们可以定一个插件工厂,来匹配输入对象

/**
 * 插件工厂
 */
public interface PluginFactory {

    /**
     * 是否支持插件创建
     *
     * @param o       插件源
     * @param concept {@link PluginConcept}
     * @return 如果支持返回 true,否则返回 false
     */
    boolean support(Object o, PluginConcept concept);

    /**
     * 创建插件 {@link Plugin}
     *
     * @param o       插件源
     * @param concept {@link PluginConcept}
     * @return 插件 {@link Plugin}
     */
    Plugin create(Object o, PluginConcept concept);
}

这样,我们可以为jar文件路径实现一个JarPathPluginFactory,为File对象实现一个JarFilePluginFactory,如果需要适配其他类型,就实现一个对应的工厂

插件上下文

之前说过我们把整个解析逻辑分成了很多步,那么每一步解析出来的内容肯定要找地方缓存起来,不可能每次重新解析一遍上一个步骤

通过定义上下文类PluginContext来缓存整个解析流程中的所有内容

当然也提供了对应的工厂PluginContextFactory,这样的话当使用者自定义解析器时如果需要引用其他对象也能十分方便的扩展

比如当需要用到Spring容器中的Bean时,就可以自定义上下文工厂,创建一个持有ApplicationContext的上下文

插件过滤器

当我们想从jar中提取类时,必然会先进行类加载

而符合条件的类可能就那么几个,完全没有必要把全部的类都加载一遍

通过定义插件过滤器PluginFilter来过滤每一步解析的内容,这样就能减小解析的范围

比如当我们添加了一个包名过滤器,这样只有对应包下的类才会进行加载,适合类非常多但是只需要提取几个核心类的场景

插件匹配器

当我们解析完之后,就可以根据方法的参数类型从上下文中获取我们需要的内容了

通过定义插件匹配器PluginMatcher来匹配上下文中的内容

比如,参数类型为Class<?>,结合之前提到的类型推导,我们就可以从“加载类(解析器)”的解析结果中获得需要的类

插件转换器

接下来我们就要看从上下文中获得的内容是否需要转换,当然如果是类的话就不用转换了

但是比如像配置文件的内容,我们在上下文中获得的内容可能是Properties对象,而方法参数类型为LinkedHashMap<String, String>,这样的话直接赋值就会有问题

通过定义插件转换器PluginConvertor来做转换,方便不同类型之间的转换

插件格式器

当我们搞定了元素类型之后,还需要判断容器类型是否匹配

比如我们从上下文中获得的类数据是Map<String, Class<?>>(其中key为文件路径和名称),而方法参数的类型定义的是List<Class<?>>或者是Class<?>[],就需要根据指定的容器类型进行格式化

通过定义插件格式器PluginFormatter来适配不同的容器类型

插件事件

事件肯定是必不可少的,加载,卸载,解析,匹配,转换,格式化等等,都可以进行事件发布

事件本身和流程上的逻辑扩展起来都是十分方便

插件自动加载

基本上的内容设计的差不多了,但是每次都要手动调用方法是不是有亿点点小麻烦

于是我就想到能不能监听某个目录路径,当文件新增时自动加载,文件修改时自动重新加载,文件删除时自动卸载

通过定义PluginAutoLoader来支持自动加载插件

结束

主要的内容就是这么多,这个库实现下来倒是对泛型这块内容深入了不少

大家有兴趣的话可以捧个场,有更详细的说明,之后也会慢慢更新其他的库


其他的文章

【Spring Boot】一个注解实现下载接口

【拿来吧你】JDK动态代理

【Java】异步回调转为同步返回