/ConsoleAppFramework

Micro-framework for console applications to building CLI tools/Daemon/Batch for .NET Core, C#.

Primary LanguageC#MIT LicenseMIT

ConsoleAppFramework

GitHub Actions Releases

ConsoleAppFramework is an infrastructure of creating CLI(Command-line interface) tools, daemon, and multi batch application.

image

ConsoleAppFramework is built on .NET Generic Host, you can use configuration, logging, DI, lifetime management by Microsoft.Extensions packages. ConsoleAppFramework do parameter binding from string args, routing multi command, dotnet style help builder, etc.

This concept is similar as Laravel Zero of PHP, share same configuration with server framework. dotnet's standard CommandLine api - dotnet/command-line-api is low level, require many boilerplate codes. ConsoleAppFramework is like ASP.NET Core MVC in CLI Applications, no needs boilerplate.

NuGet: ConsoleAppFramework

Install-Package ConsoleAppFramework

Table of Contents

CLI Tools

CLI Tools(Console Application) can write by simple method, argument is automatically binded to parameter.

using ConsoleAppFramework;
using Microsoft.Extensions.Hosting;
using System;
using System.Threading.Tasks;

// Entrypoint, create from the .NET Core Console App.
class Program : ConsoleAppBase // inherit ConsoleAppBase
{
    static async Task Main(string[] args)
    {
        // target T as ConsoleAppBase.
        await Host.CreateDefaultBuilder().RunConsoleAppFrameworkAsync<Program>(args);
    }

    // allows void/Task return type, parameter is automatically binded from string[] args.
    public void Run(string name, int repeat = 3)
    {
        for (int i = 0; i < repeat; i++)
        {
            Console.WriteLine($"Hello My ConsoleApp from {name}");
        }
    }
}

You can execute command like SampleApp.exe -name "foo" -repeat 5.

The Option parser is no longer needed. You can also use the OptionAttribute to describe the parameter.

