目的 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 public Advice load (PluginConfig plugin) { if (adviceCache.containsKey(plugin.getClassName())) { System.out.println("【加载插件】读取缓存成功" ); return adviceCache.get(plugin.getClassName()); } System.out.println("【加载插件】正在读取插件配置..." ); 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) { if (addURLMethod == null ) { System.out.println("【加载插件】初次调用,获取类加载器的 addURL 方法" ); addURLMethod = URLClassLoader.class.getDeclaredMethod("addURL" , URL.class); if (!addURLMethod.isAccessible()) { addURLMethod.setAccessible(true ); } } System.out.println("【加载插件】正在加载插件:" + targetUrl); 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)); } } 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); ((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
包下的所有方法都连接上切 面;
使用场景 包括但不限于以下几种:
待测试
自动加载插件:通过监听插件所在目录变化,自动读取插件配置并加载插件;
自动生成插件配置:统一插件的生成方式,能够在打包时自动的生成插件的配置信息;