DEV Community

loading...
Cover image for Creating a single-file executable CLI with ASP.NET Core 3.0
IT Minds

Creating a single-file executable CLI with ASP.NET Core 3.0

Morten Hau Lyng
I'm a Senior Software Engineer at IT Minds in Aarhus Denmark
Originally published at insights.it-minds.dk ・6 min read

A new experimental feature in .NET Core is the System.CommandLine NuGet package(s). These make it possible for developers to create CLI (Command Line Interface) projects using the console application template from .NET Core.

This is interesting since it enables you to expose existing business logic class libraries via a CLI.

Working with System.CommandLine

When working with System.CommandLine there are multiple approaches

DragonFruit

One approach is to use an extra NuGet Package called System.CommandLine.DragonFruit, this package allows you to make strongly typed input arguments for your Main class, afterwards the package will generate appropriate commands, options and arguments for running the application as a CLI. This is however in my opinion not the best solution as it generates input arguments as options instead of Arguments forcing user to explicitly declare argument names.

Method first

Method first relies on reflection to find the appropriate method inside the Application and create an invocation for it. I'm not a fan of this solution and will use no further time explaining it. Please see the System.CommandLine wiki for more info.

Syntax first

Syntax first is a declarative approach where you define your commands, options and arguments in code. Furthermore the invocation of the given commands are also declared in code. This is the approach we will be using for this demonstration.

Show me the code

To emulate the scenario where you have existing Business Logic you want to expose through a CLI we have implemented the class Calculator. It is a very simple static calculator class containing four methods (add, subtract, multiply and divide). See the calculator code here:

public static class Calculator
{
    public static double Add(double first, double second)
    {
        return first + second;
    }

    public static double Subtract(double first, double second)
    {
        return first - second;
    }

    public static double Multiply(double first, double second)
    {
        return first * second;
    }

    public static double Divide(double first, double second)
    {
        return first / second;
    }
}

To get started with the CLI project run the following command to scaffold you Console Application

dotnet new console -o Calculator

Add the necessary NuGet packages with the following commands (Please note that as of writing this, these are experimental features which is why we include an alpha version)

dotnet add package System.CommandLine.Experimental --version 0.3.0-alpha.19405.1

To make it console friendly a wrapper class is implemented to handle the console output.

public static class ConsoleCalculator
{
    public static void Add(double first, double second) {
        Console.WriteLine($"Add called with arguments, first: {first}; second: {second}");
        Console.WriteLine($"Result: {Calculator.Add(first, second)}");
    }

    public static void Subtract(double first, double second) {
        Console.WriteLine($"Subtract called with arguments, first: {first}; second: {second}");
        Console.WriteLine($"Result: {Calculator.Subtract(first, second)}");
    }

    public static void Multiply(double first, double second) {
        Console.WriteLine($"Multiply called with arguments, first: {first}; second: {second}");
        Console.WriteLine($"Result: {Calculator.Multiply(first, second)}");
    }

    public static void Divide(double first, double second) {
        Console.WriteLine($"Divide called with arguments, first: {first}; second: {second}");
        Console.WriteLine($"Result: {Calculator.Divide(first, second)}");
    }
}

All this is all fine and well but, there's nothing new here. For the System.CommandLine magic to shine through we need to take a look at our Program.cs.

