Aplicativo de console

Este tutorial ensina vários recursos em .NET e na linguagem C#. O que você aprenderá:

  • Noções básicas da CLI do .NET
  • A estrutura de um aplicativo de console C#
  • E/S do Console
  • Fundamentos das APIs de E/S de arquivo no .NET
  • Os fundamentos da programação assíncrona controlada por tarefas no .NET Core

Você criará um aplicativo que lê um arquivo de texto e exibe o conteúdo desse arquivo de texto no console. A saída para o console é conduzida a fim de corresponder à leitura em voz alta. É possível acelerar ou diminuir o ritmo pressionando as teclas "<" (menor que) ou ">" (maior que). Execute esse aplicativo no Windows, no Linux, no macOS ou em um contêiner do Docker.

Há vários recursos neste tutorial. Vamos criá-los individualmente.

Pré-requisitos

Criar o aplicativo

A primeira etapa é criar um novo aplicativo. Abra um prompt de comando e crie um novo diretório para seu aplicativo. Torne ele o diretório atual. Digite o comando dotnet new console no prompt de comando. Isso cria os arquivos iniciais de um aplicativo "Olá, Mundo" básico.

Antes de começar as modificações, vamos executar um simples aplicativo Olá, Mundo. Depois de criar o aplicativo, digite dotnet run no prompt de comando. Esse comando executa o processo de restauração do pacote NuGet, cria o executável do aplicativo e o executa.

O código do aplicativo simples Olá, Mundo está inteiro em Program.cs. Abra esse arquivo com o seu editor de texto favorito. Substitua o código em Program.cs pelo código a seguir:

namespace TeleprompterConsole;

internal class Program
{
    static void Main(string[] args)
    {
        Console.WriteLine("Hello World!");
    }
}

Na parte superior do arquivo, observe uma instrução namespace. Assim como em outras linguagens orientadas a objeto que você pode ter usado, o C# usa namespaces para organizar tipos. Este programa Olá, Mundo não é diferente. Você pode ver que o programa está no namespace com o nome TeleprompterConsole.

Como ler e exibir o arquivo

O primeiro recurso a ser adicionado é a capacidade de ler um arquivo de texto e a exibição de todo esse texto para um console. Primeiro, vamos adicionar um arquivo de texto. Copie o arquivo sampleQuotes.txt do repositório do GitHub para este exemplo no diretório de seu projeto. Isso servirá como o script de seu aplicativo. Para obter informações sobre como baixar o aplicativo de exemplo para este tutorial, consulte as instruções em Exemplos e Tutoriais.

Em seguida, adicione o seguinte método em sua classe Program (logo abaixo do método Main):

static IEnumerable<string> ReadFrom(string file)
{
    string? line;
    using (var reader = File.OpenText(file))
    {
        while ((line = reader.ReadLine()) != null)
        {
            yield return line;
        }
    }
}

Esse método é um tipo especial de método C# chamado de Método iterador. Os métodos de iterador retornam sequências que são avaliadas lentamente. Isso significa que cada item na sequência é gerado conforme a solicitação do código que está consumindo a sequência. Os métodos de iterador contêm uma ou mais instruções yield return. O objeto retornado pelo método ReadFrom contém o código para gerar cada item na sequência. Neste exemplo, isso envolve a leitura da próxima linha de texto do arquivo de origem e o retorno dessa cadeia de caracteres. Toda vez que o código de chamada solicita o próximo item da sequência, o código lê a próxima linha de texto do arquivo e a retorna. Após a leitura completa do arquivo, a sequência indicará que não há mais itens.

Há dois elementos da sintaxe em C# que podem ser novidade para você. A instrução using nesse método gerencia a limpeza de recursos. A variável inicializada na instrução using (reader, neste exemplo) deve implementar a interface IDisposable. Essa interface define um único método, Dispose, que deve ser chamado quando o recurso for liberado. O compilador gera essa chamada quando a execução atingir a chave de fechamento da instrução using. O código gerado pelo compilador garante que o recurso seja liberado, mesmo se uma exceção for lançada do código no bloco definido pela instrução using.

A variável reader é definida usando a palavra-chave var. var define uma variável local de tipo implícito. Isso significa que o tipo da variável é determinado pelo tipo de tempo de compilação do objeto atribuído à variável. Aqui, esse é o valor retornado do método OpenText(String), que é um objeto StreamReader.

Agora, vamos preencher o código para ler o arquivo no método Main:

var lines = ReadFrom("sampleQuotes.txt");
foreach (var line in lines)
{
    Console.WriteLine(line);
}

