-
Notifications
You must be signed in to change notification settings - Fork 240
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat: improve DependencyGraph and error reporting #4628
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -14,7 +14,6 @@ | |
|
||
package org.eclipse.edc.boot.system; | ||
|
||
import org.eclipse.edc.boot.system.injection.EdcInjectionException; | ||
import org.eclipse.edc.boot.system.injection.InjectionContainer; | ||
import org.eclipse.edc.boot.system.injection.InjectionPoint; | ||
import org.eclipse.edc.boot.system.injection.InjectionPointScanner; | ||
|
@@ -25,9 +24,9 @@ | |
import org.eclipse.edc.boot.util.TopologicalSort; | ||
import org.eclipse.edc.runtime.metamodel.annotation.Provides; | ||
import org.eclipse.edc.runtime.metamodel.annotation.Requires; | ||
import org.eclipse.edc.spi.EdcException; | ||
import org.eclipse.edc.spi.system.ServiceExtension; | ||
import org.eclipse.edc.spi.system.ServiceExtensionContext; | ||
import org.jetbrains.annotations.Nullable; | ||
|
||
import java.util.ArrayList; | ||
import java.util.Arrays; | ||
|
@@ -37,7 +36,7 @@ | |
import java.util.Map; | ||
import java.util.Objects; | ||
import java.util.Set; | ||
import java.util.stream.Collectors; | ||
import java.util.function.Function; | ||
import java.util.stream.Stream; | ||
|
||
import static java.util.stream.Collectors.toList; | ||
|
@@ -48,25 +47,42 @@ | |
* which extension depends on which other extension. | ||
*/ | ||
public class DependencyGraph { | ||
private final InjectionPointScanner injectionPointScanner = new InjectionPointScanner(); | ||
private final ServiceExtensionContext context; | ||
|
||
public DependencyGraph(ServiceExtensionContext context) { | ||
this.context = context; | ||
private final List<InjectionContainer<ServiceExtension>> injectionContainers; | ||
/** | ||
* contains all missing dependencies that were expressed as injection points | ||
*/ | ||
private final HashMap<Class<? extends ServiceExtension>, List<InjectionFailure>> unsatisfiedInjectionPoints; | ||
/** | ||
* contains all missing dependencies that were expressed as @Require(...) annotations on the extension class | ||
*/ | ||
private final ArrayList<Class<?>> unsatisfiedRequirements; | ||
|
||
private DependencyGraph(List<InjectionContainer<ServiceExtension>> injectionContainers, HashMap<Class<? extends ServiceExtension>, List<InjectionFailure>> unsatisfiedInjectionPoints, ArrayList<Class<?>> unsatisfiedRequirements) { | ||
|
||
this.injectionContainers = injectionContainers; | ||
this.unsatisfiedInjectionPoints = unsatisfiedInjectionPoints; | ||
this.unsatisfiedRequirements = unsatisfiedRequirements; | ||
} | ||
|
||
/** | ||
* Sorts all {@link ServiceExtension} implementors, that were found on the classpath, according to their dependencies. | ||
* Depending Extensions (i.e. those who <em>express</em> a dependency) are sorted first, providing extensions (i.e. those | ||
* who provide a dependency) are sorted last. | ||
* Builds the DependencyGraph by evaluating all {@link ServiceExtension} implementors, that were found on the classpath, | ||
* and sorting them topologically according to their dependencies. | ||
* <em>Dependent</em> extensions (i.e. those who <em>express</em> a dependency) are sorted first, providing extensions (i.e. those | ||
* who <em>provide</em> a dependency) are sorted last. | ||
* <p> | ||
* This factory method does not throw any exception except a {@link CyclicDependencyException}, please check {@link DependencyGraph#isValid()} if the graph is valid. | ||
* | ||
* @param context An instance of the (fully-initialized) {@link ServiceExtensionContext} which is used to resolve services and configuration. | ||
* @param extensions A list of {@link ServiceExtension} instances that were picked up by the {@link ServiceLocator} | ||
* @return A list of {@link InjectionContainer}s that are sorted topologically according to their dependencies. | ||
* @throws CyclicDependencyException when there is a dependency cycle | ||
* @see TopologicalSort | ||
* @see InjectionContainer | ||
*/ | ||
public List<InjectionContainer<ServiceExtension>> of(List<ServiceExtension> extensions) { | ||
public static DependencyGraph of(ServiceExtensionContext context, List<ServiceExtension> extensions) { | ||
var injectionPointScanner = new InjectionPointScanner(); | ||
|
||
Map<Class<?>, ServiceProvider> defaultServiceProviders = new HashMap<>(); | ||
Map<Class<?>, List<InjectionContainer<ServiceExtension>>> dependencyMap = new HashMap<>(); | ||
var injectionContainers = extensions.stream() | ||
|
@@ -94,30 +110,31 @@ public List<InjectionContainer<ServiceExtension>> of(List<ServiceExtension> exte | |
|
||
// check if all injected fields are satisfied, collect missing ones and throw exception otherwise | ||
var unsatisfiedInjectionPoints = new HashMap<Class<? extends ServiceExtension>, List<InjectionFailure>>(); | ||
var unsatisfiedRequirements = new ArrayList<String>(); | ||
var unsatisfiedRequirements = new ArrayList<Class<?>>(); | ||
|
||
injectionContainers.forEach(container -> { | ||
//check that all the @Required features are there | ||
getRequiredFeatures(container.getInjectionTarget().getClass()).forEach(serviceClass -> { | ||
var dependencies = dependencyMap.get(serviceClass); | ||
if (dependencies == null) { | ||
unsatisfiedRequirements.add(serviceClass.getName()); | ||
unsatisfiedRequirements.add(serviceClass); | ||
} else { | ||
dependencies.forEach(dependency -> sort.addDependency(container, dependency)); | ||
} | ||
}); | ||
|
||
injectionPointScanner.getInjectionPoints(container.getInjectionTarget()) | ||
.peek(injectionPoint -> { | ||
var providersResult = injectionPoint.getProviders(dependencyMap, context); | ||
if (providersResult.succeeded()) { | ||
List<InjectionContainer<ServiceExtension>> providers = providersResult.getContent(); | ||
providers.stream().filter(d -> !Objects.equals(d, container)).forEach(provider -> sort.addDependency(container, provider)); | ||
} else { | ||
if (injectionPoint.isRequired()) { | ||
unsatisfiedInjectionPoints.computeIfAbsent(injectionPoint.getTargetInstance().getClass(), s -> new ArrayList<>()).add(new InjectionFailure(injectionPoint, providersResult.getFailureDetail())); | ||
} | ||
} | ||
injectionPoint.getProviders(dependencyMap, context) | ||
.onSuccess(providers -> providers.stream() | ||
.filter(d -> !Objects.equals(d, container)) | ||
.forEach(provider -> sort.addDependency(container, provider))) | ||
.onFailure(f -> { | ||
if (injectionPoint.isRequired()) { | ||
unsatisfiedInjectionPoints.computeIfAbsent(injectionPoint.getTargetInstance().getClass(), s -> new ArrayList<>()) | ||
.add(new InjectionFailure(injectionPoint.getTargetInstance(), injectionPoint, f.getFailureDetail())); | ||
} | ||
}); | ||
|
||
var defaultServiceProvider = defaultServiceProviders.get(injectionPoint.getType()); | ||
if (defaultServiceProvider != null) { | ||
|
@@ -127,24 +144,95 @@ public List<InjectionContainer<ServiceExtension>> of(List<ServiceExtension> exte | |
.forEach(injectionPoint -> container.getInjectionPoints().add(injectionPoint)); | ||
}); | ||
|
||
if (!unsatisfiedInjectionPoints.isEmpty()) { | ||
var message = "The following injected fields or values were not provided or could not be resolved:\n"; | ||
message += unsatisfiedInjectionPoints.entrySet().stream() | ||
.map(entry -> String.format("%s is missing \n --> %s", entry.getKey(), String.join("\n --> ", entry.getValue().stream().map(Object::toString).toList()))).collect(Collectors.joining("\n")); | ||
throw new EdcInjectionException(message); | ||
} | ||
sort.sort(injectionContainers); | ||
|
||
if (!unsatisfiedRequirements.isEmpty()) { | ||
var message = String.format("The following @Require'd features were not provided: [%s]", String.join(", ", unsatisfiedRequirements)); | ||
throw new EdcException(message); | ||
} | ||
return new DependencyGraph(injectionContainers, unsatisfiedInjectionPoints, unsatisfiedRequirements); | ||
} | ||
|
||
sort.sort(injectionContainers); | ||
/** | ||
* Returns all {@link InjectionPoint}s for a particular extension class. These include all types of injection points. | ||
* | ||
* @param serviceClass The extension class | ||
* @return A (potentially empty) list of injection points. | ||
*/ | ||
public List<InjectionPoint<ServiceExtension>> getDependenciesOf(Class<? extends ServiceExtension> serviceClass) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. this method is only used by tests There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. yes, the idea was to make the DG into more like a graph, with methods to match. It would make the DG more usable, esp. in runtimes other than the There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm not in favor of that, this dependency graph is really something related to the There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I had a version where the That said, these methods aren't needed by us at the moment, but since the |
||
return injectionContainers.stream().filter(ic -> ic.getInjectionTarget().getClass().equals(serviceClass)) | ||
.flatMap(ic -> ic.getInjectionPoints().stream()) | ||
.toList(); | ||
} | ||
|
||
/** | ||
* Obtains all extension classes that declare a dependency on an object of the given point. For example, declaring a | ||
* field {@code @Inject FooService fooService} in an extension would constitute such a dependency, and in that case | ||
* {@code FooService.class} has to be passed into this method. | ||
* <p> | ||
* This can also be invoked if the dependency graph is invalid, i.e. if dependency injection is not possible. | ||
* | ||
* @param dependencyType The type of the injection point, for example the type of service that is injected. | ||
* @return a list of extension classes that declare a dependency onto the given injection point | ||
*/ | ||
public List<Class<? extends ServiceExtension>> getDependentExtensions(Class<?> dependencyType) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. same |
||
|
||
return injectionContainers.stream() | ||
.filter(ic -> ic.getInjectionPoints().stream().anyMatch(ip -> ip.getType().equals(dependencyType))) | ||
.map(InjectionContainer::getInjectionTarget) | ||
.map((Function<ServiceExtension, Class<? extends ServiceExtension>>) ServiceExtension::getClass) // yes, it's nasty, but keeps the compiler happy | ||
.toList(); | ||
} | ||
|
||
/** | ||
* Obtains a list of injection points, where the injection target is of the given type. For example, passing in {@code FooService.class} | ||
* would return a list that contains injection points across all {@link ServiceExtension}s that declare a {@code @Inject FooService fooService} field. | ||
* This is similar to {@link DependencyGraph#getDependenciesOf(Class)}, but the result values are {@link InjectionPoint}s rather than extension classes. | ||
* | ||
* @param dependencyType The type of the injection point, for example the type of service that is injected. | ||
* @return a list of {@link InjectionPoint} instances that represent the concrete dependency | ||
*/ | ||
public List<InjectionPoint<ServiceExtension>> getDependenciesFor(Class<?> dependencyType) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. same |
||
return injectionContainers.stream() | ||
.flatMap(ic -> ic.getInjectionPoints().stream().filter(ip -> ip.getType().equals(dependencyType))) | ||
.toList(); | ||
} | ||
|
||
public List<InjectionContainer<ServiceExtension>> getInjectionContainers() { | ||
return injectionContainers; | ||
} | ||
|
||
private Stream<Class<?>> getRequiredFeatures(Class<?> clazz) { | ||
/** | ||
* Returns a list of extension instances that were found on the classpath | ||
*/ | ||
public List<ServiceExtension> getExtensions() { | ||
return injectionContainers.stream().map(InjectionContainer::getInjectionTarget).toList(); | ||
} | ||
|
||
/** | ||
* Checks if the current dependency graph is valid, i.e. there are no cycles in it and all required injection | ||
* dependencies are satisfied. | ||
* | ||
* @return true if the dependency graph is valid, and the DI container can be built, false otherwise. | ||
*/ | ||
public boolean isValid() { | ||
return unsatisfiedInjectionPoints.isEmpty() && unsatisfiedRequirements.isEmpty(); | ||
} | ||
|
||
/** | ||
* Returns a list of strings, each containing information about a missing dependency | ||
* | ||
* @return A list of errors describing one missing dependency each | ||
*/ | ||
public List<String> getProblems() { | ||
var messages = unsatisfiedInjectionPoints.entrySet().stream() | ||
.map(entry -> { | ||
var dependent = entry.getKey(); | ||
var dependencies = entry.getValue(); | ||
var missingDependencyList = dependencies.stream().map(injectionFailure -> " --> " + injectionFailure.failureDetail()).toList(); | ||
return "## %s is missing\n%s".formatted(dependent, String.join("\n", missingDependencyList)); | ||
}); | ||
|
||
return messages.toList(); | ||
} | ||
|
||
private static Stream<Class<?>> getRequiredFeatures(Class<?> clazz) { | ||
var requiresAnnotation = clazz.getAnnotation(Requires.class); | ||
if (requiresAnnotation != null) { | ||
var features = requiresAnnotation.value(); | ||
|
@@ -156,7 +244,7 @@ private Stream<Class<?>> getRequiredFeatures(Class<?> clazz) { | |
/** | ||
* Obtains all features a specific extension requires as strings | ||
*/ | ||
private Set<Class<?>> getProvidedFeatures(ServiceExtension ext) { | ||
private static Set<Class<?>> getProvidedFeatures(ServiceExtension ext) { | ||
var allProvides = new HashSet<Class<?>>(); | ||
|
||
// check all @Provides | ||
|
@@ -168,10 +256,11 @@ private Set<Class<?>> getProvidedFeatures(ServiceExtension ext) { | |
return allProvides; | ||
} | ||
|
||
private record InjectionFailure(InjectionPoint<ServiceExtension> injectionPoint, String failureDetail) { | ||
private record InjectionFailure(ServiceExtension dependent, InjectionPoint<ServiceExtension> dependency, | ||
@Nullable String failureDetail) { | ||
@Override | ||
public String toString() { | ||
return "%s %s".formatted(injectionPoint.getTypeString(), failureDetail); | ||
return "%s %s".formatted(dependency.getTypeString(), failureDetail); | ||
} | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
at this point the
DependencyGraph
looks like some sort ofResult
, withList<InjectionContainer>
as content type and aFailure
that contains the unsatisfied injection points and requirement.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
given the choice, I would much rather pass around a
DependencyGraph
object than aList<InjectionPoint<ServiceExtension>>
. Personally, I find it easier to read and understand and easier to handle, and it doesn't leak theInjectionPoint
all over the code.Also, it will hide the implementation better, so changes down the road would have less of an impact.