spectreconsole/spectre.console

Registrar/Resolver Redesign

rcdailey opened this issue · 24 comments

I've never really been happy with the current type registrar/resolver model in Spectre.Console. Registration/resolution concerns are scattered across objects in the library with no clear line drawn between setup code and code with dependencies on services. This has been a complaint of mine going pretty far back.

The command interceptor logic introduced recently made the mutual exclusivity of the registrar/resolver design a little less painful, but I still often find myself in catch 22 scenarios simply because Spectre.Console steals away control over my own DI container from me.

I'd like to end this once and for all by completely rethinking the way Spectre.Console approaches DI. Instead of implementing its own nested facades, adopt a more modern approach. Something along the lines of this:

var services = new ServiceCollection();

services.AddSpectreConsole(config =>
{
    config.AddSetting("demo");
});

var provider = services.BuildServiceProvider();

var app = provider.GetRequiredService<ICommandApp>();
return app.Run(args);

There's certainly different approaches here. There's a fluent builder pattern that might be more appropriate here as well, although I will say that when I tried it, it didn't give me that separation between registration & resolution like this approach does.

I'll provide a more comprehensive implementation of this below. Instead of diving into the Spectre.Console code base and going crazy with refactoring, I wanted to start with a small demo to mimick some of the top level objects in the library and show the approach without much concern for implementation detail right off the bat.

This also gives us a chance to discuss the idea and see if the maintainers are interested in an overhaul in this area. I understand backward compatibility will be a big concern here. I honestly don't see a way to keep things backward compatible and still be able to overhaul the aspects of this that desperately need it, IMO.

Let me know how you folks feel about this. I'm happy to help drive the work, I just want to make sure it's not a PR bound to be rejected.

Thanks in advance.

NOTE: In the demo, I'm using the nuget package Microsoft.Extensions.DependencyInjection, but Spectre.Console will actually only use the abstractions (i.e. Microsoft.Extensions.DependencyInjection.Abstractions). I think going forward, the main spectre.console package should only be concerned with the IServiceProvider/IServiceCollection abstractions which allow users to adapt these just as they do today with the registrar/resolver facades. In the future we can add packages like Spectre.Console.DependencyInjection, where we might provide additional facilities like the AddSpectreConsole() method.

I'm open to opinion here but I'm just assuming you'd want to separate DI-specific implementations into their own packages because you might want to support other containers like Autofac separately without impacting the main library.

using System;
using System.Collections.Generic;
using Microsoft.Extensions.DependencyInjection;

namespace ConsoleApp1;

public interface IConfigurator
{
    void AddSetting(string value);
}

public class Configurator : IConfigurator
{
    private readonly List<string> _settings = [];

    public void AddSetting(string value)
    {
        _settings.Add(value);
    }
}

public interface ICommandApp
{
    int Run(IEnumerable<string> args);
}

internal class CommandApp : ICommandApp
{
    public readonly CommandExecutor _executor;
    private readonly IConfigurator _config;

    public CommandApp(IServiceProvider provider, IConfigurator config)
    {
        // CommandExecutor should probably be injected via constructor arg...
        _executor = provider.GetRequiredService<CommandExecutor>();
        _config = config;
    }

    public int Run(IEnumerable<string> args)
    {
        return _executor.Execute(_config, args);
    }
}

internal class CommandExecutor
{
    public int Execute(IConfigurator config, IEnumerable<string> args)
    {
        // do meaningful work
        return 0;
    }
}

public static class SpectreConsoleExtensions
{
    public static void AddSpectreConsole(
        this IServiceCollection services,
        Action<IConfigurator> configure)
    {
        services.AddTransient<ICommandApp, CommandApp>();
        services.AddTransient<CommandExecutor>();
        services.AddTransient<IConfigurator>(_ =>
        {
            var config = new Configurator();
            configure(config);

            // This is where the built-in (hidden) commands get added
            config.AddSetting("default stuff");

            return config;
        });
    }
}

public static class Program
{
    public static int Main(string[] args)
    {
        var services = new ServiceCollection();

        services.AddSpectreConsole(config =>
        {
            config.AddSetting("demo");
        });

        var provider = services.BuildServiceProvider();

        var app = provider.GetRequiredService<ICommandApp>();
        return app.Run(args);
    }
}

@rcdailey I'm not against the idea per se, as long as there is no breaking change in the API. We're about to ship 1.0 of Spectre.Console and Spectre.Console.Cli, and we can't really introduce breaking changes to the API at this point.

Thanks @patriksvensson. Would the milestone still be 2.0 as opposed to a minor/feature release if I were able to maintain backward compatibility?

