feat: support running plugins from JAR in development mode (#4589)

#### What type of PR is this?
/kind feature
/milestone 2.10.x
/area core

#### What this PR does / why we need it:
支持在开发模式下通过 JAR 运行插件

*从此版本开始 BasePlugin 的子类建议使用 BasePlugin(PluginContext context) 构造函数,而不要使用之前的 BasePlugin(PluginWrapper wrapper) 构造函数。BasePlugin(PluginWrapper wrapper) 构造函数将计划在后续版本移除* ,当移除构造函数后不再将 PluginWrapper 暴露给插件使用,它只应该在 halo core 使用。

how to test it?
1. 测试开发模式下配置的 `halo.plugin.fixed-plugin-path` 插件是否正确运行
2. 测试开发模式下通过 JAR 包安装插件是否正确运行
3. 测试生产模式下是否能通过项目目录的方式运行插件,期望是生产模式不可以运行开发模式的插件
4. 测试开发模式和生产模式的插件卸载功能是否正确

#### Which issue(s) this PR fixes:

Fixes #2908

#### Does this PR introduce a user-facing change?

```release-note
支持在开发模式下通过 JAR 运行插件
```
pull/4668/head
guqing 2023-09-27 10:16:16 +08:00 committed by GitHub
parent 470b0de70d
commit 1f0cfc18e3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 216 additions and 113 deletions

View File

@ -3,6 +3,8 @@ package run.halo.app.plugin;
import lombok.extern.slf4j.Slf4j;
import org.pf4j.Plugin;
import org.pf4j.PluginWrapper;
import org.springframework.lang.NonNull;
import org.springframework.util.Assert;
/**
* This class will be extended by all plugins and serve as the common class between a plugin and
@ -14,12 +16,46 @@ import org.pf4j.PluginWrapper;
@Slf4j
public class BasePlugin extends Plugin {
protected PluginContext context;
@Deprecated
public BasePlugin(PluginWrapper wrapper) {
super(wrapper);
log.info("Initialized plugin {}", wrapper.getPluginId());
}
/**
* Constructor a plugin with the given plugin context.
* TODO Mark {@link PluginContext} as final to prevent modification.
*
* @param pluginContext plugin context must not be null.
*/
public BasePlugin(PluginContext pluginContext) {
this.context = pluginContext;
}
/**
* use {@link #BasePlugin(PluginContext)} instead of.
*
* @deprecated since 2.10.0
*/
public BasePlugin() {
}
/**
* Compatible with old constructors, if the plugin is not use
* {@link #BasePlugin(PluginContext)} constructor then base plugin factory will use this
* method to set plugin context.
*
* @param context plugin context must not be null.
*/
final void setContext(PluginContext context) {
Assert.notNull(context, "Plugin context must not be null");
this.context = context;
}
@NonNull
public PluginContext getContext() {
return context;
}
}

View File

@ -0,0 +1,27 @@
package run.halo.app.plugin;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import org.pf4j.RuntimeMode;
/**
* <p>This class will provide a context for the plugin, which will be used to store some
* information about the plugin.</p>
* <p>An instance of this class is provided to plugins in their constructor.</p>
* <p>It's safe for plugins to keep a reference to the instance for later use.</p>
* <p>This class facilitates communication with application and plugin manager.</p>
* <p>Pf4j recommends that you use a custom PluginContext instead of PluginWrapper.</p>
* <a href="https://github.com/pf4j/pf4j/blob/e4d7c7b9ea0c9a32179c3e33da1403228838944f/pf4j/src/main/java/org/pf4j/Plugin.java#L46">Use application custom PluginContext instead of PluginWrapper</a>
*
* @author guqing
* @since 2.10.0
*/
@Getter
@RequiredArgsConstructor
public class PluginContext {
private final String name;
private final String version;
private final RuntimeMode runtimeMode;
}

View File

