Março de 2019

Volume 34 – Número 3

[.NET]

Analisar a linha de comando com System.CommandLine

De Mark Michaelis | Março de 2019

Voltando a falar sobre o .NET Framework 1.0, fiquei impressionado por não haver uma maneira simples para os desenvolvedores analisarem a linha de comando de seus aplicativos. Os aplicativos iniciam a execução do método Main, mas os argumentos são transmitidos como uma matriz (string[] args), sem nenhuma diferenciação entre quais itens na matriz são comandos, opções, argumentos e assim por diante.

Escrevi sobre esse problema em um artigo anterior (“Como contribuir com projetos de software livre da Microsoft” msdn.com/magazine/mt830359), descrevendo meu trabalho com Jon Sequeira da Microsoft. Sequeira liderou uma equipe de desenvolvedores de software livre para criar um novo analisador de linha de comando capaz de aceitar argumentos de linha de comando e analisá-los em uma API chamada System.CommandLine. Essa API faz três coisas:

  • Permite a configuração de uma linha de comando.
  • Permite a análise de argumentos de linha de comando genéricos (tokens) em construções distintas, nas quais cada palavra na linha de comando é um token. (Tecnicamente, hosts de linha de comando permitem a combinação de palavras em um único token ao usar aspas.)
  • Invoca a funcionalidade que está configurada para ser executada com base no valor de linha de comando.

As construções com suporte incluem comandos, opções, argumentos, diretivas, delimitadores e aliases. Veja uma descrição de cada constructo:

Comandos: são ações compatíveis com a linha de comando do aplicativo. Veja o git, por exemplo. Alguns dos comandos internos do git são branch, adicionar, status, confirmar e enviar. Tecnicamente, os comandos especificados após o nome do executável são, na verdade, subcomandos. Os subcomandos do comando raiz, o próprio nome do executável (por exemplo, git.exe), podem ter seus próprios subcomandos. Por exemplo, o comando “dotnet add package” tem “dotnet” como o comando raiz, “add” como um subcomando e “pacakge” como o subcomando a ser adicionado (talvez pudesse ser chamado de sub-subcomando?).

Opções: fornecem uma forma de modificar o comportamento de um comando. Por exemplo, o comando dotnet build inclui a opção --no-restore, que pode ser especificada para desativar a execução implícita da restauração do comando (e, em vez disso, confiar na execução anterior do comando restore). Como o nome implica, as opções geralmente não são um elemento obrigatório de um comando.

Argumentos: os comandos e as opções podem ter valores associados. Por exemplo, o comando dotnet new inclui o nome do modelo. Esse valor é necessário ao especificar o comando new. De modo semelhante, as opções podem ter valores associados a elas. Novamente, em dotnet new, a opção --name tem um argumento para especificar o nome do projeto. O valor associado a um comando ou a uma opção é chamado de argumento.

Diretivas: são comandos que abrangem todos os aplicativos. Por exemplo, um comando de redirecionamento pode forçar a saída inteira (stderr e stdout) a entrar em um formato .xml. Como as diretivas são parte da estrutura System.CommandLine, elas são incluídas automaticamente, sem nenhum esforço por parte do desenvolvedor da interface da linha de comando.

Delimitadores: a associação de um argumento a um comando ou a uma opção é feita por meio de um delimitador. Os delimitadores comuns são espaço, dois pontos e o sinal de igual. Por exemplo, ao especificar o nível de detalhes de um dotnet build, você pode usar qualquer uma destas três variações: --verbosity=diagnostic, --verbosity diagnostic ou --verbosity:diagnostic.

Aliases: são nomes adicionais que podem ser usados para identificar os comandos e as opções. Por exemplo, com o dotnet, “classlib” é um alias para “Biblioteca de classes” e -v é um alias para “--verbosity.”

