Command-line-api: Dependency Inject / Hosting / Middleware

Created on 21 Aug 2020  路  11Comments  路  Source: dotnet/command-line-api

Currently there is a lot of disparate discussion around utilizing dependency injects, hosting, and middleware. This issue aims to consolidate that discussion.

@jonsequitur, @KathleenDollard, and @Keboo met and after some discussion wanted to get more feedback on potential directions.

Current process

For a command line invocation the process flows through several steps:

Tokenization -> Parsing -> Middleware invocation -> Model binding -> Command handler invocation

Inside of the Middleware invocation various steps occur. Though the order is configurable the following is the current default order:
C# internal enum MiddlewareOrderInternal { Startup = -4000, ExceptionHandler = -3000, EnvironmentVariableDirective = -2600, ConfigureConsole = -2500, RegisterWithDotnetSuggest = -2400, DebugDirective = -2300, ParseDirective = -2200, SuggestDirective = -2000, TypoCorrection = -1900, VersionOption = -1200, HelpOption = -1100, ParseErrorReporting = 1000, }

Within all of these steps, there are various places where dependency injection may be desirable:

  • Manual handling of dependency inject within your code. Nothing is handled by this library and all DI registration and resolution occur within your code.
  • Within the command handler invocation, such as specifying a service as a parameter in the invocation handler.
  • Within custom individual middleware steps.

Considerations

  • The earlier in the process that dependency injection containers are configured may incur additional performance hits; even when the dependency are not consumed.

  • Dependency injection container creation may be fragile. This can cause startup exceptions if it is configured incorrectly. At the same time it is common to want to use dependency such as logger, built from the dependency injection container. This can cause circular dependencies.

  • We consider the process of spinning up a hosting model (such as generic host) a similar problem to creating a dependency injection container. A primary purpose of many hosting models is to provide the mechanism for the DI container to resolve dependencies.

Requested feedback

  • Is the above list of places where dependency injection might be desirable complete?
  • Are there any other use cases around dependency injection or hosting that don't appear to be addressed?
  • Is providing host/dependency injection container creation as a middleware step sufficient?

Related Items:

671

918

919

974

Area-Parser and Model Binder needs discussion

Most helpful comment

Thanks, @fredrikhr. That's a great overview.

To expand on this point in the issue intro and add some use cases:

The earlier in the process that dependency injection containers are configured may incur additional performance hits; even when the dependency are not consumed.

A pattern that we've used in a few apps that use System.CommandLine is that different subcommands lead to different hosting needs. For example, in dotnet try:

  • dotnet try, dotnet try demo -> Web host
  • dotnet try publish, dotnet try verify -> Do work and exit, no host needed.

Having different DI setups per command handler has two benefits:

  • We only do the work that's needed when we know it's needed, without impacting the performance of operations such as suggestions and help.
  • We prevents cross-scenario coupling that might occur by having a single DI setup for all scenarios.

All 11 comments

I also want to point that there are different flavours of Dependency Injection leading to fundamental differences in how DI containers work.

System.CommandLine

The invocation pipeline of System.CommandLine contains a light-weight internal Service Provider providing DI-services to the BindingContext and ModelBinder types. Its access level on InvocationContext is internal meaning that callers to UseMiddleware cannot interact with the service provider directly. There is a method AddService that can be called to register a new service type. The DI system in the command-line invocation middleware has the following characteristics:

  • Type registrations are always supplied by factory method (Func<T>), if need be a capturing lambda returning a singleton instance can be used.
  • The internal service provider can only directly resolve the exact types that have been added previously
  • By default BindingContext, itself, ParseResult, IConsole, CancellationToken and IHelpBuilder are registered.
  • You can use a ModelBinder instance to construct instances or populate the properties of existing instances using types supplied from the internal service provider.
  • ModelBinder is capable of resolving instances from various different sources

    • Types registered on the internal Service provider

    • If the name of the parameter or property to fill matches one of the Argument names in the invocation

  • ModelBinder is capable of providing instances for

    • Instance construction

    • Method invocation

    • Property binding

  • All registered services are essentially Transient (see below), ArgumentResult and OptionResult have caching capabilities.

Microsoft.Extensions.DependencyInjection

