mirror of https://github.com/halo-dev/halo
feat: add plugin implementation (#2128)
parent
f4a943e45a
commit
2c057f4fe1
|
@ -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"
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
}
|
|
@ -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());
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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 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<PluginLoader> customPluginLoader;
|
||||
|
||||
/**
|
||||
* The system version used for comparisons to the plugin requires attribute.
|
||||
*/
|
||||
private String systemVersion = "0.0.0";
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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 {
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
|
Loading…
Reference in New Issue