Antes de System.CommandLine, a falta de suporte de análise interno significava que, quando seu aplicativo era iniciado, por ser o desenvolvedor, você tinha de analisar a matriz de argumentos para determinar qual correspondia ao tipo de argumento e, em seguida, associar corretamente todos os valores juntos. Embora o .NET fizesse várias tentativas de resolver esse problema, nenhuma surgiu como uma solução padrão e nenhuma teve bom dimensionado para dar suporte a cenários simples e complexos. Com isso em mente, System.CommandLine foi desenvolvido e lançado no formato alfa (confira github.com/dotnet/command-line-api).

Manter simples as coisas simples

Imagine que você esteja gravando um programa de conversão de imagem que converte um arquivo de imagem em um formato diferente com base no nome de saída específico. A linha de comando pode ser algo parecido com isto:

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

Com essa linha de comando determinada (confira “Passar parâmetros ao executável do .NET Core” para obter a sintaxe de linha de comando alternativa), o programa imageconv será iniciado no ponto de entrada principal, static void Main(string[] args), com uma matriz de cadeia de caracteres de quatro argumentos correspondentes. Infelizmente, não há associações entre --input e sunrise.CR2 ou entre --output e sunrise.JPG. Não há qualquer indicação de que --input e --output identifiquem opções.

Felizmente, a nova API System.CommandLine fornece uma melhoria significativa desse cenário simples, de uma forma que eu nunca havia visto. A simplificação é que você pode programar um ponto de entrada Main com uma assinatura que corresponda à linha de comando. Em outras palavras, a assinatura de Main se torna:

static void Main(string input, string output)

É isso mesmo, System.CommandLine permite a conversão automática das opções --input e --output em parâmetros no Main, substituindo a necessidade até mesmo de gravar um ponto de entrada Main(string[] args) padrão. O único requisito adicional é fazer referência a um assembly que possibilite esse cenário. Você pode encontrar detalhes sobre a que fazer referência em itl.tc/syscmddf, uma vez que as instruções fornecidas aqui provavelmente ficarão datadas rapidamente depois que o assembly for lançado no NuGet. (Não, não há alterações de linguagem para dar suporte a isso. Em vez disso, ao adicionar a referência, o arquivo de projeto é modificado para incluir uma tarefa de build que gera um método Main padrão com um corpo que usa a reflexão para chamar o ponto de entrada “custom”.)

Além disso, os argumentos não estão limitados a cadeias de caracteres. Há um host de conversores internos (e suporte para conversores personalizados) que permite, por exemplo, que você use System.IO.FileInfo para o tipo de parâmetro na entrada e saída, desta forma:

static void Main(FileInfo input, FileInfo output)

Conforme descrito na seção do artigo, “Arquitetura System.CommandLine”, System.CommandLine é dividida em um módulo principal e um módulo de provedor do aplicativo. A configuração da linha de comando do Main é uma implementação do modelo de aplicativo, mas, por enquanto, farei referência apenas à API inteira definida como System.CommandLine.

Atualmente, o mapeamento entre os argumentos de linha de comando e os parâmetros do método Main é básico, mas ainda relativamente apropriado para vários programas. Vamos considerar uma linha de comando imageconv um pouco mais complexa que demonstra alguns dos recursos adicionais. A Figura 1 exibe a ajuda da linha de comando.

Figura 1 Linhas de comando de exemplo 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

O método Main correspondente que permite essa linha de comando atualizada é mostrado na Figura 2. Embora o exemplo não tenha nada além de um método Main totalmente documentado, há vários recursos habilitados automaticamente. Vamos explorar a funcionalidade que se torna interna ao usar System.CommandLine.

Figura 2 Método Main com suporte para linha de comando atualizada do imageconv

/// <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)

