Add support for publishing events among plugins (#6081)

#### What type of PR is this?

/kind feature
/area core
/area plugin

#### What this PR does / why we need it:

This PR enhance usage of SharedEvent annotation to add support for publishing events among plugins.

#### How to test?

1. Clone repository https://github.com/halo-dev/plugin-starter
2. Change build.gradle as following:

	```gradle
	dependencies {
	    implementation platform('run.halo.tools.platform:plugin:2.17.0-SNAPSHOT')
	```

3. Change StarterPlugin as following:

	```java
	@Component
	public class StarterPlugin extends BasePlugin {
	
	    private final ApplicationContext appContext;
	
	    public StarterPlugin(PluginContext pluginContext, ApplicationContext appContext) {
	        super(pluginContext);
	        this.appContext = appContext;
	    }
	
	    @Override
	    public void start() {
	        appContext.publishEvent(new PostDeletedEvent(this, "fake-plugin"));
	    }
	
	    @Override
	    public void stop() {
	    }

	    @EventListener(PostDeletedEvent.class)
	    public void onApplicationEvent(PostDeletedEvent event) {
	        System.out.println("Post deleted event received in plugin: " + event.getName());
	    }
	}
	```
4. Add a listener to Halo core
```java
	    @EventListener(PostDeletedEvent.class)
	    public void onApplicationEvent(PostDeletedEvent event) {
	        System.out.println("Post deleted event received in core: " + event.getName());
	    }
```
5. Build plugin and install plugin
6. Enable the plugin and see the result

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

```release-note
None
```
pull/6143/head
John Niang 2024-06-20 00:11:00 +08:00 committed by GitHub
parent b445b505be
commit a94596a9f8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 351 additions and 84 deletions

View File

@ -14,7 +14,6 @@ import org.springframework.beans.factory.support.DefaultBeanNameGenerator;
import org.springframework.boot.env.PropertySourceLoader;
import org.springframework.boot.env.YamlPropertySourceLoader;
import org.springframework.context.ApplicationContext;
import org.springframework.context.ApplicationEvent;
import org.springframework.context.ApplicationListener;
import org.springframework.context.event.ContextClosedEvent;
import org.springframework.context.event.ContextRefreshedEvent;
@ -62,16 +61,15 @@ public class DefaultPluginApplicationContextFactory implements PluginApplication
@Override
public ApplicationContext create(String pluginId) {
log.debug("Preparing to create application context for plugin {}", pluginId);
var pluginWrapper = pluginManager.getPlugin(pluginId);
var sw = new StopWatch("CreateApplicationContextFor" + pluginId);
sw.start("Create");
var context = new PluginApplicationContext(pluginId);
var context = new PluginApplicationContext(pluginId, pluginManager);
context.setBeanNameGenerator(DefaultBeanNameGenerator.INSTANCE);
context.registerShutdownHook();
context.setParent(pluginManager.getSharedContext());
var pluginWrapper = pluginManager.getPlugin(pluginId);
var classLoader = pluginWrapper.getPluginClassLoader();
var resourceLoader = new DefaultResourceLoader(classLoader);
context.setResourceLoader(resourceLoader);
@ -140,14 +138,6 @@ public class DefaultPluginApplicationContextFactory implements PluginApplication
);
});
rootContext.getBeanProvider(SharedEventListenerRegistry.class)
.ifUnique(listenerRegistry -> {
var shareEventListenerAdapter = new ShareEventListenerAdapter(listenerRegistry);
beanFactory.registerSingleton(
"shareEventListenerAdapter",
shareEventListenerAdapter
);
});
sw.stop();
sw.start("LoadComponents");
@ -177,31 +167,6 @@ public class DefaultPluginApplicationContextFactory implements PluginApplication
return context;
}
private static class ShareEventListenerAdapter {
private final SharedEventListenerRegistry listenerRegistry;
private ApplicationListener<ApplicationEvent> listener;
private ShareEventListenerAdapter(SharedEventListenerRegistry listenerRegistry) {
this.listenerRegistry = listenerRegistry;
}
@EventListener
public void onApplicationEvent(ContextRefreshedEvent event) {
this.listener = sharedEvent -> event.getApplicationContext().publishEvent(sharedEvent);
listenerRegistry.register(this.listener);
}
@EventListener(ContextClosedEvent.class)
public void onApplicationEvent() {
if (this.listener != null) {
this.listenerRegistry.unregister(this.listener);
}
}
}
private static class FinderManager {
private final String pluginId;

View File

@ -1,34 +0,0 @@
package run.halo.app.plugin;
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;
import org.springframework.context.ApplicationEvent;
import org.springframework.context.ApplicationListener;
import org.springframework.stereotype.Component;
@Component
public class DefaultSharedEventListenerRegistry implements
ApplicationListener<ApplicationEvent>, SharedEventListenerRegistry {
private final List<ApplicationListener<ApplicationEvent>> listeners;
public DefaultSharedEventListenerRegistry() {
listeners = new CopyOnWriteArrayList<>();
}
@Override
public void onApplicationEvent(ApplicationEvent event) {
if (!event.getClass().isAnnotationPresent(SharedEvent.class)) {
return;
}
listeners.forEach(listener -> listener.onApplicationEvent(event));
}
public void register(ApplicationListener<ApplicationEvent> listener) {
this.listeners.add(listener);
}
public void unregister(ApplicationListener<ApplicationEvent> listener) {
this.listeners.remove(listener);
}
}

View File

@ -0,0 +1,40 @@
package run.halo.app.plugin;
import java.util.Objects;
import lombok.Getter;
import org.springframework.context.ApplicationEvent;
/**
* The event that delegates a shared event in core into all started plugins.
*
* @author johnniang
* @since 2.17
*/
@Getter
class HaloSharedEventDelegator extends ApplicationEvent {
private final ApplicationEvent delegate;
public HaloSharedEventDelegator(Object source, ApplicationEvent delegate) {
super(source);
this.delegate = delegate;
}
@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (o == null || getClass() != o.getClass()) {
return false;
}
HaloSharedEventDelegator that = (HaloSharedEventDelegator) o;
return Objects.equals(delegate, that.delegate);
}
@Override
public int hashCode() {
return Objects.hashCode(delegate);
}
}

