Compartir a través de



Marzo de 2019

Volumen 34, número 3

[.NET]

Análisis de la línea de comandos con System.CommandLine

Por Mark Michaelis | Marzo de 2019

Al echar la vista atrás hasta .NET Framework 1.0, me maravilla que no ha habido ninguna forma sencilla de que los desarrolladores analicen la línea de comandos de sus aplicaciones. Las aplicaciones inician la ejecución desde el método Main, pero los argumentos se pasan como matriz (string[] args) sin distinguir qué elementos de la matriz son comandos, opciones, argumentos, etc.

Escribí acerca de este problema en un artículo anterior ("How to Contribute to Microsoft Open Source Software Projects" [Cómo contribuir a proyectos de software de código abierto de Microsoft], msdn.com/magazine/mt830359), donde describí el trabajo que realizaba con Jon Sequeira, de Microsoft. Sequeira ha dirigido a un equipo de desarrolladores de código abierto para crear un nuevo analizador de línea de comandos que pueda aceptar argumentos de línea de comandos y analizarlos en una API denominada System.CommandLine, que hace tres cosas:

  • Permite la configuración de una línea de comandos.
  • Habilita el análisis de argumentos genéricos de línea de comandos (tokens) en distintas construcciones, donde cada palabra de la línea de comandos es un token. (Técnicamente, los hosts de línea de comandos permiten la combinación de palabras en un solo token con comillas).
  • Invoca la funcionalidad configurada para ejecutarse según el valor de la línea de comandos.

Las construcciones admitidas incluyen comandos, opciones, argumentos, directivas, delimitadores y alias. Aquí se muestra una descripción de cada construcción:

Comandos: son las acciones que admite la línea de comandos de la aplicación. Por ejemplo, considere GIT. Algunos de los comandos integrados para GIT son branch, add, status, commit y push. Técnicamente, los comandos especificados después del nombre del ejecutable son, de hecho, subcomandos. Los subcomandos del comando raíz (el nombre del propio archivo ejecutable; p. ej., git.exe) pueden tener sus propios subcomandos. Por ejemplo, el comando "dotnet add package" tiene "dotnet" como comando raíz, "add" como subcomando y "package" como subcomando de add (¿quizás se podría denominar sub-subcomando?).

Opciones: proporcionan una manera de modificar el comportamiento de un comando. Por ejemplo, el comando build de dotnet incluye la opción --no-restore, que se puede especificar para deshabilitar la ejecución implícita del comando restore (y, en su lugar, depender de la ejecución anterior del comando restore). Como el nombre implica, las opciones no suelen ser un elemento obligatorio de un comando.

Argumentos: tanto los comandos como las opciones pueden tener valores asociados. Por ejemplo, el comando new de dotnet incluye el nombre de la plantilla. Este valor es necesario cuando se especifica el comando new. De forma similar, las opciones pueden tener valores asociados con ellas. De nuevo, con el comando new de dotnet, la opción --name tiene un argumento para especificar el nombre del proyecto. El valor asociado con un comando u opción se denomina argumento.

Directivas: se trata de comandos transversales para todas las aplicaciones. Por ejemplo, un comando de redirección puede forzar que todos los resultados (stderr y stdout) tengan el formato XML. Dado que las directivas forman parte del marco System.CommandLine, están incluidas automáticamente, sin ningún esfuerzo por parte del desarrollador de la interfaz de línea de comandos.

Delimitadores: la asociación de un argumento con un comando o una opción se realiza mediante un delimitador. Los delimitadores comunes son el espacio, los dos puntos y el signo igual. Por ejemplo, al especificar el nivel de detalle de una compilación de dotnet, puede usar cualquiera de las tres variaciones siguientes: --verbosity=diagnostic, --verbosity diagnostic o --verbosity:diagnostic.

Alias: se trata de nombres adicionales que se pueden usar para identificar comandos y opciones. Por ejemplo, con dotnet, "classlib" es un alias de "Class library" y -v es un alias de "--verbosity".