O primeiro bit da funcionalidade é a saída de ajuda da linha de comando, a qual é inferida a partir dos comentários XML no Main. Esses comentários não apenas permitem obter uma descrição geral do programa (especificado no comentário XML de resumo), como também obter a documentação em cada argumento usando comentários XML de parâmetro. O aproveitamento dos comentários XML requer a habilitação da saída do documento, mas isso é configurado automaticamente para você ao referenciar o assembly que permite a configuração por meio do Main. Existe uma saída de ajuda interna com qualquer uma de três opções de linha de comando: -h, -? ou --help. Por exemplo, a ajuda exibida na Figura 1 é gerada automaticamente por System.CommandLine.

De modo semelhante, embora não haja parâmetros de versão no Main, System.CommandLine gera automaticamente uma opção --version com uma versão assembly do executável como saída.

Um outro recurso, a verificação de sintaxe de linha de comando, detecta se um argumento necessário (para o qual nenhum padrão é especificado no parâmetro) está ausente. Se um argumento necessário não for especificado, System.CommandLine emite automaticamente um erro que informa: “Argumento necessário ausente para a opção: --output.” Embora pareça um pouco confuso, as opções com os argumentos são obrigatórias por padrão. No entanto, se o valor do argumento associado a uma opção não for obrigatório, você pode aproveitar a sintaxe do valor do parâmetro padrão C#. Por exemplo:

int xCropSize = 0

Também há suporte interno para analisar opções, independentemente da sequência em que elas apareçam na linha de comando. Além disso, vale a pena destacar que o delimitador entre a opção e o argumento pode ser um espaço, dois-pontos ou um sinal de igual, por padrão. Por fim, a concatenação com maiúsculas e minúsculas nos nomes do parâmetro Main é convertida em nomes de argumento de estilo Posix (ou seja, xCropSize se traduz em --x-crop-size na linha de comando).

Se você digitar um nome de comando ou opção não reconhecidos, System.CommandLine retorna automaticamente um erro de linha de comando que informa: “Comando ou argumento não reconhecido...”. No entanto, se o nome especificado for semelhante a uma opção existente, o erro será exibido com uma sugestão de correção de erro de digitação.

Há algumas diretivas internas disponíveis para todos os aplicativos de linha de comando que usam System.CommandLine. Essas diretivas ficam entre colchetes e aparecem imediatamente após o nome do aplicativo. Por exemplo, a diretiva [debug] dispara um ponto de interrupção que permite que você anexe um depurador, enquanto [parse] exibe uma versão prévia de como os tokens são analisados, conforme mostrado aqui:

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

Além disso, há suporte para testes automatizados por meio de uma interface IConsole e a implementação da classe TestConsole. Para injetar o TestConsole no pipeline da linha de comando, adicione um parâmetro IConsole ao Main da seguinte forma:

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

Para aproveitar o parâmetro de console, substitua as invocações de System.Console pelo parâmetro IConsole. Observe que o parâmetro IConsole será definido automaticamente para você quando invocado diretamente na linha de comando (em vez de um teste de unidade), portanto, mesmo que o parâmetro seja atribuído como nulo por padrão, ele não deve ter um valor nulo, a menos que você grave o código de teste que o invoca dessa forma. Como alternativa, cogite colocar o parâmetro IConsole primeiro.

Um de meus recursos favoritos é o suporte ao preenchimento de guia, o qual os usuários finais podem aceitar executando um comando para ativá-lo (confira bit.ly/2sSRsQq). Esse é um cenário de aceitação porque os usuários tendem a se proteger de alterações implícitas ao shell. O preenchimento de guia para nomes de comando e opções ocorre automaticamente, mas também há o preenchimento de guia para argumentos por meio de sugestões. Ao configurar um comando ou uma opção, os valores de preenchimento de guia podem vir de uma lista estática de valores, como q, m, n, d ou valores de diagnósticos de --verbosity. Ou eles podem ser fornecidos dinamicamente ao tempo de execução, como na invocação de REST que retorna uma lista de pacotes do NuGet disponíveis quando o argumento é uma referência do NuGet.