Execute o programa (usando dotnet run, e você poderá ver todas as linhas impressa no console).

Adicionar atrasos e formatar a saída

O que você possui está sendo exibido muito rápido para permitir a leitura em voz alta. Agora você precisa adicionar os atrasos na saída. Ao começar, você criará parte do código principal que permite o processamento assíncrono. No entanto, essas primeiras etapas seguirão alguns antipadrões. Os antipadrões são indicados nos comentários durante a adição do código, e o código será atualizado em etapas posteriores.

Há duas etapas nesta seção. Primeiro, você atualizará o método iterador a fim de retornar palavras individuais em vez de linhas inteiras. Isso é feito com estas modificações. Substitua a instrução yield return line; pelo seguinte código:

var words = line.Split(' ');
foreach (var word in words)
{
    yield return word + " ";
}
yield return Environment.NewLine;

Em seguida, será necessário modificar a forma como você consume as linhas do arquivo, e adicionar um atraso depois de escrever cada palavra. Substitua a instrução Console.WriteLine(line) no método Main pelo seguinte bloco:

Console.Write(line);
if (!string.IsNullOrWhiteSpace(line))
{
    var pause = Task.Delay(200);
    // Synchronously waiting on a task is an
    // anti-pattern. This will get fixed in later
    // steps.
    pause.Wait();
}

Execute o exemplo e verifique a saída. Agora, cada palavra única é impressa, seguida por um atraso de 200 ms. No entanto, a saída exibida mostra alguns problemas, pois o arquivo de texto de origem contém várias linhas com mais de 80 caracteres sem uma quebra de linha. Isso pode ser difícil de ler durante a rolagem da tela. Mas também é fácil de corrigir. Apenas mantenha o controle do comprimento de cada linha e gere uma nova linha sempre que o comprimento atingir um certo limite. Declare uma variável local após a declaração de words no método ReadFrom que contém o comprimento da linha:

var lineLength = 0;

Em seguida, adicione o seguinte código após a instrução yield return word + " "; (antes da chave de fechamento):

lineLength += word.Length + 1;
if (lineLength > 70)
{
    yield return Environment.NewLine;
    lineLength = 0;
}

Execute o exemplo e você poderá ler em voz alta de acordo com o ritmo pré-configurado.

Tarefas assíncronas

Nesta etapa final, você adicionará o código para gravar a saída de forma assíncrona em uma tarefa, enquanto executa também outra tarefa para ler a entrada do usuário, caso ele queira acelerar ou diminuir o ritmo da exibição do texto ou interromper a exibição do texto por completo. Essa etapa tem alguns passos e, no final, você terá todas as atualizações necessárias. A primeira etapa é criar um método de retorno Task assíncrono que representa o código que você criou até agora para ler e exibir o arquivo.

Adicione este método à sua classe Program (ele é obtido do corpo de seu método Main):

private static async Task ShowTeleprompter()
{
    var words = ReadFrom("sampleQuotes.txt");
    foreach (var word in words)
    {
        Console.Write(word);
        if (!string.IsNullOrWhiteSpace(word))
        {
            await Task.Delay(200);
        }
    }
}

Você observará duas alterações. Primeiro, no corpo do método, em vez de chamar Wait() para aguardar de forma síncrona a conclusão de uma tarefa, essa versão usa a palavra-chave await. Para fazer isso, você precisa adicionar o modificador async à assinatura do método. Esse método retorna Task. Observe que não há instruções return que retornam um objeto Task. Em vez disso, esse objeto Task é criado pelo código gerado pelo compilador quando você usa o operador await. Você pode imaginar que esse método retorna quando atinge um await. A Task retornada indica que o trabalho não foi concluído. O método será retomado quando a tarefa em espera for concluída. Após a execução completa, a Task retornada indicará a conclusão. O código de chamada pode monitorar essa Task retornada para determinar quando ela foi concluída.

Adicione uma palavra-chave await antes da chamada para ShowTeleprompter:

await ShowTeleprompter();

Isso exige que você altere a assinatura do método Main para:

static async Task Main(string[] args)

Saiba mais sobre o método async Main em nossa seção de conceitos básicos.

Em seguida, é necessário escrever o segundo método assíncrono a ser lido no Console e ficar atento às teclas "<" (menor que), ">" (maior que) e "X" ou "x". Este é o método que você adiciona à tarefa:

