Binden von Argumenten an Handler in System.CommandLine

Wichtig

System.CommandLine befindet sich derzeit in der VORSCHAU, und diese Dokumentation gilt für Version 2.0 Beta 4. Einige Informationen beziehen sich auf Vorabversionen des Produkts, die vor dem Release ggf. grundlegend überarbeitet werden. Microsoft übernimmt hinsichtlich der hier bereitgestellten Informationen keine Gewährleistungen, seien sie ausdrücklich oder konkludent.

Der Prozess der Analyse von Argumenten und die Bereitstellung des Befehlshandlercodes wird Parameterbindung genannt. System.CommandLine verfügt über die integrierte Möglichkeit, viele Argumenttypen zu binden. Beispielsweise können ganze Zahlen, Enumerationen und Dateisystemobjekte wie FileInfo und DirectoryInfo gebunden werden. Es können auch verschiedene System.CommandLine-Typen gebunden werden.

Integrierte Argumentüberprüfung

Argumente verfügen über erwartete Typen und Arität. System.CommandLine lehnt Argumente ab, die diesen Erwartungen nicht entsprechen.

Beispielsweise wird ein Analysefehler angezeigt, wenn das Argument für eine ganzzahlige Option (Integer) keine ganze Zahl ist.

myapp --delay not-an-int
Cannot parse argument 'not-an-int' as System.Int32.

Ein Aritätsfehler wird angezeigt, wenn mehrere Argumente an eine Option übergeben werden, deren Arität maximal eins beträgt:

myapp --delay-option 1 --delay-option 2
Option '--delay' expects a single argument but 2 were provided.

Dieses Verhalten kann durch Festlegen von Option.AllowMultipleArgumentsPerToken auf true außer Kraft gesetzt werden. In diesem Fall können Sie eine Option wiederholen, die maximal die Arität eins hat, aber nur der letzte Wert in der Zeile wird akzeptiert. Im folgenden Beispiel wird der Wert three an die App übergeben.

myapp --item one --item two --item three

Parameterbindung mit bis zu 8 Optionen und Argumenten

Das folgende Beispiel zeigt, wie Sie Optionen an Befehlshandlerparameter binden, indem Sie SetHandler aufrufen:

var delayOption = new Option<int>
    ("--delay", "An option whose argument is parsed as an int.");
var messageOption = new Option<string>
    ("--message", "An option whose argument is parsed as a string.");

var rootCommand = new RootCommand("Parameter binding example");
rootCommand.Add(delayOption);
rootCommand.Add(messageOption);

rootCommand.SetHandler(
    (delayOptionValue, messageOptionValue) =>
    {
        DisplayIntAndString(delayOptionValue, messageOptionValue);
    },
    delayOption, messageOption);

await rootCommand.InvokeAsync(args);
public static void DisplayIntAndString(int delayOptionValue, string messageOptionValue)
{
    Console.WriteLine($"--delay = {delayOptionValue}");
    Console.WriteLine($"--message = {messageOptionValue}");
}

Die Lambdaparameter sind Variablen, die die Werte von Optionen und Argumenten darstellen:

(delayOptionValue, messageOptionValue) =>
{
    DisplayIntAndString(delayOptionValue, messageOptionValue);
},

Die Variablen, die dem Lambdaparameter folgen, stellen die Options- und Argumentobjekte dar, die die Quellen der Options- und Argumentwerte sind:

delayOption, messageOption);

Die Optionen und Argumente müssen in der gleichen Reihenfolge im Lambdaparameter und in den Parametern, die dem Lambdaparameter folgen, deklariert werden. Wenn die Reihenfolge nicht konsistent ist, wird eines der folgenden Szenarien eintreten:

  • Wenn die nicht der Reihenfolge entsprechenden Optionen oder Argumente von unterschiedlichem Typ sind, wird eine Laufzeitausnahme ausgelöst. Beispielsweise könnte ein int angezeigt werden, wo ein string in der Liste der Quellen erwartet wird.
  • Wenn die nicht der Reihenfolge entsprechenden Optionen oder Argumente vom gleichen Typ sind, erhält der Handler stillschweigend die falschen Werte in den ihm übergebenen Parametern. Beispielsweise könnte string Option x angezeigt werden, wo string Option y in der Liste der Quellen stehen sollte. In diesem Fall erhält die Variable für den Wert der Option y den Wert der Option x.

