From: Andreas Rudolph Date: Fri, 11 Jan 2019 14:04:17 +0000 (+0100) Subject: Optional plugin dependencies (#270) X-Git-Tag: release-2.6.0~7 X-Git-Url: https://source.dussan.org/?a=commitdiff_plain;h=153c7b332627e3ea0b06d9d8a6d52b8373e6c2db;p=pf4j.git Optional plugin dependencies (#270) --- diff --git a/pf4j/pom.xml b/pf4j/pom.xml index 241d5ec..26ab06f 100644 --- a/pf4j/pom.xml +++ b/pf4j/pom.xml @@ -43,6 +43,12 @@ java-semver 0.9.0 + + org.ow2.asm + asm + ${asm.version} + true + junit junit diff --git a/pf4j/src/main/java/org/pf4j/AbstractExtensionFinder.java b/pf4j/src/main/java/org/pf4j/AbstractExtensionFinder.java index 0159b2d..2a84b4f 100644 --- a/pf4j/src/main/java/org/pf4j/AbstractExtensionFinder.java +++ b/pf4j/src/main/java/org/pf4j/AbstractExtensionFinder.java @@ -15,12 +15,14 @@ */ package org.pf4j; +import org.pf4j.asm.ExtensionInfo; import org.pf4j.util.ClassUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.util.ArrayList; import java.util.Collections; +import java.util.HashMap; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -35,6 +37,8 @@ public abstract class AbstractExtensionFinder implements ExtensionFinder, Plugin protected PluginManager pluginManager; protected volatile Map> entries; // cache by pluginId + protected volatile Map extensionInfos; // cache extension infos by class name + protected Boolean checkForExtensionDependencies = null; public AbstractExtensionFinder(PluginManager pluginManager) { this.pluginManager = pluginManager; @@ -97,6 +101,41 @@ public abstract class AbstractExtensionFinder implements ExtensionFinder, Plugin for (String className : classNames) { try { + if (isCheckForExtensionDependencies()) { + // Load extension annotation without initializing the class itself. + // + // If optional dependencies are used, the class loader might not be able + // to load the extension class because of missing optional dependencies. + // + // Therefore we're extracting the extension annotation via asm, in order + // to extract the required plugins for an extension. Only if all required + // plugins are currently available and started, the corresponding + // extension is loaded through the class loader. + ExtensionInfo extensionInfo = getExtensionInfo(className, classLoader); + if (extensionInfo == null) { + log.error("No extension annotation was found for '{}'", className); + continue; + } + + // Make sure, that all plugins required by this extension are available. + List missingPluginIds = new ArrayList<>(); + for (String requiredPluginId : extensionInfo.getPlugins()) { + PluginWrapper requiredPlugin = pluginManager.getPlugin(requiredPluginId); + if (requiredPlugin == null || !PluginState.STARTED.equals(requiredPlugin.getPluginState())) { + missingPluginIds.add(requiredPluginId); + } + } + if (!missingPluginIds.isEmpty()) { + StringBuilder missing = new StringBuilder(); + for (String missingPluginId : missingPluginIds) { + if (missing.length() > 0) missing.append(", "); + missing.append(missingPluginId); + } + log.trace("Extension '{}' is ignored due to missing plugins: {}", className, missing); + continue; + } + } + log.debug("Loading class '{}' using class loader '{}'", className, classLoader); Class extensionClass = classLoader.loadClass(className); @@ -186,6 +225,60 @@ public abstract class AbstractExtensionFinder implements ExtensionFinder, Plugin // TODO optimize (do only for some transitions) // clear cache entries = null; + + // By default we're assuming, that no checks for extension dependencies are necessary. + // + // A plugin, that has an optional dependency to other plugins, might lead to unloadable + // Java classes (NoClassDefFoundError) at application runtime due to possibly missing + // dependencies. Therefore we're enabling the check for optional extensions, if the + // started plugin contains at least one optional plugin dependency. + if (checkForExtensionDependencies == null && PluginState.STARTED.equals(event.getPluginState())) { + for (PluginDependency dependency : event.getPlugin().getDescriptor().getDependencies()) { + if (dependency.isOptional()) { + log.debug("Enable check for extension dependencies via ASM."); + checkForExtensionDependencies = true; + break; + } + } + } + } + + /** + * Returns true, if the extension finder checks extensions for its required plugins. + * This feature has to be enabled, in order check the availability of + * {@link Extension#plugins()} configured by an extension. + *

+ * This feature is enabled by default, if at least one available plugin makes use of + * optional plugin dependencies. Those optional plugins might not be available at runtime. + * Therefore any extension is checked by default against available plugins before its + * instantiation. + *

+ * Notice: This feature requires the optional ASM library + * to be available on the applications classpath. + * + * @return true, if the extension finder checks extensions for its required plugins + */ + public final boolean isCheckForExtensionDependencies() { + return Boolean.TRUE.equals(checkForExtensionDependencies); + } + + /** + * Plugin developers may enable / disable checks for required plugins of an extension. + * This feature has to be enabled, in order check the availability of + * {@link Extension#plugins()} configured by an extension. + *

+ * This feature is enabled by default, if at least one available plugin makes use of + * optional plugin dependencies. Those optional plugins might not be available at runtime. + * Therefore any extension is checked by default against available plugins before its + * instantiation. + *

+ * Notice: This feature requires the optional ASM library + * to be available on the applications classpath. + * + * @param checkForExtensionDependencies true to enable checks for optional extensions, otherwise false + */ + public void setCheckForExtensionDependencies(boolean checkForExtensionDependencies) { + this.checkForExtensionDependencies = checkForExtensionDependencies; } protected void debugExtensions(Set extensions) { @@ -218,6 +311,34 @@ public abstract class AbstractExtensionFinder implements ExtensionFinder, Plugin return entries; } + /** + * Returns the parameters of an {@link Extension} annotation without loading + * the corresponding class into the class loader. + * + * @param className name of the class, that holds the requested {@link Extension} annotation + * @param classLoader class loader to access the class + * @return the contents of the {@link Extension} annotation or null, if the class does not + * have an {@link Extension} annotation + */ + private ExtensionInfo getExtensionInfo(String className, ClassLoader classLoader) { + if (extensionInfos == null) { + extensionInfos = new HashMap<>(); + } + + if (!extensionInfos.containsKey(className)) { + log.trace("Load annotation for '{}' using asm", className); + ExtensionInfo info = ExtensionInfo.load(className, classLoader); + if (info == null) { + log.warn("No extension annotation was found for '{}'", className); + extensionInfos.put(className, null); + } else { + extensionInfos.put(className, info); + } + } + + return extensionInfos.get(className); + } + private ExtensionWrapper createExtensionWrapper(Class extensionClass) { int ordinal = 0; if (extensionClass.isAnnotationPresent(Extension.class)) { diff --git a/pf4j/src/main/java/org/pf4j/DependencyResolver.java b/pf4j/src/main/java/org/pf4j/DependencyResolver.java index 1e86314..c528d63 100644 --- a/pf4j/src/main/java/org/pf4j/DependencyResolver.java +++ b/pf4j/src/main/java/org/pf4j/DependencyResolver.java @@ -143,9 +143,23 @@ public class DependencyResolver { dependenciesGraph.addVertex(pluginId); dependentsGraph.addVertex(pluginId); } else { + boolean edgeAdded = false; for (PluginDependency dependency : dependencies) { - dependenciesGraph.addEdge(pluginId, dependency.getPluginId()); - dependentsGraph.addEdge(dependency.getPluginId(), pluginId); + // Don't register optional plugins in the dependency graph + // to avoid automatic disabling of the plugin, + // if an optional dependency is missing. + if (!dependency.isOptional()) { + edgeAdded = true; + dependenciesGraph.addEdge(pluginId, dependency.getPluginId()); + dependentsGraph.addEdge(dependency.getPluginId(), pluginId); + } + } + + // Register the plugin without dependencies, + // if all of its dependencies are optional. + if (!edgeAdded) { + dependenciesGraph.addVertex(pluginId); + dependentsGraph.addVertex(pluginId); } } } diff --git a/pf4j/src/main/java/org/pf4j/Extension.java b/pf4j/src/main/java/org/pf4j/Extension.java index a5131f3..43b749e 100644 --- a/pf4j/src/main/java/org/pf4j/Extension.java +++ b/pf4j/src/main/java/org/pf4j/Extension.java @@ -47,4 +47,16 @@ public @interface Extension { */ Class[] points() default {}; + /** + * An array of plugin IDs, that have to be available in order to load this extension. + * The {@link AbstractExtensionFinder} won't load this extension, if these plugins are not + * available / started at runtime. + *

+ * Notice: This feature requires the optional ASM library + * to be available on the applications classpath and has to be explicitly enabled via + * {@link AbstractExtensionFinder#setCheckForExtensionDependencies(boolean)}. + * + * @return plugin IDs, that have to be available in order to load this extension + */ + String[] plugins() default {}; } diff --git a/pf4j/src/main/java/org/pf4j/PluginClassLoader.java b/pf4j/src/main/java/org/pf4j/PluginClassLoader.java index 3c27220..022b61c 100644 --- a/pf4j/src/main/java/org/pf4j/PluginClassLoader.java +++ b/pf4j/src/main/java/org/pf4j/PluginClassLoader.java @@ -194,6 +194,12 @@ public class PluginClassLoader extends URLClassLoader { List dependencies = pluginDescriptor.getDependencies(); for (PluginDependency dependency : dependencies) { ClassLoader classLoader = pluginManager.getPluginClassLoader(dependency.getPluginId()); + + // If the dependency is marked as optional, its class loader might not be available. + if (classLoader == null && dependency.isOptional()) { + continue; + } + try { return classLoader.loadClass(className); } catch (ClassNotFoundException e) { diff --git a/pf4j/src/main/java/org/pf4j/PluginDependency.java b/pf4j/src/main/java/org/pf4j/PluginDependency.java index 59d11bc..47e77e6 100644 --- a/pf4j/src/main/java/org/pf4j/PluginDependency.java +++ b/pf4j/src/main/java/org/pf4j/PluginDependency.java @@ -22,6 +22,7 @@ public class PluginDependency { private String pluginId; private String pluginVersionSupport = "*"; + private boolean optional; public PluginDependency(String dependency) { int index = dependency.indexOf('@'); @@ -33,6 +34,13 @@ public class PluginDependency { this.pluginVersionSupport = dependency.substring(index + 1); } } + + // A dependency is considered as optional, + // if the plugin id ends with a question mark. + this.optional = this.pluginId.endsWith("?"); + if (this.optional) { + this.pluginId = this.pluginId.substring(0, this.pluginId.length() - 1); + } } public String getPluginId() { @@ -43,9 +51,15 @@ public class PluginDependency { return pluginVersionSupport; } + public boolean isOptional() { + return optional; + } + @Override public String toString() { - return "PluginDependency [pluginId=" + pluginId + ", pluginVersionSupport=" + pluginVersionSupport + "]"; + return "PluginDependency [pluginId=" + pluginId + ", pluginVersionSupport=" + + pluginVersionSupport + ", optional=" + + optional + "]"; } } diff --git a/pf4j/src/main/java/org/pf4j/asm/ExtensionInfo.java b/pf4j/src/main/java/org/pf4j/asm/ExtensionInfo.java new file mode 100644 index 0000000..e81763d --- /dev/null +++ b/pf4j/src/main/java/org/pf4j/asm/ExtensionInfo.java @@ -0,0 +1,102 @@ +/* + * Copyright (C) 2012-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.pf4j.asm; + +import org.objectweb.asm.ClassReader; +import org.pf4j.Extension; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * This class holds the parameters of an {@link org.pf4j.Extension} + * annotation defined for a certain class. + * + * @author Andreas Rudolph + * @author Decebal Suiu + */ +public final class ExtensionInfo { + private static final Logger log = LoggerFactory.getLogger(ExtensionInfo.class); + private final String className; + int ordinal = 0; + List plugins = new ArrayList<>(); + List points = new ArrayList<>(); + + private ExtensionInfo(String className) { + super(); + this.className = className; + } + + /** + * Get the name of the class, for which extension info was created. + * + * @return absolute class name + */ + public String getClassName() { + return className; + } + + /** + * Get the {@link Extension#ordinal()} value, that was assigned to the extension. + * + * @return ordinal value + */ + public int getOrdinal() { + return ordinal; + } + + /** + * Get the {@link Extension#plugins()} value, that was assigned to the extension. + * + * @return ordinal value + */ + public List getPlugins() { + return Collections.unmodifiableList(plugins); + } + + /** + * Get the {@link Extension#points()} value, that was assigned to the extension. + * + * @return ordinal value + */ + public List getPoints() { + return Collections.unmodifiableList(points); + } + + /** + * Load an {@link ExtensionInfo} for a certain class. + * + * @param className absolute class name + * @param classLoader class loader to access the class + * @return the {@link ExtensionInfo}, if the class was annotated with an {@link Extension}, otherwise null + */ + public static ExtensionInfo load(String className, ClassLoader classLoader) { + try (InputStream input = classLoader.getResourceAsStream(className.replace('.', '/') + ".class")) { + ExtensionInfo info = new ExtensionInfo(className); + new ClassReader(input).accept(new ExtensionVisitor(info), ClassReader.SKIP_DEBUG); + return info; + } catch (IOException e) { + log.error(e.getMessage(), e); + return null; + } + } +} diff --git a/pf4j/src/main/java/org/pf4j/asm/ExtensionVisitor.java b/pf4j/src/main/java/org/pf4j/asm/ExtensionVisitor.java new file mode 100644 index 0000000..fa92c67 --- /dev/null +++ b/pf4j/src/main/java/org/pf4j/asm/ExtensionVisitor.java @@ -0,0 +1,96 @@ +/* + * Copyright (C) 2012-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.pf4j.asm; + +import org.objectweb.asm.AnnotationVisitor; +import org.objectweb.asm.ClassVisitor; +import org.objectweb.asm.Opcodes; +import org.objectweb.asm.Type; +import org.pf4j.Extension; + +import java.util.Arrays; + +/** + * This visitor extracts an {@link ExtensionInfo} from any class, + * that holds an {@link Extension} annotation. + *

+ * The annotation parameters are extracted from byte code by using the + * ASM library. This makes it possible to + * access the {@link Extension} parameters without loading the class into + * the class loader. This avoids possible {@link NoClassDefFoundError}'s + * for extensions, that can't be loaded due to missing dependencies. + * + * @author Andreas Rudolph + * @author Decebal Suiu + */ +class ExtensionVisitor extends ClassVisitor { + //private static final Logger log = LoggerFactory.getLogger(ExtensionVisitor.class); + private static final int ASM_VERSION = Opcodes.ASM7; + private final ExtensionInfo extensionInfo; + + ExtensionVisitor(ExtensionInfo extensionInfo) { + super(ASM_VERSION); + this.extensionInfo = extensionInfo; + } + + @Override + public AnnotationVisitor visitAnnotation(String descriptor, boolean visible) { + //if (!descriptor.equals("Lorg/pf4j/Extension;")) { + if (!Type.getType(descriptor).getClassName().equals(Extension.class.getName())) { + return super.visitAnnotation(descriptor, visible); + } + + return new AnnotationVisitor(ASM_VERSION) { + + @Override + public AnnotationVisitor visitArray(final String name) { + if ("ordinal".equals(name) || "plugins".equals(name) || "points".equals(name)) { + return new AnnotationVisitor(ASM_VERSION, super.visitArray(name)) { + + @Override + public void visit(String key, Object value) { + //log.debug("Load annotation attribute {} = {} ({})", name, value, value.getClass().getName()); + + if ("ordinal".equals(name)) { + extensionInfo.ordinal = Integer.parseInt(value.toString()); + } else if ("plugins".equals(name)) { + if (value instanceof String) { + //log.debug("found plugin " + value); + extensionInfo.plugins.add((String) value); + } else if (value instanceof String[]) { + //log.debug("found plugins " + Arrays.toString((String[]) value)); + extensionInfo.plugins.addAll(Arrays.asList((String[]) value)); + } else { + //log.debug("found plugin " + value.toString()); + extensionInfo.plugins.add(value.toString()); + } + } else if ("points".equals(name)) { + String pointClassName = ((Type) value).getClassName(); + //log.debug("found point " + pointClassName); + extensionInfo.points.add(pointClassName); + } + + super.visit(key, value); + } + }; + } + + return super.visitArray(name); + } + }; + } +} diff --git a/pf4j/src/test/java/org/pf4j/PluginDependencyTest.java b/pf4j/src/test/java/org/pf4j/PluginDependencyTest.java index 3ca075b..dbd5fd9 100644 --- a/pf4j/src/test/java/org/pf4j/PluginDependencyTest.java +++ b/pf4j/src/test/java/org/pf4j/PluginDependencyTest.java @@ -32,15 +32,34 @@ public class PluginDependencyTest { PluginDependency instance = new PluginDependency("test"); assertEquals("test", instance.getPluginId()); assertEquals("*", instance.getPluginVersionSupport()); + assertEquals(false, instance.isOptional()); instance = new PluginDependency("test@"); assertEquals("test", instance.getPluginId()); assertEquals("*", instance.getPluginVersionSupport()); + assertEquals(false, instance.isOptional()); + + instance = new PluginDependency("test?"); + assertEquals("test", instance.getPluginId()); + assertEquals("*", instance.getPluginVersionSupport()); + assertEquals(true, instance.isOptional()); + + instance = new PluginDependency("test?@"); + assertEquals("test", instance.getPluginId()); + assertEquals("*", instance.getPluginVersionSupport()); + assertEquals(true, instance.isOptional()); instance = new PluginDependency("test@1.0"); assertEquals("test", instance.getPluginId()); assertEquals("1.0", instance.getPluginVersionSupport()); - assertEquals("PluginDependency [pluginId=test, pluginVersionSupport=1.0]", instance.toString()); + assertEquals(false, instance.isOptional()); + assertEquals("PluginDependency [pluginId=test, pluginVersionSupport=1.0, optional=false]", instance.toString()); + + instance = new PluginDependency("test?@1.0"); + assertEquals("test", instance.getPluginId()); + assertEquals("1.0", instance.getPluginVersionSupport()); + assertEquals(true, instance.isOptional()); + assertEquals("PluginDependency [pluginId=test, pluginVersionSupport=1.0, optional=true]", instance.toString()); } } diff --git a/pom.xml b/pom.xml index b466db5..54294f0 100644 --- a/pom.xml +++ b/pom.xml @@ -45,6 +45,7 @@ UTF-8 1.7 1.7.25 + 7.0 4.12 2.0.28-beta