Aplicación de consola

En este tutorial se enseña una serie de características de .NET y el lenguaje C#. Aprenderá a realizar los siguientes procedimientos:

  • Conceptos básicos de la CLI de .NET
  • La estructura de una aplicación de consola en C#
  • E/S de consola
  • Aspectos básicos de las API de E/S de archivo en .NET
  • Aspectos básicos de la programación asincrónica basada en tareas en .NET

Creará una aplicación que lea un archivo de texto y refleje el contenido de ese archivo de texto en la consola. El ritmo de la salida a la consola se ajusta para que coincida con la lectura en voz alta. Para aumentar o reducir el ritmo, presione las teclas "<" (menor que) o ">" (mayor que). Puede ejecutar esta aplicación en Windows, Linux, macOS o en un contenedor de Docker.

Hay muchas características en este tutorial. Vamos a compilarlas una a una.

Requisitos previos

Creación de la aplicación

El primer paso es crear una nueva aplicación. Abra un símbolo del sistema y cree un nuevo directorio para la aplicación. Conviértalo en el directorio actual. Escriba el comando dotnet new console en el símbolo del sistema. Esta acción crea los archivos de inicio para una aplicación básica "Hola mundo".

Antes de empezar a realizar modificaciones, vamos a ejecutar la aplicación sencilla Hola mundo. Después de crear la aplicación, escriba dotnet run en el símbolo del sistema. Este comando ejecuta el proceso de restauración de paquetes NuGet, crea el archivo ejecutable de la aplicación y lo ejecuta.

Todo el código de la aplicación sencilla Hola mundo está en Program.cs. Abra ese archivo con el editor de texto de su elección. Reemplace el código de Program.cs por el código siguiente:

namespace TeleprompterConsole;

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

En la parte superior del archivo, verá una instrucción namespace. Al igual que otros lenguajes orientados a objetos que pueda haber usado, C# utiliza espacios de nombres para organizar los tipos. Este programa Hola mundo no es diferente. Se puede ver que el programa está incluido en el espacio de nombres con el nombre TeleprompterConsole.

Lectura y reflejo del archivo

La primera característica que se va a agregar es la capacidad de leer un archivo de texto y visualizar todo ese texto en la consola. En primer lugar, vamos a agregar un archivo de texto. Copie el archivo sampleQuotes.txt del repositorio de GitHub de este ejemplo en su directorio del proyecto. Servirá como script para la aplicación. Para obtener información sobre cómo descargar la aplicación de ejemplo de este tutorial, vea las instrucciones en Ejemplos y tutoriales.

Luego, agregue el siguiente método a la clase Program (justo debajo del 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;
        }
    }
}

Este método es un tipo especial de método de C# llamado método de iterador. Los métodos de iterador devuelven secuencias que se evalúan de forma diferida. Eso significa que cada elemento de la secuencia se genera como lo solicita el código que consume la secuencia. Los métodos de iterador son métodos que contienen una o varias instrucciones yield return. El objeto que devuelve el método ReadFrom contiene el código para generar cada elemento en la secuencia. En este ejemplo, que implica la lectura de la siguiente línea de texto del archivo de origen y la devolución de esa cadena, cada vez que el código de llamada solicita el siguiente elemento de la secuencia, el código lee la siguiente línea de texto del archivo y la devuelve. Cuando el archivo se ha leído completamente, la secuencia indica que no hay más elementos.

Hay dos elementos de la sintaxis de C# con los que podría no estar familiarizado. La instrucción using de este método administra la limpieza de recursos. La variable que se inicializa en la instrucción using (reader, en este ejemplo) debe implementar la interfaz IDisposable. Esa interfaz define un único método, Dispose, que se debe llamar cuando sea necesario liberar el recurso. El compilador genera esa llamada cuando la ejecución llega a la llave de cierre de la instrucción using. El código generado por el compilador garantiza que el recurso se libera incluso si se produce una excepción desde el código en el bloqueo definido mediante la instrucción using.

La variable reader se define mediante la palabra clave var. var define una variable local con tipo implícito. Esto significa que el tipo de la variable viene determinado por el tipo en tiempo de compilación del objeto asignado a la variable. Aquí, ese es el valor devuelto por el método OpenText(String), que es un objeto StreamReader.

Ahora, vamos a rellenar el código para leer el archivo en el método Main:

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

Ejecute el programa (mediante dotnet run) y podrá ver cada línea impresa en la consola.

Adición de retrasos y formato de salida

Lo que tiene se va a mostrar lejos, demasiado rápido para leerlo en voz alta. Ahora debe agregar los retrasos en la salida. Cuando empiece, estará creando parte del código principal que permite el procesamiento asincrónico. Sin embargo, a estos primeros pasos le seguirán algunos antipatrones. Los antipatrones se señalan en los comentarios cuando se agrega el código y el código se actualizará en los pasos posteriores.

Hay dos pasos hasta esta sección. Primero, actualizará el método Iterator para devolver palabras sueltas en lugar de líneas enteras. Para ello, son necesarias estas modificaciones. Reemplace la instrucción yield return line; por el código siguiente:

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

A continuación, debe modificar el modo en que se consumen las líneas del archivo y agregar un retraso después de escribir cada palabra. Reemplace la instrucción Console.WriteLine(line) del método Main por el bloqueo siguiente:

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();
}

