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