class Program
{
    static int Main(string[] args)
    {
        // Define the 'add' command
        var addCommand = new Command("add")
        {
            Description = "Adds <first> to <second>"
        };

        // Define the arguments for the add command
        // Please note that these argument names should match the input variables in the method
        addCommand.AddArgument(new Argument<double>("first"));
        addCommand.AddArgument(new Argument<double>("second"));

        // Define command handler for command
        // The parsed arguments are forwarded to the Add method of the ConsoleCalculator
        addCommand.Handler = CommandHandler.Create<double, double>(ConsoleCalculator.Add);

        // Add a shorthand alias
        addCommand.AddAlias("a");

        // Define the 'sub' command
        var subComamnd = new Command("sub")
        {
            Description = "Subtracts <second> from <first>"
        };

        // Define the arguments for the sub command
        // Please note that these argument names should match the input variables in the method
        subComamnd.AddArgument(new Argument<double>("first"));
        subComamnd.AddArgument(new Argument<double>("second"));

        // Define command handler for command
        // The parsed arguments are forwarded to the Subtract method of the ConsoleCalculator
        subComamnd.Handler = CommandHandler.Create<double, double>(ConsoleCalculator.Subtract);

        // Add a shorthand alias
        subComamnd.AddAlias("s");

        // Define the 'multiply' command
        var multiplyComamnd = new Command("multiply")
        {
            Description = "Multiplies <first> with <second>"
        };

        // Define the arguments for the multiply command
        // Please note that these argument names should match the input variables in the method
        multiplyComamnd.AddArgument(new Argument<double>("first"));
        multiplyComamnd.AddArgument(new Argument<double>("second"));

        // Define command handler for command
        // The parsed arguments are forwarded to the Multiply method of the ConsoleCalculator
        multiplyComamnd.Handler = CommandHandler.Create<double, double>(ConsoleCalculator.Multiply);

        // Add a shorthand alias
        multiplyComamnd.AddAlias("m");

        // Define the 'divide' command
        var divideComamnd = new Command("divide")
        {
            Description = "Divides <first> with <second>"
        };

        // Define the arguments for the divide command
        // Please note that these argument names should match the input variables in the method
        divideComamnd.AddArgument(new Argument<double>("first"));
        divideComamnd.AddArgument(new Argument<double>("second"));

        // Define command handler for command
        // The parsed arguments are forwarded to the Divide method of the ConsoleCalculator
        divideComamnd.Handler = CommandHandler.Create<double, double>(ConsoleCalculator.Divide);

        // Add a shorthand alias
        divideComamnd.AddAlias("d");

        // A RootCommand is required, we define it below and define our commands as subcommands
        var rootCommand = new RootCommand
        {
            addCommand,
            subComamnd,
            multiplyComamnd,
            divideComamnd
        };

        // Invoke the root command async and return the result
        return rootCommand.InvokeAsync(args).Result;
    }
}

That's actually it. now we can build the project using the following

dotnet build

After that please go into the destination folder of the build. If your setup is similar to mine it'll be

<project-folder>\bin\Debug\netcoreapp3.0\win10-x64

Now you can test the application by running the following

.\Calculator.exe --help

which produces the following output:

Usage:
  Calculator [options] [command]
Options:
  --version    Display version information
Commands:
  a, add <first> <second>         Adds <first> to <second>
  s, sub <first> <second>         Subtracts <second> from <first>
  m, multiply <first> <second>    Multiplies <first> with <second>
  d, divide <first> <second>      Divides <first> with <second>

Let's do a simple test by adding two doubles, you can do this by running the following

.\Calculator.exe a 1.2 2.3

Which gives the output

Add called with arguments, first: 1,2; second: 2,3
Result: 3,5

Publish Single file

To enable a true cli experience we would like to ship our application as a single executable file.
This has been made possible in .NET Core 3.0 - please modify your .csproj file to look like the following

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>netcoreapp3.0</TargetFramework>
    <RuntimeIdentifier>win10-x64</RuntimeIdentifier>
    <PublishSingleFile>true</PublishSingleFile>
    <PublishTrimmed>true</PublishTrimmed>
    <PublishReadyToRun>true</PublishReadyToRun>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="System.CommandLine.Experimental" Version="0.3.0-alpha.19405.1" />
  </ItemGroup>

</Project>

The new properties will be described below:

PublishSingleFile: When true a single platform-specific single-file executable. This is the reason for setting <RuntimeIdentifier> property. See single-file bundler design document

PublishTrimmed: When true the compiler analyses the IL code and removes unused assemblies to optimize the size of the final artifact. Note! If your application uses Reflection or related dynamic features might break as the compiler cannot resolve the dynamic features. This can be prevented by introducing a list for the linker of DLL's it cannot remove. See the release announcement for .NET Core 3.0 or the documentation for the IL Linker.

PublishReadyToRun: This enables R2R (Ready To Run) compilation which is a kind of AOT (Ahead OF Time) compilation. This introduces restrictions in regard to cross-platform compilation and architecture. Please see the release announcement for .NET Core 3.0.

Now when you publish your project using

dotnet publish -c Release

You will get a single exe file that contains your application. It would seem that the first time you run this application it extracts the necessary dll's to the temporary file storage as to be able to use them.

A closer look at trimming

Let's take a look at how effective the trimming actually is, first i set the property to false in my .csproj file and then run

dotnet publish -c Release

I get a single-file executable that hasn't been trimmed. The size of this file is 67,787KB or roughly 67.7MB.
Now lets set the property to true again and publish the file anew.

After publishing with the property set to true, the size of our executable is 36,713KB or roughly 36.7MB, so the trimming actually almost halved the size of our executable - neat!

Conclusion

In my opinion this is an awesome development enabling users to expose business logic via a CLI. I especially see it as being extremely powerful in a DevOps setup where you can build customs utilities to manage stuff like database seeding, migration and so on. It is still experimental though, the code might change a lot before it's released.

All source code from this post can be found on Github.

Discussion (0)