Es gibt Überladungen von SetHandler, die bis zu 8 Parameter unterstützen, sowohl mit synchronen als auch mit asynchronen Signaturen.

Parameterbindung mit mehr als 8 Optionen und Argumenten

Um mehr als 8 Optionen zu verarbeiten oder um einen benutzerdefinierten Typ aus mehreren Optionen zu erstellen, können Sie InvocationContext oder einen benutzerdefinierten Binder verwenden.

Verwenden Sie InvocationContext

Eine SetHandler-Überladung ermöglicht den Zugriff auf das InvocationContext-Objekt, und Sie können InvocationContext verwenden, um eine beliebige Anzahl von Options- und Argumentwerten zu erhalten. Beispiele finden Sie unter Festlegen von Exitcodes und Behandeln der Beendigung.

Verwenden eines benutzerdefinierten Binders

Mit einem benutzerdefinierten Binder können Sie mehrere Options- oder Argumentwerte zu einem komplexen Typ kombinieren und diesen an einen einzelnen Handlerparameter übergeben. Angenommen, Sie verfügen über einen Person-Typ:

public class Person
{
    public string? FirstName { get; set; }
    public string? LastName { get; set; }
}

Erstellen Sie eine Klasse, die von BinderBase<T> abgeleitet ist, wobei T der Typ ist, der auf der Grundlage der Eingabe in der Befehlszeile konstruiert werden soll:

public class PersonBinder : BinderBase<Person>
{
    private readonly Option<string> _firstNameOption;
    private readonly Option<string> _lastNameOption;

    public PersonBinder(Option<string> firstNameOption, Option<string> lastNameOption)
    {
        _firstNameOption = firstNameOption;
        _lastNameOption = lastNameOption;
    }

    protected override Person GetBoundValue(BindingContext bindingContext) =>
        new Person
        {
            FirstName = bindingContext.ParseResult.GetValueForOption(_firstNameOption),
            LastName = bindingContext.ParseResult.GetValueForOption(_lastNameOption)
        };
}

Mit dem benutzerdefinierten Binder können Sie Ihren benutzerdefinierten Typ auf die gleiche Weise an Ihren Handler übergeben, wie Sie Werte für Optionen und Argumente abrufen:

rootCommand.SetHandler((fileOptionValue, person) =>
    {
        DoRootCommand(fileOptionValue, person);
    },
    fileOption, new PersonBinder(firstNameOption, lastNameOption));

Hier ist das vollständige Programm, dem die vorangegangenen Beispiele entnommen sind:

using System.CommandLine;
using System.CommandLine.Binding;

public class Program
{
    internal static async Task Main(string[] args)
    {
        var fileOption = new Option<FileInfo?>(
              name: "--file",
              description: "An option whose argument is parsed as a FileInfo",
              getDefaultValue: () => new FileInfo("scl.runtimeconfig.json"));

        var firstNameOption = new Option<string>(
              name: "--first-name",
              description: "Person.FirstName");

        var lastNameOption = new Option<string>(
              name: "--last-name",
              description: "Person.LastName");

        var rootCommand = new RootCommand();
        rootCommand.Add(fileOption);
        rootCommand.Add(firstNameOption);
        rootCommand.Add(lastNameOption);

        rootCommand.SetHandler((fileOptionValue, person) =>
            {
                DoRootCommand(fileOptionValue, person);
            },
            fileOption, new PersonBinder(firstNameOption, lastNameOption));

        await rootCommand.InvokeAsync(args);
    }

    public static void DoRootCommand(FileInfo? aFile, Person aPerson)
    {
        Console.WriteLine($"File = {aFile?.FullName}");
        Console.WriteLine($"Person = {aPerson?.FirstName} {aPerson?.LastName}");
    }

    public class Person
    {
        public string? FirstName { get; set; }
        public string? LastName { get; set; }
    }

    public class PersonBinder : BinderBase<Person>
    {
        private readonly Option<string> _firstNameOption;
        private readonly Option<string> _lastNameOption;

        public PersonBinder(Option<string> firstNameOption, Option<string> lastNameOption)
        {
            _firstNameOption = firstNameOption;
            _lastNameOption = lastNameOption;
        }

