Консольное приложение

Это руководство раскроет для вас некоторые возможности .NET и языка C#. Вы узнаете:

  • общие сведения о .NET CLI;
  • структура консольного приложения C#;
  • консольный ввод-вывод;
  • основные сведения об интерфейсах API файлового ввода-вывода в .NET;
  • основные сведениях об асинхронном программировании задач в .NET.

Вам предстоит создать приложение, которое считывает текстовый файл и выводит его содержимое в консоль. Вывод в консоль осуществляется с такой скоростью, которая позволяет читать текст вслух. Вы можете ускорить или замедлить темп, нажав клавиши "<" (меньше) или ">" (больше). Это приложение можно запустить в ОС Windows, Linux, macOS или в контейнере Docker.

В этом руководстве описано множество функций. Попробуем собрать их по одному.

Предварительные требования

Создание приложения

Первым шагом является создание нового приложения. Откройте командную строку и создайте новый каталог для приложения. Перейдите в этот каталог. В командной строке введите команду dotnet new console. Эта команда создает начальный набор файлов для базового приложения Hello World.

Прежде чем вносить изменения, давайте запустим простое приложение Hello World. Когда вы создадите приложение, наберите в командной строке команду dotnet run. Эта команда запускает процесс восстановления пакета NuGet, создает исполняемый файл приложения и запускает этот файл.

Весь код простого приложения Hello World размещается в файле Program.cs. Откройте этот файл в любом текстовом редакторе. Замените код в Program.cs на следующий код:

namespace TeleprompterConsole;

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

В верхней части файла вы видите инструкцию namespace. Как и другие объектно ориентированные языки, с которыми вы могли работать ранее, C# использует пространства имен для организации типов. В нашей программе Hello World все точно так же. Как вы видите, программа находится в пространстве имен TeleprompterConsole.

Чтение и вывод файла

Первая функция, которую мы добавим, будет считывать данные из текстового файла и выводить полученный текст в консоль. Сначала нам нужно добавить текстовый файл. Скопируйте в каталог проекта файл sampleQuotes.txt из репозитория GitHub для этого примера. Он будет источником текста для вашего приложения. Чтобы скачать пример приложения для этого раздела, воспользуйтесь инструкциями в разделе Примеры и руководства.

Теперь добавьте в класс Program (он расположен сразу за методом Main) следующий метод:

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

Этот метод является особым типом метода C#, который называется методом итератора. Методы итератора возвращают последовательности, которые оцениваются отложенно. Это означает, что каждый элемент в последовательности создается только в тот момент, когда к нему выполняется обращение в коде обработки последовательности. Методы итератора — это методы, содержащие один или несколько yield return операторов. Возвращаемый методом ReadFrom объект содержит код для создания каждого элемента последовательности. В нашем примере он читает следующую строку текста из исходного файла и возвращает эту строку. Каждый раз, когда вызывающий код запрашивает следующий элемент из последовательности, код считывает из файла и возвращает следующую строку текста. Когда файл закончится, последовательность сообщает, что в ней больше нет элементов.

Здесь используются два элемента синтаксиса C#, которые могут быть для вас новыми. Инструкция using в этом методе управляет освобождением ресурсов. Переменная, которая инициализируется в инструкции using (в нашем примере это reader) должна реализовывать интерфейс IDisposable. Этот интерфейс определяет единственный метод (Dispose), который вызывается для освобождения ресурса. Компилятор создает такой вызов, когда выполнение кода достигает закрывающей скобки инструкции using. Созданный компилятором код гарантирует освобождение ресурса даже в том случае, если в блоке кода, определенном инструкцией using, будет создано исключение.

Переменная reader определена с ключевым словом var. Ключевое слово var определяет неявно типизированную локальную переменную. Это означает, что тип переменной определяется во время компиляции по типу объекта, присвоенного этой переменной. Здесь это возвращаемое значение метода OpenText(String), то есть объект StreamReader.

Теперь давайте создадим в методе Main код для чтения файла:

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

Запустите программу командой dotnet run и убедитесь в том, что все текстовые строки выводятся в консоль.

Добавление задержек и форматирование выходных данных

Сейчас данные отображаются слишком быстро для чтения. Поэтому нам нужно добавить задержку в процесс вывода. Для этого вы создадите несложный код, выполняющий асинхронную обработку. Но первые наши действия будут нарушать стандартные рекомендации. Эти нарушения мы укажем в комментариях при создании кода, а затем заменим этот код в последующих шагах.

В этом разделе описаны два действия. Во-первых, обновите метод итератора, чтобы он возвращал не всю строку целиком, а каждое слово отдельно. Для этого внесите такие изменения. Замените инструкцию yield return line; следующим кодом:

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

Теперь следует изменить код обработки строк файла, добавив задержку после вывода каждого слова. Замените инструкцию Console.WriteLine(line) в методе Main на такой блок кода:

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

