Using CommandLineParser in a way friendly to Dependency Injection
Intro
If you are like me, you want to first establish a nice skeleton app that has everything just right before you start writing your actual code. However, as weird as it may sound, I couldn't find a way to use command line parameters with dependency injection, in the same simple way that one would use a configuration file with IOptions<T>
for example. This post shows you how to use CommandLineParser, a nice library that handles everything regarding command line parsing, but in a dependency injection friendly way.
In order to use command line arguments, we need to obtain them. For any .NET Core application or .NET Framework console application you get it from the parameters of the static Main method from Program. Alternately, you can use Environment.CommandLine, which is actually a string, not an array of strings, or Environment.GetCommandLineArgs(). But all of these are kind of nudging you towards some ugly code that either has a dependency on the static Environment, either has code early in the application to handle command line arguments, or stores the arguments somehow. What we want is complete separation of modules in our application.
Defining the command line parameters
In order to use CommandLineParser, you write a class that contains the properties you expect from the command line, decorated with attributes that inform the parser what is the expected syntax for all. In this post I will use this:
// the way we want to use the app is
// FileUtil <command> [-loglevel loglevel] [-quiet] -output <outputFile> file1 file2 .. file10
public class FileUtilOptions
{
// use Value for parameters with no name
[Value(0, Required = true, HelpText = "You have to enter a command")]
public string Command { get; set; }
// use Option for named parameters
[Option('l',"loglevel",Required = false, HelpText ="Log level can be None, Normal, Verbose")]
public string LogLevel { get; set; }
// use bool for named parameters with no value
[Option('q', "quiet", Default = false, Required = false, HelpText = "Quiet mode produces no console output")]
public bool Quiet { get; set; }
// Required for required values
[Option('o', "output", Required = true, HelpText = "Output file is required")]
public string OutputFile { get; set; }
// use Min/Max for enumerables
[Value(1, Min = 1, Max = 10, HelpText = "At least one file name and at most 10")]
public IEnumerable<string> Files { get; set; }
}
At this point the blog post will split into two parts. One is very short and easy to use, thanks to commenter Murali Karunakaran. The other one is what I wrote in 2020 when I didn't know better. This second part is just a reminder of how much people can write when they don't have to :)
The short and easy solution
All you have to do is add your command line parameters class as options, then define what will happen when you request one instance of it:
// in ConfigureServices or wherever you define dependencies for injection
services
.AddOptions<FileUtilOptions>()
.Configure(opt =>
Parser.Default.ParseArguments(() => opt, Environment.GetCommandLineArgs())
);
// when needing the parameters
public SomeConstructor(IOptions<FileUtilOptions> options)
{
_options = options.Value;
}
When an instance of FileUtilOptions is requested, the lambda will be executed, setting the options based on ParseArguments. If any issue, the parser will display the help to the console
This process, however, does not throw any exceptions. The instance of FileUtilOptions requested will be provided empty or partially/incorrectly filled. In order to handle the errors, some more complex code is needed, and here is a silly example:
using (var writer = new StringWriter())
{
var parser = new Parser(configuration =>
{
configuration.AutoHelp = true;
configuration.AutoVersion = false;
configuration.CaseSensitive = false;
configuration.IgnoreUnknownArguments = true;
configuration.HelpWriter = writer;
});
var result = parser.ParseArguments<T>(_args);
result.WithNotParsed(errors => HandleErrors(errors, writer));
result.WithParsed(value => _value = value);
}
// a possible way to handle errors
private static void HandleErrors(IEnumerable<Error> errors, TextWriter writer)
{
if (errors.Any(e => e.Tag != ErrorType.HelpRequestedError && e.Tag != ErrorType.VersionRequestedError))
{
string message = writer.ToString();
throw new CommandLineParseException(message, errors, typeof(T));
}
}
Now, the original post follows:
Writing a lot more than necessary
How can we get the arguments by injection? By creating a new type that encapsulates the simple string array.
// encapsulates the arguments
public class CommandLineArguments
{
public CommandLineArguments(string[] args)
{
this.Args = args;
}
public string[] Args { get; }
}
// adds the type to dependency injection
services.AddSingleton<CommandLineArguments>(new CommandLineArguments(args));
// the generic type declaration is superfluous, but the code is easy to read
With this, we can access the command line arguments anywhere by injecting a CommandLineArguments
object and accessing the Args property. But this still implies writing command line parsing code wherever we need that data. We could add some parsing logic in the CommandLineArguments
class so that instead of the command line arguments array it would provide us with a strong typed value of the type we want. But then we would put business logic in a command line encapsulation class. Why would it know what type of options we need and why would we need only one type of options?
What we would like is something like
public SomeClass(IOptions<MyCommandLineOptions> clOptions) {...}
Now, we could use this system by writing more complicated that adds a ConfigurationSource and then declaring that certain types are command line options. But I don't want that either for several reasons:
- writing configuration providers is complex code and at some moment in time one has to ask how much are they willing to write in order to get some damn arguments from the command line
- declaring the types at the beginning does provide some measure of centralized validation, but on the other hand it's declaring types that we need in business logic somewhere in service configuration, which personally I do not like
What I propose is adding a new type of IOptions
, one that is specific to command line arguments:
// declare the interface for generic command line options
public interface ICommandLineOptions<T> : IOptions<T>
where T : class, new() { }
// add it to service configuration
services.AddSingleton(typeof(ICommandLineOptions<>), typeof(CommandLineOptions<>));
// put the parsing logic inside the implementation of the interface
public class CommandLineOptions<T> : ICommandLineOptions<T>
where T : class, new()
{
private T _value;
private string[] _args;
// get the arguments via injection
public CommandLineOptions(CommandLineArguments arguments)
{
_args = arguments.Args;
}
public T Value
{
get
{
if (_value==null)
{
// set the value by parsing command line arguments
}
return _value;
}
}
}
Now, in order to make it work, we will use CommandLineParser which functions in a very simple way:
- declare a Parser
- create a POCO class that has properties decorated with attributes that define what kind of command line parameter they are
- parse the command line arguments string array into the type of class declared above
- get the value or handle errors
Also, to follow the now familiar Microsoft pattern, we will write an extension method to register both arguments and the mechanism for ICommandLineOptions
. The end result is:
// extension class to add the system to services
public static class CommandLineExtensions
{
public static IServiceCollection AddCommandLineOptions(this IServiceCollection services, string[] args)
{
return services
.AddSingleton<CommandLineArguments>(new CommandLineArguments(args))
.AddSingleton(typeof(ICommandLineOptions<>), typeof(CommandLineOptions<>));
}
}
public class CommandLineArguments // defined above
public interface ICommandLineOptions<T> // defined above
// full class implementation for command line options
public class CommandLineOptions<T> : ICommandLineOptions<T>
where T : class, new()
{
private T _value;
private string[] _args;
public CommandLineOptions(CommandLineArguments arguments)
{
_args = arguments.Args;
}
public T Value
{
get
{
if (_value==null)
{
using (var writer = new StringWriter())
{
var parser = new Parser(configuration =>
{
configuration.AutoHelp = true;
configuration.AutoVersion = false;
configuration.CaseSensitive = false;
configuration.IgnoreUnknownArguments = true;
configuration.HelpWriter = writer;
});
var result = parser.ParseArguments<T>(_args);
result.WithNotParsed(errors => HandleErrors(errors, writer));
result.WithParsed(value => _value = value);
}
}
return _value;
}
}
private static void HandleErrors(IEnumerable<Error> errors, TextWriter writer)
{
if (errors.Any(e => e.Tag != ErrorType.HelpRequestedError && e.Tag != ErrorType.VersionRequestedError))
{
string message = writer.ToString();
throw new CommandLineParseException(message, errors, typeof(T));
}
}
}
// usage when configuring dependency injection
services.AddCommandLineOptions(args);
Enjoy!
Final notes
Now there are some quirks in the implementation above. One of them is that the parser class generates the usage help by writing it to a TextWriter (default being Console.Error), but since we want this to be encapsulated, we declare our own StringWriter and then store the generated help if any errors. In the case above, I am storing the help text as the exception message, but it's the principle that matters.
Also, with this system one can ask for multiple types of command line options classes, depending on the module, without the need to declare said types at the configuration of dependency injection. The downside is that if you want validation of the command line options at the very beginning, you have to write extra code. In the way implemented above, the application will fail when first asking for a command line option that cannot be mapped on the command line arguments.
Note that the short style of a parameter needs to be used with a dash, the long one with two dashes:
- -o outputFile.txt - correct (value outputFile.txt)
- --output outputFile.txt - correct (value outputFile.txt)
- -output outputFile.txt - incorrect (value output and outputFile.txt is considered an unnamed argument)
Comments
can you please provide the full source code so we can understand it better thank you
ahmed salamaThe simplest way I find to handle errors is: services .AddOptions<FileUtilOptions>() .Configure(opt => Parser.Default.ParseArguments(() => opt, Environment.GetCommandLineArgs()) .WithNotParsed((errors) => { // Do what you want with errors (IEnumerable<Error>) Console.Write("Incorrect startup parameters."); Environment.Exit(-1); }));
nixHere's the simplest method I've found so far to just inject an "options" class, optionally populated from CLI arguments, into a hosted service. I can't believe what a PITA this was to figure out compared to the other dozen (or so :) languages I'm familiar with (everyone seems obsessed with interfaces and extra abstraction layers and whatnot). ``` static void Main(string[] args) { FileUtilOptions opts = new(); var parseResult = Parser.Default.ParseArguments(() => opts, args); if (parseResult.Tag == ParserResultType.NotParsed) System.Environment.Exit(-1); .... services .AddSingleton(opts) .AddSingleton<ISomeClass, SomeClass>() ; // for example } // To get the injected options: public SomeClass(FileUtilOptions options) { _options = options ?? throw new ArgumentNullException(nameof(options));; } ``` Cheers, -Max
MaxWe can combine the ErrorHandling and Registration to get best of both // in ConfigureServices or wherever you define dependencies for injection services .AddOptions&lt;FileUtilOptions&gt;() .Configure(opt =&gt; { using (var writer = new StringWriter()) { var parser = new Parser(configuration =&gt; { configuration.AutoHelp = true; configuration.AutoVersion = false; configuration.CaseSensitive = false; configuration.IgnoreUnknownArguments = true; configuration.HelpWriter = writer; }); var result = parser.ParseArguments(() =&gt; opt, Environment.GetCommandLineArgs()); result.WithNotParsed(errors =&gt; HandleErrors&lt;FileUtilOptions&gt;(errors, writer)); } }); private static void HandleErrors&lt;T&gt;(IEnumerable&lt;Error&gt; errors, TextWriter writer) { if (errors.Any(e =&gt; e.Tag != ErrorType.HelpRequestedError &amp;&amp; e.Tag != ErrorType.VersionRequestedError)) { string message = writer.ToString(); throw new CommandLineParseException(message, errors, typeof(T)); } } // when needing the parameters public SomeConstructor(IOptions&lt;FileUtilOptions&gt; options) { _options = options.Value; }
muraliI&#39;ve fixed the comment display and also updated the post with your contribution. Thanks a lot for your comments, Murali!
SideriteFor AddOptions use TemplateParameter FileOptions. For HostedService use CommandLineService as template parameter and in CommandLineService.cs for Ioptions use FileOptions as templateParameter. As soon as I use anglerackets, it is removed
murali1. Define a class to use for options // the way we want to use the app is // FileUtil &lt;command&gt; [-loglevel loglevel] [-quiet] -output &lt;outputFile&gt; file1 file2 .. file10 public class FileUtilOptions { // use Value for parameters with no name [Value(0, Required = true, HelpText = &quot;You have to enter a command&quot;)] public string Command { get; set; } // use Option for named parameters [Option(&#39;l&#39;,&quot;loglevel&quot;,Required = false, HelpText =&quot;Log level can be None, Normal, Verbose&quot;)] public string LogLevel { get; set; } // use bool for named parameters with no value [Option(&#39;q&#39;, &quot;quiet&quot;, Default = false, Required = false, HelpText = &quot;Quiet mode produces no console output&quot;)] public bool Quiet { get; set; } // Required for required values [Option(&#39;o&#39;, &quot;output&quot;, Required = true, HelpText = &quot;Output file is required&quot;)] public string OutputFile { get; set; } // use Min/Max for enumerables [Value(1, Min = 1, Max = 10, HelpText = &quot;At least one file name and at most 10&quot;)] public IEnumerable&lt;string&gt; Files { get; set; } } //Add registrations in program.cs or Startup.cs services.AddOptions&lt;FileUtilOptions&gt;().Configure(opt =&gt; Parser.Default.ParseArguments(() =&gt; opt, args); //args is from Main(string[] args) function. services.AddHostedService&lt;CommandLineService&gt;(); //CommandLineService.cs public class CommandLineService : BackgroundService { private readonly FileUtilOptions options; public CommandLineService (IOptions&lt;FileUtilOptions&gt; fileUtilOptions) { this.options = fileUtilOptions.Value; } }
muraliThat looks promising. Can you link to a blog post or expand the code?
SideriteIts easy without so much of code. Just one line services.AddOptions&lt;FileUtiOptions&gt;().Configure(opt =&gt; Parser.Default.ParseArguments(() =&gt; opt, args); Then inject IOptions&lt;FileUtiOptions&gt; fileOptions in any service you need
muraliThanks, Geoff! Although I wouldn&#39;t add all of those constructors, no matter what exception best practices suggest. Also, String.Format? What are we, animals? We have string interpolation now :)
SideriteMaybe try something like this for the CommandLineParserException: public class CommandLineParseException : Exception { public CommandLineParseException() { } public CommandLineParseException(string message) : base(message) { } public CommandLineParseException(string message, Exception innerException) : base(message, innerException) { } public CommandLineParseException(string message, IEnumerable&lt;Error&gt; errors, Type type) : base(message) { var sb = new StringBuilder(); foreach(var error in errors) { sb.Append(error.Tag.ToString()); } message = string.Format(&quot;{0}: {1}: Errors: {2}&quot;, type.Name, message, sb.ToString()); } }
Geoff DeFilippiNice post!
RamIt&#39;s just any exception.
Sideritenice post, very creative! could you please add implementation details of CommandLineParseException class?
michiel