diff --git a/build.gradle b/build.gradle index d37928842..cbd105c5d 100644 --- a/build.gradle +++ b/build.gradle @@ -47,6 +47,7 @@ ext { jsonschemaGenerator = "4.24.3" jsonschemaValidator = "1.0.69" base62 = "0.1.3" + pf4j = "3.6.0" } dependencies { @@ -68,7 +69,7 @@ dependencies { implementation "com.networknt:json-schema-validator:$jsonschemaValidator" implementation "org.apache.commons:commons-lang3:$commonsLang3" implementation "io.seruco.encoding:base62:$base62" - + implementation "org.pf4j:pf4j:$pf4j" compileOnly 'org.projectlombok:lombok' annotationProcessor "org.springframework.boot:spring-boot-configuration-processor" diff --git a/src/main/java/run/halo/app/plugin/BasePlugin.java b/src/main/java/run/halo/app/plugin/BasePlugin.java new file mode 100644 index 000000000..fff8e758b --- /dev/null +++ b/src/main/java/run/halo/app/plugin/BasePlugin.java @@ -0,0 +1,41 @@ +package run.halo.app.plugin; + +import lombok.extern.slf4j.Slf4j; +import org.pf4j.Plugin; +import org.pf4j.PluginWrapper; + +/** + * This class will be extended by all plugins and serve as the common class between a plugin and + * the application. + * + * @author guqing + * @since 2.0.0 + */ +@Slf4j +public abstract class BasePlugin extends Plugin { + + private PluginApplicationContext applicationContext; + + public BasePlugin(PluginWrapper wrapper) { + super(wrapper); + } + + /** + *

Lazy initialization plugin application context, + * avoid being unable to get context when system start scan plugin.

+ *

The plugin application context is not created until the plug-in is started.

+ * + * @return Plugin application context. + */ + public final synchronized PluginApplicationContext getApplicationContext() { + if (applicationContext == null) { + applicationContext = + getPluginManager().getPluginApplicationContext(this.wrapper.getPluginId()); + } + return applicationContext; + } + + public HaloPluginManager getPluginManager() { + return (HaloPluginManager) getWrapper().getPluginManager(); + } +} diff --git a/src/main/java/run/halo/app/plugin/ExtensionContextRegistry.java b/src/main/java/run/halo/app/plugin/ExtensionContextRegistry.java new file mode 100644 index 000000000..18a17d5d0 --- /dev/null +++ b/src/main/java/run/halo/app/plugin/ExtensionContextRegistry.java @@ -0,0 +1,61 @@ +package run.halo.app.plugin; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + *

Plugin application context registrar.

+ *

It contains a map, the key is the plugin id and the value is application context of plugin + * .

+ *

when the plugin is enabled, an application context will be registered in the map by plugin id. + * it will be deleted according to its id when the plugin is disabled.

+ * + * @author guqing + * @since 2021-11-15 + */ +public class ExtensionContextRegistry { + private static final ExtensionContextRegistry INSTANCE = new ExtensionContextRegistry(); + + private final Map registry = new ConcurrentHashMap<>(); + + public static ExtensionContextRegistry getInstance() { + return INSTANCE; + } + + private ExtensionContextRegistry() { + } + + public void register(String pluginId, PluginApplicationContext context) { + registry.put(pluginId, context); + } + + public PluginApplicationContext remove(String pluginId) { + return registry.remove(pluginId); + } + + /** + * Gets plugin application context by plugin id from registry map. + * + * @param pluginId plugin id + * @return plugin application context + * @throws IllegalArgumentException if plugin id not found in registry + */ + public PluginApplicationContext getByPluginId(String pluginId) { + PluginApplicationContext context = registry.get(pluginId); + if (context == null) { + throw new IllegalArgumentException( + String.format("The plugin [%s] can not be found.", pluginId)); + } + return context; + } + + public boolean containsContext(String pluginId) { + return registry.containsKey(pluginId); + } + + public List getPluginApplicationContexts() { + return new ArrayList<>(registry.values()); + } +} diff --git a/src/main/java/run/halo/app/plugin/HaloPluginManager.java b/src/main/java/run/halo/app/plugin/HaloPluginManager.java new file mode 100644 index 000000000..ac634e655 --- /dev/null +++ b/src/main/java/run/halo/app/plugin/HaloPluginManager.java @@ -0,0 +1,366 @@ +package run.halo.app.plugin; + +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import lombok.extern.slf4j.Slf4j; +import org.pf4j.DefaultPluginManager; +import org.pf4j.ExtensionFactory; +import org.pf4j.ExtensionFinder; +import org.pf4j.PluginDependency; +import org.pf4j.PluginDescriptor; +import org.pf4j.PluginDescriptorFinder; +import org.pf4j.PluginRuntimeException; +import org.pf4j.PluginState; +import org.pf4j.PluginStateEvent; +import org.pf4j.PluginWrapper; +import org.springframework.beans.BeansException; +import org.springframework.beans.factory.InitializingBean; +import org.springframework.context.ApplicationContext; +import org.springframework.context.ApplicationContextAware; +import org.springframework.lang.NonNull; +import run.halo.app.plugin.event.HaloPluginStartedEvent; +import run.halo.app.plugin.event.HaloPluginStateChangedEvent; +import run.halo.app.plugin.event.HaloPluginStoppedEvent; + +/** + * PluginManager to hold the main ApplicationContext. + * It provides methods for managing the plugin lifecycle. + * + * @author guqing + * @since 2.0.0 + */ +@Slf4j +public class HaloPluginManager extends DefaultPluginManager + implements ApplicationContextAware, InitializingBean { + + private final Map startingErrors = new HashMap<>(); + + private ApplicationContext rootApplicationContext; + + private PluginApplicationInitializer pluginApplicationInitializer; + + private PluginRequestMappingManager requestMappingManager; + + public HaloPluginManager() { + super(); + } + + public HaloPluginManager(Path pluginsRoot) { + super(pluginsRoot); + } + + @Override + protected ExtensionFactory createExtensionFactory() { + return new SpringExtensionFactory(this); + } + + @Override + public PluginDescriptorFinder getPluginDescriptorFinder() { + return super.getPluginDescriptorFinder(); + } + + @Override + protected ExtensionFinder createExtensionFinder() { + return new SpringComponentsFinder(this); + } + + public ApplicationContext getRootApplicationContext() { + return this.rootApplicationContext; + } + + @Override + public void setApplicationContext(@NonNull ApplicationContext rootApplicationContext) + throws BeansException { + this.rootApplicationContext = rootApplicationContext; + } + + public PluginApplicationContext getPluginApplicationContext(String pluginId) { + return pluginApplicationInitializer.getPluginApplicationContext(pluginId); + } + + @Override + public void afterPropertiesSet() { + // This method load, start plugins and inject extensions in Spring + loadPlugins(); + this.pluginApplicationInitializer = new PluginApplicationInitializer(this); + + this.requestMappingManager = + rootApplicationContext.getBean(PluginRequestMappingManager.class); + } + + public PluginStartingError getPluginStartingError(String pluginId) { + return startingErrors.get(pluginId); + } + + @Override + public List getExtensions(Class type) { + return this.getExtensions(extensionFinder.find(type)); + } + + @Override + public List getExtensions(Class type, String pluginId) { + return this.getExtensions(extensionFinder.find(type, pluginId)); + } + + @Override + protected void firePluginStateEvent(PluginStateEvent event) { + rootApplicationContext.publishEvent( + new HaloPluginStateChangedEvent(this, event.getPlugin(), event.getOldState())); + super.firePluginStateEvent(event); + } + + @Override + protected PluginState stopPlugin(String pluginId, boolean stopDependents) { + checkPluginId(pluginId); + + PluginWrapper pluginWrapper = getPlugin(pluginId); + PluginDescriptor pluginDescriptor = pluginWrapper.getDescriptor(); + PluginState pluginState = pluginWrapper.getPluginState(); + if (PluginState.STOPPED == pluginState) { + log.debug("Already stopped plugin '{}'", getPluginLabel(pluginDescriptor)); + return PluginState.STOPPED; + } + + // test for disabled plugin + if (PluginState.DISABLED == pluginState) { + // do nothing + return pluginState; + } + + if (stopDependents) { + List dependents = dependencyResolver.getDependents(pluginId); + while (!dependents.isEmpty()) { + String dependent = dependents.remove(0); + stopPlugin(dependent, false); + dependents.addAll(0, dependencyResolver.getDependents(dependent)); + } + } + try { + log.info("Stop plugin '{}'", getPluginLabel(pluginDescriptor)); + pluginWrapper.getPlugin().stop(); + pluginWrapper.setPluginState(PluginState.STOPPED); + // release plugin resources + releaseAdditionalResources(pluginId); + + startedPlugins.remove(pluginWrapper); + + rootApplicationContext.publishEvent(new HaloPluginStoppedEvent(this, pluginWrapper)); + firePluginStateEvent(new PluginStateEvent(this, pluginWrapper, pluginState)); + } catch (Exception e) { + log.error(e.getMessage(), e); + startingErrors.put(pluginWrapper.getPluginId(), PluginStartingError.of( + pluginWrapper.getPluginId(), e.getMessage(), e.toString())); + } + return pluginWrapper.getPluginState(); + } + + @Override + public PluginState stopPlugin(String pluginId) { + return stopPlugin(pluginId, true); + } + + @Override + public void startPlugins() { + startingErrors.clear(); + long ts = System.currentTimeMillis(); + + for (PluginWrapper pluginWrapper : resolvedPlugins) { + PluginState pluginState = pluginWrapper.getPluginState(); + if ((PluginState.DISABLED != pluginState) && (PluginState.STARTED != pluginState)) { + try { + log.info("Start plugin '{}'", getPluginLabel(pluginWrapper.getDescriptor())); + // inject bean + pluginApplicationInitializer.onStartUp(pluginWrapper.getPluginId()); + + pluginWrapper.getPlugin().start(); + + requestMappingManager.registerControllers(pluginWrapper); + + pluginWrapper.setPluginState(PluginState.STARTED); + pluginWrapper.setFailedException(null); + startedPlugins.add(pluginWrapper); + + rootApplicationContext.publishEvent( + new HaloPluginStartedEvent(this, pluginWrapper)); + } catch (Exception | LinkageError e) { + pluginWrapper.setPluginState(PluginState.FAILED); + pluginWrapper.setFailedException(e); + startingErrors.put(pluginWrapper.getPluginId(), PluginStartingError.of( + pluginWrapper.getPluginId(), e.getMessage(), e.toString())); + releaseAdditionalResources(pluginWrapper.getPluginId()); + log.error("Unable to start plugin '{}'", + getPluginLabel(pluginWrapper.getDescriptor()), e); + } finally { + firePluginStateEvent(new PluginStateEvent(this, pluginWrapper, pluginState)); + } + } + } + + log.info("[Halo] {} plugins are started in {}ms. {} failed", + getPlugins(PluginState.STARTED).size(), + System.currentTimeMillis() - ts, startingErrors.size()); + } + + @Override + public PluginState startPlugin(String pluginId) { + return doStartPlugin(pluginId); + } + + @Override + public void stopPlugins() { + doStopPlugins(); + } + + private PluginState doStartPlugin(String pluginId) { + checkPluginId(pluginId); + + PluginWrapper pluginWrapper = getPlugin(pluginId); + PluginDescriptor pluginDescriptor = pluginWrapper.getDescriptor(); + PluginState pluginState = pluginWrapper.getPluginState(); + if (PluginState.STARTED == pluginState) { + log.debug("Already started plugin '{}'", getPluginLabel(pluginDescriptor)); + return PluginState.STARTED; + } + + if (!resolvedPlugins.contains(pluginWrapper)) { + log.warn("Cannot start an unresolved plugin '{}'", getPluginLabel(pluginDescriptor)); + return pluginState; + } + + if (PluginState.DISABLED == pluginState) { + // automatically enable plugin on manual plugin start + if (!enablePlugin(pluginId)) { + return pluginState; + } + } + + for (PluginDependency dependency : pluginDescriptor.getDependencies()) { + // start dependency only if it marked as required (non-optional) or if it's optional + // and loaded + if (!dependency.isOptional() || plugins.containsKey(dependency.getPluginId())) { + startPlugin(dependency.getPluginId()); + } + } + log.info("Start plugin '{}'", getPluginLabel(pluginDescriptor)); + + try { + // load and inject bean + pluginApplicationInitializer.onStartUp(pluginId); + + // create plugin instance and start it + pluginWrapper.getPlugin().start(); + + requestMappingManager.registerControllers(pluginWrapper); + + pluginWrapper.setPluginState(PluginState.STARTED); + startedPlugins.add(pluginWrapper); + + rootApplicationContext.publishEvent(new HaloPluginStartedEvent(this, pluginWrapper)); + } catch (Exception e) { + log.error("Unable to start plugin '{}'", + getPluginLabel(pluginWrapper.getDescriptor()), e); + pluginWrapper.setPluginState(PluginState.FAILED); + startingErrors.put(pluginWrapper.getPluginId(), PluginStartingError.of( + pluginWrapper.getPluginId(), e.getMessage(), e.toString())); + releaseAdditionalResources(pluginId); + } finally { + firePluginStateEvent(new PluginStateEvent(this, pluginWrapper, pluginState)); + } + return pluginWrapper.getPluginState(); + } + + private void doStopPlugins() { + startingErrors.clear(); + // stop started plugins in reverse order + Collections.reverse(startedPlugins); + Iterator itr = startedPlugins.iterator(); + while (itr.hasNext()) { + PluginWrapper pluginWrapper = itr.next(); + PluginState pluginState = pluginWrapper.getPluginState(); + if (PluginState.STARTED == pluginState) { + try { + log.info("Stop plugin '{}'", getPluginLabel(pluginWrapper.getDescriptor())); + pluginWrapper.getPlugin().stop(); + pluginWrapper.setPluginState(PluginState.STOPPED); + itr.remove(); + releaseAdditionalResources(pluginWrapper.getPluginId()); + + rootApplicationContext.publishEvent( + new HaloPluginStoppedEvent(this, pluginWrapper)); + firePluginStateEvent(new PluginStateEvent(this, pluginWrapper, pluginState)); + } catch (PluginRuntimeException e) { + log.error(e.getMessage(), e); + startingErrors.put(pluginWrapper.getPluginId(), PluginStartingError.of( + pluginWrapper.getPluginId(), e.getMessage(), e.toString())); + } + } + } + } + + /** + * Unload plugin and restart. + * + * @param restartStartedOnly If true, only reload started plugin + */ + public void reloadPlugins(boolean restartStartedOnly) { + doStopPlugins(); + List startedPluginIds = new ArrayList<>(); + getPlugins().forEach(plugin -> { + if (plugin.getPluginState() == PluginState.STARTED) { + startedPluginIds.add(plugin.getPluginId()); + } + unloadPlugin(plugin.getPluginId()); + }); + loadPlugins(); + if (restartStartedOnly) { + startedPluginIds.forEach(pluginId -> { + // restart started plugin + if (getPlugin(pluginId) != null) { + doStartPlugin(pluginId); + } + }); + } else { + startPlugins(); + } + } + + /** + * Reload plugin by id,it will be clean up memory resources of plugin and reload plugin from + * disk. + * + * @param pluginId plugin id + * @return plugin startup status + */ + public PluginState reloadPlugin(String pluginId) { + PluginWrapper plugin = getPlugin(pluginId); + stopPlugin(pluginId, false); + unloadPlugin(pluginId, false); + try { + loadPlugin(plugin.getPluginPath()); + } catch (Exception ex) { + return null; + } + + return doStartPlugin(pluginId); + } + + /** + * Release plugin holding release on stop. + */ + public void releaseAdditionalResources(String pluginId) { + // release request mapping + requestMappingManager.removeControllerMapping(pluginId); + try { + pluginApplicationInitializer.contextDestroyed(pluginId); + } catch (Exception e) { + log.trace("Plugin application context close failed. ", e); + } + } + + // end-region +} diff --git a/src/main/java/run/halo/app/plugin/PluginApplicationContext.java b/src/main/java/run/halo/app/plugin/PluginApplicationContext.java new file mode 100644 index 000000000..63a2bf04e --- /dev/null +++ b/src/main/java/run/halo/app/plugin/PluginApplicationContext.java @@ -0,0 +1,75 @@ +package run.halo.app.plugin; + +import java.lang.reflect.Field; +import java.util.Set; +import org.springframework.beans.factory.config.ConfigurableListableBeanFactory; +import org.springframework.context.ApplicationEvent; +import org.springframework.context.PayloadApplicationEvent; +import org.springframework.context.event.ApplicationEventMulticaster; +import org.springframework.context.support.AbstractApplicationContext; +import org.springframework.context.support.GenericApplicationContext; +import org.springframework.core.ResolvableType; +import org.springframework.lang.NonNull; +import org.springframework.lang.Nullable; +import org.springframework.util.Assert; + +/** + * The generic IOC container for plugins. + * The plugin-classes loaded through the same plugin-classloader will be put into the same + * {@link PluginApplicationContext} for bean creation. + * + * @author guqing + * @since 2.0.0 + */ +public class PluginApplicationContext extends GenericApplicationContext { + + /** + *

覆盖父类方法中判断context parent不为空时使用parent context广播事件的逻辑。 + * 如果主应用桥接事件到插件中且设置了parent会导致发布事件时死循环.

+ * + * @param event the event to publish (may be an {@link ApplicationEvent} or a payload object + * to be turned into a {@link PayloadApplicationEvent}) + * @param eventType the resolved event type, if known + */ + @Override + protected void publishEvent(@NonNull Object event, @Nullable ResolvableType eventType) { + Assert.notNull(event, "Event must not be null"); + + // Decorate event as an ApplicationEvent if necessary + ApplicationEvent applicationEvent; + if (event instanceof ApplicationEvent) { + applicationEvent = (ApplicationEvent) event; + } else { + applicationEvent = new PayloadApplicationEvent<>(this, event); + if (eventType == null) { + eventType = ((PayloadApplicationEvent) applicationEvent).getResolvableType(); + } + } + + // Multicast right now if possible - or lazily once the multicaster is initialized + Set earlyApplicationEvents = getEarlyApplicationEvents(); + if (earlyApplicationEvents != null) { + earlyApplicationEvents.add(applicationEvent); + } else { + getApplicationEventMulticaster().multicastEvent(applicationEvent, eventType); + } + } + + private ApplicationEventMulticaster getApplicationEventMulticaster() { + ConfigurableListableBeanFactory beanFactory = getBeanFactory(); + return beanFactory.getBean(APPLICATION_EVENT_MULTICASTER_BEAN_NAME, + ApplicationEventMulticaster.class); + } + + @SuppressWarnings("unchecked") + protected Set getEarlyApplicationEvents() { + try { + Field earlyApplicationEventsField = + AbstractApplicationContext.class.getDeclaredField("earlyApplicationEvents"); + return (Set) earlyApplicationEventsField.get(this); + } catch (NoSuchFieldException | IllegalAccessException e) { + // ignore this exception + return null; + } + } +} diff --git a/src/main/java/run/halo/app/plugin/PluginApplicationEventBridgeDispatcher.java b/src/main/java/run/halo/app/plugin/PluginApplicationEventBridgeDispatcher.java new file mode 100644 index 000000000..b72582d29 --- /dev/null +++ b/src/main/java/run/halo/app/plugin/PluginApplicationEventBridgeDispatcher.java @@ -0,0 +1,51 @@ +package run.halo.app.plugin; + +import java.lang.reflect.AnnotatedElement; +import lombok.extern.slf4j.Slf4j; +import org.pf4j.PluginWrapper; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.context.ApplicationEvent; +import org.springframework.context.ApplicationListener; +import org.springframework.core.annotation.AnnotationUtils; +import org.springframework.stereotype.Component; + +/** + *

The main application bridges the events marked with {@code SharedEvent} annotation to the + * enabled plug-in so that it can be listened by the plugin.

+ * + * @author guqing + * @see SharedEvent + * @see PluginApplicationContext + * @since 2.0.0 + */ +@Slf4j +@Component +@ConditionalOnClass(HaloPluginManager.class) +public class PluginApplicationEventBridgeDispatcher + implements ApplicationListener { + + private final HaloPluginManager haloPluginManager; + + public PluginApplicationEventBridgeDispatcher(HaloPluginManager haloPluginManager) { + this.haloPluginManager = haloPluginManager; + } + + @Override + public void onApplicationEvent(ApplicationEvent event) { + if (!isSharedEventAnnotationPresent(event.getClass())) { + return; + } + + for (PluginWrapper startedPlugin : haloPluginManager.getStartedPlugins()) { + PluginApplicationContext pluginApplicationContext = + haloPluginManager.getPluginApplicationContext(startedPlugin.getPluginId()); + log.debug("Bridging broadcast event [{}] to plugin [{}]", event, + startedPlugin.getPluginId()); + pluginApplicationContext.publishEvent(event); + } + } + + private boolean isSharedEventAnnotationPresent(AnnotatedElement annotatedElement) { + return AnnotationUtils.findAnnotation(annotatedElement, SharedEvent.class) != null; + } +} diff --git a/src/main/java/run/halo/app/plugin/PluginApplicationInitializer.java b/src/main/java/run/halo/app/plugin/PluginApplicationInitializer.java new file mode 100644 index 000000000..1ebd6a511 --- /dev/null +++ b/src/main/java/run/halo/app/plugin/PluginApplicationInitializer.java @@ -0,0 +1,142 @@ +package run.halo.app.plugin; + +import java.util.HashSet; +import java.util.Set; +import lombok.extern.slf4j.Slf4j; +import org.pf4j.PluginWrapper; +import org.springframework.beans.factory.support.DefaultListableBeanFactory; +import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.AnnotationConfigUtils; +import org.springframework.core.io.DefaultResourceLoader; +import org.springframework.lang.NonNull; +import org.springframework.util.Assert; +import org.springframework.util.StopWatch; + +/** + * Plugin application initializer will create plugin application context by plugin id and + * register beans to plugin application context. + * + * @author guqing + * @since 2021-11-01 + */ +@Slf4j +public class PluginApplicationInitializer { + protected final HaloPluginManager haloPluginManager; + + private final ExtensionContextRegistry contextRegistry = ExtensionContextRegistry.getInstance(); + + public PluginApplicationInitializer(HaloPluginManager springPluginManager) { + this.haloPluginManager = springPluginManager; + } + + public ApplicationContext getRootApplicationContext() { + return this.haloPluginManager.getRootApplicationContext(); + } + + private PluginApplicationContext createPluginApplicationContext(String pluginId) { + PluginWrapper plugin = haloPluginManager.getPlugin(pluginId); + ClassLoader pluginClassLoader = plugin.getPluginClassLoader(); + + StopWatch stopWatch = new StopWatch("initialize-plugin-context"); + stopWatch.start("Create PluginApplicationContext"); + PluginApplicationContext pluginApplicationContext = new PluginApplicationContext(); + pluginApplicationContext.setParent(getRootApplicationContext()); + pluginApplicationContext.setClassLoader(pluginClassLoader); + stopWatch.stop(); + + stopWatch.start("Create DefaultResourceLoader"); + DefaultResourceLoader defaultResourceLoader = new DefaultResourceLoader(pluginClassLoader); + pluginApplicationContext.setResourceLoader(defaultResourceLoader); + stopWatch.stop(); + + DefaultListableBeanFactory beanFactory = + (DefaultListableBeanFactory) pluginApplicationContext.getBeanFactory(); + + stopWatch.start("registerAnnotationConfigProcessors"); + AnnotationConfigUtils.registerAnnotationConfigProcessors(beanFactory); + stopWatch.stop(); + + log.debug("Total millis: {} ms -> {}", stopWatch.getTotalTimeMillis(), + stopWatch.prettyPrint()); + + contextRegistry.register(pluginId, pluginApplicationContext); + return pluginApplicationContext; + } + + private void initApplicationContext(String pluginId) { + if (contextRegistry.containsContext(pluginId)) { + log.debug("Plugin application context for [{}] has bean initialized.", pluginId); + return; + } + StopWatch stopWatch = new StopWatch(); + + stopWatch.start("createPluginApplicationContext"); + PluginApplicationContext pluginApplicationContext = + createPluginApplicationContext(pluginId); + stopWatch.stop(); + + stopWatch.start("findCandidateComponents"); + Set> candidateComponents = findCandidateComponents(pluginId); + stopWatch.stop(); + + stopWatch.start("registerBean"); + for (Class component : candidateComponents) { + log.debug("Register a plugin component class [{}] to context", component); + pluginApplicationContext.registerBean(component); + } + stopWatch.stop(); + + stopWatch.start("refresh plugin application context"); + pluginApplicationContext.refresh(); + stopWatch.stop(); + + log.debug("initApplicationContext total millis: {} ms -> {}", + stopWatch.getTotalTimeMillis(), stopWatch.prettyPrint()); + } + + public void onStartUp(String pluginId) { + initApplicationContext(pluginId); + } + + @NonNull + public PluginApplicationContext getPluginApplicationContext(String pluginId) { + return contextRegistry.getByPluginId(pluginId); + } + + public void contextDestroyed(String pluginId) { + Assert.notNull(pluginId, "pluginId must not be null"); + PluginApplicationContext removed = contextRegistry.remove(pluginId); + if (removed != null) { + removed.close(); + } + } + + private Set> findCandidateComponents(String pluginId) { + StopWatch stopWatch = new StopWatch("findCandidateComponents"); + + stopWatch.start("getExtensionClassNames"); + Set extensionClassNames = haloPluginManager.getExtensionClassNames(pluginId); + stopWatch.stop(); + + // add extensions for each started plugin + PluginWrapper plugin = haloPluginManager.getPlugin(pluginId); + log.debug("Registering extensions of the plugin '{}' as beans", pluginId); + Set> candidateComponents = new HashSet<>(); + for (String extensionClassName : extensionClassNames) { + log.debug("Load extension class '{}'", extensionClassName); + try { + stopWatch.start("loadClass"); + Class extensionClass = + plugin.getPluginClassLoader().loadClass(extensionClassName); + stopWatch.stop(); + + candidateComponents.add(extensionClass); + } catch (ClassNotFoundException e) { + log.error(e.getMessage(), e); + } + } + log.debug("total millis: {}ms -> {}", stopWatch.getTotalTimeMillis(), + stopWatch.prettyPrint()); + return candidateComponents; + } +} diff --git a/src/main/java/run/halo/app/plugin/PluginAutoConfiguration.java b/src/main/java/run/halo/app/plugin/PluginAutoConfiguration.java new file mode 100644 index 000000000..5b7db6fb5 --- /dev/null +++ b/src/main/java/run/halo/app/plugin/PluginAutoConfiguration.java @@ -0,0 +1,127 @@ +package run.halo.app.plugin; + +import java.io.File; +import java.lang.reflect.Constructor; +import java.nio.file.Path; +import lombok.extern.slf4j.Slf4j; +import org.pf4j.ClassLoadingStrategy; +import org.pf4j.CompoundPluginLoader; +import org.pf4j.DevelopmentPluginLoader; +import org.pf4j.JarPluginLoader; +import org.pf4j.PluginClassLoader; +import org.pf4j.PluginDescriptor; +import org.pf4j.PluginLoader; +import org.pf4j.PluginManager; +import org.pf4j.PluginStatusProvider; +import org.pf4j.RuntimeMode; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.util.StringUtils; +import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping; + +/** + * Plugin autoconfiguration for Spring Boot. + * + * @author guqing + * @see PluginProperties + */ +@Slf4j +@Configuration +@EnableConfigurationProperties(PluginProperties.class) +public class PluginAutoConfiguration { + + private final PluginProperties pluginProperties; + + private final RequestMappingHandlerMapping requestMappingHandlerMapping; + + public PluginAutoConfiguration(PluginProperties pluginProperties, + RequestMappingHandlerMapping requestMappingHandlerMapping) { + this.pluginProperties = pluginProperties; + this.requestMappingHandlerMapping = requestMappingHandlerMapping; + } + + @Bean + public PluginRequestMappingManager pluginRequestMappingManager() { + return new PluginRequestMappingManager(requestMappingHandlerMapping); + } + + @Bean + public HaloPluginManager pluginManager() { + // Setup RuntimeMode + System.setProperty("pf4j.mode", pluginProperties.getRuntimeMode().toString()); + + // Setup Plugin folder + String pluginsRoot = + StringUtils.hasText(pluginProperties.getPluginsRoot()) + ? pluginProperties.getPluginsRoot() + : "plugins"; + System.setProperty("pf4j.pluginsDir", pluginsRoot); + String appHome = System.getProperty("app.home"); + if (RuntimeMode.DEPLOYMENT == pluginProperties.getRuntimeMode() + && StringUtils.hasText(appHome)) { + System.setProperty("pf4j.pluginsDir", appHome + File.separator + pluginsRoot); + } + + HaloPluginManager pluginManager = + new HaloPluginManager(new File(pluginsRoot).toPath()) { + @Override + protected PluginLoader createPluginLoader() { + if (pluginProperties.getCustomPluginLoader() != null) { + Class clazz = pluginProperties.getCustomPluginLoader(); + try { + Constructor constructor = clazz.getConstructor(PluginManager.class); + return (PluginLoader) constructor.newInstance(this); + } catch (Exception ex) { + throw new IllegalArgumentException( + String.format("Create custom PluginLoader %s failed. Make sure" + + "there is a constructor with one argument that accepts " + + "PluginLoader", + clazz.getName())); + } + } else { + return new CompoundPluginLoader() + .add(new DevelopmentPluginLoader(this) { + @Override + public ClassLoader loadPlugin(Path pluginPath, + PluginDescriptor pluginDescriptor) { + PluginClassLoader pluginClassLoader = + new PluginClassLoader(pluginManager, pluginDescriptor, + getClass().getClassLoader()); + + loadClasses(pluginPath, pluginClassLoader); + loadJars(pluginPath, pluginClassLoader); + + return pluginClassLoader; + } + }, this::isDevelopment) + .add(new JarPluginLoader(this) { + @Override + public ClassLoader loadPlugin(Path pluginPath, + PluginDescriptor pluginDescriptor) { + PluginClassLoader pluginClassLoader = + new PluginClassLoader(pluginManager, pluginDescriptor, + getClass().getClassLoader(), ClassLoadingStrategy.APD); + pluginClassLoader.addFile(pluginPath.toFile()); + return pluginClassLoader; + + } + }, this::isNotDevelopment); + } + } + + @Override + protected PluginStatusProvider createPluginStatusProvider() { + if (PropertyPluginStatusProvider.isPropertySet(pluginProperties)) { + return new PropertyPluginStatusProvider(pluginProperties); + } + return super.createPluginStatusProvider(); + } + }; + + pluginManager.setExactVersionAllowed(pluginProperties.isExactVersionAllowed()); + pluginManager.setSystemVersion(pluginProperties.getSystemVersion()); + + return pluginManager; + } +} diff --git a/src/main/java/run/halo/app/plugin/PluginProperties.java b/src/main/java/run/halo/app/plugin/PluginProperties.java new file mode 100644 index 000000000..85d796cc7 --- /dev/null +++ b/src/main/java/run/halo/app/plugin/PluginProperties.java @@ -0,0 +1,71 @@ +package run.halo.app.plugin; + +import java.util.ArrayList; +import java.util.List; +import lombok.Data; +import org.pf4j.PluginLoader; +import org.pf4j.RuntimeMode; +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * Properties for plugin. + * + * @author guqing + * @see PluginAutoConfiguration + */ +@Data +@ConfigurationProperties(prefix = "halo.plugin") +public class PluginProperties { + + /** + * Auto start plugin when main app is ready. + */ + private boolean autoStartPlugin = true; + + /** + * Plugins disabled by default. + */ + private String[] disabledPlugins = new String[0]; + + /** + * Plugins enabled by default, prior to `disabledPlugins`. + */ + private String[] enabledPlugins = new String[0]; + + /** + * Set to true to allow requires expression to be exactly x.y.z. The default is false, meaning + * that using an exact version x.y.z will implicitly mean the same as >=x.y.z. + */ + private boolean exactVersionAllowed = false; + + /** + * Extended Plugin Class Directory. + */ + private List classesDirectories = new ArrayList<>(); + + /** + * Extended Plugin Jar Directory. + */ + private List libDirectories = new ArrayList<>(); + + /** + * Runtime Mode:development/deployment. + */ + private RuntimeMode runtimeMode = RuntimeMode.DEPLOYMENT; + + /** + * Plugin root directory: default “plugins”; when non-jar mode plugin, the value should be an + * absolute directory address. + */ + private String pluginsRoot = "plugins"; + + /** + * Allows providing custom plugin loaders. + */ + private Class customPluginLoader; + + /** + * The system version used for comparisons to the plugin requires attribute. + */ + private String systemVersion = "0.0.0"; +} diff --git a/src/main/java/run/halo/app/plugin/PluginRequestMappingManager.java b/src/main/java/run/halo/app/plugin/PluginRequestMappingManager.java new file mode 100644 index 000000000..7f166c680 --- /dev/null +++ b/src/main/java/run/halo/app/plugin/PluginRequestMappingManager.java @@ -0,0 +1,76 @@ +package run.halo.app.plugin; + +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.util.Collection; +import lombok.extern.slf4j.Slf4j; +import org.pf4j.PluginWrapper; +import org.springframework.context.support.GenericApplicationContext; +import org.springframework.stereotype.Controller; +import org.springframework.util.ReflectionUtils; +import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping; + +/** + * Plugin mapping manager. + * + * @author guqing + * @see RequestMappingHandlerMapping + */ +@Slf4j +public class PluginRequestMappingManager { + + private final RequestMappingHandlerMapping requestMappingHandlerMapping; + + public PluginRequestMappingManager( + RequestMappingHandlerMapping requestMappingHandlerMapping) { + this.requestMappingHandlerMapping = requestMappingHandlerMapping; + } + + public void registerControllers(PluginWrapper pluginWrapper) { + String pluginId = pluginWrapper.getPluginId(); + getControllerBeans(pluginId) + .forEach(this::registerController); + } + + private void registerController(Object controller) { + log.debug("Registering plugin request mapping for bean: [{}]", controller); + Method detectHandlerMethods = ReflectionUtils.findMethod(RequestMappingHandlerMapping.class, + "detectHandlerMethods", Object.class); + if (detectHandlerMethods == null) { + return; + } + try { + detectHandlerMethods.setAccessible(true); + detectHandlerMethods.invoke(requestMappingHandlerMapping, controller); + } catch (IllegalStateException ise) { + // ignore this + log.warn(ise.getMessage()); + } catch (IllegalAccessException e) { + throw new RuntimeException(e); + } catch (InvocationTargetException e) { + log.warn("invocation target exception: [{}]", e.getMessage(), e); + } + } + + private void unregisterControllerMappingInternal(Object controller) { + requestMappingHandlerMapping.getHandlerMethods() + .forEach((mapping, handlerMethod) -> { + if (controller == handlerMethod.getBean()) { + log.debug("Removed plugin request mapping [{}] from bean [{}]", mapping, + controller); + requestMappingHandlerMapping.unregisterMapping(mapping); + } + }); + } + + public void removeControllerMapping(String pluginId) { + getControllerBeans(pluginId) + .forEach(this::unregisterControllerMappingInternal); + } + + public Collection getControllerBeans(String pluginId) { + GenericApplicationContext pluginContext = + ExtensionContextRegistry.getInstance().getByPluginId(pluginId); + return pluginContext.getBeansWithAnnotation(Controller.class).values(); + } +} diff --git a/src/main/java/run/halo/app/plugin/PluginStartingError.java b/src/main/java/run/halo/app/plugin/PluginStartingError.java new file mode 100644 index 000000000..873b0e894 --- /dev/null +++ b/src/main/java/run/halo/app/plugin/PluginStartingError.java @@ -0,0 +1,22 @@ +package run.halo.app.plugin; + +import java.io.Serializable; +import lombok.AllArgsConstructor; +import lombok.Data; + +/** + *

Use this class to collect error information when the plugin enables an error.

+ * + * @author guqing + * @since 2.0.0 + */ +@Data +@AllArgsConstructor(staticName = "of") +public class PluginStartingError implements Serializable { + + private String pluginId; + + private String message; + + private String devMessage; +} diff --git a/src/main/java/run/halo/app/plugin/PropertyPluginStatusProvider.java b/src/main/java/run/halo/app/plugin/PropertyPluginStatusProvider.java new file mode 100644 index 000000000..635f009db --- /dev/null +++ b/src/main/java/run/halo/app/plugin/PropertyPluginStatusProvider.java @@ -0,0 +1,60 @@ +package run.halo.app.plugin; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import org.pf4j.PluginStatusProvider; +import org.thymeleaf.util.ArrayUtils; + +/** + * An implementation for PluginStatusProvider. The enabled plugins are read + * from {@code halo.plugin.enabled-plugins} properties in application.yaml + * and the disabled plugins are read from {@code halo.plugin.disabled-plugins} + * in application.yaml. + * + * @author guqing + * @since 2.0.0 + */ +public class PropertyPluginStatusProvider implements PluginStatusProvider { + + private final List enabledPlugins; + private final List disabledPlugins; + + public PropertyPluginStatusProvider(PluginProperties pluginProperties) { + this.enabledPlugins = pluginProperties.getEnabledPlugins() != null + ? Arrays.asList(pluginProperties.getEnabledPlugins()) : new ArrayList<>(); + this.disabledPlugins = pluginProperties.getDisabledPlugins() != null + ? Arrays.asList(pluginProperties.getDisabledPlugins()) : new ArrayList<>(); + } + + public static boolean isPropertySet(PluginProperties pluginProperties) { + return !ArrayUtils.isEmpty(pluginProperties.getEnabledPlugins()) + && !ArrayUtils.isEmpty(pluginProperties.getDisabledPlugins()); + } + + @Override + public boolean isPluginDisabled(String pluginId) { + if (disabledPlugins.contains(pluginId)) { + return true; + } + return !enabledPlugins.isEmpty() && !enabledPlugins.contains(pluginId); + } + + @Override + public void disablePlugin(String pluginId) { + if (isPluginDisabled(pluginId)) { + return; + } + disabledPlugins.add(pluginId); + enabledPlugins.remove(pluginId); + } + + @Override + public void enablePlugin(String pluginId) { + if (!isPluginDisabled(pluginId)) { + return; + } + enabledPlugins.add(pluginId); + disabledPlugins.remove(pluginId); + } +} diff --git a/src/main/java/run/halo/app/plugin/SharedEvent.java b/src/main/java/run/halo/app/plugin/SharedEvent.java new file mode 100644 index 000000000..3174c5e43 --- /dev/null +++ b/src/main/java/run/halo/app/plugin/SharedEvent.java @@ -0,0 +1,22 @@ +package run.halo.app.plugin; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + *

It is a symbolic annotation.

+ *

When the event marked with {@link SharedEvent} annotation is published, it will be + * broadcast to the application context of the plugin by + * {@link PluginApplicationEventBridgeDispatcher}.

+ * + * @author guqing + * @since 2.0.0 + */ +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface SharedEvent { +} diff --git a/src/main/java/run/halo/app/plugin/SpringComponentsFinder.java b/src/main/java/run/halo/app/plugin/SpringComponentsFinder.java new file mode 100644 index 000000000..ef2a18747 --- /dev/null +++ b/src/main/java/run/halo/app/plugin/SpringComponentsFinder.java @@ -0,0 +1,83 @@ +package run.halo.app.plugin; + +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.Reader; +import java.nio.charset.StandardCharsets; +import java.util.Collections; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import lombok.extern.slf4j.Slf4j; +import org.pf4j.AbstractExtensionFinder; +import org.pf4j.PluginManager; +import org.pf4j.PluginWrapper; +import org.pf4j.processor.ExtensionStorage; + +/** + *

The spring component finder. it will read {@code META-INF/plugin-components.idx} file in + * plugin to obtain the class name that needs to be registered in the plugin IOC.

+ *

Reading index files directly is much faster than dynamically scanning class components when + * the plugin is enabled.

+ * + * @author guqing + * @since 2.0.0 + */ +@Slf4j +public class SpringComponentsFinder extends AbstractExtensionFinder { + public static final String EXTENSIONS_RESOURCE = "META-INF/plugin-components.idx"; + + public SpringComponentsFinder(PluginManager pluginManager) { + super(pluginManager); + } + + @Override + public Map> readClasspathStorages() { + log.debug("Reading extensions storages from classpath"); + return Collections.emptyMap(); + } + + @Override + public Map> readPluginsStorages() { + log.debug("Reading extensions storages from plugins"); + Map> result = new LinkedHashMap<>(); + + List plugins = pluginManager.getPlugins(); + for (PluginWrapper plugin : plugins) { + String pluginId = plugin.getDescriptor().getPluginId(); + log.debug("Reading extensions storage from plugin '{}'", pluginId); + Set bucket = new HashSet<>(); + + try { + log.debug("Read '{}'", EXTENSIONS_RESOURCE); + ClassLoader pluginClassLoader = plugin.getPluginClassLoader(); + try (InputStream resourceStream = pluginClassLoader.getResourceAsStream( + EXTENSIONS_RESOURCE)) { + if (resourceStream == null) { + log.debug("Cannot find '{}'", EXTENSIONS_RESOURCE); + } else { + collectExtensions(resourceStream, bucket); + } + } + + debugExtensions(bucket); + + result.put(pluginId, bucket); + } catch (IOException e) { + log.error(e.getMessage(), e); + } + } + + return result; + } + + private void collectExtensions(InputStream inputStream, Set bucket) throws IOException { + try (Reader reader = new InputStreamReader(inputStream, StandardCharsets.UTF_8)) { + ExtensionStorage.read(reader, bucket); + } + } +} + diff --git a/src/main/java/run/halo/app/plugin/SpringExtensionFactory.java b/src/main/java/run/halo/app/plugin/SpringExtensionFactory.java new file mode 100644 index 000000000..e0b715b66 --- /dev/null +++ b/src/main/java/run/halo/app/plugin/SpringExtensionFactory.java @@ -0,0 +1,207 @@ +package run.halo.app.plugin; + +import java.lang.reflect.Constructor; +import java.lang.reflect.InvocationTargetException; +import java.util.Comparator; +import java.util.Objects; +import java.util.Optional; +import java.util.stream.Stream; +import lombok.extern.slf4j.Slf4j; +import org.pf4j.Extension; +import org.pf4j.ExtensionFactory; +import org.pf4j.Plugin; +import org.pf4j.PluginManager; +import org.pf4j.PluginWrapper; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.config.AutowireCapableBeanFactory; +import org.springframework.context.ApplicationContext; +import org.springframework.lang.Nullable; + +/** + *

Basic implementation of an extension factory.

+ *

Uses Springs {@link AutowireCapableBeanFactory} to instantiate a given extension class.

+ *

All kinds of {@link Autowired} are supported (see example below). If no + * {@link ApplicationContext} is + * available (this is the case if either the related plugin is not a {@link BasePlugin} or the + * given plugin manager is not a {@link HaloPluginManager}), standard Java reflection will be used + * to instantiate an extension.

+ *

Creates a new extension instance every time a request is done.

+ * Example of supported autowire modes: + *
{@code
+ *     @Extension
+ *     public class Foo implements ExtensionPoint {
+ *
+ *         private final Bar bar;       // Constructor injection
+ *         private Baz baz;             // Setter injection
+ *         @Autowired
+ *         private Qux qux;             // Field injection
+ *
+ *         @Autowired
+ *         public Foo(final Bar bar) {
+ *             this.bar = bar;
+ *         }
+ *
+ *         @Autowired
+ *         public void setBaz(final Baz baz) {
+ *             this.baz = baz;
+ *         }
+ *     }
+ * }
+ * + * @author guqing + * @since 2.0.0 + */ +@Slf4j +public class SpringExtensionFactory implements ExtensionFactory { + + public static final boolean AUTOWIRE_BY_DEFAULT = true; + + /** + * The plugin manager is used for retrieving a plugin from a given extension class and as a + * fallback supplier of an application context. + */ + protected final PluginManager pluginManager; + + /** + * Indicates if springs autowiring possibilities should be used. + */ + protected final boolean autowire; + + public SpringExtensionFactory(PluginManager pluginManager) { + this(pluginManager, AUTOWIRE_BY_DEFAULT); + } + + public SpringExtensionFactory(final PluginManager pluginManager, final boolean autowire) { + this.pluginManager = pluginManager; + this.autowire = autowire; + if (!autowire) { + log.warn( + "Autowiring is disabled although the only reason for existence of this special " + + "factory is" + + + " supporting spring and its application context."); + } + } + + @Override + @Nullable + public T create(Class extensionClass) { + if (!this.autowire) { + log.warn("Create instance of '" + nameOf(extensionClass) + + "' without using springs possibilities as" + + " autowiring is disabled."); + return createWithoutSpring(extensionClass); + } + Optional contextOptional = + getPluginApplicationContextBy(extensionClass); + if (contextOptional.isPresent()) { + // When the plugin starts, the class has been loaded into the plugin application + // context, + // so you only need to get it directly + PluginApplicationContext pluginApplicationContext = contextOptional.get(); + return pluginApplicationContext.getBean(extensionClass); + } + return createWithoutSpring(extensionClass); + } + + /** + * Creates an instance of the given class object by using standard Java reflection. + * + * @param extensionClass The class annotated with {@code @}{@link Extension}. + * @param The type for that an instance should be created. + * @return an instantiated extension. + * @throws IllegalArgumentException if the given class object has no public constructor. + * @throws RuntimeException if the called constructor cannot be instantiated with {@code + * null}-parameters. + */ + @SuppressWarnings("unchecked") + protected T createWithoutSpring(final Class extensionClass) + throws IllegalArgumentException { + final Constructor constructor = + getPublicConstructorWithShortestParameterList(extensionClass) + // An extension class is required to have at least one public constructor. + .orElseThrow( + () -> new IllegalArgumentException("Extension class '" + nameOf(extensionClass) + + "' must have at least one public constructor.")); + try { + log.debug("Instantiate '" + nameOf(extensionClass) + "' by calling '" + constructor + + "'with standard Java reflection."); + // Creating the instance by calling the constructor with null-parameters (if there + // are any). + return (T) constructor.newInstance(nullParameters(constructor)); + } catch (InstantiationException | IllegalAccessException | InvocationTargetException ex) { + // If one of these exceptions is thrown it it most likely because of NPE inside the + // called constructor and + // not the reflective call itself as we precisely searched for a fitting constructor. + log.error(ex.getMessage(), ex); + throw new RuntimeException( + "Most likely this exception is thrown because the called constructor (" + + constructor + ")" + + " cannot handle 'null' parameters. Original message was: " + + ex.getMessage(), ex); + } + } + + private Optional> getPublicConstructorWithShortestParameterList( + final Class extensionClass) { + return Stream.of(extensionClass.getConstructors()) + .min(Comparator.comparing(Constructor::getParameterCount)); + } + + private Object[] nullParameters(final Constructor constructor) { + return new Object[constructor.getParameterCount()]; + } + + protected Optional getPluginApplicationContextBy( + final Class extensionClass) { + final Plugin plugin = Optional.ofNullable(this.pluginManager.whichPlugin(extensionClass)) + .map(PluginWrapper::getPlugin) + .orElse(null); + + final PluginApplicationContext applicationContext; + + if (plugin instanceof BasePlugin) { + log.debug( + " Extension class ' " + nameOf(extensionClass) + "' belongs to halo-plugin '" + + nameOf(plugin) + + "' and will be autowired by using its application context."); + applicationContext = ((BasePlugin) plugin).getApplicationContext(); + } else if (this.pluginManager instanceof HaloPluginManager && plugin != null) { + log.debug(" Extension class ' " + nameOf(extensionClass) + + "' belongs to a non halo-plugin (or main application)" + + " '" + nameOf(plugin) + + ", but the used Halo plugin-manager is a spring-plugin-manager. Therefore" + + " the extension class will be autowired by using the managers application " + + "contexts"); + String pluginId = plugin.getWrapper().getPluginId(); + applicationContext = ((HaloPluginManager) this.pluginManager) + .getPluginApplicationContext(pluginId); + } else { + log.warn(" No application contexts can be used for instantiating extension class '" + + nameOf(extensionClass) + "'." + + " This extension neither belongs to a halo-plugin (id: '" + nameOf(plugin) + + "') nor is the used" + + " plugin manager a spring-plugin-manager (used manager: '" + + nameOf(this.pluginManager.getClass()) + "')." + + " At perspective of PF4J this seems highly uncommon in combination with a factory" + + " which only reason for existence" + + " is using spring (and its application context) and should at least be reviewed. " + + "In fact no autowiring can be" + + " applied although autowire flag was set to 'true'. Instantiating will fallback " + + "to standard Java reflection."); + applicationContext = null; + } + + return Optional.ofNullable(applicationContext); + } + + private String nameOf(final Plugin plugin) { + return Objects.nonNull(plugin) + ? plugin.getWrapper().getPluginId() + : "system"; + } + + private String nameOf(final Class clazz) { + return clazz.getName(); + } +} diff --git a/src/main/java/run/halo/app/plugin/event/HaloPluginStartedEvent.java b/src/main/java/run/halo/app/plugin/event/HaloPluginStartedEvent.java new file mode 100644 index 000000000..97467e37e --- /dev/null +++ b/src/main/java/run/halo/app/plugin/event/HaloPluginStartedEvent.java @@ -0,0 +1,24 @@ +package run.halo.app.plugin.event; + +import org.pf4j.PluginWrapper; +import org.springframework.context.ApplicationEvent; + +/** + * This event will be published to application context once plugin is started. + * + * @author guqing + */ +public class HaloPluginStartedEvent extends ApplicationEvent { + + private final PluginWrapper plugin; + + + public HaloPluginStartedEvent(Object source, PluginWrapper plugin) { + super(source); + this.plugin = plugin; + } + + public PluginWrapper getPlugin() { + return plugin; + } +} diff --git a/src/main/java/run/halo/app/plugin/event/HaloPluginStateChangedEvent.java b/src/main/java/run/halo/app/plugin/event/HaloPluginStateChangedEvent.java new file mode 100644 index 000000000..433268da8 --- /dev/null +++ b/src/main/java/run/halo/app/plugin/event/HaloPluginStateChangedEvent.java @@ -0,0 +1,36 @@ +package run.halo.app.plugin.event; + +import org.pf4j.PluginState; +import org.pf4j.PluginWrapper; +import org.springframework.context.ApplicationEvent; + +/** + * Plugin state changed event. + * + * @author guqing + * @date 2021-11-06 + */ +public class HaloPluginStateChangedEvent extends ApplicationEvent { + + private final PluginWrapper plugin; + + private final PluginState oldState; + + public HaloPluginStateChangedEvent(Object source, PluginWrapper wrapper, PluginState oldState) { + super(source); + this.plugin = wrapper; + this.oldState = oldState; + } + + public PluginWrapper getPlugin() { + return plugin; + } + + public PluginState getOldState() { + return oldState; + } + + public PluginState getState() { + return this.plugin.getPluginState(); + } +} diff --git a/src/main/java/run/halo/app/plugin/event/HaloPluginStoppedEvent.java b/src/main/java/run/halo/app/plugin/event/HaloPluginStoppedEvent.java new file mode 100644 index 000000000..54a3ed4ca --- /dev/null +++ b/src/main/java/run/halo/app/plugin/event/HaloPluginStoppedEvent.java @@ -0,0 +1,29 @@ +package run.halo.app.plugin.event; + +import org.pf4j.PluginState; +import org.pf4j.PluginWrapper; +import org.springframework.context.ApplicationEvent; + +/** + * This event will be published to plugin application context once plugin is stopped. + * + * @author guqing + * @date 2021-11-02 + */ +public class HaloPluginStoppedEvent extends ApplicationEvent { + + private final PluginWrapper plugin; + + public HaloPluginStoppedEvent(Object source, PluginWrapper plugin) { + super(source); + this.plugin = plugin; + } + + public PluginWrapper getPlugin() { + return plugin; + } + + public PluginState getPluginState() { + return plugin.getPluginState(); + } +} diff --git a/src/main/resources/application-dev.yaml b/src/main/resources/application-dev.yaml index a29011dec..3722c1c1f 100644 --- a/src/main/resources/application-dev.yaml +++ b/src/main/resources/application-dev.yaml @@ -26,6 +26,14 @@ halo: jwt: public-key-location: classpath:app.pub private-key-location: classpath:app.key + plugin: + runtime-mode: development # development, deployment + classes-directories: + - "build/classes" + - "build/resources" + lib-directories: + - "libs" + plugins-root: plugins logging: level: run.halo.app: DEBUG