From 802eb7deef7f963b3168382f24ed02700c302565 Mon Sep 17 00:00:00 2001 From: Oliver Gierke Date: Fri, 21 Oct 2016 10:24:50 +0200 Subject: [PATCH] DATACMNS-928 - Support for publishing events from aggregate roots. Introduced @DomainEvents usable on a method of an aggregate root. The method will be invoked when the aggregate is passed to a repository to save and its returned value or values will be exposed via an ApplicationEventListener. Also, the aggregate can expose a method annotated with @AfterDomainEventPublication which will be invoked once all events have been published. --- .../data/domain/AbstractAggregateRoot.java | 40 +++ .../domain/AfterDomainEventPublication.java | 35 +++ .../data/domain/DomainEvents.java | 37 +++ ...ublishingRepositoryProxyPostProcessor.java | 232 ++++++++++++++++++ .../support/RepositoryFactoryBeanSupport.java | 20 +- ...RepositoryProxyPostProcessorUnitTests.java | 208 ++++++++++++++++ 6 files changed, 570 insertions(+), 2 deletions(-) create mode 100644 src/main/java/org/springframework/data/domain/AbstractAggregateRoot.java create mode 100644 src/main/java/org/springframework/data/domain/AfterDomainEventPublication.java create mode 100644 src/main/java/org/springframework/data/domain/DomainEvents.java create mode 100644 src/main/java/org/springframework/data/repository/core/support/EventPublishingRepositoryProxyPostProcessor.java create mode 100644 src/test/java/org/springframework/data/repository/core/support/EventPublishingRepositoryProxyPostProcessorUnitTests.java diff --git a/src/main/java/org/springframework/data/domain/AbstractAggregateRoot.java b/src/main/java/org/springframework/data/domain/AbstractAggregateRoot.java new file mode 100644 index 000000000..0981af943 --- /dev/null +++ b/src/main/java/org/springframework/data/domain/AbstractAggregateRoot.java @@ -0,0 +1,40 @@ +/* + * Copyright 2016 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.domain; + +import lombok.Getter; + +import java.util.ArrayList; +import java.util.List; + +/** + * @author Oliver Gierke + */ +public class AbstractAggregateRoot { + + private transient final @Getter(onMethod = @__(@DomainEvents)) List domainEvents = new ArrayList(); + + protected T registerEvent(T event) { + + this.domainEvents.add(event); + return event; + } + + @AfterDomainEventPublication + public void clearDomainEvents() { + this.domainEvents.clear(); + } +} diff --git a/src/main/java/org/springframework/data/domain/AfterDomainEventPublication.java b/src/main/java/org/springframework/data/domain/AfterDomainEventPublication.java new file mode 100644 index 000000000..6badfbae1 --- /dev/null +++ b/src/main/java/org/springframework/data/domain/AfterDomainEventPublication.java @@ -0,0 +1,35 @@ +/* + * Copyright 2016 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.domain; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Annotation to be used on a method of a Spring Data managed aggregate to get invoked after the events of an aggregate + * have been published. + * + * @author Oliver Gierke + * @see DomainEvents + * @soundtrack Benny Greb - September (Moving Parts Live) + */ +@Retention(RetentionPolicy.RUNTIME) +@Target({ ElementType.METHOD, ElementType.ANNOTATION_TYPE }) +public @interface AfterDomainEventPublication { + +} diff --git a/src/main/java/org/springframework/data/domain/DomainEvents.java b/src/main/java/org/springframework/data/domain/DomainEvents.java new file mode 100644 index 000000000..0493af1f3 --- /dev/null +++ b/src/main/java/org/springframework/data/domain/DomainEvents.java @@ -0,0 +1,37 @@ +/* + * Copyright 2016 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.domain; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import org.springframework.context.ApplicationEventPublisher; + +/** + * {@link DomainEvents} can be used on methods of aggregate roots managed by Spring Data repositories to publish the + * events returned by that method as Spring application events. + * + * @author Oliver Gierke + * @see ApplicationEventPublisher + * @see AfterDomainEventPublication + * @soundtrack Benny Greb - Soulfood (Moving Parts Live) + */ +@Retention(RetentionPolicy.RUNTIME) +@Target({ ElementType.METHOD, ElementType.ANNOTATION_TYPE }) +public @interface DomainEvents { +} diff --git a/src/main/java/org/springframework/data/repository/core/support/EventPublishingRepositoryProxyPostProcessor.java b/src/main/java/org/springframework/data/repository/core/support/EventPublishingRepositoryProxyPostProcessor.java new file mode 100644 index 000000000..fba2317f1 --- /dev/null +++ b/src/main/java/org/springframework/data/repository/core/support/EventPublishingRepositoryProxyPostProcessor.java @@ -0,0 +1,232 @@ +/* + * Copyright 2016 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.repository.core.support; + +import lombok.RequiredArgsConstructor; + +import java.lang.reflect.Method; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.Map; + +import org.aopalliance.intercept.MethodInterceptor; +import org.aopalliance.intercept.MethodInvocation; +import org.springframework.aop.framework.ProxyFactory; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.data.domain.AfterDomainEventPublication; +import org.springframework.data.domain.DomainEvents; +import org.springframework.data.repository.CrudRepository; +import org.springframework.data.repository.core.RepositoryInformation; +import org.springframework.data.util.AnnotationDetectionMethodCallback; +import org.springframework.util.Assert; +import org.springframework.util.ConcurrentReferenceHashMap; +import org.springframework.util.ReflectionUtils; + +/** + * {@link RepositoryProxyPostProcessor} to register a {@link MethodInterceptor} to intercept the + * {@link CrudRepository#save(Object)} method and publish events potentially exposed via a method annotated with + * {@link DomainEvents}. If no such method can be detected on the aggregate root, no interceptor is added. Additionally, + * the aggregate root can expose a method annotated with {@link AfterDomainEventPublication}. If present, the method will be + * invoked after all events have been published. + * + * @author Oliver Gierke + * @since 1.13 + * @soundtrack Henrik Freischlader Trio - Master Plan (Openness) + */ +@RequiredArgsConstructor +public class EventPublishingRepositoryProxyPostProcessor implements RepositoryProxyPostProcessor { + + private final ApplicationEventPublisher publisher; + + /* + * (non-Javadoc) + * @see org.springframework.data.repository.core.support.RepositoryProxyPostProcessor#postProcess(org.springframework.aop.framework.ProxyFactory, org.springframework.data.repository.core.RepositoryInformation) + */ + @Override + public void postProcess(ProxyFactory factory, RepositoryInformation repositoryInformation) { + + EventPublishingMethod method = EventPublishingMethod.of(repositoryInformation.getDomainType()); + + if (method == null) { + return; + } + + factory.addAdvice(new EventPublishingMethodInterceptor(repositoryInformation.getCrudMethods().getSaveMethod(), + method, publisher)); + } + + @RequiredArgsConstructor + static class EventPublishingMethodInterceptor implements MethodInterceptor { + + private final Method saveMethod; + private final EventPublishingMethod eventMethod; + private final ApplicationEventPublisher publisher; + + /* + * (non-Javadoc) + * @see org.aopalliance.intercept.MethodInterceptor#invoke(org.aopalliance.intercept.MethodInvocation) + */ + @Override + public Object invoke(MethodInvocation invocation) throws Throwable { + + if (!invocation.getMethod().equals(saveMethod)) { + return invocation.proceed(); + } + + for (Object argument : invocation.getArguments()) { + eventMethod.publishEventsFrom(argument, publisher); + } + + return invocation.proceed(); + } + } + + @RequiredArgsConstructor + static class EventPublishingMethod { + + private static Map, EventPublishingMethod> CACHE = new ConcurrentReferenceHashMap, EventPublishingMethod>(); + private static EventPublishingMethod NONE = new EventPublishingMethod(null, null); + + private final Method publishingMethod; + private final Method clearingMethod; + + /** + * Creates an {@link EventPublishingMethod} for the given type. + * + * @param type must not be {@literal null}. + * @return an {@link EventPublishingMethod} for the given type or {@literal null} in case the given type does not + * expose an event publishing method. + */ + public static EventPublishingMethod of(Class type) { + + Assert.notNull(type, "Type must not be null!"); + + EventPublishingMethod eventPublishingMethod = CACHE.get(type); + + if (eventPublishingMethod != null) { + return eventPublishingMethod.orNull(); + } + + AnnotationDetectionMethodCallback publishing = new AnnotationDetectionMethodCallback( + DomainEvents.class); + ReflectionUtils.doWithMethods(type, publishing); + + // TODO: Lazify this as the inspection might not be needed if the publishing callback didn't find an annotation in + // the first place + + AnnotationDetectionMethodCallback clearing = new AnnotationDetectionMethodCallback( + AfterDomainEventPublication.class); + ReflectionUtils.doWithMethods(type, clearing); + + EventPublishingMethod result = from(publishing, clearing); + + CACHE.put(type, result); + + return result.orNull(); + } + + /** + * Publishes all events in the given aggregate root using the given {@link ApplicationEventPublisher}. + * + * @param object can be {@literal null}. + * @param publisher must not be {@literal null}. + */ + public void publishEventsFrom(Object object, ApplicationEventPublisher publisher) { + + if (object == null) { + return; + } + + for (Object event : asCollection(ReflectionUtils.invokeMethod(publishingMethod, object))) { + publisher.publishEvent(event); + } + + if (clearingMethod != null) { + ReflectionUtils.invokeMethod(clearingMethod, object); + } + } + + /** + * Returns the current {@link EventPublishingMethod} or {@literal null} if it's the default value. + * + * @return + */ + private EventPublishingMethod orNull() { + return this == EventPublishingMethod.NONE ? null : this; + } + + /** + * Creates a new {@link EventPublishingMethod} using the given pre-populated + * {@link AnnotationDetectionMethodCallback} looking up an optional clearing method from the given callback. + * + * @param publishing must not be {@literal null}. + * @param clearing must not be {@literal null}. + * @return + */ + private static EventPublishingMethod from(AnnotationDetectionMethodCallback publishing, + AnnotationDetectionMethodCallback clearing) { + + if (!publishing.hasFoundAnnotation()) { + return EventPublishingMethod.NONE; + } + + Method eventMethod = publishing.getMethod(); + ReflectionUtils.makeAccessible(eventMethod); + + return new EventPublishingMethod(eventMethod, getClearingMethod(clearing)); + } + + /** + * Returns the {@link Method} supposed to be invoked for event clearing or {@literal null} if none is found. + * + * @param clearing must not be {@literal null}. + * @return + */ + private static Method getClearingMethod(AnnotationDetectionMethodCallback clearing) { + + if (!clearing.hasFoundAnnotation()) { + return null; + } + + Method method = clearing.getMethod(); + ReflectionUtils.makeAccessible(method); + + return method; + } + + /** + * Returns the given source object as collection, i.e. collections are returned as is, objects are turned into a + * one-element collection, {@literal null} will become an empty collection. + * + * @param source can be {@literal null}. + * @return + */ + @SuppressWarnings("unchecked") + private static Collection asCollection(Object source) { + + if (source == null) { + return Collections.emptyList(); + } + + if (Collection.class.isInstance(source)) { + return (Collection) source; + } + + return Arrays.asList(source); + } + } +} diff --git a/src/main/java/org/springframework/data/repository/core/support/RepositoryFactoryBeanSupport.java b/src/main/java/org/springframework/data/repository/core/support/RepositoryFactoryBeanSupport.java index 12ef4656c..d02d5b308 100644 --- a/src/main/java/org/springframework/data/repository/core/support/RepositoryFactoryBeanSupport.java +++ b/src/main/java/org/springframework/data/repository/core/support/RepositoryFactoryBeanSupport.java @@ -25,6 +25,8 @@ import org.springframework.beans.factory.BeanFactoryAware; import org.springframework.beans.factory.FactoryBean; import org.springframework.beans.factory.InitializingBean; import org.springframework.beans.factory.annotation.Required; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.context.ApplicationEventPublisherAware; import org.springframework.data.mapping.PersistentEntity; import org.springframework.data.mapping.context.MappingContext; import org.springframework.data.repository.Repository; @@ -47,8 +49,9 @@ import org.springframework.util.Assert; * @author Oliver Gierke * @author Thomas Darimont */ -public abstract class RepositoryFactoryBeanSupport, S, ID extends Serializable> implements - InitializingBean, RepositoryFactoryInformation, FactoryBean, BeanClassLoaderAware, BeanFactoryAware { +public abstract class RepositoryFactoryBeanSupport, S, ID extends Serializable> + implements InitializingBean, RepositoryFactoryInformation, FactoryBean, BeanClassLoaderAware, + BeanFactoryAware, ApplicationEventPublisherAware { private RepositoryFactorySupport factory; @@ -62,6 +65,7 @@ public abstract class RepositoryFactoryBeanSupport, private BeanFactory beanFactory; private boolean lazyInit = false; private EvaluationContextProvider evaluationContextProvider = DefaultEvaluationContextProvider.INSTANCE; + private ApplicationEventPublisher publisher; private T repository; @@ -162,7 +166,15 @@ public abstract class RepositoryFactoryBeanSupport, @Override public void setBeanFactory(BeanFactory beanFactory) throws BeansException { this.beanFactory = beanFactory; + } + /* + * (non-Javadoc) + * @see org.springframework.context.ApplicationEventPublisherAware#setApplicationEventPublisher(org.springframework.context.ApplicationEventPublisher) + */ + @Override + public void setApplicationEventPublisher(ApplicationEventPublisher publisher) { + this.publisher = publisher; } /* @@ -246,6 +258,10 @@ public abstract class RepositoryFactoryBeanSupport, this.factory.setBeanClassLoader(classLoader); this.factory.setBeanFactory(beanFactory); + if (publisher != null) { + this.factory.addRepositoryProxyPostProcessor(new EventPublishingRepositoryProxyPostProcessor(publisher)); + } + this.repositoryMetadata = this.factory.getRepositoryMetadata(repositoryInterface); if (!lazyInit) { diff --git a/src/test/java/org/springframework/data/repository/core/support/EventPublishingRepositoryProxyPostProcessorUnitTests.java b/src/test/java/org/springframework/data/repository/core/support/EventPublishingRepositoryProxyPostProcessorUnitTests.java new file mode 100644 index 000000000..8030d4028 --- /dev/null +++ b/src/test/java/org/springframework/data/repository/core/support/EventPublishingRepositoryProxyPostProcessorUnitTests.java @@ -0,0 +1,208 @@ +/* + * Copyright 2016 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.repository.core.support; + +import static org.hamcrest.CoreMatchers.*; +import static org.junit.Assert.*; +import static org.mockito.Matchers.*; +import static org.mockito.Matchers.any; +import static org.mockito.Mockito.*; + +import lombok.Getter; +import lombok.Value; + +import java.io.Serializable; +import java.lang.reflect.Method; +import java.util.Arrays; +import java.util.Collection; +import java.util.UUID; + +import org.aopalliance.aop.Advice; +import org.aopalliance.intercept.MethodInvocation; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.runners.MockitoJUnitRunner; +import org.springframework.aop.framework.ProxyFactory; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.data.domain.DomainEvents; +import org.springframework.data.repository.CrudRepository; +import org.springframework.data.repository.core.RepositoryInformation; +import org.springframework.data.repository.core.support.EventPublishingRepositoryProxyPostProcessor.EventPublishingMethod; +import org.springframework.data.repository.core.support.EventPublishingRepositoryProxyPostProcessor.EventPublishingMethodInterceptor; + +/** + * Unit tests for {@link EventPublishingRepositoryProxyPostProcessor} and contained classes. + * + * @author Oliver Gierke + * @soundtrack Henrik Freischlader Trio - Nobody Else To Blame (Openness) + */ +@RunWith(MockitoJUnitRunner.class) +public class EventPublishingRepositoryProxyPostProcessorUnitTests { + + @Mock ApplicationEventPublisher publisher; + @Mock MethodInvocation invocation; + + /** + * @see DATACMNS-928 + */ + @Test(expected = IllegalArgumentException.class) + public void rejectsNullAggregateTypes() { + EventPublishingMethod.of(null); + } + + /** + * @see DATACMNS-928 + */ + @Test + public void publishingEventsForNullIsNoOp() { + EventPublishingMethod.of(OneEvent.class).publishEventsFrom(null, publisher); + } + + /** + * @see DATACMNS-928 + */ + @Test + public void exposesEventsExposedByEntityToPublisher() { + + SomeEvent first = new SomeEvent(); + SomeEvent second = new SomeEvent(); + MultipleEvents entity = MultipleEvents.of(Arrays.asList(first, second)); + + EventPublishingMethod.of(MultipleEvents.class).publishEventsFrom(entity, publisher); + + verify(publisher).publishEvent(eq(first)); + verify(publisher).publishEvent(eq(second)); + } + + /** + * @see DATACMNS-928 + */ + @Test + public void exposesSingleEventByEntityToPublisher() { + + SomeEvent event = new SomeEvent(); + OneEvent entity = OneEvent.of(event); + + EventPublishingMethod.of(OneEvent.class).publishEventsFrom(entity, publisher); + + verify(publisher, times(1)).publishEvent(event); + } + + /** + * @see DATACMNS-928 + */ + @Test + public void doesNotExposeNullEvent() { + + OneEvent entity = OneEvent.of(null); + + EventPublishingMethod.of(OneEvent.class).publishEventsFrom(entity, publisher); + + verify(publisher, times(0)).publishEvent(any()); + } + + /** + * @see DATACMNS-928 + */ + @Test + public void doesNotCreatePublishingMethodIfNoAnnotationDetected() { + assertThat(EventPublishingMethod.of(Object.class), is(nullValue())); + } + + /** + * @see DATACMNS-928 + */ + @Test + public void interceptsSaveMethod() throws Throwable { + + Method saveMethod = SampleRepository.class.getMethod("save", Object.class); + doReturn(saveMethod).when(invocation).getMethod(); + + SomeEvent event = new SomeEvent(); + MultipleEvents sample = MultipleEvents.of(Arrays.asList(event)); + doReturn(new Object[] { sample }).when(invocation).getArguments(); + + new EventPublishingMethodInterceptor(saveMethod, EventPublishingMethod.of(MultipleEvents.class), publisher) + .invoke(invocation); + + verify(publisher).publishEvent(event); + } + + /** + * @see DATACMNS-928 + */ + @Test + public void doesNotInterceptNonSaveMethod() throws Throwable { + + Method saveMethod = SampleRepository.class.getMethod("save", Object.class); + doReturn(SampleRepository.class.getMethod("findOne", Serializable.class)).when(invocation).getMethod(); + + new EventPublishingMethodInterceptor(saveMethod, EventPublishingMethod.of(MultipleEvents.class), publisher) + .invoke(invocation); + + verify(publisher, never()).publishEvent(any()); + } + + /** + * @see DATACMNS-928 + */ + @Test + public void registersAdviceIfDomainTypeExposesEvents() { + + RepositoryInformation information = new DummyRepositoryInformation(SampleRepository.class); + RepositoryProxyPostProcessor processor = new EventPublishingRepositoryProxyPostProcessor(publisher); + + ProxyFactory factory = mock(ProxyFactory.class); + + processor.postProcess(factory, information); + + verify(factory).addAdvice(any(EventPublishingMethodInterceptor.class)); + } + + /** + * @see DATACMNS-928 + */ + @Test + public void doesNotAddAdviceIfDomainTypeDoesNotExposeEvents() { + + RepositoryInformation information = new DummyRepositoryInformation(CrudRepository.class); + RepositoryProxyPostProcessor processor = new EventPublishingRepositoryProxyPostProcessor(publisher); + + ProxyFactory factory = mock(ProxyFactory.class); + + processor.postProcess(factory, information); + + verify(factory, never()).addAdvice(any(Advice.class)); + } + + @Value(staticConstructor = "of") + static class MultipleEvents { + @Getter(onMethod = @__(@DomainEvents)) Collection events; + } + + @Value(staticConstructor = "of") + static class OneEvent { + @Getter(onMethod = @__(@DomainEvents)) Object event; + } + + @Value + static class SomeEvent { + UUID id = UUID.randomUUID(); + } + + interface SampleRepository extends CrudRepository {} +}