diff --git a/spring-modulith-bom/pom.xml b/spring-modulith-bom/pom.xml index b52f42a7..ce581807 100644 --- a/spring-modulith-bom/pom.xml +++ b/spring-modulith-bom/pom.xml @@ -39,6 +39,11 @@ spring-modulith-docs 1.1.0-SNAPSHOT + + org.springframework.modulith + spring-modulith-events-amqp + 1.1.0-SNAPSHOT + org.springframework.modulith spring-modulith-events-core @@ -54,11 +59,21 @@ spring-modulith-events-jdbc 1.1.0-SNAPSHOT + + org.springframework.modulith + spring-modulith-events-jms + 1.1.0-SNAPSHOT + org.springframework.modulith spring-modulith-events-jpa 1.1.0-SNAPSHOT + + org.springframework.modulith + spring-modulith-events-kafka + 1.1.0-SNAPSHOT + org.springframework.modulith spring-modulith-events-mongodb diff --git a/spring-modulith-events/pom.xml b/spring-modulith-events/pom.xml index d6278f71..26c3375a 100644 --- a/spring-modulith-events/pom.xml +++ b/spring-modulith-events/pom.xml @@ -14,12 +14,15 @@ Spring Modulith - Events + spring-modulith-events-amqp spring-modulith-events-api spring-modulith-events-core - spring-modulith-events-jpa - spring-modulith-events-jdbc - spring-modulith-events-mongodb spring-modulith-events-jackson + spring-modulith-events-jdbc + spring-modulith-events-jms + spring-modulith-events-jpa + spring-modulith-events-kafka + spring-modulith-events-mongodb diff --git a/spring-modulith-events/spring-modulith-events-amqp/pom.xml b/spring-modulith-events/spring-modulith-events-amqp/pom.xml new file mode 100644 index 00000000..454ad8e3 --- /dev/null +++ b/spring-modulith-events/spring-modulith-events-amqp/pom.xml @@ -0,0 +1,92 @@ + + + 4.0.0 + + + org.springframework.modulith + spring-modulith-events + 1.1.0-SNAPSHOT + + + Spring Modulith - Events - AMQP support + spring-modulith-events-amqp + + + org.springframework.modulith.events.amqp + + + + + + org.springframework.modulith + spring-modulith-api + ${project.version} + + + + org.springframework.modulith + spring-modulith-events-core + ${project.version} + + + + org.springframework.amqp + spring-amqp + + + + org.springframework.amqp + spring-rabbit + true + + + + com.fasterxml.jackson.core + jackson-databind + true + + + + + + org.springframework.modulith + spring-modulith-starter-jdbc + ${project.version} + test + + + + com.h2database + h2 + test + + + + org.springframework.boot + spring-boot-starter-json + test + + + + org.springframework.boot + spring-boot-starter-test + test + + + + org.springframework.boot + spring-boot-testcontainers + test + + + + org.testcontainers + rabbitmq + test + + + + + diff --git a/spring-modulith-events/spring-modulith-events-amqp/src/main/java/org/springframework/modulith/events/amqp/RabbitEventExternalizerConfiguration.java b/spring-modulith-events/spring-modulith-events-amqp/src/main/java/org/springframework/modulith/events/amqp/RabbitEventExternalizerConfiguration.java new file mode 100644 index 00000000..5106b924 --- /dev/null +++ b/spring-modulith-events/spring-modulith-events-amqp/src/main/java/org/springframework/modulith/events/amqp/RabbitEventExternalizerConfiguration.java @@ -0,0 +1,63 @@ +/* + * Copyright 2023 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 + * + * https://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.modulith.events.amqp; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.amqp.rabbit.core.RabbitMessageOperations; +import org.springframework.amqp.rabbit.core.RabbitTemplate; +import org.springframework.beans.factory.BeanFactory; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.AutoConfigureAfter; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.context.annotation.Bean; +import org.springframework.context.expression.BeanFactoryResolver; +import org.springframework.expression.spel.support.StandardEvaluationContext; +import org.springframework.modulith.events.EventExternalizationConfiguration; +import org.springframework.modulith.events.config.EventExternalizationAutoConfiguration; +import org.springframework.modulith.events.support.BrokerRouting; +import org.springframework.modulith.events.support.DelegatingEventExternalizer; + +/** + * Auto-configuration to set up a {@link DelegatingEventExternalizer} to externalize events to RabbitMQ. + * + * @author Oliver Drotbohm + * @since 1.1 + */ +@AutoConfiguration +@AutoConfigureAfter(EventExternalizationAutoConfiguration.class) +@ConditionalOnClass(RabbitTemplate.class) +class RabbitEventExternalizerConfiguration { + + private static final Logger logger = LoggerFactory.getLogger(RabbitEventExternalizerConfiguration.class); + + @Bean + DelegatingEventExternalizer rabbitEventExternalizer(EventExternalizationConfiguration configuration, + RabbitMessageOperations operations, BeanFactory factory) { + + logger.debug("Registering domain event externalization to RabbitMQ…"); + + var context = new StandardEvaluationContext(); + context.setBeanResolver(new BeanFactoryResolver(factory)); + + return new DelegatingEventExternalizer(configuration, (target, payload) -> { + + var routing = BrokerRouting.of(target, context); + + operations.convertAndSend(routing.getTarget(), routing.getKey(payload), payload); + }); + } +} diff --git a/spring-modulith-events/spring-modulith-events-amqp/src/main/java/org/springframework/modulith/events/amqp/RabbitJacksonConfiguration.java b/spring-modulith-events/spring-modulith-events-amqp/src/main/java/org/springframework/modulith/events/amqp/RabbitJacksonConfiguration.java new file mode 100644 index 00000000..a01082bb --- /dev/null +++ b/spring-modulith-events/spring-modulith-events-amqp/src/main/java/org/springframework/modulith/events/amqp/RabbitJacksonConfiguration.java @@ -0,0 +1,50 @@ +/* + * Copyright 2023 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 + * + * https://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.modulith.events.amqp; + +import org.springframework.amqp.rabbit.core.RabbitTemplate; +import org.springframework.amqp.support.converter.Jackson2JsonMessageConverter; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.amqp.RabbitTemplateCustomizer; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; + +import com.fasterxml.jackson.databind.ObjectMapper; + +/** + * Auto-configuration to configure {@link RabbitTemplate} to use the Jackson {@link ObjectMapper} present in the + * application. + * + * @author Oliver Drotbohm + * @since 1.1 + */ +@AutoConfiguration +@ConditionalOnClass({ RabbitTemplate.class, ObjectMapper.class }) +@ConditionalOnProperty(name = "spring.modulith.events.rabbitmq.enable-json", havingValue = "true", + matchIfMissing = true) +class RabbitJacksonConfiguration { + + @Bean + @ConditionalOnBean(ObjectMapper.class) + RabbitTemplateCustomizer rabbitTemplateCustomizer(ObjectMapper mapper) { + + return template -> { + template.setMessageConverter(new Jackson2JsonMessageConverter(mapper)); + }; + } +} diff --git a/spring-modulith-events/spring-modulith-events-amqp/src/main/resources/META-INF/spring-configuration-metadata.json b/spring-modulith-events/spring-modulith-events-amqp/src/main/resources/META-INF/spring-configuration-metadata.json new file mode 100644 index 00000000..a0fdb66f --- /dev/null +++ b/spring-modulith-events/spring-modulith-events-amqp/src/main/resources/META-INF/spring-configuration-metadata.json @@ -0,0 +1,10 @@ +{ + "properties": [ + { + "name": "spring.modulith.events.rabbitmq.json-enabled", + "type": "java.lang.boolean", + "description": "Whether to auto-configure RabbitTemplate to use JSON for message serialization.", + "defaultValue": "true" + } + ] +} diff --git a/spring-modulith-events/spring-modulith-events-amqp/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/spring-modulith-events/spring-modulith-events-amqp/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 00000000..4e5078f2 --- /dev/null +++ b/spring-modulith-events/spring-modulith-events-amqp/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1,2 @@ +org.springframework.modulith.events.amqp.RabbitEventExternalizerConfiguration +org.springframework.modulith.events.amqp.RabbitJacksonConfiguration diff --git a/spring-modulith-events/spring-modulith-events-amqp/src/test/java/org/springframework/modulith/events/amqp/RabbitEventPublicationIntegrationTests.java b/spring-modulith-events/spring-modulith-events-amqp/src/test/java/org/springframework/modulith/events/amqp/RabbitEventPublicationIntegrationTests.java new file mode 100644 index 00000000..fd4dfe65 --- /dev/null +++ b/spring-modulith-events/spring-modulith-events-amqp/src/test/java/org/springframework/modulith/events/amqp/RabbitEventPublicationIntegrationTests.java @@ -0,0 +1,111 @@ +/* + * Copyright 2023 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 + * + * https://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.modulith.events.amqp; + +import static org.assertj.core.api.Assertions.*; + +import lombok.RequiredArgsConstructor; + +import org.junit.jupiter.api.Test; +import org.springframework.amqp.core.Binding; +import org.springframework.amqp.core.BindingBuilder; +import org.springframework.amqp.core.FanoutExchange; +import org.springframework.amqp.core.Queue; +import org.springframework.amqp.rabbit.core.RabbitAdmin; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.testcontainers.service.connection.ServiceConnection; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.context.annotation.Bean; +import org.springframework.modulith.ApplicationModuleListener; +import org.springframework.modulith.events.Externalized; +import org.springframework.transaction.annotation.Transactional; +import org.testcontainers.containers.RabbitMQContainer; +import org.testcontainers.utility.DockerImageName; + +/** + * Integration tests for RabbitMQ-based event publication. + * + * @author Oliver Drotbohm + */ +@SpringBootTest +class RabbitEventPublicationIntegrationTests { + + @Autowired TestPublisher publisher; + @Autowired RabbitAdmin rabbit; + + @SpringBootApplication + static class TestConfiguration { + + @Bean + @ServiceConnection + RabbitMQContainer rabbitMqContainer() { + return new RabbitMQContainer(DockerImageName.parse("rabbitmq")); + } + + @Bean + TestPublisher testPublisher(ApplicationEventPublisher publisher) { + return new TestPublisher(publisher); + } + + @Bean + TestListener testListener() { + return new TestListener(); + } + } + + @Test + void publishesEventToRabbitMq() throws Exception { + + var target = new FanoutExchange("target"); + rabbit.declareExchange(target); + + var queue = new Queue("queue"); + rabbit.declareQueue(queue); + + Binding binding = BindingBuilder.bind(queue).to(target); + rabbit.declareBinding(binding); + + publisher.publishEvent(); + + Thread.sleep(200); + + var info = rabbit.getQueueInfo("queue"); + + assertThat(info.getMessageCount()).isEqualTo(1); + } + + @Externalized("target") + static class TestEvent {} + + @RequiredArgsConstructor + static class TestPublisher { + + private final ApplicationEventPublisher events; + + @Transactional + void publishEvent() { + events.publishEvent(new TestEvent()); + } + } + + static class TestListener { + + @ApplicationModuleListener + void on(TestEvent event) {} + } +} diff --git a/spring-modulith-events/spring-modulith-events-amqp/src/test/resources/application.properties b/spring-modulith-events/spring-modulith-events-amqp/src/test/resources/application.properties new file mode 100644 index 00000000..62b9893f --- /dev/null +++ b/spring-modulith-events/spring-modulith-events-amqp/src/test/resources/application.properties @@ -0,0 +1,2 @@ +spring.artemis.embedded.topics=target +spring.modulith.events.jdbc.schema-initialization.enabled=true diff --git a/spring-modulith-events/spring-modulith-events-amqp/src/test/resources/logback.xml b/spring-modulith-events/spring-modulith-events-amqp/src/test/resources/logback.xml new file mode 100644 index 00000000..572b9716 --- /dev/null +++ b/spring-modulith-events/spring-modulith-events-amqp/src/test/resources/logback.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/spring-modulith-events/spring-modulith-events-api/pom.xml b/spring-modulith-events/spring-modulith-events-api/pom.xml index f30568fa..81954119 100644 --- a/spring-modulith-events/spring-modulith-events-api/pom.xml +++ b/spring-modulith-events/spring-modulith-events-api/pom.xml @@ -30,6 +30,18 @@ spring-context + + org.jmolecules + jmolecules-events + true + + + + org.springframework + spring-test + test + + diff --git a/spring-modulith-events/spring-modulith-events-api/src/main/java/org/springframework/modulith/events/AnnotationTargetLookup.java b/spring-modulith-events/spring-modulith-events-api/src/main/java/org/springframework/modulith/events/AnnotationTargetLookup.java new file mode 100644 index 00000000..6ae283ee --- /dev/null +++ b/spring-modulith-events/spring-modulith-events-api/src/main/java/org/springframework/modulith/events/AnnotationTargetLookup.java @@ -0,0 +1,177 @@ +/* + * Copyright 2023 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 + * + * https://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.modulith.events; + +import static org.springframework.core.annotation.AnnotatedElementUtils.*; + +import java.lang.annotation.Annotation; +import java.util.Arrays; +import java.util.Map; +import java.util.Optional; +import java.util.function.Function; +import java.util.function.Predicate; +import java.util.function.Supplier; + +import org.springframework.modulith.events.RoutingTarget.ParsedRoutingTarget; +import org.springframework.util.Assert; +import org.springframework.util.ClassUtils; +import org.springframework.util.ConcurrentReferenceHashMap; + +/** + * An annotation based target lookup strategy to enable caching of the function lookups that involve classpath checks. + * The currently supported annotations are: + * + * + * @author Oliver Drotbohm + * @since 1.1 + */ +class AnnotationTargetLookup implements Supplier> { + + private static Map, AnnotationTargetLookup> LOOKUPS = new ConcurrentReferenceHashMap<>(25); + private static final String JMOLECULES_EXTERNALIZED = "org.jmolecules.event.annotation.Externalized"; + private static final Class JMOLECULES_ANNOTATION = loadJMoleculesExternalizedIfPresent(); + + private final Class type; + private final Supplier> lookup; + + /** + * Creates a new {@link AnnotationTargetLookup} for the given type. + * + * @param type must not be {@literal null}. + */ + private AnnotationTargetLookup(Class type) { + + Assert.notNull(type, "Type must not be null!"); + + this.type = type; + this.lookup = firstMatching(fromJMoleculesExternalized(), fromModulithExternalized()); + } + + /** + * Returns the {@link AnnotationTargetLookup} for the given type. + * + * @param type must not be {@literal null}. + * @return will never be {@literal null}. + */ + static AnnotationTargetLookup of(Class type) { + return LOOKUPS.computeIfAbsent(type, AnnotationTargetLookup::new); + } + + /** + * Returns whether the given event is annotated with a supported {@code Externalized} annotation. + * + * @param event must not be {@literal null}. + */ + static boolean hasExternalizedAnnotation(Object event) { + + Assert.notNull(event, "Event must not be null!"); + + var type = event.getClass(); + + return hasAnnotation(type, Externalized.class) + || JMOLECULES_ANNOTATION != null && hasAnnotation(type, JMOLECULES_ANNOTATION); + } + + /* + * (non-Javadoc) + * @see java.util.function.Supplier#get() + */ + @Override + public Optional get() { + return lookup.get(); + } + + /** + * Creates a {@link Supplier} to lookup the target from Spring Modulith's {@link Externalized} annotation. + * + * @return will never be {@literal null}. + */ + private Supplier> fromModulithExternalized() { + return () -> lookupTarget(Externalized.class, Externalized::target); + } + + /** + * Creates a {@link Supplier} to lookup the target from jMolecules + * {@link org.jmolecules.event.annotation.Externalized} annotation if present on the classpath. + * + * @return will never be {@literal null}. + */ + private Supplier> fromJMoleculesExternalized() { + + return JMOLECULES_ANNOTATION == null + ? () -> Optional.empty() + : () -> lookupTarget(org.jmolecules.event.annotation.Externalized.class, + org.jmolecules.event.annotation.Externalized::target, + org.jmolecules.event.annotation.Externalized::value); + } + + /** + * Returns a new {@link Function} that chains the given lookup functions until one returns a non-empty + * {@link Optional}. + * + * @param functions must not be {@literal null}. + * @return will never be {@literal null}. + */ + @SafeVarargs + private Supplier> firstMatching( + Supplier>... functions) { + + return () -> Arrays.stream(functions) + .reduce(Optional.empty(), (current, function) -> current.or(() -> function.get()), (l, r) -> r); + } + + /** + * Looks up the target from the given annotation applying the given extractors aborting if a non-empty {@link String} + * is found. + * + * @param the annotation type + * @param annotation must not be {@literal null}. + * @param extractors must not be {@literal null}. + * @return will never be {@literal null}. + */ + @SafeVarargs + private Optional lookupTarget(Class annotation, + Function... extractors) { + + return Optional.ofNullable(findMergedAnnotation(type, annotation)) + .stream() + .flatMap(it -> Arrays.stream(extractors) + .map(function -> function.apply(it)) + .filter(Predicate.not(String::isBlank))) + .findFirst() + .map(RoutingTarget::parse); + } + + @SuppressWarnings("unchecked") + private static Class loadJMoleculesExternalizedIfPresent() { + + var classLoader = DefaultEventExternalizationConfiguration.class.getClassLoader(); + + if (ClassUtils.isPresent(JMOLECULES_EXTERNALIZED, classLoader)) { + + try { + return (Class) ClassUtils.forName(JMOLECULES_EXTERNALIZED, classLoader); + } catch (Exception o_O) { + return null; + } + } + + return null; + } +} diff --git a/spring-modulith-events/spring-modulith-events-api/src/main/java/org/springframework/modulith/events/DefaultEventExternalizationConfiguration.java b/spring-modulith-events/spring-modulith-events-api/src/main/java/org/springframework/modulith/events/DefaultEventExternalizationConfiguration.java new file mode 100644 index 00000000..075dfb51 --- /dev/null +++ b/spring-modulith-events/spring-modulith-events-api/src/main/java/org/springframework/modulith/events/DefaultEventExternalizationConfiguration.java @@ -0,0 +1,98 @@ +/* + * Copyright 2023 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 + * + * https://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.modulith.events; + +import java.util.function.Function; +import java.util.function.Predicate; + +import org.springframework.util.Assert; + +/** + * Default implementation of {@link EventExternalizationConfiguration}. + * + * @author Oliver Drotbohm + * @since 1.1 + */ +class DefaultEventExternalizationConfiguration implements EventExternalizationConfiguration { + + private final Predicate filter; + private final Function mapper; + private final Function router; + + /** + * Creates a new {@link DefaultEventExternalizationConfiguration} + * + * @param filter must not be {@literal null}. + * @param mapper must not be {@literal null}. + * @param router must not be {@literal null}. + */ + DefaultEventExternalizationConfiguration(Predicate filter, Function mapper, + Function router) { + + Assert.notNull(filter, "Filter must not be null!"); + Assert.notNull(mapper, "Mapper must not be null!"); + Assert.notNull(router, "Router must not be null!"); + + this.filter = filter; + this.mapper = mapper; + this.router = router; + } + + /** + * Returns a new {@link Selector} instance to build up a new configuration. + * + * @return will never be {@literal null}. + */ + static Selector builder() { + return new Selector(); + } + + /* + * (non-Javadoc) + * @see org.springframework.modulith.events.EventExternalizationConfiguration#supports(java.lang.Object) + */ + @Override + public boolean supports(Object event) { + + Assert.notNull(event, "Event must not be null!"); + + return filter.test(event); + } + + /* + * (non-Javadoc) + * @see org.springframework.modulith.events.EventExternalizationConfiguration#map(java.lang.Object) + */ + @Override + public Object map(Object event) { + + Assert.notNull(event, "Event must not be null!"); + + return mapper.apply(event); + } + + /* + * (non-Javadoc) + * @see org.springframework.modulith.events.EventExternalizationConfiguration#determineTarget(java.lang.Object) + */ + @Override + public RoutingTarget determineTarget(Object event) { + + Assert.notNull(event, "Event must not be null!"); + + return router.apply(event).verify(); + } +} diff --git a/spring-modulith-events/spring-modulith-events-api/src/main/java/org/springframework/modulith/events/EventExternalizationConfiguration.java b/spring-modulith-events/spring-modulith-events-api/src/main/java/org/springframework/modulith/events/EventExternalizationConfiguration.java new file mode 100644 index 00000000..85287038 --- /dev/null +++ b/spring-modulith-events/spring-modulith-events-api/src/main/java/org/springframework/modulith/events/EventExternalizationConfiguration.java @@ -0,0 +1,548 @@ +/* + * Copyright 2023 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 + * + * https://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.modulith.events; + +import static org.springframework.core.annotation.AnnotatedElementUtils.*; + +import java.lang.annotation.Annotation; +import java.util.Collection; +import java.util.Optional; +import java.util.function.BiFunction; +import java.util.function.BiPredicate; +import java.util.function.Function; +import java.util.function.Predicate; + +import org.springframework.core.annotation.AnnotatedElementUtils; +import org.springframework.lang.Nullable; +import org.springframework.modulith.events.RoutingTarget.ParsedRoutingTarget; +import org.springframework.modulith.events.RoutingTarget.RoutingTargetBuilder; +import org.springframework.util.Assert; + +/** + * Configuration for externalizing application events to messaging infrastructure. + * + * @author Oliver Drotbohm + * @since 1.1 + * @see #externalizing() + */ +public interface EventExternalizationConfiguration { + + /** + * Creates a default {@link DefaultEventExternalizationConfiguration} with the following characteristics: + *
    + *
  • Only events that reside in any of the given packages and that are annotated with any supported + * {@code Externalized} annotation will be considered.
  • + *
  • Routing information is discovered from the {code Externalized} annotation and, if missing, will default to the + * application-local name of the event type. In other words, an event type {@code com.acme.myapp.mymodule.MyEvent} + * will result in a route {@code mymodule.MyEvent}.
  • + *
+ * + * @param packages must not be {@literal null} or empty. + * @return will never be {@literal null}. + * @see Externalized + */ + public static Router defaults(Collection packages) { + + Assert.notEmpty(packages, "Packages must not be null or empty!"); + + Function router = it -> Optional.of(it) + .flatMap(byApplicationLocalName(packages)::apply) + .map(target -> mergeWithExternalizedAnnotation(it, target)) + .orElseGet(() -> byFullyQualifiedTypeName().apply(it)); + + return DefaultEventExternalizationConfiguration.builder() + .selectByPackagesAndFilter(packages, AnnotationTargetLookup::hasExternalizedAnnotation) + .routeAll(router); + } + + /** + * Creates a new {@link Selector} to define which events to externalize. + * + * @return will never be {@literal null}. + */ + public static Selector externalizing() { + return new Selector(); + } + + /** + * A {@link Predicate} to select all events annotated as to be externalized. The currently supported annotations are: + *
    + *
  • Spring Modulith's {@link Externalized}
  • + *
  • jMolecules {@link org.jmolecules.event.annotation.Externalized} (if present on the classpath)
  • + *
+ * + * @return will never be {@literal null}. + */ + public static Predicate annotatedAsExternalized() { + return event -> AnnotationTargetLookup.hasExternalizedAnnotation(event); + } + + /** + * Creates a new routing that uses the application-local type name as target + * + * @param packages must not be {@literal null}. + * @return will never be {@literal null}. + */ + public static Function> byApplicationLocalName(Collection packages) { + + Assert.notNull(packages, "Application packages must not be null!"); + + return toEventType().andThen(type -> packages.stream() + .filter(it -> type.getPackageName().startsWith(it)) + .map(it -> type.getName().substring(it.length() + 1)) + .findFirst() + .map(RoutingTarget::forTarget) + .map(RoutingTargetBuilder::withoutKey)); + } + + /** + * Returns a {@link Function} that looks up the target from the supported externalization annotations. The currently + * supported annotations are: + *
    + *
  • Spring Modulith's {@link Externalized}
  • + *
  • jMolecules {@link org.jmolecules.event.annotation.Externalized} (if present on the classpath)
  • + *
+ * + * @return will never be {@literal null}. + */ + public static Function> byExternalizedAnnotations() { + return event -> AnnotationTargetLookup.of(event.getClass()).get(); + } + + /** + * Returns a {@link Function} that looks up the target from the fully-qualified type name of the event's type. + * + * @return will never be {@literal null}. + */ + public static Function byFullyQualifiedTypeName() { + + return toEventType() + .andThen(Class::getName) + .andThen(it -> RoutingTarget.forTarget(it).withoutKey()); + } + + /** + * Whether the configuration supports the given event. In other words, whether the given event is supposed to be + * externalized in the first place. + * + * @param event must not be {@literal null}. + * @return whether to externalize the given event. + */ + boolean supports(Object event); + + /** + * Map the event to be externalized before publishing it. + * + * @param event must not be {@literal null}. + * @return the mapped event. + */ + Object map(Object event); + + /** + * Determines the {@link RoutingTarget} for the given event based on the current configuration. + * + * @param event must not be {@literal null}. + * @return will never be {@literal null}. + */ + RoutingTarget determineTarget(Object event); + + /** + * API to define which events are supposed to be selected for externalization. + * + * @author Oliver Drotbohm + * @since 1.1 + */ + public static class Selector { + + private static final Predicate DEFAULT_FILTER = it -> true; + + private final @Nullable Predicate predicate; + + /** + * Creates a new {@link Selector}. + */ + Selector() { + this.predicate = DEFAULT_FILTER; + } + + /** + * Selects events to externalize by applying the given {@link Predicate}. + * + * @param predicate will never be {@literal null}. + * @return will never be {@literal null}. + */ + public Router select(Predicate predicate) { + return new Router(predicate); + } + + /** + * Selects events to externalize by the given base package and all sub-packages. + * + * @param basePackage must not be {@literal null} or empty. + * @return will never be {@literal null}. + */ + public Router selectByPackage(String basePackage) { + + Assert.hasText(basePackage, "Base package must not be null or empty!"); + + return select(it -> it.getClass().getPackageName().startsWith(basePackage)); + } + + /** + * Selects events to externalize by the package of the given type and all sub-packages. + * + * @param type must not be {@literal null}. + * @return will never be {@literal null}. + */ + public Router selectByPackage(Class type) { + + Assert.notNull(type, "Type must not be null!"); + + return selectByPackage(type.getPackageName()); + } + + /** + * Selects events to externalize by the given base packages (and their sub-packages) that match the given filter + * {@link Predicate}. + * + * @param basePackages must not be {@literal null} or empty. + * @param filter must not be {@literal null}. + * @return will never be {@literal null}. + */ + public Router selectByPackagesAndFilter(Collection basePackages, + Predicate filter) { + + Assert.notEmpty(basePackages, "Base packages must not be null or empty!"); + Assert.notNull(filter, "Filter must not be null!"); + + BiPredicate matcher = (event, reference) -> event.getClass().getPackageName() + .startsWith(reference); + Predicate residesInPackage = it -> basePackages.stream().anyMatch(inner -> matcher.test(it, inner)); + + return select(residesInPackage.and(filter)); + } + + /** + * Selects events to be externalized by inspecting the event type for the given annotation. + * + * @param type the annotation type to find on the event type, must not be {@literal null}. + * @return will never be {@literal null}. + */ + public Router selectByAnnotation(Class type) { + + Assert.notNull(type, "Annotation type must not be null!"); + + return select(it -> AnnotatedElementUtils.hasAnnotation(it.getClass(), type)); + } + + /** + * Selects events to be externalized by type. + * + * @param type the type that events to be externalized need to implement, must not be {@literal null}. + * @return will never be {@literal null}. + */ + public Router selectByType(Class type) { + + Assert.notNull(type, "Type must not be null!"); + + return select(type::isInstance); + } + + /** + * Selects events to be externalized by the given {@link Predicate}. + * + * @param predicate must not be {@literal null}. + * @return will never be {@literal null}. + */ + public Router selectByType(Predicate> predicate) { + + Assert.notNull(predicate, "Predicate must not be null!"); + + return select(it -> predicate.test(it.getClass())); + } + + /** + * Selects events by the presence of an annotation of the given type and routes based on the given router + * {@link Function}. + * + * @param the annotation type. + * @param annotationType the annotation type, must not be {@literal null}. + * @param router must not be {@literal null}. + * @return will never be {@literal null}. + */ + public Router selectAndRoute(Class annotationType, + Function router) { + + Assert.notNull(annotationType, "Annotation type must not be null!"); + Assert.notNull(router, "Router must not be null!"); + + Function extractor = it -> findAnnotation(it, annotationType); + + return selectByAnnotation(annotationType) + .routeAll(it -> extractor + .andThen(router) + .andThen(RoutingTarget::parse) + .andThen(target -> target.withFallback(byFullyQualifiedTypeName().apply(it))) + .apply(it)); + } + + /** + * Selects events by the presence of an annotation of the given type and routes based on the given router + * {@link BiFunction} that also gets the event type to build up a complete {@link RoutingTarget}. + * + * @param the annotation type. + * @param annotationType must not be {@literal null}. + * @param router must not be {@literal null}. + * @return will never be {@literal null}. + */ + public Router selectAndRoute(Class annotationType, + BiFunction router) { + + Assert.notNull(annotationType, "Annotation type must not be null!"); + Assert.notNull(router, "Router must not be null!"); + + return selectByAnnotation(annotationType) + .routeAll(it -> router.apply(it, findAnnotation(it, annotationType))); + } + + private static T findAnnotation(Object event, Class annotationType) { + return findMergedAnnotation(event.getClass(), annotationType); + } + } + + /** + * API to define the event routing. + * + * @author Oliver Drotbohm + * @since 1.1 + */ + public static class Router { + + private static final Function DEFAULT_ROUTER = it -> { + return mergeWithExternalizedAnnotation(it, byFullyQualifiedTypeName().apply(it)); + }; + + private final Predicate filter; + private final Function mapper; + private final Function router; + + /** + * Creates a new {@link Router} for the given selector {@link Predicate} and mapper and router {@link Function}s. + * + * @param filter must not be {@literal null}. + * @param mapper must not be {@literal null}. + * @param router must not be {@literal null}. + */ + Router(Predicate filter, Function mapper, Function router) { + + Assert.notNull(filter, "Selector must not be null!"); + Assert.notNull(mapper, "Mapper must not be null!"); + Assert.notNull(router, "Router must not be null!"); + + this.filter = filter; + this.mapper = mapper; + this.router = router; + } + + /** + * Creates a new {@link Router} for the given selector filter. + * + * @param filter must not be {@literal null}. + */ + Router(Predicate filter) { + this(filter, Function.identity(), DEFAULT_ROUTER); + } + + /** + * Registers a new mapping {@link Function} replacing the old one entirely. + * + * @param mapper must not be {@literal null}. + * @return will never be {@literal null}. + * @see #mapping(Class, Function) + */ + public Router mapping(Function mapper) { + + Assert.notNull(mapper, "Mapper must not be null!"); + + return new Router(filter, mapper, router); + } + + /** + * Registers a type-specific mapping function. Events not matching that type will still fall back to the global + * mapping function defined. + * + * @param the type to handle. + * @param type the type to handle, must not be {@literal null}. + * @param mapper the mapping function, must not be {@literal null}. + * @return will never be {@literal null}. + * @see #mapping(Function) + */ + public Router mapping(Class type, Function mapper) { + + Assert.notNull(type, "Type must not be null!"); + Assert.notNull(mapper, "Mapper must not be null!"); + + Function combined = it -> toOptional(type, it) + .map(mapper::apply) + .orElse(it); + + return new Router(filter, this.mapper.compose(combined), router); + } + + /** + * Configures the routing to rather use the mapping result rather than the original event instance. + * + * @return will never be {@literal null}. + */ + public Router routeMapped() { + return new Router(filter, mapper, router.compose(mapper)); + } + + /** + * Routes all events based on the given function. + * + * @param router must not be {@literal null}. + * @return will never be {@literal null}. + */ + public Router routeAll(Function router) { + + Assert.notNull(router, "Router must not be null!"); + + return new Router(filter, mapper, router); + } + + /** + * Registers a router function for the events of the given specific type. + * + * @param the type to handle + * @param type must not be {@literal null}. + * @param router must not be {@literal null}. + * @return will never be {@literal null}. + */ + public Router route(Class type, Function router) { + + Assert.notNull(type, "Type must not be null!"); + Assert.notNull(router, "Router must not be null!"); + + return new Router(filter, mapper, it -> toOptional(type, it) + .map(router::apply) + .orElseGet(() -> this.router.apply(it))); + } + + /** + * Registers a {@link BiFunction} to resolve the key for a {@link RoutingTarget} based on the event instance. The + * actual target will have been resolved through the currently configured, global router. To dynamically define + * full, type-specific resolution of a {@link RoutingTarget}, see {@link #route(Class, Function)}. + * + * @param the type to handle. + * @param type the type to configure the key extraction for, must not be {@literal null}. + * @param extractor the key extractor, must not be {@literal null}. + * @return will never be {@literal null}. + * @see #route(Class, Function) + */ + public Router routeKey(Class type, Function extractor) { + + Assert.notNull(type, "Type must not be null!"); + Assert.notNull(extractor, "Extractor must not be null!"); + + return new Router(filter, mapper, it -> toOptional(type, it) + .map(t -> this.router.apply(t).withKey(extractor.apply(t))) + .orElseGet(() -> this.router.apply(it))); + } + + /** + * Routes by extracting an {@link Optional} route from the event. If {@link Optional#empty()} is returned by the + * function, we will fall back to the configured default routing. + * + * @param router must not be {@literal null}. + * @return will never be {@literal null}. + */ + public EventExternalizationConfiguration routeOptional(Function> router) { + + Assert.notNull(router, "Router must not be null!"); + + return new Router(filter, mapper, it -> router.apply(it) + .orElseGet(() -> this.router.apply(it))) + .build(); + } + + /** + * Routes by extracting an {@link Optional} route from the event type. If {@link Optional#empty()} is returned by + * the function, we will fall back to the configured general routing. + * + * @param router must not be {@literal null}. + * @return will never be {@literal null}. + */ + public EventExternalizationConfiguration routeOptionalByType( + Function, Optional> router) { + + Assert.notNull(router, "Router must not be null!"); + + return routeOptional(it -> router.apply(it.getClass())); + } + + /** + * Routes all messages based on the event type only. + * + * @param router must not be {@literal null}. + * @return will never be {@literal null}. + */ + public Router routeAllByType(Function, RoutingTarget> router) { + + Assert.notNull(router, "Router must not be null!"); + + return new Router(filter, mapper, it -> router.apply(it.getClass())); + } + + /** + * Creates a new {@link EventExternalizationConfiguration} refelcting the current configuration. + * + * @return will never be {@literal null}. + */ + public EventExternalizationConfiguration build() { + return new DefaultEventExternalizationConfiguration(filter, mapper, router); + } + + private static Optional toOptional(Class type, Object source) { + + return Optional.of(source) + .filter(type::isInstance) + .map(type::cast); + } + } + + /** + * Detects standard {@code Externalized} annotations on the given event and applies the given {@link RoutingTarget}'s + * target to the one found in the annotation, if the latter does not declare a target itself. + * + * @param event must not be {@literal null}. + * @param target must not be {@literal null}. + * @return will never be {@literal null}. + */ + private static RoutingTarget mergeWithExternalizedAnnotation(Object event, RoutingTarget target) { + + Assert.notNull(event, "Event must not be null!"); + Assert.notNull(target, "RoutingTarget must not be null!"); + + return byExternalizedAnnotations().apply(event) + .map(it -> it.withFallback(target)) + .orElse(target); + } + + private static Function> toEventType() { + return event -> event.getClass(); + } +} diff --git a/spring-modulith-events/spring-modulith-events-api/src/main/java/org/springframework/modulith/events/EventPublication.java b/spring-modulith-events/spring-modulith-events-api/src/main/java/org/springframework/modulith/events/EventPublication.java index dda8118b..f07e01b7 100644 --- a/spring-modulith-events/spring-modulith-events-api/src/main/java/org/springframework/modulith/events/EventPublication.java +++ b/spring-modulith-events/spring-modulith-events-api/src/main/java/org/springframework/modulith/events/EventPublication.java @@ -40,7 +40,7 @@ public interface EventPublication { /** * Returns the event that is published. * - * @return + * @return will never be {@literal null}. */ Object getEvent(); @@ -48,7 +48,7 @@ public interface EventPublication { * Returns the event as Spring {@link ApplicationEvent}, effectively wrapping it into a * {@link PayloadApplicationEvent} in case it's not one already. * - * @return + * @return the underlying event as {@link ApplicationEvent}. */ default ApplicationEvent getApplicationEvent() { @@ -62,7 +62,7 @@ public interface EventPublication { /** * Returns the time the event is published at. * - * @return + * @return will never be {@literal null}. */ Instant getPublicationDate(); @@ -86,6 +86,7 @@ public interface EventPublication { * (non-Javadoc) * @see java.lang.Comparable#compareTo(java.lang.Object) */ + @SuppressWarnings("javadoc") default int compareTo(EventPublication that) { return this.getPublicationDate().compareTo(that.getPublicationDate()); } diff --git a/spring-modulith-events/spring-modulith-events-api/src/main/java/org/springframework/modulith/events/Externalized.java b/spring-modulith-events/spring-modulith-events-api/src/main/java/org/springframework/modulith/events/Externalized.java new file mode 100644 index 00000000..f0ac4fce --- /dev/null +++ b/spring-modulith-events/spring-modulith-events-api/src/main/java/org/springframework/modulith/events/Externalized.java @@ -0,0 +1,52 @@ +/* + * Copyright 2023 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 + * + * https://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.modulith.events; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import org.springframework.core.annotation.AliasFor; + +/** + * Marks domain events as to be externalized. In other words, as to be published to external infrastructure. + * + * @author Oliver Drotbohm + * @since 1.1 + */ +@Documented +@Retention(RetentionPolicy.RUNTIME) +@Target({ ElementType.TYPE }) +public @interface Externalized { + + /** + * The logical target name. Will default to a strategy defined by configuration if empty. + * + * @see #target() + */ + @AliasFor("target") + String value() default ""; + + /** + * The logical target name. Will default to a strategy defined by configuration if empty. + * + * @see #value() + */ + @AliasFor("value") + String target() default ""; +} diff --git a/spring-modulith-events/spring-modulith-events-api/src/main/java/org/springframework/modulith/events/RoutingTarget.java b/spring-modulith-events/spring-modulith-events-api/src/main/java/org/springframework/modulith/events/RoutingTarget.java new file mode 100644 index 00000000..dfc4a768 --- /dev/null +++ b/spring-modulith-events/spring-modulith-events-api/src/main/java/org/springframework/modulith/events/RoutingTarget.java @@ -0,0 +1,297 @@ +/* + * Copyright 2023 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 + * + * https://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.modulith.events; + +import java.util.Objects; + +import org.springframework.lang.Nullable; +import org.springframework.util.Assert; +import org.springframework.util.StringUtils; + +/** + * A {@link String}-based routing target that supports a {@code ::} delimiter to separate the sole target from an + * additional key. The key itself could be of any format and might be subject for deeper inspection downstream. + * + * @author Oliver Drotbohm + * @since 1.1 + */ +public class RoutingTarget { + + private final String target; + private final @Nullable String key; + + /** + * Creates a new {@link RoutingTarget} for the given target and key. + * + * @param target must not be {@literal null} or empty. + * @param key must not be {@literal null}. + */ + private RoutingTarget(String target, @Nullable String key) { + + Assert.hasText(target, "Target must not be null or empty!"); + + this.target = target; + this.key = key; + } + + /** + * Creates a new {@link ParsedRoutingTarget} by parsing the given source. + * + * @param source must not be {@literal null}. + * @return will never be {@literal null}. + */ + static ParsedRoutingTarget parse(String source) { + + Assert.notNull(source, "Routing target source must not be null!"); + + var parts = source.split("::", 2); + var target = parts[0].isBlank() ? null : parts[0]; + var key = parts.length == 2 ? parts[1] : null; + + return new ParsedRoutingTarget(target, key); + } + + /** + * Creates a new {@link RoutingTargetBuilder} for the given target. + * + * @param target must not be {@literal null} or empty. + * @return will never be {@literal null}. + */ + public static RoutingTargetBuilder forTarget(String target) { + return new RoutingTargetBuilder(target); + } + + /** + * An intermediary to ultimately create {@link RoutingTarget} instances. + * + * @author Oliver Drotbohm + * @since 1.1 + */ + public static class RoutingTargetBuilder { + + private final String target; + + /** + * Creates a new {@link RoutingTargetBuilder} for the given target. + * + * @param target will never be {@literal null} or empty. + */ + private RoutingTargetBuilder(String target) { + + Assert.hasText(target, "Target must not be null or empty!"); + + this.target = target; + } + + /** + * Returns a new {@link RoutingTarget} with the already configured target and the given key. + * + * @param key must not be {@literal null}. + * @return will never be {@literal null}. + */ + public RoutingTarget andKey(String key) { + return new RoutingTarget(target, key); + } + + /** + * Returns a new {@link RoutingTarget} without a key. + * + * @return will never be {@literal null}. + */ + public RoutingTarget withoutKey() { + return new RoutingTarget(target, null); + } + } + + /** + * Returns the routing target. + * + * @return will never be {@literal null}. + */ + public String getTarget() { + return target; + } + + /** + * Returns the routing key. + * + * @return can be {@literal null}. + */ + @Nullable + public String getKey() { + return key; + } + + /** + * Returns whether the routing key is a SpEL expression. + * + * @return whether the routing key is a SpEL expression. + */ + public boolean hasKeyExpression() { + return key != null && key.startsWith("#{"); + } + + RoutingTarget withTarget(String target) { + return new RoutingTarget(target, key); + } + + /** + * Creates a new {@link RoutingTarget} with the same target but the given routing key. + * + * @param key can be {@literal null}. + * @return will never be {@literal null}. + */ + RoutingTarget withKey(@Nullable String key) { + return new RoutingTarget(target, key); + } + + RoutingTarget verify() { + + if (target.isBlank()) { + throw new IllegalStateException( + "No target set! Make sure your externalization configuration always produces a target!"); + } + + return this; + } + + /* + * (non-Javadoc) + * @see java.lang.Object#toString() + */ + @Override + public String toString() { + return target + "::" + (key == null ? "" : key); + } + + /* + * (non-Javadoc) + * @see java.lang.Object#equals(java.lang.Object) + */ + @Override + public boolean equals(@Nullable Object obj) { + + if (this == obj) { + return true; + } + + if (!(obj instanceof RoutingTarget that)) { + return false; + } + + return Objects.equals(this.target, that.target) + && Objects.equals(this.key, that.key); + } + + /* + * (non-Javadoc) + * @see java.lang.Object#hashCode() + */ + @Override + public int hashCode() { + return Objects.hash(this.target, this.key); + } + + /** + * A parsed routing target that can have {@literal null} target and key values. It can be turned into a constrained + * {@link RoutingTarget} by either explicitly expecting it to be valid (see {@link #toRoutingTarget()} or by providing + * a fallback {@link RoutingTarget} that would be used to fill in the blanks. + * + * @author Oliver Drotbohm + * @since 1.1 + * @see #toRoutingTarget() + * @see #withFallback(RoutingTarget) + */ + static class ParsedRoutingTarget { + + private final @Nullable String target, key; + + ParsedRoutingTarget(@Nullable String target, @Nullable String key) { + this.target = target; + this.key = key; + } + + /** + * @return the target + */ + public String getTarget() { + return target; + } + + /** + * @return the key + */ + public String getKey() { + return key; + } + + RoutingTarget toRoutingTarget() { + + if (!StringUtils.hasText(target)) { + throw new IllegalStateException("Routing target must not be empty!"); + } + + return new RoutingTarget(target, key); + } + + RoutingTarget withFallback(RoutingTarget fallback) { + + var newTarget = StringUtils.hasText(target) ? target : fallback.getTarget(); + + return new RoutingTarget(newTarget, key != null ? key : fallback.getKey()); + } + + /* + * (non-Javadoc) + * @see java.lang.Object#equals(java.lang.Object) + */ + @Override + public boolean equals(Object obj) { + + if (this == obj) { + return true; + } + + if (!(obj instanceof ParsedRoutingTarget that)) { + return false; + } + + return Objects.equals(this.target, that.target) + && Objects.equals(this.key, that.key); + } + + /* + * (non-Javadoc) + * @see java.lang.Object#hashCode() + */ + @Override + public int hashCode() { + return Objects.hash(target, key); + } + + /* + * (non-Javadoc) + * @see java.lang.Object#toString() + */ + @Override + public String toString() { + + return (target != null ? target : "") + .concat("::") + .concat(key != null ? key : ""); + } + } +} diff --git a/spring-modulith-events/spring-modulith-events-api/src/test/java/org/springframework/modulith/events/AnnotationTargetLookupUnitTests.java b/spring-modulith-events/spring-modulith-events-api/src/test/java/org/springframework/modulith/events/AnnotationTargetLookupUnitTests.java new file mode 100644 index 00000000..4c038e0f --- /dev/null +++ b/spring-modulith-events/spring-modulith-events-api/src/test/java/org/springframework/modulith/events/AnnotationTargetLookupUnitTests.java @@ -0,0 +1,127 @@ +/* + * Copyright 2023 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 + * + * https://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.modulith.events; + +import static org.assertj.core.api.Assertions.*; + +import java.util.HashMap; +import java.util.Map; +import java.util.stream.Stream; + +import org.jmolecules.event.annotation.Externalized; +import org.junit.jupiter.api.DynamicTest; +import org.junit.jupiter.api.Named; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestFactory; +import org.springframework.modulith.events.RoutingTarget.ParsedRoutingTarget; +import org.springframework.test.util.ReflectionTestUtils; + +/** + * Unit tests for {@link AnnotationTargetLookup}. + * + * @author Oliver Drotbohm + * @since 1.1 + */ +class AnnotationTargetLookupUnitTests { + + @TestFactory // GH-248 + Stream detectesModulithExternalizedTarget() { + + var tests = Stream.of( + new $(Unannotated.class, null), + new $(WithJMoleculesExternalizedValue.class, RoutingTarget.parse("jMoleculesTarget")), + new $(WithJMoleculesExternalizedTarget.class, RoutingTarget.parse("jMoleculesTarget")), + new $(WithModulithExternalizedValue.class, RoutingTarget.parse("modulithTarget")), + new $(WithModulithExternalizedTarget.class, RoutingTarget.parse("modulithTarget"))); + + return DynamicTest.stream(tests, $::verify); + } + + @Test // GH-248 + void cachesLookups() { + + wipeCache(); + + AnnotationTargetLookup.of(Unannotated.class); + assertCacheEntries(1); + + AnnotationTargetLookup.of(WithJMoleculesExternalizedValue.class); + assertCacheEntries(2); + + AnnotationTargetLookup.of(Unannotated.class); + assertCacheEntries(2); + } + + private static void wipeCache() { + ReflectionTestUtils.setField(AnnotationTargetLookup.class, "LOOKUPS", new HashMap<>()); + } + + private static void assertCacheEntries(int size) { + + Map lookups = (Map) ReflectionTestUtils.getField(AnnotationTargetLookup.class, "LOOKUPS"); + + assertThat(lookups).hasSize(size); + } + + class Unannotated {} + + @Externalized("jMoleculesTarget") + class WithJMoleculesExternalizedValue {} + + @Externalized(target = "jMoleculesTarget") + class WithJMoleculesExternalizedTarget {} + + @org.springframework.modulith.events.Externalized("modulithTarget") + class WithModulithExternalizedValue {} + + @org.springframework.modulith.events.Externalized(target = "modulithTarget") + class WithModulithExternalizedTarget {} + + record $(Class type, ParsedRoutingTarget target) implements Named<$> { + + /* + * (non-Javadoc) + * @see org.junit.jupiter.api.Named#getName() + */ + @Override + public String getName() { + return target == null + ? "%s does not carry target".formatted(type.getSimpleName()) + : "%s targets %s".formatted(type.getSimpleName(), target); + } + + /* + * (non-Javadoc) + * @see org.junit.jupiter.api.Named#getPayload() + */ + @Override + public $ getPayload() { + return this; + } + + public void verify() { + + var lookup = AnnotationTargetLookup.of(type).get(); + + if (target == null) { + assertThat(lookup).isEmpty(); + } else { + assertThat(lookup).hasValue(target); + } + } + + } +} diff --git a/spring-modulith-events/spring-modulith-events-api/src/test/java/org/springframework/modulith/events/EventExternalizationConfigurationUnitTests.java b/spring-modulith-events/spring-modulith-events-api/src/test/java/org/springframework/modulith/events/EventExternalizationConfigurationUnitTests.java new file mode 100644 index 00000000..9d6ff85f --- /dev/null +++ b/spring-modulith-events/spring-modulith-events-api/src/test/java/org/springframework/modulith/events/EventExternalizationConfigurationUnitTests.java @@ -0,0 +1,156 @@ +/* + * Copyright 2023 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 + * + * https://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.modulith.events; + +import static org.assertj.core.api.Assertions.*; +import static org.springframework.modulith.events.EventExternalizationConfiguration.*; + +import lombok.RequiredArgsConstructor; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.util.List; + +import org.junit.jupiter.api.Test; + +/** + * Unit tests for {@link DefaultEventExternalizationConfiguration}. + * + * @author Oliver Drotbohm + * @since 1.1 + */ +class EventExternalizationConfigurationUnitTests { + + @Test // GH-248 + void filtersEventByAnnotation() { + + var filter = externalizing() + .selectByAnnotation(CustomExternalized.class) + .build(); + + var event = new SampleEvent(); + + assertThat(filter.supports(event)).isTrue(); + assertThat(filter.supports(new Object())).isFalse(); + assertThat(filter.determineTarget(event)) + .isEqualTo(RoutingTarget.forTarget(SampleEvent.class.getName()).withoutKey()); + } + + @Test // GH-248 + void routesByAnnotationAttribute() { + + var filter = externalizing() + .selectAndRoute(CustomExternalized.class, CustomExternalized::value) + .build(); + + var event = new SampleEvent(); + + assertThat(filter.supports(event)).isTrue(); + assertThat(filter.determineTarget(event)) + .isEqualTo(RoutingTarget.forTarget("target").withoutKey()); + } + + @Test // GH-248 + void mapsSourceEventBeforeSerializing() { + + var configuration = externalizing() + .select(__ -> true) + .mapping(SampleEvent.class, it -> "foo") + .mapping(AnotherSampleEvent.class, it -> "bar") + .build(); + + assertThat(configuration.map(new SampleEvent())).isEqualTo("foo"); + assertThat(configuration.map(new AnotherSampleEvent())).isEqualTo("bar"); + assertThat(configuration.map(4711L)).isEqualTo(4711L); + } + + @Test // GH-248 + void setsUpMappedRouting() { + + var configuration = externalizing() + .select(__ -> true) + .mapping(SampleEvent.class, it -> "foo") + .routeMapped() + .build(); + + assertThat(configuration.determineTarget(new SampleEvent())) + .isEqualTo(RoutingTarget.forTarget(String.class.getName()).withoutKey()); + } + + @Test // GH-248 + void registersMultipleTypeBasedRouters() { + + var configuration = externalizing() + .select(annotatedAsExternalized()) + .routeKey(WithKeyProperty.class, WithKeyProperty::getKey) + .build(); + + assertThat(configuration.determineTarget(new WithKeyProperty("key")).getKey()).isEqualTo("key"); + + var undefined = configuration.determineTarget(new Object()); + + assertThat(undefined.getTarget()).isEqualTo(Object.class.getName()); + assertThat(undefined.getKey()).isNull(); + } + + @Test // GH-248 + void addsFallbackTargetIfNotSpecifiedInAnnotation() { + + var configuration = externalizing() + .select(__ -> true) + .build(); + + assertThat(configuration.determineTarget(new KeyOnlyAnnotated())) + .isEqualTo(RoutingTarget.forTarget(KeyOnlyAnnotated.class.getName()).andKey("key")); + } + + @Test // GH-248 + void defaultSetup() { + + var configuration = defaults(List.of("org.springframework.modulith")).build(); + + var target = configuration.determineTarget(new AnotherSampleEvent()); + + var expected = AnotherSampleEvent.class.getName(); + expected = expected.substring(expected.indexOf("events")); + + assertThat(target.getTarget()).isEqualTo(expected); + assertThat(target.getKey()).isNull(); + } + + @Retention(RetentionPolicy.RUNTIME) + @interface CustomExternalized { + String value() default ""; + } + + @CustomExternalized("target") + static class SampleEvent {} + + static class AnotherSampleEvent {} + + @Externalized("::key") + static class KeyOnlyAnnotated {} + + @RequiredArgsConstructor + static class WithKeyProperty { + + private final String key; + + String getKey() { + return key; + } + } +} diff --git a/spring-modulith-events/spring-modulith-events-api/src/test/java/org/springframework/modulith/events/RoutingTargetUnitTests.java b/spring-modulith-events/spring-modulith-events-api/src/test/java/org/springframework/modulith/events/RoutingTargetUnitTests.java new file mode 100644 index 00000000..f1bf6343 --- /dev/null +++ b/spring-modulith-events/spring-modulith-events-api/src/test/java/org/springframework/modulith/events/RoutingTargetUnitTests.java @@ -0,0 +1,85 @@ +/* + * Copyright 2023 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 + * + * https://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.modulith.events; + +import static org.assertj.core.api.Assertions.*; + +import org.junit.jupiter.api.Test; + +/** + * Unit tests for {@link RoutingTarget}. + * + * @author Oliver Drotbohm + * @since 1.1 + */ +class RoutingTargetUnitTests { + + @Test // GH-248 + void targetWithoutDelimiterDoesNotExposeKey() { + + var target = RoutingTarget.parse("target"); + + assertThat(target.getTarget()).isEqualTo("target"); + assertThat(target.getKey()).isNull(); + } + + @Test // GH-248 + void presenceOfSoleDelimiterResultsInEmptyKey() { + + assertThat(RoutingTarget.parse("target::key").getKey()).isEqualTo("key"); + assertThat(RoutingTarget.parse("target::").getKey()).isEqualTo(""); + } + + @Test // GH-248 + void detectsKeyExpression() { + + assertThat(RoutingTarget.forTarget("target").andKey("key").hasKeyExpression()).isFalse(); + assertThat(RoutingTarget.forTarget("target").andKey("#{expression}").hasKeyExpression()).isTrue(); + } + + @Test // GH-248 + void createsEmptyParsedRoutingTarget() { + + var target = RoutingTarget.parse(""); + + assertThat(target.getTarget()).isNull(); + assertThat(target.getKey()).isNull(); + assertThatIllegalStateException().isThrownBy(target::toRoutingTarget); + } + + @Test // GH-248 + void equalsAndHashCode() { + + var first = RoutingTarget.parse("target"); + var second = RoutingTarget.parse("target::"); + var third = RoutingTarget.parse("target::key"); + var fourth = RoutingTarget.parse("target::key"); + var fifth = RoutingTarget.parse("another"); + + assertThat(first).isEqualTo(first); + assertThat(first).isNotEqualTo(second); + assertThat(first).isNotEqualTo(third); + assertThat(first).isNotEqualTo(fifth); + assertThat(first).isNotEqualTo(new Object()); + + assertThat(third).isEqualTo(fourth); + assertThat(fourth).isEqualTo(third); + + // Unfortunate but not to avoid + assertThat(first.hashCode()).isEqualTo(second.hashCode()); + assertThat(first.hashCode()).isNotEqualTo(third); + } +} diff --git a/spring-modulith-events/spring-modulith-events-api/src/test/resources/logback.xml b/spring-modulith-events/spring-modulith-events-api/src/test/resources/logback.xml new file mode 100644 index 00000000..dafa3a40 --- /dev/null +++ b/spring-modulith-events/spring-modulith-events-api/src/test/resources/logback.xml @@ -0,0 +1,14 @@ + + + + + + %d{HH:mm:ss.SSS} %1.-1level - %8.8t : %m%n + + + + + + + + diff --git a/spring-modulith-events/spring-modulith-events-core/pom.xml b/spring-modulith-events/spring-modulith-events-core/pom.xml index d4fadacb..a78b4bf8 100644 --- a/spring-modulith-events/spring-modulith-events-core/pom.xml +++ b/spring-modulith-events/spring-modulith-events-core/pom.xml @@ -18,6 +18,12 @@ + + org.springframework.modulith + spring-modulith-api + ${project.version} + + org.springframework.modulith spring-modulith-events-api diff --git a/spring-modulith-events/spring-modulith-events-core/src/main/java/org/springframework/modulith/events/config/EventExternalizationAutoConfiguration.java b/spring-modulith-events/spring-modulith-events-core/src/main/java/org/springframework/modulith/events/config/EventExternalizationAutoConfiguration.java new file mode 100644 index 00000000..ebd5872c --- /dev/null +++ b/spring-modulith-events/spring-modulith-events-core/src/main/java/org/springframework/modulith/events/config/EventExternalizationAutoConfiguration.java @@ -0,0 +1,148 @@ +/* + * Copyright 2023 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 + * + * https://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.modulith.events.config; + +import java.lang.reflect.Method; + +import org.springframework.beans.factory.BeanFactory; +import org.springframework.beans.factory.config.BeanDefinition; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.AutoConfigurationPackages; +import org.springframework.boot.autoconfigure.AutoConfigureAfter; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.context.ApplicationListener; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Role; +import org.springframework.context.event.EventListenerFactory; +import org.springframework.core.Ordered; +import org.springframework.modulith.events.EventExternalizationConfiguration; +import org.springframework.modulith.events.core.ConditionalEventListener; +import org.springframework.modulith.events.support.PersistentApplicationEventMulticaster; +import org.springframework.transaction.event.TransactionalApplicationListenerMethodAdapter; +import org.springframework.transaction.event.TransactionalEventListenerFactory; + +/** + * Auto-configuration to externalize application events. + * + * @author Oliver Drotbohm + * @since 1.1 + */ +@AutoConfiguration +@AutoConfigureAfter(EventPublicationConfiguration.class) +public class EventExternalizationAutoConfiguration { + + @Bean + @Role(BeanDefinition.ROLE_INFRASTRUCTURE) + static EventListenerFactory filteringEventListenerFactory(EventExternalizationConfiguration config) { + return new ConditionalTransactionalEventListenerFactory(config); + } + + @Bean + @Role(BeanDefinition.ROLE_INFRASTRUCTURE) + @ConditionalOnMissingBean + static EventExternalizationConfiguration eventExternalizationConfiguration(BeanFactory factory) { + + var packages = AutoConfigurationPackages.get(factory); + + return EventExternalizationConfiguration.defaults(packages).build(); + } + + /** + * A custom {@link EventListenerFactory} to create {@link ConditionalTransactionalApplicationListenerMethodAdapter} + * instances. + * + * @author Oliver Drotbohm + */ + private static final class ConditionalTransactionalEventListenerFactory + extends TransactionalEventListenerFactory implements Ordered { + + private final EventExternalizationConfiguration config; + + /** + * Creates a new {@link ConditionalTransactionalEventListenerFactory} for the given + * {@link EventExternalizationConfiguration}. + * + * @param config must not be {@literal null}. + */ + private ConditionalTransactionalEventListenerFactory(EventExternalizationConfiguration config) { + this.config = config; + } + + /* + * (non-Javadoc) + * @see org.springframework.transaction.event.TransactionalEventListenerFactory#supportsMethod(java.lang.reflect.Method) + */ + @Override + public boolean supportsMethod(Method method) { + return super.supportsMethod(method) + && ConditionalEventListener.class.isAssignableFrom(method.getDeclaringClass()); + } + + /* + * (non-Javadoc) + * @see org.springframework.transaction.event.TransactionalEventListenerFactory#createApplicationListener(java.lang.String, java.lang.Class, java.lang.reflect.Method) + */ + @Override + public ApplicationListener createApplicationListener(String beanName, Class type, Method method) { + return new ConditionalTransactionalApplicationListenerMethodAdapter(beanName, type, method, config); + } + + /* + * (non-Javadoc) + * @see org.springframework.transaction.event.TransactionalEventListenerFactory#getOrder() + */ + @Override + public int getOrder() { + return 25; + } + } + + /** + * A custom {@link TransactionalApplicationListenerMethodAdapter} that also implements + * {@link ConditionalEventListener} so that the adapter can be filtered out based on the event to be published. + * + * @author Oliver Drotbohm + * @see ConditionalEventListener + * @see PersistentApplicationEventMulticaster + */ + private static class ConditionalTransactionalApplicationListenerMethodAdapter + extends TransactionalApplicationListenerMethodAdapter + implements ConditionalEventListener { + + private final EventExternalizationConfiguration configuration; + + /** + * @param beanName + * @param targetClass + * @param method + * @param configuration + */ + ConditionalTransactionalApplicationListenerMethodAdapter(String beanName, Class targetClass, Method method, + EventExternalizationConfiguration configuration) { + super(beanName, targetClass, method); + this.configuration = configuration; + } + + /* + * (non-Javadoc) + * @see org.springframework.modulith.events.ConditionalEventListener#supports(java.lang.Object) + */ + @Override + public boolean supports(Object event) { + return configuration.supports(event); + } + } +} diff --git a/spring-modulith-events/spring-modulith-events-core/src/main/java/org/springframework/modulith/events/core/ConditionalEventListener.java b/spring-modulith-events/spring-modulith-events-core/src/main/java/org/springframework/modulith/events/core/ConditionalEventListener.java new file mode 100644 index 00000000..f6397f37 --- /dev/null +++ b/spring-modulith-events/spring-modulith-events-core/src/main/java/org/springframework/modulith/events/core/ConditionalEventListener.java @@ -0,0 +1,33 @@ +/* + * Copyright 2023 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 + * + * https://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.modulith.events.core; + +/** + * An event listener that exposes whether it supports a particular event instance. + * + * @author Oliver Drotbohm + * @since 1.1 + */ +public interface ConditionalEventListener { + + /** + * Returns whether the given event instance is supported by the listener. + * + * @param event must not be {@literal null}. + * @return whether the given event instance is supported by the listener. + */ + boolean supports(Object event); +} diff --git a/spring-modulith-events/spring-modulith-events-core/src/main/java/org/springframework/modulith/events/support/BrokerRouting.java b/spring-modulith-events/spring-modulith-events-core/src/main/java/org/springframework/modulith/events/support/BrokerRouting.java new file mode 100644 index 00000000..b8f91875 --- /dev/null +++ b/spring-modulith-events/spring-modulith-events-core/src/main/java/org/springframework/modulith/events/support/BrokerRouting.java @@ -0,0 +1,128 @@ +/* + * Copyright 2023 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 + * + * https://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.modulith.events.support; + +import org.springframework.expression.EvaluationContext; +import org.springframework.expression.Expression; +import org.springframework.expression.common.TemplateParserContext; +import org.springframework.expression.spel.standard.SpelExpressionParser; +import org.springframework.lang.Nullable; +import org.springframework.modulith.events.RoutingTarget; +import org.springframework.util.Assert; + +/** + * A {@link BrokerRouting} supports {@link RoutingTarget} instances that contain values matching the format + * {@code $target::$key} for which the key can actually be a SpEL expression. + * + * @author Oliver Drotbohm + * @since 1.1 + */ +public class BrokerRouting { + + private final RoutingTarget target; + + /** + * Creates a new {@link BrokerRouting} for the given {@link RoutingTarget}. + * + * @param target must not be {@literal null}. + */ + private BrokerRouting(RoutingTarget target) { + + Assert.notNull(target, "RoutingTarget must not be null!"); + + this.target = target; + } + + /** + * Creates a new {@link BrokerRouting} for the given {@link RoutingTarget} and {@link EvaluationContext}. + * + * @param target must not be {@literal null}. + * @param context must not be {@literal null}. + * @return will never be {@literal null}. + */ + public static BrokerRouting of(RoutingTarget target, EvaluationContext context) { + return target.hasKeyExpression() ? new SpelBrokerRouting(target, context) : new BrokerRouting(target); + } + + /** + * Returns the actual routing target. + * + * @return will never be {@literal null}. + */ + public String getTarget() { + return target.getTarget(); + } + + /** + * Resolves the routing key against the given event. In case the original {@link RoutingTarget} contained an + * expression, the event will be used as root object to evaluate that expression. + * + * @param event must not be {@literal null}. + * @return can be {@literal null}. + */ + @Nullable + public String getKey(Object event) { + return target.getKey(); + } + + /** + * A {@link BrokerRouting} that evaluates a {@link RoutingTarget}'s key as SpEL expression. + * + * @author Oliver Drotbohm + * @since 1.1 + */ + static class SpelBrokerRouting extends BrokerRouting { + + private static final SpelExpressionParser PARSER = new SpelExpressionParser(); + private static final TemplateParserContext CONTEXT = new TemplateParserContext(); + + private final Expression expression; + private final EvaluationContext context; + + /** + * Creates a new {@link SpelBrokerRouting} for the given {@link RoutingTarget} and {@link EvaluationContext}. + * + * @param target must not be {@literal null}. + * @param context must not be {@literal null}. + */ + @SuppressWarnings("null") + private SpelBrokerRouting(RoutingTarget target, EvaluationContext context) { + + super(target); + + var key = target.getKey(); + + Assert.notNull(target.getKey(), "Routing key must not be null!"); + Assert.notNull(context, "EvaluationContext must not be null!"); + + this.expression = PARSER.parseExpression(key, CONTEXT); + this.context = context; + } + + /* + * (non-Javadoc) + * @see org.springframework.modulith.events.support.BrokerRouting#getKey(java.lang.Object) + */ + @Nullable + @Override + public String getKey(Object event) { + + var result = expression.getValue(context, event); + + return result == null ? null : result.toString(); + } + } +} diff --git a/spring-modulith-events/spring-modulith-events-core/src/main/java/org/springframework/modulith/events/support/DelegatingEventExternalizer.java b/spring-modulith-events/spring-modulith-events-core/src/main/java/org/springframework/modulith/events/support/DelegatingEventExternalizer.java new file mode 100644 index 00000000..9bcae04c --- /dev/null +++ b/spring-modulith-events/spring-modulith-events-core/src/main/java/org/springframework/modulith/events/support/DelegatingEventExternalizer.java @@ -0,0 +1,74 @@ +/* + * Copyright 2023 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 + * + * https://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.modulith.events.support; + +import java.util.function.BiConsumer; + +import org.springframework.modulith.ApplicationModuleListener; +import org.springframework.modulith.events.EventExternalizationConfiguration; +import org.springframework.modulith.events.RoutingTarget; +import org.springframework.stereotype.Component; +import org.springframework.util.Assert; + +/** + * An {@link EventExternalizationSupport} delegating to a {@link BiConsumer} for the actual externalization. Note, that + * this needs to be a {@link Component} to make sure it is considered an event listener, as without the + * annotation Spring Framework would skip it as it lives in the {@code org.springframework} package. + * + * @author Oliver Drotbohm + * @since 1.1 + */ +@Component +public class DelegatingEventExternalizer extends EventExternalizationSupport { + + private final BiConsumer delegate; + + /** + * Creates a new {@link DelegatingEventExternalizer} for the given {@link EventExternalizationConfiguration} and + * {@link BiConsumer} implementing the actual externalization. + * + * @param configuration must not be {@literal null}. + * @param delegate must not be {@literal null}. + */ + public DelegatingEventExternalizer(EventExternalizationConfiguration configuration, + BiConsumer delegate) { + + super(configuration); + + Assert.notNull(delegate, "BiConsumer must not be null!"); + + this.delegate = delegate; + } + + /* + * (non-Javadoc) + * @see org.springframework.modulith.events.support.EventExternalizationSupport#externalize(java.lang.Object) + */ + @Override + @ApplicationModuleListener + public void externalize(Object event) { + super.externalize(event); + } + + /* + * (non-Javadoc) + * @see org.springframework.modulith.events.support.EventExternalizationSupport#externalize(org.springframework.modulith.events.RoutingTarget, java.lang.Object) + */ + @Override + protected void externalize(Object payload, RoutingTarget target) { + delegate.accept(target, payload); + } +} diff --git a/spring-modulith-events/spring-modulith-events-core/src/main/java/org/springframework/modulith/events/support/EventExternalizationSupport.java b/spring-modulith-events/spring-modulith-events-core/src/main/java/org/springframework/modulith/events/support/EventExternalizationSupport.java new file mode 100644 index 00000000..197a7a85 --- /dev/null +++ b/spring-modulith-events/spring-modulith-events-core/src/main/java/org/springframework/modulith/events/support/EventExternalizationSupport.java @@ -0,0 +1,93 @@ +/* + * Copyright 2023 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 + * + * https://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.modulith.events.support; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.modulith.ApplicationModuleListener; +import org.springframework.modulith.events.EventExternalizationConfiguration; +import org.springframework.modulith.events.RoutingTarget; +import org.springframework.modulith.events.core.ConditionalEventListener; +import org.springframework.util.Assert; + +/** + * Fundamental support for event externalization. Considers the configured {@link EventExternalizationConfiguration} + * before handing off the ultimate message publication to {@link #externalize(Object, RoutingTarget)}. + * + * @author Oliver Drotbohm + * @since 1.1 + */ +abstract class EventExternalizationSupport implements ConditionalEventListener { + + private static final Logger logger = LoggerFactory.getLogger(EventExternalizationSupport.class.getClass()); + + private final EventExternalizationConfiguration configuration; + + /** + * Creates a new {@link EventExternalizationSupport} for the given {@link EventExternalizationConfiguration}. + * + * @param configuration must not be {@literal null}. + */ + protected EventExternalizationSupport(EventExternalizationConfiguration configuration) { + + Assert.notNull(configuration, "EventExternalizationConfiguration must not be null!"); + + this.configuration = configuration; + } + + /* + * (non-Javadoc) + * @see org.springframework.modulith.events.ConditionalEventListener#supports(java.lang.Object) + */ + @Override + public boolean supports(Object event) { + return configuration.supports(event); + } + + /** + * Externalizes the given event. + * + * @param event must not be {@literal null}. + */ + @ApplicationModuleListener + public void externalize(Object event) { + + Assert.notNull(event, "Object must not be null!"); + + if (!configuration.supports(event)) { + return; + } + + var target = configuration.determineTarget(event); + var mapped = configuration.map(event); + + if (logger.isTraceEnabled()) { + logger.trace("Externalizing event of type {} to {}, payload: {}).", event.getClass(), target, mapped); + } else if (logger.isDebugEnabled()) { + logger.debug("Externalizing event of type {} to {}.", event.getClass(), target); + } + + externalize(mapped, target); + } + + /** + * Publish the given payload to the given {@link RoutingTarget}. + * + * @param payload must not be {@literal null}. + * @param target must not be {@literal null}. + */ + protected abstract void externalize(Object payload, RoutingTarget target); +} diff --git a/spring-modulith-events/spring-modulith-events-core/src/main/java/org/springframework/modulith/events/support/PersistentApplicationEventMulticaster.java b/spring-modulith-events/spring-modulith-events-core/src/main/java/org/springframework/modulith/events/support/PersistentApplicationEventMulticaster.java index 552f32cc..47356b98 100644 --- a/spring-modulith-events/spring-modulith-events-core/src/main/java/org/springframework/modulith/events/support/PersistentApplicationEventMulticaster.java +++ b/spring-modulith-events/spring-modulith-events-core/src/main/java/org/springframework/modulith/events/support/PersistentApplicationEventMulticaster.java @@ -38,6 +38,7 @@ import org.springframework.lang.NonNull; import org.springframework.lang.Nullable; import org.springframework.modulith.events.EventPublication; import org.springframework.modulith.events.IncompleteEventPublications; +import org.springframework.modulith.events.core.ConditionalEventListener; import org.springframework.modulith.events.core.EventPublicationRegistry; import org.springframework.modulith.events.core.PublicationTargetIdentifier; import org.springframework.modulith.events.core.TargetEventPublication; @@ -116,6 +117,22 @@ public class PersistentApplicationEventMulticaster extends AbstractApplicationEv /* * (non-Javadoc) + * @see org.springframework.context.event.AbstractApplicationEventMulticaster#getApplicationListeners(org.springframework.context.ApplicationEvent, org.springframework.core.ResolvableType) + */ + @Override + protected Collection> getApplicationListeners(ApplicationEvent event, + ResolvableType eventType) { + + Object eventToPersist = getEventToPersist(event); + + return super.getApplicationListeners(event, eventType) + .stream() + .filter(it -> matches(eventToPersist, it)) + .toList(); + } + + /* + * (non-Javadoc) * @see org.springframework.modulith.events.IncompleteEventPublications#resubmitIncompletePublications(java.util.function.Predicate) */ @Override @@ -202,6 +219,13 @@ public class PersistentApplicationEventMulticaster extends AbstractApplicationEv : event; } + private static boolean matches(Object event, ApplicationListener listener) { + + return ConditionalEventListener.class.isInstance(listener) + ? ConditionalEventListener.class.cast(listener).supports(event) + : true; + } + private static String getConfirmationMessage(Collection publications) { var size = publications.size(); diff --git a/spring-modulith-events/spring-modulith-events-core/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/spring-modulith-events/spring-modulith-events-core/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports index 0e82e46e..e5a67a4e 100644 --- a/spring-modulith-events/spring-modulith-events-core/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports +++ b/spring-modulith-events/spring-modulith-events-core/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -1 +1,2 @@ org.springframework.modulith.events.config.EventPublicationAutoConfiguration +org.springframework.modulith.events.config.EventExternalizationAutoConfiguration diff --git a/spring-modulith-events/spring-modulith-events-core/src/test/java/org/springframework/modulith/events/support/BrokerRoutingUnitTests.java b/spring-modulith-events/spring-modulith-events-core/src/test/java/org/springframework/modulith/events/support/BrokerRoutingUnitTests.java new file mode 100644 index 00000000..1cfb2e00 --- /dev/null +++ b/spring-modulith-events/spring-modulith-events-core/src/test/java/org/springframework/modulith/events/support/BrokerRoutingUnitTests.java @@ -0,0 +1,79 @@ +/* + * Copyright 2023 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 + * + * https://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.modulith.events.support; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +import org.junit.jupiter.api.Test; +import org.springframework.expression.EvaluationContext; +import org.springframework.expression.spel.support.StandardEvaluationContext; +import org.springframework.modulith.events.RoutingTarget; +import org.springframework.modulith.events.support.BrokerRouting.SpelBrokerRouting; + +/** + * Unit tests for {@link BrokerRouting}. + * + * @author Oliver Drotbohm + * @since 1.1 + */ +class BrokerRoutingUnitTests { + + @Test // GH-248 + void parsesSpelExpressionFromTarget() { + + var target = RoutingTarget.forTarget("target").andKey("#{@bean.getKey(#this) + someKey()}"); + + var routing = BrokerRouting.of(target, getEvaluationContext()); + + assertThat(routing).isInstanceOf(SpelBrokerRouting.class); + assertThat(routing.getKey(new TestEvent())).isEqualTo("foofoo"); + } + + @Test // GH-248 + void doesNotAccessEvaluationContextIfNoSpelExpression() { + + var context = mock(EvaluationContext.class); + + var routing = BrokerRouting.of(RoutingTarget.forTarget("target").andKey("key"), context); + + assertThat(routing).isNotInstanceOf(SpelBrokerRouting.class); + assertThat(routing.getKey(new TestEvent())).isEqualTo("key"); + verifyNoInteractions(context); + } + + private static EvaluationContext getEvaluationContext() { + + var evaluationContext = new StandardEvaluationContext(); + evaluationContext.setBeanResolver((context, name) -> new TestBean()); + + return evaluationContext; + } + + static class TestEvent { + + public String someKey() { + return "foo"; + } + } + + static class TestBean { + + public String getKey(TestEvent event) { + return event.someKey(); + } + } +} diff --git a/spring-modulith-events/spring-modulith-events-jms/pom.xml b/spring-modulith-events/spring-modulith-events-jms/pom.xml new file mode 100644 index 00000000..fd49e53d --- /dev/null +++ b/spring-modulith-events/spring-modulith-events-jms/pom.xml @@ -0,0 +1,79 @@ + + + 4.0.0 + + + org.springframework.modulith + spring-modulith-events + 1.1.0-SNAPSHOT + + + Spring Modulith - Events - JMS support + spring-modulith-events-jms + + + org.springframework.modulith.events.jms + + + + + + org.springframework.modulith + spring-modulith-api + ${project.version} + + + + org.springframework.modulith + spring-modulith-events-core + ${project.version} + + + + org.springframework + spring-jms + + + + jakarta.jms + jakarta.jms-api + + + + com.fasterxml.jackson.core + jackson-databind + true + + + + + + org.springframework.modulith + spring-modulith-starter-jdbc + ${project.version} + test + + + + com.h2database + h2 + test + + + + org.springframework.boot + spring-boot-starter-test + test + + + + org.apache.activemq + artemis-jakarta-server + test + + + + + diff --git a/spring-modulith-events/spring-modulith-events-jms/src/main/java/org/springframework/modulith/events/jms/JmsEventExternalizerConfiguration.java b/spring-modulith-events/spring-modulith-events-jms/src/main/java/org/springframework/modulith/events/jms/JmsEventExternalizerConfiguration.java new file mode 100644 index 00000000..234b629b --- /dev/null +++ b/spring-modulith-events/spring-modulith-events-jms/src/main/java/org/springframework/modulith/events/jms/JmsEventExternalizerConfiguration.java @@ -0,0 +1,56 @@ +/* + * Copyright 2023 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 + * + * https://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.modulith.events.jms; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.AutoConfigureAfter; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.context.annotation.Bean; +import org.springframework.jms.core.JmsOperations; +import org.springframework.modulith.events.EventExternalizationConfiguration; +import org.springframework.modulith.events.config.EventExternalizationAutoConfiguration; +import org.springframework.modulith.events.core.EventSerializer; +import org.springframework.modulith.events.support.DelegatingEventExternalizer; + +/** + * Auto-configuration to set up a {@link DelegatingEventExternalizer} to externalize events to JMS. + * + * @author Oliver Drotbohm + * @since 1.1 + */ +@AutoConfiguration +@AutoConfigureAfter(EventExternalizationAutoConfiguration.class) +@ConditionalOnClass(JmsOperations.class) +class JmsEventExternalizerConfiguration { + + private static final Logger logger = LoggerFactory.getLogger(JmsEventExternalizerConfiguration.class); + + @Bean + DelegatingEventExternalizer jmsEventExternalizer(EventExternalizationConfiguration configuration, + JmsOperations operations, EventSerializer serializer) { + + logger.debug("Registering domain event externalization to JMS…"); + + return new DelegatingEventExternalizer(configuration, (target, payload) -> { + + var serialized = serializer.serialize(payload); + + operations.send(target.getTarget(), session -> session.createTextMessage(serialized.toString())); + }); + } +} diff --git a/spring-modulith-events/spring-modulith-events-jms/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/spring-modulith-events/spring-modulith-events-jms/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 00000000..f488eae2 --- /dev/null +++ b/spring-modulith-events/spring-modulith-events-jms/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1 @@ +org.springframework.modulith.events.jms.JmsEventExternalizerConfiguration diff --git a/spring-modulith-events/spring-modulith-events-jms/src/test/java/org/springframework/modulith/events/jms/JmsEventPublicationIntegrationTests.java b/spring-modulith-events/spring-modulith-events-jms/src/test/java/org/springframework/modulith/events/jms/JmsEventPublicationIntegrationTests.java new file mode 100644 index 00000000..4b6f3b57 --- /dev/null +++ b/spring-modulith-events/spring-modulith-events-jms/src/test/java/org/springframework/modulith/events/jms/JmsEventPublicationIntegrationTests.java @@ -0,0 +1,93 @@ +/* + * Copyright 2023 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 + * + * https://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.modulith.events.jms; + +import static org.assertj.core.api.Assertions.*; + +import lombok.RequiredArgsConstructor; + +import org.apache.activemq.artemis.core.server.embedded.EmbeddedActiveMQ; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.context.annotation.Bean; +import org.springframework.modulith.ApplicationModuleListener; +import org.springframework.modulith.events.Externalized; +import org.springframework.transaction.annotation.Transactional; + +/** + * Integration tests for JMS-based event publication. + * + * @author Oliver Drotbohm + * @since 1.1 + */ +@SpringBootTest +class JmsEventPublicationIntegrationTests { + + @Autowired TestPublisher publisher; + @Autowired EmbeddedActiveMQ artemis; + + @SpringBootApplication + static class Infrastructure { + + @Bean + TestPublisher testPublisher(ApplicationEventPublisher publisher) { + return new TestPublisher(publisher); + } + + @Bean + TestListener testListener() { + return new TestListener(); + } + } + + @Test // GH-248 + void publishesEventToJmsBroker() throws Exception { + + var server = artemis.getActiveMQServer(); + var before = server.getTotalMessageCount(); + + publisher.publishEvent(); + + Thread.sleep(400); + + var after = server.getTotalMessageCount(); + + assertThat(after - before).isEqualTo(1); + } + + @Externalized("target") + static class TestEvent {} + + @RequiredArgsConstructor + static class TestPublisher { + + private final ApplicationEventPublisher events; + + @Transactional + void publishEvent() { + events.publishEvent(new TestEvent()); + } + } + + static class TestListener { + + @ApplicationModuleListener + void on(TestEvent event) {} + } +} diff --git a/spring-modulith-events/spring-modulith-events-jms/src/test/resources/application.properties b/spring-modulith-events/spring-modulith-events-jms/src/test/resources/application.properties new file mode 100644 index 00000000..79d64cb2 --- /dev/null +++ b/spring-modulith-events/spring-modulith-events-jms/src/test/resources/application.properties @@ -0,0 +1 @@ +spring.modulith.events.jdbc.schema-initialization.enabled=true diff --git a/spring-modulith-events/spring-modulith-events-jms/src/test/resources/logback.xml b/spring-modulith-events/spring-modulith-events-jms/src/test/resources/logback.xml new file mode 100644 index 00000000..a13724ce --- /dev/null +++ b/spring-modulith-events/spring-modulith-events-jms/src/test/resources/logback.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/spring-modulith-events/spring-modulith-events-kafka/pom.xml b/spring-modulith-events/spring-modulith-events-kafka/pom.xml new file mode 100644 index 00000000..fe4016c2 --- /dev/null +++ b/spring-modulith-events/spring-modulith-events-kafka/pom.xml @@ -0,0 +1,47 @@ + + + 4.0.0 + + + org.springframework.modulith + spring-modulith-events + 1.1.0-SNAPSHOT + + + Spring Modulith - Events - Kafka support + spring-modulith-events-kafka + + + org.springframework.modulith.events.kafka + + + + + + org.springframework.modulith + spring-modulith-api + ${project.version} + + + + org.springframework.modulith + spring-modulith-events-core + ${project.version} + + + + org.springframework.kafka + spring-kafka + + + + com.fasterxml.jackson.core + jackson-databind + true + + + + + diff --git a/spring-modulith-events/spring-modulith-events-kafka/src/main/java/org/springframework/modulith/events/kafka/KafkaEventExternalizerConfiguration.java b/spring-modulith-events/spring-modulith-events-kafka/src/main/java/org/springframework/modulith/events/kafka/KafkaEventExternalizerConfiguration.java new file mode 100644 index 00000000..5bb00688 --- /dev/null +++ b/spring-modulith-events/spring-modulith-events-kafka/src/main/java/org/springframework/modulith/events/kafka/KafkaEventExternalizerConfiguration.java @@ -0,0 +1,63 @@ +/* + * Copyright 2023 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 + * + * https://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.modulith.events.kafka; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.BeanFactory; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.AutoConfigureAfter; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.context.annotation.Bean; +import org.springframework.context.expression.BeanFactoryResolver; +import org.springframework.expression.spel.support.StandardEvaluationContext; +import org.springframework.kafka.core.KafkaOperations; +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.modulith.events.EventExternalizationConfiguration; +import org.springframework.modulith.events.config.EventExternalizationAutoConfiguration; +import org.springframework.modulith.events.support.BrokerRouting; +import org.springframework.modulith.events.support.DelegatingEventExternalizer; + +/** + * Auto-configuration to set up a {@link DelegatingEventExternalizer} to externalize events to Kafka. + * + * @author Oliver Drotbohm + * @since 1.1 + */ +@AutoConfiguration +@AutoConfigureAfter(EventExternalizationAutoConfiguration.class) +@ConditionalOnClass(KafkaTemplate.class) +class KafkaEventExternalizerConfiguration { + + private static final Logger logger = LoggerFactory.getLogger(KafkaEventExternalizerConfiguration.class); + + @Bean + DelegatingEventExternalizer kafkaEventExternalizer(EventExternalizationConfiguration configuration, + KafkaOperations operations, BeanFactory factory) { + + logger.debug("Registering domain event externalization to Kafka…"); + + var context = new StandardEvaluationContext(); + context.setBeanResolver(new BeanFactoryResolver(factory)); + + return new DelegatingEventExternalizer(configuration, (target, payload) -> { + + var routing = BrokerRouting.of(target, context); + + operations.send(routing.getTarget(), routing.getKey(payload), payload); + }); + } +} diff --git a/spring-modulith-events/spring-modulith-events-kafka/src/main/java/org/springframework/modulith/events/kafka/KafkaJacksonConfiguration.java b/spring-modulith-events/spring-modulith-events-kafka/src/main/java/org/springframework/modulith/events/kafka/KafkaJacksonConfiguration.java new file mode 100644 index 00000000..9f2f5c41 --- /dev/null +++ b/spring-modulith-events/spring-modulith-events-kafka/src/main/java/org/springframework/modulith/events/kafka/KafkaJacksonConfiguration.java @@ -0,0 +1,47 @@ +/* + * Copyright 2023 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 + * + * https://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.modulith.events.kafka; + +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.PropertySource; +import org.springframework.kafka.support.converter.JsonMessageConverter; + +import com.fasterxml.jackson.databind.ObjectMapper; + +/** + * Auto-configures Spring for Apache Kafka to use JSON as transport format by default. + * + * @author Oliver Drotbohm + * @since 1.1 + */ +@AutoConfiguration +@ConditionalOnClass(ObjectMapper.class) +@ConditionalOnProperty(name = "spring.modulith.events.kafka.enable-json", havingValue = "true", matchIfMissing = true) +@PropertySource("classpath:kafka-json.properties") +class KafkaJacksonConfiguration { + + @Bean + @ConditionalOnBean(ObjectMapper.class) + @ConditionalOnMissingBean(JsonMessageConverter.class) + JsonMessageConverter jsonMessageConverter(ObjectMapper mapper) { + return new JsonMessageConverter(mapper); + } +} diff --git a/spring-modulith-events/spring-modulith-events-kafka/src/main/resources/META-INF/spring-configuration-metadata.json b/spring-modulith-events/spring-modulith-events-kafka/src/main/resources/META-INF/spring-configuration-metadata.json new file mode 100644 index 00000000..fd47909b --- /dev/null +++ b/spring-modulith-events/spring-modulith-events-kafka/src/main/resources/META-INF/spring-configuration-metadata.json @@ -0,0 +1,10 @@ +{ + "properties": [ + { + "name": "spring.modulith.events.kafka.json-enabled", + "type": "java.lang.boolean", + "description": "Whether to auto-configure Spring for Apache Kafka to use JSON for message serialization.", + "defaultValue": "true" + } + ] +} diff --git a/spring-modulith-events/spring-modulith-events-kafka/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/spring-modulith-events/spring-modulith-events-kafka/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 00000000..8a47f062 --- /dev/null +++ b/spring-modulith-events/spring-modulith-events-kafka/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1,2 @@ +org.springframework.modulith.events.kafka.KafkaEventExternalizerConfiguration +org.springframework.modulith.events.kafka.KafkaJacksonConfiguration diff --git a/spring-modulith-events/spring-modulith-events-kafka/src/main/resources/kafka-json.properties b/spring-modulith-events/spring-modulith-events-kafka/src/main/resources/kafka-json.properties new file mode 100644 index 00000000..6cae2776 --- /dev/null +++ b/spring-modulith-events/spring-modulith-events-kafka/src/main/resources/kafka-json.properties @@ -0,0 +1,2 @@ +spring.kafka.producer.value-serializer=org.springframework.kafka.support.serializer.JsonSerializer +spring.kafka.consumer.value-deserializer=org.apache.kafka.common.serialization.ByteArrayDeserializer diff --git a/spring-modulith-examples/pom.xml b/spring-modulith-examples/pom.xml index b438a3d9..e9b9eded 100644 --- a/spring-modulith-examples/pom.xml +++ b/spring-modulith-examples/pom.xml @@ -19,6 +19,7 @@ spring-modulith-example-epr-jdbc spring-modulith-example-epr-mongodb spring-modulith-example-full + spring-modulith-example-kafka diff --git a/spring-modulith-examples/spring-modulith-example-kafka/pom.xml b/spring-modulith-examples/spring-modulith-example-kafka/pom.xml new file mode 100644 index 00000000..8c625a8d --- /dev/null +++ b/spring-modulith-examples/spring-modulith-example-kafka/pom.xml @@ -0,0 +1,52 @@ + + 4.0.0 + + + org.springframework.modulith + spring-modulith-examples + 1.1.0-SNAPSHOT + + + Spring Modulith - Examples - Kafka Example + spring-modulith-example-kafka + + + + + org.springframework.boot + spring-boot-starter-data-jpa + + + + com.h2database + h2 + runtime + + + + org.springframework.modulith + spring-modulith-starter-jpa + + + + org.springframework.modulith + spring-modulith-events-kafka + + + + org.springframework.kafka + spring-kafka + + + + + + org.jmolecules.integrations + jmolecules-starter-ddd + + + + + diff --git a/spring-modulith-examples/spring-modulith-example-kafka/readme.adoc b/spring-modulith-examples/spring-modulith-example-kafka/readme.adoc new file mode 100644 index 00000000..c163a797 --- /dev/null +++ b/spring-modulith-examples/spring-modulith-example-kafka/readme.adoc @@ -0,0 +1,29 @@ += Spring Modulith -- Kafka event externalization example + +This examples how domain events can automatically be externalized to Kafka. +The two fundamentally required steps are: + +1. Add the `spring-modulith-events-kafka` dependency to the project (`runtime` scope is sufficient). +2. Add the `spring-modulith-events-api` dependency to annotate the event types to be externalized automatically with `@Externalized` (see `OrderCompleted`). + +`TestApplication` (in `src/test/java`) declares a `KafkaOperations` instance so that we do not need an actual Kafka instance running for the sample. +The bean declared simply triggers some log output simulating the actual interaction with Kafka. +Running the test application using `./mvnw spring-boot:test-run` should show the following output. + +[source] +---- +22:20:20.398 D - main : Registering domain event externalization to Kafka… <1> +… +22:20:21.267 I - main : Triggering order completion… <2> +22:20:21.277 D - main : Registering publication of example.order.OrderCompleted for org.springframework.modulith.events.support.DelegatingEventExternalizer.externalize(java.lang.Object). <3> +22:20:21.325 D - task-1 : Externalizing event of type class example.order.OrderCompleted to RoutingTarget[value=order.OrderCompleted]. <4> +22:20:21.327 I - task-1 : Sending message {"orderId":{"id":"ef3521e8-d498-4539-8745-3a1c74bbe90d"}} to RoutingTarget[value=order.OrderCompleted]. <5> +22:20:21.376 D - task-1 : Marking publication of event example.order.OrderCompleted to listener org.springframework.modulith.events.support.DelegatingEventExternalizer.externalize(java.lang.Object) completed. <6> +---- +<1> On application bootstrap, the `spring-modulith-events-kafka` module registers an `ApplicationModuleListener` that will listen to domain events to be externalized. +<2> Once started, the application's `main` method invokes a business method on the `OrderManagement` that ultimately results in the publication of an `OrderCompleted` event. +That in turn is annotated with Spring Modulith's `@Externalized` and thus qualifies for externalization. +<3> The event publication infrastructure detects an `@ApplicationModuleListener` interested in the event, it creates an entry in the Event Publication Registry to track the processing of the event. +<4> The externalizing `@ApplicationModuleListener` gets triggered (note how it runs asynchronously, indicated by the `task-1` thread). +<5> Our mock `KafkaOperations` is invoked and triggers the log message simulating the actual sending. +<6> The Event Publication Registry eventually marks the publication completed as the sending has completed successfully. diff --git a/spring-modulith-examples/spring-modulith-example-kafka/src/main/java/example/Application.java b/spring-modulith-examples/spring-modulith-example-kafka/src/main/java/example/Application.java new file mode 100644 index 00000000..17dba184 --- /dev/null +++ b/spring-modulith-examples/spring-modulith-example-kafka/src/main/java/example/Application.java @@ -0,0 +1,35 @@ +/* + * Copyright 2023 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 + * + * https://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 example; + +import example.order.Order; +import example.order.OrderManagement; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +/** + * @author Oliver Drotbohm + */ +@SpringBootApplication +public class Application { + + public static void main(String[] args) { + SpringApplication.run(Application.class, args) + .getBean(OrderManagement.class) + .complete(new Order()); + } +} diff --git a/spring-modulith-examples/spring-modulith-example-kafka/src/main/java/example/order/Order.java b/spring-modulith-examples/spring-modulith-example-kafka/src/main/java/example/order/Order.java new file mode 100644 index 00000000..d0bc7088 --- /dev/null +++ b/spring-modulith-examples/spring-modulith-example-kafka/src/main/java/example/order/Order.java @@ -0,0 +1,34 @@ +/* + * Copyright 2022-2023 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 + * + * https://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 example.order; + +import example.order.Order.OrderIdentifier; +import lombok.Getter; + +import java.util.UUID; + +import org.jmolecules.ddd.types.AggregateRoot; +import org.jmolecules.ddd.types.Identifier; + +/** + * @author Oliver Drotbohm + */ +public class Order implements AggregateRoot { + + private @Getter OrderIdentifier id = new OrderIdentifier(UUID.randomUUID()); + + public static record OrderIdentifier(UUID id) implements Identifier {} +} diff --git a/spring-modulith-examples/spring-modulith-example-kafka/src/main/java/example/order/OrderCompleted.java b/spring-modulith-examples/spring-modulith-example-kafka/src/main/java/example/order/OrderCompleted.java new file mode 100644 index 00000000..8bada87b --- /dev/null +++ b/spring-modulith-examples/spring-modulith-example-kafka/src/main/java/example/order/OrderCompleted.java @@ -0,0 +1,27 @@ +/* + * Copyright 2022-2023 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 + * + * https://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 example.order; + +import example.order.Order.OrderIdentifier; + +import org.jmolecules.event.types.DomainEvent; +import org.springframework.modulith.events.Externalized; + +/** + * @author Oliver Drotbohm + */ +@Externalized +public record OrderCompleted(OrderIdentifier orderId) implements DomainEvent {} diff --git a/spring-modulith-examples/spring-modulith-example-kafka/src/main/java/example/order/OrderManagement.java b/spring-modulith-examples/spring-modulith-example-kafka/src/main/java/example/order/OrderManagement.java new file mode 100644 index 00000000..05d84c52 --- /dev/null +++ b/spring-modulith-examples/spring-modulith-example-kafka/src/main/java/example/order/OrderManagement.java @@ -0,0 +1,38 @@ +/* + * Copyright 2022-2023 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 + * + * https://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 example.order; + +import lombok.NonNull; +import lombok.RequiredArgsConstructor; + +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * @author Oliver Drotbohm + */ +@Service +@RequiredArgsConstructor +public class OrderManagement { + + private final @NonNull ApplicationEventPublisher events; + + @Transactional + public void complete(Order order) { + events.publishEvent(new OrderCompleted(order.getId())); + } +} diff --git a/spring-modulith-examples/spring-modulith-example-kafka/src/main/java/example/order/package-info.java b/spring-modulith-examples/spring-modulith-example-kafka/src/main/java/example/order/package-info.java new file mode 100644 index 00000000..376d865b --- /dev/null +++ b/spring-modulith-examples/spring-modulith-example-kafka/src/main/java/example/order/package-info.java @@ -0,0 +1,8 @@ +/** + * The logical application module order implemented as a multi-package module. Internal components located in nested + * packages are prevented from being accessed by the {@link org.springframework.modulith.core.ApplicationModules} type. + * + * @see example.ModularityTests + */ +@org.springframework.lang.NonNullApi +package example.order; diff --git a/spring-modulith-examples/spring-modulith-example-kafka/src/main/resources/application.properties b/spring-modulith-examples/spring-modulith-example-kafka/src/main/resources/application.properties new file mode 100644 index 00000000..0b9ca8d5 --- /dev/null +++ b/spring-modulith-examples/spring-modulith-example-kafka/src/main/resources/application.properties @@ -0,0 +1 @@ +spring.jpa.show-sql=true diff --git a/spring-modulith-examples/spring-modulith-example-kafka/src/main/resources/logback.xml b/spring-modulith-examples/spring-modulith-example-kafka/src/main/resources/logback.xml new file mode 100644 index 00000000..e8831a40 --- /dev/null +++ b/spring-modulith-examples/spring-modulith-example-kafka/src/main/resources/logback.xml @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/spring-modulith-examples/spring-modulith-example-kafka/src/test/java/example/TestApplication.java b/spring-modulith-examples/spring-modulith-example-kafka/src/test/java/example/TestApplication.java new file mode 100644 index 00000000..813c3780 --- /dev/null +++ b/spring-modulith-examples/spring-modulith-example-kafka/src/test/java/example/TestApplication.java @@ -0,0 +1,66 @@ +/* + * Copyright 2023 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 + * + * https://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 example; + +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import example.order.Order; +import example.order.OrderManagement; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Primary; +import org.springframework.kafka.core.KafkaOperations; + +/** + * @author Oliver Drotbohm + */ +@SpringBootApplication +public class TestApplication { + + private static final Logger logger = LoggerFactory.getLogger(TestApplication.class); + + @Bean + @Primary + @SuppressWarnings("unchecked") + KafkaOperations kafkaOperations() { + + var mock = mock(KafkaOperations.class); + + when(mock.send(any(), any())).then(invocation -> { + + logger.info("Sending message {} to {}.", invocation.getArguments()[1], invocation.getArguments()[0]); + + return null; + }); + + return mock; + } + + public static void main(String[] args) { + + var orders = SpringApplication.run(TestApplication.class, args) + .getBean(OrderManagement.class); + + logger.info("Triggering order completion…"); + + orders.complete(new Order()); + } +} diff --git a/spring-modulith-starters/spring-modulith-starter-jdbc/pom.xml b/spring-modulith-starters/spring-modulith-starter-jdbc/pom.xml index 18285ad4..e0857219 100644 --- a/spring-modulith-starters/spring-modulith-starter-jdbc/pom.xml +++ b/spring-modulith-starters/spring-modulith-starter-jdbc/pom.xml @@ -35,7 +35,6 @@ org.springframework.modulith spring-modulith-events-core 1.1.0-SNAPSHOT - runtime org.springframework.modulith diff --git a/src/docs/asciidoc/40-events.adoc b/src/docs/asciidoc/30-events.adoc similarity index 63% rename from src/docs/asciidoc/40-events.adoc rename to src/docs/asciidoc/30-events.adoc index 8930bd68..dfb61179 100644 --- a/src/docs/asciidoc/40-events.adoc +++ b/src/docs/asciidoc/30-events.adoc @@ -124,8 +124,8 @@ For a more flexible arrangement, `EventPublicationRegistry` exposes a method ` .The transactional event listener arrangement after execution image::event-publication-registry-end.png[] -[[events.managing-publications]] -== Managing Event Publications +[[events.publication-registry.managing-publications]] +=== Managing Event Publications Event publications may need to be managed in a variety of ways during the runtime of an application. Incomplete publications might have to be re-submitted to the corresponding listeners after a given amount of time. @@ -148,8 +148,8 @@ This artifact contains two primary abstractions, that are available to applicati * `CompletedEventPublications` -- This interface allows accessing all completed event publications, and provides API to immediately purge all of them from the database or the completed publications older that a given duration (for example, 1 minute). * `IncompleteEventPublications`-- This interface allows accessing all incomplete event publications to resubmit either the ones matching a given predicate or older than a given `Duration` relative to the original publishing date. -[[events.publication-repositories]] -== Event Publication Repositories +[[events.publication-registry.publication-repositories]] +=== Event Publication Repositories To actually write the event publication log, Spring Modulith exposes an `EventPublicationRepository` SPI and implementations for popular persistence technologies that support transactions, like JPA, JDBC and MongoDB. You select the persistence technology to be used by adding the corresponding JAR to your Spring Modulith application. @@ -158,15 +158,15 @@ We have prepared dedicated <> to ease that task. The JDBC-based implementation can create a dedicated table for the event publication log when the respective configuration property (`spring.modulith.events.jdbc-schema-initialization.enabled`) is set to `true`. For details, please consult the <> in the appendix. -[[events.serialization]] -== Event Serializer +[[events.publication-registry.serialization]] +=== Event Serializer Each log entry contains the original event in serialized form. The `EventSerializer` abstraction contained in `spring-modulith-events-core` allows plugging different strategies for how to turn the event instances into a format suitable for the datastore. Spring Modulith provides a Jackson-based JSON implementation through the `spring-modulith-events-jackson` artifact, which registers a `JacksonEventSerializer` consuming an `ObjectMapper` through standard Spring Boot auto-configuration by default. -[[events.customize-publication-date]] -== Customizing the Event Publication Date +[[events.publication-registry.customize-publication-date]] +=== Customizing the Event Publication Date By default, the Event Publication Registry will use the date returned by the `Clock.systemUTC()` as event publication date. If you want to customize this, register a bean of type clock with the application context: @@ -182,45 +182,129 @@ class MyConfiguration { } ---- -[[events.starters]] -== Spring Boot Event Registry Starters +[[events.externalization]] +== Externalizing Events -Using the transactional event publication log requires a combination of artifacts added to your application. -To ease that task, Spring Modulith provides starter POMs that are centered around the <> to be used and default to the Jackson-based `EventSerializer` implementation. -The following starters are available: +Some of the events exchanged between application modules might be interesting to external systems. +Spring Modulith allows publishing selected events to a variety of message brokers. +To use that support you need to take the following steps: -* `spring-modulith-starter-jpa` -- Using JPA as persistence technology. -* `spring-modulith-starter-jdbc` -- Using JDBC as persistence technology. -Also works in JPA-based applications but bypasses your JPA provider for actual event persistence. -* `spring-modulith-starter-mongodb` -- Using MongoDB behind Spring Data MongoDB. -Also enables MongoDB transactions and requires a replica set setup of the server to interact with. -The transaction auto-configuration can be disabled by setting the `spring.modulith.events.mongobd.transaction-management.enabled` property to `false`. +1. Add the <> to your project. +2. Select event types to be externalized by annotating them with either Spring Modulith's or jMolecules' `@Externalized` annotation. +3. Specify the broker-specific routing target in the annotation's value. -[[events.integration-testing]] -== Integration Testing Application Modules Working with Events +To find out how to use other ways of selecting events for externalization, or customize their routing within the broker, check out <>. -Integration tests for application modules that interact with other modules' Spring beans usually have those mocked and the test cases verify the interaction by verifying that that mock bean was invoked in a particular way. +[[events.externalization.infrastructure]] +=== Supported Infrastructure -.Traditional integration testing of the application module interaction -[source, java, subs="quotes"] +[%header,cols="1,3,6"] +|=== +|Broker|Artifact|Description + +|Kafka +|`spring-modulith-events-kafka` +|Uses Spring Kafka for the interaction with the broker. +The logical routing key will be used as + +|AMQP +|`spring-modulith-events-amqp` +|Uses Spring AMQP for the interaction with any compatible broker. +Requires an explicit dependency declaration for Spring Rabbit for example. +The logical routing key will be used as AMQP routing key. + +|JMS +|`spring-modulith-events-jms` +|Uses Spring's core JMS support. +Does not support routing keys. +|=== + +[[events.externalization.fundamentals]] +=== Fundamentals of Event Externalization + +The event externalization performs three steps on each application event published. + +1. _Determining whether the event is supposed to be externalized_ -- We refer to this as "`event selection`". +By default, only event types located within a Spring Boot auto-configuration package and annotated with one of the supported `@Externalized` annotations are selected for externalization. +2. _Mapping the event (optional)_ -- By default, the event is serialized to JSON using the Jackson `ObjectMapper` present in the application and published as is. +The mapping step allows developers to either customize the representation or even completely replace the original event with a representation suitable for external parties. +Note, that the mapping step precedes the actual serialization of the to be published object. +3. _Determining a routing target_ -- Message broker clients need a logical target to publish the message to. +The target usually identifies physical infrastructure (a topic, exchange, or queue depending on the broker) and is often statically derived from the event type. +Unless defined in the `@Externalized` annotation specifically, Spring Modulith uses the application-local type name as target. +In other words, in a Spring Boot application with a base package of `com.acme.app`, an event type `com.acme.app.sample.SampleEvent` would get published to `sample.SampleEvent`. ++ +Some brokers also allow to define a rather dynamic routing key, that is used for different purposes within the actual target. +By default, no routing key is used. + +[[events.externalization.annotations]] +=== Annotation-based Event Externalization Configuration + +To define a custom routing key via the `@Externalized` annotations, a pattern of `$target::$key` can be used for the target/value attribute available in each of the particular annotations. +The key can be a SpEL expression which will get the event instance configured as root object. + +.Defining a dynamic routing key via SpEL expression +[source, java] ---- -@ApplicationModuleTest -class OrderIntegrationTests { +@Externalized("customer-created::#{#this.getLastname()}") // <2> +class CustomerCreated { - **@MockBean SomeOtherComponent someOtherComponent;** - - @Test - void someTestMethod() { - - // Given - // When - // Then - **verify(someOtherComponent).someMethodCall();** + String getLastname() { // <1> + // … } } ---- -In an event-based application interaction model, the dependency to the other application module's Spring bean is gone and we have nothing to verify. +The `CustomerCreated` event exposes the lastname of the customer via an accessor method. +That method is then used via the ``#this.getLastname()`` expression in key expression following the `::` delimiter of the target declaration. + +If the key calculation becomes more involved, it is advisable to rather delegate that into a Spring bean that takes the event as argument: + +.Invoking a Spring bean to calculate a routing key +[source, java] +---- +@Externalized("…::#{@beanName.someMethod(#this)}") +---- + +[[events.externalization.api]] +=== Programmatic Event Externalization Configuration + + +The `spring-modulith-events-api` artifact contains `EventExternalizationConfiguration` that allows developers to customize all of the above mentioned steps. + +.Programmatically configuring event externalization +[source, java] +---- +@Configuration +class ExternalizationConfiguration { + + @Bean + EventExternalizationConfiguration eventExternalizationConfiguration() { + + return EventExternalizationConfiguration.externalizing() // <1> + .select(EventExternalizationConfiguration.annotatedAsExternalized()) // <2> + .mapping(SomeEvent.class, it -> …) // <3> + .routeKey(WithKeyProperty.class, WithKeyProperty::getKey) // <4> + .build(); + } +} +---- +<1> We start by creating a default instance of `EventExternalizationConfiguration`. +<2> We customize the event selection by calling one of the `select(…)` methods on the `Selector` instance returned by the previous call. +This step fundamentally disables the application base package filter as we only look for the annotation now. +Convenience methods to easily select events by type, by packages, packages and annotation exist. +Also, a shortcut to define selection and routing in one step. +<3> We define a mapping step for `SomeEvent` instances. +Note, that the routing will still be determined by the original event instance, unless you additionally call `….routeMapped()` on the router. +<4> We finally determine a routing key by defining a method handle to extract a value of the event instance. +Alternatively, a full `RoutingKey` can be produced for individual events by using the general `route(…)` method on the `Router` instance returned from the previous call. + +[[events.testing]] +== Testing published events + +NOTE: The following section describes a testing approach solely focused on tracking Spring application events. +For a more holistic approach on testing modules that use <>, please check out the <>. + Spring Modulith's `@ApplicationModuleTest` enables the ability to get a `PublishedEvents` instance injected into the test method to verify a particular set of events has been published during the course of the business operation under test. .Event-based integration testing of the application module arrangement @@ -264,4 +348,17 @@ class OrderIntegrationTests { Note, how the type returned by the `assertThat(…)` expression allows to define constraints on the published events directly. +[[events.starters]] +== Spring Boot Event Registry Starters + +Using the transactional event publication log requires a combination of artifacts added to your application. +To ease that task, Spring Modulith provides starter POMs that are centered around the <> to be used and default to the Jackson-based `EventSerializer` implementation. +The following starters are available: + +* `spring-modulith-starter-jpa` -- Using JPA as persistence technology. +* `spring-modulith-starter-jdbc` -- Using JDBC as persistence technology. +Also works in JPA-based applications but bypasses your JPA provider for actual event persistence. +* `spring-modulith-starter-mongodb` -- Using MongoDB behind Spring Data MongoDB. +Also enables MongoDB transactions and requires a replica set setup of the server to interact with. +The transaction auto-configuration can be disabled by setting the `spring.modulith.events.mongobd.transaction-management.enabled` property to `false`. diff --git a/src/docs/asciidoc/30-testing.adoc b/src/docs/asciidoc/40-testing.adoc similarity index 100% rename from src/docs/asciidoc/30-testing.adoc rename to src/docs/asciidoc/40-testing.adoc diff --git a/src/docs/asciidoc/90-appendix.adoc b/src/docs/asciidoc/90-appendix.adoc index 75c2f07e..cadbbc7e 100644 --- a/src/docs/asciidoc/90-appendix.adoc +++ b/src/docs/asciidoc/90-appendix.adoc @@ -17,10 +17,18 @@ |`false` |Whether to initialize the JDBC event publication schema. +|`spring.modulith.events.kafka.json-enabled` +|`true` +|Whether to enable JSON support for `KafkaTemplate`. + |`spring.modulith.events.mongodb.transaction-management.enabled` |`true` |Whether to automatically enable transactions for MongoDB. Requires the database to be run with a replica set. +|`spring.modulith.events.rabbitmq.json-enabled` +|`true` +|Whether to enable JSON support for `RabbitTemplate`. + |`spring.modulith.moments.enableTimeMachine` |`false` |Whether to enable the <>. @@ -102,10 +110,14 @@ a|* `spring-modulith-docs` |`spring-modulith-api`|`compile`|The abstractions to be used in your production code to customize Spring Modulith's default behavior. |`spring-modulith-core`|`runtime`|The core application module model and API. |`spring-modulith-docs`|`test`|The `Documenter` API to create Asciidoctor and PlantUML documentation from the module model. +|`spring-modulith-events-amqp`|`runtime`|Event externalization support for AMQP. +|`spring-modulith-events-api`|`runtime`|API to customize the event features of Spring Modulith. |`spring-modulith-events-core`|`runtime`|The core implementation of the event publication registry as well as the integration abstractions `EventPublicationRegistry` and `EventPublicationSerializer`. |`spring-modulith-events-jackson`|`runtime`|A Jackson-based implementation of the `EventPublicationSerializer`. |`spring-modulith-events-jdbc`|`runtime`|A JDBC-based implementation of the `EventPublicationRegistry`. +|`spring-modulith-events-jms`|`runtime`|Event externalization support for JMS. |`spring-modulith-events-jpa`|`runtime`|A JPA-based implementation of the `EventPublicationRegistry`. +|`spring-modulith-events-kafka`|`runtime`|Event externalization support for Kafka. |`spring-modulith-events-mongodb`|`runtime`|A MongoDB-based implementation of the `EventPublicationRegistry`. |`spring-modulith-moments`|`compile`|The Passage of Time events implementation described <>. |`spring-modulith-runtime`|`runtime`|Support to bootstrap an `ApplicationModules` instance at runtime. Usually not directly depended on but transitively used by `spring-modulith-actuator` and `spring-modulith-observability`. diff --git a/src/docs/asciidoc/index.adoc b/src/docs/asciidoc/index.adoc index e281c3b9..b7d996ab 100644 --- a/src/docs/asciidoc/index.adoc +++ b/src/docs/asciidoc/index.adoc @@ -20,9 +20,9 @@ include::10-fundamentals.adoc[] include::20-verification.adoc[] -include::30-testing.adoc[] +include::30-events.adoc[] -include::40-events.adoc[] +include::40-testing.adoc[] include::50-moments.adoc[]