控制台应用

本教程介绍了 .NET 和 C# 语言中的许多功能。 学习内容:

  • .NET CLI 的基础知识
  • C# 控制台应用程序的结构
  • 控制台输入/输出
  • .NET 中文件 I/O API 的基础知识
  • .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的命名空间中。

读取和回显文件

添加的第一项功能是能够读取文本文件并将所有文本显示到控制台。 首先,添加文本文件。 将此 示例 的 GitHub 存储库中的 sampleQuotes.txt 文件复制到项目目录中。 这将充当应用程序的脚本。 有关如何下载本教程的示例应用的信息,请参阅 示例和教程中的说明。

接下来,在 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;

接下来,你需要修改读取文件行的方式,并在编写每个单词后添加延迟。 将 Main 方法中的 Console.WriteLine(line) 语句替换为以下代码块:

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 个字符,没有换行符。 很难滚动读取这些文本。 这很容易修复。 只需跟踪每行的长度,并在行长度达到特定阈值时生成一个新行。 在 ReadFrom 方法中,在声明 words 之后,声明一个用于存储行长度的局部变量:

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

你会注意到两个更改。 首先,在方法正文中,此版本使用 await 关键字,而不是调用 Wait() 同步等待任务完成。 为此,需要将 async 修饰符添加到方法签名。 此方法返回 Task。 请注意,没有返回 Task 对象的返回语句。 相反,Task 对象是由编译器在使用 await 运算符时生成的代码创建的。 可以想象,此方法在到达 await 时返回。 返回的 Task 指示工作尚未完成。 该方法将在等待的任务完成后恢复。 当它执行到完成时,返回的 Task 指示它已完成。 调用代码可以监控返回的 Task,以判断何时完成。

在调用 ShowTeleprompter之前添加 await 关键字:

await ShowTeleprompter();

这要求将 Main 方法签名更改为:

static async Task Main(string[] args)

在基础知识部分详细了解async Main方法

接下来,需要编写第二个异步方法,从控制台读取键,并监视“<”(小于)、“>”(大于)和“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 委托的 lambda 表达式,用于在用户按“<”(小于)或“>”(大于)键时,从控制台读取键,并修改表示延迟的局部变量。 当用户按下“X”或“x”键时,委托方法将完成,这允许用户随时停止文本显示。 此方法使用 ReadKey() 阻止并等待用户按键。

若要完成此功能,需要创建一个新的 async Task 返回方法,该方法启动这两个任务(GetInputShowTeleprompter),并管理这两个任务之间的共享数据。

是时候创建一个类来处理这两个任务之间的共享数据了。 此类包含两个公共属性,即延迟和指示已读取完整个文件的标志 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 语句,以便无需封闭类或命名空间名称即可引用 MinMax 方法。 using static 语句从一个类导入方法。 这与不带 staticusing 语句形成鲜明对比,该语句从命名空间导入所有类。

using static System.Math;

接下来,需要更新 ShowTeleprompterGetInput 方法来使用新的 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,只要自变量列表中的任意一项任务完成,它就会完成。

接下来,需要更新 ShowTeleprompterGetInput 方法,以便将 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 以调用 RunTeleprompter 而不是 ShowTeleprompter

await RunTeleprompter();

结论

本教程介绍了 C# 语言和与在控制台应用程序中工作相关的 .NET Core 库的一些功能。 你可以利用此知识进一步探索语言,以及此处介绍的课程。 你已经了解了文件和控制台 I/O 的基础知识、任务异步编程模型中的阻塞和非阻塞使用、C# 语言概览及其程序组织方式,以及 .NET CLI。

有关文件 I/O 的详细信息,请参阅 文件和流 I/O。 有关本教程中使用的异步编程模型的详细信息,请参阅 基于任务的异步编程异步编程