I won't say that backward compatibility is impossible, but it will certainly be challenging. Any advice or tips you can share in this area would be valuable. I'd also like to make sure you're OK with the general approach I've chosen.

I decided to introduce the DI abstractions instead of keeping your current interfaces for a few reasons:

  • Most DI frameworks (that I've used) tend to already interop with the MS.DI abstractions.
  • They're a bit more typesafe and functional
  • Most developers will already know how to use them

I'll give this some more thought and share progress along the way; I definitely would like for this to be a collaborative effort. If you have a discord server or something we can chat on that'd also be great, if you wouldn't be bothered by me!

Thank you for the fast response.

@patriksvensson In addition to my previous reply, I have another question:

        // Register the arguments with the container.
        _registrar.RegisterInstance(typeof(CommandTreeParserResult), parsedResult);
        _registrar.RegisterInstance(typeof(IRemainingArguments), parsedResult.Remaining);

How important are these registrations in CommandExecutor.Execute()? I'm not really seeing anywhere they might be injected.

@rcdailey this will force all users to use the "Microsoft DI" and restrict everyone else who likes to use some other DI container, right?

@rcdailey this will force all users to use the "Microsoft DI" and restrict everyone else who likes to use some other DI container, right?

No, that is something I'm deliberately trying to avoid, but I'd like to share my plan below for feedback and also to make sure things really will go as I hope.

  • Main Spectre.Console Package

    Instead of using our own abstractions for DI concerns, such as ITypeRegistrar and ITypeResolver, consume dependencies directly in constructors. For example, in one instance, the CommandApp constructor would take a CommandExecutor (or abstraction of it) and then using the fluent builder pattern, remove construction concerns from the user (i.e. factory).

  • New Spectre.Console.DependencyInjection Package

    This package would introduce extension methods for IServiceCollection (e.g. IServiceCollection.AddSpectreConsole()) that support transparently registering internal dependencies into the MS DI container, without users having to be aware of the ins & outs of how the registrations work and what types are needed.

The benefits I'm aiming for:

  • Clearer separation of concerns (DI-related code won't be sprinkled across the Spectre.Console code base)
  • More flexibility for users. Users have more control over what code is executed between the registration & configure steps of Spectre.Console and actual execution (e.g. RunAsync()) of the CLI application. The command interceptors are supposed to fill this gap today but they add a layer of complexity and are still not entirely flexible enough.
  • Users no longer have to implement their own DI container adapters. The responsibility of DI container support is shifted to specific extension packages. I don't suspect these to really ever change, so I think the maintainers taking on this responsibility is a good balance for achieving higher usability.

I think achieving these goals would be much easier if we were willing to accept breaking changes. It might be possible to avoid it, but it will result in a significant amount of problem solving and effort that I do not personally believe to be worth it. My principle is always to avoid backward breaking changes, so this isn't an opinion I take lightly. However, if you all feel strongly about backward compatibility, I will do my best to find an iterative approach that allows for it.

I would likely take a two step approach:

  • First PR would likely be an effort to refactor the existing implementation to shift existing registrations into the CommandApp.Configure() method, and greatly simplify the amount of setup work that is happening in the CommandExecutor class.
  • Second PR would lay the foundation for passing parameters directly into constructors to make them DI-friendly, and also probably introduce the fluent builder system at this time.
  • Third PR would probably be the MS DI-specific package. The reason I start with MS DI is because many other DI frameworks (e.g. Autofac) already have adapters for MS DI so I think starting here gets us the best bang for our buck, so to speak, in terms of DI compatibility across the board.

I hope my explanation is helpful. If my plan sounds too ambitious or just impossible let me know. Happy to adjust, after all, I need you all to be on board with this for PR approvals and I really don't want to waste effort if I can avoid it.

I noticed IPairDeconstructor is internal but CommandParameter supports a custom deconstructor via the PairDeconstructor property. However, because it's internal, no clients of the library can effectively implement their own deconstructor due to this logic (taken from CommandValueBinder):

        // Create deconstructor.
        var deconstructorType = parameter.PairDeconstructor?.Type ?? typeof(DefaultPairDeconstructor);
        if (!(resolver.Resolve(deconstructorType) is IPairDeconstructor deconstructor))
        {
            if (!(Activator.CreateInstance(deconstructorType) is IPairDeconstructor activatedDeconstructor))
            {
                throw new InvalidOperationException($"Could not create pair deconstructor.");
            }

            deconstructor = activatedDeconstructor;
        }

This leads me to believe that there are no active users for CommandParameter.PairDeconstructor since that type is required to implement IPairDeconstructor but they do not have access to it.

What is the intent here? Should the configurability of a "pair deconstructor" be removed from the public interface, or should the interface simply be made public?