feat: add plugin implementation (#2128)

pull/2131/head
guqing 2022-05-31 12:06:10 +08:00 committed by GitHub
parent f4a943e45a
commit 2c057f4fe1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 1503 additions and 1 deletions

View File

@ -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"

View File

@ -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);
}
/**
* <p>Lazy initialization plugin application context,
* avoid being unable to get context when system start scan plugin.</p>
* <p>The plugin application context is not created until the plug-in is started.</p>
*
* @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();
}
}

View File

@ -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;
/**
* <p>Plugin application context registrar.</p>
* <p>It contains a map, the key is the plugin id and the value is application context of plugin
* .</p>
* <p>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.</p>
*
* @author guqing
* @since 2021-11-15
*/
public class ExtensionContextRegistry {
private static final ExtensionContextRegistry INSTANCE = new ExtensionContextRegistry();
private final Map<String, PluginApplicationContext> 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<PluginApplicationContext> getPluginApplicationContexts() {
return new ArrayList<>(registry.values());
}
}

View File

@ -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<String, PluginStartingError> 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 <T> List<T> getExtensions(Class<T> type) {
return this.getExtensions(extensionFinder.find(type));
}
@Override
public <T> List<T> getExtensions(Class<T> 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<String> 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<PluginWrapper> 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<String> 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
}

View File

@ -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 {
/**
* <p>context parent使parent context广
* parent.</p>
*
* @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<ApplicationEvent> 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<ApplicationEvent> getEarlyApplicationEvents() {
try {
Field earlyApplicationEventsField =
AbstractApplicationContext.class.getDeclaredField("earlyApplicationEvents");
return (Set<ApplicationEvent>) earlyApplicationEventsField.get(this);
} catch (NoSuchFieldException | IllegalAccessException e) {
// ignore this exception
return null;
}
}
}

View File

@ -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;
/**
* <p>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.</p>
*
* @author guqing
* @see SharedEvent
* @see PluginApplicationContext
* @since 2.0.0
*/
@Slf4j
@Component
@ConditionalOnClass(HaloPluginManager.class)
public class PluginApplicationEventBridgeDispatcher
implements ApplicationListener<ApplicationEvent> {
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;
}
}

View File

@ -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<Class<?>> 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<Class<?>> findCandidateComponents(String pluginId) {
StopWatch stopWatch = new StopWatch("findCandidateComponents");
stopWatch.start("getExtensionClassNames");
Set<String> 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<Class<?>> 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;
}
}

View File

@ -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<PluginLoader> 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;
}
}

View File

@ -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<String> classesDirectories = new ArrayList<>();
/**
* Extended Plugin Jar Directory.
*/
private List<String> libDirectories = new ArrayList<>();
/**
* Runtime Modedevelopment/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<PluginLoader> customPluginLoader;
/**
* The system version used for comparisons to the plugin requires attribute.
*/
private String systemVersion = "0.0.0";
}

View File

@ -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<Object> getControllerBeans(String pluginId) {
GenericApplicationContext pluginContext =
ExtensionContextRegistry.getInstance().getByPluginId(pluginId);
return pluginContext.getBeansWithAnnotation(Controller.class).values();
}
}

View File

@ -0,0 +1,22 @@
package run.halo.app.plugin;
import java.io.Serializable;
import lombok.AllArgsConstructor;
import lombok.Data;
/**
* <p>Use this class to collect error information when the plugin enables an error.</p>
*
* @author guqing
* @since 2.0.0
*/
@Data
@AllArgsConstructor(staticName = "of")
public class PluginStartingError implements Serializable {
private String pluginId;
private String message;
private String devMessage;
}

View File

@ -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 <code>application.yaml</code>
* and the disabled plugins are read from {@code halo.plugin.disabled-plugins}
* in <code>application.yaml</code>.
*
* @author guqing
* @since 2.0.0
*/
public class PropertyPluginStatusProvider implements PluginStatusProvider {
private final List<String> enabledPlugins;
private final List<String> 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);
}
}

View File

@ -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;
/**
* <p>It is a symbolic annotation.</p>
* <p>When the event marked with {@link SharedEvent} annotation is published, it will be
* broadcast to the application context of the plugin by
* {@link PluginApplicationEventBridgeDispatcher}.</p>
*
* @author guqing
* @since 2.0.0
*/
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface SharedEvent {
}

View File

@ -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;
/**
* <p>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.</p>
* <p>Reading index files directly is much faster than dynamically scanning class components when
* the plugin is enabled.</p>
*
* @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<String, Set<String>> readClasspathStorages() {
log.debug("Reading extensions storages from classpath");
return Collections.emptyMap();
}
@Override
public Map<String, Set<String>> readPluginsStorages() {
log.debug("Reading extensions storages from plugins");
Map<String, Set<String>> result = new LinkedHashMap<>();
List<PluginWrapper> plugins = pluginManager.getPlugins();
for (PluginWrapper plugin : plugins) {
String pluginId = plugin.getDescriptor().getPluginId();
log.debug("Reading extensions storage from plugin '{}'", pluginId);
Set<String> 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<String> bucket) throws IOException {
try (Reader reader = new InputStreamReader(inputStream, StandardCharsets.UTF_8)) {
ExtensionStorage.read(reader, bucket);
}
}
}

View File

@ -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;
/**
* <p>Basic implementation of an extension factory.</p>
* <p>Uses Springs {@link AutowireCapableBeanFactory} to instantiate a given extension class.</p>
* <p>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.</p>
* <p>Creates a new extension instance every time a request is done.</p>
* Example of supported autowire modes:
* <pre>{@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;
* }
* }
* }</pre>
*
* @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> T create(Class<T> extensionClass) {
if (!this.autowire) {
log.warn("Create instance of '" + nameOf(extensionClass)
+ "' without using springs possibilities as"
+ " autowiring is disabled.");
return createWithoutSpring(extensionClass);
}
Optional<PluginApplicationContext> 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 <T> 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> T createWithoutSpring(final Class<T> 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<Constructor<?>> 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 <T> Optional<PluginApplicationContext> getPluginApplicationContextBy(
final Class<T> 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 <T> String nameOf(final Class<T> clazz) {
return clazz.getName();
}
}

View File

@ -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 <b>application context</b> 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;
}
}

View File

@ -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();
}
}

View File

@ -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 <b>plugin application context</b> 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();
}
}

View File

@ -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