        protected override Person GetBoundValue(BindingContext bindingContext) =>
            new Person
            {
                FirstName = bindingContext.ParseResult.GetValueForOption(_firstNameOption),
                LastName = bindingContext.ParseResult.GetValueForOption(_lastNameOption)
            };
    }
}

Festlegen von Exitcodes

Es gibt Func-Überladungen von SetHandler mit Rückgabe von Task. Wenn Ihr Handler von asynchronem Code aufgerufen wird, können Sie einen Task<int> von einem Handler zurückgeben, der einen dieser Werte verwendet, und den int-Wert verwenden, um den Exitcode des Prozesses festzulegen, wie im folgenden Beispiel:

static async Task<int> Main(string[] args)
{
    var delayOption = new Option<int>("--delay");
    var messageOption = new Option<string>("--message");

    var rootCommand = new RootCommand("Parameter binding example");
    rootCommand.Add(delayOption);
    rootCommand.Add(messageOption);

    rootCommand.SetHandler((delayOptionValue, messageOptionValue) =>
        {
            Console.WriteLine($"--delay = {delayOptionValue}");
            Console.WriteLine($"--message = {messageOptionValue}");
            return Task.FromResult(100);
        },
        delayOption, messageOption);

    return await rootCommand.InvokeAsync(args);
}

Wenn der Lambdaparameter selbst jedoch asynchron sein muss, können Sie keinen Task<int> zurückgeben. Verwenden Sie in diesem Fall InvocationContext.ExitCode. Sie können die InvocationContext-Instanz in Ihren Lambdaparameter einschleusen, indem Sie eine SetHandler-Überladung verwenden, die InvocationContext als einzigen Parameter angibt. Mit dieser SetHandler-Überladung können Sie keine IValueDescriptor<T>-Objekte angeben, aber Sie können die Werte von Optionen und Argumenten aus der ParseResult-Eigenschaft von InvocationContext abrufen, wie im folgenden Beispiel gezeigt:

static async Task<int> Main(string[] args)
{
    var delayOption = new Option<int>("--delay");
    var messageOption = new Option<string>("--message");

    var rootCommand = new RootCommand("Parameter binding example");
    rootCommand.Add(delayOption);
    rootCommand.Add(messageOption);

    rootCommand.SetHandler(async (context) =>
        {
            int delayOptionValue = context.ParseResult.GetValueForOption(delayOption);
            string? messageOptionValue = context.ParseResult.GetValueForOption(messageOption);
        
            Console.WriteLine($"--delay = {delayOptionValue}");
            await Task.Delay(delayOptionValue);
            Console.WriteLine($"--message = {messageOptionValue}");
            context.ExitCode = 100;
        });

    return await rootCommand.InvokeAsync(args);
}

Wenn Sie keine asynchrone Arbeit zu erledigen haben, können Sie die Action-Überladungen verwenden. In diesem Fall legen Sie den Exitcode fest, indem Sie InvocationContext.ExitCode verwenden, so wie Sie bei einem asynchronen Lambdaparameter vorgehen würden.

Der Exitcode ist standardmäßig auf 1 festgelegt. Wenn Sie ihn nicht explizit festlegen, wird sein Wert auf 0 festgelegt, wenn Ihr Handler normal beendet wird. Wenn eine Ausnahme ausgelöst wird, wird der Standardwert beibehalten.

Unterstützte Typen

Die folgenden Beispiele zeigen Code, der einige häufig verwendete Typen bindet.

Enumerationen

Die Werte der enum-Typen werden über den Namen gebunden und die Groß- und Kleinschreibung wird nicht beachtet:

var colorOption = new Option<ConsoleColor>("--color");

var rootCommand = new RootCommand("Enum binding example");
rootCommand.Add(colorOption);

rootCommand.SetHandler((colorOptionValue) =>
    { Console.WriteLine(colorOptionValue); },
    colorOption);

await rootCommand.InvokeAsync(args);

Hier sehen Sie ein Beispiel für die Befehlszeileneingabe und die daraus resultierende Ausgabe des vorangegangenen Beispiels:

myapp --color red
myapp --color RED
Red
Red

Arrays und Listen

Viele gängige Typen, die IEnumerable implementieren, werden unterstützt. Zum Beispiel:

var itemsOption = new Option<IEnumerable<string>>("--items")
    { AllowMultipleArgumentsPerToken = true };

var command = new RootCommand("IEnumerable binding example");
command.Add(itemsOption);

command.SetHandler((items) =>
    {
        Console.WriteLine(items.GetType());

        foreach (string item in items)
        {
            Console.WriteLine(item);
        }
    },
    itemsOption);

await command.InvokeAsync(args);

Hier sehen Sie ein Beispiel für die Befehlszeileneingabe und die daraus resultierende Ausgabe des vorangegangenen Beispiels:

--items one --items two --items three
System.Collections.Generic.List`1[System.String]
one
two
three

Da AllowMultipleArgumentsPerToken auf true festgelegt ist, ergibt die folgende Eingabe die gleiche Ausgabe:

--items one two three

Dateisystemtypen

Befehlszeilenanwendungen, die mit dem Dateisystem arbeiten, können die Typen FileSystemInfo, FileInfo und DirectoryInfo verwenden. Im folgenden Beispiel wird eine Verwendung von FileSystemInfo gezeigt.

var fileOrDirectoryOption = new Option<FileSystemInfo>("--file-or-directory");

var command = new RootCommand();
command.Add(fileOrDirectoryOption);

command.SetHandler((fileSystemInfo) =>
    {
        switch (fileSystemInfo)
        {
            case FileInfo file                    :
                Console.WriteLine($"File name: {file.FullName}");
                break;
            case DirectoryInfo directory:
                Console.WriteLine($"Directory name: {directory.FullName}");
                break;
            default:
                Console.WriteLine("Not a valid file or directory name.");
                break;
        }
    },
    fileOrDirectoryOption);

await command.InvokeAsync(args);

Bei FileInfo und DirectoryInfo ist der Musterabgleichscode nicht erforderlich:

var fileOption = new Option<FileInfo>("--file");

var command = new RootCommand();
command.Add(fileOption);

command.SetHandler((file) =>
    {
        if (file is not null)
        {
            Console.WriteLine($"File name: {file?.FullName}");
        }
        else
        {
            Console.WriteLine("Not a valid file name.");
        }
    },
    fileOption);

await command.InvokeAsync(args);

Andere unterstützte Typen

Viele Typen, die einen Konstruktor aufweisen, der einen einzelnen Zeichenfolgeparameter benötigt, können auf diese Weise gebunden werden. Beispielsweise funktioniert ein Code, der mit FileInfo funktionieren würde, stattdessen mit einem Uri.

var endpointOption = new Option<Uri>("--endpoint");

var command = new RootCommand();
command.Add(endpointOption);

command.SetHandler((uri) =>
    {
        Console.WriteLine($"URL: {uri?.ToString()}");
    },
    endpointOption);

await command.InvokeAsync(args);

Neben den Dateisystemtypen und Uri werden auch die folgenden Typen unterstützt:

  • bool
  • byte
  • DateTime
  • DateTimeOffset
  • decimal
  • double
  • float
  • Guid
  • int
  • long
  • sbyte
  • short
  • uint
  • ulong
  • ushort

Verwenden von System.CommandLine-Objekten

Es gibt eine SetHandler-Überladung, mit der Sie Zugriff auf das InvocationContext-Objekt erhalten. Dieses Objekt kann dann für den Zugriff auf andere System.CommandLine-Objekte verwendet werden. Sie haben z. B. Zugriff auf die folgenden Objekte:

InvocationContext

Beispiele finden Sie unter Festlegen von Exitcodes und Behandeln der Beendigung.

CancellationToken

Informationen zur Verwendung von CancellationTokenfinden Sie unter Behandeln der Beendigung.

IConsole

Die Verwendung von IConsole gestaltet das Testen sowie viele Erweiterungsszenarien einfacher als die Verwendung von System.Console. Sie ist in der InvocationContext.Console-Eigenschaft verfügbar.

ParseResult

Das ParseResult-Objekt ist in der InvocationContext.ParseResult-Eigenschaft verfügbar. Es handelt sich um eine Singletonstruktur, die die Ergebnisse der Analyse der Befehlszeileneingabe darstellt. Sie können sie verwenden, um das Vorhandensein von Optionen oder Argumenten in der Befehlszeile zu überprüfen oder um die Eigenschaft ParseResult.UnmatchedTokens abzurufen. Diese Eigenschaft enthält eine Liste der Token, die analysiert wurden, aber keinem konfigurierten Befehl, keiner Option oder keinem Argument entsprachen.

Die Liste der nicht übereinstimmenden Token ist bei Befehlen nützlich, die sich wie Wrapper verhalten. Ein Wrapperbefehl nimmt einen Satz von Token und leitet sie an einen anderen Befehl oder eine App weiter. Der sudo-Befehl in Linux ist ein Beispiel dafür. Er benötigt den Namen eines Benutzers, zu dessen Identität er wechseln soll, gefolgt von einem auszuführenden Befehl. Zum Beispiel:

sudo -u admin apt update

Diese Befehlszeile würde den Befehl apt update als Benutzer admin ausführen.

Um einen Wrapperbefehl wie diesen zu implementieren, legen Sie die Befehlseigenschaft TreatUnmatchedTokensAsErrors auf false fest. Dann enthält die ParseResult.UnmatchedTokens-Eigenschaft alle Argumente, die nicht explizit zum Befehl gehören. Im vorangegangenen Beispiel würde ParseResult.UnmatchedTokens die Token apt und update enthalten. Ihr Befehlshandler könnte dann z. B. das UnmatchedTokens an einen neuen Shellaufruf weiterleiten.

Benutzerdefinierte Validierung und Bindung

Um einen benutzerdefinierten Validierungscode bereitzustellen, rufen Sie AddValidator für Ihren Befehl, Ihre Option oder Ihr Argument auf, wie im folgenden Beispiel gezeigt:

var delayOption = new Option<int>("--delay");
delayOption.AddValidator(result =>
{
    if (result.GetValueForOption(delayOption) < 1)
    {
        result.ErrorMessage = "Must be greater than 0";
    }
});

Wenn Sie die Eingabe nicht nur analysieren, sondern auch überprüfen möchten, verwenden Sie einen ParseArgument<T>-Delegaten, wie im folgenden Beispiel gezeigt:

var delayOption = new Option<int>(
      name: "--delay",
      description: "An option whose argument is parsed as an int.",
      isDefault: true,
      parseArgument: result =>
      {
          if (!result.Tokens.Any())
          {
              return 42;
          }

          if (int.TryParse(result.Tokens.Single().Value, out var delay))
          {
              if (delay < 1)
              {
                  result.ErrorMessage = "Must be greater than 0";
              }
              return delay;
          }
          else
          {
              result.ErrorMessage = "Not an int.";
              return 0; // Ignored.
          }
      });

Der vorangehende Code legt isDefault auf true fest, sodass der Delegat parseArgument auch dann aufgerufen wird, wenn der Benutzer keinen Wert für diese Option eingegeben hat.

Hier sind einige Beispiele dafür, wie Sie mit ParseArgument<T> vorgehen können, was mit AddValidator nicht möglich ist:

  • Analyse von benutzerdefinierten Typen, z. B. der Klasse Person, im folgenden Beispiel:

    public class Person
    {
        public string? FirstName { get; set; }
        public string? LastName { get; set; }
    }
    
    var personOption = new Option<Person?>(
          name: "--person",
          description: "An option whose argument is parsed as a Person",
          parseArgument: result =>
          {
              if (result.Tokens.Count != 2)
              {
                  result.ErrorMessage = "--person requires two arguments";
                  return null;
              }
              return new Person
              {
                  FirstName = result.Tokens.First().Value,
                  LastName = result.Tokens.Last().Value
              };
          })
    {
        Arity = ArgumentArity.OneOrMore,
        AllowMultipleArgumentsPerToken = true
    };
    
  • Analysieren anderer Arten von Eingabezeichenfolgen (z. B. analysieren Sie „1,2,3“ in int[]).

  • Dynamische Arität. Sie haben beispielsweise zwei Argumente, die als Zeichenfolgenarrays definiert sind, und Sie müssen eine Sequenz von Zeichenfolgen in der Befehlszeileneingabe behandeln. Mit der Methode ArgumentResult.OnlyTake können Sie die eingegebenen Zeichenfolgen dynamisch auf die Argumente aufteilen.

Weitere Informationen

System.CommandLine – Übersicht