private static async Task GetInput()
{
    var delay = 200;
    Action work = () =>
    {
        do {
            var key = Console.ReadKey(true);
            if (key.KeyChar == '>')
            {
                delay -= 10;
            }
            else if (key.KeyChar == '<')
            {
                delay += 10;
            }
            else if (key.KeyChar == 'X' || key.KeyChar == 'x')
            {
                break;
            }
        } while (true);
    };
    await Task.Run(work);
}

Isso cria uma expressão lambda para representar um delegado Action que lê uma chave no Console e modifica uma variável local que representa o atraso quando o usuário pressiona as teclas "<" (menor que) ou ">" (maior que). O método delegado termina quando o usuário pressiona a tecla "X" ou "x", que permitem ao usuário interromper a exibição de texto a qualquer momento. Esse método usa ReadKey() para bloquear e aguardar até que o usuário pressione uma tecla.

Para concluir esse recurso, você precisa criar um novo método de retorno async Task que inicia essas duas tarefas (GetInput e ShowTeleprompter) e também gerencia os dados compartilhados entre essas tarefas.

É hora de criar uma classe que pode manipular os dados compartilhados entre essas duas tarefas. Essa classe contém duas propriedades públicas: o atraso e um sinalizador Done para indicar que o arquivo foi lido completamente:

namespace TeleprompterConsole;

internal class TelePrompterConfig
{
    public int DelayInMilliseconds { get; private set; } = 200;
    public void UpdateDelay(int increment) // negative to speed up
    {
        var newDelay = Min(DelayInMilliseconds + increment, 1000);
        newDelay = Max(newDelay, 20);
        DelayInMilliseconds = newDelay;
    }
    public bool Done { get; private set; }
    public void SetDone()
    {
        Done = true;
    }
}

Coloque essa classe em um novo arquivo e inclua-a no namespace TeleprompterConsole, conforme mostrado anteriormente. Também é necessário adicionar uma instrução using static na parte superior do arquivo para que você possa fazer referência aos métodos Min e Max sem os nomes de classe ou namespace delimitadores. Uma instrução using static importa os métodos de uma classe. Isso contrasta com a instrução using sem static, que importa todas as classes de um namespace.

using static System.Math;

Em seguida, atualize os métodos ShowTeleprompter e GetInput para usar o novo objeto config. Escreva um método final async de retorno de Task para iniciar as duas tarefas e sair quando a primeira tarefa for concluída:

private static async Task RunTeleprompter()
{
    var config = new TelePrompterConfig();
    var displayTask = ShowTeleprompter(config);

    var speedTask = GetInput(config);
    await Task.WhenAny(displayTask, speedTask);
}

O novo método aqui é a chamada WhenAny(Task[]). Isso cria uma Task que termina assim que qualquer uma das tarefas na lista de argumentos for concluída.

Depois, atualize os métodos ShowTeleprompter e GetInput para usar o objeto config para o atraso:

private static async Task ShowTeleprompter(TelePrompterConfig config)
{
    var words = ReadFrom("sampleQuotes.txt");
    foreach (var word in words)
    {
        Console.Write(word);
        if (!string.IsNullOrWhiteSpace(word))
        {
            await Task.Delay(config.DelayInMilliseconds);
        }
    }
    config.SetDone();
}

private static async Task GetInput(TelePrompterConfig config)
{
    Action work = () =>
    {
        do {
            var key = Console.ReadKey(true);
            if (key.KeyChar == '>')
                config.UpdateDelay(-10);
            else if (key.KeyChar == '<')
                config.UpdateDelay(10);
            else if (key.KeyChar == 'X' || key.KeyChar == 'x')
                config.SetDone();
        } while (!config.Done);
    };
    await Task.Run(work);
}

Essa nova versão de ShowTeleprompter chama um novo método na classe TeleprompterConfig. Agora, você precisa atualizar Main para chamar RunTeleprompter em vez de ShowTeleprompter:

await RunTeleprompter();

Conclusão

Este tutorial mostrou a você alguns recursos da linguagem C# e as bibliotecas .NET Core relacionadas ao trabalho em aplicativos de Console. Use esse conhecimento como base para explorar mais sobre a linguagem e sobre as classes apresentadas aqui. Você já viu os fundamentos de E/S do Arquivo e do Console, uso com bloqueio e sem bloqueio da programação assíncrona controlada por tarefa, um tour pela linguagem C# e como os programas em C# são organizados, além da CLI do .NET.

Para obter mais informações sobre E/S de arquivo, consulte E/S de arquivo e de fluxo. Para obter mais informações sobre o modelo de programação assíncrona usado neste tutorial, consulte Programação assíncrona controlada por tarefas e Programação assíncrona.