View File

@ -2,7 +2,10 @@ package run.halo.app.plugin;
import java.util.List;
import java.util.concurrent.locks.StampedLock;
import org.springframework.context.ApplicationEvent;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.core.ResolvableType;
import org.springframework.core.annotation.AnnotationUtils;
import org.springframework.util.Assert;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
@ -22,8 +25,11 @@ public class PluginApplicationContext extends AnnotationConfigApplicationContext
private final String pluginId;
public PluginApplicationContext(String pluginId) {
private final SpringPluginManager pluginManager;
public PluginApplicationContext(String pluginId, SpringPluginManager pluginManager) {
this.pluginId = pluginId;
this.pluginManager = pluginManager;
}
public String getPluginId() {
@ -105,6 +111,23 @@ public class PluginApplicationContext extends AnnotationConfigApplicationContext
}
}
@Override
protected void publishEvent(Object event, ResolvableType typeHint) {
if (event instanceof ApplicationEvent applicationEvent
&& AnnotationUtils.findAnnotation(event.getClass(), SharedEvent.class) != null) {
// publish event via root context
var delegateEvent = new PluginSharedEventDelegator(this, applicationEvent);
pluginManager.getRootContext().publishEvent(delegateEvent);
return;
}
// unwrap event if needed
var originalEvent = event;
if (event instanceof HaloSharedEventDelegator delegator) {
originalEvent = delegator.getDelegate();
}
super.publishEvent(originalEvent, typeHint);
}
@Override
protected void onClose() {
// For subclasses: do nothing by default.

View File

@ -0,0 +1,44 @@
package run.halo.app.plugin;
import java.util.Objects;
import lombok.Getter;
import org.springframework.context.ApplicationEvent;
import org.springframework.lang.NonNull;
/**
* The event that delegates to another shared event published by a plugin.
*
* @author johnniang
* @since 2.17
*/
@Getter
class PluginSharedEventDelegator extends ApplicationEvent {
/**
* The delegate event.
*/
private final ApplicationEvent delegate;
public PluginSharedEventDelegator(@NonNull Object source, @NonNull ApplicationEvent delegate) {
super(source);
this.delegate = delegate;
}
@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (o == null || getClass() != o.getClass()) {
return false;
}
PluginSharedEventDelegator that = (PluginSharedEventDelegator) o;
return Objects.equals(delegate, that.delegate);
}
@Override
public int hashCode() {
return Objects.hashCode(delegate);
}
}

