Write a modern .Net Console application

Write a modern .Net Console application

Using Generic Host, System.CommandLine, Dependency Injection, Configuration and Logging

Building a modern console application has never been easier.

In this article we will be creating a console application using Generic Host and System.Commandline as well as leveraging dependency injection, configuration and logging. If you wish to see the final result you can just check out the Github project.

1. Create a new dotnet console application

dotnet new console -n MyConsoleApp
cd MyConsoleApp

Add System.Commandline packages

dotnet add package Microsoft.Extensions.Hosting
dotnet add package System.CommandLine --prerelease
dotnet add package System.CommandLine.Hosting --prerelease

At the time of the writing System.CommandLine is in Preview. The API may change substantially before it's released.

2. Use Generic Host with a Command

Let's create a new file and call it HashNodeGreetCommand. This will hold the command class and our command handler.

using System.CommandLine;
using System.CommandLine.Invocation;

    public class HashNodeGreetCommand : Command
    {
        public HashNodeGreetCommand() : base("greet", "Greet hashnode")
        {
            AddOption(new Option<string>(new string[] { "--message", "-m" }, "The greeting message"));
        }

        public new class Handler : ICommandHandler
        {
            public int Invoke(InvocationContext context)
            {
                Console.WriteLine("Hello from Invoke");
                return 0;
            }

            public Task<int> InvokeAsync(InvocationContext context)
            {
                return Task.FromResult(Invoke(context));
            }
        }
    }

Change Program.cs to this

using Microsoft.Extensions.Hosting;
var parser = BuildCommandLine()
    .UseHost(_ => Host.CreateDefaultBuilder(args), builder => builder
        .ConfigureServices((hostContext, services) =>
        {
        })
        .UseCommandHandler<HashNodeGreetCommand, HashNodeGreetCommand.Handler>())
        .UseDefaults()
        .Build();

return await parser.InvokeAsync(args);

static CommandLineBuilder BuildCommandLine()
{
    var rootCommand = new RootCommand("HashNode console application");
    rootCommand.AddCommand(new HashNodeGreetCommand());

    return new CommandLineBuilder(rootCommand);

System.CommandLine.Hosting adds a few methods that allow us to use the Generic Host with System.CommandLine.

The first one is UseHost, this will allow us to set up dependency injection, logging, configuration, etc.

The second method from this package that we are going to use is UseCommandHandler. We are going to use for setting up the commands and command handlers. If we have multiple commands, we can chain call this method multiple times.

If we run the program, we should see "Hello from Invoke" printed in the console.

After the parser is built, we call InvokeAsync and pass in the args.

3. Use Dependency Injection

First, let's create a simple service with a single method Run that will use the injected logger to print out a message.

public class HashNodeService
{
    private readonly ILogger<HashNodeService> _logger;

    public HashNodeService(ILogger<HashNodeService> logger)
    {
        _logger = logger;
    }

    public void Run()
    {
        _logger.LogInformation("Starting HashNode service");
    }
}

In Program.cs register the service in the ConfigureServices method. services.AddSingleton<HashNodeService>();

Inject the service into the command handler and call Run

    public new class Handler : ICommandHandler
    {
        private readonly HashNodeService _hashNodeService;

        public Handler(HashNodeService hashNodeService)
        {
            _hashNodeService = hashNodeService;
        }
        public int Invoke(InvocationContext context)
        {
            _hashNodeService.Run();
            return 0;
        }

        public Task<int> InvokeAsync(InvocationContext context)
        {
            return Task.FromResult(Invoke(context));
        }
    }

4. Configuration using appsettings.json and IOptions

Suppose we have a configuration for our service that is loaded from appsettings.json or some other configuration provider, but we also want to be able to overwrite it by using command line arguments.

Because Host.CreateDefaultBuilder loads command line args the last, we can use this to our advantage.

Let's start by adding appsettings.json file to the project (make sure it is copied to the output directory) and add some configuration. For example:

{
  "PersonToGreet": "Hash"
}

Create the config class

public class HashNodeConfig
{
    public string? PersonToGreet { get; set; }
}

Register the configuration in the ConfigureServices method

 .ConfigureServices((hostContext, services) =>
        {
             var configuration = hostContext.Configuration;
             services.Configure<HashNodeConfig>(configuration);

             services.AddSingleton<HashNodeService>();
        })

Inject the config into the service and log it

public class HashNodeService
{
    private readonly HashNodeConfig _config;
    private readonly ILogger<HashNodeService> _logger;

    public HashNodeService(IOptions<HashNodeConfig> options, ILogger<HashNodeService> logger)
    {
        _config = options.Value;
        _logger = logger;
    }

    public void Run()
    {
        _logger.LogInformation("Starting HashNode service");

        _logger.LogInformation("Hello: {personToGreet}", _config.PersonToGreet);
    }
}

If we run the program right now it will print the configuration from the appsettings.json dotnet run -- greet will print Hello: Hash

Next, we will add the ability to overwrite the option from the command line. In the HashNodeGreetCommand class, add a new option:

AddOption(new Option<string>("--personToGreet", "Person to greet"));

Now, if we run dotnet run -- greet --personToGreet Node it will overwrite the PersonToGreet configuration and it will print Hello: Node

5. Log using Serilog

We will be going to log using Serilog and configure it in appsettings.json. First, install the Serilog dependencies.

dotnet add package Serilog.Extensions.Hosting
dotnet add package Serilog.Sinks.Console
dotnet add package Serilog.Settings.Configuration

Update Program.cs to use Serilog with the configuration from appsettings.json



using System.CommandLine;
using System.CommandLine.Builder;
using System.CommandLine.Hosting;
using System.CommandLine.Parsing;

using ConsoleAppBoilerplate;

using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;

using Serilog;

var parser = BuildCommandLine()
    .UseHost(_ => Host.CreateDefaultBuilder(args), builder => builder
        .ConfigureServices((hostContext, services) =>
        {
            var configuration = hostContext.Configuration;
            services.Configure<HashNodeConfig>(configuration);

            services.AddSingleton<HashNodeService>();
        })
        .UseSerilog((hostingContext, _, loggerConfiguration) => loggerConfiguration
            .ReadFrom.Configuration(hostingContext.Configuration))
        .UseCommandHandler<HashNodeGreetCommand, HashNodeGreetCommand.Handler>())
        .UseDefaults()
        .Build();

return await parser.InvokeAsync(args);

static CommandLineBuilder BuildCommandLine()
{
    var rootCommand = new RootCommand("HashNode console application");
    rootCommand.AddCommand(new HashNodeGreetCommand());

    return new CommandLineBuilder(rootCommand);
}

Conclusion

We have set up a .Net Console application that uses System.CommandLine, offering us a lot of features out of the gate, like parsing the input and displaying help text. On top of that, we leveraged the Generic Host that gave us more goodies like Dependency injection (DI), Logging, Configuration, App shutdown etc. We also showed how we can use configuration and overwrite it from the command line, as well as setting up Serilog, useful when building serious applications.