Antes de System.CommandLine, la falta de compatibilidad con el análisis integrado significaba que, al iniciar la aplicación, usted, como desarrollador, tenía que analizar la matriz de argumentos para determinar qué correspondía a qué tipo de argumento y, a continuación, asociar correctamente todos los valores juntos. Aunque .NET incluye numerosos intentos de resolver este problema, ninguno ha servido como solución predeterminada y ninguno se puede escalar bien para admitir tanto escenarios sencillos como complejos. Teniendo esto en mente, se desarrolló System.CommandLine y se lanzó en formato alfa (consulte github.com/dotnet/command-line-api).

Simplificar las cosas sencillas

Imagine que está escribiendo un programa de conversión de imagen que convierte un archivo de imagen en otro formato según el nombre de salida especificado. La línea de comandos podría tener un aspecto parecido a este:

imageconv --input sunrise.CR2 --output sunrise.JPG

Dada esta línea de comandos (consulte "Pasar parámetros al ejecutable de .NET Core" para obtener una sintaxis de línea de comandos alternativa), se iniciará el programa imageconv en el punto de entrada principal, static void Main (string[]args), con una matriz de cadena de cuatro argumentos correspondientes. Lamentablemente, no hay ninguna asociación entre --input y sunrise.CR2, ni entre --output y sunrise.JPG. Tampoco hay ninguna indicación de que --input y --output identifiquen opciones.

Afortunadamente, la nueva API System.CommandLine proporciona una mejora considerable en este sencillo escenario y lo hace de una forma que no había visto nunca. La simplificación es que puede programar un punto de entrada de Main con una firma que coincida con la línea de comandos. En otras palabras, la firma de Main se convierte en:

static void Main(string input, string output)

Así es: System.CommandLine permite la conversión automática de las opciones --input y --output en parámetros en Main, lo que reemplaza la necesidad de escribir un punto de entrada estándar Main(string[] args). El único requisito adicional es hacer referencia a un ensamblado que habilite este escenario. Puede encontrar detalles acerca de a qué debe hacer referencia en itl.tc/syscmddf, ya que es probable que las instrucciones que se proporcionan aquí se queden obsoletas rápidamente una vez se publique el ensamblado en NuGet. No, no hay ningún cambio de lenguaje para admitirlo. En su lugar, al agregar la referencia, el archivo de proyecto se modifica para incluir una tarea de compilación que genera un método Main estándar con un cuerpo que usa la reflexión para llamar al punto de entrada "personalizado".

Además, los argumentos no se limitan a las cadenas. Hay un montón de convertidores integrados (y soporte técnico para convertidores personalizados) que permiten, por ejemplo, usar System.IO.FileInfo para el tipo de parámetro de entrada y salida, como se muestra a continuación:

static void Main(FileInfo input, FileInfo output)

Como se describe en la sección del artículo "Arquitectura de System.CommandLine", System.CommandLine se divide en un módulo principal y un módulo de proveedor de aplicaciones. Configurar la línea de comandos desde Main es una implementación de modelo de aplicación, pero, por ahora, me referiré a todo el conjunto de API como System.CommandLine.

La asignación entre los argumentos de línea de comandos y los parámetros del método Main es básica hoy en día, pero todavía resulta relativamente útil para muchos programas. Pensemos en una línea de comandos imageconv ligeramente más compleja que muestra algunas características adicionales. En la figura 1 se muestra la ayuda de la línea de comandos.

Figura 1 Línea de comandos de ejemplo para imageconv

imageconv:
  Converts an image file from one format to another.
Usage:
  imageconv [options]
Options:
  --input          The path to the image file that is to be converted.
  --output         The target name of the output after conversion.
  --x-crop-size    The X dimension size to crop the picture.
                   The default is 0 indicating no cropping is required.
  --y-crop-size    The Y dimension size to crop the picture.
                   The default is 0 indicating no cropping is required.
  --version        Display version information

