Warning: the original post at: https://siderite.dev/blog/using-commandlineparser-in-way-friendly-to-depende has been updated with a much more elegant solution. Please follow the link above to access it.
Now for the old post content from Apr 2020:
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 handler 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. 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.
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 commmand 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!
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.
As a bonus, here is the way to define an option class that CommandLineParser will parse from the arguments:
// 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; }
}
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 utput and outputFile.txt is considered an unnamed argument)
Top comments (0)