spectreconsole / spectre.console

A .NET library that makes it easier to create beautiful console applications.
https://spectreconsole.net
MIT License
9.56k stars 510 forks source link

Registrar/Resolver Redesign #1657

Open rcdailey opened 2 months ago

rcdailey commented 2 months ago

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);
    }
}
patriksvensson commented 2 months ago

@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.

rcdailey commented 2 months ago

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:

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.

rcdailey commented 2 months ago

@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.

nils-a commented 2 months ago

@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 commented 2 months ago

@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.

The benefits I'm aiming for:

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:

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.

rcdailey commented 1 month ago

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?

FrankRay78 commented 1 month ago

I've read none of the above @rcdailey, but when my plate is more clear (that shouldn't be too long away), I'm happy to work alongside you with this, if it's still of interest.

rcdailey commented 1 month ago

Thanks Frank. I'll try to renew interest later, but the slow turnaround makes any motivation or progress difficult. I understand everyone is spending their free time here, so I'm not upset and certainly not pointing fingers. But it is a reality, and unfortunately I barely have enough free time as it is working on my own projects.

I absolutely do want to maintain a helpful relationship so if you ever get some downtime let me know. I mentioned connecting on Discord earlier, if that's an option anyone is comfortable with, that would likely make communication much easier.

phil-scott-78 commented 1 month ago

I would propose considering making this work tied closely with any AOT work (e.g. #1322, #1155, #955). Both are going to have some overlap on breaking changes and architecture rework, so both being in the same discussion would probably save some headaches in both directions

FrankRay78 commented 1 month ago

@rcdailey There is a lull in maintainer activity/free time at the moment, except for myself, which is what you are seeing from the outside. Nothing untoward, just the reality of situation. I'm currently clearing down a few PR's and preparing the command line system for version 1 of Spectre.Console. But once that's out of the way, say early Dec, I can stick around and support any development you do, so you know it will be merged. That said, if it's too ambitious or breaking, then I'll become slowed down having to square it off with the other maintainers. But other things I can merge, and/or we could look at other issues if you feel like contributing there instead. Or simply acknowledging this is the situation currently, and waiting a bit. I think you're good in terms of 'maintain a helpful relationship', so don't let that cloud the decision how we proceed here.

rcdailey commented 1 month ago

Thanks again Frank. You've always been a pleasure to chat with on here so definitely will take advantage of your free time in December.

I think this is a challenging effort, especially if it means not breaking backward compatibility. I've mulled over a few different approaches but it involves a LOT of hacks and shims just to avoid breaking things. And the way the resolver is exposed to users in various structures and callbacks makes it even more challenging.

My inclination is to just break everything to get it the way we want, instead of tip toeing around compatibility. I understand that implies everything you mentioned, but maybe Phil's idea of bundling it with Native AOT work might make it more palatable (although I have no idea what that effort is yet, or if it's a good idea to build a bigger kitchen sink).

kvpt commented 3 weeks ago

I also noticed an issue with the current implementation that should be addressed if a new one is made. Currently there is two main steps, the creation of the app (new CommandApp()) where the TypeRegistrar is passed and the app run (app.RunAsync). From a DI perspective I expect that all dependencies registrations are made in the application building phase and not in the run phase, It is not currently the case.

I use myself SimpleInjector container which lock itself after the first dependency resolution. So if I want to do something like :

FrankRay78 commented 3 weeks ago

I want to understand the situation with Spectre.Console AOT trimming, hence my comment on 1508 here.

FrankRay78 commented 3 weeks ago

From a DI perspective I expect that all dependencies registrations are made in the application building phase and not in the run phase, It is not currently the case.

nb. It's the following line that resolves the registrations, obviously deep in the command execution method:

https://github.com/spectreconsole/spectre.console/blob/aa9e5c48c6b212e75ee9bd25b890359267997579/src/Spectre.Console.Cli/Internal/CommandExecutor.cs#L60

If this was moved somewhere else @kvpt, how should it work?

For example.

new CommandApp(DI container)

(opportunity to continue modifying registrations here)

CommandApp.ResolveDependencies()

(container is locked, internally registered types locked also)

CommandApp.Run()

Is this what you had in mind?

kvpt commented 3 weeks ago

I think there is no need for a new method here, it only need to adhere on this two principles :

  1. All the registrations need to be made in the build phase new CommandApp(DI container)

  2. All the resolutions need to be done in the run phase CommandApp.Run()

Currently rule 2 seems ok, but rule 1 is not. https://github.com/spectreconsole/spectre.console/blob/aa9e5c48c6b212e75ee9bd25b890359267997579/src/Spectre.Console.Cli/Internal/CommandExecutor.cs#L13-L28

For exemple the executor register IConfiguration, IAnsiConsole and CommandModel. Is it not possible to build and register them in build phase instead of doing it at the execution ?

FrankRay78 commented 3 weeks ago

Yes, I see what you mean @kvpt.

I'm keen to look into whether a non-breaking refactor could separate out these different build/run concerns.

I'll update the thread once I've investigated further.

wazzamatazz commented 2 weeks ago

@rcdailey In case it's of any interest, I've got a project here that I use to integrate with Microsoft.Extensions.DependencyInjection (actually in most cases Microsoft.Extensions.Hosting) that you may or may not find useful.

FrankRay78 commented 1 week ago

It's nearly December @rcdailey, my diary is emptying a little - how are you feeling about this issue?

patriksvensson commented 4 days ago

I'm going to take a look at this tonight and see if we can remove the TypeRegistrar/TypeResolver and instead take a dependency on the Microsoft.Extensions.DependencyInjection package.

FrankRay78 commented 4 days ago

I'm going to take a look at this tonight and see if we can remove the TypeRegistrar/TypeResolver and instead take a dependency on the Microsoft.Extensions.DependencyInjection package.

@patriksvensson It would be worth keeping in mind @kvpt's comment above, when looking into this, see: https://github.com/spectreconsole/spectre.console/issues/1657#issuecomment-2486250826

rcdailey commented 4 days ago

I'm going to take a look at this tonight and see if we can remove the TypeRegistrar/TypeResolver and instead take a dependency on the Microsoft.Extensions.DependencyInjection package.

Awesome news. I would see if you can instead get away with a dependency on just Microsoft.Extensions.DependencyInjection.Abstractions for the main package, this would allow users to adapt to their own DI system if they want. Then you can provide an extension package for actual concrete support for specific DI frameworks like Microsoft's.

patriksvensson commented 4 days ago

No, we still used a built-in DI container even if the user doesn't provide one, so would need to have an actual implementation as well.

rcdailey commented 4 days ago

I guess what I'm saying is that there's a strategy you could adopt where the Spectre.Console library itself uses constructor injection normally with no DI framework dependencies. Imagine using pure DI in this case. There would be nothing special about this, you're just using dependency inversion and factories.

Then via extension libraries, you can extend your configuration builder pattern to facilitate initialization of the library (i.e. provide its dependencies) using specific DI libraries, such as MS.DI, Autofac, etc. Users can also implement their own.

I'm not saying you should do this; it's just an approach. If you want to hard-code to support Microsoft.DI, then I completely understand. Other popular libraries like MassTransit do this, and it seems to work fine (except when you need to support older .NET frameworks).

Thanks again for acknowledging the issue and expressing support for it. If I can help at all please let me know.