]> source.dussan.org Git - pf4j.git/commitdiff
Optional plugin dependencies (#270)
authorAndreas Rudolph <andy@openindex.de>
Fri, 11 Jan 2019 14:04:17 +0000 (15:04 +0100)
committerDecebal Suiu <decebal.suiu@gmail.com>
Fri, 11 Jan 2019 14:04:17 +0000 (16:04 +0200)
pf4j/pom.xml
pf4j/src/main/java/org/pf4j/AbstractExtensionFinder.java
pf4j/src/main/java/org/pf4j/DependencyResolver.java
pf4j/src/main/java/org/pf4j/Extension.java
pf4j/src/main/java/org/pf4j/PluginClassLoader.java
pf4j/src/main/java/org/pf4j/PluginDependency.java
pf4j/src/main/java/org/pf4j/asm/ExtensionInfo.java [new file with mode: 0644]
pf4j/src/main/java/org/pf4j/asm/ExtensionVisitor.java [new file with mode: 0644]
pf4j/src/test/java/org/pf4j/PluginDependencyTest.java
pom.xml

index 241d5eca34c1c4f85d9c2dc5f2890eb8af111371..26ab06f32a55377d432f75244007011b5091a2f8 100644 (file)
             <artifactId>java-semver</artifactId>
             <version>0.9.0</version>
         </dependency>
+        <dependency>
+            <groupId>org.ow2.asm</groupId>
+            <artifactId>asm</artifactId>
+            <version>${asm.version}</version>
+            <optional>true</optional>
+        </dependency>
         <dependency>
             <groupId>junit</groupId>
             <artifactId>junit</artifactId>
index 0159b2d634bb91f621d7d532c23aaa2530385586..2a84b4f4fc1756f6e53259c6ea25546eeb489e9d 100644 (file)
  */
 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<String, Set<String>> entries; // cache by pluginId
+    protected volatile Map<String, ExtensionInfo> 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<String> 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.
+     * <p>
+     * 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.
+     * <p>
+     * Notice: This feature requires the optional <a href="https://asm.ow2.io/">ASM library</a>
+     * 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.
+     * <p>
+     * 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.
+     * <p>
+     * Notice: This feature requires the optional <a href="https://asm.ow2.io/">ASM library</a>
+     * 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<String> 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)) {
index 1e863142b7902399a7899bb1a740a00a8c09b165..c528d63e3d2df466e524db15ea405fe2f25b2344 100644 (file)
@@ -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);
             }
         }
     }
index a5131f349e05720a18ef08742e78720bd96ef13d..43b749ef2af6a4c0bd8800a53024564caf6f5d1f 100644 (file)
@@ -47,4 +47,16 @@ public @interface Extension {
      */
     Class<? extends ExtensionPoint>[] 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.
+     * <p>
+     * Notice: This feature requires the optional <a href="https://asm.ow2.io/">ASM library</a>
+     * 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 {};
 }
index 3c27220b7e29f0ed906464b67ef86f2fc7b22bab..022b61cf06e34536b9c8a6d3a1296f1fa0c9cb07 100644 (file)
@@ -194,6 +194,12 @@ public class PluginClassLoader extends URLClassLoader {
         List<PluginDependency> 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) {
index 59d11bc7b027c5627dda4d54ab1b70b304adf26b..47e77e6fc07b476113144804cdfad3a7917d42f4 100644 (file)
@@ -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 (file)
index 0000000..e81763d
--- /dev/null
@@ -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<String> plugins = new ArrayList<>();
+    List<String> 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<String> getPlugins() {
+        return Collections.unmodifiableList(plugins);
+    }
+
+    /**
+     * Get the {@link Extension#points()} value, that was assigned to the extension.
+     *
+     * @return ordinal value
+     */
+    public List<String> 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 (file)
index 0000000..fa92c67
--- /dev/null
@@ -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.
+ * <p>
+ * The annotation parameters are extracted from byte code by using the
+ * <a href="https://asm.ow2.io/">ASM library</a>. 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);
+            }
+        };
+    }
+}
index 3ca075b5feff8043fcdd3226ed0550627c063035..dbd5fd9a9b997c6ffeea20c3757862262b4f1445 100644 (file)
@@ -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 b466db59f5fbf6a310da3778c3a46c911fdb387f..54294f0e8806270dba7c56048f9e5bab95e9ab3c 100644 (file)
--- a/pom.xml
+++ b/pom.xml
@@ -45,6 +45,7 @@
         <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
         <java.version>1.7</java.version>
         <slf4j.version>1.7.25</slf4j.version>
+        <asm.version>7.0</asm.version>
 
         <junit.version>4.12</junit.version>
         <mockito.version>2.0.28-beta</mockito.version>