public void Hello(
    [Option("n", "name of send user.")]string name, 
    [Option("r", "repeat count.")]int repeat = 3)
{

Method parameter will be required parameter, optional parameter will be oprional parameter. Also support boolean flag, if parameter is bool, in default it will be optional parameter and with -foo set true to parameter.

help command (or no argument to pass) shows there detail. This help format is same as dotnet command.

> SampleApp.exe help
Usage: SampleApp [options...]

Options:
  -n, -name <String>     name of send user. (Required)
  -r, -repeat <Int32>    repeat count. (Default: 3)

Commands:
  help          Display help.
  version       Display version.

version option shows AssemblyInformationalVersion or AssemblylVersion.

> SampleApp.exe version
1.0.0

You can use CommandAttribute to create multi command program.

class Program : ConsoleAppBase
{
    static async Task Main(string[] args)
    {
        await Host.CreateDefaultBuilder().RunConsoleAppFrameworkAsync<Program>(args);
    }

    // default(no command)
    public void Hello(
        [Option("n", "name of send user.")]string name,
        [Option("r", "repeat count.")]int repeat = 3)
    {
        for (int i = 0; i < repeat; i++)
        {
            Console.WriteLine($"Hello My ConsoleApp from {name}");
        }
    }

    // [Option(int)] describes that parameter is passed by index
    [Command("escape")]
    public void UrlEscape([Option(0)]string input)
    {
        Console.WriteLine(Uri.EscapeDataString(input));
    }

    // define async method returns Task
    [Command("timer")]
    public async Task Timer([Option(0)]uint waitSeconds)
    {
        Console.WriteLine(waitSeconds + " seconds");
        while (waitSeconds != 0)
        {
            // ConsoleAppFramework does not stop immediately on terminate command(Ctrl+C)
            // so you have to pass Context.CancellationToken to async method.
            await Task.Delay(TimeSpan.FromSeconds(1), Context.CancellationToken);
            waitSeconds--;
            Console.WriteLine(waitSeconds + " seconds");
        }
    }
}

You can call like

SampleApp.exe -n "foo" -r 3
SampleApp.exe escape http://foo.bar/
SampleApp.exe timer 10

Automatically Class/Method command routing

ConsoleAppFramework can create easily to many command application. You can write many class, methods and select by class method command like MVC application. It is useful to manage application specified batch programs. Uploading single binary and execute it, or git pull and run by dotnet run -- [command] [option] on CI.

using ConsoleAppFramework;
using Microsoft.Extensions.Hosting;
using System;
using System.Threading.Tasks;

// Entrypoint.
class Program
{
    static async Task Main(string[] args)
    {
        // don't pass <T>.
        await Host.CreateDefaultBuilder().RunConsoleAppFrameworkAsync(args);
    }
}

// Batches.
public class Foo : ConsoleAppBase
{
    public void Echo(string msg)
    {
        Console.WriteLine(msg);
    }

    public void Sum(int x, int y)
    {
        Console.WriteLine((x + y).ToString());
    }
}

public class Bar : ConsoleAppBase
{
    public void Hello2()
    {
        Console.WriteLine("H E L L O");
    }
}

You can call {TypeName} {MethodName} like

SampleApp.exe foo echo -msg "aaaaa"
SampleApp.exe foo sum -x 100 -y 200
SampleApp.exe bar hello2

help describe the method list

> SampleApp.exe help
Usage: SampleApp <Command>

Commands:
  foo echo
  foo sum
  bar hello2

[command] help shows command details.

> SampleApp.exe Foo.Echo -help
Usage: SampleApp Foo.Echo [options...]

Options:
  -msg <String>     (Required)

Commands are searched from loaded assemblies, when does not touch other assemblies type, it will be trimmed and can not load it. In that case, use RunConsoleAppFrameworkAsync(searchAssemblies: ) option to pass target assembly, for example searchAssemblies: new [] { typeof(Foo).Assembly }.

Complex Argument

If the argument is not primitive, you can pass JSON string.

public class ComplexArgTest : ConsoleAppBase
{
    public void Foo(int[] array, Person person)
    {
        Console.WriteLine(string.Join(", ", array));
        Console.WriteLine(person.Age + ":" + person.Name);
    }
}

public class Person
{
    public int Age { get; set; }
    public string Name { get; set; }
}

You can call like here.

> SampleApp.exe -array [10,20,30] -person {"Age":10,"Name":"foo"}

# including space, use escaping
> SampleApp.exe -array [10,20,30] -person "{\"Age\":10,\"Name\":\"foo bar\"}"

be careful with JSON string double quotation.

For the array handling, it can be a treat without correct JSON. e.g. one-length argument can handle without [].

Foo(int[] array)
> SampleApp.exe -array 9999

multiple-argument can handle by split with or ,.

Foo(int[] array)
> SampleApp.exe -array "11 22 33"
> SampleApp.exe -array "11,22,33"
> SampleApp.exe -array "[11,22,33]"

string argument can handle without ".

Foo(string[] array)
> SampleApp.exe -array hello
> SampleApp.exe -array "foo bar baz"
> SampleApp.exe -array foo,bar,baz
> SampleApp.exe -array "["foo","bar","baz"]"

Exit Code

If the method returns int or Task<int> or `ValueTask value, ConsoleAppFramework will set the return value to the exit code.

public class ExampleApp : ConsoleAppBase
{
    [Command("exit")]
    public int ExitCode()
    {
        return 12345;
    }
    
    [Command("exitwithtask")]
    public async Task<int> ExitCodeWithTask()
    {
        return 54321;
    }
}

NOTE: If the method throws an unhandled exception, ConsoleAppFramework always set 1 to the exit code.

CommandAttribute

CommandAttribute enables subscommand on RunConsoleAppFramework<T>()(for single type CLI app), changes command name on RunConsoleAppFramework()(for muilti type command routing), also describes the description.

RunConsoleAppFramework<App>();

public class App : ConsoleAppBase
{
    // as Root Command(no command argument)
    public void Run()
    {
    }

    [Command("sec", "sub comman of this app")]
    public void Second()
    {
    }
}
RunConsoleAppFramework();

public class App2 : ConsoleAppBase
{
    // routing command: `app2 exec`
    [Command("exec", "exec app.")]
    public void Exec1()
    {
    }
}

// command attribute also can use to class.
[Command("mycmd")]
public class App3 : ConsoleAppBase
{
     // routing command: `mycmd e2`
    [Command("e2", "exec app 2.")]
    public void ExecExec()
    {
    }
}

OptionAttribute

OptionAttribute configure parameter, it can set shortName or order index, and help description.

If you want to add only description, set "" or null to shortName parameter.

public void Hello(
    [Option("n", "name of send user.")]string name,
    [Option("r", "repeat count.")]int repeat = 3)
{
}

[Command("escape")]
public void UrlEscape([Option(0, "input of this command")]string input)
{
}

[Command("unescape")]
public void UrlUnescape([Option(null, "input of this command")]string input)
{
}

Daemon

ConsoleAppBase.Context.CancellationToken is lifecycle token of application. In default, ConsoleAppFramework does not abort on received terminate request, you can check CancellationToken.IsCancellationRequested and shutdown gracefully. If use infinite-loop, it becomes daemon program.

public class Daemon : ConsoleAppBase
{
    public async Task Run()
    {
        // you can write infinite-loop while stop request(Ctrl+C or docker terminate).
        try
        {
            while (!this.Context.CancellationToken.IsCancellationRequested)
            {
                try
                {
                    Console.WriteLine("Wait One Minutes");
                }
                catch (Exception ex)
                {
                    // error occured but continue to run(or terminate).
                    Console.WriteLine(ex, "Found error");
                }

                // wait for next time
                await Task.Delay(TimeSpan.FromMinutes(1), this.Context.CancellationToken);
            }
        }
        catch (Exception ex) when (!(ex is OperationCanceledException))
        {
            // you can write finally exception handling(without cancellation)
        }
        finally
        {
            // you can write cleanup code here.
        }
    }
}

Filter

Filter can hook before/after batch running event. You can implement ConsoleAppFilter for it and attach to global/class/method.

public class MyFilter : ConsoleAppFilter
{
    // Filter is instantiated by DI so you can get parameter by constructor injection.

    public async override ValueTask Invoke(ConsoleAppContext context, Func<ConsoleAppContext, ValueTask> next)
    {
        try
        {
            /* on before */
            await next(context); // next
        }
        catch
        {
            /* on after */
            throw;
        }
        finally
        {
            /* on finally */
        }
    }
}

ConsoleAppContext.Timestamp has start time so if subtraction from now, get elapsed time.

public class LogRunningTimeFilter : ConsoleAppFilter
{
    public override async ValueTask Invoke(ConsoleAppContext context, Func<ConsoleAppContext, ValueTask> next)
    {
        context.Logger.LogInformation("Call method at " + context.Timestamp.ToLocalTime()); // LocalTime for human readable time
        try
        {
            await next(context);
            context.Logger.LogInformation("Call method Completed successfully, Elapsed:" + (DateTimeOffset.UtcNow - context.Timestamp));
        }
        catch
        {
            context.Logger.LogInformation("Call method Completed Failed, Elapsed:" + (DateTimeOffset.UtcNow - context.Timestamp));
            throw;
        }
    }
}

In default, ConsoleAppFramework does not prevent double startup but if create filter, can do.

public class MutexFilter : ConsoleAppFilter
{
    public override async ValueTask Invoke(ConsoleAppContext context, Func<ConsoleAppContext, ValueTask> next)
    {
        var name = context.MethodInfo.DeclaringType.Name + "." + context.MethodInfo.Name;
        using (var mutex = new Mutex(true, name, out var createdNew))
        {
            if (!createdNew)
            {
                throw new Exception($"already running {name} in another process.");
            }
            
            await next(context);
        }
    }
}

There filters can pass to ConsoleAppOptions.GlobalFilters on startup or attach by attribute on class, method.

class Program
{
    static async Task Main(string[] args)
    {
        await Host.CreateDefaultBuilder()
            .RunConsoleAppFrameworkAsync(args, new ConsoleAppOptions()
            {
                GlobalFilters = new ConsoleAppFilter[]{ 
                    new MutextFilter() { Order = -9999 } ,
                    new LogRunningTimeFilter() { Oder = -9998 }, 
            });
    }
}

[ConsoleAppFilter(typeof(MyFilter3))]
public class MyBatch : ConsoleAppBase
{
    [ConsoleAppFilter(typeof(MyFilter4), Order = -9999)]
    [ConsoleAppFilter(typeof(MyFilter5), Order = 9999)]
    public void Do()
    {
    }
}

Execution order can control by int Order property.

Logging

In default, Context.Logger has ILogger<ConsoleAppEngine> and ILogger<T> can inject to constructor. Default ConsoleLogger format in Host.CreateDefaultBuilder is supernumerary and not suitable for console application. ConsoleAppFramework provides SimpleConsoleLogger to replace default ConsoleLogger.

static async Task Main(string[] args)
{
    await Host.CreateDefaultBuilder()
        .ConfigureLogging(logging =>
        {
            // Replacing default console logger to SimpleConsoleLogger.
            logging.ReplaceToSimpleConsole();

            // Add ConsoleAppFramework.Logging.SimpleConsoleLogger.
            // logging.AddSimpleConsole();

            // Configure MinimumLogLevel(CreaterDefaultBuilder's default is Warning).
            logging.SetMinimumLevel(LogLevel.Trace);
        })
        .RunConsoleAppFrameworkAsync<Program>(args);
}

If you want to use high performance logger/output to file, also use Cysharp/ZLogger that easy to integrate ConsoleAppFramework.

await Host.CreateDefaultBuilder()
    .ConfigureLogging(x =>
    {
        x.ClearProviders();
        x.SetMinimumLevel(LogLevel.Trace);
        x.AddZLoggerConsole();
        x.AddZLoggerFile("fileName.log");
    })
    .RunConsoleAppFrameworkAsync(args);

Configuration

ConsoleAppFramework is just an infrastructure. You can add appsettings.json or other configs as .NET Core offers via Microsoft.Extensions.Options. You can add appsettings.json and appsettings.{environment}.json and typesafe load via map config to Class w/IOption.

Here's single contained batch with Config loading sample.

// appconfig.json(Content, Copy to Output Directory)
{
  "Foo": 42,
  "Bar": true
}
using Microsoft.Extensions.DependencyInjection;

class Program
{
    static async Task Main(string[] args)
    {
        await Host.CreateDefaultBuilder()
            .ConfigureServices((hostContext, services) =>
            {
                // mapping config json to IOption<MyConfig>
                // requires "Microsoft.Extensions.Options.ConfigurationExtensions" package
                // if you want to map subscetion in json, use Configure<T>(hostContext.Configuration.GetSection("foo"))
                services.Configure<MyConfig>(hostContext.Configuration);
            })
            .RunConsoleAppFrameworkAsync<ConfigAppSample>(args);
    }
}

public class ConfigAppSample : ConsoleAppBase
{
    MyConfig config;

    // get configuration from DI.
    public ConfigAppSample(IOptions<MyConfig> config)
    {
        this.config = config.Value;
    }

    public void ShowOption()
    {
        Console.WriteLine(config.Bar);
        Console.WriteLine(config.Foo);
    }
}

for the details, please see .NET Core Generic Host documentation.

DI

You can use DI(constructor injection) by GenericHost.

IOptions<MyConfig> config;
ILogger<MyApp> logger;

public MyApp(IOptions<MyConfig> config, ILogger<MyApp> logger)
{
    this.config = config;
    this.logger = logger;
}

DI also inject to filter.

Cleanup

You can implement IDisposable.Dispose explicitly, that is called after command finished.

public class MyApp : ConsoleAppBase, IDisposable
{
    public void Hello()
    {
        Console.WriteLine("Hello");
    }

    void IDisposable.Dispose() // NOTE: can not implement `public void Dispose()`
    {
        Console.WriteLine("DISPOSED");
    }
}

also supports IAsyncDisposable.Dispose, however if implements both IDisposable and IAsyncDisposable, called only IAsyncDisposable(this is the limitation of default ServiceProviderEngineScope).

public class MyApp : ConsoleAppBase, IDisposable, IAsyncDisposable
{
    public void Hello()
    {
        Console.WriteLine("Hello");
    }

    void IDisposable.Dispose()
    {
        Console.WriteLine("Not called.");
    }
        
    async ValueTask IAsyncDisposable.DisposeAsync()
    {
        Console.WriteLine("called.");
    }
}

ConsoleAppContext

ConsoleAppContext is injected to property on method executing.

public class ConsoleAppContext
{
    public string?[] Arguments { get; }
    public DateTime Timestamp { get; }
    public CancellationToken CancellationToken { get; }
    public ILogger<ConsoleAppEngine> Logger { get; }
    public MethodInfo MethodInfo { get; }
    public IServiceProvider ServiceProvider { get; }
    public IDictionary<string, object> Items { get; }
}

ConsoleAppOptions

You can configure framework behaviour by ConsoleAppOptions.

static async Task Main(string[] args)
{
    await Host.CreateDefaultBuilder().RunConsoleAppFrameworkAsync<Program>(args, new ConsoleAppOptions
    {
        StrictOption = true, // default is false.
        ShowDefaultCommand = false, // default is true
    });
}
public class ConsoleAppOptions
{
    /// <summary>Argument parser uses strict(-short, --long) option. Default is false.</summary>
    public bool StrictOption { get; set; } = false;

    /// <summary>Show default command(help/version) to help. Default is true.</summary>
    public bool ShowDefaultCommand { get; set; } = true;

    public JsonSerializerOptions? JsonSerializerOptions { get; set; }

    public ConsoleAppFilter[]? GlobalFilters { get; set; }
}

By default, The ConsoleAppFramework does not distinguish between the number of -. For example, this method

public void Hello([Option("m", "Message to display.")]string message)

can pass argument by -m, --message and -message. This is styled like a go lang command. But if you want to strictly distinguish argument of -, set StrcitOption = true.

> SampleApp.exe help

Usage: SampleApp [options...]

Options:
  -m, --message <String>    Message to display. (Required)

Also, by default, the help and version commands appear as help, which can be hidden by setting ShowDefaultCommand = false.

Web Interface with Swagger

ConsoleAppFramework.WebHosting is support to expose web interface and swagger(with executable api document). It is useful for debugging.

NuGet: ConsoleAppFramework.WebHosting

Install-Package ConsoleAppFramework.WebHosting
public class Program
{
    public static async Task Main(string[] args)
    {
        await Host.CreateDefaultBuilder(args)
            .RunConsoleAppFrameworkWebHostingAsync("http://localhost:12345");
    }
}

in browser http://localhost:12345, launch swagger ui.

image

Publish to executable file

dotnet publish to create executable file. .NET Core 3.0 offers Single Executable File via PublishSingleFile.

Here is the sample .github/workflows/build-release.yml of GitHub Actions.

name: Build-Release

on:
  push:
    branches:
      - "**"

jobs:
  build-dotnet:
    runs-on: ubuntu-latest
    env:
      DOTNET_CLI_TELEMETRY_OPTOUT: 1
      DOTNET_SKIP_FIRST_TIME_EXPERIENCE: 1
      NUGET_XMLDOC_MODE: skip
    steps:
      - uses: actions/checkout@v2
      - uses: actions/setup-dotnet@v1
        with:
          dotnet-version: 3.1.101

      # Build
      - run: dotnet publish -c Release --self-contained /p:PublishSingleFile=true /p:IncludeSymbolsInSingleFile=true -r win-x64 -o ./bin/win-x64
      - run: dotnet publish -c Release --self-contained /p:PublishSingleFile=true /p:IncludeSymbolsInSingleFile=true -r linux-x64 -o ./bin/linux-x64
      - run: dotnet publish -c Release --self-contained /p:PublishSingleFile=true /p:IncludeSymbolsInSingleFile=true -r osx-x64 -o ./bin/osx-x64

      # Store artifacts.
      - uses: actions/upload-artifact@v2
        with:
          name: SampleApp
          path: ./src/SampleApp/bin/Release/

CLI tool can use .NET Core Local/Global Tools. If you want to create it, check the Global Tools how to create or Local Tools introduction.

License

This library is under the MIT License.