So when talking about DI in general one prominent example is the Microsoft.Extensions.DependencyInjection package (and its brothers and sisters) containing the DI-system that you use in the .NET Generic Host and which people are familiar with mostly from working with ASP.NET Core. That DI system has the following main characteristics:

  • It is designed around Constructor parameter injection only
    That means the DI system can construct instances for registered types (and by extension also foreign types) by calling a constructor of the type and then recursively resolving instances for each of the chosen construtor's parameters.
  • Has no direct capability to populate properties/fields of a type.
  • Has no direct capability to fill out parameter values for method invocation (other than the .ctor).
  • It supports scoping of lifetime of the resolved instances into three catagories:

    1. Singleton — instance is created once during the lifetime of the DI container and always resolves to the same exact instance, when subsequently resolving the type.

    2. Scoped — you construct a nested, short-lived DI container that inherits all previously resolved singletons and all service registrations, but only lives until it is disposed. Singletons in the nested container are shared with the root, others are known/visible only to the scoped container. ASP.NET Core introduces a new scope for each new incoming HTTP request, allow user identifiable services (such as login tokens) to be available in DI, but ensuring that they do not flow into other client's DI containers.

    3. Transient — A new instance of the type is constructed every time it is resolved.

  • It supports registration of open generic types
    e.g. registering Logger<> will enable the DI container to resolve any instance of Logger<T> regardless of the given generic type argument. i.e. it can now automatically resolve both Logger<int> and Logger<MySpecialType>.

The default logging and configuration systems for the .NET Generic Host (Microsoft.Extensions.Logging, Microsoft.Extensions.Configuration and Microsoft.Extensions.Options) use open generic type registrations and are usually used together with the Microsoft.Extensions.DependencyInjection system.

System.CommandLine.Hosting

In this repo, we use the System.CommandLine.Hosting package to create a sort of bridge between these two systems. The way this is done is by creating an invocation middleware, that constructs a .NET Generic Host, configures it and then runs it. While the host is running, the next middleware in the pipeline is invoked. When the next middleware completes, the Host is shutdown and disposed. The next middleware gets access to an instance of IHost that is available through a System.CommandLine.ModelBinder against the binding context in the invocation context. When resolving IHost you now have access to the DI system on the other side of the bridge (the side of the .NET Generic Host) and (by default) a Microsoft.Extensions.DependencyInjection DI-container that is purposefully disjunct from System.CommandLine. You access that IServiceProvider through the Services property on the IHost instance.

.NET Generic Host services that can be resolved by the System.CommandLine ModelBinder:

  • Microsoft.Extensions.Hosting.IHost

The .NET Generic Host will also get access to parts of the System.CommandLine invocation context:

  • InvocationContext, registered as a pre-constructed singleton instance, this is the context available when calling UseMiddleware.
  • BindingContext, registered as a pre-constructed singleton instance. Supplied from the BindingContext property on InvocationContext.
  • IConsole, registered as a pre-constructed singleton instance. Supplied from the Console property on InvocationContext.
  • ParseResult, registered as a transient service, that when resolved always reads the current value of the ParseResult property on InvocationContext.
  • IInvocationResult, registered as a transient service, that when resolved always reads the current value of the InvocationResult property on InvocationContext.

Examples

Imagine the following method being a command handler that is set as the Handler of a Command:

public static async Task<int> RunCommand(IHost host, ParseResult parseResult, string myOptions, 
    CancellationToken cancelToken)
{
    var hostedParseResult = host.Services.GetRequiredService<ParseResult>();
    Console.WriteLine(ReferenceEquals(parseResult, hostedParseResult)); // Will write: True

    var loggerFactory = host.Services.GetService<ILoggerFactory>() ??
        Microsoft.Extensions.Logging.Abstractions.NullLoggerFactory.Instance;
}

Above, the values for the method parameters host, parseResult, myOptions and cancelToken are all provided by the Model Binding capabilities of System.CommandLine and its BindingContext.

  • It always knows about ParseResult.
  • The name myOptions must match match an argument in the command, and is resolved from the command-line arguments.
  • IHost is made known to then BindingContext when UseHost is called.
  • CancellationToken is also know by default, but is only configured when CancelOnProcessTermination is called (included in UseDefaults).

The body shows interactions with the Microsoft.Extensions.DependencyInjection system.

  • ParseResult is made known to it by calling UseHost.
  • ILoggerFactory does not have any affiliation to System.CommandLine. Whether the call to GetService returns null or not depends on whether you have added logging to the hosted DI container (done by default when calling Host.CreateDefaultBuilder).

Thanks, @fredrikhr. That's a great overview.

To expand on this point in the issue intro and add some use cases:

The earlier in the process that dependency injection containers are configured may incur additional performance hits; even when the dependency are not consumed.

A pattern that we've used in a few apps that use System.CommandLine is that different subcommands lead to different hosting needs. For example, in dotnet try:

  • dotnet try, dotnet try demo -> Web host
  • dotnet try publish, dotnet try verify -> Do work and exit, no host needed.

Having different DI setups per command handler has two benefits:

  • We only do the work that's needed when we know it's needed, without impacting the performance of operations such as suggestions and help.
  • We prevents cross-scenario coupling that might occur by having a single DI setup for all scenarios.

