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 00000000..fb48b229 --- /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 fa6052d0..3f3cbcbe 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 9c5162fc..c6b40105 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 00000000..f3a2e6b3 --- /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 00000000..a2aa4148 --- /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: + * + * + * @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 00000000..035efcb3 --- /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 00000000..4355e63f --- /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 00000000..5e23196a --- /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 00000000..86553a40 --- /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 00000000..2646298a --- /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 3b651a98..55417670 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 00000000..26d0eded --- /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 00000000..a593479f --- /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 00000000..6156fd1f --- /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 00000000..387d319c --- /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 00000000..2d71934d --- /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 beedebd1..390d68d8 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 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-kafka/pom.xml b/spring-modulith-events/spring-modulith-events-kafka/pom.xml new file mode 100644 index 00000000..2b7f7fc9 --- /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 00000000..0e7d6c81 --- /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 00000000..bcdb57b4 --- /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 00000000..cbeecc83 --- /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 00000000..cc92de48 --- /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 00000000..572e5b05 --- /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 0b072cf1..5fa0dbc5 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..899bbcfb --- /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 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..7cc2ed31 --- /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 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..02995903 --- /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 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()); + } +}