El método Main correspondiente que permite esta línea de comandos actualizada se muestra en la figura 2. Aunque el ejemplo no es más que un método Main totalmente documentado, hay muchas características que se habilitan automáticamente. Vamos a explorar la funcionalidad que está integrada al usar System.CommandLine.

Figura 2 Método Main compatible con la línea de comandos de imageconv actualizada

/// <summary>/// Converts an image file from one format to another./// </summary>/// <param name="input">The path to the image file that is to be
    converted.</param>/// <param name="output">The name of the output from the conversion.
    </param>/// <param name="xCropSize">The x dimension size to crop the picture.
    The default is 0 indicating no cropping is required.</param>/// <param name="yCropSize">The x dimension size to crop the picture.
    The default is 0 indicating no cropping is required.</param>
public static void Main(  FileInfo input, FileInfo output,   int xCropSize = 0, int yCropSize = 0)

La primera parte de funcionalidad es el resultado de la ayuda de la línea de comandos, que se deduce de los comentarios XML en Main. Estos comentarios no solo permiten una descripción general del programa (especificada en el comentario XML de resumen), sino también la documentación de cada argumento mediante comentarios XML de parámetro. Para usar comentarios XML, es necesario habilitar la salida de documentos, pero esto se configura automáticamente cuando se hace referencia al ensamblado que permite la configuración a través de Main. Hay un resultado de ayuda integrado con cualquiera de las tres opciones de línea de comandos: -h, -? o --help. Por ejemplo, System.CommandLine genera automáticamente la ayuda que se muestra en la figura 1.

Del mismo modo, aunque no hay ningún parámetro de versión en Main, System.CommandLine genera automáticamente una opción --version que devuelve la versión de ensamblado del ejecutable.

Otra característica, la comprobación de la sintaxis de la línea de comandos, detecta si falta algún argumento necesario (para el que no hay ningún valor predeterminado especificado en el parámetro). Si no se especifica un argumento necesario, System.CommandLine emite automáticamente un error con el texto: “Required argument missing for option: --output” (Falta un argumento necesario para la opción --output). Aunque parezca contradictorio, de forma predeterminada, las opciones con argumentos son obligatorias. Sin embargo, si el valor del argumento asociado con una opción no es necesario, puede aprovechar la sintaxis del valor del parámetro predeterminado de C#. Por ejemplo:

int xCropSize = 0

También hay compatibilidad integrada para analizar opciones, independientemente de la secuencia en que las opciones aparezcan en la línea de comandos. Y es importante destacar que el delimitador entre la opción y el argumento puede ser un espacio, dos puntos o un signo igual, de forma predeterminada. Por último, se convierte la grafía Camel de los nombres de parámetro de Main en nombres de argumento de estilo Posix (es decir, xCropSize se traduce a --x-crop-size en la línea de comandos).

Si escribe un nombre de comando o una opción no reconocida, System.CommandLine devuelve automáticamente un error de línea de comandos con el texto "Unrecognized command or argument..." (Comando o argumento no reconocido). Sin embargo, si el nombre especificado es similar a una opción existente, se mostrará un error con una sugerencia de corrección de error de escritura.

Hay algunas directivas integradas disponibles para todas las aplicaciones de línea de comandos que usan System.CommandLine. Estas directivas se incluyen entre corchetes y aparecen inmediatamente después del nombre de la aplicación. Por ejemplo, la directiva [debug] desencadena un punto de interrupción que le permite adjuntar un depurador, mientras que [parse] muestra una vista previa de cómo se analizan los tokens, como se muestra a continuación:

imageconv [parse] --input sunrise.CR2 --output sunrise.JPG

Además, también se admiten las pruebas automatizadas mediante una interfaz IConsole y la implementación de la clase TestConsole. Para insertar el archivo TestConsole en la canalización de la línea de comandos, agregue un parámetro IConsole a Main, como se muestra a continuación:

public static void Main(
  FileInfo input, FileInfo output,
  int xCropSize = 0, int yCropSize = 0,
    IConsole console = null)

Para aprovechar el parámetro de la consola, reemplace las invocaciones a System.Console con el parámetro IConsole. Tenga en cuenta que el parámetro IConsole se establecerá automáticamente cuando se invoque directamente desde la línea de comandos (en lugar de desde una prueba unitaria), así que, aunque se asigne el valor null al parámetro de forma predeterminada, no debe tener un valor null a menos que escriba código de prueba que lo invoque de este modo. Como alternativa, considere la posibilidad de poner en primer lugar el parámetro IConsole.

Una de mis características favoritas es la compatibilidad con la terminación de línea de comandos, que los usuarios finales pueden elegir mediante la ejecución de un comando para activarla (consulte bit.ly/2sSRsQq). Se trata de un escenario opcional porque los usuarios tienden a ser protectores respecto a los cambios implícitos en el shell. La finalización con tabulación para las opciones y nombres de comando se lleva a cabo automáticamente, pero también hay finalización con tabulación para los argumentos mediante sugerencias. Al configurar un comando o una opción, los valores de terminación de línea de comandos pueden proceder de una lista estática de valores, como q, m, n, d o valores de diagnóstico de --verbosity. También se pueden proporcionar dinámicamente en tiempo de ejecución, como desde la invocación de REST que devuelve una lista de paquetes NuGet disponibles cuando el argumento es una referencia de NuGet.

El uso del método Main como especificación para la línea de comandos es solo una de las distintas formas de programar mediante System.CommandLine. La arquitectura es flexible, lo que permite otras formas de definir la línea de comandos y trabajar con ella.

La arquitectura System.CommandLine

System.CommandLine se ha diseñado en torno a un ensamblado principal que incluye una API para configurar la línea de comandos y un analizador que resuelve los argumentos de línea de comandos en una estructura de datos. Todas las características enumeradas en la sección anterior se pueden habilitar mediante el ensamblado principal, excepto la habilitación de una firma de método diferente para Main. Sin embargo, se admite la configuración de la línea de comandos, concretamente con un lenguaje específico de dominio (por ejemplo, un método similar a Main) mediante un modelo de aplicación. (El modelo de aplicación utilizado para la implementación del método similar a Main que se ha descrito antes tiene el nombre en código "DragonFruit".) Sin embargo, la arquitectura System.CommandLine habilita la compatibilidad con otros modelos de aplicación (como se muestra en la figura 3).

Arquitectura System.CommandLine
Figura 3 Arquitectura System.CommandLine

Por ejemplo, podría escribir un modelo de aplicación que utiliza un modelo de clase C# para definir la sintaxis de línea de comandos para una aplicación. En este tipo de modelo, los nombres de propiedad pueden corresponderse con el nombre de opción y el tipo de propiedad se correspondería con el tipo de datos al que convertir un argumento. Además, el modelo podría aprovechar los atributos para definir alias, por ejemplo. Como alternativa, podría escribir un modelo que analiza un archivo docopt (consulte docopt.org) para la configuración. Cada uno de estos modelos de aplicación invocaría la API de configuración de System.CommandLine. Por supuesto, es posible que los desarrolladores prefieran invocar System.CommandLine directamente desde la aplicación, en lugar de a través de un modelo de aplicación, y este enfoque también es compatible.

Pasar parámetros al ejecutable de .NET Core

Si se especifican argumentos de línea de comandos en combinación con el comando de ejecución de dotnet, la línea de comandos completa sería:

dotnet run --project imageconv.csproj -- --input sunrise.CR2
  --output sunrise.JPG

Si, en cambio, ejecuta dotnet desde el mismo directorio en el que se encontraba el archivo csproj, la línea de comandos sería:

dotnet run -- --input sunrise.CR2 --output sunrise.JPG