Ejecute el ejemplo y compruebe la salida. Ahora, se imprime cada palabra suelta, seguido de un retraso de 200 ms. Sin embargo, la salida mostrada indica algunos problemas porque el archivo de texto de origen tiene varias líneas con más de 80 caracteres sin un salto de línea. Este texto puede ser difícil de leer al desplazarse por él. Esto es fácil de corregir. Simplemente realizará el seguimiento de la longitud de cada línea y generará una nueva línea cada vez que la longitud de la línea alcance un determinado umbral. Declare una variable local después de la declaración de words en el método ReadFrom que contiene la longitud de línea:

var lineLength = 0;

A continuación, agregue el código siguiente después de la instrucción yield return word + " "; (antes de la llave de cierre):

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

Ejecute el ejemplo y podrá leer en alto a su ritmo preconfigurado.

Tareas asincrónicas

En este paso final, agregará el código para escribir la salida de manera asincrónica en una tarea, mientras se ejecuta también otra tarea para leer la entrada del usuario si quiere aumentar o reducir la velocidad de la pantalla de texto, o detendrá la presentación del texto por completo. Incluye unos cuantos pasos y, al final, tendrá todas las actualizaciones que necesita. El primer paso es crear un método de devolución Task asincrónico que represente el código que ha creado hasta el momento para leer y visualizar el archivo.

Agregue este método a su clase Program (se toma del cuerpo del 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);
        }
    }
}

Advertirá dos cambios. Primero, en el cuerpo del método, en lugar de llamar a Wait() para esperar a que finalice una tarea de manera sincrónica, esta versión usa la palabra clave await. Para ello, debe agregar el modificador async a la signatura del método. Este método devuelve un objeto Task. Observe que no hay ninguna instrucción Return que devuelva un objeto Task. En su lugar, ese objeto Task se crea mediante el código que genera el compilador cuando usa el operador await. Puede imaginar que este método devuelve cuando alcanza un valor de await. El valor devuelto de Task indica que el trabajo no ha finalizado. El método se reanuda cuando se completa la tarea en espera. Cuando se ha ejecutado hasta su finalización, el valor de Task devuelto indica que se ha completado. El código de llamada puede supervisar ese valor de Task devuelto para determinar cuándo se ha completado.

Agregue una palabra clave await antes de la llamada a ShowTeleprompter:

await ShowTeleprompter();

Esto requiere que cambie la firma del método Main a:

static async Task Main(string[] args)

Obtenga más información sobre el método async Main en nuestra sección de aspectos básicos.

A continuación, debe escribir el segundo método asincrónico para leer desde la Consola y controlar las teclas "<" (menor que), ">" (mayor que), "X" o "x". Este es el método que agrega para esa tarea:

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);
}

Esto crea una expresión lambda que representa un delegado de Action que lee una clave de la Consola y modifica una variable local que representa el retraso que se da cuando el usuario presiona las teclas "<" (menor que) o ">" (mayor que). El método de delegado finaliza cuando el usuario presiona las teclas "X" o "x", que permiten al usuario detener la presentación del texto en cualquier momento. Este método usa ReadKey() para bloquear y esperar a que el usuario presione una tecla.

Para finalizar esta característica, debe crear un nuevo método de devolución async Task que inicie estas dos tareas (GetInput y ShowTeleprompter) y también administre los datos compartidos entre ellas.

Es hora de crear una clase que controle los datos compartidos entre estas dos tareas. Esta clase contiene dos propiedades públicas: el retraso y una marca Done para indicar que el archivo se ha leído 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 esa clase en un archivo nuevo e inclúyala en el espacio de nombres TeleprompterConsole, tal como se ha mostrado anteriormente. También deberá agregar una instrucción using static en la parte superior del archivo para que pueda hacer referencia a los métodos Min y Max sin la clase incluida o los nombres de espacio de nombres. Una instrucción using static importa los métodos de una clase, Esto contrasta con la instrucción using sin static, que importa todas las clases de un espacio de nombres.

using static System.Math;

A continuación, debe actualizar los métodos ShowTeleprompter y GetInput para usar el nuevo objeto config. Escriba un método final async de devolución de Task para iniciar ambas tareas y salir cuando la primera tarea finalice:

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

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

El método nuevo aquí es la llamada a WhenAny(Task[]). Dicha llamada crea un valor de Task que finaliza en cuanto alguna de las tareas de su lista de argumentos se completa.

A continuación, debe actualizar los métodos ShowTeleprompter y GetInput para usar el objeto config para el retraso:

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);
}

Esta nueva versión de ShowTeleprompter llama a un nuevo método de la clase TeleprompterConfig. Ahora, debe actualizar Main para llamar a RunTeleprompter en lugar de a ShowTeleprompter:

await RunTeleprompter();

Conclusión

En este tutorial se han mostrado varias características en torno al lenguaje C# y las bibliotecas .NET Core, relacionadas con el trabajo en aplicaciones de consola. Puede partir de este conocimiento para explorar más sobre el lenguaje y las clases aquí presentadas. Ha visto los conceptos básicos de E/S de archivo y consola, el uso con bloqueo y sin bloqueo de la programación asincrónica basada en tareas, un paseo por el lenguaje C# y cómo se organizan los programas en C#. También ha conocido la interfaz de la línea de comandos y la CLI de .NET.

Para obtener más información sobre la E/S de archivo, vea E/S de archivos y secuencias. Para obtener más información sobre el modelo de programación asincrónica que se ha usado en este tutorial, vea Programación asincrónica basada en tareas y Programación asincrónica.