View File

@ -0,0 +1,50 @@
package run.halo.app.plugin;
import java.util.ArrayList;
import org.pf4j.PluginManager;
import org.springframework.context.ApplicationEvent;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.context.Lifecycle;
import org.springframework.context.event.EventListener;
import org.springframework.core.annotation.AnnotationUtils;
import org.springframework.stereotype.Component;
@Component
public class SharedEventDispatcher {
private final PluginManager pluginManager;
private final ApplicationEventPublisher publisher;
public SharedEventDispatcher(PluginManager pluginManager, ApplicationEventPublisher publisher) {
this.pluginManager = pluginManager;
this.publisher = publisher;
}
@EventListener(ApplicationEvent.class)
void onApplicationEvent(ApplicationEvent event) {
if (AnnotationUtils.findAnnotation(event.getClass(), SharedEvent.class) == null) {
return;
}
// we should copy the plugins list to avoid ConcurrentModificationException
var startedPlugins = new ArrayList<>(pluginManager.getStartedPlugins());
// broadcast event to all started plugins except the publisher
for (var startedPlugin : startedPlugins) {
var plugin = startedPlugin.getPlugin();
if (!(plugin instanceof SpringPlugin springPlugin)) {
continue;
}
var context = springPlugin.getApplicationContext();
// make sure the context is running before publishing the event
if (context instanceof Lifecycle lifecycle && lifecycle.isRunning()) {
context.publishEvent(new HaloSharedEventDelegator(this, event));
}
}
}
@EventListener(PluginSharedEventDelegator.class)
void onApplicationEvent(PluginSharedEventDelegator event) {
publisher.publishEvent(event.getDelegate());
}
}

View File

@ -1,12 +0,0 @@
package run.halo.app.plugin;
import org.springframework.context.ApplicationEvent;
import org.springframework.context.ApplicationListener;
public interface SharedEventListenerRegistry {
void register(ApplicationListener<ApplicationEvent> listener);
void unregister(ApplicationListener<ApplicationEvent> listener);
}

View File

