diff --git a/README.md b/README.md index 0976d69..fc91408 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ The repo contains the following modules: * **[lifecycle](lifecycle)** [[JavaDocs](https://javadoc.io/doc/org.creekservice/creek-observability-lifecycle)]: a common model of lifecycle events. * **[logging](logging)** [[JavaDocs](https://javadoc.io/doc/org.creekservice/creek-observability-logging)]: handles the logging of _structured_ events via [Slf4J][slf4j]. * **[logging fixtures](logging-fixtures)** [[JavaDocs](https://javadoc.io/doc/org.creekservice/creek-observability-logging-fixtures)]: test fixtures for testing logging output. +* **[patterns](patterns)** [[JavaDocs](https://javadoc.io/doc/org.creekservice/creek-observability-patterns)]: util code and patterns to help with observability [slf4j]: https://www.slf4j.org [splunk]: https://www.splunk.com diff --git a/patterns/README.md b/patterns/README.md new file mode 100644 index 0000000..65f9c63 --- /dev/null +++ b/patterns/README.md @@ -0,0 +1,8 @@ +[![javadoc](https://javadoc.io/badge2/org.creekservice/creek-observability-patterns/javadoc.svg)](https://javadoc.io/doc/org.creekservice/creek-observability-patterns) + +# Creek Observability Patterns + +Contains helpers and utility code to help implement good observability in your code. + + + diff --git a/patterns/build.gradle.kts b/patterns/build.gradle.kts new file mode 100644 index 0000000..4f3cfeb --- /dev/null +++ b/patterns/build.gradle.kts @@ -0,0 +1,22 @@ +/* + * Copyright 2022-2023 Creek Contributors (https://github.com/creek-service) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +plugins { + `java-library` +} + +dependencies { +} diff --git a/patterns/src/main/java/org/creekservice/api/observability/patterns/CompositeObserverBuilder.java b/patterns/src/main/java/org/creekservice/api/observability/patterns/CompositeObserverBuilder.java new file mode 100644 index 0000000..89f5270 --- /dev/null +++ b/patterns/src/main/java/org/creekservice/api/observability/patterns/CompositeObserverBuilder.java @@ -0,0 +1,166 @@ +/* + * Copyright 2023 Creek Contributors (https://github.com/creek-service) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.creekservice.api.observability.patterns; + +import java.lang.invoke.MethodHandle; +import java.lang.invoke.MethodHandles; +import java.lang.reflect.Method; +import java.lang.reflect.Proxy; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.function.Function; +import java.util.stream.Collectors; + +/** + * Easily build a chain of multiple Observer / Listener implementations. + * + *

This encourages decomposition and decoupling of functionality. + * + *

Given an interface {@code MyListener}, this class can be used to chain multiple listeners + * together, without the need to write any custom code. + * + *

{@code
+ * MyListener composite = CompositeObserverBuilder.builder(MyListener.class, listener1)
+ *     .observer(listener2)
+ *     ...
+ *     .observer(listenerN)
+ *     .build()
+ * }
+ * + * @param the observer interface type + */ +public final class CompositeObserverBuilder { + + private final Class observerClass; + private final List observers = new ArrayList<>(); + + /** + * Create a builder + * + * @param observerClass the observer interface type. + * @param observer the first observer to call. + * @return the composite builder + * @param the type of the observer interface. + */ + public static CompositeObserverBuilder builder( + final Class observerClass, final Observer observer) { + if (!observerClass.isInterface()) { + throw new IllegalArgumentException("observerClass must be an interface"); + } + validateMethodsAreVoidReturnType(observerClass); + return new CompositeObserverBuilder<>(observerClass, observer); + } + + private CompositeObserverBuilder(final Class observerClass, final Observer observer) { + this.observerClass = observerClass; + add(observer); + } + + /** + * Add another observer to the chain + * + * @param observer the observer to add. + * @return self, to allow chaining. + */ + public CompositeObserverBuilder add(final Observer observer) { + this.observers.add(Objects.requireNonNull(observer, "observer")); + return this; + } + + /** + * @return the composite observer implementation. + */ + @SuppressWarnings("unchecked") + public Observer build() { + try { + final Map handles = methodHandles(observerClass); + return (Observer) + Proxy.newProxyInstance( + getClass().getClassLoader(), + new Class[] {observerClass}, + (proxy, method, args) -> { + if (method.getName().equals("toString")) { + return "Proxy composite for " + observerClass.getName(); + } + if (method.getName().equals("equals")) { + return this == proxy; + } + if (method.getName().equals("hashCode")) { + return observerClass.getName().hashCode(); + } + + for (Observer delegate : observers) { + final MethodHandle methodHandle = handles.get(method); + methodHandle.bindTo(delegate).invokeWithArguments(args); + } + return null; + }); + } catch (Exception e) { + throw new CompositeCreationFailedException( + "failed to generate composite observer for interface: " + observerClass, e); + } + } + + private Map methodHandles(final Class observerClass) { + final Map handles = + Arrays.stream(observerClass.getDeclaredMethods()) + .collect( + Collectors.toMap( + Function.identity(), + m -> { + try { + return MethodHandles.publicLookup().unreflect(m); + } catch (IllegalAccessException e) { + throw new CompositeCreationFailedException( + "Unable to unreflect method", e); + } + })); + + for (Class extending : observerClass.getInterfaces()) { + handles.putAll(methodHandles(extending)); + } + return handles; + } + + private static void validateMethodsAreVoidReturnType(final Class observerClass) { + final List invalidMethods = + Arrays.stream(observerClass.getDeclaredMethods()) + .filter(method -> !method.getReturnType().equals(void.class)) + .collect(Collectors.toList()); + + if (!invalidMethods.isEmpty()) { + throw new UnsupportedOperationException( + "Only observer interfaces, where all methods have a void return type, are" + + " supported. Interface: " + + observerClass + + " invalidMethods: " + + invalidMethods); + } + + for (Class extending : observerClass.getInterfaces()) { + validateMethodsAreVoidReturnType(extending); + } + } + + private static class CompositeCreationFailedException extends RuntimeException { + CompositeCreationFailedException(final String message, final Throwable cause) { + super(message, cause); + } + } +} diff --git a/patterns/src/test/java/org/creekservice/api/observability/patterns/CompositeObserverBuilderTest.java b/patterns/src/test/java/org/creekservice/api/observability/patterns/CompositeObserverBuilderTest.java new file mode 100644 index 0000000..349a370 --- /dev/null +++ b/patterns/src/test/java/org/creekservice/api/observability/patterns/CompositeObserverBuilderTest.java @@ -0,0 +1,185 @@ +/* + * Copyright 2023 Creek Contributors (https://github.com/creek-service) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.creekservice.api.observability.patterns; + +import static org.hamcrest.CoreMatchers.notNullValue; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.is; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InOrder; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class CompositeObserverBuilderTest { + + @Mock Observer observer1; + @Mock Observer observer2; + + @Test + public void shouldDelegateToSingle() { + // Given: + final Observer result = CompositeObserverBuilder.builder(Observer.class, observer1).build(); + + // When: + result.foo("text"); + + // Then: + verify(observer1).foo("text"); + } + + @Test + public void shouldDelegateToMultipleInOrder() { + // Given: + final Observer result = + CompositeObserverBuilder.builder(Observer.class, observer1).add(observer2).build(); + + // When: + result.foo("text"); + + // Then: + final InOrder inOrder = inOrder(observer1, observer2); + inOrder.verify(observer1).foo("text"); + inOrder.verify(observer2).foo("text"); + } + + @Test + public void shouldBlowUpIfObserverTypeNotInterface() { + // When: + final Exception e = + assertThrows( + IllegalArgumentException.class, + () -> + CompositeObserverBuilder.builder( + CompositeObserverBuilderTest.class, this)); + + // Then: + assertThat(e.getMessage(), is("observerClass must be an interface")); + } + + @Test + public void shouldBlowUpIfYouTryDelegateToNull() { + // When: + final Exception e = + assertThrows( + NullPointerException.class, + () -> CompositeObserverBuilder.builder(Observer.class, null)); + + // Then: + assertThat(e.getMessage(), is("observer")); + } + + @Test + public void shouldBlowUpIfYouTryToAddNullObserver() { + // Given: + final CompositeObserverBuilder builder = + CompositeObserverBuilder.builder(Observer.class, observer1); + + // When: + final Exception e = assertThrows(NullPointerException.class, () -> builder.add(null)); + + // Then: + assertThat(e.getMessage(), is("observer")); + } + + @Test + public void shouldBlowUpIfInterfaceHasNonVoidReturnMethods() { + // When: + final Exception e = + assertThrows( + UnsupportedOperationException.class, + () -> CompositeObserverBuilder.builder(NotAnObserver.class, mock())); + + // Then: + assertThat( + e.getMessage(), + is( + "Only observer interfaces, where all methods have a void return type, are" + + " supported. Interface: interface" + + " org.creekservice.api.observability.patterns.CompositeObserverBuilderTest$NotAnObserver" + + " invalidMethods: [public abstract int" + + " org.creekservice.api.observability.patterns.CompositeObserverBuilderTest$NotAnObserver.foo(java.lang.String)]")); + } + + @Test + public void shouldHandleInterfaceExtensionWithoutBlowingUp() { + // Given: + final ExtendingExtendingObserver observer1 = mock(); + + final ExtendingExtendingObserver observer = + CompositeObserverBuilder.builder(ExtendingExtendingObserver.class, observer1) + .build(); + + // When: + observer.foo("blah"); + observer.bar(); + observer.lar(); + + // Then: + verify(observer1).foo("blah"); + verify(observer1).bar(); + verify(observer1).lar(); + } + + @SuppressWarnings("ResultOfMethodCallIgnored") + @Test + public void shouldNotBlowUpOnEquals() { + CompositeObserverBuilder.builder(ExtendingExtendingObserver.class, mock()) + .build() + .equals(new Object()); + } + + @SuppressWarnings("ResultOfMethodCallIgnored") + @Test + public void shouldNotBlowUpOnHashCode() { + CompositeObserverBuilder.builder(ExtendingExtendingObserver.class, mock()) + .build() + .hashCode(); + } + + @Test + public void shouldNotBlowUpOnToString() { + assertThat( + CompositeObserverBuilder.builder(ExtendingExtendingObserver.class, mock()) + .build() + .toString(), + notNullValue()); + } + + public interface Observer { + + void foo(String message); + } + + public interface ExtendingObserver extends Observer { + void bar(); + } + + public interface ExtendingExtendingObserver extends ExtendingObserver { + void lar(); + } + + @SuppressWarnings("unused") + public interface NotAnObserver { + int foo(String message); + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 33bac40..f4ff808 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -19,6 +19,7 @@ rootProject.name = "creek-observability" include( "lifecycle", "logging", - "logging-fixtures" + "logging-fixtures", + "patterns" )