Compartir vía


System.CommandLine Guía de migración de 2.0.0-beta5+

El objetivo principal de la versión 2.0.0-beta5 era mejorar las API y dar un paso para publicar una versión estable de System.CommandLine. Las API se han simplificado y hecho más coherentes y coherentes con las directrices de diseño del marco. En este artículo se describen los cambios importantes realizados en la versión 2.0.0-beta5 y 2.0.0-beta7, y el razonamiento subyacente.

Cambiar nombre

En 2.0.0-beta4, no todos los tipos y miembros siguieron las reglas de nomenclatura. Algunos no eran coherentes con las convenciones de nomenclatura, como usar el Is prefijo para las propiedades booleanas. En 2.0.0-beta5, se ha cambiado el nombre de algunos tipos y miembros. En la tabla siguiente se muestran los nombres antiguos y nuevos:

Nombre antiguo Nuevo nombre
System.CommandLine.Parsing.Parser CommandLineParser
System.CommandLine.Parsing.OptionResult.IsImplicit Implicit
System.CommandLine.Option.IsRequired Required
System.CommandLine.Symbol.IsHidden Hidden
System.CommandLine.Option.ArgumentHelpName HelpName
System.CommandLine.Parsing.OptionResult.Token IdentifierToken
System.CommandLine.Parsing.ParseResult.FindResultFor GetResult
System.CommandLine.Parsing.SymbolResult.ErrorMessage AddError(String)

†Para permitir que se notifiquen varios errores para que se notifique el mismo símbolo, la ErrorMessage propiedad se convirtió en un método y se cambió el nombre a AddError.

Colecciones mutables de opciones y validadores

La versión 2.0.0-beta4 tenía numerosos Add métodos que se usaban para agregar elementos a colecciones, como argumentos, opciones, subcomandos, validadores y finalizaciones. Algunas de estas colecciones se expusieron a través de propiedades como colecciones de solo lectura. Debido a eso, era imposible quitar elementos de esas colecciones.

En la versión 2.0.0-beta5, las API se cambiaron para exponer colecciones mutables en lugar de métodos y (a veces) colecciones de Add solo lectura. Esto le permite no solo agregar elementos ni enumerarlos, sino también quitarlos. En la tabla siguiente se muestran el método antiguo y los nombres de propiedad nuevos:

Nombre del método antiguo Propiedad nueva
Command.AddArgument Command.Arguments.Add
Command.AddOption Command.Options.Add
Command.AddCommand Command.Subcommands.Add
Command.AddValidator Command.Validators.Add
Option.AddValidator Option.Validators.Add
Argument.AddValidator Argument.Validators.Add
Command.AddCompletions Command.CompletionSources.Add
Option.AddCompletions Option.CompletionSources.Add
Argument.AddCompletions Argument.CompletionSources.Add
Command.AddAlias Command.Aliases.Add
Option.AddAlias Option.Aliases.Add

Los RemoveAlias métodos y HasAlias también se quitaron, ya que la Aliases propiedad es ahora una colección mutable. Puede usar el Remove método para quitar un alias de la colección. Use el Contains método para comprobar si existe un alias.

Nombres y alias

Antes de la versión 2.0.0-beta5, no había ninguna separación clara entre el nombre y los alias de un símbolo. Cuando no se proporcionó name para el constructor Option<T>, el símbolo informó su nombre como el alias más largo con los prefijos como --, - o / eliminados. Eso fue confuso.

Además, para obtener el valor analizado, tenía que almacenar una referencia a una opción o un argumento y, a continuación, usarlo para obtener el valor de ParseResult.

Para promover la simplicidad y la explícitaidad, el nombre de un símbolo es ahora un parámetro obligatorio para cada constructor de símbolos (incluido Argument<T>). El concepto de un nombre y alias ahora es independiente: los alias son solo alias y no incluyen el nombre del símbolo. Por supuesto, son opcionales. Como resultado, se realizaron los siguientes cambios:

  • name ahora es un argumento obligatorio para cada constructor público de Argument<T>, Option<T>y Command. En el caso de Argument<T>, no se usa para el análisis, sino para generar la ayuda. En el caso de Option<T> y Command, se usa para identificar el símbolo durante el análisis y también para obtener ayuda y finalizaciones.
  • La propiedad Symbol.Name ya no es virtual, ahora es de solo lectura y devuelve el nombre tal como se proporcionó cuando se creó el símbolo. Debido a eso, Symbol.DefaultName se quitó y Symbol.Name ya no quita --, -, / ni cualquier otro prefijo del alias de mayor longitud.
  • La propiedad Aliases expuesta por Option y Command ahora es una colección mutable. Esta colección ya no incluye el nombre del símbolo.
  • System.CommandLine.Parsing.IdentifierSymbol se quitó (era un tipo base tanto para Command como para Option).

