refactor: enhance cache management in plugin setting config (#6141)

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

#### What this PR does / why we need it:
增强插件配置的缓存管理

1. 通过 SettingFetcher/ReactiveSettingFetcher 获取插件配置可以不在考虑获取数据的性能问题,当数据变更后会自动更新缓存
2. 现在你可以通过在插件中监听 `PluginConfigUpdatedEvent` 事件来做一些处理,它会在用户更改插件配置后被触发

#### Does this PR introduce a user-facing change?
```release-note
增强插件配置的缓存管理并支持通过监听 `PluginConfigUpdatedEvent` 事件做一些特殊处理
```
pull/6116/head
guqing 2024-06-26 19:20:51 +08:00 committed by GitHub
parent 59edade8bb
commit 68d428aa29
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 327 additions and 75 deletions

View File

@ -0,0 +1,32 @@
package run.halo.app.plugin;
import com.fasterxml.jackson.databind.JsonNode;
import java.util.Map;
import lombok.Builder;
import lombok.Getter;
import org.springframework.context.ApplicationEvent;
import run.halo.app.core.extension.Plugin;
import run.halo.app.extension.ConfigMap;
/**
* <p>Event that is triggered when the {@link ConfigMap } represented by
* {@link Plugin.PluginSpec#getConfigMapName()} in the {@link Plugin} is updated.</p>
* <p>has two properties, oldConfig and newConfig, which represent the {@link ConfigMap#getData()}
* property value of the {@link ConfigMap}.</p>
*
* @author guqing
* @since 2.17.0
*/
@Getter
public class PluginConfigUpdatedEvent extends ApplicationEvent {
private final Map<String, JsonNode> oldConfig;
private final Map<String, JsonNode> newConfig;
@Builder
public PluginConfigUpdatedEvent(Object source, Map<String, JsonNode> oldConfig,
Map<String, JsonNode> newConfig) {
super(source);
this.oldConfig = oldConfig;
this.newConfig = newConfig;
}
}

View File

@ -1,5 +1,6 @@
package run.halo.app.plugin; package run.halo.app.plugin;
import lombok.Builder;
import lombok.Getter; import lombok.Getter;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.pf4j.RuntimeMode; import org.pf4j.RuntimeMode;
@ -17,10 +18,13 @@ import org.pf4j.RuntimeMode;
* @since 2.10.0 * @since 2.10.0
*/ */
@Getter @Getter
@Builder
@RequiredArgsConstructor @RequiredArgsConstructor
public class PluginContext { public class PluginContext {
private final String name; private final String name;
private final String configMapName;
private final String version; private final String version;
private final RuntimeMode runtimeMode; private final RuntimeMode runtimeMode;

View File

@ -1,5 +1,6 @@
package run.halo.app.plugin; package run.halo.app.plugin;
import static org.springframework.beans.factory.config.BeanDefinition.SCOPE_SINGLETON;
import static org.springframework.util.ResourceUtils.CLASSPATH_URL_PREFIX; import static org.springframework.util.ResourceUtils.CLASSPATH_URL_PREFIX;
import java.io.IOException; import java.io.IOException;
@ -101,10 +102,9 @@ public class DefaultPluginApplicationContextFactory implements PluginApplication
rootContext.getBeanProvider(ReactiveExtensionClient.class) rootContext.getBeanProvider(ReactiveExtensionClient.class)
.ifUnique(client -> { .ifUnique(client -> {
var reactiveSettingFetcher = new DefaultReactiveSettingFetcher(client, pluginId); context.registerBean("reactiveSettingFetcher",
var settingFetcher = new DefaultSettingFetcher(reactiveSettingFetcher); DefaultReactiveSettingFetcher.class, bhd -> bhd.setScope(SCOPE_SINGLETON));
beanFactory.registerSingleton("reactiveSettingFetcher", reactiveSettingFetcher); beanFactory.registerSingleton("settingFetcher", DefaultSettingFetcher.class);
beanFactory.registerSingleton("settingFetcher", settingFetcher);
}); });
rootContext.getBeanProvider(PluginRequestMappingHandlerMapping.class) rootContext.getBeanProvider(PluginRequestMappingHandlerMapping.class)

View File

@ -0,0 +1,29 @@
package run.halo.app.plugin;
import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Component;
import run.halo.app.core.extension.Plugin;
import run.halo.app.extension.ExtensionClient;
import run.halo.app.infra.exception.NotFoundException;
/**
* Default implementation of {@link PluginGetter}.
*
* @author guqing
* @since 2.17.0
*/
@Component
@RequiredArgsConstructor
public class DefaultPluginGetter implements PluginGetter {
private final ExtensionClient client;
@Override
public Plugin getPlugin(String name) {
if (StringUtils.isBlank(name)) {
throw new IllegalArgumentException("Plugin name must not be blank");
}
return client.fetch(Plugin.class, name)
.orElseThrow(() -> new NotFoundException("Plugin not found"));
}
}

View File

@ -1,16 +1,30 @@
package run.halo.app.plugin; package run.halo.app.plugin;
import static run.halo.app.extension.index.query.QueryFactory.equal;
import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.JsonNodeFactory; import com.fasterxml.jackson.databind.node.JsonNodeFactory;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.Map; import java.util.Map;
import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.BeansException;
import org.springframework.beans.factory.DisposableBean;
import org.springframework.cache.Cache;
import org.springframework.cache.CacheManager;
import org.springframework.context.ApplicationContext;
import org.springframework.context.ApplicationContextAware;
import org.springframework.lang.NonNull; import org.springframework.lang.NonNull;
import org.springframework.lang.Nullable;
import reactor.core.publisher.Mono; import reactor.core.publisher.Mono;
import run.halo.app.core.extension.Plugin;
import run.halo.app.extension.ConfigMap; import run.halo.app.extension.ConfigMap;
import run.halo.app.extension.DefaultExtensionMatcher;
import run.halo.app.extension.ExtensionClient;
import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.extension.ReactiveExtensionClient;
import run.halo.app.extension.controller.Controller;
import run.halo.app.extension.controller.ControllerBuilder;
import run.halo.app.extension.controller.Reconciler;
import run.halo.app.extension.router.selector.FieldSelector;
import run.halo.app.infra.utils.JsonParseException; import run.halo.app.infra.utils.JsonParseException;
import run.halo.app.infra.utils.JsonUtils; import run.halo.app.infra.utils.JsonUtils;
@ -20,15 +34,36 @@ import run.halo.app.infra.utils.JsonUtils;
* @author guqing * @author guqing
* @since 2.0.0 * @since 2.0.0
*/ */
public class DefaultReactiveSettingFetcher implements ReactiveSettingFetcher { public class DefaultReactiveSettingFetcher
implements ReactiveSettingFetcher, Reconciler<Reconciler.Request>, DisposableBean,
ApplicationContextAware {
private final ReactiveExtensionClient client; private final ReactiveExtensionClient client;
private final ExtensionClient blockingClient;
private final CacheManager cacheManager;
/**
* The application context of the plugin.
*/
private ApplicationContext applicationContext;
private final String pluginName; private final String pluginName;
public DefaultReactiveSettingFetcher(ReactiveExtensionClient client, String pluginName) { private final String configMapName;
private final String cacheName;
public DefaultReactiveSettingFetcher(PluginContext pluginContext,
ReactiveExtensionClient client, ExtensionClient blockingClient,
CacheManager cacheManager) {
this.client = client; this.client = client;
this.pluginName = pluginName; this.pluginName = pluginContext.getName();
this.configMapName = pluginContext.getConfigMapName();
this.blockingClient = blockingClient;
this.cacheManager = cacheManager;
this.cacheName = buildCacheKey(pluginName);
} }
@Override @Override
@ -60,26 +95,31 @@ public class DefaultReactiveSettingFetcher implements ReactiveSettingFetcher {
.defaultIfEmpty(JsonNodeFactory.instance.missingNode()); .defaultIfEmpty(JsonNodeFactory.instance.missingNode());
} }
private Mono<Map<String, JsonNode>> getValuesInternal() { Mono<Map<String, JsonNode>> getValuesInternal() {
return configMap(pluginName) var cache = getCache();
.mapNotNull(ConfigMap::getData) var cachedValue = getCachedConfigData(cache);
.map(data -> { if (cachedValue != null) {
Map<String, JsonNode> result = new LinkedHashMap<>(); return Mono.justOrEmpty(cachedValue);
data.forEach((key, value) -> result.put(key, readTree(value))); }
return result; return Mono.defer(() -> {
}) // double check
.defaultIfEmpty(Map.of()); var newCachedValue = getCachedConfigData(cache);
} if (newCachedValue != null) {
return Mono.justOrEmpty(newCachedValue);
private Mono<ConfigMap> configMap(String pluginName) { }
return client.fetch(Plugin.class, pluginName) if (StringUtils.isBlank(configMapName)) {
.flatMap(plugin -> { return Mono.empty();
String configMapName = plugin.getSpec().getConfigMapName(); }
if (StringUtils.isBlank(configMapName)) { return client.fetch(ConfigMap.class, configMapName)
return Mono.empty(); .mapNotNull(ConfigMap::getData)
} .map(data -> {
return client.fetch(ConfigMap.class, plugin.getSpec().getConfigMapName()); Map<String, JsonNode> result = new LinkedHashMap<>();
}); data.forEach((key, value) -> result.put(key, readTree(value)));
return result;
})
.defaultIfEmpty(Map.of())
.doOnNext(values -> cache.put(pluginName, values));
});
} }
private JsonNode readTree(String json) { private JsonNode readTree(String json) {
@ -96,4 +136,76 @@ public class DefaultReactiveSettingFetcher implements ReactiveSettingFetcher {
private <T> T convertValue(JsonNode jsonNode, Class<T> clazz) { private <T> T convertValue(JsonNode jsonNode, Class<T> clazz) {
return JsonUtils.DEFAULT_JSON_MAPPER.convertValue(jsonNode, clazz); return JsonUtils.DEFAULT_JSON_MAPPER.convertValue(jsonNode, clazz);
} }
@NonNull
private Cache getCache() {
var cache = cacheManager.getCache(cacheName);
if (cache == null) {
// should never happen
throw new IllegalStateException("Cache [" + cacheName + "] not found.");
}
return cache;
}
static String buildCacheKey(String pluginName) {
return "plugin-" + pluginName + "-configmap";
}
@Override
public Result reconcile(Request request) {
blockingClient.fetch(ConfigMap.class, configMapName)
.ifPresent(configMap -> {
var cache = getCache();
var existData = getCachedConfigData(cache);
var configMapData = configMap.getData();
Map<String, JsonNode> result = new LinkedHashMap<>();
if (configMapData != null) {
configMapData.forEach((key, value) -> result.put(key, readTree(value)));
}
applicationContext.publishEvent(PluginConfigUpdatedEvent.builder()
.source(this)
.oldConfig(existData)
.newConfig(result)
.build());
// update cache
cache.put(pluginName, result);
});
return Result.doNotRetry();
}
@Nullable
@SuppressWarnings("unchecked")
private Map<String, JsonNode> getCachedConfigData(@NonNull Cache cache) {
var existData = cache.get(pluginName);
if (existData == null) {
return null;
}
return (Map<String, JsonNode>) existData.get();
}
@Override
public Controller setupWith(ControllerBuilder builder) {
var configMap = new ConfigMap();
var extensionMatcher =
DefaultExtensionMatcher.builder(blockingClient, configMap.groupVersionKind())
.fieldSelector(FieldSelector.of(equal("metadata.name", configMapName)))
.build();
return builder
.extension(configMap)
.syncAllOnStart(false)
.onAddMatcher(extensionMatcher)
.onUpdateMatcher(extensionMatcher)
.build();
}
@Override
public void destroy() {
getCache().invalidate();
}
@Override
public void setApplicationContext(@NonNull ApplicationContext applicationContext)
throws BeansException {
this.applicationContext = applicationContext;
}
} }

View File

@ -79,7 +79,8 @@ public class HaloPluginManager extends DefaultPluginManager implements SpringPlu
@Override @Override
protected PluginFactory createPluginFactory() { protected PluginFactory createPluginFactory() {
var contextFactory = new DefaultPluginApplicationContextFactory(this); var contextFactory = new DefaultPluginApplicationContextFactory(this);
return new SpringPluginFactory(contextFactory); var pluginGetter = rootContext.getBean(PluginGetter.class);
return new SpringPluginFactory(contextFactory, pluginGetter);
} }
@Override @Override

View File

@ -0,0 +1,24 @@
package run.halo.app.plugin;
import run.halo.app.core.extension.Plugin;
import run.halo.app.infra.exception.NotFoundException;
/**
* An interface to get {@link Plugin} by name.
*
* @author guqing
* @since 2.17.0
*/
@FunctionalInterface
public interface PluginGetter {
/**
* Get plugin by name.
*
* @param name plugin name must not be null
* @return plugin
* @throws IllegalArgumentException if plugin name is null
* @throws NotFoundException if plugin not found
*/
Plugin getPlugin(String name);
}

View File

@ -1,15 +0,0 @@
package run.halo.app.plugin;
import org.springframework.context.ApplicationContext;
import org.springframework.context.support.GenericApplicationContext;
/**
* <p>An {@link ApplicationContext} implementation shared by plugins.</p>
* <p>Beans in the Core that need to be shared with plugins will be injected into this
* {@link SharedApplicationContext}.</p>
*
* @author guqing
* @since 2.0.0
*/
public class SharedApplicationContext extends GenericApplicationContext {
}

View File

@ -1,5 +1,6 @@
package run.halo.app.plugin; package run.halo.app.plugin;
import org.springframework.cache.CacheManager;
import org.springframework.context.ApplicationContext; import org.springframework.context.ApplicationContext;
import org.springframework.context.support.GenericApplicationContext; import org.springframework.context.support.GenericApplicationContext;
import org.springframework.security.web.server.context.ServerSecurityContextRepository; import org.springframework.security.web.server.context.ServerSecurityContextRepository;
@ -56,6 +57,8 @@ public enum SharedApplicationContextFactory {
rootContext.getBean(ExternalLinkProcessor.class)); rootContext.getBean(ExternalLinkProcessor.class));
beanFactory.registerSingleton("postContentService", beanFactory.registerSingleton("postContentService",
rootContext.getBean(PostContentService.class)); rootContext.getBean(PostContentService.class));
beanFactory.registerSingleton("cacheManager",
rootContext.getBean(CacheManager.class));
// TODO add more shared instance here // TODO add more shared instance here
sharedContext.refresh(); sharedContext.refresh();

View File

@ -16,24 +16,27 @@ import org.pf4j.PluginWrapper;
@Slf4j @Slf4j
public class SpringPluginFactory implements PluginFactory { public class SpringPluginFactory implements PluginFactory {
private final PluginApplicationContextFactory contextFactory; private final PluginApplicationContextFactory contextFactory;
private final PluginGetter pluginGetter;
public SpringPluginFactory(PluginApplicationContextFactory contextFactory) { public SpringPluginFactory(PluginApplicationContextFactory contextFactory,
PluginGetter pluginGetter) {
this.contextFactory = contextFactory; this.contextFactory = contextFactory;
this.pluginGetter = pluginGetter;
} }
@Override @Override
public Plugin create(PluginWrapper pluginWrapper) { public Plugin create(PluginWrapper pluginWrapper) {
var pluginContext = new PluginContext( var plugin = pluginGetter.getPlugin(pluginWrapper.getPluginId());
pluginWrapper.getPluginId(), var pluginContext = PluginContext.builder()
pluginWrapper.getDescriptor().getVersion(), .name(pluginWrapper.getPluginId())
pluginWrapper.getRuntimeMode() .configMapName(plugin.getSpec().getConfigMapName())
); .version(pluginWrapper.getDescriptor().getVersion())
.runtimeMode(pluginWrapper.getRuntimeMode())
.build();
return new SpringPlugin( return new SpringPlugin(
contextFactory, contextFactory,
pluginContext pluginContext
); );
} }
} }

View File

@ -6,8 +6,10 @@ import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.times; import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
import static run.halo.app.plugin.DefaultReactiveSettingFetcher.buildCacheKey;
import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.JsonNode;
import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Optional; import java.util.Optional;
@ -18,11 +20,18 @@ import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock; import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension; import org.mockito.junit.jupiter.MockitoExtension;
import org.skyscreamer.jsonassert.JSONAssert; import org.skyscreamer.jsonassert.JSONAssert;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.cache.Cache;
import org.springframework.cache.CacheManager;
import org.springframework.cache.concurrent.ConcurrentMapCache;
import org.springframework.context.ApplicationContext;
import reactor.core.publisher.Mono; import reactor.core.publisher.Mono;
import run.halo.app.core.extension.Plugin; import run.halo.app.core.extension.Plugin;
import run.halo.app.extension.ConfigMap; import run.halo.app.extension.ConfigMap;
import run.halo.app.extension.ExtensionClient;
import run.halo.app.extension.Metadata; import run.halo.app.extension.Metadata;
import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.extension.ReactiveExtensionClient;
import run.halo.app.extension.controller.Reconciler;
import run.halo.app.infra.utils.JsonUtils; import run.halo.app.infra.utils.JsonUtils;
/** /**
@ -35,31 +44,50 @@ import run.halo.app.infra.utils.JsonUtils;
class DefaultSettingFetcherTest { class DefaultSettingFetcherTest {
@Mock @Mock
private ReactiveExtensionClient extensionClient; private ReactiveExtensionClient client;
@Mock
private ExtensionClient blockingClient;
@Mock
private CacheManager cacheManager;
@MockBean
private final PluginContext pluginContext = PluginContext.builder()
.name("fake")
.configMapName("fake-config")
.build();
@Mock
private ApplicationContext applicationContext;
private DefaultReactiveSettingFetcher reactiveSettingFetcher;
private DefaultSettingFetcher settingFetcher; private DefaultSettingFetcher settingFetcher;
private final Cache cache = new ConcurrentMapCache(buildCacheKey(pluginContext.getName()));
@BeforeEach @BeforeEach
void setUp() { void setUp() {
DefaultReactiveSettingFetcher reactiveSettingFetcher = cache.invalidate();
new DefaultReactiveSettingFetcher(extensionClient, "fake");
settingFetcher = new DefaultSettingFetcher(reactiveSettingFetcher);
// do not call extensionClient when the settingFetcher first time created
verify(extensionClient, times(0)).fetch(eq(ConfigMap.class), any());
verify(extensionClient, times(0)).fetch(eq(Plugin.class), any());
Plugin plugin = buildPlugin(); this.reactiveSettingFetcher = new DefaultReactiveSettingFetcher(pluginContext, client,
when(extensionClient.fetch(eq(Plugin.class), any())).thenReturn(Mono.just(plugin)); blockingClient, cacheManager);
reactiveSettingFetcher.setApplicationContext(applicationContext);
settingFetcher = new DefaultSettingFetcher(reactiveSettingFetcher);
when(cacheManager.getCache(eq(cache.getName()))).thenReturn(cache);
ConfigMap configMap = buildConfigMap(); ConfigMap configMap = buildConfigMap();
when(extensionClient.fetch(eq(ConfigMap.class), any())).thenReturn(Mono.just(configMap)); when(client.fetch(eq(ConfigMap.class), eq(pluginContext.getConfigMapName())))
.thenReturn(Mono.just(configMap));
} }
@Test @Test
void getValues() throws JSONException { void getValues() throws JSONException {
Map<String, JsonNode> values = settingFetcher.getValues(); Map<String, JsonNode> values = settingFetcher.getValues();
verify(extensionClient, times(1)).fetch(eq(ConfigMap.class), any()); verify(client, times(1)).fetch(eq(ConfigMap.class), any());
assertThat(values).hasSize(2); assertThat(values).hasSize(2);
JSONAssert.assertEquals(getSns(), JsonUtils.objectToJson(values.get("sns")), true); JSONAssert.assertEquals(getSns(), JsonUtils.objectToJson(values.get("sns")), true);
@ -67,6 +95,40 @@ class DefaultSettingFetcherTest {
// The extensionClient will only be called once // The extensionClient will only be called once
Map<String, JsonNode> callAgain = settingFetcher.getValues(); Map<String, JsonNode> callAgain = settingFetcher.getValues();
assertThat(callAgain).isNotNull(); assertThat(callAgain).isNotNull();
verify(client, times(1)).fetch(eq(ConfigMap.class), any());
}
@Test
void getValuesWithUpdateCache() throws JSONException {
Map<String, JsonNode> values = settingFetcher.getValues();
verify(client, times(1)).fetch(eq(ConfigMap.class), any());
JSONAssert.assertEquals(getSns(), JsonUtils.objectToJson(values.get("sns")), true);
ConfigMap configMap = buildConfigMap();
configMap.getData().put("sns", """
{
"email": "abc@example.com",
"github": "abc"
}
""");
when(blockingClient.fetch(eq(ConfigMap.class), eq(pluginContext.getConfigMapName())))
.thenReturn(Optional.of(configMap));
reactiveSettingFetcher.reconcile(new Reconciler.Request(pluginContext.getConfigMapName()));
verify(applicationContext).publishEvent(any());
Map<String, JsonNode> updatedValues = settingFetcher.getValues();
verify(client, times(1)).fetch(eq(ConfigMap.class), any());
assertThat(updatedValues).hasSize(2);
JSONAssert.assertEquals(configMap.getData().get("sns"),
JsonUtils.objectToJson(updatedValues.get("sns")), true);
// cleanup cache
reactiveSettingFetcher.destroy();
updatedValues = settingFetcher.getValues();
assertThat(updatedValues).hasSize(2);
verify(client, times(2)).fetch(eq(ConfigMap.class), any());
} }
@Test @Test
@ -74,10 +136,6 @@ class DefaultSettingFetcherTest {
Optional<Sns> sns = settingFetcher.fetch("sns", Sns.class); Optional<Sns> sns = settingFetcher.fetch("sns", Sns.class);
assertThat(sns.isEmpty()).isFalse(); assertThat(sns.isEmpty()).isFalse();
JSONAssert.assertEquals(getSns(), JsonUtils.objectToJson(sns.get()), true); JSONAssert.assertEquals(getSns(), JsonUtils.objectToJson(sns.get()), true);
when(extensionClient.fetch(eq(ConfigMap.class), any())).thenReturn(Mono.empty());
Optional<Sns> missing = settingFetcher.fetch("sns1", Sns.class);
assertThat(missing.isEmpty()).isTrue();
} }
@Test @Test
@ -101,14 +159,15 @@ class DefaultSettingFetcherTest {
configMap.setMetadata(metadata); configMap.setMetadata(metadata);
configMap.setKind("ConfigMap"); configMap.setKind("ConfigMap");
configMap.setApiVersion("v1alpha1"); configMap.setApiVersion("v1alpha1");
configMap.setData(Map.of("sns", getSns(), var map = new HashMap<String, String>();
"basic", """ map.put("sns", getSns());
{ map.put("basic", """
"color": "red", {
"width": "100" "color": "red",
} "width": "100"
""") }
); """);
configMap.setData(map);
return configMap; return configMap;
} }