Shawn Wildermuth

Author, Teacher, and Filmmaker
.NET Foundation Board Member

.NET Core Console Apps - A Better Way?


Console [UPDATE] After reviewing some of the code and talking with commenters, I agree that using HostServices for console apps is a bad idea. I've refactored this to use the host but not the HostService. I believe that the entire host is still useful for configuration, logging, and DI. See below for the latest changes:

I was at a customer site last week and a lot of their integration code is a set of console apps that are run on timers to import and export data. This isn't an uncommon use-case.

I've got a couple of these lying around myself. I realized that I didn't know of a good exemplar of doing simple console apps using .NET Core in a way that is closer to ASP.NET Core (e.g. dependency injection, lifetime management, cancellation, etc.). So I decided to re-write an old console app I have.

Welcome to Pinger. Pinger was a simple console app I wrote ages ago to ping a range of addresses. I am sure this exists in other places, but sometimes you just want to write it. So I took the core logic of the old, .NET version and ported it to .NET Core, but with some additional services to make the code simple.

Dependency Injection was one of my main wants so I started simple and just created my own service collection:

ServiceCollection coll = new ServiceCollection();
coll.AddSingleton(options);
coll.AddTransient<PingerService>();

var provider = coll.BuildServiceProvider();

var svc = provider.GetService<PingerService>();
svc.Run();

This was fine, but I decided that I wanted more features. Luckily, the hosting in .NET Core 3.1 supports this. If you add a reference to the Microsoft.Extensions.Hosting assembly, you can use the default hosting:

> dotnet add package Microsoft.Extensions.Hosting
var host = Host.CreateDefaultBuilder()
               .Build();

By default, we get dependency injection, support for configuration files, and logging. We need to setup our services first:

var host = Host.CreateDefaultBuilder()
  .ConfigureServices((b, c) =>
  {
    c.AddTransient<SomeDependency>();
  })
  .Build();

Then we can use the host's services to create our instance:

var pinger = ActivatorUtilities.CreateInstance<PingerService>(host.Services);
pinger.Run();

Instead of adding our own service, we're just adding the dependencies to the service collection. We create our service instance by just using the ActivatorUtilities. By adding the service, you can handle the code that your project is actually doing any way you need:

  internal class PingerService 
  {
    private readonly ILogger<PingerService> _logger;

    public PingerService(ILogger<PingerService> logger)
    {
      _logger = logger;
    }
    
    public void Run()
    {
      // ...
    }

      
  }

Because we're using just executing our code, we can just run it. This could be asynchronous if necessarily (pinging asynchronously might return results out of order, so I'm doing it synchronously) It will close the console app after all work is complete. The rest of this is just plumbing that you don't have to worry about. But I wasn't finished. I wanted to handle the console command parsing too. To do this, I'm using the CommandLineParser:

> dotnet add package CommandLineParser 

With the parser, you can just use the parser to take the arguments and turn them into the options. This starts by creating an object that describes the options (I'm using it for a very simple case):

public class Options
{
  [Value(0, MetaName = "first", Required = true, HelpText = "Starting IP Address (or DNS Name)")]
  public string FirstAddress { get; set; }

  [Value(1, MetaName = "last",  Required = false, HelpText = "Ending IP Address (or DNS Name)")]
  public string LastAddress { get; set; }

  [Option('r', "repeats", Required = false, HelpText = "Number of times to repeat the pings")]
  public int Repeats { get; set; } = 1;
}

In this case, I just want a start and ending IP address and optionally specify if we should ping each IP multiple times. Then I can use the parser in the program.cs:

Parser.Default.ParseArguments<Options>(args)
  .WithParsed(options =>
  {
    var host = Host.CreateDefaultBuilder()
      .ConfigureServices((b, c) =>
      {
        c.AddSingleton(options);
      })
      .ConfigureLogging(bldr =>
      {
        bldr.ClearProviders();
        bldr.AddConsole()
          .SetMinimumLevel(LogLevel.Error);
      })
      .Build();

    var svc = ActivatorUtilities.CreateInstance<PingerService>(host.Services);
    svc.Run();

  });

This just parses the options and if it's not a good set of options, it just displays the help text. Otherwise, it executes the WithParsed lambda function to do the other work. I'm adding the options object as a singleton so that the PingerService (or anything that uses it) can ask for the Options object as a dependency.

That's really it. The example is a little bit more complex than that. You can view all the code (or fork it) at:

https://github.com/shawnwildermuth/pinger

If you think there is something I'm missing, please reply in the comments. I want to have a good example to show students.