Tener el nombre siempre presente le permite obtener el valor analizado por nombre:

RootCommand command = new("The description.")
{
    new Option<int>("--number")
};

ParseResult parseResult = command.Parse(args);
int number = parseResult.GetValue<int>("--number");

Creación de opciones con alias

Antes, Option<T> exponía muchos de los constructores, algunos de los cuales aceptaban el nombre. Dado que el nombre ahora es obligatorio y los alias se proporcionarán con frecuencia para Option<T>, solo hay un único constructor. Acepta el nombre y una params matriz de alias.

Antes de la versión 2.0.0-beta5, Option<T> tenía un constructor que tomaba un nombre y una descripción. Debido a eso, el segundo argumento podría tratarse ahora como un alias en lugar de una descripción. Es el único cambio importante conocido en la API que no provoca un error del compilador.

Actualice cualquier código que pase una descripción al constructor para usar el nuevo constructor que tome un nombre y alias y, a continuación, establezca la Description propiedad por separado. Por ejemplo:

Option<bool> beta4 = new("--help", "An option with aliases.");
beta4b.Aliases.Add("-h");
beta4b.Aliases.Add("/h");

Option<bool> beta5 = new("--help", "-h", "/h")
{
    Description = "An option with aliases."
};

Valores predeterminados y análisis personalizado

En 2.0.0-beta4, puede establecer valores predeterminados para opciones y argumentos mediante los SetDefaultValue métodos . Esos métodos aceptaron un object valor, que no era seguro para tipos y podrían provocar errores en tiempo de ejecución si el valor no era compatible con la opción o el tipo de argumento:

Option<int> option = new("--number");
// This is not type safe, as the value is a string, not an int:
option.SetDefaultValue("text");

Además, algunos de los Option constructores y Argument aceptaron un delegado de análisis (parse) y un valor booleano (isDefault) que indica si el delegado era un analizador personalizado o un proveedor de valores predeterminado, lo que resultaba confuso.

Option<T> Las clases y Argument<T> ahora tienen una DefaultValueFactory propiedad que puede usar para establecer un delegado al que se puede llamar para obtener el valor predeterminado de la opción o argumento. Este delegado se invoca cuando no se encuentra la opción o el argumento en la entrada de la línea de comandos analizada.

Option<int> number = new("--number")
{
    DefaultValueFactory = _ => 42
};

Argument<T> y Option<T> también incluyen una CustomParser propiedad que puede usar para establecer un analizador personalizado para el símbolo:

Argument<Uri> uri = new("arg")
{
    CustomParser = result =>
    {
        if (!Uri.TryCreate(result.Tokens.Single().Value, UriKind.RelativeOrAbsolute, out var uriValue))
        {
            result.AddError("Invalid URI format.");
            return null;
        }

        return uriValue;
    }
};

Además, CustomParser acepta un delegado de tipo Func<ParseResult,T>, en lugar del delegado anterior ParseArgument . Esto y algunos otros delegados personalizados se quitaron para simplificar la API y reducir el número de tipos expuestos por la API, lo que reduce el tiempo de inicio invertido durante la compilación JIT.

Para obtener más ejemplos de cómo usar DefaultValueFactory y CustomParser, vea Cómo personalizar el análisis y la validación en System.CommandLine.

Separación de análisis e invocación

