refactor: plugin components finder to avoid cannot be started after installing the plugin (#2272)

<!--  Thanks for sending a pull request!  Here are some tips for you:
1. 如果这是你的第一次,请阅读我们的贡献指南:<https://github.com/halo-dev/halo/blob/master/CONTRIBUTING.md>。
1. If this is your first time, please read our contributor guidelines: <https://github.com/halo-dev/halo/blob/master/CONTRIBUTING.md>.
2. 请根据你解决问题的类型为 Pull Request 添加合适的标签。
2. Please label this pull request according to what type of issue you are addressing, especially if this is a release targeted pull request.
3. 请确保你已经添加并运行了适当的测试。
3. Ensure you have added or ran the appropriate tests for your PR.
-->

#### What type of PR is this?
/kind improvement
/milestone 2.0
/area core
<!--
添加其中一个类别:
Add one of the following kinds:

/kind bug
/kind cleanup
/kind documentation
/kind feature
/kind improvement

适当添加其中一个或多个类别(可选):
Optionally add one or more of the following kinds if applicable:

/kind api-change
/kind deprecation
/kind failing-test
/kind flake
/kind regression
-->

#### What this PR does / why we need it:
使用 SpringComponentsFinder 覆盖 pf4j 默认实现的插件组件类缓存逻辑,以解决插件安装后缓存没有更新导致插件类加载不正确问题
原问题浮现步骤:
1. 先删除插件目录的所有插件
2. 通过接口上传一个插件`apis/api.halo.run/v1alpha1/plugins/install`
3. 启动它
4. 再上传另一个插件并启动它就会发现这个插件无法启动

现在修复过后可以重复测试上述步骤并观察日志显示的插件组件是否被正确加载,再此之前你可能需要配置以下日志级别
```
logging:
  level:
    org.pf4j.AbstractExtensionFinder: DEBUG
```
启动插件时观察这样的日志是否符合预期:
```
r.h.app.plugin.SpringComponentsFinder    : Read 'META-INF/plugin-components.idx'
org.pf4j.AbstractExtensionFinder         : Found possible 2 extensions:
org.pf4j.AbstractExtensionFinder         :    run.halo.template.TemplatePlugin
org.pf4j.AbstractExtensionFinder         :    run.halo.template.ApplesController
r.h.app.plugin.SpringComponentsFinder    : Load [2] component names into storage cache for plugin [PluginTemplate].
```
可以结合此 PR 上传插件进行测试 https://github.com/halo-dev/halo-admin/pull/590
#### Which issue(s) this PR fixes:

<!--
PR 合并时自动关闭 issue。
Automatically closes linked issue when PR is merged.

用法:`Fixes #<issue 号>`,或者 `Fixes (粘贴 issue 完整链接)`
Usage: `Fixes #<issue number>`, or `Fixes (paste link of issue)`.
-->
Fixes #

#### Special notes for your reviewer:
/cc @halo-dev/sig-halo 
#### Does this PR introduce a user-facing change?

<!--
如果当前 Pull Request 的修改不会造成用户侧的任何变更,在 `release-note` 代码块儿中填写 `NONE`。
否则请填写用户侧能够理解的 Release Note。如果当前 Pull Request 包含破坏性更新(Break Change),
Release Note 需要以 `action required` 开头。
If no, just write "NONE" in the release-note block below.
If yes, a release note is required:
Enter your extended release note in the block below. If the PR requires additional action from users switching to the new release, include the string "action required".
-->

```release-note
None
```
pull/2274/head
guqing 2022-07-23 20:40:10 +08:00 committed by GitHub
parent 0b4b1c321b
commit d85c83bf6e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 223 additions and 24 deletions

View File

@ -182,6 +182,7 @@ public class HaloPluginManager extends DefaultPluginManager
long ts = System.currentTimeMillis();
for (PluginWrapper pluginWrapper : resolvedPlugins) {
checkExtensionFinderReady(pluginWrapper);
PluginState pluginState = pluginWrapper.getPluginState();
if ((PluginState.DISABLED != pluginState) && (PluginState.STARTED != pluginState)) {
try {
@ -232,6 +233,8 @@ public class HaloPluginManager extends DefaultPluginManager
checkPluginId(pluginId);
PluginWrapper pluginWrapper = getPlugin(pluginId);
checkExtensionFinderReady(pluginWrapper);
PluginDescriptor pluginDescriptor = pluginWrapper.getDescriptor();
PluginState pluginState = pluginWrapper.getPluginState();
if (PluginState.STARTED == pluginState) {
@ -286,6 +289,15 @@ public class HaloPluginManager extends DefaultPluginManager
return pluginWrapper.getPluginState();
}
private void checkExtensionFinderReady(PluginWrapper pluginWrapper) {
if (extensionFinder instanceof SpringComponentsFinder springComponentsFinder) {
springComponentsFinder.readPluginStorageToMemory(pluginWrapper);
return;
}
// should never happen
throw new PluginRuntimeException("Plugin component classes may not loaded yet.");
}
private void doStopPlugins() {
startingErrors.clear();
// stop started plugins in reverse order
@ -367,6 +379,7 @@ public class HaloPluginManager extends DefaultPluginManager
* Release plugin holding release on stop.
*/
public void releaseAdditionalResources(String pluginId) {
removePluginComponentsCache(pluginId);
// release request mapping
requestMappingManager.removeHandlerMappings(pluginId);
try {
@ -389,5 +402,10 @@ public class HaloPluginManager extends DefaultPluginManager
return null;
}
private void removePluginComponentsCache(String pluginId) {
if (extensionFinder instanceof SpringComponentsFinder springComponentsFinder) {
springComponentsFinder.removeComponentsStorage(pluginId);
}
}
// end-region
}

View File

@ -138,6 +138,10 @@ public class PluginApplicationInitializer {
stopWatch.start("getExtensionClassNames");
Set<String> extensionClassNames = haloPluginManager.getExtensionClassNames(pluginId);
if (extensionClassNames == null) {
log.debug("No components class names found for plugin [{}]", pluginId);
extensionClassNames = Set.of();
}
stopWatch.stop();
// add extensions for each started plugin

View File

@ -6,16 +6,22 @@ import java.io.InputStreamReader;
import java.io.Reader;
import java.nio.charset.StandardCharsets;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.locks.StampedLock;
import lombok.extern.slf4j.Slf4j;
import org.pf4j.AbstractExtensionFinder;
import org.pf4j.PluginDependency;
import org.pf4j.PluginManager;
import org.pf4j.PluginState;
import org.pf4j.PluginStateEvent;
import org.pf4j.PluginWrapper;
import org.pf4j.processor.ExtensionStorage;
import org.springframework.util.Assert;
/**
* <p>The spring component finder. it will read {@code META-INF/plugin-components.idx} file in
@ -29,6 +35,7 @@ import org.pf4j.processor.ExtensionStorage;
@Slf4j
public class SpringComponentsFinder extends AbstractExtensionFinder {
public static final String EXTENSIONS_RESOURCE = "META-INF/plugin-components.idx";
private final StampedLock entryStampedLock = new StampedLock();
public SpringComponentsFinder(PluginManager pluginManager) {
super(pluginManager);
@ -36,48 +43,124 @@ public class SpringComponentsFinder extends AbstractExtensionFinder {
@Override
public Map<String, Set<String>> readClasspathStorages() {
log.debug("Reading extensions storages from classpath");
log.debug("Reading components storages from classpath");
return Collections.emptyMap();
}
@Override
public Map<String, Set<String>> readPluginsStorages() {
log.debug("Reading extensions storages from plugins");
log.debug("Reading components 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);
}
readPluginStorageToMemory(plugin);
}
return result;
}
@Override
public void pluginStateChanged(PluginStateEvent event) {
// see supper class for more details
if (checkForExtensionDependencies == null && PluginState.STARTED.equals(
event.getPluginState())) {
for (PluginDependency dependency : event.getPlugin().getDescriptor()
.getDependencies()) {
if (dependency.isOptional()) {
log.debug("Enable check for extension dependencies via ASM.");
checkForExtensionDependencies = true;
break;
}
}
}
}
private void collectExtensions(InputStream inputStream, Set<String> bucket) throws IOException {
try (Reader reader = new InputStreamReader(inputStream, StandardCharsets.UTF_8)) {
ExtensionStorage.read(reader, bucket);
}
}
protected void readPluginStorageToMemory(PluginWrapper pluginWrapper) {
String pluginId = pluginWrapper.getPluginId();
if (containsComponentsStorage(pluginId)) {
return;
}
log.debug("Reading components storage from plugin '{}'", pluginId);
Set<String> bucket = new HashSet<>();
try {
log.debug("Read '{}'", EXTENSIONS_RESOURCE);
ClassLoader pluginClassLoader = pluginWrapper.getPluginClassLoader();
try (InputStream resourceStream = pluginClassLoader.getResourceAsStream(
EXTENSIONS_RESOURCE)) {
if (resourceStream == null) {
log.debug("Cannot find '{}'", EXTENSIONS_RESOURCE);
} else {
collectExtensions(resourceStream, bucket);
}
}
debugExtensions(bucket);
putComponentsStorage(pluginId, bucket);
} catch (IOException e) {
log.error(e.getMessage(), e);
}
}
protected boolean containsComponentsStorage(String pluginId) {
Assert.notNull(pluginId, "The pluginId cannot be null");
long stamp = entryStampedLock.tryOptimisticRead();
boolean contains = super.entries != null && super.entries.containsKey(pluginId);
if (!entryStampedLock.validate(stamp)) {
stamp = entryStampedLock.readLock();
try {
return super.entries != null && entries.containsKey(pluginId);
} finally {
entryStampedLock.unlockRead(stamp);
}
}
return contains;
}
protected void putComponentsStorage(String pluginId, Set<String> components) {
Assert.notNull(pluginId, "The pluginId cannot be null");
// When the lock remains in write mode, the read lock cannot be obtained
long stamp = entryStampedLock.writeLock();
try {
Map<String, Set<String>> componentNamesMap;
if (super.entries == null) {
componentNamesMap = new HashMap<>();
} else {
componentNamesMap = new HashMap<>(super.entries);
}
log.debug("Load [{}] component names into storage cache for plugin [{}].",
components.size(), pluginId);
componentNamesMap.put(pluginId, components);
super.entries = componentNamesMap;
} finally {
entryStampedLock.unlockWrite(stamp);
}
}
protected void removeComponentsStorage(String pluginId) {
Assert.notNull(pluginId, "The pluginId cannot be null");
long stamp = entryStampedLock.writeLock();
try {
Map<String, Set<String>> componentNamesMap;
if (super.entries == null) {
componentNamesMap = new HashMap<>();
} else {
componentNamesMap = new HashMap<>(super.entries);
}
log.debug("Removing components storage from cache [{}].", pluginId);
componentNamesMap.remove(pluginId);
super.entries = componentNamesMap;
} finally {
entryStampedLock.unlockWrite(stamp);
}
}
}

View File

@ -0,0 +1,92 @@
package run.halo.app.plugin;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.util.Set;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.pf4j.PluginClassLoader;
import org.pf4j.PluginWrapper;
import org.springframework.util.ResourceUtils;
/**
* Tests for {@link SpringComponentsFinder}.
*
* @author guqing
* @since 2.0.0
*/
@ExtendWith(MockitoExtension.class)
class SpringComponentsFinderTest {
private File testFile;
@Mock
private HaloPluginManager pluginManager;
@Mock
private PluginWrapper pluginWrapper;
@Mock
private PluginClassLoader pluginClassLoader;
@InjectMocks
private SpringComponentsFinder springComponentsFinder;
@BeforeEach
void setUp() throws FileNotFoundException {
testFile = ResourceUtils.getFile("classpath:plugin/test-plugin-components.idx");
}
@Test
void readPluginStorageToMemory() throws FileNotFoundException {
boolean contains = springComponentsFinder.containsComponentsStorage("fakePlugin");
assertThat(contains).isFalse();
when(pluginWrapper.getPluginId()).thenReturn("fakePlugin");
when(pluginWrapper.getPluginClassLoader()).thenReturn(pluginClassLoader);
when(pluginClassLoader.getResourceAsStream(any()))
.thenReturn(new FileInputStream(testFile));
springComponentsFinder.readPluginStorageToMemory(pluginWrapper);
contains = springComponentsFinder.containsComponentsStorage("fakePlugin");
assertThat(contains).isTrue();
verify(pluginClassLoader, times(1)).getResourceAsStream(any());
// repeat it
springComponentsFinder.readPluginStorageToMemory(pluginWrapper);
verify(pluginClassLoader, times(1)).getResourceAsStream(any());
}
@Test
void containsPlugin() {
boolean exist = springComponentsFinder.containsComponentsStorage("NotExist");
assertThat(exist).isFalse();
assertThatThrownBy(() -> springComponentsFinder.containsComponentsStorage(null))
.hasMessage("The pluginId cannot be null");
}
@Test
void removeComponentsCache() {
springComponentsFinder.putComponentsStorage("fakePlugin", Set.of("A"));
boolean contains = springComponentsFinder.containsComponentsStorage("fakePlugin");
assertThat(contains).isTrue();
springComponentsFinder.removeComponentsStorage("fakePlugin");
contains = springComponentsFinder.containsComponentsStorage("fakePlugin");
assertThat(contains).isFalse();
}
}

View File

@ -0,0 +1,2 @@
# Generated by Halo
run.halo.links.LinkPlugin