Usar o método Main como a especificação da linha de comando é apenas uma das várias maneiras com as quais você pode programar usando System.CommandLine. A arquitetura é flexível, permitindo outras maneiras de definir e trabalhar com a linha de comando.

A arquitetura System.CommandLine

System.CommandLine foi projetada em torno de um assembly principal que inclui uma API para configurar a linha de comando e um analisador que resolve os argumentos de linha de comando em uma estrutura de dados. Todos os recursos listados na seção anterior podem ser habilitados por meio do assembly principal, exceto a habilitação de uma assinatura de método diferente para Main. No entanto, o suporte para a configuração da linha de comando, usando uma linguagem específica de domínio (por exemplo, um método semelhante ao Main) é habilitado por um modelo de aplicativo. (O modelo de aplicativo usado para a implementação do método semelhante ao Main descrito anteriormente tem o codinome de “DragonFruit”.) No entanto, a arquitetura System.CommandLine habilita o suporte para modelos de aplicativo adicionais (como mostrado na Figura 3).

Arquitetura System.CommandLine
Figura 3 Arquitetura System.CommandLine

Por exemplo, você poderia gravar um modelo de aplicativo que usa um modelo de classe C# para definir a sintaxe de linha de comando de um aplicativo. Nesse tipo de modelo, os nomes da propriedade podem corresponder aos nomes da opção, e o tipo de propriedade corresponderia ao tipo de dados no qual converter um argumento. Além disso, o modelo poderia aproveitar os atributos para definir aliases, por exemplo. Como alternativa, você poderia gravar um modelo para analisar um arquivo docopt (confira docopt.org) para a configuração. Cada um desses modelos de aplicativo invocaria a API de configuração System.CommandLine. Pode ser que os desenvolvedores prefiram chamar System.CommandLine diretamente do aplicativo em vez de por meio de um modelo de aplicativo, uma abordagem para a qual também há suporte.

Transmitir parâmetros para o executável do .NET Core

Ao especificar argumentos de linha de comando combinados ao comando dotnet run, a linha de comando completa seria:

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

No entanto, se você estiver executando o dotnet usando o mesmo diretório no qual o arquivo csproj foi localizado, a linha de comando seria:

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

O comando dotnet run usa “--” como o identificador, indicando que todos os outros argumentos devem ser transmitidos para o executável para serem analisados.

Começando com o .NET Core 2.2, também há suporte para aplicativos autônomos (mesmo no Linux). Com um aplicativo autossuficiente, você pode inicializá-lo sem usar o dotnet run e, em vez disso, apenas confiar no executável resultante, dessa forma:

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

Obviamente, esse é o comportamento que se espera dos usuários do Windows.

Como tornar o complexo possível

Anteriormente, mencionei que a funcionalidade era básica para manter simples as coisas simples. Isso ocorre porque a habilitação da análise da linha de comando por meio do método Main ainda não conta com alguns recursos que podem ser considerados importantes para algumas pessoas. Por exemplo, você não pode configurar um comando (ou subcomando) ou um alias de opção. Se encontrar essas limitações, crie seu próprio modelo de aplicativo ou chame diretamente no Core (assembly de System.CommandLine).

System.CommandLine inclui classes que representam os constructos de uma linha de comando. Entre elas, comando (e RootCommand), opção e argumento. A Figura 4 fornece alguns exemplos de código para invocar System.CommandLine diretamente e configurá-lo para realizar a funcionalidade básica definida no texto de ajuda da Figura 1.

Figura 4 Trabalhar diretamente com 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...
}

Nesse exemplo, em vez de confiar no modelo de aplicativo Main para definir a configuração de linha de comando, cada constructo é explicitamente instanciado. A única diferença funcional é a adição de aliases para cada opção. Aproveitando a API principal diretamente, contudo, fornece mais controle do que seria possível usando o Main como abordagem.