En 2.0.0-beta4, era posible separar el análisis y invocar comandos, pero no estaba claro cómo hacerlo. Command no expondía un Parse método, pero CommandExtensions proporcionaba Parsemétodos de extensión , Invokey InvokeAsync para Command. Esto fue confuso, ya que no estaba claro qué método usar y cuándo. Se realizaron los siguientes cambios para simplificar la API:

  • Command ahora expone un Parse método que devuelve un ParseResult objeto . Este método se usa para analizar la entrada de la línea de comandos y devolver el resultado de la operación de análisis. Además, deja claro que el comando no se invoca sino se analiza, y solo de manera sincrónica.
  • ParseResult ahora expone tanto Invoke como InvokeAsync métodos que puede usar para invocar el comando. Este patrón deja claro que el comando se invoca después del análisis y permite la invocación sincrónica y asincrónica.
  • La CommandExtensions clase se quitó, ya que ya no es necesaria.

Configuración

Antes de la versión 2.0.0-beta5, era posible personalizar el análisis, pero solo con algunos de los métodos públicos Parse . Había una Parser clase que exponía dos constructores públicos: uno que acepta un Command y otro que acepta un CommandLineConfiguration. CommandLineConfiguration era inmutable y para crearlo, tenía que usar un patrón de generador expuesto por la CommandLineBuilder clase . Se realizaron los siguientes cambios para simplificar la API:

  • CommandLineConfiguration se dividió en dos clases mutables (en 2.0.0-beta7): ParserConfiguration y InvocationConfiguration. La creación de una configuración de invocación ahora es tan sencilla como crear una instancia de InvocationConfiguration y establecer las propiedades que desea personalizar.
  • Cada Parse método ahora acepta un parámetro opcional ParserConfiguration que puede usar para personalizar el análisis. Cuando no se proporciona, se usa la configuración predeterminada.
  • Para evitar conflictos de nombres, Parser fue renombrado a CommandLineParser para diferenciarse de otros tipos de analizador. Puesto que no tiene estado, ahora es una clase estática con solo métodos estáticos. Expone dos Parse métodos de análisis: uno acepta un IReadOnlyList<string> args y otro acepta un string args. Este último usa CommandLineParser.SplitCommandLine(String) (también público) para dividir la entrada de la línea de comandos en tokens antes de analizarla.

CommandLineBuilderExtensions también se ha eliminado. Aquí se muestra cómo puede asignar sus métodos a las nuevas API:

  • CancelOnProcessTermination ahora es una propiedad de InvocationConfiguration denominada ProcessTerminationTimeout. Está habilitado de forma predeterminada, con un tiempo de espera de 2 segundos. Para deshabilitarlo, establézcalo en null. Para obtener más información, consulte Tiempo de espera de finalización del proceso.

  • EnableDirectives, UseEnvironmentVariableDirective, UseParseDirectivey UseSuggestDirective se quitaron. Se introdujo un nuevo tipo de directiva y RootCommand ahora expone una Directives propiedad . Puede agregar, quitar e iterar directivas mediante esta colección. La directiva Suggest se incluye de forma predeterminada; También puede usar otras directivas como DiagramDirective o EnvironmentVariablesDirective.

  • EnableLegacyDoubleDashBehavior fue eliminado. Ahora, la ParseResult.UnmatchedTokens propiedad expone todos los tokens no coincidentes. Para obtener más información, consulte Tokens no coincidentes.

  • EnablePosixBundling fue eliminado. La agrupación ahora está habilitada de forma predeterminada, puede deshabilitarla estableciendo la ParserConfiguration.EnablePosixBundling propiedad false en. Para obtener más información, consulte EnablePosixBundling.

  • RegisterWithDotnetSuggest se quitó al realizar una operación costosa, normalmente durante el inicio de la aplicación. Ahora debe registrar comandos con dotnet suggestmanualmente.

  • UseExceptionHandler fue eliminado. El controlador de excepciones predeterminado ahora está habilitado por defecto, puede deshabilitarlo estableciendo la propiedad InvocationConfiguration.EnableDefaultExceptionHandler a false. Esto resulta útil cuando desea controlar excepciones de forma personalizada, simplemente encapsulando los métodos Invoke o InvokeAsync en un bloque try-catch. Para obtener más información, consulte EnableDefaultExceptionHandler.

  • UseHelp y UseVersion se quitaron. La ayuda y la versión están ahora expuestas por los tipos públicos de HelpOption y VersionOption. Ambos se incluyen de forma predeterminada en las opciones definidas por RootCommand. Para obtener más información, vea Personalizar la salida de ayuda y la opción Versión.

  • UseHelpBuilder fue eliminado. Para obtener más información sobre cómo personalizar la salida de ayuda, vea Cómo personalizar la ayuda en System.CommandLine.

  • AddMiddleware fue eliminado. Ralentiza el inicio de la aplicación y las características se pueden expresar sin ella.

  • UseParseErrorReporting y UseTypoCorrections se quitaron. Los errores de análisis ahora se notifican de forma predeterminada al invocar ParseResult. Puede configurarlo utilizando la acción ParseErrorAction expuesta por la propiedad ParseResult.Action.

    ParseResult result = rootCommand.Parse("myArgs", config);
    if (result.Action is ParseErrorAction parseError)
    {
        parseError.ShowTypoCorrections = true;
        parseError.ShowHelp = false;
    }
    
  • UseLocalizationResources y LocalizationResources se quitaron. La dotnet CLI usó esta característica principalmente para agregar traducciones que faltan a System.CommandLine. Todas esas traducciones se movieron al System.CommandLine, por lo que esta función ya no es necesaria. Si falta compatibilidad con el idioma, notifique un problema.

  • UseTokenReplacer fue eliminado. Los archivos de respuesta están habilitados de forma predeterminada, pero puede deshabilitarlos estableciendo la propiedad ResponseFileTokenReplacer en null. También puede proporcionar una implementación personalizada para personalizar cómo se procesan los archivos de respuesta.

