Skip to content

Commit

Permalink
Composite observer (#272)
Browse files Browse the repository at this point in the history

* Add composite observer
  • Loading branch information
big-andy-coates authored May 20, 2024
1 parent 2f38808 commit 778ac9d
Show file tree
Hide file tree
Showing 6 changed files with 384 additions and 1 deletion.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions patterns/README.md
Original file line number Diff line number Diff line change
@@ -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.



22 changes: 22 additions & 0 deletions patterns/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -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 {
}
Original file line number Diff line number Diff line change
@@ -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 <i>Observer</i> / <i>Listener</i> implementations.
*
* <p>This encourages decomposition and decoupling of functionality.
*
* <p>Given an interface {@code MyListener}, this class can be used to chain multiple listeners
* together, without the need to write any custom code.
*
* <pre>{@code
* MyListener composite = CompositeObserverBuilder.builder(MyListener.class, listener1)
* .observer(listener2)
* ...
* .observer(listenerN)
* .build()
* }</pre>
*
* @param <Observer> the observer interface type
*/
public final class CompositeObserverBuilder<Observer> {

private final Class<Observer> observerClass;
private final List<Observer> observers = new ArrayList<>();

/**
* Create a builder
*
* @param observerClass the observer interface type.
* @param observer the first observer to call.
* @return the composite builder
* @param <Observer> the type of the observer interface.
*/
public static <Observer> CompositeObserverBuilder<Observer> builder(
final Class<Observer> 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<Observer> 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<Observer> 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<Method, MethodHandle> 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<Method, MethodHandle> methodHandles(final Class<?> observerClass) {
final Map<Method, MethodHandle> 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<Method> 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);
}
}
}
Original file line number Diff line number Diff line change
@@ -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<Observer> 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);
}
}
Loading

0 comments on commit 778ac9d

Please sign in to comment.