Por exemplo, você poderia definir subcomandos, como um comando de aprimoramento de imagem que inclui seu próprio conjunto de opções e argumentos relacionados à ação de aprimoramento. Programas de linha de comando complexos têm vários subcomandos e até sub-subcomandos. O comando dotnet, por exemplo, tem o comando “dotnet sln add”, em que “dotnet” é a raiz de comando, “sln” é um dos vários subcomandos e “add” (ou “list and remove”) é um comando filho de “sln”.

A chamada final para InvokeAsync define implicitamente muitos dos recursos ao incluir automaticamente:

  • Diretivas para análise e depuração.
  • A configuração das opções de ajuda e versão.
  • Preenchimento de guia e correções de erros de digitação.

Também há métodos de extensão separados para cada recurso, caso um controle mais individualizado seja necessário. Também há vários outros recursos de configuração expostos pela API principal. Estão incluídos:

  • Manuseio de tokens que explicitamente não correspondem à configuração.
  • Manipuladores de sugestão que permitem o preenchimento de guias, retornando uma lista de valores possíveis, dada a cadeia de caracteres de linha de comando atual e o local do cursor.
  • Comandos ocultos que você não deseja que sejam descobertos usando o preenchimento de guias ou a ajuda.

Além disso, embora haja vários botões para controlar a análise de linha de comando com System.CommandLine, ela também fornece uma abordagem do tipo método primeiro. Na verdade, isso é o que é usado internamente para associar ao método semelhante ao Main. Com a abordagem método primeiro, você pode usar um método como o Convert na parte inferior da Figura 4 para configurar o analisador (como mostrado na Figura 5).

Figura 5 Usar a abordagem de método primeiro 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);
}

Nesse caso, observe que o método Convert é usado na configuração inicial. Em seguida, navegue pelo modelo de objeto do comando raiz para adicionar aliases. A propriedade indexável Children contém todas as opções e comandos anexados ao comando raiz.

Conclusão

Estou muito empolgado com a funcionalidade disponível em System.CommandLine. É maravilhoso ser realmente possível obter os cenários explorados aqui com tão pouca codificação. Além disso, a quantidade de funcionalidade obtida, incluindo o suporte a questões como o preenchimento de guia, a conversão de argumento e os testes automatizados, apenas para citar alguns, mostra que, com pouco esforço, você pode ter um suporte de linha de comando totalmente funcional em todos os seus aplicativos do dotnet.

Por fim, System.CommandLine é um software livre. Isso significa que, caso alguma funcionalidade de que você precise esteja ausente, você pode desenvolver um aprimoramento e enviá-lo para a comunidade como uma solicitação de pull. Algumas coisas que eu adoraria ver adicionadas são o suporte para não ser preciso especificar os nomes de opção ou de comando na linha de comando e confiar na posição dos argumentos para deduzir quais são os nomes. Além disso, seria ótimo se você pudesse incluir declarativamente um alias adicional (por exemplo, aliases curtos) ao usar a abordagem de primeiro método ou semelhante ao Main.


Mark Michaelis é fundador da IntelliTect, onde atua como arquiteto técnico principal e instrutor. Ele vem atuando como Microsoft MVP por mais de duas décadas e é Diretor Regional da Microsoft desde 2007. Michaelis atua em diversas equipes de análise de design de software da Microsoft, incluindo C#, Microsoft Azure, SharePoint e Visual Studio ALM. Ele apresenta palestras em conferências de desenvolvedores e escreveu diversos livros, incluindo o mais recente, “Essential C# 7.0 (6ª edição)” (itl.tc/EssentialCSharp). Você pode entrar em contato com ele pelo Facebook, em facebook.com/Mark.Michaelis, pelo seu blog IntelliTect.com/Mark, no Twitter: @markmichaelis ou pelo email mark@IntelliTect.com.

Agradecemos aos seguintes especialistas técnicos da Microsoft pela revisão deste artigo: Kevin Bost, Kathleen Dollard, Jon Sequeira