Por último, pero no menos importante, se eliminaron el IConsole y todas las interfaces relacionadas (IStandardOut, IStandardError, IStandardIn). InvocationConfiguration expone dos TextWriter propiedades: Output y Error. Puede establecer estas propiedades en cualquier instancia TextWriter, como StringWriter, que puede usar para capturar la salida en pruebas. La motivación de este cambio era exponer menos tipos y reutilizar abstracciones existentes.

Invocación

En la versión 2.0.0-beta4, la interfaz ICommandHandler expuso los métodos Invoke y InvokeAsync que se usaron para invocar el comando analizado. Esto facilita la combinación de código sincrónico y asincrónico, por ejemplo, mediante la definición de un controlador sincrónico para un comando y, a continuación, invocarlo de forma asincrónica (lo que podría provocar un interbloqueo). Además, era posible definir un controlador solo para un comando, pero no para una opción (como ayuda, que muestra ayuda) o una directiva.

Se ha introducido una nueva clase CommandLineAction base abstracta y dos clases derivadas, SynchronousCommandLineAction y AsynchronousCommandLineAction. El primero se usa para acciones sincrónicas que devuelven un int código de salida, mientras que el último se usa para acciones asincrónicas que devuelven un Task<int> código de salida.

No es necesario crear un tipo derivado para definir una acción. Puede usar el Command.SetAction método para establecer una acción para un comando. La acción sincrónica puede ser un delegado que toma un System.CommandLine.ParseResult parámetro y devuelve un int código de salida (o nada) y, a continuación, se devuelve un código de salida predeterminado 0 . La acción asincrónica puede ser un delegado que toma como entrada los parámetros System.CommandLine.ParseResult y CancellationToken y devuelve Task<int> (o Task si se quiere que devuelva el código de salida predeterminado).

rootCommand.SetAction(ParseResult parseResult =>
{
    FileInfo parsedFile = parseResult.GetValue(fileOption);
    ReadFile(parsedFile);
});

En el pasado, el CancellationToken pasado a InvokeAsync se exponía al controlador a través de un método de InvocationContext:

rootCommand.SetHandler(async (InvocationContext context) =>
{
    string? urlOptionValue = context.ParseResult.GetValueForOption(urlOption);
    var token = context.GetCancellationToken();
    returnCode = await DoRootCommand(urlOptionValue, token);
});

La mayoría de los usuarios no obtenían este token y lo pasaban más. CancellationToken ahora es un argumento obligatorio para las acciones asincrónicas, de modo que el compilador genera una advertencia cuando no se pasa más (consulte CA2016).

rootCommand.SetAction((ParseResult parseResult, CancellationToken token) =>
{
    string? urlOptionValue = parseResult.GetValue(urlOption);
    return DoRootCommandAsync(urlOptionValue, token);
});

Como resultado de estos y otros cambios mencionados anteriormente, la InvocationContext clase también se quitó. ParseResult Ahora se pasa directamente a la acción, por lo que puede acceder a los valores y opciones analizados directamente desde ella.