El comando de ejecución de dotnet utiliza "--" como identificador, lo que indica que el resto de argumentos se deben pasar al ejecutable para que los analice.

A partir de .NET Core 2.2, también se admiten las aplicaciones independientes (incluso en Linux). Con una aplicación independiente, puede iniciarlo sin usar la ejecución de dotnet y, en su lugar, confiar solamente en el ejecutable resultante, como se muestra a continuación:

imageconv.exe --input sunrise.CR2 --output sunrise.JPG

Obviamente, este es el comportamiento que esperan los usuarios de Windows.

Convertir lo complejo en realidad

Antes, he mencionado que la funcionalidad para simplificar las cosas sencillas era básica. Esto es porque la habilitación del análisis de línea de comandos a través del método Main aún carece de algunas características que algunos podrían considerar importantes. Por ejemplo, no se puede configurar un (sub)comando ni un alias de opción. Si tiene estas limitaciones, puede crear su propio modelo de aplicación o llamar directamente al núcleo (ensamblado System.CommandLine).

System.CommandLine incluye clases que representan las construcciones de una línea de comandos. Esto incluye Command (y RootCommand), Option y Argument. La figura 4 proporciona código de ejemplo para invocar System.CommandLine directamente y configurarlo para lograr la funcionalidad básica que se define en el texto de ayuda de la figura 1.

Figura 4 Trabajar directamente con System.CommandLine

using System;
using System.CommandLine;
using System.CommandLine.Invocation;
using System.IO;
...
public static async Task<int> Main(params string[] args)
{
  RootCommand rootCommand = new RootCommand(
    description: "Converts an image file from one format to another."
    , treatUnmatchedTokensAsErrors: true);
  Option inputOption = new Option(
    aliases: new string[] { "--input", "-i" }
    , description: "The path to the image file that is to be converted."
    , argument: new Argument<FileInfo>());
  rootCommand.AddOption(inputOption);
  Option outputOption = new Option(
    aliases: new string[] { "--output", "-o" }
    , description: "The target name of the output file after conversion."
    , argument: new Argument<FileInfo>());
  rootCommand.AddOption(outputOption);
  Option xCropSizeOption = new Option(
    aliases: new string[] { "--x-crop-size", "-x" }
    , description: "The x dimension size to crop the picture. 
      The default is 0 indicating no cropping is required."
    , argument: new Argument<FileInfo>());
  rootCommand.AddOption(xCropSizeOption);
  Option yCropSizeOption = new Option(
    aliases: new string[] { "--y-crop-size", "-y" }
    , description: "The Y dimension size to crop the picture. 
      The default is 0 indicating no cropping is required."
    , argument: new Argument<FileInfo>());
  rootCommand.AddOption(yCropSizeOption);
  rootCommand.Handler =
    CommandHandler.Create<FileInfo, FileInfo, int, int>(Convert);
  return await rootCommand.InvokeAsync(args);
}
static public void Convert(
  FileInfo input, FileInfo output, int xCropSize = 0, int yCropSize = 0)
{
  // Convert...
}

En este ejemplo, en lugar de confiar en un modelo de aplicación Main para definir la configuración de la línea de comandos, se crea una instancia de cada construcción explícitamente. La única diferencia funcional es la adición de alias para cada opción. Sin embargo, aprovechar la API principal directamente proporciona más control del que es posible con un enfoque similar a Main.

Por ejemplo, podría definir subcomandos, como un comando de mejora de imagen que incluye su propio conjunto de opciones y argumentos relacionados con la acción de mejora. Los programas de línea de comandos complejos tienen varios subcomandos e incluso sub-subcomandos. Por ejemplo, el comando dotnet tiene el comando dotnet sln add, donde dotnet es el comando raíz, sln es uno de los varios subcomandos y add (o list y remove) es un comando secundario de sln.

La llamada final a InvokeAsync configura implícitamente muchas características automáticamente, como:

  • Directivas de análisis y depuración.
  • La configuración de las opciones de ayuda y versión.
  • Corrección de errores tipográficos y terminación de línea de comandos.

