square / Cleanse

Lightweight Swift Dependency Injection Framework
Other
1.78k stars 90 forks source link

[RFC] Cleanse: SPI #118

Closed sebastianv1 closed 4 years ago

sebastianv1 commented 4 years ago

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

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