diff --git a/customer-api-provider/src/main/java/de/samples/schulung/quarkus/domain/CustomersService.java b/customer-api-provider/src/main/java/de/samples/schulung/quarkus/domain/CustomersService.java index cbbda5e..003dde7 100644 --- a/customer-api-provider/src/main/java/de/samples/schulung/quarkus/domain/CustomersService.java +++ b/customer-api-provider/src/main/java/de/samples/schulung/quarkus/domain/CustomersService.java @@ -1,11 +1,12 @@ package de.samples.schulung.quarkus.domain; import de.samples.schulung.quarkus.domain.Customer.CustomerState; +import de.samples.schulung.quarkus.shared.FireEvent; +import de.samples.schulung.quarkus.shared.LogPerformance; import jakarta.enterprise.context.ApplicationScoped; -import jakarta.enterprise.event.Event; import jakarta.validation.Valid; import jakarta.validation.constraints.NotNull; -import lombok.RequiredArgsConstructor; +import org.jboss.logging.Logger; import java.util.HashMap; import java.util.Map; @@ -14,13 +15,10 @@ import java.util.stream.Stream; @ApplicationScoped -@RequiredArgsConstructor public class CustomersService { private final Map customers = new HashMap<>(); - private final Event eventPublisher; - public long getCount() { return customers.size(); } @@ -40,10 +38,11 @@ public Optional findCustomerByUuid(@NotNull UUID uuid) { return Optional.ofNullable(customers.get(uuid)); } + @LogPerformance(Logger.Level.DEBUG) + @FireEvent(CustomerCreatedEvent.class) public void createCustomer(@Valid @NotNull Customer customer) { customer.setUuid(UUID.randomUUID()); customers.put(customer.getUuid(), customer); - eventPublisher.fireAsync(new CustomerCreatedEvent(customer)); } public void updateCustomer(@Valid @NotNull Customer customer) { diff --git a/customer-api-provider/src/main/java/de/samples/schulung/quarkus/shared/AnnotationUtils.java b/customer-api-provider/src/main/java/de/samples/schulung/quarkus/shared/AnnotationUtils.java new file mode 100644 index 0000000..2a9f385 --- /dev/null +++ b/customer-api-provider/src/main/java/de/samples/schulung/quarkus/shared/AnnotationUtils.java @@ -0,0 +1,23 @@ +package de.samples.schulung.quarkus.shared; + +import java.lang.annotation.Annotation; +import java.lang.reflect.Method; +import java.util.Optional; + +class AnnotationUtils { + + private AnnotationUtils() { + } + + static Optional findAnnotation(Method method, Class annotationClass) { + return Optional + .ofNullable(method.getAnnotation(annotationClass)) + .or(() -> findAnnotation(method.getDeclaringClass(), annotationClass)); + } + + static Optional findAnnotation(Class clazz, Class annotationClass) { + return Optional + .ofNullable(clazz.getAnnotation(annotationClass)); + } + +} diff --git a/customer-api-provider/src/main/java/de/samples/schulung/quarkus/shared/FireEvent.java b/customer-api-provider/src/main/java/de/samples/schulung/quarkus/shared/FireEvent.java new file mode 100644 index 0000000..e35bdee --- /dev/null +++ b/customer-api-provider/src/main/java/de/samples/schulung/quarkus/shared/FireEvent.java @@ -0,0 +1,49 @@ +package de.samples.schulung.quarkus.shared; + +import jakarta.enterprise.util.Nonbinding; +import jakarta.interceptor.InterceptorBinding; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +import java.lang.annotation.*; + +/** + * Annotate a method to get an event fired after method execution. + */ +@Inherited +@Documented +@InterceptorBinding +@Target({ElementType.METHOD, ElementType.TYPE}) +@Retention(RetentionPolicy.RUNTIME) +public @interface FireEvent { + + /** + * The event class. This class needs a constructor with the same parameters as the method. + * + * @return the event class + */ + @Nonbinding Class value(); + + /** + * Whether the event has to be fired synchronously, asynchronously or both. + * Defaults to both. + * + * @return the mode + */ + @Nonbinding FireMode mode() default FireMode.SYNC_AND_ASYNC; + + @RequiredArgsConstructor + @Getter(AccessLevel.PACKAGE) + enum FireMode { + + ONLY_SYNC(true, false), + ONLY_ASYNC(false, true), + SYNC_AND_ASYNC(true, true); + + private final boolean fireSync; + private final boolean fireAsync; + + } + +} diff --git a/customer-api-provider/src/main/java/de/samples/schulung/quarkus/shared/FireEventInterceptor.java b/customer-api-provider/src/main/java/de/samples/schulung/quarkus/shared/FireEventInterceptor.java new file mode 100644 index 0000000..146ac91 --- /dev/null +++ b/customer-api-provider/src/main/java/de/samples/schulung/quarkus/shared/FireEventInterceptor.java @@ -0,0 +1,62 @@ +package de.samples.schulung.quarkus.shared; + +import jakarta.annotation.Priority; +import jakarta.enterprise.event.Event; +import jakarta.inject.Inject; +import jakarta.interceptor.AroundInvoke; +import jakarta.interceptor.Interceptor; +import jakarta.interceptor.InvocationContext; +import lombok.SneakyThrows; + +import java.util.Optional; + +@Priority(5) +@Interceptor +@FireEvent(Object.class) +public class FireEventInterceptor { + + @Inject + Event eventPublisher; + + @SneakyThrows + private static T createEventObject(InvocationContext invocation, Class eventType) { + return eventType + .getConstructor(invocation.getMethod().getParameterTypes()) + .newInstance(invocation.getParameters()); + } + + @AroundInvoke + public Object fireEvent(InvocationContext invocation) throws Exception { + final Optional annotation = AnnotationUtils + .findAnnotation(invocation.getMethod(), FireEvent.class); + @SuppressWarnings("unchecked") final Optional> eventType = AnnotationUtils + .findAnnotation(invocation.getMethod(), FireEvent.class) + .map((FireEvent publishEvent) -> (Class) publishEvent.value()); + final FireEvent.FireMode mode = annotation + .map(FireEvent::mode) + .orElse(FireEvent.FireMode.SYNC_AND_ASYNC); + final Optional event = eventType + .map(clazz -> createEventObject(invocation, clazz)); + // if something is wrong until here, we do not invoke the service's create-method + // now, we invoke the service + final Object result = invocation.proceed(); + // if an exception occured, the event is not fired + // now, we fire the event + event.ifPresent( + e -> eventType + .map(eventPublisher::select) + .ifPresent(publisher -> { + // fire synchronous events + if (mode.isFireSync()) { + publisher.fire(e); + } + // if no error occured, fire asynchronous events + if (mode.isFireAsync()) { + publisher.fireAsync(e); + } + }) + ); + // and we need to return the service's result to the invoker (the controller) + return result; + } +} diff --git a/customer-api-provider/src/main/java/de/samples/schulung/quarkus/shared/LogPerformance.java b/customer-api-provider/src/main/java/de/samples/schulung/quarkus/shared/LogPerformance.java new file mode 100644 index 0000000..c4a6547 --- /dev/null +++ b/customer-api-provider/src/main/java/de/samples/schulung/quarkus/shared/LogPerformance.java @@ -0,0 +1,22 @@ +package de.samples.schulung.quarkus.shared; + +import jakarta.enterprise.util.Nonbinding; +import jakarta.interceptor.InterceptorBinding; +import org.jboss.logging.Logger; + +import java.lang.annotation.*; + +@Retention(RetentionPolicy.RUNTIME) +@Target({ + ElementType.METHOD, + ElementType.TYPE +}) +@Documented +@Inherited +@InterceptorBinding +public @interface LogPerformance { + + @Nonbinding + Logger.Level value() default Logger.Level.INFO; + +} diff --git a/customer-api-provider/src/main/java/de/samples/schulung/quarkus/shared/LogPerformanceInterceptor.java b/customer-api-provider/src/main/java/de/samples/schulung/quarkus/shared/LogPerformanceInterceptor.java new file mode 100644 index 0000000..2edba1d --- /dev/null +++ b/customer-api-provider/src/main/java/de/samples/schulung/quarkus/shared/LogPerformanceInterceptor.java @@ -0,0 +1,42 @@ +package de.samples.schulung.quarkus.shared; + +import io.quarkus.arc.log.LoggerName; +import jakarta.annotation.Priority; +import jakarta.interceptor.AroundInvoke; +import jakarta.interceptor.Interceptor; +import jakarta.interceptor.InvocationContext; +import org.jboss.logging.Logger; + +@Interceptor +@LogPerformance +@Priority(10) +public class LogPerformanceInterceptor { + + @LoggerName("performance") + Logger logger; + + private Logger.Level findLevel(InvocationContext ic) { + return AnnotationUtils + .findAnnotation(ic.getMethod(), LogPerformance.class) + .map(LogPerformance::value) + .orElse(Logger.Level.INFO); + } + + @AroundInvoke + public Object logPerformance(InvocationContext invocationContext) throws Exception { + final var methodName = invocationContext.getMethod().getName(); + final var level = findLevel(invocationContext); + var ts1 = System.currentTimeMillis(); + try { + return invocationContext.proceed(); // Weiterleitung an das Original + } finally { + var ts2 = System.currentTimeMillis(); + logger.logf( + level, + "Dauer der Methode '%s': %d ms", + new Object[]{methodName, ts2 - ts1} + ); + } + } + +} diff --git a/customer-api-provider/src/test/java/de/samples/schulung/quarkus/infrastructure/CustomersLoggingTests.java b/customer-api-provider/src/test/java/de/samples/schulung/quarkus/infrastructure/CustomersLoggingTests.java index d556e7b..84f38be 100644 --- a/customer-api-provider/src/test/java/de/samples/schulung/quarkus/infrastructure/CustomersLoggingTests.java +++ b/customer-api-provider/src/test/java/de/samples/schulung/quarkus/infrastructure/CustomersLoggingTests.java @@ -2,24 +2,17 @@ import de.samples.schulung.quarkus.domain.Customer; import de.samples.schulung.quarkus.domain.CustomersService; +import de.samples.schulung.quarkus.utilities.ProfileWithMockedLogger; import io.quarkus.arc.log.LoggerName; import io.quarkus.test.junit.QuarkusTest; -import jakarta.annotation.PreDestroy; -import jakarta.enterprise.context.ApplicationScoped; -import jakarta.enterprise.context.Dependent; -import jakarta.enterprise.inject.Produces; -import jakarta.enterprise.inject.spi.InjectionPoint; +import io.quarkus.test.junit.TestProfile; import jakarta.inject.Inject; import org.jboss.logging.Logger; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; -import org.mockito.Mockito; import java.time.LocalDate; import java.time.Month; -import java.util.HashMap; -import java.util.Map; -import java.util.Optional; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; @@ -28,6 +21,7 @@ import static org.mockito.Mockito.*; @QuarkusTest +@TestProfile(ProfileWithMockedLogger.class) class CustomersLoggingTests { /* @@ -76,38 +70,4 @@ void shouldLogWhenCustomerCreated() throws InterruptedException { } - // just for this class, not for all tests! - @ApplicationScoped - static class LoggerMocksProducer { - private final Map loggers = new HashMap<>(); - - private Optional findLoggerName(InjectionPoint injectionPoint) { - return injectionPoint.getQualifiers() - .stream() - .filter(q -> q.annotationType().equals(LoggerName.class)) - .findFirst() - .map(LoggerName.class::cast); - } - - private Logger createLogger(String name) { - return Mockito.mock(Logger.class); - } - - @Produces - @Dependent - @LoggerName("") - Logger getMockedLogger(InjectionPoint injectionPoint) { - return findLoggerName(injectionPoint) - .map(LoggerName::value) - .map(name -> loggers.computeIfAbsent(name, this::createLogger)) - .orElseThrow(() -> new IllegalStateException("Unable to derive the logger name at " + injectionPoint)); - } - - @PreDestroy - void clear() { - loggers.clear(); - } - - } - } diff --git a/customer-api-provider/src/test/java/de/samples/schulung/quarkus/shared/LogPerformanceInterceptorTest.java b/customer-api-provider/src/test/java/de/samples/schulung/quarkus/shared/LogPerformanceInterceptorTest.java new file mode 100644 index 0000000..9e76bcb --- /dev/null +++ b/customer-api-provider/src/test/java/de/samples/schulung/quarkus/shared/LogPerformanceInterceptorTest.java @@ -0,0 +1,79 @@ +package de.samples.schulung.quarkus.shared; + +import de.samples.schulung.quarkus.utilities.ProfileWithMockedLogger; +import io.quarkus.arc.log.LoggerName; +import io.quarkus.test.junit.QuarkusTest; +import io.quarkus.test.junit.TestProfile; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import org.jboss.logging.Logger; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.verifyNoInteractions; + +@QuarkusTest +@TestProfile(ProfileWithMockedLogger.class) +public class LogPerformanceInterceptorTest { + + @Inject + SampleBean sampleBean; + @LoggerName("performance") + Logger logger; + + @BeforeEach + void setup() { + Mockito.reset(logger); + } + + @Test + @DisplayName("[SHARED] No @LogPerformance -> no logging") + void given_whenNoAnnotation_thenDoNotLog() { + sampleBean.dontLog(); + verifyNoInteractions(logger); + } + + @Test + @DisplayName("[SHARED] @LogPerformance -> default level logging") + void given_whenAnnotation_thenLogDefaultLevel() { + sampleBean.logDefaultLevel(); + Mockito.verify(logger).logf( + eq(Logger.Level.INFO), + anyString(), + any(Object[].class) + ); + } + + @Test + @DisplayName("[SHARED] @LogPerformance(DEBUG) -> debug logging") + void given_whenAnnotationWithDebugLevel_thenLogDebugLevel() { + sampleBean.logDebugLevel(); + Mockito.verify(logger).logf( + eq(Logger.Level.DEBUG), + anyString(), + any(Object[].class) + ); + } + + + // Initialized for all tests, but does not disturb + @ApplicationScoped + static class SampleBean { + + void dontLog() { + } + + @LogPerformance + void logDefaultLevel() { + } + + @LogPerformance(Logger.Level.DEBUG) + void logDebugLevel() { + } + + } + +} diff --git a/customer-api-provider/src/test/java/de/samples/schulung/quarkus/utilities/ProfileWithMockedLogger.java b/customer-api-provider/src/test/java/de/samples/schulung/quarkus/utilities/ProfileWithMockedLogger.java new file mode 100644 index 0000000..b2a9534 --- /dev/null +++ b/customer-api-provider/src/test/java/de/samples/schulung/quarkus/utilities/ProfileWithMockedLogger.java @@ -0,0 +1,53 @@ +package de.samples.schulung.quarkus.utilities; + +import io.quarkus.arc.log.LoggerName; +import io.quarkus.test.junit.QuarkusTestProfile; +import jakarta.annotation.PreDestroy; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.context.Dependent; +import jakarta.enterprise.inject.Produces; +import jakarta.enterprise.inject.spi.InjectionPoint; +import org.jboss.logging.Logger; +import org.mockito.Mockito; + +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; + +public class ProfileWithMockedLogger implements QuarkusTestProfile { + + @ApplicationScoped + static class LoggerMocksProducer { + + private final Map loggers = new HashMap<>(); + + private Optional findLoggerName(InjectionPoint injectionPoint) { + return injectionPoint.getQualifiers() + .stream() + .filter(q -> q.annotationType().equals(LoggerName.class)) + .findFirst() + .map(LoggerName.class::cast); + } + + private Logger createLogger(String name) { + return Mockito.mock(Logger.class); + } + + @Produces + @Dependent + @LoggerName("") + Logger getMockedLogger(InjectionPoint injectionPoint) { + return findLoggerName(injectionPoint) + .map(LoggerName::value) + .map(name -> loggers.computeIfAbsent(name, this::createLogger)) + .orElseThrow(() -> new IllegalStateException("Unable to derive the logger name at " + injectionPoint)); + } + + @PreDestroy + void clear() { + loggers.clear(); + } + + } + +} diff --git a/docs/06-interceptors.png b/docs/06-interceptors.png new file mode 100644 index 0000000..0a6fe43 Binary files /dev/null and b/docs/06-interceptors.png differ diff --git a/docs/README.md b/docs/README.md index 2bd21af..81fa32b 100644 --- a/docs/README.md +++ b/docs/README.md @@ -21,3 +21,7 @@ # Layers ![Layers](05-layers.png) + +# Interceptors + +![Proxy Objects](06-interceptors.png)