También hay métodos de extensión diferentes para cada característica, si se necesita un control más preciso. Además, la API de Core también expone muchas otras funcionalidades de configuración. Éstos son:

  • Control de tokens sin una correspondencia explícita en la configuración.
  • Controladores de sugerencias que habilitan la terminación de línea de comandos y devuelven una lista de valores posibles, dada la cadena de línea de comandos actual y la ubicación del cursor.
  • Comandos ocultos que no quiere que se puedan detectar mediante la terminación de línea de comandos o la ayuda.

Además, aunque hay muchos mandos y botones para controlar el análisis de línea de comandos con System.CommandLine, también proporciona un enfoque de método en primer lugar. De hecho, esto es lo que se usa internamente para enlazar con un método similar a Main. Con el enfoque de método en primer lugar, puede usar un método, como Convert, en la parte inferior de la figura 4 para configurar el analizador (como se muestra en la figura 5).

Figura 5 Uso del enfoque de método en primer lugar para configurar System.CommandLine

public static async Task<int> Main(params string[] args)
{
  RootCommand rootCommand = new RootCommand(
    description: "Converts an image file from one format to another."
    , treatUnmatchedTokensAsErrors: true);
  MethodInfo method = typeof(Program).GetMethod(nameof(Convert));
  rootCommand.ConfigureFromMethod(method);
  rootCommand.Children["--input"].AddAlias("-i");
  rootCommand.Children["--output"].AddAlias("-o");
  return await rootCommand.InvokeAsync(args);
}

En este caso, tenga en cuenta que el método Convert se utiliza para la configuración inicial y, a continuación, se navega al modelo de objetos del comando raíz para agregar alias. La propiedad Children indexable contiene todas las opciones y comandos adjuntos al comando raíz.

Resumen

Estoy muy entusiasmado con la funcionalidad disponible en System.CommandLine. El hecho de que para lograr los escenarios sencillos que se exploran aquí se requiera tan poco código es maravilloso. Además, la cantidad de funcionalidad que se consigue, incluidos ciertos aspectos como la terminación de línea de comandos, la conversión de argumentos y la compatibilidad con las pruebas automatizadas, por mencionar algunos, significa que, con poco esfuerzo, puede admitir una línea de comandos totalmente funcional en todas sus aplicaciones de dotnet.

Además, System.CommandLine es código abierto. Esto significa que, si hay alguna funcionalidad que falta que necesita, puede desarrollar la mejora y volverla a enviar a la comunidad como solicitud de incorporación de cambios. Personalmente, me encantaría ver un par de cosas agregadas: la posibilidad de no tener que especificar siempre los nombres de opción o comando en la línea de comandos y la de usar, en su lugar, la posición de los argumentos para sugerir los nombres. Además, sería estupendo que se pudiera agregar mediante declaración un alias adicional (por ejemplo, alias cortos) cuando se usa un enfoque similar a Main o de método en primer lugar.


Mark Michaelis es el fundador de IntelliTect y trabaja de arquitecto técnico como jefe y formador. Durante más de dos décadas, ha sido MVP de Microsoft y director regional de Microsoft desde 2007. Michaelis trabaja con varios equipos de revisión de diseño de software de Microsoft, como C#, Microsoft Azure, SharePoint y Visual Studio ALM. Realiza ponencias en conferencias de desarrolladores y ha escrito varios libros, el más reciente de los cuales es "Essential C# 7.0 (6th Edition)" (itl.tc/EssentialCSharp). Póngase en contacto con él en Facebook en facebook.com/Mark.Michaelis, en su blog IntelliTect.com/Mark, en Twitter @markmichaelis o a través de la dirección de correo electrónico mark@IntelliTect.com.

Gracias a los siguientes expertos técnicos de Microsoft por revisar este artículo: Kevin Bost, Kathleen Dollard, Jon Sequeira