@ -0,0 +1,108 @@
package run.halo.app.plugin;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.mockito.Mockito.withSettings;
import java.util.List;
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.PluginManager;
import org.pf4j.PluginWrapper;
import org.springframework.context.ApplicationContext;
import org.springframework.context.ApplicationEvent;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.context.Lifecycle;
@ExtendWith(MockitoExtension.class)
class SharedEventDispatcherTest {
@Mock
PluginManager pluginManager;
@Mock
ApplicationEventPublisher publisher;
@InjectMocks
SharedEventDispatcher dispatcher;
@Test
void shouldNotDispatchEventIfNotSharedEvent() {
dispatcher.onApplicationEvent(new FakeEvent(this));
verify(pluginManager, never()).getStartedPlugins();
}
@Test
void shouldDispatchEventToAllStartedPlugins() {
var pw = mock(PluginWrapper.class);
var plugin = mock(SpringPlugin.class);
var context =
mock(ApplicationContext.class, withSettings().extraInterfaces(Lifecycle.class));
when(((Lifecycle) context).isRunning()).thenReturn(true);
when(plugin.getApplicationContext()).thenReturn(context);
when(pw.getPlugin()).thenReturn(plugin);
when(pluginManager.getStartedPlugins()).thenReturn(List.of(pw));
var event = new FakeSharedEvent(this);
dispatcher.onApplicationEvent(event);
verify(context).publishEvent(new HaloSharedEventDelegator(dispatcher, event));
}
@Test
void shouldNotDispatchEventToAllStartedPluginsWhilePluginContextIsNotRunning() {
var pw = mock(PluginWrapper.class);
var plugin = mock(SpringPlugin.class);
var context =
mock(ApplicationContext.class, withSettings().extraInterfaces(Lifecycle.class));
when(((Lifecycle) context).isRunning()).thenReturn(false);
when(plugin.getApplicationContext()).thenReturn(context);
when(pw.getPlugin()).thenReturn(plugin);
when(pluginManager.getStartedPlugins()).thenReturn(List.of(pw));
var event = new FakeSharedEvent(this);
dispatcher.onApplicationEvent(event);
verify(context, never()).publishEvent(event);
}
@Test
void shouldNotDispatchEventToAllStartedPluginsWhilePluginContextIsNotLifecycle() {
var pw = mock(PluginWrapper.class);
var plugin = mock(SpringPlugin.class);
var context = mock(ApplicationContext.class);
when(plugin.getApplicationContext()).thenReturn(context);
when(pw.getPlugin()).thenReturn(plugin);
when(pluginManager.getStartedPlugins()).thenReturn(List.of(pw));
var event = new FakeSharedEvent(this);
dispatcher.onApplicationEvent(event);
verify(context, never()).publishEvent(event);
}
@Test
void shouldUnwrapPluginSharedEventAndRepublish() {
var event = new PluginSharedEventDelegator(this, new FakeSharedEvent(this));
dispatcher.onApplicationEvent(event);
verify(publisher).publishEvent(event.getDelegate());
}
class FakeEvent extends ApplicationEvent {
public FakeEvent(Object source) {
super(source);
}
}
@SharedEvent
class FakeSharedEvent extends ApplicationEvent {
public FakeSharedEvent(Object source) {
super(source);
}
}
}

View File

@ -0,0 +1,83 @@
# 插件中如何发送共享事件SharedEvent
在插件中可以通过共享事件SharedEvent来发送消息。 共享事件是一种特殊的事件,它可以被核心和所有插件订阅。
## 订阅共享事件
目前,核心中已经提供了不少的共享事件,例如 `run.halo.app.event.post.PostPublishedEvent`、`run.halo.app.event.post.PostUpdatedEvent`
,这些事件由核心发布,核心和插件均可订阅。请看下面的示例:
```java
@Component
public class PostPublishedEventListener implements ApplicationListener<PostPublishedEvent> {
@Override
public void onApplicationEvent(PostPublishedEvent event) {
// Do something
}
}
```
或者通过 `@EventListener` 注解实现,
```java
@Component
public class PostPublishedEventListener {
@EventListener
// @Async // 如果需要异步处理,可以添加此注解
public void onPostPublished(PostPublishedEvent event) {
// Do something
}
}
```
> 需要注意的是,只有被 `@SharedEvent` 注解标记的事件才能够被其他插件或者核心订阅。
## 发送共享事件
在插件中,我们可以通过 `ApplicationEventPublisher` 来发送共享事件,请看下面的示例:
```java
@Service
public class PostService {
private final ApplicationEventPublisher eventPublisher;
public PostService(ApplicationEventPublisher eventPublisher) {
this.eventPublisher = eventPublisher;
}
public void publishPost(Post post) {
// Do something
eventPublisher.publishEvent(new PostPublishedEvent(post));
}
}
```
## 创建共享事件
在插件中,我们可以创建自定义的共享事件,供其他插件订阅,示例如下:
```java
@SharedEvent
public class MySharedEvent extends ApplicationEvent {
public MySharedEvent(Object source) {
super(source);
}
}
```
> 需要注意的是:
> 1. 共享事件必须继承 `ApplicationEvent`
> 2. 共享事件必须被 `@SharedEvent` 注解标记。
> 3. 如果想要被其他插件订阅,则需要将该事件类发布到 Maven 仓库中,供其他插件引用。