Запустите пример и проверьте выходные данные. Теперь слова появляются по одному и с задержками по 200 мс. Но пока с выводом сохраняются некоторые проблемы, поскольку в исходном текстовом файле есть несколько строк длиной более 80 символов, и они выводятся без перевода строки. Это не очень удобно читать с прокруткой. Но эту проблему легко исправить. Вам нужно лишь отслеживать длину каждой строки и создавать новую строку каждый раз, когда эта длина достигает определенного порога. После объявления words в методе ReadFrom объявите локальную переменную для хранения длины строки:

var lineLength = 0;

Теперь добавьте следующий код после инструкции yield return word + " "; (перед закрывающей фигурной скобкой):

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

Запустите пример, и теперь вы сможете читать текст вслух в заданном темпе.

Асинхронные задачи

И на последнем этапе мы добавим код, который позволяет выполнять две асинхронные задачи, одна из которых — вывод текста, а вторая — ожидание ввода от пользователя для ускорения, замедления или прекращения вывода текста. Этот этап разделяется на несколько шагов, по завершении которых вы получите все необходимые обновления. Первым шагом является создание асинхронной задачи (Task), которая возвращает метод с тем кодом, который вы создали ранее для чтения и отображения файла.

Добавьте следующий метод в класс Program. Этот текст основан на тексте метода 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);
        }
    }
}

Вы можете заметить два изменения. Во-первых, в тексте нет вызова Wait(), который в синхронном режиме ожидает завершения задачи. Вместо него в этой версии используется ключевое слово await. Чтобы это работало, в сигнатуру метода нужно добавить модификатор async. Этот метод возвращает Task. Обратите внимание, что здесь нет инструкции для возвращения объекта Task. Вместо этого объект Task создается в коде, который компилятор предоставляет в точке использования оператора await. Представьте, что метод завершает выполнение при достижении await. Он возвращает Task в знак того, что работа еще не завершена. Метод возобновит свою работу, когда завершится ожидаемая задача. Когда работа метода завершится, это будет отражено в возвращаемом объекте Task. Вызывающий код может отслеживать состояние полученного Task, чтобы определить момент завершения метода.

await Добавьте ключевое слово перед вызовом :ShowTeleprompter

await ShowTeleprompter();

Для этого необходимо изменить сигнатуру Main метода следующим образом:

static async Task Main(string[] args)

Дополнительные сведения о методеasync Main см. в разделе "Основы".

Затем необходимо написать второй асинхронный метод для чтения из консоли и watch для ключей "<" (меньше), ">" (больше) и "X" или "x". Для выполнения этой задачи добавьте приведенный ниже метод.

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

При этом создается лямбда-выражение для представления делегата Action , который считывает ключ из консоли и изменяет локальную переменную, представляющую задержку, когда пользователь нажимает клавиши "<" (меньше) или ">" (больше). Метод делегата завершается, когда пользователь нажимает клавиши "X" или "x", что позволяет пользователю в любое время остановить отображение текста. Этот метод использует метод ReadKey(), чтобы блокировать выполнение и ожидать нажатия клавиши.

Чтобы завершить создание этой функции, нам нужна новая инструкция async Task, которая вернет метод, запускающий обе задачи (GetInput и ShowTeleprompter) и управляющий обменом данными между этими задачами.

Пришло время создать класс, который может обрабатывать совместное использование данных двумя задачами. Этот класс содержит два открытых свойства: delay (задержка) и флаг Done, который означает, что файл прочитан полностью:

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

Поместите этот класс в отдельный новый файл и включите его в пространство имен TeleprompterConsole, как показано выше. Также следует добавить оператор using static в верхнюю часть файлами, чтобы можно было ссылаться на методы Min и Max без указания имени внешнего класса или пространства имен. Инструкция using static импортирует все методы из одного класса. Это отличается от оператора using без static, который импортирует все классы из пространства имен.

using static System.Math;

Теперь вам нужно обновить методы ShowTeleprompter и GetInput для использования нового объекта config. И еще одна инструкция Task, которая возвращает метод async, запускающий обе задачи и завершающий работу после окончания первой задачи:

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

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

Новым методом здесь является WhenAny(Task[]). Этот метод создает задачу (Task), которая завершается сразу, как только завершится любая из задач в списке аргументов.

Теперь вам нужно обновить методы ShowTeleprompter и GetInput, чтобы они использовали объект config для задержки:

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

Новая версия метода ShowTeleprompter вызывает новый метод из класса TeleprompterConfig. Сейчас нужно изменить метод Main, чтобы вместо ShowTeleprompter он вызывал RunTeleprompter:

await RunTeleprompter();

Заключение

В этом учебнике мы продемонстрировали вам ряд функций языка C# и библиотек .NET Core, связанных с работой в консольных приложениях. На основе полученных знаний вы сможете развивать свои представления о языке и представленных здесь классах. Вы увидели базовые примеры использования файлового и консольного ввода-вывода, асинхронного программирования на основе задач с блокировкой и без блокировки. Вы узнали о языке C# и структуре программ на C#, а также о .NET CLI.

Дополнительные сведения о файловом вводе-выводе см. в статье Файловый и потоковый ввод-вывод. Дополнительные сведения о модели асинхронного программирования, используемой в учебнике, см. в статьях Асинхронное программирование на основе задач и Асинхронное программирование.