Para resumir estos cambios:

  • Se quitó la ICommandHandler interfaz. SynchronousCommandLineAction y AsynchronousCommandLineAction se introdujeron.
  • Se cambió el nombre del Command.SetHandler método a SetAction.
  • Se ha cambiado el nombre de la Command.Handler propiedad a Command.Action. Option se extendió con Option.Action.
  • InvocationContext fue eliminado. ParseResult ahora se pasa directamente a la acción.

Para obtener más información sobre cómo usar acciones, vea Cómo analizar e invocar comandos en System.CommandLine.

Las ventajas de la API simplificada

Los cambios realizados en la versión 2.0.0-beta5 hacen que la API sea más coherente, a prueba de futuro y sea más fácil de usar para los usuarios nuevos y existentes.

Los nuevos usuarios necesitan aprender menos conceptos y tipos, ya que el número de interfaces públicas disminuyó de 11 a 0 y las clases públicas (y estructuras) disminuyeron de 56 a 38. El número de métodos públicos se redujo de 378 a 235, y el número de propiedades públicas de 118 a 99.

El número de ensamblados a los que hace System.CommandLine referencia se reduce de 11 a 6:

System.Collections
- System.Collections.Concurrent
- System.ComponentModel
System.Console
- System.Diagnostics.Process
System.Linq
System.Memory
- System.Net.Primitives
System.Runtime
- System.Runtime.Serialization.Formatters
+ System.Runtime.InteropServices
- System.Threading

El tamaño de la biblioteca se reduce en un 32% y lo mismo ocurre con el tamaño de las aplicaciones NativeAOT que utilizan la biblioteca.

La simplicidad también ha mejorado el rendimiento de la biblioteca (es un efecto secundario del trabajo, no el objetivo principal de ella). Las pruebas comparativas muestran que el análisis y la invocación de comandos ahora son más rápidos que en la versión 2.0.0-beta4, especialmente para comandos grandes con muchas opciones y argumentos. Las mejoras de rendimiento son visibles en escenarios sincrónicos y asincrónicos.

La aplicación más sencilla, presentada anteriormente, genera los siguientes resultados:

BenchmarkDotNet v0.15.0, Windows 11 (10.0.26100.4061/24H2/2024Update/HudsonValley)
AMD Ryzen Threadripper PRO 3945WX 12-Cores 3.99GHz, 1 CPU, 24 logical and 12 physical cores
.NET SDK 9.0.300
  [Host]     : .NET 9.0.5 (9.0.525.21509), X64 RyuJIT AVX2
  Job-JJVAFK : .NET 9.0.5 (9.0.525.21509), X64 RyuJIT AVX2

EvaluateOverhead=False  OutlierMode=DontRemove  InvocationCount=1
IterationCount=100  UnrollFactor=1  WarmupCount=3

| Method                  | Args           | Mean      | StdDev   | Ratio |
|------------------------ |--------------- |----------:|---------:|------:|
| Empty                   | --bool -s test |  63.58 ms | 0.825 ms |  0.83 |
| EmptyAOT                | --bool -s test |  14.39 ms | 0.507 ms |  0.19 |
| SystemCommandLineBeta4  | --bool -s test |  85.80 ms | 1.007 ms |  1.12 |
| SystemCommandLineNow    | --bool -s test |  76.74 ms | 1.099 ms |  1.00 |
| SystemCommandLineNowR2R | --bool -s test |  69.35 ms | 1.127 ms |  0.90 |
| SystemCommandLineNowAOT | --bool -s test |  17.35 ms | 0.487 ms |  0.23 |

Como puede ver, el tiempo de inicio (los puntos de referencia notifican el tiempo necesario para ejecutar un ejecutable determinado) ha mejorado en 12% en comparación con 2.0.0-beta4. Si compila la aplicación con NativeAOT, es solo 3 ms más lenta que una aplicación NativeAOT que no analiza los argumentos en absoluto (EmptyAOT en la tabla anterior). Además, si excluye la sobrecarga de una aplicación vacía (63.58 ms), el análisis es de 40% más rápido que en 2.0.0-beta4 (22.22 ms frente a 13.66 ms).

Consulte también