@ -60,6 +60,7 @@ import run.halo.app.infra.utils.JsonUtils;
import run.halo.app.infra.utils.PathUtils;
import run.halo.app.infra.utils.YamlUnstructuredLoader;
import run.halo.app.plugin.HaloPluginManager;
import run.halo.app.plugin.HaloPluginWrapper;
import run.halo.app.plugin.PluginConst;
import run.halo.app.plugin.PluginExtensionLoaderUtils;
import run.halo.app.plugin.PluginStartingError;
@ -184,11 +185,12 @@ public class PluginReconciler implements Reconciler<Request> {
Assert.notNull(name, "Plugin name must not be null");
Assert.notNull(settingName, "Setting name must not be null");
PluginWrapper pluginWrapper = getPluginWrapper(name);
var runtimeMode = getRuntimeMode(name);
var resourceLoader =
new DefaultResourceLoader(pluginWrapper.getPluginClassLoader());
return PluginExtensionLoaderUtils.lookupExtensions(pluginWrapper.getPluginPath(),
pluginWrapper.getRuntimeMode())
runtimeMode)
.stream()
.map(resourceLoader::getResource)
.filter(Resource::exists)
@ -215,6 +217,7 @@ public class PluginReconciler implements Reconciler<Request> {
return false;
}
var runtimeMode = getRuntimeMode(pluginName);
Optional<Setting> settingOption = lookupPluginSetting(pluginName, settingName)
.map(setting -> {
// This annotation is added to prevent it from being deleted when stopped.
@ -802,11 +805,19 @@ public class PluginReconciler implements Reconciler<Request> {
}
private boolean isDevelopmentMode(String name) {
PluginWrapper pluginWrapper = haloPluginManager.getPlugin(name);
RuntimeMode runtimeMode = haloPluginManager.getRuntimeMode();
if (pluginWrapper != null) {
runtimeMode = pluginWrapper.getRuntimeMode();
return RuntimeMode.DEVELOPMENT.equals(getRuntimeMode(name));
}
private RuntimeMode getRuntimeMode(String name) {
var pluginWrapper = haloPluginManager.getPlugin(name);
if (pluginWrapper == null) {
return haloPluginManager.getRuntimeMode();
}
return RuntimeMode.DEVELOPMENT.equals(runtimeMode);
if (pluginWrapper instanceof HaloPluginWrapper haloPluginWrapper) {
return haloPluginWrapper.getRuntimeMode();
}
return Files.isDirectory(pluginWrapper.getPluginPath())
? RuntimeMode.DEVELOPMENT
: RuntimeMode.DEPLOYMENT;
}
}

View File

@ -23,13 +23,17 @@ public class BasePluginFactory implements PluginFactory {
return getPluginContext(pluginWrapper)
.map(context -> {
try {
return context.getBean(BasePlugin.class);
var basePlugin = context.getBean(BasePlugin.class);
var pluginContext = context.getBean(PluginContext.class);
basePlugin.setContext(pluginContext);
return basePlugin;
} catch (NoSuchBeanDefinitionException e) {
log.info(
"No bean named 'basePlugin' found in the context create default instance");
DefaultListableBeanFactory beanFactory =
context.getDefaultListableBeanFactory();
BasePlugin pluginInstance = new BasePlugin();
var pluginContext = beanFactory.getBean(PluginContext.class);
BasePlugin pluginInstance = new BasePlugin(pluginContext);
beanFactory.registerSingleton(Plugin.class.getName(), pluginInstance);
return pluginInstance;
}

View File

@ -108,6 +108,17 @@ public class HaloPluginManager extends DefaultPluginManager
return new YamlPluginDescriptorFinder();
}
@Override
protected PluginWrapper createPluginWrapper(PluginDescriptor pluginDescriptor, Path pluginPath,
ClassLoader pluginClassLoader) {
// create the plugin wrapper
log.debug("Creating wrapper for plugin '{}'", pluginPath);
HaloPluginWrapper pluginWrapper =
new HaloPluginWrapper(this, pluginDescriptor, pluginPath, pluginClassLoader);
pluginWrapper.setPluginFactory(getPluginFactory());
return pluginWrapper;
}
@Override
protected void firePluginStateEvent(PluginStateEvent event) {
rootApplicationContext.publishEvent(

View File

@ -0,0 +1,34 @@
package run.halo.app.plugin;
import java.nio.file.Files;
import java.nio.file.Path;
import org.pf4j.PluginDescriptor;
import org.pf4j.PluginManager;
import org.pf4j.PluginWrapper;
import org.pf4j.RuntimeMode;
/**
* A wrapper over plugin instance for Halo.
*
* @author guqing
* @since 2.10.0
*/
public class HaloPluginWrapper extends PluginWrapper {
private final RuntimeMode runtimeMode;
/**
* Creates a new plugin wrapper to manage the specified plugin.
*/
public HaloPluginWrapper(PluginManager pluginManager, PluginDescriptor descriptor,
Path pluginPath, ClassLoader pluginClassLoader) {
super(pluginManager, descriptor, pluginPath, pluginClassLoader);
this.runtimeMode = Files.isDirectory(pluginPath)
? RuntimeMode.DEVELOPMENT : RuntimeMode.DEPLOYMENT;
}
@Override
public RuntimeMode getRuntimeMode() {
return runtimeMode;
}
}

View File

@ -9,6 +9,7 @@ import java.util.List;
import java.util.Set;
import java.util.stream.Stream;
import lombok.extern.slf4j.Slf4j;
import org.pf4j.PluginRuntimeException;
import org.pf4j.PluginWrapper;
import org.springframework.beans.factory.support.DefaultListableBeanFactory;
import org.springframework.boot.env.PropertySourceLoader;
@ -88,6 +89,8 @@ public class PluginApplicationInitializer {
AnnotationConfigUtils.registerAnnotationConfigProcessors(beanFactory);
stopWatch.stop();
beanFactory.registerSingleton("pluginContext", createPluginContext(plugin));
// TODO deprecated
beanFactory.registerSingleton("pluginWrapper", haloPluginManager.getPlugin(pluginId));
populateSettingFetcher(pluginId, beanFactory);
@ -131,6 +134,15 @@ public class PluginApplicationInitializer {
stopWatch.getTotalTimeMillis(), stopWatch.prettyPrint());
}
PluginContext createPluginContext(PluginWrapper pluginWrapper) {
if (pluginWrapper instanceof HaloPluginWrapper haloPluginWrapper) {
return new PluginContext(haloPluginWrapper.getPluginId(),
pluginWrapper.getDescriptor().getVersion(),
haloPluginWrapper.getRuntimeMode());
}
throw new PluginRuntimeException("PluginWrapper must be instance of HaloPluginWrapper");
}
private void populateSettingFetcher(String pluginName,
DefaultListableBeanFactory listableBeanFactory) {
ReactiveExtensionClient extensionClient =

View File

@ -5,6 +5,7 @@ import static run.halo.app.plugin.resources.BundleResourceUtils.getJsBundleResou
import java.io.File;
import java.io.IOException;
import java.lang.reflect.Constructor;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Instant;
import lombok.extern.slf4j.Slf4j;
@ -109,33 +110,7 @@ public class PluginAutoConfiguration {
}
} else {
return new CompoundPluginLoader()
.add(new DevelopmentPluginLoader(this) {
@Override
protected PluginClassLoader createPluginClassLoader(Path pluginPath,
PluginDescriptor pluginDescriptor) {
return new PluginClassLoader(pluginManager, pluginDescriptor,
getClass().getClassLoader(), ClassLoadingStrategy.APD);
}
@Override
public ClassLoader loadPlugin(Path pluginPath,
PluginDescriptor pluginDescriptor) {
if (pluginProperties.getClassesDirectories() != null) {
for (String classesDirectory :
pluginProperties.getClassesDirectories()) {
pluginClasspath.addClassesDirectories(classesDirectory);
}
}
if (pluginProperties.getLibDirectories() != null) {
for (String libDirectory :
pluginProperties.getLibDirectories()) {
pluginClasspath.addJarsDirectories(libDirectory);
}
}
return super.loadPlugin(pluginPath, pluginDescriptor);
}
}, this::isDevelopment)
.add(createDevelopmentPluginLoader(this), this::isDevelopment)
.add(new JarPluginLoader(this) {
@Override
public ClassLoader loadPlugin(Path pluginPath,
@ -145,9 +120,8 @@ public class PluginAutoConfiguration {
getClass().getClassLoader(), ClassLoadingStrategy.APD);
pluginClassLoader.addFile(pluginPath.toFile());
return pluginClassLoader;
}
}, this::isNotDevelopment);
});
}
}
@ -167,9 +141,8 @@ public class PluginAutoConfiguration {
.setFixedPaths(pluginProperties.getFixedPluginPath());
return new CompoundPluginRepository()
.add(developmentPluginRepository, this::isDevelopment)
.add(new JarPluginRepository(getPluginsRoots()), this::isNotDevelopment)
.add(new DefaultPluginRepository(getPluginsRoots()),
this::isNotDevelopment);
.add(new JarPluginRepository(getPluginsRoots()))
.add(new DefaultPluginRepository(getPluginsRoots()));
}
};
@ -181,6 +154,41 @@ public class PluginAutoConfiguration {
return pluginManager;
}
DevelopmentPluginLoader createDevelopmentPluginLoader(PluginManager pluginManager) {
return new DevelopmentPluginLoader(pluginManager) {
@Override
protected PluginClassLoader createPluginClassLoader(Path pluginPath,
PluginDescriptor pluginDescriptor) {
return new PluginClassLoader(pluginManager, pluginDescriptor,
getClass().getClassLoader(), ClassLoadingStrategy.APD);
}
@Override
public ClassLoader loadPlugin(Path pluginPath,
PluginDescriptor pluginDescriptor) {
if (pluginProperties.getClassesDirectories() != null) {
for (String classesDirectory :
pluginProperties.getClassesDirectories()) {
pluginClasspath.addClassesDirectories(classesDirectory);
}
}
if (pluginProperties.getLibDirectories() != null) {
for (String libDirectory :
pluginProperties.getLibDirectories()) {
pluginClasspath.addJarsDirectories(libDirectory);
}
}
return super.loadPlugin(pluginPath, pluginDescriptor);
}
@Override
public boolean isApplicable(Path pluginPath) {
return Files.exists(pluginPath)
&& Files.isDirectory(pluginPath);
}
};
}
String getSystemVersion() {
return systemVersionSupplier.get().getNormalVersion();
}

View File

@ -6,11 +6,12 @@ import java.util.Comparator;
import java.util.Objects;
import java.util.Optional;
import java.util.stream.Stream;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.pf4j.Extension;
import org.pf4j.ExtensionFactory;
import org.pf4j.Plugin;
import org.pf4j.PluginManager;
import org.pf4j.PluginRuntimeException;
import org.pf4j.PluginWrapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.config.AutowireCapableBeanFactory;
@ -52,46 +53,18 @@ import org.springframework.lang.Nullable;
* @since 2.0.0
*/
@Slf4j
@RequiredArgsConstructor
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()) {
@ -154,52 +127,38 @@ public class SpringExtensionFactory implements ExtensionFactory {
protected <T> Optional<PluginApplicationContext> getPluginApplicationContextBy(
final Class<T> extensionClass) {
final Plugin plugin = Optional.ofNullable(this.pluginManager.whichPlugin(extensionClass))
return 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 = ExtensionContextRegistry.getInstance()
.getByPluginId(plugin.getWrapper().getPluginId());
return Optional.of(applicationContext);
} 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);
.map(plugin -> {
if (plugin instanceof BasePlugin basePlugin) {
return basePlugin;
}
throw new PluginRuntimeException(
"The plugin must be an instance of BasePlugin");
})
.map(plugin -> {
var pluginName = plugin.getContext().getName();
if (this.pluginManager instanceof HaloPluginManager haloPluginManager) {
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");
return haloPluginManager.getPluginApplicationContext(pluginName);
}
log.debug(
" Extension class ' " + nameOf(extensionClass) + "' belongs to halo-plugin '"
+ nameOf(plugin)
+ "' and will be autowired by using its application context.");
return ExtensionContextRegistry.getInstance().getByPluginId(pluginName);
});
}
private String nameOf(final Plugin plugin) {
private String nameOf(final BasePlugin plugin) {
return Objects.nonNull(plugin)
? plugin.getWrapper().getPluginId()
? plugin.getContext().getName()
: "system";
}

View File

@ -50,6 +50,7 @@ import run.halo.app.extension.controller.Reconciler;
import run.halo.app.infra.utils.FileUtils;
import run.halo.app.infra.utils.JsonUtils;
import run.halo.app.plugin.HaloPluginManager;
import run.halo.app.plugin.HaloPluginWrapper;
import run.halo.app.plugin.PluginConst;
import run.halo.app.plugin.PluginStartingError;
@ -69,7 +70,7 @@ class PluginReconcilerTest {
ExtensionClient extensionClient;
@Mock
PluginWrapper pluginWrapper;
HaloPluginWrapper pluginWrapper;
@Mock
ApplicationEventPublisher eventPublisher;