C# .NET Core, Console App, DI, and Serilog - Getting Started

My day job involves a lot of .NET Core development and a whole lot of logging. As a result, I've become accustomed to having logs when I need them. When working on pet projects I usually don't spend as much time on the "niceties" of life. Lately I've started doing it a little more and wanted to detail some findings.

Command line apps with CommandLineApplication

There's a lot of resources out there on using Microsoft's new CommandLineApplication.

You could read through those if you want, they mostly say the same things.

"...it’s something I’ve wished for..."
"...really cool package..."

I always end up having to re-google these documents whenever I start a project because I have a hard time remembering the particulars. So I'm going to lay them out here:

Dependencies

  • Microsoft.Extensions.CommandLineUtils

Commands

Grouping of arguments and options:

Examples

git clone git://some/where.git
git checkout some/branch

Options

The normal command line options:

Examples

git checkout -b
git diff --name-status

Arguments

Arguments are the non-obvious trailing, order specific, facets to a console application:

Examples

git add some/file/here another/file
git clone git://some/where.git

Copy/Pasta Starter

public static void Main(string[] args)
{
    await BuildApplication();
}

private static CommandLineApplication BuildApplication()
{
    var app = new CommandLineApplication();
    app.Command("start", config =>
    {
        config.HelpOption("--help");
        var option = config.Option(
            "-o|--option",
            "Some fancy option",
            CommandOptionType.SingleValue);
        var multiOption = config.Option(
            "-m|--multi-option",
            "Some fancy multi-option",
            CommandOptionType.MultipleValue);
        var flagOption = config.Option(
            "-f|--flag",
            "Some fancy flag",
            CommandOptionType.NoValue);
        var arg1Arg = config.Argument(
            "[arg1]",
            "Some fancy argument");
        var argListArg = config.Argument(
            "[argList] ...",
            "Some argument list",
            multipleValues: true);

        config.OnExecute(async () => {
            var optionValue = option.HasValue()
                ? option.Value()
                : "Some Default Value";
            var multiOptionValue = multiOption.Values;
            var isFlag = flagOption.HasValue();
            var arg1Value = arg1Arg.HasValue()
                ? arg1Arg.Value()
                : "Some Default Value";
            var argListValue = argListArg.Values;

            // TODO : Something
        });
    });
    app.Execute(args);
}

Serilog

We use Serilog for logging instead of the vanilla Microsoft.Extensions.Logging. It adds a few more bells and whistles to your logs and has worked really well for us. The drawback with Serilog is that certain things (ie. setup) is challenging to get working. Below are some of those steps in copy/pasta form.

Using Serilog with appsettings

Serilog is great for programmatically settings it up - they use a convenient, readable builder pattern. That's not so great for editing post-compilation though. There is Serilog.Settings.Configuration NuGet package that lets you configure the logger straight from Microsoft's Microsoft.Extensions.Configuration library.

We use a JSON appsettings file for work but I much prefer YAML.

Dependencies

  • Serilog
  • Serilog.Settings.Configuration
  • Microsoft.Extensions.Configuration.Yaml

Copy/Pasta Starter

public static void Main(string[] args)
{
    var configuration = BuildConfiguration();
    var logger = BuildLogger(configuration);
}

public static IConfiguration BuildConfiguration()
{
    return new ConfigurationBuilder()
        .AddYamlFile("appsettings.yaml")
        .Build();
}

public static ILogger BuildLogger(IConfiguration config)
{
    return new LoggerConfiguration()
        .ReadFrom.Configuration(config)
        .CreateLogger();
}
Serilog:
  Using:
    - "Serilog.Sinks.Console"
  MinimumLevel: "Debug"
  WriteTo:
    - Name: "Console"
  Enrich:
    - "FromLogContext"
    - "WithMachineName"
    - "WithThreadId"

Customizing Sinks

The actual challenging part using Serilog is with a configuration file. The documentation for interacting with Serilog via Serilog.Settings.Configuration is incredibly sparse. The trick is figuring out how to pass arguments generally passed when programmatically configuring the logger.

The simplest way to explain this is to show the extension methods for setting up the Console sink programmatically and then showing what that turns into in the configuration file:

public static LoggerConfiguration Console(
            this LoggerSinkConfiguration sinkConfiguration,
            LogEventLevel restrictedToMinimumLevel = LevelAlias.Minimum,
            string outputTemplate = DefaultConsoleOutputTemplate,
            IFormatProvider formatProvider = null,
            LoggingLevelSwitch levelSwitch = null,
            LogEventLevel? standardErrorFromLevel = null,
            ConsoleTheme theme = null);

public static LoggerConfiguration Console(
            this LoggerSinkConfiguration sinkConfiguration,
            ITextFormatter formatter,
            LogEventLevel restrictedToMinimumLevel = LevelAlias.Minimum,
            LoggingLevelSwitch levelSwitch = null,
            LogEventLevel? standardErrorFromLevel = null);

As you can see, Console extends LoggerSinkConfiguration and accepts a bunch of parameters - most of which are defaulted. When you specify Serilog.Sinks.Console in appsettings.yaml, the configuration reader is actually locating one of these methods and calling it on an internal configuration builder.

So how de we pass parameters to this? The following shows how to pass in a IFormatProvider to the Console extension method. The formatter is one I'm using for Guids and is merely used as an example.

Serilog:
  Using:
    - "Serilog.Sinks.Console"
  MinimumLevel: "Debug"
  WriteTo:
    - Name: "Console"
      Args:
        formatProvider: "Shared.Logging.GuidFormatter"
  Enrich:
    - "FromLogContext"
    - "WithMachineName"
    - "WithThreadId"

The takeaway here is that the arguments are passed in as an key/value dictionary to Args.

Tying it all together with Dependency Injection

DI is great and can really simplify your code and object activation and management. Microsoft provides Microsoft.Extensions.DependencyInjection and it gets the job done.

Dependencies

  • Microsoft.Extensions.DependencyInjection;
  • Microsoft.Extensions.DependencyInjection.Extensions;

Copy/Pasta Starter

public static void Main(string[] args)
{
    var serviceProvider = ConfigureServices();
    var app = serviceProvider.GetRequiredService<Application>();
    app.Execute();
}

public static IServiceProvider ConfigureServices()
{
    return new ServiceCollection()
        .AddSingleton(BuildConfiguration)
        .AddSingleton<ILogger>(s => BuildLogger(s))
        .AddSingleton<Application>()
        .BuildServiceProvider();
}

public static IConfiguration BuildConfiguration()
{
    return new ConfigurationBuilder()
        .AddYamlFile("appsettings.yaml")
        .Build());
}

public static ILogger BuildLogger(IServiceProvider provider)
{
    return new LoggerConfiguration()
        .ReadFrom
        .Configuration(provider.GetRequiredService<IConfiguration>())
        .CreateLogger();
}
public class Application
{
    private readonly ILogger _logger;
    private readonly IConfiguration _config;
    private readonly CommandLineApplication _app;

    public Application(ILogger logger, IConfiguration config)
    {
        _logger = logger;
        _config = config;
        _app = BuildApplication();
    }

    public CommandLineApplication BuildApplication()
    {
        // ...
    }

    public void Execute()
    {
        _app.Execute();
    }
}

The coup de grâce is decomposing your command line application into separate blocks. Check out Structuring Neat .NET Core Console Apps Neatly for a pretty handy guide on doing that!

Show Comments