目的

spring aop 能够对其托管的 bean 进行无侵入式的增强,原有代码与增强代码之间解耦,但是其配置始终是写死在项目中,对其管理依旧需要重启项目,这对生产来说是比较麻烦的。
插件式开发正是解决此类问题的一种有效方案,能够提供灵活的、可插拔式的热更新(即无重启更新),通过定义合适的配置文件,能够提供方便有效且统一的管理页面。同时,也可基于环境读取不同的配置,实现同一插件在不同环境的个性化配置。
对于插件来说,只要向运行项目提供透明的配置信息,则可以在项目外直接被识别并使用。

实现原理

JVM 通过类加载器在启动时加载 class 文件,且一般地只会加载一次,但在程序内可以通过手动配置类加载器,可提供要加载的类的 jar 包路径及限定名,将其读取并加载到虚拟机,之后通过反射可以实例化加载的类对象,最后注册成 spring bean 之后就可以被 spring 托管。

代码实现

插件的基本配置

1
2
3
4
5
6
7
8
9
{
"config": {
"active": false,
"className": "com.example.plugin.libs.LogPlugin",
"id": 1,
"jarRemoteUrl": "C:WorkspaceVSCodeProjectsJavademosrcmain\resourcesplugins",
"name": "日志插件"
}
}
属性 备注
id 唯一标识
name 插件名称
jarRemoteUrl jar 包的地址
className 类的限定名
active 是否激活

注:以上为必备属性,亦可添加描述、版本号等其他属性。

类加载器读取插件

URLClassLoader 类加载器可以通过路径来读取 jar 包,但由于其 addURL 方法并不是被 public 修饰,无法直接访问,因此需要通过反射来获取该方法的使用权限。
同时应该避免多次加载重复文件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
/**
* 加载插件
*
* @param plugin 插件
* @return 返回 aop 通知对象
*/
public Advice load(PluginConfig plugin) {
// 若缓存中已存在该类,则直接返回
if (adviceCache.containsKey(plugin.getClassName())) {
System.out.println("【加载插件】读取缓存成功");
return adviceCache.get(plugin.getClassName());
}
System.out.println("【加载插件】正在读取插件配置...");
// 获取插件 jar 包路径
try {
URL targetUrl = Paths.get(plugin.getJarRemoteUrl()).toUri().toURL();
// 获取当前项目的类加载器
URLClassLoader loader = (URLClassLoader) this.getClass().getClassLoader();
boolean isLoader = false;
// 遍历已加载类
for (URL url : loader.getURLs()) {
// 当前插件已经被加载退出
if (url.equals(targetUrl)) {
System.out.println("【加载插件】插件已被加载!");
isLoader = true;
break;
}
}
// 当该插件尚未被加载
if (!isLoader) {
// 反射获取类加载器的 addURL 方法
if (addURLMethod == null) {
System.out.println("【加载插件】初次调用,获取类加载器的 addURL 方法");
addURLMethod = URLClassLoader.class.getDeclaredMethod("addURL", URL.class);
if (!addURLMethod.isAccessible()) {
addURLMethod.setAccessible(true);
}
}
System.out.println("【加载插件】正在加载插件:" + targetUrl);
// 类加载器获取将插件的 jar 路径
addURLMethod.invoke(loader, targetUrl);
}
// 加载类
Class<?> adviceClass = Class.forName(plugin.getClassName());
// 创建插件对象,并缓存
System.out.println("【加载插件】生成插件并缓存");
adviceCache.put(adviceClass.getName(), (Advice) adviceClass.newInstance());
} catch (MalformedURLException | NoSuchMethodException | SecurityException | IllegalAccessException
| IllegalArgumentException | InvocationTargetException | ClassNotFoundException
| InstantiationException e) {
System.out.println(e.getLocalizedMessage());
}
return adviceCache.get(plugin.getClassName());
}

注册成 spring bean

此处的插件基于 aop。对于 aop 增强的 bean 会被 spring 自动在最顶层上生成一个 Advised 的增强类,因此插件的最终去向是被应用在 bean instanceof Advised (即被增强的)spring 类对象上,当然,插件实现是应该继承 spring aop 的通知接口,如  MethodBeforeAdvice  等。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
@Data
public class LogPlugin implements MethodBeforeAdvice {

@Override
public void before(Method method, Object[] args, Object target) {
System.out.println(
method.getDeclaringClass().getName() + "." + method.getName() + ",args = " + Arrays.toString(args));
}
}

//////////// 激活插件

/**
* 激活插件
* @param id 插件 id
*/
public void active(String id) {
PluginConfig pluginConfig = pluginConfigMap.get(id);
System.out.println("【插件激活】正在激活插件:" + pluginConfig);
// 检查插件是否被加载
if (!pluginConfigMap.containsKey(id)) {
throw new RuntimeException("【插件激活】尚未读取插件配置信息,无法激活");
}
Object bean;
for (String name : applicationContext.getBeanDefinitionNames()) {
bean = applicationContext.getBean(name);
if (bean == this || !(bean instanceof Advised) || findAdvice((Advised) bean, pluginConfig.getClassName())) {
continue;
}
System.out.printf("【插件激活】正在为【%s】激活插件", name);
// 对增强 bean 应用插件
((Advised) bean).addAdvice(this.load(pluginConfig));
}
System.out.println("【插件激活】激活完毕");
}

注意事项

  • 使用  spring-boot-maven-plugin  打包会同时生成两个 jar 包,一种是可直接执行的程序包(.jar),另一种是作为外部引用的依赖包(.jar.original)。这里需要的是依赖包,使用压缩软件打开可以看到,其目录结构完全符合类的限定名(jar/包名/类名);而可执行程序的目录是(jar/BOOT-INF/classes/包名/类名);
  • 对于类加载器,可以使用当前类的加载器  (URLClassLoader) this.getClass().getClassLoader(),也可以使用系统类加载器  (URLClassLoader) ClassLoader.getSystemClassLoader(),但都需要转型URLClassLoader,因为其可以加载外部 jar 或 class 文件;
  • spring aop 接口对 bean 的要求是其顶层实现必须继承  Advised 接口,因此,对于需要支持插件增强的 bean 必须已经被 aop 切入,所以最好的做法是提前使用一个空白的切面组件对 bean 进行标记,如:
1
2
3
4
5
6
7
@Aspect
@Component
public class PluginAspect {

@After(value = "execution(* com.example.demo.controller..*.*(..))")
public void after() {}
}

实际上,切面方法内不执行任何操作,目的是将  com.example.demo.controller 包下的所有方法都连接上切          面;

使用场景

包括但不限于以下几种:

  • 日志;
  • 性能监控;
  • 权限拦截器;

待测试

  • 自动加载插件:通过监听插件所在目录变化,自动读取插件配置并加载插件;
  • 自动生成插件配置:统一插件的生成方式,能够在打包时自动的生成插件的配置信息;

评论