@fredrikhr very insightful! I would like to clarify on a few points in the section regarding Microsoft.Extensions.DependencyInjection.
You say:

  • Has no direct capability to populate properties/fields of a type.
  • Has no direct capability to fill out parameter values for method invocation (other than the .ctor).

Neither of these items are considered dependency injection as those actions are a product of deserialization and mapping of fields, they aren't objects that the class depends on in order to be constructed successfully.
The only reason I mention this is that I believe users like the model binding characteristics but it's not to be confused with the desire of getting dependencies through a constructor so that they can, for example, create unit tests around the required framework implementations using the command line api in a well known way. I don't think anyone should misconstrue model binding and method invocation parameter values with dependency injection here.

Fantastic write up once again!

@Keboo I don't see this specifically called out regarding abilities gained. In addition to what is listed, being able to associate a command line handler to a given command through a DI method would be good. I think https://github.com/dotnet/command-line-api/pull/671 has done a nice job in that effort but thinking about it more, it's not as apparent to the user that the given command already has been set for you. I think to follow the DI pattern more closely it should just be a passed at construction for clarity of what is happening.

I posted in an issue and was linked here and I'm also dealing with this @jonsequitur. We have hosted services that we configured based on manually parsed arguments and I was wanting to use this library to define sub commands in code and then have it parse and automatically configure hosted services dynamically. This part I've really been struggling with as I want to fail fast but I've had a hard time getting access to parsed results / parsed options objects inside of configure services (in IHostBuilder).

@niemyjski For the issue you describe in https://github.com/dotnet/command-line-api/issues/556#issuecomment-695022203 I'd use the BindCommandLine() extension method for options binding.

See this Gist I made for you: https://gist.github.com/fredrikhr/c9509ff5ce2071880f987d770097c843#file-dotnetcommandlineapibindcommandline-cs-L37

Note that BindCommandLine will actually even try to use a ModelBinder from DI if available, you can see in the Gist that I add a ModelBinder<MyOptions> to DI that maps --setting to the MySetting property (Note that the names do not match here, and it still works, because I instructed the model binder on how to populate the property!)

@niemyjski Generally, you should not need to access the BindingContext directly from the host builder. E.g. for options you can always do:

services.AddOptions<MyOptions>()
  .Configure<BindingContext, ParseResult>((opts, bindingContext, parseResult) =>
  {
    // Initialize options using the BindingContext and/or ParseResult
  });

which is generally what the implementation of BindCommandLine does.

That being said, there is a really hacky escape hatch for the less-than-one-percent-scenario where you really cannot live without access to the BindingContext and ParseResult from the IHostBuilder:
https://github.com/dotnet/command-line-api/blob/08d0408ce80894e1c9801f05561fc7b8b583c390/src/System.CommandLine.Hosting/HostingExtensions.cs#L25

This is from the implementation of the UseHost extension method. It enables you to retrieve the InvocationContext (which has access to BindingContext, ParseResult, IConsole, etc.) from the IHostBuilder properties like so:

var invocation = (InvocationContext)(hostBuilder.Properties[typeof(InvocationContext)]);

Thanks, I'll see I can get it to work from this. I wasn't sure I had access to the host builder from inside configure services.

That being said, there is a really hacky escape hatch for the less-than-one-percent-scenario where you really cannot live without access to the BindingContext and ParseResult from the IHostBuilder:

Maybe the scenario isn't that rare and this API should be a little easier to access.

The next middleware gets access to an instance of IHost that is available through a System.CommandLine.ModelBinder against the binding context in the invocation context.

A convenient way to get access to the IHost (or any other service) from within a middleware function would be nice to have. I'm currently using this extension method to get the IHost:

public T GetService<T>(this InvocationContext context) {
    return (T)(new ModelBinder(typeof(T)).CreateInstance(context.BindingContext));
}

I assume that's the correct way to get a service within a middleware function, but it would be nice if that was something that was already available. Even just exposing an IServiceProvider from the InvocationContext might be good enough, though that could be a bit confusing if you're using the System.CommandLine.Hosting package since it's not the same as the IServiceProvider that's exposed off the IHost.

Maybe the System.CommandLine.Hosting package could have an InvocationContext.GetHost() extension method, which would allow you to do this:

context.GetHost().Services.GetRequiredService<IMyService>()

Maybe the System.CommandLine.Hosting package could have an InvocationContext.GetHost() extension method, which would allow you to do this:
context.GetHost().Services.GetRequiredService()

This seems like a solid approach, as it keeps the System.CommandLine service provider out of sight.

Was this page helpful?
0 / 5 - 0 ratings

Related issues

DualBrain picture DualBrain  路  3Comments

mariopasquali picture mariopasquali  路  4Comments

erichiller picture erichiller  路  3Comments

urig picture urig  路  6Comments

rgueldenpfennig picture rgueldenpfennig  路  4Comments