-
Notifications
You must be signed in to change notification settings - Fork 83
[RFC] Cleanse: SPI #118
Description
Cleanse: Service Provider Interface
Author: sebastianv1
Date: 10/11/2019
Links:
Service Provider Interface (SPI)
Dagger SPI
Introduction
One of the benefits of a dependency injection framework is that it provides extra tooling and features for dependency analysis. Cleanse has a few dynamic analysis tools such as cycle detection, component validation, and individual binding validation. These tools operate in the same way by traversing the object graph, catching any errors along the way, and reporting them back to the user.
Although features like cycle detection and component validation make sense to belong inside the core Cleanse framework, building every new feature or tooling that operates over the dependency graph isn't feasible and expands the overall size of the framework (one of the goals of Cleanse is to remain as lightweight as possible). Likewise, there isn't a way for developers to also specify application specific rules about their dependency graph unless they fork the framework and code them in themselves.
Proposed Solution
We propose defining a new public interface that allows developers to create their own validations, features, or tools that hook into the Cleanse object graph. For now, this will be a read-only plugin.
Creating a plugin is done in three steps, first by creating an object that conforms to the protocol CleanseBindingPlugin
and implementing the required function:
func visit(root: ComponentBinding, errorReporter: CleanseErrorReporter)
For example, let's say we're creating a plugin to visualize our dependency graph via Graphviz.
struct GraphvizPlugin: CleanseBindingPlugin {
// Required function from `CleanseBindingPlugin`.
func visit(root: ComponentBinding, errorReporter: CleanseErrorReporter) {
// ...
}
}
Then we register our plugin with a CleanseServiceLoader
instance.
func application(application: UIApplication, didFinishLaunchingWithOptions launchOptions: [NSObject: AnyObject]?) -> Bool {
let serviceLoader = CleanseServiceLoader()
serviceLoader.register(GraphwizPlugin())
// ...
}
And finally inject our service loader instance into the root builder function ComponetFactory.of(_:validate:serviceLoader:)
.
func application(application: UIApplication, didFinishLaunchingWithOptions launchOptions: [NSObject: AnyObject]?) -> Bool {
let serviceLoader = CleanseServiceLoader()
serviceLoader.register(GraphwizPlugin())
// Build our root object in our graph.
let rootObject = try! ComponentFactory.of(AppDelegate.Component.self, serviceLoader: serviceLoader).build(())
}
Inside our GraphvizPlugin
implementation, the entry function visit(root:errorReporter)
for our plugin is handed a representation of the graph's root component ComponentInfo
, and a CleanseErrorReporter
. The ComponentInfo
instance holds all the information required to traverse over the entire dependency graph, and the CleanseErrorReporter
can be used to append errors to report back to the user after validation.
Example Usage
Application specific dependency validation
Consumers of Cleanse may have specific rule sets about their dependency graph for quality or security reasons. One example might be a deprecated class that a developer wants to make sure isn't accidentally included in the dependency graph by his/her other developers.
class MyDeprecatedViewController: UIViewController {}
struct DeprecatedTypesPlugin: CleanseBindingPlugin {
let deprecatedTypes: [Any.Type] = [MyDeprecatedViewController.self]
func visit(root: ComponentBinding, errorReporter: CleanseErrorReporter) {
for type in deprecatedType {
if checkComponent(root, for: type) {
errorReporter.append(error: Error(type: type))
}
}
}
func checkComponent(_ component: ComponentBinding, for type: Any.Type) -> Bool {
let providers = component.providers.keys
let found = providers.contains { $0.type == type }
if found {
return true
} else if component.subcomponents.isEmpty {
return false
} else {
for subcomponent in component.subcomponents {
if checkComponent(subcomponent, for: type) {
return true
}
}
return false
}
}
struct Error: CleanseError {
let type: Any.Type
var description: String {
return "Found deprecated type: \(type)"
}
}
}
Other Use Cases
- Graphviz Visualization Tool: A serialized format of the dependency graph could be written to disk and easily mapped to the Graphviz API used to produce a visualization of the Cleanse object graph.
- Cleanse CLI Tool: The same serialized format mentioned above could also be used to write a CLI tool that allows writing queries against the object graph. These commands could include
determining if a dependency exist, printing all incoming edges to a dependency, or printing all outgoing edges from a dependency.
Detailed Design
CleanseBindingPlugin
The public service interface is a protocol with one required function.
public protocol CleanseBindingPlugin {
/// Plugin entry point function that is called by the service loader.
///
/// - Parameter root: Root component of the object graph.
/// - Parameter errorReporter: Reporter used to append errors that are used to fail validation.
///
func visit(root: ComponentBinding, errorReporter: CleanseErrorReporter)
}
This function is the entry point for our plugin and will be called during the validation step of building our Cleanse object graph.
ComponentBinding
The ComponentBinding
holds all the necessary details to describe a dependency graph (or subgraph).
/// Describes the object graph for a component node.
public protocol ComponentBinding {
var scope: Scope.Type? { get }
var seed: Any.Type { get }
var parent: ComponentBinding? { get }
var subcomponents: [ComponentBinding] { get }
var componentType: Any.Type? { get }
// All bindings registered in the component.
var providers: [ProviderKey: [ProviderInfo]] { get }
}
Internally, the class ComponentInfo
will conform to ComponentBinding
to provide the implementation. The decision to expose a public protocol instead of raising the access control of ComponentInfo
gives us the flexibility in the future to change the underlying implementation. ComponentInfo
was implemented alongside the existing dynamic validation features Cleanse provides and is a suitable implementation for ComponentBinding
today, but its implementation is likely to change in the future and we'd like to maintain a consistent and stable API for the plugin interface.
CleanseErrorReporter
The validation phase internally holds a list of CleanseError
objects that is used to append any validation errors found. We will extract this into a simple class CleanseErrorReporter
that can be used to append errors internally, and via the plugin interface.
public final class CleanseErrorReporter {
public func append(error: CleanseError) { ... }
public func append(contentsOf: [CleanseError]) { ... }
func report() throws { ... }
}
At the end of validation, the report()
function will be called and throw an exception if any errors have been reported. This will include the entire list of errors from all plugins and internal validations and currently does not support any short-circuiting.
ComponentFactory.of(_:validate:serviceLoader:)
The entry point function into Cleanse, ComponentFactory.of(_:validate:)
will have an additional parameter for loading our plugins registered with a CleanseServiceLoader
instance.
However, these public API changes will be backwards compatible with existing Cleanse projects since the parameter includes a default value set to an empty instance of the CleanseServiceLoader
with no plugins.
// RootComponent.swift
public extension ComponentFactoryProtocol where ComponentElement : RootComponent {
static func of(_ componentType: ComponentElement.Type, validate: Bool = true, serviceLoader: CleanseServiceLoader = CleanseServiceLoader.instance) throws { ... }
}
Impotant: The plugins registered from the service loader will only be run if validate
is set to true
.
Future Direction
Future additions of the Cleanse SPI could allow plugins write-access into the dependency graph. This would allow developers to delete, modify, or create dependencies on the fly.
This could come in the form of extending the parameters of ComponentBinding
to be settable properties or possibly as an entirely different plugin setup distinct from read-only plugins.
Revisions
11/1: Initial Draft