From aca891c65c9e31142cd693b1fc927fef591b602c Mon Sep 17 00:00:00 2001 From: Oliver Drotbohm Date: Tue, 18 Jul 2023 19:02:11 +0200 Subject: [PATCH] GH-248 - Initial prototype to automatically externalize events. We now provide an SPI to plug custom implementations to externalize events that match an EventExternalizationConfiguration (EEC) into messaging technology. The first implementation of that API is based on Spring Kafka to consume a KafkaTemplate and translate events into Kafka messages. --- .../modulith/Externalized.java | 51 +++ spring-modulith-bom/pom.xml | 5 + spring-modulith-events/pom.xml | 2 + .../spring-modulith-events-api/pom.xml | 47 +++ .../events/AnnotationTargetLookup.java | 172 +++++++++ ...aultEventExternalizationConfiguration.java | 363 ++++++++++++++++++ .../EventExternalizationConfiguration.java | 42 ++ .../AnnotationTargetLookupUnitTests.java | 126 ++++++ ...ExternalizationConfigurationUnitTests.java | 98 +++++ .../src/test/resources/logback.xml | 14 + .../spring-modulith-events-core/pom.xml | 12 + ...EventExternalizationAutoConfiguration.java | 146 +++++++ .../events/core/ConditionalEventListener.java | 24 ++ .../events/core/EventExternalizer.java | 27 ++ .../support/AbstractEventExternalizer.java | 82 ++++ .../support/DelegatingEventExternalizer.java | 59 +++ ...PersistentApplicationEventMulticaster.java | 24 ++ ...ot.autoconfigure.AutoConfiguration.imports | 1 + .../spring-modulith-events-kafka/pom.xml | 42 ++ .../KafkaEventExternalizerConfiguration.java | 52 +++ .../kafka/SpringKafkaJsonConfiguration.java | 46 +++ .../spring-configuration-metadata.json | 10 + ...ot.autoconfigure.AutoConfiguration.imports | 2 + .../src/main/resources/kafka-json.properties | 1 + spring-modulith-examples/pom.xml | 1 + .../spring-modulith-example-kafka/pom.xml | 53 +++ .../spring-modulith-example-kafka/readme.adoc | 29 ++ .../src/main/java/example/Application.java | 35 ++ .../src/main/java/example/order/Order.java | 34 ++ .../java/example/order/OrderCompleted.java | 27 ++ .../java/example/order/OrderManagement.java | 38 ++ .../main/java/example/order/package-info.java | 8 + .../src/main/resources/application.properties | 1 + .../src/main/resources/logback.xml | 17 + .../test/java/example/TestApplication.java | 66 ++++ 35 files changed, 1757 insertions(+) create mode 100644 spring-modulith-api/src/main/java/org/springframework/modulith/Externalized.java create mode 100644 spring-modulith-events/spring-modulith-events-api/pom.xml create mode 100644 spring-modulith-events/spring-modulith-events-api/src/main/java/org/springframework/modulith/events/AnnotationTargetLookup.java create mode 100644 spring-modulith-events/spring-modulith-events-api/src/main/java/org/springframework/modulith/events/DefaultEventExternalizationConfiguration.java create mode 100644 spring-modulith-events/spring-modulith-events-api/src/main/java/org/springframework/modulith/events/EventExternalizationConfiguration.java create mode 100644 spring-modulith-events/spring-modulith-events-api/src/test/java/org/springframework/modulith/events/AnnotationTargetLookupUnitTests.java create mode 100644 spring-modulith-events/spring-modulith-events-api/src/test/java/org/springframework/modulith/events/DefaultEventExternalizationConfigurationUnitTests.java create mode 100644 spring-modulith-events/spring-modulith-events-api/src/test/resources/logback.xml create mode 100644 spring-modulith-events/spring-modulith-events-core/src/main/java/org/springframework/modulith/events/config/EventExternalizationAutoConfiguration.java create mode 100644 spring-modulith-events/spring-modulith-events-core/src/main/java/org/springframework/modulith/events/core/ConditionalEventListener.java create mode 100644 spring-modulith-events/spring-modulith-events-core/src/main/java/org/springframework/modulith/events/core/EventExternalizer.java create mode 100644 spring-modulith-events/spring-modulith-events-core/src/main/java/org/springframework/modulith/events/support/AbstractEventExternalizer.java create mode 100644 spring-modulith-events/spring-modulith-events-core/src/main/java/org/springframework/modulith/events/support/DelegatingEventExternalizer.java create mode 100644 spring-modulith-events/spring-modulith-events-kafka/pom.xml create mode 100644 spring-modulith-events/spring-modulith-events-kafka/src/main/java/org/springframework/modulith/events/kafka/KafkaEventExternalizerConfiguration.java create mode 100644 spring-modulith-events/spring-modulith-events-kafka/src/main/java/org/springframework/modulith/events/kafka/SpringKafkaJsonConfiguration.java create mode 100644 spring-modulith-events/spring-modulith-events-kafka/src/main/resources/META-INF/spring-configuration-metadata.json create mode 100644 spring-modulith-events/spring-modulith-events-kafka/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports create mode 100644 spring-modulith-events/spring-modulith-events-kafka/src/main/resources/kafka-json.properties create mode 100644 spring-modulith-examples/spring-modulith-example-kafka/pom.xml create mode 100644 spring-modulith-examples/spring-modulith-example-kafka/readme.adoc create mode 100644 spring-modulith-examples/spring-modulith-example-kafka/src/main/java/example/Application.java create mode 100644 spring-modulith-examples/spring-modulith-example-kafka/src/main/java/example/order/Order.java create mode 100644 spring-modulith-examples/spring-modulith-example-kafka/src/main/java/example/order/OrderCompleted.java create mode 100644 spring-modulith-examples/spring-modulith-example-kafka/src/main/java/example/order/OrderManagement.java create mode 100644 spring-modulith-examples/spring-modulith-example-kafka/src/main/java/example/order/package-info.java create mode 100644 spring-modulith-examples/spring-modulith-example-kafka/src/main/resources/application.properties create mode 100644 spring-modulith-examples/spring-modulith-example-kafka/src/main/resources/logback.xml create mode 100644 spring-modulith-examples/spring-modulith-example-kafka/src/test/java/example/TestApplication.java diff --git a/spring-modulith-api/src/main/java/org/springframework/modulith/Externalized.java b/spring-modulith-api/src/main/java/org/springframework/modulith/Externalized.java new file mode 100644 index 000000000..fb48b2291 --- /dev/null +++ b/spring-modulith-api/src/main/java/org/springframework/modulith/Externalized.java @@ -0,0 +1,51 @@ +/* + * 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; + +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. + * + * @author Oliver Drotbohm + */ +@Retention(RetentionPolicy.RUNTIME) +@Target({ ElementType.TYPE }) +public @interface Externalized { + + /** + * The logical target name. Will default to a strategy defined by configuration if empty. + * + * @return + * @see #target() + */ + @AliasFor("target") + String value() default ""; + + /** + * The logical target name. Will default to a strategy defined by configuration if empty. + * + * @return + * @see #value() + */ + @AliasFor("value") + String target() default ""; +} diff --git a/spring-modulith-bom/pom.xml b/spring-modulith-bom/pom.xml index fa6052d04..3f3cbcbe2 100644 --- a/spring-modulith-bom/pom.xml +++ b/spring-modulith-bom/pom.xml @@ -64,6 +64,11 @@ 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 9c5162fce..c6b40105b 100644 --- a/spring-modulith-events/pom.xml +++ b/spring-modulith-events/pom.xml @@ -14,11 +14,13 @@ Spring Modulith - Events + 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-kafka diff --git a/spring-modulith-events/spring-modulith-events-api/pom.xml b/spring-modulith-events/spring-modulith-events-api/pom.xml new file mode 100644 index 000000000..f3a2e6b3f --- /dev/null +++ b/spring-modulith-events/spring-modulith-events-api/pom.xml @@ -0,0 +1,47 @@ + + + 4.0.0 + + + org.springframework.modulith + spring-modulith-events + 1.1.0-SNAPSHOT + + + Spring Modulith - Events - API + spring-modulith-events-api + + + org.springframework.modulith.events.api + + + + + + org.springframework.modulith + spring-modulith-api + ${project.version} + + + + org.springframework + spring-core + + + + 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 000000000..a2aa41483 --- /dev/null +++ b/spring-modulith-events/spring-modulith-events-api/src/main/java/org/springframework/modulith/events/AnnotationTargetLookup.java @@ -0,0 +1,172 @@ +/* + * 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.Externalized; +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: + *
    + *
  • Spring Modulith's {@link Externalized}
  • + *
  • jMolecules {@link org.jmolecules.event.annotation.Externalized} (if present on the classpath)
  • + *
+ * + * @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(); + + static { + + } + + 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); + } + + static boolean hasExternalizedAnnotation(Object event) { + + 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(); + } + + 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 000000000..035efcb32 --- /dev/null +++ b/spring-modulith-events/spring-modulith-events-api/src/main/java/org/springframework/modulith/events/DefaultEventExternalizationConfiguration.java @@ -0,0 +1,363 @@ +/* + * 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.Externalized; +import org.springframework.util.Assert; + +/** + * @author Oliver Drotbohm + */ +public class DefaultEventExternalizationConfiguration implements EventExternalizationConfiguration { + + private static final Predicate DEFAULT_FILTER = it -> true; + private static final Function DEFAULT_ROUTER = it -> Optional.of(it) + .flatMap(byExternalizedAnnotations()) + .orElseGet(() -> byFullyQualifiedTypeName().apply(it)); + + private final Predicate filter; + private final Function mapper; + private final Function router; + + static { + + } + + /** + * 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) { + + this.filter = filter; + this.mapper = mapper; + this.router = router; + } + + /** + * Creates a default {@link DefaultEventExternalizationConfiguration} with the following characteristics: + *
    + *
  • Only events that reside in any application auto-configuration package and are annotated with + * {@link Externalized} will be selected for externalization.
  • + *
  • Routing information is discovered from the {@link 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 DefaultEventExternalizationConfiguration defaults(Collection packages) { + + Assert.notEmpty(packages, "Packages must not be null or empty!"); + + Function router = it -> Optional.of(it) + .flatMap(byExternalizedAnnotations()) + .or(() -> byApplicationLocalName(packages).apply(it)) + .orElseGet(() -> byFullyQualifiedTypeName().apply(it)); + + return DefaultEventExternalizationConfiguration.builder() + .selectByPackagesAndAnnotation(packages, AnnotationTargetLookup::hasExternalizedAnnotation) + .route(router); + } + + public static Selector builder() { + return new Selector(); + } + + /* + * (non-Javadoc) + * @see org.springframework.modulith.events.foo.EventExternalizerFilter#supports(java.lang.Object) + */ + @Override + public boolean supports(Object event) { + return filter.test(event); + } + + /* + * (non-Javadoc) + * @see org.springframework.modulith.events.foo.EventExternalizationConfiguration#map(java.lang.Object) + */ + @Override + public Object map(Object event) { + return mapper.apply(event); + } + + /* + * (non-Javadoc) + * @see org.springframework.modulith.events.foo.EventExternalizerFilter#determineTarget(java.lang.Object) + */ + @Override + public RoutingTarget determineTarget(Object event) { + return new RoutingTarget(router.apply(event)); + } + + public static Function> byApplicationLocalName(Collection packages) { + + return toEventType().andThen(type -> packages.stream() + .filter(it -> type.getPackageName().startsWith(it)) + .map(it -> type.getName().substring(it.length() + 1)) + .findFirst()); + } + + /** + * 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); + } + + private static Function> toEventType() { + return event -> event.getClass(); + } + + public static class Selector { + + private final @Nullable Predicate predicate; + + 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 + * + * @param basePackages must not be {@literal null} or empty. + * @param filter must not be {@literal null}. + * @return + */ + public final Router selectByPackagesAndAnnotation(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())); + } + + public EventExternalizationConfiguration selectAndRoute(Class annotationType, + Function router) { + + Function extractor = it -> findAnnotation(it, annotationType); + + return selectByAnnotation(annotationType).route(it -> extractor.andThen(router).apply(it)); + } + + public EventExternalizationConfiguration selectAndRoute(Class annotationType, + BiFunction router) { + + return selectByAnnotation(annotationType) + .route(it -> router.apply(it, findAnnotation(it, annotationType))); + } + + private static T findAnnotation(Object event, Class annotationType) { + return findMergedAnnotation(event.getClass(), annotationType); + } + } + + public static class Router { + + private final Predicate filter; + private final Function mapper; + private final @Nullable Function router; + + Router(Predicate filter, Function mapper, Function router) { + + this.filter = filter; + this.mapper = mapper; + this.router = router; + } + + Router(Predicate filter) { + this(filter, Function.identity(), DEFAULT_ROUTER); + } + + public Router mapping(Function mapper) { + return new Router(filter, mapper, router); + } + + public Router mapping(Class type, Function mapper) { + + Function combined = it -> { + return type.isInstance(it) + ? mapper.apply(type.cast(it)) + : it; + }; + + return new Router(filter, this.mapper.compose(combined), router); + } + + public Router routeMapped() { + return new Router(filter, mapper, router.compose(mapper)); + } + + public DefaultEventExternalizationConfiguration route(Function router) { + return new Router(filter, mapper, router).build(); + } + + /** + * 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 currently configured routing. + * + * @param router must not be {@literal null}. + * @return will never be {@literal null}. + */ + public DefaultEventExternalizationConfiguration routeOptional(Function> router) { + + Assert.notNull(router, "Router must not be null!"); + + Function foo = it -> router.apply(it).orElseGet(() -> this.router.apply(it)); + + return new Router(filter, mapper, foo).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 currently configured routing. + * + * @param router must not be {@literal null}. + * @return will never be {@literal null}. + */ + public DefaultEventExternalizationConfiguration routeOptionalByType( + Function, Optional> router) { + return routeOptional(it -> router.apply(it.getClass())); + } + + public Router routeByType(Function, String> router) { + return new Router(filter, mapper, it -> router.apply(it.getClass())); + } + + public DefaultEventExternalizationConfiguration routeByTypeName() { + return new Router(filter, mapper, DEFAULT_ROUTER).build(); + } + + public DefaultEventExternalizationConfiguration build() { + return new DefaultEventExternalizationConfiguration(filter, mapper, router); + } + } +} 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 000000000..4355e63f9 --- /dev/null +++ b/spring-modulith-events/spring-modulith-events-api/src/main/java/org/springframework/modulith/events/EventExternalizationConfiguration.java @@ -0,0 +1,42 @@ +/* + * 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 org.springframework.modulith.events.DefaultEventExternalizationConfiguration.Selector; + +/** + * @author Oliver Drotbohm + */ +public interface EventExternalizationConfiguration { + + boolean supports(Object event); + + /** + * Map the event to be externalized before + * + * @param event + * @return + */ + Object map(Object event); + + RoutingTarget determineTarget(Object event); + + public static Selector externalizing() { + return new Selector(); + } + + record RoutingTarget(String value) {} +} 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 000000000..5e23196a1 --- /dev/null +++ b/spring-modulith-events/spring-modulith-events-api/src/test/java/org/springframework/modulith/events/AnnotationTargetLookupUnitTests.java @@ -0,0 +1,126 @@ +/* + * 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.AnnotationTargetLookup; +import org.springframework.test.util.ReflectionTestUtils; + +/** + * Unit tests for {@link AnnotationTargetLookup}. + * + * @author Oliver Drotbohm + */ +class AnnotationTargetLookupUnitTests { + + @TestFactory // GH-248 + Stream detectesModulithExternalizedTarget() { + + var tests = Stream.of( + new $(Unannotated.class, null), + new $(WithJMoleculesExternalizedValue.class, "jMoleculesTarget"), + new $(WithJMoleculesExternalizedTarget.class, "jMoleculesTarget"), + new $(WithModulithExternalizedValue.class, "modulithTarget"), + new $(WithModulithExternalizedTarget.class, "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.Externalized("modulithTarget") + class WithModulithExternalizedValue {} + + @org.springframework.modulith.Externalized(target = "modulithTarget") + class WithModulithExternalizedTarget {} + + record $(Class type, String 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/DefaultEventExternalizationConfigurationUnitTests.java b/spring-modulith-events/spring-modulith-events-api/src/test/java/org/springframework/modulith/events/DefaultEventExternalizationConfigurationUnitTests.java new file mode 100644 index 000000000..86553a408 --- /dev/null +++ b/spring-modulith-events/spring-modulith-events-api/src/test/java/org/springframework/modulith/events/DefaultEventExternalizationConfigurationUnitTests.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 static org.assertj.core.api.Assertions.*; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +import org.junit.jupiter.api.Test; +import org.springframework.modulith.events.DefaultEventExternalizationConfiguration; +import org.springframework.modulith.events.EventExternalizationConfiguration; +import org.springframework.modulith.events.EventExternalizationConfiguration.RoutingTarget; + +/** + * Unit tests for {@link DefaultEventExternalizationConfiguration}. + * + * @author Oliver Drotbohm + */ +class DefaultEventExternalizationConfigurationUnitTests { + + @Test // GH-248 + void filtersEventByAnnotation() { + + var filter = EventExternalizationConfiguration.externalizing() + .selectByAnnotation(Externalized.class) + .build(); + + var event = new SampleEvent(); + + assertThat(filter.supports(event)).isTrue(); + assertThat(filter.supports(new Object())).isFalse(); + assertThat(filter.determineTarget(event)) + .isEqualTo(new RoutingTarget(SampleEvent.class.getName())); + } + + @Test // GH-248 + void routesByAnnotationAttribute() { + + var filter = EventExternalizationConfiguration.externalizing() + .selectAndRoute(Externalized.class, Externalized::value); + + var event = new SampleEvent(); + + assertThat(filter.supports(event)).isTrue(); + assertThat(filter.determineTarget(event)).isEqualTo(new RoutingTarget("target")); + } + + @Test // GH-248 + void mapsSourceEventBeforeSerializing() { + + var configuration = EventExternalizationConfiguration.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 = EventExternalizationConfiguration.externalizing() + .select(__ -> true) + .mapping(SampleEvent.class, it -> "foo") + .routeMapped() + .build(); + + assertThat(configuration.determineTarget(new SampleEvent())) + .isEqualTo(new RoutingTarget(String.class.getName())); + } + + @Retention(RetentionPolicy.RUNTIME) + @interface Externalized { + String value() default ""; + } + + @Externalized("target") + static class SampleEvent {} + + static class AnotherSampleEvent {} +} 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 000000000..2646298a2 --- /dev/null +++ b/spring-modulith-events/spring-modulith-events-api/src/test/resources/logback.xml @@ -0,0 +1,14 @@ + + + + + + %d %5p %40.40c:%4L - %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 3b651a982..55417670e 100644 --- a/spring-modulith-events/spring-modulith-events-core/pom.xml +++ b/spring-modulith-events/spring-modulith-events-core/pom.xml @@ -18,6 +18,18 @@ + + org.springframework.modulith + spring-modulith-api + ${project.version} + + + + org.springframework.modulith + spring-modulith-events-api + ${project.version} + + org.springframework spring-context 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 000000000..26d0eded4 --- /dev/null +++ b/spring-modulith-events/spring-modulith-events-core/src/main/java/org/springframework/modulith/events/config/EventExternalizationAutoConfiguration.java @@ -0,0 +1,146 @@ +/* + * 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.DefaultEventExternalizationConfiguration; +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; + +/** + * @author Oliver Drotbohm + */ +@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 DefaultEventExternalizationConfiguration eventExternalizationConfiguration(BeanFactory factory) { + + var packages = AutoConfigurationPackages.get(factory); + + return DefaultEventExternalizationConfiguration.defaults(packages); + } + + /** + * 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 000000000..a593479fe --- /dev/null +++ b/spring-modulith-events/spring-modulith-events-core/src/main/java/org/springframework/modulith/events/core/ConditionalEventListener.java @@ -0,0 +1,24 @@ +/* + * 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; + +/** + * @author Oliver Drotbohm + */ +public interface ConditionalEventListener { + + boolean supports(Object event); +} diff --git a/spring-modulith-events/spring-modulith-events-core/src/main/java/org/springframework/modulith/events/core/EventExternalizer.java b/spring-modulith-events/spring-modulith-events-core/src/main/java/org/springframework/modulith/events/core/EventExternalizer.java new file mode 100644 index 000000000..6156fd1fc --- /dev/null +++ b/spring-modulith-events/spring-modulith-events-core/src/main/java/org/springframework/modulith/events/core/EventExternalizer.java @@ -0,0 +1,27 @@ +/* + * 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; + +import org.springframework.modulith.ApplicationModuleListener; + +/** + * @author Oliver Drotbohm + */ +public interface EventExternalizer { + + @ApplicationModuleListener + void externalize(Object event); +} diff --git a/spring-modulith-events/spring-modulith-events-core/src/main/java/org/springframework/modulith/events/support/AbstractEventExternalizer.java b/spring-modulith-events/spring-modulith-events-core/src/main/java/org/springframework/modulith/events/support/AbstractEventExternalizer.java new file mode 100644 index 000000000..387d319c4 --- /dev/null +++ b/spring-modulith-events/spring-modulith-events-core/src/main/java/org/springframework/modulith/events/support/AbstractEventExternalizer.java @@ -0,0 +1,82 @@ +/* + * 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.EventExternalizationConfiguration.RoutingTarget; +import org.springframework.modulith.events.core.ConditionalEventListener; +import org.springframework.modulith.events.core.EventExternalizer; +import org.springframework.util.Assert; + +/** + * @author Oliver Drotbohm + */ +abstract class AbstractEventExternalizer implements EventExternalizer, ConditionalEventListener { + + private static final Logger logger = LoggerFactory.getLogger(AbstractEventExternalizer.class.getClass()); + + private final EventExternalizationConfiguration configuration; + + /** + * Creates a new {@link AbstractEventExternalizer} for the given {@link EventExternalizationConfiguration}. + * + * @param configuration must not be {@literal null}. + */ + protected AbstractEventExternalizer(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); + } + + /* + * (non-Javadoc) + * @see org.springframework.modulith.events.foo.EventExternalizer#externalize(java.lang.Object) + */ + @Override + @ApplicationModuleListener + public void externalize(Object event) { + + 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(target, mapped); + } + + protected abstract void externalize(RoutingTarget target, Object payload); +} 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 000000000..2d71934d1 --- /dev/null +++ b/spring-modulith-events/spring-modulith-events-core/src/main/java/org/springframework/modulith/events/support/DelegatingEventExternalizer.java @@ -0,0 +1,59 @@ +/* + * 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.EventExternalizationConfiguration.RoutingTarget; + +/** + * @author Oliver Drotbohm + */ +public class DelegatingEventExternalizer extends AbstractEventExternalizer { + + private final BiConsumer delegate; + + /** + * @param configuration + * @param delegate + */ + public DelegatingEventExternalizer(EventExternalizationConfiguration configuration, + BiConsumer delegate) { + super(configuration); + this.delegate = delegate; + } + + /* + * (non-Javadoc) + * @see org.springframework.modulith.events.foo.AbstractEventExternalizer#externalize(java.lang.Object) + */ + @Override + @ApplicationModuleListener + public void externalize(Object event) { + super.externalize(event); + } + + /* + * (non-Javadoc) + * @see org.springframework.modulith.events.externalize.AbstractEventExternalizer#externalize(org.springframework.modulith.events.externalize.EventExternalizationConfiguration.RoutingTarget, java.lang.Object) + */ + @Override + protected void externalize(RoutingTarget target, Object payload) { + delegate.accept(target, payload); + } +} 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 beedebd1f..390d68d8d 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 @@ -33,6 +33,7 @@ import org.springframework.core.annotation.AnnotationAwareOrderComparator; import org.springframework.core.env.Environment; import org.springframework.lang.NonNull; +import org.springframework.modulith.events.core.ConditionalEventListener; import org.springframework.modulith.events.core.EventPublication; import org.springframework.modulith.events.core.EventPublicationRegistry; import org.springframework.modulith.events.core.PublicationTargetIdentifier; @@ -109,6 +110,22 @@ public void multicastEvent(ApplicationEvent event, ResolvableType eventType) { } } + /* + * (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.beans.factory.SmartInitializingSingleton#afterSingletonsInstantiated() @@ -169,6 +186,13 @@ private static Object getEventToPersist(ApplicationEvent event) { : event; } + private static boolean matches(Object event, ApplicationListener listener) { + + return ConditionalEventListener.class.isInstance(listener) + ? ConditionalEventListener.class.cast(listener).supports(event) + : true; + } + /** * First-class collection to work with transactional event listeners, i.e. {@link ApplicationListener} instances that * implement {@link TransactionalApplicationListener}. 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 0e82e46ed..e5a67a4ed 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-kafka/pom.xml b/spring-modulith-events/spring-modulith-events-kafka/pom.xml new file mode 100644 index 000000000..2b7f7fc9a --- /dev/null +++ b/spring-modulith-events/spring-modulith-events-kafka/pom.xml @@ -0,0 +1,42 @@ + + + 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 + + + + \ No newline at end of file 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 000000000..0e7d6c81c --- /dev/null +++ b/spring-modulith-events/spring-modulith-events-kafka/src/main/java/org/springframework/modulith/events/kafka/KafkaEventExternalizerConfiguration.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.kafka; + +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.kafka.core.KafkaOperations; +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.modulith.events.EventExternalizationConfiguration; +import org.springframework.modulith.events.config.EventPublicationAutoConfiguration; +import org.springframework.modulith.events.support.DelegatingEventExternalizer; + +/** + * Auto-configuration to set up a {@link DelegatingEventExternalizer} to externalize events to Kafka. + * + * @author Oliver Drotbohm + */ +@AutoConfiguration +@AutoConfigureAfter(EventPublicationAutoConfiguration.class) +@ConditionalOnClass(KafkaTemplate.class) +class KafkaEventExternalizerConfiguration { + + private static final Logger logger = LoggerFactory.getLogger(KafkaEventExternalizerConfiguration.class); + + @Bean + DelegatingEventExternalizer kafkaEventExternalizer(EventExternalizationConfiguration configuration, + KafkaOperations operations) { + + logger.debug("Registering domain event externalization to Kafka…"); + + return new DelegatingEventExternalizer(configuration, (target, payload) -> { + operations.send(target.value(), payload); + }); + } +} diff --git a/spring-modulith-events/spring-modulith-events-kafka/src/main/java/org/springframework/modulith/events/kafka/SpringKafkaJsonConfiguration.java b/spring-modulith-events/spring-modulith-events-kafka/src/main/java/org/springframework/modulith/events/kafka/SpringKafkaJsonConfiguration.java new file mode 100644 index 000000000..bcdb57b47 --- /dev/null +++ b/spring-modulith-events/spring-modulith-events-kafka/src/main/java/org/springframework/modulith/events/kafka/SpringKafkaJsonConfiguration.java @@ -0,0 +1,46 @@ +/* + * 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 + */ +@AutoConfiguration +@ConditionalOnClass(ObjectMapper.class) +@ConditionalOnProperty(name = "spring.modulith.kafka.enable-json", havingValue = "true", matchIfMissing = true) +@PropertySource("classpath:kafka-json.properties") +class SpringKafkaJsonConfiguration { + + @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 000000000..cbeecc83b --- /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.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 000000000..cc92de485 --- /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.SpringKafkaJsonConfiguration 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 000000000..572e5b05b --- /dev/null +++ b/spring-modulith-events/spring-modulith-events-kafka/src/main/resources/kafka-json.properties @@ -0,0 +1 @@ +spring.kafka.producer.value-serializer=org.springframework.kafka.support.serializer.JsonSerializer diff --git a/spring-modulith-examples/pom.xml b/spring-modulith-examples/pom.xml index 0b072cf1f..5fa0dbc59 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 000000000..899bbcfbc --- /dev/null +++ b/spring-modulith-examples/spring-modulith-example-kafka/pom.xml @@ -0,0 +1,53 @@ + + 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 + ${project.version} + + + + 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 000000000..c163a7976 --- /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 000000000..17dba184a --- /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 000000000..d0bc7088c --- /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 000000000..7cc2ed310 --- /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.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 000000000..05d84c52c --- /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 000000000..376d865b3 --- /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 000000000..0b9ca8d52 --- /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 000000000..029959030 --- /dev/null +++ b/spring-modulith-examples/spring-modulith-example-kafka/src/main/resources/logback.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + 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 000000000..813c3780b --- /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()); + } +}